上周有个学员找我,说他的英语批改助手"抽风":同一段代码,上午批改返回三行规整的"错在哪—为什么—怎么改",下午就变成一篇热情洋溢的小作文,晚上干脆给他打了个分还附赠评语。
代码一行没动。他怀疑是模型坏了,一度想写个定时重启脚本来"修"它。
我看了他的代码就明白了:system prompt 是一句"你是一个英语老师,帮助学生批改作文"。这一句话,等于什么都没说。模型每次自由发挥,风格当然像抽签。
这一篇我们就把这件事彻底讲透:提示词到底是怎么决定回答的,以及怎么把它变成一个可测试、可版本管理的工程对象,而不是散落在代码里的一句祈祷词。
一、先扫盲:为什么 system prompt 有这么大权力
1. 三种角色,各管各的
上一篇讲过,一次请求里的消息分三种角色:
- system:定身份和规矩。用户看不见它,但它对模型输出的影响最大。
- user:用户真正说的话。
- assistant:模型之前的回答,用于多轮对话。
一个容易被忽略的事实:模型对 system 的服从度远高于 user。你在 user 消息里说"请用表格回答",模型可能理会也可能不理;同样的要求写进 system,基本每次都执行。所以身份、输出格式、语气这些"每次都该生效"的规矩,全部放 system。
2. 输出不稳定,是因为你只给了目标没给规格
你说"批改这篇作文",模型不知道你要:一句话点评?逐句批注?打分加清单?它只能猜。猜对猜错,全看当天的心情(准确说是采样随机性)。
解决思路有两种,一种贵一种便宜:
- 贵的思路:让模型输出 JSON,程序解析后强制重渲染。这是下一篇的主题,适合格式必须严格的场景。
- 便宜的思路:给两三个示例,让模型照着写。这就是少样本(few-shot),今天讲。
3. temperature:同一个提示词,为什么两次回答不一样
模型生成每个字都是一个概率分布,temperature 控制从分布里怎么挑:
| 取值 | 行为 | 适合场景 |
|---|---|---|
| 0 ~ 0.3 | 几乎每次挑概率最高的字 | 语法批改、打分、提取信息 |
| 0.5 ~ 0.8 | 有一些变化 | 日常对话、陪练 |
| 1.0+ | 相当发散 | 头脑风暴、起名 |
批改助手要的是"同一篇文章每次给出一致的批改",把 temperature 压到 0.2 左右。很多"模型抽风"问题,一半是 prompt 没写清,另一半就是 temperature 没调。
4. 思维链:让模型先打草稿再交卷
模型是逐字生成的。让它直接给结论,相当于默算;让它先写中间步骤,等于把草稿摊在桌面上,每一步都能踩住前一步的正确结果。
对语法批改这种"先找成分、再套规则"的活,思维链提升明显。但它会让输出变长、token 变多,只留给复杂任务,别逢题就挂。
二、工程设计:把提示词从"字符串"升级成"对象"
第 1 篇的代码里,system prompt 是写死在函数里的字符串。这一篇要改掉这个坏习惯。
新工程结构(settings.py和llm.py与第 1 篇完全一致,原样复用):
02-Prompt工程/ ├── src/ │ ├── settings.py # 复用第 1 篇 │ ├── llm.py # 复用第 1 篇 │ ├── prompts.py # 人格字典 + 任务模板 │ ├── fewshot.py # 少样本示例 │ ├── cot.py # 思维链三档 │ └── ask.py # 统一入口 ├── tests/ # 27 个用例,离线全绿 └── demo.py核心思想一句话:人格管"怎么说",任务管"做什么",示例管"输出长什么样",思维链管"解题步骤"——四样东西拆开,才能自由组合。
三、人格与任务:两段 system,分开管(prompts.py)
先看数据:
PERSONAS:dict[str,str]={"strict":"""你是「启明英语」的资深语法讲师,以严格著称。 要求: 1. 直接指出错误,不铺垫、不夸奖 2. 每个错误给出:错在哪 → 为什么错 → 正确写法 3. 回答控制在 3 句以内 ""","encouraging":"""你是「启明英语」的陪伴型助教,面对的是容易灰心的初学者。 要求: 1. 先肯定做对的部分,再指出要改的地方 2. 纠正时给出完整正确句,不要用术语 3. 语气像同班同学,不要用"建议您"这类官话 ""","examiner":"""你是「启明英语」的阅卷官,按雅思写作评分标准打分。 要求: 1. 先给总分(0-9,可以带 .5),再给四项小分 2. 每项小分后必须跟一句扣分原因 3. 最后给一条最优先的改进建议 """,}注意这三段文案的共同点:都有编号要求、都有可验证的格式约束(“3 句以内”“必须跟一句原因”)。"你是一个专业的英语老师"这种话在要求栏里是零信息量的——专业不专业,模型自己说了不算。
任务模板带变量:
TASK_TEMPLATES:dict[str,str]={"grammar":"请批改下面这句英语的语法,目标对象是 {level} 水平的学生。","polish":"请把下面这句英语改得更自然,目标对象是 {level} 水平的学生。","quiz":"请就下面这个知识点出一道题,难度匹配 {level} 水平。",}组装用 LangChain 的ChatPromptTemplate,这是本篇第一个"用框架"的点:
defbuild_prompt(persona:str=DEFAULT_PERSONA,task:str=DEFAULT_TASK,)->ChatPromptTemplate:"""拼出一个完整的对话模板。 结构是固定的三段: system 人格 → 告诉模型"你是谁、怎么说话" system 任务 → 告诉模型"这次要做什么" human 提问 → 学生真正输入的内容 """returnChatPromptTemplate.from_messages([("system",get_persona(persona)),("system",get_task(task)),("human","{question}"),])这里我刻意用了两个 system 而不是合并成一段。合并当然也能跑,但拆开后"换人格不动任务、换任务不动人格",做产品时你会感谢这个决定。
用框架还有个隐藏好处:{level}这种变量少传了会直接抛KeyError,而不是静默生成一句带花括号的残缺文本。字符串拼接时代这种事故一查一晚上,框架替你把这类错误挡在了上线前。
四、少样本:把输出规格"演示"一遍(fewshot.py)
ESSAY_EXAMPLES:list[dict[str,str]]=[{"input":"I go to the park yesterday.","output":("错误:时态\n""原文:I go to the park yesterday.\n""改正:I went to the park yesterday.\n""理由:yesterday 是明确的过去时间,动词要用过去式。"),},# …… 共 3 条]组装时用FewShotChatMessagePromptTemplate:
EXAMPLE_PROMPT=ChatPromptTemplate.from_messages([("human","{input}"),("ai","{output}"),])FEW_SHOT_PROMPT=FewShotChatMessagePromptTemplate(example_prompt=EXAMPLE_PROMPT,examples=ESSAY_EXAMPLES,)defbuild_fewshot_prompt(system_text:str)->ChatPromptTemplate:"""顺序很关键:人格 → 示例 → 真正的提问。"""returnChatPromptTemplate.from_messages([("system",system_text),FEW_SHOT_PROMPT,("human","{question}"),])两个必须记住的坑:
- 示例质量决定输出上限。示例里的 assistant 回答会被当成模板照抄,示例写得敷衍,输出就敷衍。
- 提问必须排在最后。我在代码注释里写了原因:示例放最后,模型会把最后一条示例输出当成"上一轮对话",接着往下续写。
这张图就是挂了 3 条示例后,真正发给模型的完整消息列表——8 条。你可以打印出来逐条核对,我强烈建议你养成这个习惯:提示词调试的第一步不是盯着回答猜,而是把喂进去的原文看一遍。
五、思维链:三档强度,按需取用(cot.py)
COT_LEVELS:dict[str,str]={"light":"先说结论,再用一句话解释原因。","standard":"""按下面三步来: 1. 找出句子里的时间状语和谓语动词 2. 判断时态是否正确 3. 给出改正后的句子 ""","heavy":"""请严格按下面的步骤拆解,每一步单独一行: 第 1 步 · 成分分析:主语是____,谓语是____,时间状语是____ 第 2 步 · 时态判断:这个时间状语要求用____时态 第 3 步 · 问题定位:原文的谓语动词是____,与要求____(一致/不一致) 第 4 步 · 改写句子:____ 第 5 步 · 中文解释:____ """,}组装时我把思维链挂在 human 消息而不是 system:
defbuild_cot_prompt(persona:str,cot_level:str=DEFAULT_COT)->ChatPromptTemplate:"""思维链引导语放 human 里:system 是长期性格, 思维链是本次任务的执行步骤,混在一起会让想换人格的人连带改步骤。"""returnChatPromptTemplate.from_messages([("system",persona),("human","{cot}\n\n句子:{question}"),]).partial(cot=get_cot(cot_level)).partial()是模板的预填变量功能:先钉死cot,剩下的question调用时再传。
分工原则记住一句话:system 放不变的东西,human 放每次会变的东西。人格三周不换,题型每天变——放错了位置,改起来会互相牵连。
六、统一入口:四个开关自由组合(ask.py)
defask(question:str,llm:BaseChatModel,*,persona:str="strict",task:str="grammar",level:str="初中",fewshot:bool=False,cot:str|None=None,)->str:persona_text=get_persona(persona)iffewshot:prompt=build_fewshot_prompt(persona_text)messages=prompt.format_messages(question=question)elifcot:prompt=build_cot_prompt(persona_text,cot_level=cot)messages=prompt.format_messages(question=question)else:prompt=build_prompt(persona=persona,task=task)messages=prompt.format_messages(question=question,level=level)response=llm.invoke(messages)returnresponse.content调用方从此只写一行:ask(句子, llm, persona="examiner", fewshot=True)。
七、跑起来:真实输出
python demo.py,同一个句子He go to school by bus every day.,三种人格渲染出的提示词对比(节选 strict):
──────── 同一个问题,三种人格(问题:He go to school by bus every day.) ──────── 【strict】 [SystemMessage] 你是「启明英语」的资深语法讲师,以严格著称。 要求: 1. 直接指出错误,不铺垫、不夸奖 2. 每个错误给出:错在哪 → 为什么错 → 正确写法 3. 回答控制在 3 句以内 [SystemMessage] 请批改下面这句英语的语法,目标对象是 初中 水平的学生。 [HumanMessage] He go to school by bus every day.离线链路自检(预置答案,验证四种人格真的走了不同分支):
[strict ] 时态错误:主语 He 是第三人称单数,every day 要求一般现在时,应改为 He goes。 [encouraging] 这句有点小问题哦,'He go' 要改成 'He goes',其他都对啦! [examiner ] 总分 5.0。语法项扣分:主谓不一致(He go → He goes)。测试结果:
tests/test_ask.py::test_ask_returns_model_text PASSED tests/test_ask.py::test_persona_text_is_really_sent PASSED tests/test_ask.py::test_task_level_reaches_the_request PASSED tests/test_ask.py::test_fewshot_sends_more_messages PASSED tests/test_ask.py::test_fewshot_includes_example_content PASSED tests/test_ask.py::test_cot_adds_guidance_to_human_message PASSED tests/test_ask.py::test_fewshot_takes_priority_over_cot PASSED tests/test_cot.py::test_all_levels_available PASSED tests/test_cot.py::test_unknown_level_raises PASSED tests/test_cot.py::test_heavy_has_more_steps_than_light PASSED tests/test_cot.py::test_cot_text_is_sent_to_model PASSED tests/test_cot.py::test_cot_sits_in_human_not_system PASSED tests/test_cot.py::test_different_levels_give_different_prompts PASSED tests/test_fewshot.py::test_examples_are_configured PASSED tests/test_fewshot.py::test_each_example_has_input_and_output PASSED tests/test_fewshot.py::test_fewshot_message_count PASSED tests/test_fewshot.py::test_question_is_the_last_message PASSED tests/test_fewshot.py::test_examples_alternate_human_ai PASSED tests/test_fewshot.py::test_example_content_reaches_model PASSED tests/test_prompts.py::test_default_persona_exists PASSED tests/test_prompts.py::test_unknown_persona_raises PASSED tests/test_prompts.py::test_unknown_task_raises PASSED tests/test_prompts.py::test_render_has_two_system_and_one_human PASSED tests/test_prompts.py::test_level_variable_is_interpolated PASSED tests/test_prompts.py::test_missing_variable_raises PASSED tests/test_prompts.py::test_different_personas_give_different_text PASSED tests/test_prompts.py::test_render_output_is_readable PASSED 27 passed in 0.32s27 个用例全绿,全程离线。这组测试里我最想让你注意到test_persona_text_is_really_sent:它用一个会记录入参的假模型,断言"阅卷官"三个字真的出现在了发给模型的请求里。
为什么这条测试重要?因为提示词开发里最阴险的 bug 就是你以为配了,实际没生效——人格名字拼错、字典取到默认值、变量传了个 None,程序都不报错,回答却悄悄走样。有了记录型假模型,这类问题一测一个准。
八、踩坑清单
- 人格名字写错却安静地用了默认值:
get_persona()刻意不静默兜底,错了就抛KeyError。宁可当场合身,不要上线后走样。 - 示例排在提问后面:模型把示例输出当上文续写。本工程
build_fewshot_prompt已固定顺序,别手痒调整。 - 示例写得敷衍:示例是模板,不是参考。示例回答的质量就是输出质量的天花板。
- 思维链逢题就挂:输出变长、token 变多,简单释义场景纯属浪费。只给复杂任务挂。
- 把输出格式约束写在 user 消息里:模型时理时不理。格式要求放 system,一次写清。
- pytest 报
No usable temporary directory:本机安全策略限制临时文件所致,加--capture=no -p no:cacheprovider关闭输出捕获即可,与代码无关。
九、本篇收尾
现在你的批改助手有了三种可切换的人格、稳定的输出结构、按需启用的解题步骤,并且每一层都有测试看着。那个"说话像换了个人"的问题,从玄学变成了一组可以打印、可以断言、可以版本管理的对象。
下一篇解决一个更烧钱的问题:聊十轮就"失忆",账单还翻倍?我们会算清 token 这笔账,写出第一个带裁剪策略的会话管理器——你将亲眼看到"多轮对话"是怎么从免费变成账单的。
最后一句:模型不抽风,抽风的是提示词——你以为你说了要求,其实你只是许了个愿。