上下文窗口是 Agent 的工作记忆。它的容量有限、成本线性、注意力会衰减——所以「放什么」比「放多少」重要一个数量级。
一个思想实验
假设你要做一个客服 Agent,手头有:
- 系统提示 1.2k token
- 工具定义 2.5k token
- 产品手册 80k token
- 用户历史 6k token
- 当前问题 200 token
窗口有 200k,看起来绰绰有余。但如果每次请求都把手册全文塞进去:
- 输入成本变成 20 倍;
- 首 token 延迟显著增加;
- 模型要在 80k 文本里找相关段落,关键信息被稀释;
- 手册更新后,提示缓存全部失效。
更合理的做法是:手册放进检索库,每次只取最相关的 3–5 段(约 2k token)。窗口从「仓库」变成「工作台」。
上下文腐烂
「Context Rot」描述的是:上下文越长,模型对其中信息的利用率反而下降。常见表现:
- 忽略放在中间的关键约束;
- 被无关段落带偏,答非所问;
- 前后引用不一致;
- 对早期对话中的决定「失忆」;
- 工具定义太多时,选错工具。
原因不神秘:注意力资源有限,长文本里噪声更多,位置也会影响权重。即使模型宣称支持 1M 窗口,有效利用仍然是一个工程问题。
窗口是上限,不是目标。优秀的 Agent 通常在 8k–30k 的有效上下文里工作,而不是把 200k 塞满。
四种基本操作
把上下文管理拆成四个动作:
写入:把状态放到模型之外
不要指望模型记住一切。把关键状态写进结构化存储:
{
"goal": "为 ACME 生成 2025 年风险简报",
"status": "collecting",
"done": ["找到 10-K", "抽取利润表"],
"open": ["核对一次性费用", "对比同行毛利率"],
"facts": [
{ "key": "gross_margin_2025", "value": "38.2%", "source": "10-K p.42" }
],
"decisions": ["不采用新闻稿口径,以审计报表为准"]
}每一步只把这份「任务状态」的精简版放进上下文,而不是全部历史。这也是为什么有状态的图编排(LangGraph 等)比纯消息列表更好维护。
选择:不是检索到就放进去
检索结果需要二次处理:
- 重排:按相关性排序,而不是按向量距离;
- 去重:同一段落的不同切片只留一个;
- 截断:按 token 预算截取,保留标题与来源;
- 过滤:按权限、时间、版本筛选;
- 标注:告诉模型每段的来源、日期和可信度。
一个常见错误是把 20 个检索片段原样拼接。模型看到大量重复和弱相关内容,反而更容易忽略真正有用的那一段。
压缩:摘要要保留决策,而不只是内容
压缩不是「写一段更短的总结」。对 Agent 来说,历史摘要必须保留:
- 目标与当前状态;
- 已确认的事实与来源;
- 做过的决定及理由;
- 失败的尝试(避免重复);
- 未解决的问题;
- 用户的明确偏好与约束。
COMPACT_PROMPT = """
把下面的 Agent 历史压缩成可继续工作的状态摘要,必须保留:
1. 原始目标与当前进度
2. 已确认的事实(附来源)
3. 已做决策及理由
4. 失败的尝试与原因
5. 未完成事项与下一步
不要保留寒暄、重复内容和已经被推翻的假设。
历史:
{history}
"""压缩本身会丢信息。所以原始记录要保留在外部,需要时可以回查;摘要只是工作副本。
隔离:让子任务拥有自己的上下文
当一个子任务需要读大量材料时,把它交给子 Agent:
主 Agent:目标、计划、当前状态(约 3k token)
└─ 子 Agent:读取 40 页文档,返回 800 字结论 + 引用
主 Agent:只接收结论,不接收 40 页原文这就是多智能体最有价值的场景:上下文隔离。它不是为了「更多智能」,而是为了不让一个子任务的噪声污染全局。
隔离的其他形式:
- 代码在沙箱执行,只把 stdout / 错误摘要返回;
- 长文档用工具按页读取,而不是整篇加载;
- 不同阶段使用不同的系统提示;
- 把「研究」和「写作」分开,避免写作时被原始材料淹没。
每一步的上下文怎么组装
一个生产级 Agent 的上下文通常按优先级组装:
[固定前缀]
系统提示(角色、规则、输出契约)
工具定义(只放当前可用的)
少量高质量示例
[动态部分]
任务状态摘要
最近 N 轮对话
检索结果(重排、截断、带来源)
当前用户消息
[尾部]
本轮要求(格式、不要做什么)固定前缀放前面,能最大化提示缓存命中。动态内容放后面,避免每次请求都改变前缀。
很多团队把时间戳放在系统提示开头,导致每次请求前缀都不同,缓存全部失效。把它移到用户消息或尾部,成本可能直接减半。
预算策略:给每一类内容设上限
不要等超限了才截断。提前分配预算:
| 内容 | 预算占比 | 超限策略 |
|---|---|---|
| 系统提示 + 工具 | 15% | 精简描述、按需加载工具 |
| 任务状态 | 10% | 结构化摘要 |
| 对话历史 | 20% | 保留最近 N 轮 + 摘要 |
| 检索结果 | 40% | 重排后截断,保留来源 |
| 输出预留 | 15% | 设置 max_tokens |
具体比例按任务调整,但必须有一个显式的预算表,而不是让拼接逻辑随机决定。
def build_context(state, budget=32_000):
ctx = []
ctx.append(section("system", state.system_prompt, max_tokens=4_000))
ctx.append(section("tools", render_tools(state.tools), max_tokens=2_500))
ctx.append(section("state", state.summary, max_tokens=3_000))
ctx.append(section("history", recent_turns(state, n=6), max_tokens=6_000))
remaining = budget - used(ctx) - 2_000 # 给输出留余量
docs = rerank(state.query, state.candidates)[:8]
ctx.append(section("evidence", fit(docs, remaining), max_tokens=remaining))
return ctx长任务:什么时候压缩
一个实用的阈值策略:
- 上下文使用率 < 50%:正常追加;
- 50%–70%:开始对早期历史做摘要,保留最近 6–10 轮原文;
- 70%–85%:触发压缩,写入外部状态,重建上下文;
- > 85%:停止新工具调用,优先完成当前步骤或转人工。
压缩时机的选择会影响质量:太早压缩会丢细节,太晚则模型已经开始被噪声干扰。最好在「一个子任务完成」的边界压缩,而不是在步骤中间。
上下文质量的评估
上下文工程做得好不好,不能只看「回答像不像」。可以测:
- 关键信息召回:在给定上下文下,模型能否答出必须知道的事实;
- 噪声敏感度:加入无关段落,正确率下降多少;
- 位置稳定性:把关键信息放在开头/中间/结尾,结果是否一致;
- 压缩损失:压缩前后在同一测试集上的通过率差异;
- 成本与延迟:平均输入 token、TTFT、P95。
一个简单实验:固定 50 条问题,分别用「全量文档」「检索 top-5」「检索 top-5 + 重排」三种上下文跑,比较准确率与成本。多数团队会发现,更少的上下文反而更准。
常见失败模式
一个 API 返回 50KB JSON,模型真正需要的可能只有 5 个字段。在工具层做裁剪和摘要,别让原始数据污染上下文。
每次压缩都保留上一版摘要,导致摘要本身不断膨胀。压缩应该是有损的、定长的,并丢弃已被推翻的内容。
子 Agent 不需要知道主 Agent 的全部对话。给它目标、必要背景和输出要求即可,否则隔离就失去了意义。
动态内容放前面、每次请求都变,会让提示缓存完全失效。成本可能相差数倍。
检查清单
- 有显式的上下文预算表,而不是能塞多少塞多少
- 系统提示与工具定义精简,且稳定在前
- 检索结果经过重排、去重、截断
- 长历史有摘要机制,且保留决策与失败
- 大型子任务用子 Agent 或工具按需读取隔离
- 上下文使用率有监控与压缩阈值
- 关键信息的位置经过测试(开头/中间/结尾)
- 提示缓存命中率被记录
小结
- 上下文是工作记忆,不是仓库。窗口大不代表有效。
- 上下文腐烂真实存在,噪声和长度都会降低利用率。
- 四个动作:写入外部状态、选择相关内容、压缩历史、隔离子任务。
- 预算、位置、压缩时机和缓存友好性,决定了成本与质量。
- 评估上下文质量要看召回、噪声敏感度和压缩损失,而不只是最终答案。
下一篇把「输出」也变成可编程的接口:结构化输出与 Schema。