上下文工程这个词最近被聊得很多,但真正把它讲透的材料并不多。大多数人第一次听到"Context Engineering"会下意识觉得这不就是写提示词吗,换个高大上的名字而已。我一开始也这么想,直到自己动手搭了一个基于 ReAct 模式的 AI Agent,跑了几十个任务之后才发现,提示词只是冰山露出水面的那一角,真正决定 Agent 能不能稳定干活的,是水面下那套上下文组织、裁剪、注入和回收的机制。这篇内容就是把我这段时间在上下文工程上的踩坑、调试和总结完整摊开,从概念到落地,从原理到代码,尽量让刚接触 AI Agent 的朋友也能看懂,同时给已经在做 Agent 的同行一些可以直接抄的实操细节。
1. 为什么提示词工程不够用了
1.1 从"写一句话"到"管理一整个信息流"
早期玩大语言模型,大家的关注点都在提示词上:怎么措辞、怎么加角色设定、怎么用 few-shot 示例。这套方法在单轮问答场景里确实够用,你精心打磨一段提示词,模型就能给出不错的回答。但一旦进入 Agent 场景,情况完全变了。Agent 不是一问一答,它要循环执行:思考、调用工具、观察结果、再思考、再调用,直到任务完成。这个循环里,每一轮都会产生新的信息——模型的推理过程、工具返回的原始数据、中间结论、错误信息。这些信息全部要拼进下一次请求的上下文里。
问题就来了:模型的上下文窗口是有限的。你不可能把几十轮的工具调用结果原封不动全塞进去,token 会爆,成本会飙,而且模型在超长上下文里的注意力会稀释,关键信息反而被淹没。这时候你要做的就不再是"写一句好提示词",而是"管理一整个信息流"——哪些信息该留、哪些该压缩、哪些该丢弃、以什么顺序排列、用什么格式呈现。这就是上下文工程要解决的核心问题。
我打个比方。提示词工程像是给一个人写一张便签,告诉他该干什么。上下文工程像是给一个正在连续工作八小时的员工管理他的工作台:桌面上放哪些文件、哪些归档、哪些扔掉、当前手头这份材料怎么摆放最顺手。便签写得再好,工作台一团乱,活照样干砸。
1.2 上下文窗口不是越大越好
很多人有个误区,觉得现在模型上下文窗口都到 128K 甚至更大了,那还操心什么上下文管理,全塞进去不就完了。我实测下来的结论是:能塞不等于该塞。有几个很现实的原因。
第一是成本。上下文越长,每次请求的 token 消耗越大,而且是每一轮循环都要重新计费。一个跑二十轮工具调用的 Agent 任务,如果每轮都带着完整历史,token 消耗是线性甚至指数级增长的。第二是延迟,长上下文的首 token 延迟明显更高,Agent 的响应会变慢。第三也是最关键的,是效果。业界有不少测试表明,模型在超长上下文中间位置的信息召回率会下降,也就是所谓的"lost in the middle"现象。你把关键的工具返回结果埋在几万 token 的中间,模型很可能就忽略了。
所以上下文工程的第一性原则是:上下文不是越多越好,而是越精准越好。你要让进入模型窗口的每一段内容都有明确的存在理由。
1.3 上下文工程到底包含哪些工作
把话说具体一点,一个 Agent 的上下文工程通常要处理这几类事情:
- 系统提示的组织:角色设定、能力边界、工具说明、输出格式约束,这些是常驻的,但也要精简,不能无限膨胀。
- 对话历史的裁剪:多轮交互后,早期轮次怎么处理,是保留、摘要还是丢弃。
- 工具结果的注入:工具返回的原始数据往往很长很杂,需要清洗、截断、结构化后再放进上下文。
- 记忆的读写:长期记忆怎么存、什么时候检索出来、以什么形式拼进当前上下文。
- 动态信息的排序:把最重要的信息放在模型注意力最集中的位置,通常是开头和结尾。
这五件事,每一件都有讲究,下面我会逐个拆开讲。理解了这五件事,你就理解了上下文工程的全貌。
2. ReAct 循环里上下文是怎么一步步膨胀的
2.1 一个典型 ReAct Agent 的执行轨迹
要理解上下文工程为什么重要,最好的办法是看一个真实的执行轨迹。ReAct 是 Reasoning + Acting 的缩写,核心思路是让模型交替进行推理和行动。一个典型的循环长这样:
用户提问 -> 模型思考(Thought) -> 决定行动(Action) -> 调用工具 -> 得到观察(Observation) -> 模型再思考 -> ... -> 输出最终答案(Final Answer)假设用户问"帮我查一下北京今天天气,如果下雨就提醒我带伞"。Agent 的执行过程可能是:
- Thought:我需要先查天气
- Action:调用天气查询工具,参数 city=北京
- Observation:返回一大段 JSON,包含温度、湿度、降水概率、风力、空气质量等几十个字段
- Thought:降水概率 80%,会下雨,需要提醒带伞
- Final Answer:今天北京有雨,记得带伞
看起来很简单对吧。但注意第 3 步,工具返回的那一大段 JSON,如果原封不动塞进上下文,可能就占了几百甚至上千 token,而模型真正需要的只是"降水概率"这一个字段。这就是上下文工程要动手的地方。
2.2 每一轮都在做加法,token 悄悄失控
单看一轮没什么,但 Agent 任务往往是多步的。我做过一个测试,让 Agent 完成"分析一份销售数据并生成报告"的任务,它需要:读取文件、解析数据、计算统计量、生成图表、写报告,中间还穿插了几次自我纠错。整个任务跑了 18 轮工具调用。
如果每一轮都把完整历史带上,token 消耗是这样的:
| 轮次 | 单轮新增 token | 累计上下文 token |
|---|---|---|
| 第 1 轮 | 800 | 800 |
| 第 5 轮 | 1200 | 6500 |
| 第 10 轮 | 1500 | 18000 |
| 第 18 轮 | 900 | 42000 |
到第 18 轮,上下文已经累积到四万多 token,而其中真正对当前决策有用的信息可能不到两千 token。剩下的四万都是"历史包袱"。这还只是一个中等复杂度的任务,如果任务再长一点,很容易就顶到窗口上限,然后你就得面对截断、报错或者效果骤降。
2.3 膨胀的三个主要来源
我把上下文膨胀的来源归成三类,搞清楚来源才能对症下药。
第一类是工具返回的冗余数据。这是最大头。API 返回的 JSON 通常字段很多,还有嵌套结构、元数据、状态码等,模型真正需要的往往只有几个字段。不做清洗直接塞,等于让模型在一堆噪音里找信号。
第二类是重复的推理过程。模型每一轮的 Thought 都会累积,但很多早期的思考在后续轮次里已经失去价值了。比如第一轮"我需要先查天气"这种话,到第十轮完全没必要再带着。
第三类是失败的尝试记录。Agent 经常会走弯路,调用工具失败、参数传错、结果不符合预期,然后重试。这些失败记录对调试有用,但对模型继续完成任务往往是干扰,容易让它陷入重复错误的循环。
理解了这三个来源,上下文工程的手段就清晰了:清洗工具输出、压缩历史推理、处理失败记录。接下来逐个讲具体怎么做。
3. 工具返回结果的清洗与结构化
3.1 为什么不能把原始 API 响应直接喂给模型
我踩过的第一个大坑就是这个。当时写了个查询数据库的工具,返回的是完整的查询结果集,几十行记录,每行十几个字段。Agent 拿到之后经常"看花眼",明明问的是某个特定条件的数据,它却把不相关的行也写进了答案里。后来我把工具返回改成只返回必要字段,问题立刻缓解。
原因其实不复杂。模型处理信息是靠注意力机制,信息越多、越杂,单个信息分到的注意力权重就越低。你给它一坨原始数据,它得先花力气去理解这坨数据长什么样,再去找自己要的那部分,中间很容易出错。而如果你在工具层就把数据整理成"模型一眼能看懂"的形式,它的推理负担就小很多。
3.2 清洗的三个动作:截断、提取、格式化
我的做法是在工具返回结果进入上下文之前,加一层处理管道,做三件事。
截断:对超长的文本或列表,只保留前 N 条或者做摘要。比如搜索结果返回 100 条,我只取前 10 条,并明确告诉模型"这是前 10 条,共 100 条"。这样模型知道信息是不完整的,不会误以为这就是全部。
提取:从结构化数据里只挑出相关字段。比如天气 API 返回几十个字段,我只提取温度、天气状况、降水概率、风力四个,其余丢掉。提取逻辑可以写死,也可以让模型先判断需要哪些字段再提取,后者更灵活但更慢。
格式化:把数据整理成模型友好的形式。模型对 Markdown 表格、简洁的键值对、带标签的段落理解得最好。原始 JSON 的嵌套结构对模型其实不太友好,扁平化之后效果好很多。
下面是一个清洗前后的对比示例。原始工具返回:
{ "code": 200, "message": "success", "data": { "city": "北京", "updateTime": "2024-06-15T08:00:00Z", "weather": { "temp": 26, "feelsLike": 28, "condition": "多云转雷阵雨", "humidity": 72, "windSpeed": 15, "windDir": "东南风", "precipProbability": 0.8, "pressure": 1005, "visibility": 8, "uvIndex": 5, "aqi": 65 } } }清洗后进入上下文的形式:
天气查询结果(北京,2024-06-15 08:00): - 温度:26℃(体感 28℃) - 天气:多云转雷阵雨 - 降水概率:80% - 风力:东南风 15km/h同样的信息,token 数从两百多降到几十,而且模型一眼就能抓到"降水概率 80%"这个关键点。这个清洗动作看起来简单,但对 Agent 稳定性的提升非常明显。
3.3 给工具结果加上"元信息"
还有一个容易被忽略的细节:给工具结果加上元信息,告诉模型这段数据是什么、什么时候拿到的、是否完整。比如:
[工具:weather_query | 时间:08:00 | 状态:成功 | 数据完整性:完整] 天气查询结果(北京): - 温度:26℃ ...为什么要加这些?因为模型在多轮循环里很容易"忘记"某段数据是哪来的。加上来源和时间戳,模型在后续推理时能更准确地引用。尤其是当同一个工具被调用多次、返回不同时间点的数据时,元信息能帮模型区分新旧。
提示:元信息不要写太长,控制在十几个 token 以内。它的作用是给模型一个"锚点",不是写日志。
4. 对话历史的压缩策略
4.1 滑动窗口:最简单也最容易翻车
处理长对话历史,最直觉的做法是滑动窗口:只保留最近 N 轮,更早的直接丢掉。这个方法实现简单,但翻车点很多。
最大的问题是丢掉了关键信息。假设 Agent 在第三轮从用户那里得知了一个重要约束(比如"预算不超过五千"),到第十五轮做决策时,这个约束如果被滑出窗口,Agent 就可能给出超预算的方案。我遇到过好几次这种情况,Agent 前面明明确认过条件,后面却"忘了"。
所以滑动窗口不能无脑用,得配合一个机制:把关键信息从历史里"提"出来,单独存着,不随窗口滑动而丢失。这个机制就是下面要讲的摘要和记忆。
4.2 滚动摘要:把历史压成一段话
滚动摘要的思路是:当历史累积到一定长度,就让模型把早期历史总结成一段简短的摘要,用摘要替换原始历史。这样既保留了信息,又大幅压缩了 token。
具体做法我一般是这样:设定一个阈值,比如历史超过 10 轮或者超过 3000 token,就触发摘要。把最早的若干轮交给模型,让它输出一段不超过 200 字的摘要,重点保留:用户的核心诉求、已确认的约束条件、已完成的关键步骤、未解决的问题。然后用这段摘要替换掉那几轮原始对话。
摘要的提示词大概长这样:
请把以下对话历史压缩成一段不超过 200 字的摘要,必须保留: 1. 用户的核心目标和约束条件 2. 已经完成的关键操作及其结果 3. 尚未解决的问题 不要保留:寒暄、重复的推理、失败的尝试细节。 对话历史: {history}这里有个经验:摘要一定要明确"保留什么、丢弃什么",否则模型会平均用力,把废话也摘要进去。我一开始没写清楚,摘要出来还是又臭又长,后来加了明确的保留/丢弃清单,效果好很多。
4.3 分层记忆:短期、中期、长期分开管
滚动摘要解决了历史压缩,但还有个问题:有些信息是贯穿整个任务的,不该被摘要掉。比如用户的身份、任务的最终目标、一些硬性约束。这些信息我倾向于单独存成"长期记忆",每轮都注入上下文,不参与摘要和滑动。
我的分层做法是这样的:
| 记忆层级 | 内容 | 生命周期 | 注入方式 |
|---|---|---|---|
| 短期 | 最近几轮对话 | 随窗口滑动 | 完整保留 |
| 中期 | 早期历史的摘要 | 任务期间 | 摘要形式 |
| 长期 | 目标、约束、用户偏好 | 跨任务 | 每轮常驻注入 |
这样分层之后,上下文的结构就很清晰:长期记忆打底,中期摘要补充背景,短期对话提供最新状态。模型既不会忘掉大目标,也不会被历史细节淹没。
4.4 摘要的时机比摘要的方法更重要
这里分享一个我踩过的坑。一开始我是"定时摘要",每 10 轮触发一次。结果发现有时候正好在任务关键节点触发摘要,把刚拿到的关键结果给摘要没了,Agent 直接跑偏。
后来我改成"事件驱动摘要":不在固定轮次触发,而是在特定事件发生时触发,比如一个子任务完成、工具连续失败、上下文接近阈值。这样摘要的时机更合理,不会打断正在进行的推理链。
判断上下文是否接近阈值,可以估算 token 数。粗略的估算方法是:英文大约 4 个字符 1 个 token,中文大约 1.5 个字符 1 个 token。精确一点可以用对应模型的 tokenizer 来算。我一般留 20% 的余量,到 80% 就触发压缩。
5. 系统提示与工具描述的精简之道
5.1 系统提示是常驻成本,必须抠
系统提示每一轮都要带上,所以它的长度直接乘以轮次就是总成本。一个 2000 token 的系统提示,跑 20 轮就是 4 万 token 的纯开销。所以系统提示必须精简,能一句话说清就别用三句。
但精简不等于简陋。系统提示里该有的东西一个不能少:角色定位、能力边界、工具使用规范、输出格式要求、安全约束。关键是怎么用最少的字说清楚。
我的经验是:用清单代替段落,用例子代替描述。比如描述输出格式,与其写一大段"你的输出应该包含思考过程和最终答案两部分,思考过程用 Thought 标记……",不如直接给一个格式示例:
输出格式: Thought: <你的推理> Action: <工具名> Action Input: <参数> 或 Thought: <你的推理> Final Answer: <最终答案>一个示例顶得上一段描述,而且模型照着抄不会错。
5.2 工具描述要写"什么时候用"而不是"是什么"
工具描述也是常驻上下文的一部分,尤其是工具多的 Agent,光工具描述就能占掉一大块。很多人写工具描述只写功能,比如"weather_query:查询天气"。这不够,模型不知道该在什么时候调用它。
好的工具描述应该包含:这个工具解决什么问题、什么场景下用、输入参数的含义和格式、返回什么。但也要控制长度。我的写法是:
weather_query(city: str) -> str 查询指定城市的实时天气。当用户询问天气、需要根据天气做决策时使用。 参数 city 为城市名,如"北京"。 返回温度、天气状况、降水概率、风力。这样模型既知道功能,也知道触发时机,还知道参数怎么填。比单纯写"查询天气"强太多。
5.3 工具太多时的分组与按需加载
当 Agent 有几十个工具时,把所有工具描述都塞进系统提示会非常臃肿。这时候可以做工具分组,或者按需加载。
按需加载的思路是:先给模型一个工具目录(只有名字和一句话说明),当模型决定要用某类工具时,再把该类工具的详细描述注入上下文。这样常驻的只有目录,详细描述按需出现,能省不少 token。
不过按需加载会增加一轮交互,适合工具特别多、调用不频繁的场景。工具不多的话,直接全量注入更简单可靠。这个取舍要看具体场景,没有标准答案。
6. 上下文的排序与注意力引导
6.1 把最重要的信息放在开头和结尾
前面提到过"lost in the middle"现象,模型对上下文开头和结尾的信息注意力最强,中间容易被忽略。这个特性直接影响上下文的排序策略。
我的排序原则是:最关键的信息放开头,次关键的信息放结尾,中间放背景和辅助信息。具体到 Agent 上下文:
- 开头:系统提示、当前任务目标、硬性约束
- 中间:历史摘要、工具结果、背景资料
- 结尾:最近一轮的观察结果、当前待解决的问题
这样模型在生成下一步推理时,既能看到大目标(开头),又能看到最新状态(结尾),决策质量明显更高。
6.2 用分隔符和标签划清边界
上下文里信息一多,模型容易混淆哪段是哪段。这时候分隔符和标签就很重要。我习惯用清晰的分隔线加标签:
=== 任务目标 === 帮用户分析销售数据并生成报告 === 历史摘要 === 用户提供了 2024 年 Q1 的销售数据,已完成数据读取和清洗... === 最近操作 === [工具:read_file | 状态:成功] 读取到 1200 行销售记录,字段包括:日期、产品、销量、金额、地区 === 当前问题 === 计算各地区的销售总额这种结构化的呈现方式,模型理解起来毫不费力,也不会把不同来源的信息搞混。相比之下,把所有内容用换行堆在一起,模型就得多花力气去分辨边界。
6.3 动态调整:根据任务阶段改变上下文重心
上下文的重心不是一成不变的,要跟着任务阶段走。任务刚开始时,重点是目标和初始信息;任务进行中,重点是最近的操作结果;任务收尾时,重点是汇总所有关键结论。
我一般会在 Agent 里加一个简单的阶段判断:如果还没开始调用工具,就多给目标和背景;如果已经调用了几个工具,就压缩早期历史,突出最近结果;如果模型开始输出 Final Answer,就确保所有关键结论都在上下文里可见。这个动态调整能让上下文始终"贴合当前需要",而不是一成不变。
7. 实操中踩过的坑与调试方法
7.1 上下文污染:一个错误结果带偏整条链
这是我遇到的最隐蔽的坑。有一次 Agent 调用一个数据接口,接口临时返回了错误数据(某个字段是 null),Agent 基于这个错误数据继续推理,后面所有结论都建立在错误前提上,最后输出了一份完全错误的报告。而我在调试时只看了最终输出,根本没意识到问题出在中间某一轮的工具返回。
这个坑的本质是"上下文污染":一个错误信息进入上下文后,会像病毒一样影响后续所有推理。解决办法有两个。一是在工具层做校验,返回结果不符合预期就不放进上下文,而是返回一个明确的错误提示让模型重试。二是在关键节点做"事实核查",让模型在继续之前先确认已有信息是否自洽。
7.2 循环陷阱:模型反复调用同一个工具
另一个常见问题是模型陷入循环,反复调用同一个工具,每次参数还差不多。这通常是因为工具返回的结果没有满足模型的预期,它以为再调一次就能拿到想要的东西。
排查这个问题的思路是看上下文里工具结果的呈现方式。如果结果被截断得太狠,模型看不到它要的信息,就会一直重试。如果结果格式混乱,模型理解不了,也会重试。我的处理办法是:在工具结果里明确标注"这是完整结果"或"结果已截断,仅显示前 N 条",并在连续失败两次后强制注入一条提示,告诉模型"该工具已连续失败,请换一种方式或直接基于现有信息作答"。
7.3 调试上下文:把每一轮的完整上下文打出来
调试 Agent 最有效的方法,没有之一,就是把每一轮实际发给模型的完整上下文打印出来。很多人调试只看模型的输出,但问题往往出在输入。你把输入打出来一看,经常能立刻发现问题:哦,原来关键信息被摘要掉了;哦,原来工具结果格式乱了;哦,原来历史里混进了上一次任务的残留。
我习惯在开发阶段把每轮上下文存成文件,出问题时逐个对比。这个习惯帮我定位了至少一半的诡异 bug。生产环境可以只存摘要和关键字段,避免存储爆炸。
7.4 一个实用的上下文健康度检查清单
跑了一段时间之后,我总结了一个检查清单,每次 Agent 表现异常时按这个过一遍:
- 当前任务目标是否在上下文开头清晰可见?
- 硬性约束是否还在上下文里(没被摘要掉)?
- 最近一轮工具结果是否完整、格式是否清晰?
- 是否有失败记录在干扰模型?
- 上下文总长度是否接近阈值,需要压缩?
- 是否有上一次任务的残留信息?
这个清单看起来简单,但能覆盖大部分常见问题。养成习惯之后,排查效率高很多。
8. 上下文工程和提示词工程、记忆系统的边界
8.1 三者不是一回事,但紧密咬合
聊到这里,有必要把几个容易混淆的概念理清楚。提示词工程关注的是"怎么把话说清楚",是静态的、一次性的。上下文工程关注的是"每一轮该给模型看什么",是动态的、贯穿整个任务生命周期的。记忆系统关注的是"信息怎么存和取",是上下文工程的一个子模块。
三者的关系可以这样理解:记忆系统负责把信息存起来,上下文工程负责决定每一轮从记忆里取什么、怎么组织、怎么呈现,提示词工程负责把取出来的内容用模型最容易理解的方式表达。三者配合,Agent 才能稳定工作。
8.2 什么时候该上上下文工程
不是所有 Agent 都需要复杂的上下文工程。如果任务很短,三五轮就结束,工具返回也不大,那简单拼接就够了,过度设计反而增加复杂度。
我的判断标准是:当 Agent 任务超过 10 轮,或者工具返回数据量大,或者需要跨任务记住信息时,就该认真做上下文工程了。这几个条件满足任意一个,上下文管理带来的收益就会超过它的实现成本。
8.3 一个渐进式的落地路线
如果你刚开始做 Agent,我建议按这个顺序逐步引入上下文工程:
- 先做工具结果清洗,这是投入产出比最高的一步,改动小、见效快。
- 再加滑动窗口加关键信息提取,解决历史膨胀。
- 然后引入滚动摘要,进一步压缩。
- 最后做分层记忆和动态排序,应对复杂长任务。
每一步都能独立带来改善,不用一次性全上。我当初就是一步步加的,每加一层都能明显感觉到 Agent 更稳了。
9. 关于上下文工程的一些个人体会
做了一段时间上下文工程,最大的感受是:它本质上是一个"信息取舍"的活儿,而不是"技术堆砌"的活儿。你不需要多复杂的框架,核心就是想清楚每一轮模型到底需要什么信息,然后想办法把不需要的拿掉、把需要的放对位置。
我见过不少人一上来就搞很复杂的记忆架构、向量检索、多级缓存,结果基础的工具结果清洗都没做好,Agent 照样跑不稳。顺序反了。先把最基础的上下文卫生做好,再考虑高级玩法。
另外一个体会是,上下文工程非常依赖实测。同样一套策略,在不同模型、不同任务上效果可能差很多。所以别迷信任何"最佳实践",包括我上面写的这些,都要拿到自己的场景里跑一遍,看数据说话。我上面提到的所有数字和阈值,都是我在自己场景里调出来的,你直接抄可能不合适,但思路可以借鉴。
最后说个细节:上下文工程做得好不好,一个很直观的信号是 token 消耗曲线。如果随着任务推进,每轮 token 增长很平缓,说明你的压缩和清洗做到位了;如果曲线陡峭上升,那多半有地方在漏。把这个曲线监控起来,比看任何指标都直观。