从 Prompt 工程到 Context 工程

outlier 15 分钟 5858 字 0 0
系列教程Agent从入门到放弃 第 15 / 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 给项目里的一个接口加分页。它改得很快,但改法不对:项目里明明有一个现成的分页工具函数,它没用,自己又写了一套,风格还和其他接口不一致。

我的第一反应是"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 工程问的是"这次请求里,应该放进哪些东西、不放哪些东西、按什么顺序放"。前者是后者的一部分。

flowchart LR subgraph P["Prompt 工程"] P1["怎么措辞?"] end subgraph C["Context 工程"] C1["system prompt 写什么"] C2["给哪些工具,怎么描述"] C3["历史留多少,怎么压缩"] C4["检索什么,什么时候检索"] C5["工具输出怎么截断"] end P --> C1 style P fill:#e3f2fd,stroke:#1565c0 style C fill:#fff3e0,stroke:#e65100

二、上下文是模型唯一的世界

2.1 回到第 01 篇

整个系列反复强调过一句话:模型知道的一切,都在这次请求的输入里。输入里没有的,它就不知道。

这句话有两层含义,第二层经常被忽略:

  1. 上下文之外的,它一概不知道:你项目里的约定、你上周跟同事讨论的结论、你脑子里的那个"显然应该这样",只要没有放进上下文,对它来说就不存在。开场那个分页函数就是这样;
  2. 上下文之内的,它都当真:第 01 篇的实验里,伪造的聊天历史它深信不疑。同样,上下文里的一份过时的文档、一段错误的注释、一个已经被推翻的方案,它也会当真。
flowchart TB W["真实世界
你的项目、你的约定、你的意图"] -->|"只有放进上下文的部分"| 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。

flowchart LR S1["消息历史"] --> F{"harness 组装
f(状态, 这一步)"} S2["项目记忆
AGENTS.md"] --> F S3["skills 列表"] --> F S4["环境信息
日期、目录"] --> F F --> C["这次请求的上下文"] C --> M(("模型")) style F fill:#fff3e0,stroke:#e65100

这个 f 也不能乱写:第 10 篇说过,前缀要保持稳定,缓存才能命中。所以好的组装方式是前面尽量不动、后面按需变化,而不是每一圈都从头重新排一遍。

三、上下文的五个组成部分

一次 Agent 请求的上下文,可以拆成五个部分:

flowchart TB subgraph R["一次 Agent 请求"] direction TB S["① system prompt
角色、规则、环境信息"] 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,全是噪声。

pie title 一次请求里各部分的占比(演示模型估算) "你写的那句话" : 25 "system prompt" : 58 "工具定义" : 188 "对话历史" : 21 "检索内容" : 1224 "工具输出" : 2823

你精心打磨的那句 prompt,在饼图上几乎看不见。 这就是为什么开场那个下午的措辞调整收效甚微:我在优化饼图里最小的那一块,而真正的问题出在"检索内容"里缺了一块。

五、上下文工程的四个基本动作

知道了上下文由什么组成,接下来的问题是怎么管理它。业界常用四个动作来概括:

flowchart TB CE(("上下文工程")) CE --- W["写入
把信息存到上下文外面
需要时再拿回来"] 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
  1. 写入:有些信息不需要一直放在上下文里,但需要的时候要能拿回来。比如一个长任务做到一半,把进度写成一份交接文档;项目的约定写进一个固定的文件,每次启动时自动加载。第 16、17 篇;
  2. 选择:不是所有可能有用的东西都要放进来,只放这一步需要的。比如不要一开始就把整个代码库塞进去,而是让 Agent 按需搜索、按需读取。第 18 篇;
  3. 压缩:放进来的东西尽量小。工具输出截断,只留关键部分;对话历史太长了,总结成一份摘要。第 28 篇;
  4. 隔离:不同的子任务用不同的上下文。比如让一个子 Agent 去读二十个文件,它的上下文用完就扔,只把结论带回主 Agent。第 30 篇。

还有一个贯穿始终的考虑:顺序。稳定的东西放前面,变化的东西放后面,这样缓存才能命中(第 10、37 篇);最重要的指令放在开头或者紧挨着问题(第 10 篇)。

接下来的四篇,加上第五部分的几篇,会把这四个动作一个一个落到代码里。先剧透一下它们在 mini-agent 里分别长什么样:

动作mini-agent 里的实现在哪里
写入启动时读工作目录下的 AGENTS.md,拼进 system promptagent/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,试着做这些修改:

  1. 多加几个工具:照着现有的三个,再加五个工具定义(随便编几个,比如 search_code、list_files、git_diff……),看看工具定义那一层涨了多少;
  2. 换一个大文件:把 internal/llm/demo.go 换成仓库里更大的文件,比如 agent/agent.go,或者 README.md;
  3. 把测试日志改成只有一行: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。

flowchart LR A["对的信息"] --> C["上下文"] B["对的时间"] --> C D["对的位置"] --> C C --> M(("模型")) N["无关的、过时的、重复的"] -.->|"挡在外面"| C style C fill:#fff3e0,stroke:#e65100 style N fill:#ffcdd2,stroke:#b71c1c

评论

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

发表评论

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