System Prompt 写作,角色、约束与示例

outlier 16 分钟 6316 字 0 0
系列教程Agent从入门到放弃 第 16 / 19 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02①大模型、Chatbot、Agent,一次把名字理清楚
  3. 03②Agent 的最小定义,一个 while 循环
  4. 04③Agent 简史,从 ReAct 到 Claude Code
  5. 05④Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
查看教程目录

开场:一个"世界顶级"的审查员

我写的第一个代码审查 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,通常包括这几块:

flowchart TB SP(("system prompt")) SP --- R["角色与场景
你是谁,给谁干活,结果给谁看"] 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 非常专业!!!

它的问题:

  1. 形容词不携带信息:"世界顶级""非常认真""非常专业",模型读完这些词,并不知道你具体想要什么。它只能按自己对"顶级审查员"的理解去猜,而它的理解,往往是"挑得越多越专业";
  2. "找出所有问题"是一个危险的指令:它没有定义什么是问题,于是风格、命名、注释,统统都成了问题;
  3. 没有给出口:没告诉它"代码没问题的时候怎么办",它就只能硬挑;
  4. 大写和感叹号: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 篇的自作主张;
  • 怎么汇报:做完之后说什么、说多长。"简洁直接"四个字,能省掉很多"我已经成功地完成了您交代的任务,以下是详细的总结"。
flowchart LR O["一次调用的 prompt
角色、标准、格式、示例"] --> A["Agent 的 prompt
再加上:怎么用工具、
完成标准、遇到困难怎么办、
边界、怎么汇报"] style A fill:#fff3e0,stroke:#e65100

这几条有一个共同点:它们管的都是"过程",而不是"结果"。 一次调用只需要告诉模型结果长什么样;Agent 要在几十圈里自己做决定,你得告诉它在那些岔路口该往哪边走。

三、写 system prompt 的原则

把三版的演进收拢一下:

flowchart LR V1["v1
头衔 + 形容词 + MUST"] -->|"换成场景
解释为什么
分区"| V2["v2
能判断轻重"] V2 -->|"给出口
写格式
加示例"| V3["v3
能直接用"] style V1 fill:#ffcdd2,stroke:#b71c1c style V2 fill:#fff9c4 style V3 fill:#e8f5e9,stroke:#2e7d32
  1. 具体胜过形容词:说清楚场景、受众、标准,而不是"顶级""认真""专业";
  2. 解释为什么:每条规则后面跟一句原因,模型就能在规则没覆盖的地方举一反三;
  3. 说要做什么,不只说不要做什么:"不要啰嗦"不如"每个问题用三行说清楚";
  4. 给出口:允许它说"LGTM""不确定""这个我做不了",否则它会硬编;
  5. 格式写清楚:要什么结构,就写出那个结构;
  6. 给一个示例:最省字、最有效的说明方式,但别让示例剧透答案;
  7. 分区:用标签或标题把不同性质的内容分开;
  8. 重要的放前面:最关键的约束不要埋在一大段背景的中间(第 10 篇);
  9. 别写成手册:只放每次请求都需要的东西。

关于最后一条,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 篇会讲。但哪怕是这么粗糙的打分,也比"感觉好像好了一点"强得多。

flowchart LR P["prompts/v1、v2、v3"] --> R["对每个用例
调用一次模型"] 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 分,只能看看流程。

接上真实模型后,重点看两件事:

  1. 最后一个"干净"用例:v1 会不会硬挑毛病?v3 有没有直接说 LGTM?
  2. 回复长度:三版的平均字数差多少?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

  1. 加一个用例:把你们团队真实踩过的一个坑写进 cases/cases.json,比如一个时区处理错误,或者一个没有关闭的 goroutine;
  2. 写一版 v4:在 prompts/ 下新建 v4.md,针对你的用例改进 prompt。记得把 main.go 里的版本列表 []string{"v1", "v2", "v3"} 也加上 v4;
  3. 跑分对比: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,用场景代替头衔,用原因代替大写,用示例代替形容词;给模型一个出口,把格式写清楚,用标签分区;然后像对待代码一样,用测试用例迭代它。

flowchart LR A["场景"] --> P["好的 system prompt"] B["原因"] --> P C["出口"] --> P D["格式"] --> P E["示例"] --> P P --> T["测试用例 + 打分
迭代"] T --> P style P fill:#fff3e0,stroke:#e65100

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭