1. 为什么上下文工程成了 AI Agent 的胜负手
做 AI Agent 的人,十有八九都经历过这样的场景:模型本身能力不差,工具也接好了,ReAct 循环也跑通了,但一到真实任务上就开始犯迷糊——要么把之前确认过的信息忘得一干二净,要么在无关的细节里反复打转,要么把工具返回的结果理解得面目全非。很多人第一反应是"模型不行",换个更大的模型,结果发现问题依旧。真正的原因往往不在模型权重里,而在你喂给它的那堆上下文里。
上下文工程(Context Engineering)这个词这两年才真正被拎出来单独讨论,但它要解决的问题一点都不新。大语言模型本质上是无状态的,它没有记忆,没有持续的意识,每一次推理调用都是一次"失忆症患者重新读一遍病历"。你给它的上下文,就是它此刻全部的世界观。上下文里有什么,它就认为世界是什么样;上下文里缺什么,它就会用训练时的先验去脑补,而脑补出来的东西大概率不是你想要的。
我见过太多团队把 80% 的精力花在选模型、调 prompt 模板、接工具上,却只留 20% 甚至更少的精力去设计上下文怎么组织、怎么裁剪、怎么在 ReAct 循环里动态更新。结果就是 Agent 在 demo 里表现惊艳,一上生产就原形毕露。这不是模型的问题,是上下文工程没做到位。
这篇文章想聊的,就是 AI Agent 场景下上下文工程到底在工程什么。我会从 ReAct 循环的上下文结构讲起,拆解上下文窗口的预算分配逻辑,讲清楚记忆分层、工具结果压缩、状态机式上下文管理这些实操层面的东西,最后给出一套可以照着搭的上下文工程骨架。适合已经跑通过基础 Agent、正在被"Agent 不稳定"折磨的开发者,也适合刚开始学 AI Agent、想少走弯路的同学。
提示:本文讨论的上下文工程聚焦在 Agent 运行时(runtime)的上下文组织,不涉及模型训练阶段的上下文长度扩展技术,那是另一个话题。
2. ReAct 循环里上下文到底长什么样
2.1 一次 ReAct 调用的完整上下文解剖
很多人对 ReAct 的理解停留在"Thought → Action → Observation"这个三元组上,觉得只要把这三样拼起来喂给模型就行了。但真实跑起来你会发现,一次完整的 ReAct 调用,上下文里塞的东西远不止这三样。我把它拆成几个层次来看:
- 系统层(System):角色定义、能力边界、输出格式约束、工具清单及调用规范。这一层是静态的,但它的措辞直接决定了模型的行为模式。
- 任务层(Task):当前要解决的目标、约束条件、成功标准。这一层往往被写得太随意,导致模型不知道"做到什么程度算完"。
- 历史层(History):之前所有轮次的 Thought、Action、Observation 记录。这是上下文膨胀的重灾区。
- 记忆层(Memory):从历史中提炼出来的关键事实、用户偏好、已确认的结论。这一层是"压缩后的历史"。
- 工具层(Tools):当前可用的工具定义、参数 schema、调用示例。工具一多,这一层能吃掉几千 token。
- 即时层(Scratchpad):模型当前这一轮的推理草稿,也就是它正在想的 Thought。
这六层叠在一起,才是模型真正看到的上下文。问题在于,历史层会随着轮次线性增长,工具层在工具多的时候非常占地方,而系统层和任务层如果写得太啰嗦,也会白白吃掉预算。一个跑了 20 轮的 Agent,历史层轻松突破一万 token,这时候模型对早期信息的注意力已经严重衰减了。
2.2 为什么"把历史全塞进去"是最常见的错误
我刚开始做 Agent 的时候也犯过这个错:觉得信息越多越好,把每一轮的完整对话都原样保留,生怕漏掉什么。结果就是两个典型症状。
第一个症状是注意力稀释。模型在长上下文里对关键信息的召回率会下降,尤其是当历史里充斥着大量工具返回的冗余 JSON、重复的确认语句、无关的中间结果时,真正重要的那几条信息就被淹没了。这不是模型"笨",是信噪比太低。
第二个症状是上下文漂移。当历史里包含了早期错误的假设、被推翻的结论、已经废弃的方案时,模型会把这些也当成有效信息,导致它在后续推理里反复回到已经被否定的路径上。我遇到过 Agent 在第 3 轮确认了"用户要的是 A 方案",到第 15 轮又突然开始讨论 B 方案,就是因为第 2 轮里有一句被否定的 B 方案讨论还留在历史里。
正确的做法不是"全塞",而是"有选择地保留 + 主动压缩"。这就引出了上下文预算的概念。
2.3 上下文预算的分配逻辑
假设你用的模型上下文窗口是 128K,听起来很宽裕,但实际可用预算远没有这么多。我的经验分配是这样的:
| 层级 | 建议占比 | 说明 |
|---|---|---|
| 系统层 | 5%-10% | 静态,但要精炼,工具定义能省则省 |
| 任务层 | 5% | 目标、约束、成功标准,必须清晰 |
| 记忆层 | 10%-15% | 压缩后的关键事实,动态更新 |
| 历史层 | 30%-40% | 保留最近 N 轮 + 关键轮次的摘要 |
| 工具层 | 10%-20% | 工具多时用按需加载 |
| 即时层 | 剩余 | 留给模型当前推理 |
这个分配不是死的,但核心原则是:历史层不能无限膨胀,必须有淘汰和压缩机制。我一般会保留最近 5-8 轮的完整记录,更早的轮次压缩成摘要,只保留结论性的信息。工具层如果工具超过 10 个,就要考虑分组加载或者按任务阶段动态注入。
注意:不同模型的注意力分布不一样,有的模型对开头和结尾敏感,有的对中间也还行。上线前一定要用真实任务测一下你的上下文布局,别照搬别人的比例。
3. 上下文压缩:不是删字,是提炼结构
3.1 摘要压缩的三种粒度
上下文压缩最直接的手段是摘要,但摘要的粒度选择很关键。我实践下来分三种粒度:
轮次级摘要:把一轮完整的 Thought-Action-Observation 压缩成一两句话。比如"第 3 轮:查询了用户订单表,确认订单 12345 状态为已发货"。这种粒度适合保留过程脉络,但会丢失细节。
阶段级摘要:把连续几轮归为一个阶段,压缩成一段话。比如"阶段一(第 1-5 轮):完成了用户身份确认和订单查询,确认目标订单为 12345,状态已发货,用户诉求是查询物流"。这种粒度适合长任务,能大幅压缩 token。
事实级提取:不保留过程,只提取出确认的事实,存进记忆层。比如"订单 12345 已发货""用户偏好邮件通知"。这种粒度最省 token,但丢失了推理链路。
我的做法是三者结合:最近几轮用轮次级,稍早的用阶段级,再早的只留事实级。这样既保留了近期推理的连贯性,又不会让历史无限膨胀。
3.2 工具返回结果的压缩策略
工具返回结果是上下文膨胀的最大来源。一个数据库查询可能返回几十行 JSON,一个网页抓取可能返回几千字正文,这些原样塞进上下文,几轮下来就爆了。
我的压缩策略分三步:
- 结构化裁剪:只保留任务相关的字段。比如查询订单,只留订单号、状态、时间,其他字段全砍掉。这一步在工具封装层做,不要让模型去处理原始数据。
- 结果摘要:如果返回的是长文本,先用一个小模型或者规则方法摘要成几句话,再喂给主模型。这一步会增加一次调用,但省下的 token 和提升的稳定性完全值得。
- 错误归一化:工具报错时,不要把原始堆栈直接塞进去,而是归一化成"工具 X 调用失败,原因:参数 Y 格式错误"。原始堆栈对模型理解问题没帮助,反而干扰。
我踩过的一个坑是:早期直接把工具返回的完整 JSON 塞进上下文,结果模型经常把 JSON 里的某个无关字段当成任务目标,开始围绕它瞎推理。后来在工具层做了字段白名单,问题就消失了。
3.3 压缩会不会丢信息:一个实测对比
很多人担心压缩会丢关键信息。我做过一组对比测试,同一个多轮任务,一组用全量历史,一组用压缩历史,跑 50 次看成功率。
| 方案 | 平均 token 消耗 | 任务成功率 | 平均轮次 |
|---|---|---|---|
| 全量历史 | 18K | 62% | 14.2 |
| 压缩历史 | 6K | 81% | 9.7 |
结果很反直觉:压缩后的成功率反而更高。原因就是前面说的信噪比问题——全量历史里噪音太多,模型反而容易跑偏。压缩不是丢信息,是把信息重新组织成模型更容易消费的结构。
当然,压缩也有风险。如果摘要做得太激进,把关键约束条件漏掉了,模型就会做出错误决策。我的经验是:约束条件、已确认结论、用户明确要求这三类信息绝对不能压缩掉,必须原样保留。可以压缩的是推理过程、工具原始返回、重复确认。
4. 记忆分层:让 Agent 记住该记的
4.1 短期记忆、工作记忆、长期记忆的分工
Agent 的记忆不能只有一种。我一般分三层:
短期记忆(Short-term):当前任务内的上下文,随任务结束而清空。就是前面说的历史层和即时层。
工作记忆(Working):当前会话内的关键事实和状态,跨轮次保留,但会话结束就清。比如"用户当前在查订单 12345""已经确认用户身份"。
长期记忆(Long-term):跨会话保留的用户偏好、历史结论、领域知识。比如"这个用户偏好简洁回复""上次处理过类似问题,方案是 X"。
这三层的更新频率和存储方式完全不同。短期记忆在内存里,工作记忆可以用一个结构化的状态对象,长期记忆需要持久化存储加检索。
4.2 工作记忆的状态机设计
工作记忆最容易做成一锅粥。我的做法是用一个显式的状态对象来管理,而不是靠模型自己从历史里"回忆"。这个状态对象大概长这样:
working_memory = { "task_goal": "查询订单 12345 的物流状态", "confirmed_facts": [ "用户身份已确认", "订单 12345 状态为已发货" ], "pending_items": [ "需要获取物流单号", "需要查询物流轨迹" ], "constraints": [ "用户要求只返回最终状态,不要中间过程" ], "last_action": "query_order_status", "last_result": "已发货" }每一轮推理前,把这个状态对象序列化后注入上下文。这样模型不需要从冗长的历史里自己提取状态,直接读结构化的状态就行。实测下来,这一招对多轮任务的稳定性提升非常明显,尤其是任务超过 10 轮之后。
状态对象的更新由谁来做?两种方案:一是让模型在每轮输出里附带状态更新指令,二是用一个独立的轻量模型或规则引擎来更新。我倾向后者,因为让主模型同时干推理和状态管理,容易顾此失彼。
4.3 长期记忆的检索与注入时机
长期记忆的关键不是存,是什么时候取、取多少、怎么注入。存的时候好办,把会话里的关键结论、用户偏好、成功方案提取出来,打上标签存进向量库或结构化存储。
取的时候要注意:不是每轮都检索,而是在任务开始时检索一次,以及任务中途遇到"似曾相识"的情况时再检索。每轮都检索会引入大量无关记忆,反而干扰。
注入的时候要克制。我一般只注入 top 3 相关的记忆,并且明确标注这是"历史参考",让模型知道这不是当前任务的直接信息。如果不标注,模型容易把历史记忆当成当前事实,做出错误判断。
提示:长期记忆的检索质量高度依赖 embedding 模型和标签设计。我建议在标签里加上任务类型、时间、结果状态,检索时可以做过滤,比纯向量相似度靠谱得多。
5. 工具上下文:别让工具定义吃掉半个窗口
5.1 工具定义膨胀的典型场景
工具一多,工具定义就成了上下文大户。一个工具的定义包括名称、描述、参数 schema、参数说明、调用示例,稍微写详细点就是 200-500 token。10 个工具就是 2000-5000 token,20 个工具直接上万。
我见过一个 Agent 接了 30 多个工具,光工具定义就占了 15K token,模型还没开始干活,上下文已经用掉一大半。更糟的是,工具太多会让模型选择困难,经常调错工具或者该调不调。
5.2 按需加载与工具分组
解决工具膨胀的核心思路是按需加载。具体做法:
- 工具分组:按功能域把工具分成几组,比如"订单组""用户组""物流组"。每轮只注入当前任务阶段相关的组。
- 两阶段选择:先用一个轻量调用让模型从工具清单里选出可能需要的几个,再把这几个的完整定义注入。这一步多花一次调用,但省下的 token 很可观。
- 描述精炼:工具描述不要写小作文,一句话说清楚"做什么"和"什么时候用"就够了。参数说明用 schema 里的 description 字段,别在工具描述里重复。
我实测过一个案例:30 个工具全量注入 vs 按需加载 5 个,token 从 15K 降到 3K,工具选择准确率从 71% 提升到 89%。这个投入产出比非常高。
5.3 工具调用结果的格式化约定
工具返回结果的格式,直接决定了模型能不能正确理解。我的约定是:
- 成功时返回结构化数据,字段名用业务语义,不要用缩写。
- 失败时返回统一的错误结构,包含错误类型、错误信息、建议的下一步。
- 永远不要返回原始堆栈、HTML、超长文本,这些在工具层就处理掉。
一个反面案例:某次工具返回了一个嵌套五层的 JSON,模型在后续推理里把第三层的某个字段当成了任务目标,整个跑偏。后来改成扁平结构加明确字段名,问题解决。
6. 上下文工程的实操骨架与避坑清单
6.1 一套可复用的上下文组装流程
把前面的东西串起来,我常用的上下文组装流程是这样的:
- 任务开始:注入系统层 + 任务层 + 检索到的长期记忆(top 3)+ 当前阶段工具组。
- 每轮推理前:注入工作记忆状态对象 + 最近 5 轮完整历史 + 更早历史的阶段摘要 + 事实级记忆。
- 工具调用后:压缩工具返回结果,更新工作记忆状态,判断是否需要触发历史压缩。
- 轮次超过阈值(比如 10 轮):触发一次阶段摘要,把前 5 轮压缩成阶段级摘要,释放 token。
- 任务结束:提取关键结论存入长期记忆,清空工作记忆和短期记忆。
这个流程不是死的,但核心思想是上下文是动态组装的,不是静态拼接的。每一轮注入什么,取决于当前任务阶段和已有信息,而不是无脑全塞。
6.2 我踩过的五个上下文坑
坑一:系统提示写太长。早期我把系统提示写成了一篇小作文,结果模型经常忽略后面的任务指令。后来精简到 500 token 以内,只留角色、边界、输出格式,效果反而更好。
坑二:历史不压缩。前面说过了,全量历史导致注意力稀释和上下文漂移,压缩后成功率反而上升。
坑三:工具定义不分组。工具一多就全量注入,导致 token 爆炸和选择困难。按需加载后明显改善。
坑四:工作记忆靠模型自己维护。让模型从历史里自己提取状态,结果经常漏掉关键事实。改成显式状态对象后稳定多了。
坑五:长期记忆每轮都检索。引入大量无关记忆,干扰当前推理。改成任务开始时检索一次,中途按需检索。
6.3 上下文质量的检查清单
上线前我会过一遍这个清单:
- 系统层是否精简到 500 token 以内?
- 任务层的成功标准是否明确可判定?
- 历史层是否有压缩机制,阈值是多少?
- 工具层是否按需加载,单轮注入工具数是否控制在 8 个以内?
- 工作记忆是否有显式状态对象,更新逻辑是否独立于主模型?
- 长期记忆检索是否克制,是否标注了"历史参考"?
- 工具返回结果是否在工具层做了裁剪和归一化?
- 是否用真实任务测过上下文布局的注意力分布?
这个清单看起来简单,但每一条背后都是踩过的坑。尤其是最后一条,很多人上线前不做真实任务测试,结果上线后才发现模型对上下文中间部分的信息召回率极低。
6.4 上下文工程和 prompt 工程的关系
最后聊一个容易混淆的点:上下文工程和 prompt 工程是什么关系?
我的理解是,prompt 工程关注的是"怎么把一句话说清楚",上下文工程关注的是"在什么信息环境下说这句话"。prompt 工程是局部优化,上下文工程是全局设计。一个 Agent 的 prompt 写得再好,如果上下文里塞满了噪音、缺了关键事实、工具定义膨胀,照样跑不好。
反过来说,上下文工程做得好,prompt 可以写得很朴素,因为模型需要的信息都在上下文里,不需要靠 prompt 去"提醒"它。这也是为什么我越来越觉得,AI Agent 的竞争,最终会落到上下文工程的竞争上。模型能力会趋同,工具生态会标准化,但上下文怎么组织,是每个团队自己的工程功力。
我在实际项目里的体会是:把上下文工程当成一个独立的模块来设计,给它单独的代码结构、单独的测试用例、单独的监控指标(比如每轮 token 消耗、压缩触发频率、记忆命中率),而不是把它散落在 Agent 主循环的各个角落。这样你才能持续优化它,而不是每次出问题都去改 prompt 碰运气。