最近好几个朋友找我聊Agent开发,上来第一句话都是"帮我看看这个Prompt怎么写更好"。我理解这种思路,毕竟Prompt Engineering这个概念火了好几年,大家习惯了在提示词上死磕。但你真去跑一个多步骤的Agent项目就会发现,Prompt调得再漂亮,效果上限也被上下文管理卡得死死的。真正让Agent表现拉开差距的,是Context Engineering——上下文工程。
我这不是在否定Prompt,而是想说明一件事:Agent不是单轮问答,它是一个连续执行、反复决策的过程。在这个过程中,上下文是动态的、会被污染、会过期、会互相干扰。你精心设计的系统提示词可能只占几千token,但工具返回结果、多轮对话历史、检索回来的文档块、Agent自己的中间推理,这些东西加起来能把上下文窗口塞得满满当当。窗口一满,模型的行为就开始失控。
这篇文章不打算讲太多理论,主要结合我自己做Agent项目的实操经验,聊清楚Context Engineering到底在解决什么问题,以及一套能直接落地的处理框架。如果你想做的是那种"能真正干活"的Agent,而不是Demo里的玩具,这篇文章应该能帮你少踩不少坑。
1. Prompt那套打法,到了Agent身上为什么失灵了
1.1 单轮问答里的"静态上下文"思维
传统Prompt Engineering有一个基本的隐含假设:你给模型一段输入,模型给一段输出,然后结束。在这种模式下,上下文是静态的,你可以在写Prompt之前就精确控制输入里有什么、没什么、顺序是什么、该怎么强调重点。
所以你会发现很多Prompt技巧都是围绕"静态文本"展开的:角色设定怎么写、few-shot示例放几条、输出格式怎么约束、关键词怎么强调。这些技巧在单轮场景下确实有效,因为它们本质上是在帮你把一份固定的输入材料组织得更好。
但Agent不一样。Agent要完成的任务往往需要多步决策,每一步都可能调用工具、读取数据、产生中间结果,然后把新的信息追加到对话里。你写Prompt的时候压根不知道运行时会进来什么内容。换句话说,在Agent场景里,上下文不是一份你可以提前拟好的草稿,而是一锅随时间不断加料的汤。你的Prompt只是最初放进去的那把盐,后续的食材才是味道的关键。
1.2 Agent的上下文是流动的水,不是静止的池子
我见过太多人把Prompt当"咒语",以为系统提示词写得足够详细,Agent就能稳定完成任务。实际上,系统提示词在完整上下文里的占比通常不到5%。剩下95%的内容,全是在运行过程中动态生成的:用户输入、Agent的历史思考、工具返回的JSON、检索到的文本块、上一步的报错信息。
这些动态内容有几个特性,是静态Prompt完全没办法预处理的。
第一是时效性。对话早期的一条信息,可能在第十步已经过时了。比如Agent第一步查了用户所在城市的天气,第五步要做路线规划时,这条天气信息已经不重要了,但它还躺在上下文里占位置。第二是相关性差异。工具返回结果往往包含大量字段,真正对下一步决策有用的可能只有一两个字段,其余全是噪音。第三是相互干扰。不同来源的信息可能互相矛盾,比如检索到的文档说方案A可行,但工具执行的报错又显示方案A不行,这两个信号同时出现在上下文里,模型就容易犯迷糊。
1.3 一个最典型的翻车现场:检索内容冲垮了系统指令
我举个例子,这是我早期做RAG类Agent时候真实遇到的情况。系统提示词里明确写了"必须使用工具查询后再回答,禁止根据内部知识臆测",Prompt调了好几版,语气、强调、few-shot都试过,仍然会在某些问题上直接"裸答"。
一开始我以为是Prompt不够强,继续往系统提示词里堆规则。后来把整个运行时上下文dump出来一看,才发现问题压根不在这儿:RAG检索回来的内容里包含了大量百科式的背景介绍,这些背景介绍在信息组织上和"直接回答"的样例非常接近,模型在长上下文中被这些样例"带跑"了,优先模仿了检索内容的格式,而忽略了系统提示词里的约束。
这个案例特别典型,因为它说明了一个关键问题:在你运行Agent的时候,决定模型行为的不是"你写了什么",而是"模型最终看到的完整上下文长什么样"。上下文里有10条"应该这样做"的指令,但只要有一段足够长的"实际输出样板"在上下文里,模型就会倾向于模仿那段样板。这是注意力机制带来的天然倾向,靠堆Prompt是压不住的。
2. Context Engineering到底在管理什么:四个绕不开的核心问题
2.1 上下文生命周期:注入、更新、衰减、移除
Context Engineering做的第一件事,是把上下文当作有生命周期的对象来管理,而不是一条流水账。信息进入上下文只是第一步,你还得决定它什么时候该被更新、什么时候该被弱化、什么时候该干脆移除。
我常用的一个比喻是:上下文跟人的工作台面一样。桌面就那么大,你会在手边放正在用的东西,做完一件事就得把用过的材料收走,再放上新的材料。如果桌面上的东西只进不出,用不了十分钟,你要找一把螺丝刀都得翻过三摞文件。LLM的上下文窗口本质上就是工作台面,而且它比人的桌面更死板——人的桌面至少还是三维的、可以叠放,LLM的上下文是线性的,只能从左往右读,靠前和靠后的位置信息价值完全不同。
所以在设计一个Agent时,我会明确给每类上下文定义好生命周期:
- 系统指令和全局约束:整个任务期间保持不变,放在最前面,占固定的预算。
- 用户核心目标:随对话推进保持稳定,但可以被进一步澄清后更新。
- 工具返回结果:只在当前步骤和紧邻的几步有效,用完后应当被压缩或移除。
- 中间推理过程:如果用了CoT类方法,历史步骤可以只保留结论,不保留全部推理细节。
- 检索回来的文本块:只有当它们被引用时才有意义,没有被引用的文本块应该尽快清理出主上下文。
2.2 记忆分层:工作记忆、情景记忆、语义记忆
人脑的记忆不是铁板一块,LLM Agent的记忆也不该是。我在项目里会把记忆拆成三层来管理。
工作记忆对应当前任务正在处理的信息,放在上下文窗口里,量最小、最精确,比如当前的中间状态、刚拿到的关键工具结果。情景记忆对应过去几轮对话或过去几个相似任务中的具体经历,比如"在上一次运行时,用户拒绝了那种方案,原因是价格太高",这类记忆会对当前决策产生影响,但不需要每条都留在主上下文里,可以放到外部存储或者摘要中。语义记忆对应长期稳定的领域知识和规则,比如业务规范、用户偏好、常见处理流程,这些可以做成向量库或者结构化知识库。
这三层记忆的读取频率和写入频率差别很大,管理方式也应该不同。工作记忆每步都可能读写,必须常驻上下文;情景记忆在每次任务开始时重建,任务中按需读取;语义记忆只在特定场景触发读取,平时不占上下文。很多翻车事故,根源就是把这三层记忆混在一个大列表里,什么都往主上下文里塞。
2.3 上下文预算:窗口是稀缺资源,分配决定上限
上下文窗口是Agent最稀缺的资源。把窗口当成资源来规划,而不是当成垃圾桶来堆,是Context Engineering和朴素Prompt Engineering的分水岭。
我会给一个典型的Agent任务设置上下文预算表。比如以128K窗口为例,一个稳妥的分配方案可能是:
| 区块 | 预算占比 | 说明 |
|---|---|---|
| 系统指令与全局约束 | 5% - 10% | 角色、输出格式、安全规则、工具说明 |
| 当前任务状态 | 10% - 15% | 用户目标、已完成步骤、当前待办 |
| 工具返回与外部数据 | 30% - 40% | 需要被模型感知的事实性材料 |
| 对话历史与记忆摘要 | 20% - 30% | 经过摘要压缩的过往信息 |
| 输出规划与暂存区 | 10% - 15% | 给模型留出思考和生成的空间 |
这个比例不是固定的,但有一件事是固定的:你需要知道每个区块什么时候会超支。超支意味着你要压缩或者丢弃某些内容,这时候你要有预案,而不是等到窗口写满了再让程序报错。
2.4 检索质量:召回的不是越多越好
很多人做RAG有一个朴素的执念:把相关的文本块尽量多地塞进上下文,以为信息越多模型越从容。实际体验告诉我,这个想法在原理想通之前就已经错了。检索回来的文本块占据预算,并且如果它们彼此风格类似,会形成"信息茧房",把模型的注意力从真正的指令上拽走。更麻烦的是,检索回来的内容还可能互相冲突,模型为了调和这些冲突,会产生大量无意义的中间推理。
所以我现在做检索策略,核心指标不是召回率,而是"信息增益率"——每个加入上下文的文本块,必须能带来当前决策需要的新信息,否则就不该放进来。具体怎么判断,后面实操框架里我会展开说。
3. 我踩过的那些Context坑:三次事故复盘
3.1 事故一:RAG把整本手册塞进窗口,Agent开始"胡言乱语"
这个事故发生在我做一个内部文档问答Agent的时候。当时检索模块用的向量相似度召回TopK,K设成了8,每个文本块1000字,单次检索大概带回8000字。看起来不多,但问题在于Agent在回答一个复杂问题时,会反复多次检索——每走一步查一次,查完的结果全堆在上下文里。
跑到第五六步的时候,上下文里已经塞了四五轮检索结果,累计三万多字。这些检索结果里包含了不少语义相似但结论不同的内容。结果模型开始出现一种极其诡异的错误:它能把两段互相矛盾的文档内容"缝合"在一起,产出一个看起来合理、实际上完全错误的结果。你单独看Prompt,会发现系统提示词写得无懈可击,但运行时上下文已经被污染了。
那次之后我做了两个改动。第一,每轮检索前会先看当前上下文还剩多少预算,如果预算不足,先触发历史摘要压缩,腾出空间再检索。第二,检索结果按轮次管理,每一轮用完之后,只把被模型实际引用的段落保留下来(通过记录工具返回结果的引用ID来判断),其他段落从上下文里替换成一行简短摘要。
3.2 事故二:旧对话里的失败示范,带偏了后面的工具选择
这个坑发生在多轮对话型Agent里。Agent支持用户在同一个会话里连续提需求,比如先让Agent订个会议室,再让Agent查一下参会人的日程,然后安排会议纪要。
问题出在会话历史没有做分层。第一轮订会议室的时候,用户尝试了一种错误的说法,Agent也错误地调用了一个不存在的工具,然后报错、纠正、再成功。这段"错误->纠正"的完整过程全都被保留在了对话历史里。到了第三轮,新任务本来应该调用日程查询工具,模型却在历史中搜索到了之前"错误调用不存在的工具"的例子,于是又开始尝试那个不存在的工具。
排查的时候我一度以为是工具描述写得不够清楚,后来把历史捞出来对比才意识到,模型不是不知道工具怎么选,而是被历史中的失败示范给锚定了。解决方法是做了三层处理:一是对话历史在进入上下文之前先做"错误步骤折叠",把"尝试-失败-纠正"的过程提炼成一句"用了错误工具,后改为正确工具";二是每轮任务开始前,只保留与该任务相关的历史片段,其他历史内容降级为摘要;三是在工具选择的关键步骤,临时把历史里跟当前任务无关的部分从上下文里拿掉。
3.3 事故三:压缩摘要后丢失关键约束,Agent开始越权
做Context Engineering,压缩摘要几乎是绕不开的手段,但摘要本身也有副作用。我有一版方案是每隔几轮对话就对历史做一次总结,把细节浓缩成摘要放进上下文。结果有一次用户明确说过"所有金额超过1000元的操作必须先经过审批",这条信息在摘要里被压缩成了"涉及金额操作要注意审批"。后面Agent在执行一个1200元的支付任务时,直接判定不需要额外审批,因为"注意审批"在它看来只是一条软性提醒,而不是一条硬性门禁。
这次事故给我的教训是:压缩摘要不是简单的"变短",而是要保证关键指令性信息零丢失。我的做法是给摘要模块一份"关键信息保留清单",强制摘要里必须包含最近的数值阈值、用户明确表达的偏好、曾经被拒绝过的方案类型、以及所有"禁止"和"必须"类的约束。凡是在清单里的内容,压缩时原样保留,不做改写。
3.4 排查思路:不要急着改Prompt,先dump上下文
三位事故复盘下来,最大的共性经验是:遇到Agent行为异常,第一反应不应该是继续调Prompt,而是把运行时上下文完整dump下来,从模型的角度去看它到底"看"到了什么。
我现在排查Agent问题的固定流程是这样:
- 复现问题,记录触发条件。
- 在出错的步骤前后,把发送给模型的完整上下文存成文件。
- 按区块分析:系统指令有没有被淹没?历史中是否有误导性内容?检索结果是否冗余?工具返回是否超过了必要字段?
- 用"最小化复现"原则,逐步删除可疑区块,看问题是否消失。
- 定位到具体区块之后再决定改哪里——可能是改检索、改压缩、改记忆结构,也可能是最后才改Prompt。
这套流程帮我解决了不少"看起来像Prompt问题"的Context问题。说实话,十次里有七八次,问题都不在Prompt本身。
4. 一套能直接落地的Context Engineering实操框架
4.1 上下文预算分配:给每块内容算笔账
实操第一步,给每个Agent任务画一张上下文预算表。别等到线上跑起来再凭感觉调,直接在代码里把预算写死。
我建议定义一个ContextBudget结构,包含总窗口大小和每个区块的硬上限。以128K窗口为例,我自己常用的分配是:系统指令8K、任务状态16K、工具与数据40K、历史摘要24K、预留生成区40K。写代码时通过断言来保证每个区块实际写入长度不超过预算。
这里有一个很容易忽略的点:模型的输出也要占上下文空间。很多Agent框架把输入token和输出token混在一起统计,如果你的生成区预算不够,模型会在输出到一半时被截断,导致整个任务失败。所以预算表里必须把输出空间单独留出来。
4.2 分层记忆与检索策略:短期滑动窗口加长期摘要加实体记忆
我在生产环境里用的组合方案是"短期滑动窗口 + 长期摘要 + 实体记忆"三层结构。
短期滑动窗口:只在上下文里保留最近N轮对话的原始文本。N通常取3到5轮,超过的部分进入长期摘要。这个窗口服务于当前任务,保证模型能看到最新的用户意图和最近的工具反馈。
长期摘要:每经过N轮对话或者上下文预算超过阈值时,触发一次摘要生成,把旧对话压缩成结构化摘要。摘要按时间分段存储,每段摘要带上时间戳和主题标签,方便以后检索。写摘要的时候,必须把前面提到的"关键信息保留清单"作为强制约束。
实体记忆:从对话和工具返回中抽取实体关系,比如用户名称、项目编号、审批阈值、偏好设置,存入一个轻量级的KV存储。在每次任务开始时,把与当前任务相关的实体记录以极简格式注入上下文。这样即使对话隔了很久,重要事实也不会丢。
4.3 工具返回结果的"降噪"处理
工具返回结果普遍存在"字段冗余"的问题,尤其是一些API返回。10KB的JSON里,对下一步决策有用的可能就两个字段。把全部内容放进上下文,等于给模型塞了一堆需要费力忽略的信息。
我现在会在工具调用之后增加一个轻量的"提炼层"。核心做法是三步:
第一,从原始返回中提取关键字段,按当前任务流程所需的粒度重组。第二,对长列表只保留前几条摘要加总数。第三,给返回内容打上标签,标注数据的时间戳、来源工具、可信度,如果某条结果已经过时或与当前任务无关,直接不入上下文。
举个例子,一个查询订单状态的工具返回了订单的所有字段,包括下单时间、支付流水、物流轨迹、操作日志。对当前"回答用户是否发货"这个任务来说,只需要提取发货状态、预计到达时间和最近一条物流信息,其余全部丢弃。这个降噪过程可以是一段解析代码,也可以让一个小模型完成,关键是让主模型只看到高密度的决策信息。
4.4 上下文更新的触发条件与替换策略
上下文不是每步都要更新,也不是永远不动。我给Agent设置了几类触发器:
- 新信息到达:工具返回了新的关键数据,需要写入当前状态区块。
- 目标漂移:用户新的输入改变了任务目标,需要重写任务状态区块。
- 预算超限:预计下一轮写入后总token会超出预算,触发历史摘压缩。
- 状态过期:某个信息被更新的信息取代,旧信息立即移除。
替换策略上,我遵循"相关优先、最近优先、指令优先"三个原则。相关内容比不相关内容更值得保留;最近的信息比旧信息更值得保留;指令和约束永远不被低优先级的工具输出挤出窗口。执行替换时,我先从预算最大的区块开始找可压缩项,而不是一刀切地把最早的历史删掉。
4.5 一个最小可运行的伪代码示例
下面给一个非常简化的伪代码,演示在自定义Agent循环中如何搭建Context管理逻辑。这不是完整可运行的工业代码,但结构上可以当成一个起点模板。
class AgentContext: def __init__(self, max_tokens=128000): self.max_tokens = max_tokens self.system_block = "" # 系统指令 self.state_block = [] # 当前任务状态区 self.data_block = [] # 工具返回/外部数据区 self.history_block = [] # 历史摘要区 self.recent_window = [] # 短期滑动窗口(原始对话) self.output_reserve = 40000 # 给模型生成留的预算 def sync_tool_result(self, tool_result): # 降噪:只保留与当前决策相关的字段 kept = extract_key_fields(tool_result) self.data_block.append(kept) # 数据区超预算时,触发历史压缩并裁剪最早数据 if self.estimate_tokens() > self.max_tokens - self.output_reserve: self.recent_window = summarize_old_history(self.recent_window) def build_prompt(self, new_user_input): # 在发送给模型前,合并所有块 assert self.estimate_tokens() < self.max_tokens - self.output_reserve * 1.2 prompt_sections = [ ("system", self.system_block), ("state", format_state(self.state_block)), ("data", format_data(self.data_block)), ("history", format_history(self.recent_window + self.history_block)), ("user", new_user_input) ] return "\n".join(f"<{name}>\n{content}" for name, content in prompt_sections)这个伪代码的核心逻辑很简单:所有上下文都按区块管理,每次构造Prompt前做一次预算校验,超了就压缩历史,而不是无脑截断。实际工程里,你可以在这个基础上加更多细节,比如区块内容按时间戳排序、为每个区块设置权重值、用向量检索替换简单栈式内存等等。
5. 用Eval把Context Engineering变成可迭代的工程
5.1 衡量上下文质量的四个指标
Context Engineering如果没有评估,就是靠运气。我自己的评估体系里,重点看四个指标。
信息密度:上下文里每个区块的有效信息字节数与总字节数的比值。密度太低说明塞了大量冗余内容,需要加强降噪。
指令保持度:模型在长上下文中执行系统指令的稳定程度。可以用一组已知的对抗样本来测试,比如在上下文中故意放入与指令相反的示范,看模型是否会被带偏。
时间衰减度:越早的信息对当前决策的影响应当越低。如果历史中的旧信息频繁干扰当前决策,说明记忆分层没做好。
预算利用率:实际使用token与窗口总容量的比值,以及输出被截断的频率。利用率长时间接近100%不是好事,说明系统一直在崩溃边缘游走,应该提高压缩频率。
5.2 构造上下文污染回归测试集
评估Context Engineering效果,很难用一两条Prompt测试搞定,需要一组专门的回归测试集。我给每个Agent项目都建了一套"对抗上下文"样本,专门用来暴露污染问题。
构造思路是人为地在上下文中插入干扰项:比如插入一段与正确答案格式完全一致的错误示范、插入过时的工具返回结果、插入用户早期说过的与当前目标矛盾的需求。然后看Agent在这些干扰下是否还能坚持正确行为。
这种回归测试做起来不算复杂,但价值极高。我每次改动压缩策略或者记忆方案,都会先跑一遍测试集,如果新增干扰能稳定通过,再上生产。没有这套测试,你根本不敢动Context管理逻辑——一动就可能在线上冒出诡异问题。
5.3 上下文策略A/B对比的经验
做Context Engineering调整,一定要做A/B对比,不要拍脑袋。方法和做普通功能迭代一样:先定义离线指标,再准备同一批输入样本,分别在旧策略和新策略下跑一遍,对比输出质量。
有一个容易被忽视的点:Context策略的差异往往在长尾场景才体现出来。可能你测试10个简单样本,新旧策略结果一样;测试到第50个复杂样才看出区别。所以对比样本里要专门准备多步长任务、工具密集任务、信息冲突任务这些高压场景。
我在实际对比中碰到过一种情况:新策略在整体准确率上只提升了2%,但把某类高频失败样本的失败率从30%降到了5%。这种局部收益往往比整体收益更有价值,因为它可能正好命中了你产品里的核心痛点。
5.4 我的建议:先做减法再做加法
最后说一条我很早就总结出来的经验:做Context Engineering,优先级永远是先做减法,再做加法。
所谓减法,就是把上下文里没用的东西清理掉,把工具返回的冗余字段去掉,把旧的、过时的、互相矛盾的记忆压缩掉,让模型每次看到的都是高密度信息。加法则是加更多的背景知识、加更多few-shot示例、加更复杂的工具描述。很多新手一上来就想着怎么把更多信息塞进上下文,结果窗口越塞越满,模型表现越来越差。
我会在每次改动前问自己一个问题:如果只能保留当前上下文里50%的内容,我应该去掉哪些?这个问题的答案,往往就是Context Engineering真正要优化的方向。把上下文当成一个有预算约束的资源池,精心编排每一块内容的进出,比单纯优化Prompt带来的提升要大得多,而且这条路能让你从"调参师"真正变成"系统工程师"。
在实际项目中多做几次dump上下文、分析污染、压缩重构的循环,你慢慢会建立起对上下文空间的直觉——哪种信息该放、放多少、放多长时间,一眼就能判断。这种能力很难靠看文章学会,但一旦养成,做Agent开发的效率会有质的提升。