1. 上下文工程到底在解决什么问题
1.1 从一个真实翻车现场说起
去年我帮一个团队调他们的客服 Agent,模型用的是当时第一梯队的大参数版本,工具链也接得挺全,查订单、改地址、退款申请都能调。测试环境跑得挺顺,一上生产就出幺蛾子:用户说“我上周买的那双鞋想退了,顺便问下我上个月那笔退款到账没”,Agent 第一轮调了查订单工具,拿到一堆订单数据,第二轮直接开始编——它把两个不同订单的退款状态混在一起回答,还煞有介事地报了个不存在的退款单号。
问题不在模型笨,也不在工具少。问题在于:每一轮对话里,塞给模型的那段上下文,是错的。工具返回的原始 JSON 有 3000 多 token,里面 90% 是模型不需要的字段;历史对话里上一轮的订单号没被清理,模型看到两个订单号就开始串;系统提示词里写着“退款需确认订单状态”,但工具返回的状态字段叫status而不是order_status,模型根本没对上。
这就是上下文工程要解决的事。它不是提示词工程换个马甲,提示词工程关心的是“怎么把一句话说清楚”,上下文工程关心的是“在模型每一次推理之前,它的工作台上到底摆了哪些东西、按什么顺序摆、哪些该扔、哪些该压缩、哪些该高亮”。
1.2 上下文工程和提示词工程的边界在哪
很多人把这两个概念混着用,我自己的划分标准很简单:
- 提示词工程:静态的、一次性的。你写一段 system prompt,调措辞、加 few-shot、改格式,目标是让模型在单次调用里表现更好。它的作用域是“一次请求”。
- 上下文工程:动态的、有状态的。你要设计一套机制,在 Agent 多轮循环中,每一轮都重新组装上下文——从记忆里捞什么、从工具结果里截什么、历史对话怎么压缩、当前任务状态怎么表达。它的作用域是“整个任务生命周期”。
打个比方:提示词工程是给厨师写一份好菜谱,上下文工程是设计整个厨房的动线——食材什么时候从冷库拿出来、切好的配菜放哪个台面、上一道菜的锅什么时候洗。菜谱写得再好,厨房动线乱了,出菜照样翻车。
1.3 为什么现在必须重视这件事
三个现实压力逼着大家往这个方向走。
第一,Agent 的循环轮次在变多。早期的 Agent 基本是“一问一答一工具”,现在稍微复杂点的任务,ReAct 循环跑十几轮是常态。轮次一多,历史上下文线性膨胀,不做工程化处理,要么爆 token 要么模型被噪声淹没。
第二,工具返回的数据量在变大。查个数据库返回几十行、读个文件返回几千字、调个搜索接口返回一堆网页摘要,这些原始数据直接塞进上下文,token 烧得飞快,而且大部分是干扰项。
第三,成本敏感。上下文里每多一千 token,每一轮都要多付一次钱。一个跑 20 轮的 Agent,如果每轮上下文多 2000 token,整个任务的成本可能翻倍。上下文工程做得好,省下来的都是真金白银。
我个人的判断标准:如果一个 Agent 任务的平均循环轮次超过 5 轮,或者单次工具返回超过 1000 token,就必须认真做上下文工程,否则迟早出问题。
2. 拆解 Agent 上下文的五个组成部分
2.1 系统指令层:不只是写一段话
系统指令层是上下文的骨架,它决定了模型“以什么身份、按什么规则、朝什么目标”工作。但很多人只把它当成一段静态文本,这是最大的误区。
一个完整的系统指令层,我通常会拆成四块:
- 角色与边界:你是谁、你负责什么、你不负责什么。边界比角色重要,因为 Agent 最容易犯的错就是越界操作。
- 工具使用规范:什么情况下调哪个工具、调用前需要确认什么、工具返回后怎么处理。这块要写得像操作手册,不能含糊。
- 输出格式约束:最终回答的结构、中间思考的表达方式。格式约束直接影响后续轮次的解析难度。
- 当前任务状态:这是动态注入的部分,告诉模型“你现在进行到哪一步了、已经完成了什么、还差什么”。
最后一块是很多人忽略的。Agent 跑到第 8 轮的时候,模型其实已经“忘了”最初的任务目标是什么,如果你不在系统指令里动态刷新任务状态,它很容易跑偏。
2.2 记忆层:短期、长期、工作记忆怎么分
记忆层是上下文工程里最考验设计功力的部分。我习惯把它分成三类:
短期记忆就是当前对话的历史消息。它的特点是增长快、噪声多、需要压缩。处理方式后面会详细讲。
长期记忆是跨会话持久化的信息,比如用户偏好、历史工单、知识库片段。它的关键不是“存什么”,而是“什么时候捞、捞多少”。我见过太多 Agent 把整个知识库往上下文里塞,结果模型被淹死。正确做法是基于当前 query 做检索,只捞 top-k 相关片段,而且要做相关性阈值过滤。
工作记忆是当前任务执行过程中的临时状态,比如已经查到的订单号、已经确认的地址、已经排除的选项。它应该被结构化存储,而不是散落在历史对话里。我通常用一个 JSON 对象维护工作记忆,每轮循环结束后更新,下一轮开始时序列化注入。
实操心得:工作记忆一定要显式维护,不要指望模型从历史对话里自己“回忆”。模型没有真正的记忆,它只有上下文窗口。你不主动整理,它就自己乱猜。
2.3 工具结果层:原始数据是上下文杀手
工具返回的原始数据,是上下文膨胀的头号元凶。一个数据库查询返回 50 行 JSON,可能 4000 token,但模型真正需要的可能只是其中 3 个字段。
处理工具结果,我总结了三板斧:
第一板斧:字段裁剪。在工具适配层就把不需要的字段砍掉,只保留模型决策必需的。比如查订单,返回order_id、status、amount、create_time就够了,user_agent、channel_code这些内部字段没必要给模型看。
第二板斧:结果摘要。对于文本类结果(比如读文件、搜索摘要),先用一个小模型或规则做摘要,把 3000 字压成 300 字,再注入上下文。摘要要保留关键实体和数字,丢掉修饰性内容。
第三板斧:分页与按需加载。如果结果确实很大,不要一次性全给,先给模型一个“目录”或“前 N 条”,让它决定要不要深入看某一条。这本质上是把上下文管理的一部分决策权交给模型。
2.4 历史对话层:压缩不是简单截断
历史对话的压缩,最常见的错误做法是“保留最近 N 轮,丢掉更早的”。这在简单任务里能用,但在长任务里会丢掉关键信息。
我常用的压缩策略是分层摘要:
- 最近 2-3 轮:保留完整原文,因为模型需要精确的上下文衔接。
- 更早的轮次:按“任务阶段”做摘要。比如“第 1-5 轮完成了订单查询,确认了订单号 XXX 状态为已发货”。
- 关键决策点:单独标记。比如“第 6 轮用户明确表示不接受换货,只接受退款”,这种信息必须保留原文。
摘要的粒度要控制好。太粗会丢信息,太细等于没压缩。我的经验是:每 5 轮对话压缩成 2-3 句话,保留所有实体(订单号、金额、时间)和所有用户明确表达的偏好与约束。
2.5 当前输入层:用户这句话怎么放
当前输入层看起来最简单,其实也有讲究。用户的一句话,直接放进去就行了吗?不一定。
如果用户输入里包含指代(“那个订单”、“上次说的那个”),你需要先做指代消解,把“那个订单”替换成具体的订单号,再注入上下文。否则模型又要去历史里翻,翻错了就串。
如果用户输入很长(比如粘贴了一段日志),你需要判断是全文注入还是先摘要。我的原则是:超过 500 字的用户输入,先做一次结构化提取,把关键信息抽出来,原文作为附件按需引用。
3. 上下文组装的核心机制与实操
3.1 上下文窗口的预算分配
把上下文窗口当成一个固定预算来分配,这是上下文工程的核心思维。假设模型窗口是 128k token,你不能全用满,要留出输出空间和缓冲。
我通常的分配比例是这样的:
| 组成部分 | 预算占比 | 说明 |
|---|---|---|
| 系统指令 | 10-15% | 含动态任务状态 |
| 工作记忆 | 5-10% | 结构化 JSON |
| 长期记忆检索结果 | 10-20% | top-k 片段 |
| 历史对话摘要 | 15-25% | 分层压缩后 |
| 最近对话原文 | 10-15% | 最近 2-3 轮 |
| 工具结果 | 15-25% | 裁剪摘要后 |
| 当前输入 | 5-10% | 含指代消解 |
| 输出预留 | 10-15% | 不占用输入预算 |
这个比例不是死的,要根据任务类型调。比如代码生成类任务,工具结果(文件内容)占比要高;客服类任务,历史对话和长期记忆占比要高。
注意:预算分配要在代码里硬编码成常量,并且每轮组装后打印实际占用,方便监控和调优。我见过太多团队凭感觉调,结果某类任务突然爆窗口。
3.2 动态组装流程的代码骨架
下面是一个上下文组装的核心流程骨架,用 Python 伪代码表达,重点看结构而不是具体实现:
def build_context(task_state, user_input, history, tools_result): # 1. 初始化预算 budget = ContextBudget(total=128000, output_reserve=16000) # 2. 系统指令层(含动态任务状态) system_prompt = render_system_prompt( role=task_state.role, task_status=task_state.current_step, completed=task_state.completed_steps, remaining=task_state.remaining_steps ) budget.allocate("system", system_prompt) # 3. 工作记忆层 working_memory = serialize_working_memory(task_state.memory) budget.allocate("working_memory", working_memory) # 4. 长期记忆检索 retrieved = retrieve_long_term_memory( query=user_input, top_k=5, threshold=0.75 ) budget.allocate("long_term", retrieved) # 5. 历史对话分层压缩 compressed_history = compress_history( history, recent_n=3, summary_granularity=5 ) budget.allocate("history", compressed_history) # 6. 工具结果裁剪 trimmed_tools = trim_tool_results( tools_result, keep_fields=task_state.required_fields ) budget.allocate("tools", trimmed_tools) # 7. 当前输入指代消解 resolved_input = resolve_references( user_input, working_memory=task_state.memory ) budget.allocate("input", resolved_input) # 8. 按优先级组装,超预算时从低优先级截断 return budget.assemble()这个骨架的关键在于预算对象和优先级截断。当总占用超过预算时,从优先级最低的部分开始截断,而不是简单报错。
3.3 优先级排序的实操原则
优先级排序没有标准答案,但有几条我验证过的原则:
- 当前任务状态 > 一切。模型如果不知道现在该干嘛,其他信息再多也没用。
- 用户明确约束 > 工具结果。用户说“不要打电话”,这个约束比任何工具返回都重要。
- 最近对话 > 早期摘要。衔接性优先于完整性。
- 结构化数据 > 自然语言描述。同样信息量,JSON 比散文省 token 且更精确。
- 实体信息 > 修饰信息。订单号、金额、时间必须保留,形容词可以丢。
我通常把优先级分成五档,从高到低:任务状态、用户约束、工作记忆、最近对话、工具结果、早期摘要、长期记忆。超预算时从最低档开始砍。
3.4 工具结果的裁剪规则设计
工具结果裁剪,我建议在工具适配层做,而不是在上下文组装层做。因为适配层最了解这个工具返回的数据结构,知道哪些字段有用。
具体做法是给每个工具定义一个result_schema,声明哪些字段需要保留、哪些需要摘要、哪些直接丢弃:
TOOL_RESULT_SCHEMA = { "query_order": { "keep": ["order_id", "status", "amount", "create_time"], "summarize": ["items"], "drop": ["internal_code", "channel", "raw_response"] }, "search_knowledge": { "keep": ["title", "url", "score"], "summarize": ["snippet"], "drop": ["html", "metadata"] } }keep字段原样保留,summarize字段做摘要后保留,drop字段直接丢弃。这样每个工具的结果都有确定的处理方式,不会出现“这个工具返回太大不知道怎么处理”的情况。
4. 常见翻车场景与排查手册
4.1 模型串台:把两个任务的信息混在一起
这是最常见的翻车场景。表现是模型把上一轮或另一个任务的信息,错误地应用到当前任务上。
根因通常是:历史对话里的实体没有被清理,或者工作记忆没有按任务隔离。
排查步骤:
- 打印当前轮次的完整上下文,检查是否有不属于当前任务的实体(订单号、用户 ID、地址等)。
- 检查工作记忆是否在任务切换时被重置。
- 检查历史对话压缩时,是否把不同任务的摘要混在了一起。
解决方法:给每个任务一个独立的task_id,工作记忆按task_id隔离,历史对话压缩时按任务分段,跨任务的信息不互相注入。
4.2 工具结果淹没:模型忽略了关键信息
表现是工具明明返回了正确数据,但模型回答时没用上,或者用错了。
根因通常是:工具结果太长,关键信息被埋在中间,模型注意力没分配到。
排查步骤:
- 检查工具结果在上下文中的位置。如果放在很靠前或很靠后的位置,模型可能忽略。
- 检查关键字段是否被裁剪掉了。
- 检查是否有多个工具结果互相干扰。
解决方法:把最关键的字段提取出来,放在工具结果的开头,并用明确标记(如[关键信息])高亮。如果多个工具结果,按重要性排序,最重要的放最前面。
4.3 上下文爆窗口:跑到一半突然报错
表现是 Agent 跑到第 N 轮,突然报 token 超限。
根因通常是:历史对话或工具结果没有做压缩,线性增长。
排查步骤:
- 打印每轮的上下文占用,看是哪一部分在膨胀。
- 检查压缩逻辑是否生效,摘要是否真的在缩短。
- 检查是否有某个工具返回了异常大的结果。
解决方法:在组装层加硬性截断,任何部分超过预算就强制压缩或丢弃。同时给工具结果加大小上限,超过上限的先摘要再注入。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 模型串台 | 实体未清理、任务未隔离 | 打印上下文查实体 | 按 task_id 隔离 |
| 忽略工具结果 | 结果太长、位置不佳 | 检查结果位置和长度 | 关键字段前置高亮 |
| 爆窗口 | 历史/工具未压缩 | 打印各层占用 | 加预算和截断 |
| 重复调用工具 | 工作记忆未更新 | 检查记忆写入逻辑 | 每轮更新工作记忆 |
| 跑偏任务目标 | 任务状态未刷新 | 检查系统指令 | 动态注入任务状态 |
| 回答格式错乱 | 格式约束被淹没 | 检查约束位置 | 约束放系统指令末尾 |
独家避坑技巧:在开发阶段,把每轮组装的完整上下文写入日志文件,按轮次编号。出问题时直接翻日志,比在代码里打断点快十倍。这个日志在线上可以采样记录,不用全量。
5. 从 ReAct 到 Harness:上下文工程的演进视角
5.1 ReAct 循环里的上下文变化
ReAct 模式的核心是“思考-行动-观察”循环。在这个循环里,上下文每一轮都在变化:
- 思考阶段:模型需要看到任务目标、当前状态、可用工具。
- 行动阶段:模型输出工具调用,这部分要追加到历史。
- 观察阶段:工具结果注入,同时更新工作记忆。
很多 ReAct 实现的问题在于,它把每一轮的完整思考过程都保留在历史里,导致上下文快速膨胀。实际上,中间的思考过程大部分可以压缩,只保留最终决策和关键推理链。
我的做法是:每轮循环结束后,把“思考”部分压缩成一句话摘要,只保留“行动”和“观察”的完整记录。这样既保留了决策链,又控制了膨胀速度。
5.2 Harness 概念带来的启发
Harness 这个词在 Agent 语境里,通常指“驾驭层”——包裹在模型外面的一整套控制逻辑,包括上下文组装、工具调度、循环控制、错误处理。
从上下文工程的角度看,Harness 的价值在于它把上下文管理从“模型的事”变成了“工程的事”。模型只负责推理,Harness 负责在每次推理前把工作台收拾干净。
一个设计良好的 Harness,应该具备这些能力:
- 上下文组装:按预算和优先级拼装每一轮的输入。
- 状态管理:维护工作记忆、任务状态、循环计数。
- 工具适配:裁剪工具结果、处理工具错误、管理工具调用配额。
- 压缩调度:在合适的时机触发历史压缩和记忆更新。
- 可观测性:记录每轮上下文,支持回放和调试。
5.3 不同架构下的上下文策略差异
不同 Agent 架构,上下文策略差别很大:
单 Agent 线性循环:上下文按轮次线性增长,重点是压缩和截断。
多 Agent 协作:每个 Agent 有独立上下文,重点是 Agent 间的信息传递——传什么、传多少、什么时候传。我通常只传结构化的工作记忆,不传完整对话历史。
带规划器的架构:规划器负责拆解任务,执行器负责执行。规划器的上下文要包含全局任务状态,执行器的上下文要聚焦当前子任务。两者上下文要隔离,避免执行器被全局信息干扰。
带反思的架构:反思阶段需要看到执行历史,但不需要看到工具原始结果。所以反思的上下文应该是压缩后的执行摘要,而不是完整历史。
实操心得:不管什么架构,上下文隔离都是关键。不同角色、不同阶段、不同任务的上下文,能隔离就隔离,不要图省事全塞在一起。隔离带来的清晰度,远大于共享带来的便利。
6. 我踩过的坑和几条硬经验
6.1 不要相信模型的“记忆”
我早期做 Agent 的时候,总想着“模型上下文窗口这么大,历史都留着,它自己会处理”。结果就是模型在长对话里频繁串台、遗忘、跑偏。
后来我彻底放弃了这个幻想。模型没有记忆,它只有当前上下文。你不主动整理,它就自己乱猜。工作记忆必须显式维护,任务状态必须每轮刷新,历史必须主动压缩。这三件事做到位,Agent 的稳定性会有质的提升。
6.2 压缩要有损,但不能丢实体
压缩历史对话的时候,我一开始追求“无损压缩”,结果压缩比上不去,上下文还是膨胀。后来想通了:压缩一定是有损的,关键是丢什么、留什么。
我的原则是:实体必留,修饰可丢,推理链留骨架,工具结果留结论。订单号、金额、时间、用户明确表达的偏好,这些是实体,必须保留。形容词、语气词、重复确认,这些可以丢。推理链保留“因为 A 所以 B”的骨架,中间的试错过程可以丢。工具结果保留最终结论,原始数据可以丢。
6.3 预算要硬,截断要狠
上下文预算必须是硬约束,不能“差不多就行”。我见过太多团队因为预算不硬,某类任务突然爆窗口,线上直接挂掉。
截断要狠的意思是:超预算时,从最低优先级开始,该丢就丢,不要犹豫。宁可丢一些边缘信息,也不能让整个请求失败。丢信息最多导致回答不完美,爆窗口直接导致任务失败,两害相权取其轻。
6.4 可观测性是生命线
上下文工程做得好不好,不看代码写得多漂亮,看你能不能快速定位问题。而定位问题的前提是,你能看到每一轮的完整上下文。
我的做法是:开发阶段全量记录,线上采样记录。记录内容包括:轮次编号、各层占用 token 数、实际注入的完整文本、模型输出、工具调用与结果。出问题时,直接翻日志,五分钟定位,比打断点快得多。
6.5 一个容易被忽略的细节:上下文顺序
上下文里各部分的顺序,对模型表现有实际影响。我实测下来,比较稳的顺序是:
- 系统指令(含任务状态)
- 工作记忆
- 长期记忆检索结果
- 历史对话摘要
- 最近对话原文
- 工具结果
- 当前用户输入
这个顺序的逻辑是:先给模型定身份和任务,再给背景信息,再给近期上下文,最后给当前要处理的内容。模型读到最后,正好是当前输入,注意力最集中。
如果把工具结果放在最后,模型可能被工具结果带偏,忽略了用户输入。如果把系统指令放在中间,模型可能读到后面就忘了前面的约束。顺序这件事,值得花时间调。
6.6 关于 token 计算的实操建议
token 计算不要用估算,要用实际 tokenizer。不同模型的 tokenizer 不一样,估算误差可能到 20% 以上,在预算紧张的时候会出问题。
我的做法是:在组装层集成对应模型的 tokenizer,每次组装后实际计算 token 数,而不是按字符数估算。虽然多花一点计算资源,但预算控制准确得多。
另外,中文和英文的 token 比例差别很大。中文大概 1 个字 1-2 个 token,英文大概 4 个字符 1 个 token。做预算的时候要按实际内容类型来,不能一刀切。
6.7 后续可以扩展的方向
这套上下文工程的框架,后续可以往几个方向扩展。
一是自适应预算。根据任务类型和历史表现,动态调整各层预算比例,而不是用固定值。
二是上下文缓存。对于重复出现的上下文片段(比如固定的系统指令、常用的长期记忆),做缓存复用,减少重复计算。
三是上下文质量评估。在组装完成后,用一个轻量模型评估上下文的“信息密度”和“噪声比例”,自动触发优化。
四是多模态上下文。图片、音频、视频的上下文管理,比纯文本复杂得多,需要单独设计压缩和检索策略。
这些方向我自己也在摸索,有新的心得再分享。上下文工程这件事,没有银弹,就是不断踩坑、不断调优的过程。但只要方向对了,每一点优化都会体现在 Agent 的稳定性和成本上。