开场:我花了一下午改措辞
有一次我让编码 Agent 给项目里的一个接口加分页。它改得很快,但改法不对:项目里明明有一个现成的分页工具函数,它没用,自己又写了一套,风格还和其他接口不一致。
我的第一反应是"prompt 没写好"。于是我开始打磨措辞:
- "你是一位资深的 Go 工程师,非常注重代码复用。"
- "务必遵循项目已有的代码风格。"
- "在动手之前,请认真思考项目中是否已有可复用的代码。"
改了一下午,加粗、全大写、换着花样说,效果时好时坏。
后来我换了个思路,打开它每一步的请求看了看,才发现问题根本不在措辞:它从头到尾就没读到那个分页函数所在的文件。 那个文件放在一个叫 internal/pkg/x 的目录里,名字起得很随意,它列目录的时候扫了一眼就跳过了。
我在任务描述里加了一句平平无奇的话:"项目里的分页逻辑在 internal/pkg/x/page.go,请复用它。"一次就改对了。之前那一下午加的粗、写的"务必",全部删掉,也一样能改对。
那一刻我意识到:比起怎么说,更重要的是让它看到什么。 这就是这一篇的主题,也是整个第三部分的主题。读完本篇,你应该能回答这几个问题:
- Prompt 工程和 Context 工程有什么区别?
- 为什么说上下文是模型唯一的世界?
- 一次 Agent 请求的上下文由哪五个部分组成?各占多少?
- 上下文工程有哪几个基本动作?在 mini-agent 里分别对应哪段代码?
- 怎么判断上下文工程做得好不好?
一、从"怎么说"到"给它看什么"
1.1 Prompt 工程的年代
2023 年前后,"prompt 工程"是个热词。大家研究的是措辞:用什么语气,加不加"一步一步思考",要不要说"你是某某专家",用不用"请务必"。网上流传着各种 prompt 模板和"咒语"。
这些技巧在当时是有用的:那时的模型对措辞比较敏感,交互也大多是一问一答,一次请求里,你写的那段话就是上下文的主体。
它们今天也没有完全失效。第 15 篇会讲,清楚地说明规则、解释原因、给出示例,这些依然是写好 system prompt 的基本功。变的是重心:措辞从"主要矛盾"变成了"次要矛盾"。 模型越来越聪明,对措辞越来越不敏感,你说"务必"和说"请",差别已经不大;但它看不到的东西,再聪明也看不到。
1.2 Agent 改变了什么
到了 Agent 的时代,情况变了。一次 Agent 请求里,你写的那段话只占很小一部分,更多的是:system prompt、十几个工具的定义、几十轮的对话历史、读进来的文件、命令的输出……
你写的那句 prompt,变成了上下文里最小的一块。 而模型表现得好不好,越来越取决于另外那几块:它有没有看到关键的文件,工具描述写得清不清楚,历史里有没有一堆过时的噪声。
所以业界开始用一个新词:context engineering(上下文工程)。Anthropic 有一篇工程博客专门讨论它,标题就叫《Effective context engineering for AI agents》。它的核心观点可以概括成一句话:
在每一次调用模型时,挑选出最小的、信息密度最高的一组 token,让模型最有可能做出你想要的行为。
prompt 工程问的是"这句话怎么写",context 工程问的是"这次请求里,应该放进哪些东西、不放哪些东西、按什么顺序放"。前者是后者的一部分。
二、上下文是模型唯一的世界
2.1 回到第 01 篇
整个系列反复强调过一句话:模型知道的一切,都在这次请求的输入里。输入里没有的,它就不知道。
这句话有两层含义,第二层经常被忽略:
- 上下文之外的,它一概不知道:你项目里的约定、你上周跟同事讨论的结论、你脑子里的那个"显然应该这样",只要没有放进上下文,对它来说就不存在。开场那个分页函数就是这样;
- 上下文之内的,它都当真:第 01 篇的实验里,伪造的聊天历史它深信不疑。同样,上下文里的一份过时的文档、一段错误的注释、一个已经被推翻的方案,它也会当真。
你的项目、你的约定、你的意图"] -->|"只有放进上下文的部分"| C["上下文"] C --> M(("模型")) W -.->|"没放进去的:不存在"| X["模型不知道"] N["过时的文档、错误的注释"] -->|"放进去了:当真"| C style C fill:#fff3e0,stroke:#e65100 style X fill:#ffcdd2,stroke:#b71c1c style N fill:#ffcdd2,stroke:#b71c1c
所以上下文工程的工作,本质上是为模型构造一个世界:该有的东西都在,不该有的东西都不在,而且重要的东西摆在显眼的位置。
2.2 注意力是有限的预算
那是不是把所有可能有用的东西都塞进去就好了?反正上下文窗口有 100 万 token。
不是。第 09 篇讲过:注意力是有限的预算,softmax 的权重加起来永远等于 1。上下文里的东西越多,每样东西平均分到的注意力就越少。第 10 篇讲过:放在中间的信息容易被忽略。第 13 篇讲过:越聊越笨,是因为上下文被无关内容稀释了。
所以塞得越多,不是越好,而是越贵、越慢、越容易漏掉关键信息。上下文工程追求的不是"全",而是"准":最小的、信息密度最高的那一组 token。
2.3 上下文是每一圈"组装"出来的
还有一个视角上的转变,对理解后面几篇很重要。
在聊天产品里,上下文看起来像一个只会变长的日志:你说一句,它回一句,全部按顺序往后追加。第 02 篇的 20 行 Agent 也是这么做的。
但在一个认真设计的 Agent 里,每一次调用模型之前,harness 都在重新"组装"这次请求的上下文:
- system prompt 是拼出来的:固定的角色说明,加上这个项目的记忆文件,加上这次可用的 skills,加上今天的日期;
- 工具列表是挑出来的:有的 Agent 会根据当前的权限模式,或者用户是否接了某个 MCP server,决定这次给哪些工具;
- 历史是处理过的:太长的工具输出在放进来之前已经被截断了;历史超过预算时,前面那一大段会被换成一份摘要;
- 有些信息是临时加进去的:比如在用户的消息后面附上"当前的 git 分支"。
可以把它写成一个式子:这次的上下文 = f(Agent 的全部状态,这一步要做的事)。消息历史只是状态的一部分,硬盘上的记忆文件、交接文档、子 Agent 的结论,都是状态。上下文工程,就是设计这个 f。
f(状态, 这一步)"} S2["项目记忆
AGENTS.md"] --> F S3["skills 列表"] --> F S4["环境信息
日期、目录"] --> F F --> C["这次请求的上下文"] C --> M(("模型")) style F fill:#fff3e0,stroke:#e65100
这个 f 也不能乱写:第 10 篇说过,前缀要保持稳定,缓存才能命中。所以好的组装方式是前面尽量不动、后面按需变化,而不是每一圈都从头重新排一遍。
三、上下文的五个组成部分
一次 Agent 请求的上下文,可以拆成五个部分:
角色、规则、环境信息"] T["② 工具定义
每个工具的名字、描述、参数"] H["③ 对话历史
用户说的话、模型的回复、工具调用"] K["④ 检索内容
读进来的文件、文档、搜索结果"] O["⑤ 工具输出
命令结果、测试日志、报错"] end style S fill:#e3f2fd,stroke:#1565c0 style T fill:#e3f2fd,stroke:#1565c0 style H fill:#e8f5e9,stroke:#2e7d32 style K fill:#fff9c4 style O fill:#ffcdd2,stroke:#b71c1c
(严格来说,检索内容和工具输出在消息历史里都是以工具结果的形式出现的。把它们单独拎出来,是因为它们的性质和管理方法很不一样。)
逐个看一下:
| 部分 | 谁写的 | 稳定性 | 典型大小 | 常见问题 |
|---|---|---|---|---|
| system prompt | 开发 Agent 的人 | 几乎不变 | 几百到几千 token | 写成了一本手册,或者太含糊 |
| 工具定义 | 开发 Agent 的人、MCP server | 几乎不变 | 每个工具几十到几百 token | 工具太多、描述不清、功能重叠 |
| 对话历史 | 用户和模型 | 只增不减 | 随轮数线性增长 | 越来越长,混着过时的信息 |
| 检索内容 | 工具(读文件、搜索) | 每次不同 | 一个文件几百到几万 token | 读多了、读错了、读漏了 |
| 工具输出 | 工具(跑命令) | 每次不同 | 从一行到几万行 | 一次输出就能撑爆上下文 |
3.1 system prompt
它是每次请求的开头,告诉模型"你是谁、你在哪里、规则是什么"。配套仓库的 mini-agent,在 agent/prompt.go 里拼出 system prompt:角色说明、工作方式,加上项目记忆(第 17 篇)、可用的 skills(第 31 篇),最后是环境信息(工作目录、操作系统、今天的日期)。
它最稳定,也最适合缓存(第 10 篇)。写法第 15 篇专门讲。
3.2 工具定义
每个工具的名字、描述、参数的 JSON Schema,每次请求都要带上。一个工具几十到几百 token 不算多,但工具一多就不一样了:接了几个 MCP server,一下子多出几十个工具,光定义就可能好几千 token。而且工具越多,模型越容易选错(第 20、23 篇)。
3.3 对话历史
第 02 篇说过,Agent 的全部状态就是消息历史,它只增不减。几十轮之后,历史里会混进很多已经过时的东西:被推翻的方案、走过的弯路、早就被修改过的文件的旧内容。
3.4 检索内容
Agent 为了完成任务而读进来的东西:源文件、文档、搜索结果。这部分最考验 Agent 的判断力:读什么、读多少、什么时候读。 开场那个故事,就是该读的没读到。第 18 篇专门讲检索。
3.5 工具输出
跑命令、跑测试的输出。这是最大、也最难控制的一块:一次 go test 可能输出几千行,一次 cat 一个日志文件可能就是几万行。而且这些输出里,真正有用的往往只有几行(报错的那几行),其余全是噪声。第 28 篇会专门处理这件事。
四、拆开一次请求
4.1 实验:一层一层叠起来
光说不练,我们来量一量。配套代码 examples/ch14-context-anatomy/main.go 模拟了一次编码 Agent 的请求,把五个部分一层一层叠上去,每叠一层数一次 token:
- 一句话:"帮我看看 demo.go 里的缓存逻辑有没有问题";
- system prompt:一段两行的编码助手说明;
- 工具定义:
read_file、edit_file、run_bash三个工具; - 对话历史:模型回了一句"好的,我先读一下文件";
- 检索内容:仓库里一个真实的源文件
internal/llm/demo.go; - 工具输出:模拟一次
go test的输出,200 个测试用例的 PASS 记录。
go run ./examples/ch14-context-anatomy不设 Key 时,token 数是演示模型按字数估算的,量级可信(也可以在 Playground 的第 14 篇里跑):
模型 demo-1
上下文 累计 本项
① 只有一句话 25 25 ▇
② + system prompt 83 58 ▇
③ + 工具定义 271 188 ▇
④ + 对话历史 292 21 ▇
⑤ + 检索内容(一个源文件) 1516 1224 ▇▇▇▇▇▇
⑥ + 工具输出(一段测试日志) 4339 2823 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇
通常最占地方的不是你写的 prompt,而是工具输出和检索内容——上下文工程主要管的就是它们。想看 DeepSeek 分词器的真实计数,在线 Playground 演示不了:先 export DEEPSEEK_API_KEY=sk-... 再跑。每一层都是一次只生成 1 个 token 的请求,一共 6 次,花不到一分钱。4.2 看出了什么
先说一句:这个实验里的数字,是"第一圈"的样子。Agent 往后转,饼图会变:工具输出和检索内容会越来越多,对话历史也会跟着变长,因为每一次工具调用和它的结果,都会变成历史的一部分。到了第二十圈,"历史"这一块往往会变成最大的那一块,而它里面装的,大多就是前面十九圈的检索内容和工具输出。
看"本项"那一列:
- 你写的那句话:25 token;
- system prompt:58 token;
- 三个工具的定义:188 token,已经是 system prompt 的三倍多;
- 一个源文件:一千多 token;
- 一段测试日志:两千八百多 token,是你那句话的一百多倍。
而这还只是"第一圈"。Agent 继续往下转,每读一个文件、每跑一次测试,都会再加上这么一大块,而且每一圈都要把之前所有的块重新发一遍(第 02 篇)。
更扎心的是那段测试日志:200 行 PASS,对模型有用的信息只有一句"全部通过了"。剩下的两千多 token,全是噪声。
你精心打磨的那句 prompt,在饼图上几乎看不见。 这就是为什么开场那个下午的措辞调整收效甚微:我在优化饼图里最小的那一块,而真正的问题出在"检索内容"里缺了一块。
五、上下文工程的四个基本动作
知道了上下文由什么组成,接下来的问题是怎么管理它。业界常用四个动作来概括:
把信息存到上下文外面
需要时再拿回来"] CE --- S["选择
只把需要的放进来"] CE --- C["压缩
把放进来的变小"] CE --- I["隔离
不同的事情用不同的上下文"] W --- W1["交接文档、项目记忆
第 16、17 篇"] S --- S1["检索、按需读取
第 18 篇"] C --- C1["截断输出、压缩历史
第 28 篇"] I --- I1["子 Agent
第 30 篇"] style CE fill:#fff3e0,stroke:#e65100
- 写入:有些信息不需要一直放在上下文里,但需要的时候要能拿回来。比如一个长任务做到一半,把进度写成一份交接文档;项目的约定写进一个固定的文件,每次启动时自动加载。第 16、17 篇;
- 选择:不是所有可能有用的东西都要放进来,只放这一步需要的。比如不要一开始就把整个代码库塞进去,而是让 Agent 按需搜索、按需读取。第 18 篇;
- 压缩:放进来的东西尽量小。工具输出截断,只留关键部分;对话历史太长了,总结成一份摘要。第 28 篇;
- 隔离:不同的子任务用不同的上下文。比如让一个子 Agent 去读二十个文件,它的上下文用完就扔,只把结论带回主 Agent。第 30 篇。
还有一个贯穿始终的考虑:顺序。稳定的东西放前面,变化的东西放后面,这样缓存才能命中(第 10、37 篇);最重要的指令放在开头或者紧挨着问题(第 10 篇)。
接下来的四篇,加上第五部分的几篇,会把这四个动作一个一个落到代码里。先剧透一下它们在 mini-agent 里分别长什么样:
| 动作 | mini-agent 里的实现 | 在哪里 |
|---|---|---|
| 写入 | 启动时读工作目录下的 AGENTS.md,拼进 system prompt | agent/prompt.go |
| 选择 | read_file 默认最多读 2000 行,大文件用 offset、limit 分段读 | agent/tools/files.go |
| 压缩 | 工具输出超过上限时保留头尾、省略中间;历史超过预算时自动压缩成摘要 | agent/tools/registry.go 的 Truncate、agent/compact.go |
| 隔离 | task 工具开一个子 Agent,用全新的上下文干活,只带回结论 | agent/subagent.go |
每一行背后,都是一次"翻车之后才加上"的经历,第五部分会一个一个讲清楚它们是怎么来的。
5.1 怎么知道上下文工程做得好不好
这四个动作做得好不好,有几个看得见的信号:
- 缓存命中率:前缀稳定的 Agent,从第二圈开始命中率就应该很高。命中率低,说明组装方式有问题(第 10、37 篇);
- 上下文涨得多快:每一圈新增的 token 里,有多少是有用的?一次工具调用就涨几万 token,多半是输出没截断;
- 它有没有反复读同一个文件:读了又读,说明之前读的内容被淹没了,或者被压缩丢了;
- 它有没有读到关键的文件:开场那个故事,就是这一条没做好。翻一遍它这次任务里所有的
read_file调用,看看该读的读了没有。
这几个信号,第 37 篇的 trace 和账本都能直接看到。上下文工程不是凭感觉调的,是可以观测的。
六、动手:量一量你的上下文
实验 1:跑一遍拆解
go run ./examples/ch14-context-anatomy对照上面的分析,看看每一部分的大小。如果接了 DeepSeek,对比一下真实计数和演示模型的估算差多少。
实验 2:改改参数,看看变化
打开 examples/ch14-context-anatomy/main.go,试着做这些修改:
- 多加几个工具:照着现有的三个,再加五个工具定义(随便编几个,比如
search_code、list_files、git_diff……),看看工具定义那一层涨了多少; - 换一个大文件:把
internal/llm/demo.go换成仓库里更大的文件,比如agent/agent.go,或者README.md; - 把测试日志改成只有一行:
toolOutput改成"ok agent-from-zero 0.5s",看看总量降了多少。这就是第 28 篇"截断工具输出"能带来的效果。
实验 3:估算你自己的 Agent
想一想你最常让 Agent 做的一类任务,估算一下:
- 它一般要读几个文件?每个大概多大?
- 它一般要跑几次命令?输出大概多长?
- 一个任务大概转几圈?
用这些数字,粗略算一下这类任务一次的总输入 token。再想想:里面有多少是真正有用的?如果让你只留下最有用的十分之一,你会留下哪些?你留下的那部分,就是你的 Agent 最该优化的方向。
七、踩坑记录:上下文工程的七个坑
坑 1:花大力气打磨措辞,却不看上下文里有什么。
开场的故事。遇到 Agent 表现不好,第一步不是改 prompt,而是打开它那一步的完整请求,看看它到底看到了什么。第 02 篇说过,这是调试 Agent 的第一原则。
坑 2:什么都往 system prompt 里塞。
项目的所有约定、所有注意事项、所有可能用到的背景知识,全写进 system prompt。结果它变成了一本几千行的手册,每次请求都带着,关键的规则淹没在里面。system prompt 只放每一次请求都需要的东西,其余的按需加载。
坑 3:工具越多越好。
每多一个工具,就多一份定义要放进上下文,模型也多一个可能选错的选项。功能重叠的工具尤其糟糕:两个工具都能"读文件",它该用哪个?第 20 篇讲工具设计时细说。
坑 4:仗着窗口大,一次把整个代码库塞进去。
100 万 token 的窗口确实装得下一个中等规模的代码库,但这样做又贵又慢,而且模型在一大堆无关代码里找关键的几行,效果往往不如让它自己按需搜索(第 18 篇)。
坑 5:工具输出不截断。
一次 cat 大日志、一次全量 go test -v,几万 token 就进去了,而且之后每一圈都要带着。第 28 篇的第一道防线就是截断,我们会先故意让 Agent 因为这个崩一次。
坑 6:上下文里有互相矛盾的信息。
README 说用 A 方案,代码里实际用的是 B 方案,注释里还写着 C 方案。它们都在上下文里,模型都当真,于是它要么困惑,要么随便挑一个。上下文的质量和它的数量一样重要,过时的文档比没有文档更糟糕。
坑 7:忘了上下文会被发出去。
上下文里的一切,都会随着请求发送给模型服务商。Agent 读了一个 .env 文件,里面的密钥就进了上下文;跑了一个打印环境变量的命令,输出也进了上下文。而且之后的每一圈,它都会被再发一遍。选择放什么进上下文,也是在选择把什么发出去。 配套的 mini-agent 并没有拦着它读 .env,这是一个刻意保留的简化,也是它的一个局限;真要用在有敏感信息的项目里,最好在 read_file 里加一份"不许读的文件"清单。第 38 篇讲"致命三件套"时,会专门讨论 Agent 能读私有数据这件事意味着什么。
一句话总结
Prompt 工程问"怎么说",Context 工程问"给它看什么":上下文是模型唯一的世界,由 system prompt、工具定义、对话历史、检索内容、工具输出五部分组成,最大的往往是后两块;上下文工程就是用写入、选择、压缩、隔离四个动作,为模型准备一组最小、最准的 token。
评论
还没有评论,来说点什么吧。