前言

很多人以为调用大模型API就是写一句"你是xxx助手",然后让LLM自由发挥。但在真实产品中,LLM不是聊天玩具,而是业务流程的一环——它必须输出结构化数据、遵守业务规则、在模糊场景中做出正确决策。

这篇文章基于"灶台导航"微信小程序的开发实践,从零讲起提示词工程(Prompt Engineering)的核心方法论。

一、提示词工程的本质:给LLM写需求文档

1.1 为什么需要提示词工程?

LLM的原始能力是"续写文本",它不知道你的业务规则、数据格式、边界条件。提示词就是一份给LLM看的需求文档——写得越精确,LLM的行为越可控。

一个反面案例:

你是一个烹饪助手,帮用户推荐菜谱。

这行提示词下,LLM可能:

  • 用markdown格式回复,前端无法解析
  • 编造不存在的菜谱和做法
  • 用户说"做红烧肉"时直接返回一整篇菜谱,而不是先推荐
  • 不知道何时追问、何时推荐、何时确认

一个正面案例(项目实际使用的版本):

你是"灶台导航"的AI烹饪助手。你的职责是:
1. 根据用户描述的用餐场景、可用食材、口味偏好,推荐合适的菜谱
2. 回答烹饪相关的问题(做法、技巧、食材替代等)
3. 如果用户提到家庭成员,考虑他们的口味和忌口

你必须严格以JSON格式回复:
{"reply":"...","action":"recommend|ask|generateRecipe|cook","recommendations":[...],"cookData":{...}}

字段说明:
- action: recommend=推荐菜谱, ask=追问信息, generateRecipe=生成完整菜谱, cook=开始烹饪
- ...

两种推荐类型的处理规则:
1.【系统菜谱】如果推荐的菜在系统菜谱库中,必须使用其ID作为recipeId,fullRecipe设为null。不要自己编造菜谱数据。
2.【非收录菜谱】如果用户想要的菜不在菜谱库中:recipeId设为null,告知用户该菜谱非系统收录...

重要规则:
- 用户说"做XX菜"=recommend+recommendations,不要用cook
- 只有用户明确确认"就做这个""开始做菜"时才用cook

差异在哪?正面案例定义了LLM的输出格式、行为边界、决策逻辑。这不是在"聊天",而是在"编程"。

1.2 提示词工程的三个层次

层次 目标 示例
格式控制 让LLM输出可解析的结构化数据 JSON Schema、枚举值
行为控制 让LLM在特定场景下做特定的事 4种action类型、何时追问
业务约束 让LLM遵守业务规则和安全边界 不编造菜谱、不生成危险做法

下面逐层展开。

二、格式控制:让LLM输出机器可读的数据

2.1 为什么必须结构化?

在产品中,LLM的输出不是给人看的,是给代码处理的。前端需要知道:

  • AI推荐了哪些菜谱?(渲染卡片)
  • AI是推荐菜谱还是在追问?(决定显示逻辑)
  • 菜谱ID是什么?(关联数据库)

如果LLM返回自然语言:“我推荐红烧肉和可乐鸡翅,红烧肉大概要1小时…”,前端需要用正则或NLP去提取信息,极不可靠。

2.2 强制JSON输出

// API参数层面
response_format: { type: 'json_object' }

这是最基础的格式控制——让模型知道输出必须是合法JSON。但这还不够,因为LLM可能返回:

{"message": "我推荐红烧肉"}  // 字段名不对

或者:

{"reply": "我推荐红烧肉和可乐鸡翅"}  // 缺少action和recommendations

2.3 在提示词中定义JSON Schema

必须在提示词中精确描述输出的JSON结构:

你必须严格以JSON格式回复,不要包含任何其他文字或markdown标记:
{"reply":"你的回复文字","action":"recommend|ask|generateRecipe|cook","recommendations":[{"name":"菜名","reason":"推荐理由","recipeId":"ID或null","fullRecipe":null或{...}}],"cookData":{"recipeIds":["xxx"]}}

字段说明:
- reply: 给用户的文字回复(必填)
- action: 动作类型:
  - recommend=推荐菜谱(提到菜名或食材场景时使用,必须返回recommendations)
  - ask=追问信息(信息不够时使用,不返回recommendations)
  - generateRecipe=生成完整菜谱(仅当用户确认需要AI生成做法时使用)
  - cook=开始烹饪(仅当用户明确说"开始做""就做这个"时使用)
- recommendations: 菜谱列表(action为recommend/generateRecipe/cook时返回),每项包含:
  - name: 菜名(必填)
  - reason: 推荐理由(必填)
  - recipeId: 如果菜在系统菜谱库中,填入对应的ID;不在则为null
  - fullRecipe: 仅action=generateRecipe时填入完整菜谱数据,其他情况为null
- cookData: 烹饪数据(仅action为cook时返回)

关键设计点:

  1. 先给完整JSON示例,再逐字段解释。LLM对"示例+说明"的理解远好于纯文字描述。
  2. 枚举值用|分隔明确列出recommend|ask|generateRecipe|cook,而不是说"填入动作类型"。
  3. 标注必填/条件填写(必填)仅action=generateRecipe时,让LLM知道何时填何时不填。
  4. 明确null的语义recipeId:"ID或null"fullRecipe:null或{...},避免LLM猜测空值应该填什么。

2.4 解析容错:LLM不总是听话

即使写了response_format: { type: 'json_object' },LLM偶尔还是会返回非法格式。必须做防御性解析:

function parseAIReply(content) {
  // 第一层:直接JSON解析
  try {
    return JSON.parse(content)
  } catch (e) {
    // 第二层:正则提取JSON部分(LLM可能在JSON前后加文字)
    const jsonMatch = content.match(/\{[\s\S]*\}/)
    if (jsonMatch) {
      try {
        return JSON.parse(jsonMatch[0])
      } catch (e2) { /* 继续 */ }
    }
    // 第三层:把原始文本当reply返回,action默认ask
    return {
      reply: content,
      action: 'ask',
      recommendations: [],
      cookData: null
    }
  }
}

原则:永远不要信任LLM的输出格式,必须有兜底解析。

三、行为控制:让LLM在正确场景做正确的事

3.1 动作类型设计:从自由对话到状态机

最朴素的做法是让LLM自由回复,前端用关键词判断意图。但关键词匹配极不可靠——用户说"做个红烧肉吧",到底是要推荐还是要开始做?

更好的做法是让LLM自己输出意图标签,前端根据标签决定行为。这就是4种action类型的由来:

action: recommend | ask | generateRecipe | cook
action 含义 触发条件 前端行为
recommend 推荐菜谱 用户提到菜名、食材、场景 显示菜谱卡片
ask 追问信息 信息不够时 只显示文字回复
generateRecipe 生成完整做法 用户明确要求 显示含fullRecipe的卡片
cook 确认烹饪 用户确认"就做这个" 跳转烹饪页

3.2 消除歧义:用具体示例代替抽象描述

最初版本的提示词写的是:

action: cook=用户确认要开始做菜时使用

但LLM经常把"做红烧肉"理解为cook,而实际应该是recommend(用户只是在说菜名,还没确认)。

优化后,用具体示例消除歧义:

重要规则:
- 用户点名要做某道菜时,必须用action="recommend"并返回该菜谱
- 用户说"做XX菜"=recommend+recommendations,不要用cook
- 只有用户明确确认"就做这个""开始做菜"时才用cook
- 多道菜场景返回多个recommendations

方法论:不要指望LLM理解抽象定义,给它具体的输入-输出对。

这在提示词工程中叫做Few-shot引导——虽然这里不是给完整的示例对话,但"用户说X → action=Y"本质上就是在教LLM分类规则。

3.3 主动追问:让LLM知道什么时候"不知道"

烹饪推荐需要上下文——几个人吃?有没有忌口?什么口味?如果LLM在信息不足时直接推荐,结果往往不靠谱。

提示词中的追问规则:

回答要求:
- 如果信息不够,主动追问(如人数、口味偏好、是否有忌口)

配合action=ask:

- ask=追问信息(信息不够时使用,不返回recommendations)

这样LLM在用户只说了"做点好吃的"时,会回复"请问几个人吃?有没有忌口?"(action=ask),而不是直接推荐5道随机菜。

3.4 用户上下文注入:让LLM"看到"结构化数据

家庭成员的偏好和忌口是结构化数据,不能指望LLM从对话中记住。必须在每次请求时注入:

// 代码层面构建上下文
let userContext = ''
if (userProfile) {
  const parts = []
  if (userProfile.roleName) parts.push(`当前用餐人:${userProfile.roleName}`)
  if (userProfile.preferences.length) parts.push(`口味偏好:${userProfile.preferences.join('、')}`)
  if (userProfile.allergies.length) parts.push(`忌口:${userProfile.allergies.join('、')}`)
  if (parts.length > 0) userContext = `\n\n[当前用户上下文] ${parts.join(';')}`
}

// 拼接到用户消息后面
const fullMessage = userMessage + userContext
// 例如: "做点下饭的\n\n[当前用户上下文] 当前用餐人:宝宝;口味偏好:甜、清淡;忌口:花生、辣"

为什么不把用户上下文放在System Prompt里? 因为System Prompt会被多轮对话共享,而用户可能中途切换家庭成员(从"给宝宝做"切到"给全家做")。放在用户消息中,每轮都是最新的。

四、业务约束:让LLM遵守规则和边界

4.1 禁止编造:RAG上下文的引导策略

LLM最大的问题是幻觉——推荐不存在的菜谱、编造做法。RAG检索结果注入提示词后,需要明确告诉LLM如何使用这些数据:

// tfidf.js - formatContext()
const lines = ['以下是系统菜谱库中的相关菜谱,请优先推荐。如果菜谱库中没有用户想要的菜,先告知菜名并询问是否需要生成完整做法,不要自行生成。']

这句话做了三件事:

  1. “请优先推荐” — 引导LLM优先使用RAG数据
  2. “如果菜谱库中没有” — 告诉LLM数据是有限的,不是什么菜都有
  3. “不要自行生成” — 明确禁止在非generateRecipe场景下编造菜谱

4.2 ID标签:让LLM引用真实数据

RAG上下文中每个菜谱都带有[ID:xxx]标签:

1. [ID:abc123] 红烧肉(中等,60分钟):经典家常菜
   食材:五花肉500g、酱油2勺、冰糖30g
2. [ID:def456] 可乐鸡翅(简单,30分钟):甜咸口味
   食材:鸡翅8个、可乐1罐

提示词中要求:

- recipeId: 如果菜在系统菜谱库中,填入对应的ID

这样LLM在推荐红烧肉时会返回"recipeId": "abc123",前端就能直接关联数据库记录。没有ID标签,LLM只会返回菜名,后端还得做模糊匹配。

方法论:在RAG上下文中给数据打上唯一标识,让LLM在输出时引用,而非让代码事后匹配。

4.3 安全约束:LLM输出也需要审核

烹饪场景有安全风险——生食、有毒食材、未煮熟肉类。在提示词中加入硬性约束:

AI生成菜谱规则:
- steps中每个步骤必须有description,duration为正整数(秒),tips可选
- 食材用量必须具体(如"500g"、"2个"),不能用"适量"
- 禁止生成涉及生食、未煮熟肉类、有毒食材的菜谱
- 如果对某道菜做法不确定,不要生成fullRecipe

最后一条尤其重要——让LLM承认不确定,比让它编造更安全。

4.4 条件分支:不同场景的输出差异

LLM在不同场景下需要输出不同粒度的数据。通过条件约束实现:

两种推荐类型的处理规则:
1.【系统菜谱】如果推荐的菜在系统菜谱库中,必须使用其ID作为recipeId,fullRecipe设为null。不要自己编造菜谱数据。
2.【非收录菜谱】如果用户想要的菜不在菜谱库中:
   - recipeId设为null,fullRecipe设为null
   - 在reply中告知用户该菜谱非系统收录,用户可通过菜谱卡片生成完整做法
   - 当用户明确要求生成菜谱时:action设为generateRecipe,填写fullRecipe完整数据

这个条件树实现了:

  • 系统菜谱 → 只返回ID,不生成做法(避免编造)
  • 非收录菜谱 → 先告知,等用户确认后再生成(避免浪费token和产生幻觉)
  • 用户确认生成 → 才输出完整的fullRecipe(带严格格式约束)

五、RAG上下文的提示词设计

5.1 上下文注入的位置

RAG检索结果拼接到System Prompt末尾,而非用户消息中:

let systemContent = SYSTEM_PROMPT
if (ragContext) {
  systemContent += '\n\n' + ragContext
}

为什么放在System Prompt而不是User消息?

  1. System Prompt对LLM的约束力更强,LLM更倾向于遵守System级的指令
  2. RAG数据是"知识",不是"对话",放在System层级语义更合理
  3. 多轮对话中,每轮的User消息不同,但System Prompt(含RAG)是相对稳定的

5.2 上下文的格式设计

RAG上下文不是简单地把菜谱JSON丢进去,而是经过精心格式化:

以下是系统菜谱库中的相关菜谱,请优先推荐。如果菜谱库中没有用户想要的菜,先告知菜名并询问是否需要生成完整做法,不要自行生成。
1. [ID:abc123] 红烧肉(中等,60分钟):经典家常菜
   食材:五花肉500g、酱油2勺、冰糖30g
2. [ID:def456] 可乐鸡翅(简单,30分钟):甜咸口味
   食材:鸡翅8个、可乐1罐

格式设计的考量:

  1. 首句是行为指令:不只是给数据,还告诉LLM怎么用这些数据
  2. 编号列表:LLM对编号列表的理解比纯文本更准确
  3. [ID:xxx]标签:唯一标识,让LLM在输出中引用
  4. 括号内的结构化信息:难度+时间,LLM可以据此做筛选
  5. 食材单独一行:LLM可以据此判断食材匹配度

5.3 私人菜谱的上下文隔离

用户私人菜谱在上下文中有独立段落和标签:

以下是用户的私人菜谱库中的相关菜谱:
1. [私人菜谱] [ID:xyz789] 妈妈的红烧肉(简单,45分钟):妈妈的味道
   食材:五花肉、老抽、白糖

[私人菜谱]标签的作用:

  1. 让LLM区分来源:推荐时可以说"您自己创建的’妈妈的红烧肉’也很适合"
  2. 避免混淆:两道同名菜谱(系统"红烧肉"和私人"红烧肉")不会搞混
  3. 个性化表达:LLM知道这是用户自己的菜谱,语气可以更亲切

六、提示词的迭代优化过程

6.1 第一版:能对话就行

你是一个烹饪助手,帮用户推荐菜谱。用中文回复。

问题:

  • 输出格式不可控(markdown、纯文本、偶尔带JSON)
  • 用户说"做红烧肉"时LLM直接返回一篇长文做法
  • 没有action概念,前端无法区分推荐和追问
  • 经常编造不存在的菜谱

6.2 第二版:加JSON格式

你是烹饪助手。你必须以JSON格式回复:
{"reply":"回复","recommendations":[{"name":"菜名","reason":"理由"}]}

问题:

  • LLM不知道什么时候该返回recommendations,什么时候不该
  • 用户说"你好"时也返回空recommendations数组
  • recipeId的概念没有引入,前端无法关联数据库
  • generateRecipe和cook没有区分

6.3 第三版:引入action类型

action类型:
- recommend:推荐菜谱
- ask:追问
- cook:开始做菜

规则:
- 信息不够时用ask
- 用户确认时用cook

问题:

  • LLM频繁混淆recommend和cook——用户说"做红烧肉"被理解为cook
  • 没有generateRecipe,AI生成菜谱的做法数据没有格式约束
  • 私人菜谱没有标记,LLM不知道哪些是用户的

6.4 第四版(当前版):消除歧义+业务约束

重要规则:
- 用户点名要做某道菜时,必须用action="recommend"并返回该菜谱
- 用户说"做XX菜"=recommend+recommendations,不要用cook
- 只有用户明确确认"就做这个""开始做菜"时才用cook

加上RAG上下文引导、ID标签、fullRecipe格式约束、安全规则,形成了当前版本。

6.5 迭代总结

版本 核心改进 解决的问题
v1 自由对话 -
v2 JSON格式 输出不可解析
v3 action类型 前端无法区分意图
v4 具体示例+业务约束 recommend/cook混淆、幻觉

每次迭代都是因为遇到了真实的问题,而不是预设的优化。 这是提示词工程最重要的原则——先让LLM跑起来,遇到问题再针对性修改提示词。

七、提示词工程的6条实战方法论

方法论1:示例优于定义

❌ "action=cook表示用户确认开始烹饪"
✅ "用户说'做XX菜'=recommend,用户说'就做这个''开始做菜'=cook"

LLM对具体示例的理解远好于抽象定义。给2-3个典型输入-输出对,比写一段精确但抽象的定义更有效。

方法论2:约束优于期望

❌ "尽量使用系统菜谱库中的菜谱"
✅ "如果菜在系统菜谱库中,必须使用其ID作为recipeId,fullRecipe设为null。不要自己编造菜谱数据。"

"尽量"对LLM来说不是约束,"必须"和"不要"才是。用确定性的语言代替模糊的期望。

方法论3:条件优于通用

❌ "返回菜谱数据"
✅ "action=recommend时返回recommendations,action=ask时不返回recommendations,
   action=generateRecipe时返回recommendations且含fullRecipe"

不同场景下的输出差异,必须逐条件写清楚。LLM不会自己推理"这个场景应该返回什么"。

方法论4:标识优于匹配

❌ LLM返回菜名"红烧肉",后端用模糊匹配找数据库记录
✅ RAG上下文中标注[ID:abc123],LLM返回"recipeId":"abc123",前端直接使用

让LLM在输出中引用唯一标识,比事后用菜名匹配更可靠。

方法论5:兜底优于报错

❌ JSON.parse失败时抛出异常,前端白屏
✅ 三层解析(直接解析→正则提取→原始文本兜底),保证总能返回有效结果

LLM的输出永远不可100%信任,代码层面必须有防御性处理。

方法论6:迭代优于预设

❌ 一开始就写一个"完美"的提示词
✅ 先跑起来 → 遇到问题 → 针对性修改提示词 → 验证 → 再迭代

提示词工程是经验驱动的,不可能一次写对。真实用户的使用方式永远比预想的更复杂。

八、进阶:Prompt与代码的协作设计

8.1 代码做的,不要让LLM做

LLM不擅长精确计算、确定性逻辑、状态管理。这些应该交给代码:

工作 交给谁 原因
判断菜谱是否在数据库中 代码(RAG检索+resolveRecipeIds) 需要精确匹配,LLM会编造ID
计算烹饪时间线 代码(scheduler.js逆向调度) 数学计算,LLM不靠谱
决定何时追问 LLM(action=ask) 需要理解语义
推荐理由 LLM(reason字段) 需要自然语言生成
安全约束 LLM(提示词约束)+ 代码(后端校验) 双重保障

8.2 提示词与API参数的配合

提示词说:你必须严格以JSON格式回复
API参数:response_format: { type: 'json_object' }

两者配合使用。提示词告诉LLM"应该做什么",API参数从技术层面强制约束。单一手段都不够——只用提示词约束,LLM偶尔不听;只用API参数,LLM不知道JSON的具体Schema。

8.3 max_tokens的策略性使用

max_tokens: generateMode ? 2048 : 512

推荐场景只需512 token(短回复+菜谱卡片),生成菜谱场景需要2048 token(完整的食材步骤数据)。这不是随意选的——token限制直接控制LLM的输出长度和成本。

过低的max_tokens会导致输出被截断,过高的max_tokens会让LLM"水字数"。根据业务场景动态调整是最优解。

九、提示词的可维护性

9.1 模块化提示词

当前提示词虽然是单个字符串,但内部结构是分块的:

1. 角色定义(你是谁)
2. 回答要求(怎么说话)
3. JSON格式定义(输出什么)
4. 字段说明(每个字段填什么)
5. 推荐类型规则(条件分支)
6. fullRecipe格式(数据Schema)
7. 安全约束(禁止事项)
8. 重要规则(消歧义)

每个模块解决一个问题。后续新增功能时(比如加入私人菜谱标记[私人菜谱]),只需在对应模块追加规则,不用重写整段提示词。

9.2 提示词版本管理

提示词和代码一样需要版本管理。每次修改提示词时应该记录:

  • 改了什么
  • 为什么改(遇到了什么问题)
  • 改后的效果

提示词的演进可以从代码注释和formatContext的引导语变化中看出——从"以下是相关菜谱"到"请优先推荐。如果菜谱库中没有…不要自行生成",每次变化都是因为遇到了LLM幻觉的问题。

十、总结

提示词工程不是"写一句描述",而是一个系统化的工程过程:

  1. 格式控制是地基——没有结构化输出,LLM的回复就是一坨文本,代码无法处理
  2. 行为控制是骨架——action类型把自由对话变成有限状态机,前端才能程序化响应
  3. 业务约束是护栏——防止LLM编造数据、忽略安全边界、混淆业务逻辑
  4. RAG上下文是知识——让LLM基于真实数据回答,而非凭空想象
  5. 迭代优化是常态——提示词不可能一次写对,必须根据真实问题持续改进
  6. 代码兜底是底线——永远不要100%信任LLM的输出,解析层面必须有防御

最后记住:提示词是给人读的,也是给LLM读的,但最终是给业务服务的。 一条好的提示词不是写得优美、逻辑严密就够,而是能让LLM在实际使用中稳定产出业务需要的结果。


作者:「倒灶了队」

项目:灶台导航 - 微信小程序

更新时间:2026-05-22

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐