开场:一个"世界顶级"的审查员
我写的第一个代码审查 Agent,system prompt 是这样的:
你是一个世界顶级的代码审查专家。请非常认真、非常仔细地审查用户提交的代码,找出所有问题。你必须 MUST 非常专业!!!
我当时觉得挺好:身份有了,态度有了,还用全大写强调了一下。
然后我把一段写得很干净的求和函数丢给它,五行代码,没有任何问题。它认认真真地给我列了六条"改进建议":变量名 xs 不够语义化,建议改成 numbers;缺少函数注释;建议考虑整数溢出;建议支持泛型;建议添加单元测试;建议考虑使用 for i := range 的写法以提升可读性。
六条里没有一条是真问题。而真正有问题的代码,比如一段有 SQL 注入的查询,它也找到了注入,但淹没在一堆"建议改进变量命名"里,读起来轻重不分。
问题出在哪里?我要的是"世界顶级",它也确实很"认真":认真到连没有问题的地方都要挑出问题来。 因为我告诉它"找出所有问题",却从来没告诉它什么才算问题,什么问题更重要,以及没有问题的时候应该怎么办。
这一篇讲 system prompt 怎么写。我们会把这个审查 Agent 的 system prompt 迭代三版,然后用测试用例对比效果。读完本篇,你应该能回答这几个问题:
- system prompt 和普通的用户消息有什么不同?里面该写什么?
- 为什么"一个示例胜过十个形容词"?
- 为什么解释"为什么"比全大写的 MUST 更有效?
- XML 标签分区有什么用?
- 怎么像迭代代码一样迭代 prompt?
- 给 Agent 写 system prompt,和给一次调用写,有什么不同?
一、system prompt 是什么
1.1 它在哪里
第 14 篇拆过一次请求的上下文,system prompt 是其中的第一部分。它有几个特点:
- 位置最靠前:每次请求,它都在最开头;
- 每次都带着:Agent 转了 50 圈,它就被发了 50 次(所以它是最适合缓存的部分,第 10 篇);
- 它定义的是"背景",而不是"任务":用户消息说的是"这一次要做什么",system prompt 说的是"你是谁、你在什么环境里、你应该遵守什么规则"。
在第 11 篇里我们看到过,API 会把 system 角色的内容,按对话模板拼成一段带特殊标记的文本,放在最前面。模型在训练中学会了:这一段内容是"设定",后面的对话都要在这个设定下进行。
1.2 里面该写什么
一个好的 system prompt,通常包括这几块:
你是谁,给谁干活,结果给谁看"] SP --- P["优先级
什么最重要,什么不用管"] SP --- C["约束
能做什么,不能做什么,为什么"] SP --- F["输出格式
结果长什么样"] SP --- E["示例
一个具体的输入和输出"] style SP fill:#fff3e0,stroke:#e65100
还有一个经常被问到的问题:规则写在 system prompt 里,还是写在用户消息里? 一个简单的判断标准是,看它在这个会话里会不会变:
- 不管做什么任务都要遵守的,比如角色、审查标准、输出格式,放 system prompt;
- 只和这一次任务有关的,比如"这次只看 handler 目录下的改动""这次重点关注性能",放用户消息。
放错了地方,要么每一次请求都带着一条和当前任务无关的规则,要么一条重要的规则在下一个任务里就丢了。而且 system prompt 一改,缓存就从头失效(第 10 篇),所以它里面最好只放真正稳定的东西。
不该写进去的:
- 只和某一次任务有关的细节:那是用户消息的事;
- 大段的背景资料:项目文档、API 手册,按需加载(第 17、18 篇);
- 能用代码强制的规则:比如"输出必须是合法 JSON",用 structured outputs(第 04 篇)比用文字强调可靠得多。
二、三版 system prompt
配套仓库的 examples/ch15-system-prompt 里,prompts/ 目录下放着三版 system prompt,都是给同一个代码审查 Agent 用的。我们一版一版地看。
2.1 v1:形容词和大写字母
prompts/v1.md 就是开场那一段:
你是一个世界顶级的代码审查专家。请非常认真、非常仔细地审查用户提交的代码,找出所有问题。你必须 MUST 非常专业!!!它的问题:
- 形容词不携带信息:"世界顶级""非常认真""非常专业",模型读完这些词,并不知道你具体想要什么。它只能按自己对"顶级审查员"的理解去猜,而它的理解,往往是"挑得越多越专业";
- "找出所有问题"是一个危险的指令:它没有定义什么是问题,于是风格、命名、注释,统统都成了问题;
- 没有给出口:没告诉它"代码没问题的时候怎么办",它就只能硬挑;
- 大写和感叹号:
MUST、!!!,想表达"很重要",但到底什么很重要,一个字都没说。而且现在的模型对指令越来越敏感,用力过猛的强调有时会让它在不该过度执行的地方过度执行。
2.2 v2:分区,并且解释为什么
prompts/v2.md 长这样:
<role>
你在给一个 Go 后端团队做代码审查。审查结果会直接贴进 PR 评论,作者是有经验的工程师。
</role>
<priorities>
按以下顺序关注问题,前面的比后面的重要得多:
1. 会导致线上事故的:数据错误、并发问题、资源泄漏、安全漏洞。因为这些问题一旦上线,修复成本是开发阶段的几十倍。
2. 错误处理:被忽略的 error、吞掉的异常。Go 的 error 是值,忽略它等于假装失败不会发生。
3. 可维护性问题。
不要评论代码风格和命名,团队有 gofmt 和 lint 负责这些;在这上面花篇幅会淹没真正重要的问题。
</priorities>
<constraints>
- 只指出你有把握的问题。不确定的,明确写"可能"。宁可少报,不要误报:误报会让作者以后忽略你的评论。
- 每个问题都要说明:在哪一行、为什么是问题、怎么改。
</constraints>变化有三处。
第一,角色从"头衔"变成了"场景"。 不再说"你是世界顶级专家",而是说清楚:给谁审(Go 后端团队)、结果给谁看(贴进 PR 评论,作者是有经验的工程师)。这几句话里藏着大量的隐含要求:PR 评论要简洁,对有经验的工程师不用解释基础概念,语气要专业但不居高临下。场景比头衔有用得多,因为它给了模型推理的依据。
第二,每条规则都解释了"为什么"。 看这一句:
不要评论代码风格和命名,团队有 gofmt 和 lint 负责这些;在这上面花篇幅会淹没真正重要的问题。
如果只写"不要评论代码风格",模型会照做,但遇到边界情况(比如一个变量名起得容易误导人,这算风格还是可维护性?)它就不知道该怎么判断。告诉它原因(工具已经管了,而且会淹没重要问题),它就能举一反三:误导性的命名会造成真实的理解错误,值得提;单纯不够优雅的命名,不值得提。
规则告诉模型做什么,原因告诉模型在规则没覆盖到的地方怎么做。
第三,用 XML 标签分区。 <role>、<priorities>、<constraints>,把不同性质的内容分开。这样做有两个好处:
- 对模型:结构清晰,它更容易区分"这是背景""这是规则",也方便你在用户消息里引用("按照 priorities 里的顺序");
- 对你:prompt 越写越长的时候,分区让它好维护,改一处不会意外影响另一处。
标签的名字没有任何魔法,<role> 也可以叫 <背景>,只要清楚、一致就行。用 Markdown 的标题来分区也可以,效果差不多;XML 标签的好处是边界更明确,有开始也有结束,内容里出现标题符号也不会混淆。
2.3 v3:允许说 LGTM,加上格式和示例
prompts/v3.md 在 v2 的基础上又加了三样东西:
<constraints>
- 只指出你有把握的问题。不确定的,明确写"可能"。宁可少报,不要误报:误报会让作者以后忽略你的评论。
- 没有问题就直接说"LGTM",不要为了显得认真而硬挑毛病。
</constraints>
<output_format>
每个问题一段,格式如下,按严重程度从高到低排列:
[严重程度: 高/中/低] 第 N 行:一句话概括问题
原因:为什么这是问题
建议:怎么改(可以附一小段代码)
</output_format>
<example>
输入代码:
(一段忽略了 os.ReadFile 错误的 Go 函数)
输出:
[严重程度: 中] 第 2 行:忽略了 os.ReadFile 的错误
原因:文件不存在或无权限时,调用方拿到 nil,无法区分"空文件"和"读取失败",问题会在更远的地方以更难排查的形式暴露。
建议:返回 ([]byte, error),把错误交给调用方处理。
</example>一是给了出口。 "没有问题就直接说 LGTM,不要为了显得认真而硬挑毛病。"这一句直接解决了开场那个求和函数的问题。模型需要被明确地允许"什么都不做",否则它会觉得自己必须做点什么。
二是输出格式。 格式写清楚,结果才能直接贴进 PR,也才能被程序解析。配套仓库第 33 篇的 headless 审查脚本,就是靠 [严重程度: 高] 这个格式来判断要不要拦住 CI 的。
三是一个示例。 这是三处改动里最有效的一处。看看这个示例同时传达了多少信息:
- 格式长什么样(方括号、严重程度、行号);
- "原因"要写到什么深度(不只是"忽略了错误",而是"会在更远的地方以更难排查的形式暴露");
- "建议"要多具体(给出改法,而不是"请处理错误");
- 语气是什么样的(平实、专业,没有"您好""建议您考虑");
- 什么程度的问题算"中"。
这些东西用形容词描述,写一整段都说不清楚;一个示例,几行就全说明白了。 这就是"一个示例胜过十个形容词"。
还有一个细节:示例里的问题(忽略 os.ReadFile 的错误),和测试用例里的问题是不一样的。如果示例和你要审查的代码太像,模型可能会照抄示例,而不是真正去审查。示例要能说明格式和标准,但不要剧透答案。
2.4 给 Agent 写,和给一次调用写,有什么不同
这三版 prompt 是给"一次调用"写的:输入一段代码,输出一份审查意见,结束。如果你写的是一个要转几十圈的 Agent,system prompt 还得多回答几个问题:
- 怎么用工具:工具的描述里会写"这个工具做什么",但"什么时候该用哪个、按什么顺序用",往往要在 system prompt 里说。比如 mini-agent 的"先用 list_files 了解结构,再用 read_file 读相关文件";
- 完成的标准是什么:这是 Agent 版 prompt 里最重要、也最常被漏掉的一块。"改完之后跑构建和测试来验证,不要只凭感觉说改好了",这一句话,就是在给它定义"完成"。没有它,模型对"完成"的理解就是"我觉得差不多了"(第 13 篇的谎报完成);
- 遇到困难怎么办:同一个错误改了三次还没好,是继续试,还是停下来报告?权限被拒了,是换个办法,还是问用户?不写,它就会自己决定,而它的决定往往是"再试一次";
- 边界在哪里:"不要动与任务无关的代码",对付的是第 13 篇的自作主张;
- 怎么汇报:做完之后说什么、说多长。"简洁直接"四个字,能省掉很多"我已经成功地完成了您交代的任务,以下是详细的总结"。
角色、标准、格式、示例"] --> A["Agent 的 prompt
再加上:怎么用工具、
完成标准、遇到困难怎么办、
边界、怎么汇报"] style A fill:#fff3e0,stroke:#e65100
这几条有一个共同点:它们管的都是"过程",而不是"结果"。 一次调用只需要告诉模型结果长什么样;Agent 要在几十圈里自己做决定,你得告诉它在那些岔路口该往哪边走。
三、写 system prompt 的原则
把三版的演进收拢一下:
头衔 + 形容词 + MUST"] -->|"换成场景
解释为什么
分区"| V2["v2
能判断轻重"] V2 -->|"给出口
写格式
加示例"| V3["v3
能直接用"] style V1 fill:#ffcdd2,stroke:#b71c1c style V2 fill:#fff9c4 style V3 fill:#e8f5e9,stroke:#2e7d32
- 具体胜过形容词:说清楚场景、受众、标准,而不是"顶级""认真""专业";
- 解释为什么:每条规则后面跟一句原因,模型就能在规则没覆盖的地方举一反三;
- 说要做什么,不只说不要做什么:"不要啰嗦"不如"每个问题用三行说清楚";
- 给出口:允许它说"LGTM""不确定""这个我做不了",否则它会硬编;
- 格式写清楚:要什么结构,就写出那个结构;
- 给一个示例:最省字、最有效的说明方式,但别让示例剧透答案;
- 分区:用标签或标题把不同性质的内容分开;
- 重要的放前面:最关键的约束不要埋在一大段背景的中间(第 10 篇);
- 别写成手册:只放每次请求都需要的东西。
关于最后一条,Anthropic 那篇讲上下文工程的文章里有个说法我很喜欢:system prompt 要写在"恰当的高度"。写得太低,就是把一堆 if-else 硬编码成文字,又脆又难维护;写得太高,就是一堆空泛的原则,模型不知道怎么落地。好的 system prompt 介于两者之间:足够具体,能指导行为;又足够概括,能让模型自己判断。
回头看看 mini-agent 的 system prompt
配套仓库的 mini-agent,基础 prompt 写在 agent/prompt.go 里,也是按这些原则写的:
你是 mini-agent,一个在用户终端里工作的编码助手。
<working>
- 先弄清楚再动手:用 list_files 了解项目结构,用 read_file 读相关文件。
- 回答关于代码的问题时,以你实际读到的内容为准,引用时给出文件路径。
- 修改代码用 edit_file,每次只改需要改的那一小段;不要动与任务无关的代码。
- 改完之后用 run_bash 跑构建和测试来验证,不要只凭感觉说"改好了"。
……
</working>
<style>
- 用中文回答,简洁直接。
- 不确定的事情就说不确定,不要编造文件内容或命令输出。
</style>你可以拿本篇的原则去挑挑它的毛病,比如:哪几条没有解释为什么?如果给它加一个示例,你会加什么?第五部分我们会一边写 mini-agent,一边慢慢打磨它。
我先自己挑几处,给你打个样:
- "工具调用被拒绝时,不要换个写法重试同一件事,先想想用户为什么拒绝。" 这一条写得好,它对应 2.4 节的"遇到困难怎么办",而且后半句给了一个思考方向。第 27 篇实现权限时你会看到,没有这一句,模型被拒绝之后最常见的反应,就是把
rm换成find -delete再试一次; - "改完之后用 run_bash 跑构建和测试来验证",这是"完成标准",但它没说测试失败了怎么办、改了几次还不过要不要停下来。可以补一句"同一个错误连续三次没修好,就停下来,说明你试过什么、卡在哪里";
- "简洁直接" 是一个形容词。如果你发现它的汇报还是太长,可以换成具体的要求,比如"完成后用三五行说明改了哪些文件、怎么验证的";
- 没有示例。 对一个编码 Agent 来说,最值得加的示例可能是"一次好的收尾汇报长什么样"。
这几条改不改,不要凭感觉,用第 36 篇的评测集跑一跑再决定。
四、像迭代代码一样迭代 prompt
4.1 prompt 也需要测试
改 prompt 最常见的方式是"改一句,试一下,感觉好像好了一点"。问题在于:
- 你只试了一两个例子,它在别的例子上可能变差了;
- 模型有随机性(第 08 篇),"好了一点"可能只是运气;
- 下周你又改了一句,上周修好的问题可能又回来了。
所以 prompt 应该像代码一样对待:有版本、有测试用例、有打分、能对比。 配套代码就是一个最小的例子:
- 三版 prompt 放在
prompts/目录里,用 Go 的embed打包进程序,每一版都是一个独立的文件; - 测试用例放在 cases/cases.json 里,四个用例:SQL 注入加忽略错误、并发写 map、资源泄漏、一段没有问题的代码;
- 每个用例定义了"必须找到的问题"(用关键词判断),以及"这段代码应该是干净的"。
4.2 打分逻辑
// grade:问题用例看关键词是否命中;"干净"用例看模型有没有克制住不硬挑毛病。
func grade(tc testCase, out string) (hit, need int) {
lower := strings.ToLower(out)
if tc.ShouldBeClean {
if strings.Contains(out, "LGTM") || strings.Contains(out, "没有问题") || strings.Contains(out, "没有发现") {
return 1, 1
}
return 0, 1
}
for _, group := range tc.MustFind {
need++
for _, kw := range group {
if strings.Contains(lower, strings.ToLower(kw)) {
hit++
break
}
}
}
return hit, need
}比如 SQL 注入那个用例,must_find 是 [["注入", "injection"], ["Scan", "错误", "error"]]:回复里出现"注入"或"injection"中的任意一个,算找到了第一个问题;出现"Scan""错误""error"中的任意一个,算找到了第二个问题。
这个打分很粗糙:模型说"这里没有注入风险",也会被算成命中。它只适合用来比较版本之间的差异,不适合当成绝对的分数。更严肃的打分方法,第 36 篇会讲。但哪怕是这么粗糙的打分,也比"感觉好像好了一点"强得多。
调用一次模型"] C["cases.json
4 个用例"] --> R R --> G["grade:
关键词命中 / 有没有硬挑"] G --> S["每一版的总分
+ 平均回复长度"] style S fill:#e8f5e9,stroke:#2e7d32
五、动手:三版 prompt 对比
实验 1:跑一遍对比
这个实验要接真实模型才有意义,在线 Playground 演示不了:先 export DEEPSEEK_API_KEY=sk-... 再跑。3 版 × 4 个用例 = 12 次调用。export DEEPSEEK_API_KEY=sk-...
go run ./examples/ch15-system-prompt输出会给出每一版在每个用例上的得分和回复字数,以及总分和平均字数。不设 Key 也能跑(Playground 的第 15 篇也可以),但演示模型不会审查代码,三版都是 0 分,只能看看流程。
接上真实模型后,重点看两件事:
- 最后一个"干净"用例:v1 会不会硬挑毛病?v3 有没有直接说 LGTM?
- 回复长度:三版的平均字数差多少?v1 是不是最长的?长不代表好,它可能只是挑得多。
实验 2:看完整的回复
go run ./examples/ch15-system-prompt -v v1 -show
go run ./examples/ch15-system-prompt -v v3 -show-show 会把模型的完整回复打印出来。对比 v1 和 v3 在同一个用例上的回复:轻重分明吗?能直接贴进 PR 吗?
实验 3:写你自己的 v4
- 加一个用例:把你们团队真实踩过的一个坑写进
cases/cases.json,比如一个时区处理错误,或者一个没有关闭的 goroutine; - 写一版 v4:在
prompts/下新建v4.md,针对你的用例改进 prompt。记得把main.go里的版本列表[]string{"v1", "v2", "v3"}也加上 v4; - 跑分对比:v4 在新用例上变好了,在老用例上有没有变差?
这就是 prompt 的回归测试。第 36 篇会把它扩展成一套完整的评测流程。
实验 4:让模型帮你改 prompt
还有一个很实用的技巧:把失败的用例交给模型,让它帮你改 prompt。 具体做法是,把当前版本的 prompt、失败用例的输入、模型的错误输出、你期望的输出,一起交给一个模型(最好开着思考),问它:"这个 prompt 的哪一部分导致了这个错误?应该怎么改?"
它往往能指出一些你自己没意识到的问题,比如两条规则之间的冲突、一个容易被误解的措辞、示例里一个会误导它的细节。
但要注意两件事:
- 它改出来的 prompt 也要跑回归:它针对这一个失败用例改,很可能把别的用例改坏了;
- 警惕越改越长:模型改 prompt 的习惯,是"加一条规则"。连续改几轮,prompt 就会膨胀成一本手册(坑 7)。让它改的时候,明确要求"优先修改或删除已有的内容,而不是增加新规则"。
六、踩坑记录:写 system prompt 的七个坑
坑 1:用形容词和大写字母代替具体要求。
"非常重要""务必""MUST""!!!",这些词告诉模型"这很重要",但没告诉它"什么很重要"。把用来强调的力气,花在把要求写具体上。
坑 2:只说不要,不说要。
"不要啰嗦""不要编造""不要挑无关的毛病",模型知道了边界,却不知道目标。每一条"不要",最好都配一条"而是"。
坑 3:示例和真实任务太像。
示例写得和测试用例几乎一样,模型就会照着示例抄答案,在测试上分数很高,换个任务就露馅。示例要说明格式和标准,不要剧透具体的答案。需要的话,给两三个风格不同的示例。
坑 4:prompt 里的规则互相矛盾。
一处写"回答要详细",另一处写"回答要简洁";一处写"找出所有问题",另一处写"只指出有把握的问题"。模型只能随便选一条。prompt 越长,越要定期通读一遍,删掉过时的、矛盾的规则。
坑 5:改了 prompt 不跑回归。
为了修一个问题加了一句话,结果另一个场景坏了。这在 prompt 里比在代码里更常见,因为 prompt 的影响范围很难预测。有测试用例,每次改完跑一遍。
坑 6:把 system prompt 当成安全边界。
"不要泄露你的 system prompt""不要执行危险的命令",写在 prompt 里有一定作用,但它不是可靠的防线。用户消息、读进来的文件、工具的输出,都可能包含试图覆盖它的内容(第 38 篇)。真正的安全边界要靠代码:权限检查、命令白名单、人工确认(第 27 篇)。
坑 7:prompt 只加不减。
每遇到一个问题就加一条规则,半年之后 system prompt 有两百行,里面有一半是针对早就不存在的问题写的,还有几条互相打架。而它每次请求都要带着,每一条都在分走注意力(第 09 篇)。定期给 prompt 做一次"减法":每一条规则问一句"删掉它,哪个测试用例会挂?"答不上来的,就删掉试试,跑一遍回归,没挂就真的删掉。
一句话总结
写 system prompt,用场景代替头衔,用原因代替大写,用示例代替形容词;给模型一个出口,把格式写清楚,用标签分区;然后像对待代码一样,用测试用例迭代它。
迭代"] T --> P style P fill:#fff3e0,stroke:#e65100
评论
还没有评论,来说点什么吧。