提示词工程实战:从“能对话“到“能干活“的LLM应用设计
很多人以为调用大模型API就是写一句"你是xxx助手",然后让LLM自由发挥。但在真实产品中,LLM不是聊天玩具,而是业务流程的一环——它必须输出结构化数据、遵守业务规则、在模糊场景中做出正确决策。这篇文章基于"灶台导航"微信小程序的开发实践,从零讲起提示词工程(Prompt Engineering)的核心方法论。
前言
很多人以为调用大模型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时返回)
关键设计点:
- 先给完整JSON示例,再逐字段解释。LLM对"示例+说明"的理解远好于纯文字描述。
- 枚举值用
|分隔明确列出:recommend|ask|generateRecipe|cook,而不是说"填入动作类型"。 - 标注必填/条件填写:
(必填)、仅action=generateRecipe时,让LLM知道何时填何时不填。 - 明确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 = ['以下是系统菜谱库中的相关菜谱,请优先推荐。如果菜谱库中没有用户想要的菜,先告知菜名并询问是否需要生成完整做法,不要自行生成。']
这句话做了三件事:
- “请优先推荐” — 引导LLM优先使用RAG数据
- “如果菜谱库中没有” — 告诉LLM数据是有限的,不是什么菜都有
- “不要自行生成” — 明确禁止在非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消息?
- System Prompt对LLM的约束力更强,LLM更倾向于遵守System级的指令
- RAG数据是"知识",不是"对话",放在System层级语义更合理
- 多轮对话中,每轮的User消息不同,但System Prompt(含RAG)是相对稳定的
5.2 上下文的格式设计
RAG上下文不是简单地把菜谱JSON丢进去,而是经过精心格式化:
以下是系统菜谱库中的相关菜谱,请优先推荐。如果菜谱库中没有用户想要的菜,先告知菜名并询问是否需要生成完整做法,不要自行生成。
1. [ID:abc123] 红烧肉(中等,60分钟):经典家常菜
食材:五花肉500g、酱油2勺、冰糖30g
2. [ID:def456] 可乐鸡翅(简单,30分钟):甜咸口味
食材:鸡翅8个、可乐1罐
格式设计的考量:
- 首句是行为指令:不只是给数据,还告诉LLM怎么用这些数据
- 编号列表:LLM对编号列表的理解比纯文本更准确
- [ID:xxx]标签:唯一标识,让LLM在输出中引用
- 括号内的结构化信息:难度+时间,LLM可以据此做筛选
- 食材单独一行:LLM可以据此判断食材匹配度
5.3 私人菜谱的上下文隔离
用户私人菜谱在上下文中有独立段落和标签:
以下是用户的私人菜谱库中的相关菜谱:
1. [私人菜谱] [ID:xyz789] 妈妈的红烧肉(简单,45分钟):妈妈的味道
食材:五花肉、老抽、白糖
[私人菜谱]标签的作用:
- 让LLM区分来源:推荐时可以说"您自己创建的’妈妈的红烧肉’也很适合"
- 避免混淆:两道同名菜谱(系统"红烧肉"和私人"红烧肉")不会搞混
- 个性化表达: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幻觉的问题。
十、总结
提示词工程不是"写一句描述",而是一个系统化的工程过程:
- 格式控制是地基——没有结构化输出,LLM的回复就是一坨文本,代码无法处理
- 行为控制是骨架——action类型把自由对话变成有限状态机,前端才能程序化响应
- 业务约束是护栏——防止LLM编造数据、忽略安全边界、混淆业务逻辑
- RAG上下文是知识——让LLM基于真实数据回答,而非凭空想象
- 迭代优化是常态——提示词不可能一次写对,必须根据真实问题持续改进
- 代码兜底是底线——永远不要100%信任LLM的输出,解析层面必须有防御
最后记住:提示词是给人读的,也是给LLM读的,但最终是给业务服务的。 一条好的提示词不是写得优美、逻辑严密就够,而是能让LLM在实际使用中稳定产出业务需要的结果。
作者:「倒灶了队」
项目:灶台导航 - 微信小程序
更新时间:2026-05-22
更多推荐


所有评论(0)