12 · 上下文工程
速览卡
| 项 | 内容 |
|---|---|
| 一句话定位 | 决定模型"看什么"的那层工程——同样的模型,上下文装配质量不同,输出质量能差一个档次 |
| 核心内容 | 上下文装配、Token 预算与压缩、失败模式识别、记忆分层、工具与上下文的关系 |
| 现状判断 | 已取代"提示词工程"成为主流说法;是 Agent 类应用最容易被低估、也最容易返工的一层 |
| 读完能回答 | 我的 Agent 跑几轮就变笨,到底是哪里出了问题?该加东西还是该删东西? |
| 相关板块 | 02 模型能力增强技术 | 03 Agent | 05 工程化 | 实操 06 Agent 实战 |
一、先纠正一个直觉:窗口大 ≠ 该多塞
最常见的误区是把上下文窗口当成"硬盘"——1M 的窗口,那我把所有资料都塞进去不就好了?
塞得下和用得好是两回事。 三条绕不开的物理约束:
| 约束 | 表现 | 后果 |
|---|---|---|
| 注意力稀释 | 上下文里的无关信息越多,模型对关键信息的注意力越分散 | 相关信息明明在,却"看不见" |
| 位置偏差 | 模型对开头与结尾的敏感度高,中间部分容易被忽略 | 关键信息埋在中间=白放 |
| 成本非线性 | 注意力开销随序列长度增长,长上下文的边际成本高于短上下文 [易变] | 第 10 万个 token 的代价远高于第 1 千个 |
💡 一个反复被验证的经验值:窗口填充率控制在 30%~40% 时模型表现最好, 塞满反而触发下面第四节要讲的"上下文腐化"。这条比"用多大窗口"重要得多。
所以上下文工程的第一性原则是:每一个进入窗口的 token 都要"挣"到它的位置。
二、上下文里到底装了什么
先把窗口拆开,才知道能优化哪里。一次典型调用的输入:
| 组成 | 谁来定 | 常见占比 | 优化的杠杆 |
|---|---|---|---|
| 系统指令 | 你 | 小 | 写清楚、别写全(见第七节反模式) |
| 工具定义 | 你 | 中 | 数量与描述质量影响巨大 |
| 检索来的资料 | 检索系统 | 大 | RAG 的主战场,见 02 篇 |
| 历史对话 | 累积 | 越跑越大 | 压缩 / 驱逐 |
| 工具返回结果 | 外部世界 | 最容易失控 | 截断、只留必要字段 |
| 工作记忆 / 中间产物 | 你 | 中 | 结构化笔记、外部化存储 |
| 输出预留 | — | 必须留 | 忘了留就是"生成到一半截断" |
⚠️ 新手最常翻车的是"工具返回结果":一次
cat一个大文件、一次全表查询, 几万 token 就进去了,而且每轮都在。这是上下文膨胀的头号来源, 却因为"我只是调了个工具"而被忽略。
三、Token 预算:先划地盘,再谈优化
不要等跑崩了再压缩。开工前就给每一段划上限,超了就截断或淘汰。
一个可直接抄的比例框架(按窗口总量算,比例比绝对值稳定):
| 段 | 建议占比 | 超了怎么办 |
|---|---|---|
| 系统指令 | 2%~4% | 拆出去:规则按目录放、能力做成按需加载 |
| 工具定义 | 2%~3% | 减少工具数;或按阶段渐进披露 |
| 检索资料 | 10%~20% | 重排后截断;按源设子预算 |
| 历史对话(已压缩) | 20%~30% | 淘汰旧轮次,保留决策与结论 |
| 工作记忆 | 20%~30% | 结构化摘要,细节外置 |
| 安全缓冲 | 20%~30% | 留给出乎意料的工具输出与生成 |
填充率的分区响应(把预算做成运行时策略,而不是一次性规定):
| 填充率 | 状态 | 动作 |
|---|---|---|
| 0~50% | 正常 | 正常跑 |
| 50%~70% | 注意 | 压缩工具输出 |
| 70%~85% | 警告 | 淘汰低价值历史 |
| 85%~100% | 危险 | 存档 + 起子任务(别硬撑) |
这个"分区响应"是本篇最值得照搬的一条——它把"什么时候压缩"从拍脑袋变成了可配置的规则。 多数上下文事故,都是因为没有在 70% 那一步做任何动作。
3.1 把预算做成代码,而不是文档
上面那张比例表只有落地成运行时检查才有用。一个最小可抄的预算守卫:
BUDGET = { # 各段占窗口总量比例
"system": 0.04, "tools": 0.03, "retrieval": 0.20,
"history": 0.30, "memory": 0.30, "buffer": 0.13,
}
def partition_response(fill_rate: float) -> str:
"""填充率到了就触发动作,而不是等崩。"""
if fill_rate < 0.50: return "normal"
if fill_rate < 0.70: return "compress_tool_outputs"
if fill_rate < 0.85: return "evict_low_value_history"
return "archive_and_spawn_subtask" # 别硬撑
# 用法:每轮拼上下文前算一次 fill_rate,按返回值决定本轮回不回收
action = partition_response(current_tokens / WINDOW_SIZE)关键点:分区阈值写死在代码里,团队才有统一的行为预期。 否则"什么时候压缩"永远取决于是谁在值班——这正是事故来源。
3.2 先测量,再优化
不做测量就调上下文,几乎一定在做无用功。一个把一次调用拆成各类 token 的小工具:
def profile_context(ctx: dict) -> dict:
return {
"system": count_tokens(ctx["system"]),
"tools": sum(count_tokens(t["schema"]) for t in ctx["tools"]),
"retrieval":count_tokens(ctx["retrieval"]),
"history": count_tokens(ctx["history"]),
"tool_outputs": sum(count_tokens(r) for r in ctx["tool_results"]),
}
# 跑一周,你会发现自己以为"很有用"的那段,常常占了 40% 却从没被模型引用四、失败模式:先会诊断,再谈开药
上下文出问题时,表现都是"模型变笨了",但病因不同。四种失败模式(Drew Breunig 的分类,业内常用):
| 模式 | 症状 | 根因 | 对策 |
|---|---|---|---|
| 污染 Poisoning | 一个幻觉被后续轮次当作事实反复引用 | 错误内容进了上下文并留存 | 注入前校验;对关键事实做二次确认 |
| 分心 Distraction | 模型执着于历史里的某个细节,忘了当前任务 | 历史过长淹没了训练所得的判断力 | 压缩历史;把目标前置重申 |
| 混淆 Confusion | 输出格式乱了、用了不该用的工具 | 无关信息(尤其多余的工具定义)干扰了决策 | 精简工具;结构化指令 |
| 冲突 Clash | 模型在两个说法之间摇摆,或输出自相矛盾 | 上下文中存在互相矛盾的信息 | 检索去重;标注来源与时效 |
上下文腐化(Context Rot) 是这四种的统称表现:随着上下文变长,模型准确率持续下降, 而且下降是静默的——没有报错,只是答案慢慢变差。这正是它危险的地方。
诊断口诀:先问"变长了还是变脏了"。 变长了 → 压缩与驱逐;变脏了(有错、有冲突)→ 校验与去重。两者常同时发生。
五、压缩:三层级联,从便宜到贵
压缩不是只有"让模型总结一下"这一招。按代价从低到高:
| 层 | 手段 | 代价 | 适用 |
|---|---|---|---|
| 第 1 层 | 工具输出截断:只返回必要字段、限制行数、给"静默模式" | 几乎为零 | 应该永远先做这层 |
| 第 2 层 | 输入驱逐:按规则淘汰旧轮次/旧片段,保留骨架 | 零(纯规则) | 通用 |
| 第 3 层 | LLM 摘要:让模型压缩历史 | 一次额外调用 + 有损 | 前两层兜不住时 |
很多团队一上来就做第 3 层,结果既贵又丢信息。第 1、2 层能解决大部分压力, 第 3 层是兜底,不是默认。
摘要时要用结构化字段,不要让它写散文。推荐字段:
会话意图 / 已完成的步骤 / 关键决策与理由 / 当前进度 / 未决问题 / 下一步💡 一个反直觉但有效的做法(来自编码 Agent 的实践): 保留完整的"行动与推理骨架",只把旧的"工具输出"替换成占位符。 比整体摘要更省、也更不容易丢关键细节。
[有争议]不同场景效果有差异,建议自己测。
5.1 三层级联的可运行实现
理论落到代码。注意顺序:永远先做第 1、2 层,第 3 层是兜底。
# 第 1 层:工具输出截断(零成本,应该永远先做)
def truncate_tool_results(results, max_lines=20, keep_fields=("status", "id")):
cleaned = []
for r in results:
# 只留必要字段、限制行数,别把整个文件塞回上下文
cleaned.append({k: r[k] for k in keep_fields if k in r})
return [truncate_lines(c, max_lines) for c in cleaned]
# 第 2 层:输入驱逐(纯规则,零调用)
def evict_old_turns(history, keep_recent=6, keep_decisions=True):
recent = history[-keep_recent:]
if keep_decisions:
# 把"已做的决策"抽成一行摘要留着,细节丢给旧轮次
decisions = [t for t in history[:-keep_recent] if t.get("is_decision")]
return decisions + recent
return recent
# 第 3 层:LLM 摘要(有损,前两层兜不住才用)
def summarize_history(history, llm):
skeleton = "会话意图 / 已完成的步骤 / 关键决策与理由 / 当前进度 / 未决问题 / 下一步"
return llm.complete(f"按字段压缩:{skeleton}\n{history}")很多团队一上来就写第 3 层(最"高级"),结果既贵又丢信息。 正确顺序是 1 → 2 → 3:前两层能解决大部分压力,第 3 层只在"还得挤"时兜底。
六、记忆:别把所有"记住"都塞进上下文
"让 Agent 记住"不等于"放进上下文窗口"。记忆应当分层外置:
| 层 | 存什么 | 放哪 | 生命周期 |
|---|---|---|---|
| 工作记忆 | 当前任务的中间状态 | 上下文窗口 | 任务结束即弃 |
| 情景记忆 | 之前几次会话发生了什么 | 外部存储,按需检索 | 短期,会过期 |
| 语义记忆 | 稳定的事实(用户偏好、架构决策) | 外部存储,常驻少量摘要 | 长期,需人工确认 |
| 程序记忆 | 做事的套路(怎么发布、怎么迁移) | 技能/脚本,按需加载 | 长期 |
关键原则:
- 常驻上下文的只放"索引"(几十到几百 token 的清单),具体内容按需取——这是"能力按需加载"的核心收益
- 记忆必须有过期机制。不朽的记忆会变成污染:三个月前的决定今天已经作废,但模型会照旧执行
- 写入前先判断:这条值得长期记住吗?还是只是本次任务的中间态?
💡 落点建议:语义/情景记忆的持久化,已有 Postgres 的团队可直接用向量库——把"记忆条目"存成带元数据(用户/时效/类型)的向量行,按需检索回填上下文。 索引、过滤与过期清理的具体做法见 DB11 · pgvector。
七、工具与上下文:少即是多
工具定义占据上下文,而且每个工具都会参与模型的决策——工具越多,选错的概率越高。
| 建议 | 理由 |
|---|---|
| 同时暴露 5~8 个工具 | 超过这个数,各模型上的表现普遍下降 |
| 工具职责不重叠 | 重叠会让模型难以抉择,是"混淆"的主要来源 |
| 按需加载而非全量预置 | 用不到却挂在上下文里,是纯浪费 |
| 描述要写清"什么时候用我" | 含糊的描述导致该调不调、不该调乱调 |
一个被反复引用的案例:某团队删掉了一批工具后,任务准确率从 80% 升到 100%。 这说明上下文里的"噪音"代价被系统性低估了——减法常常比加法有效。
八、自检清单
- [ ] 能在不看资料的情况下列出一次调用的 7 类上下文组成,并指出本项目里哪一类最容易膨胀
- [ ] 给自己的系统设了分段的 Token 预算,而不是只有一个总上限
- [ ] 有填充率分区响应策略(70% 时做什么、85% 时做什么)
- [ ] 压缩按三层级联实施,且第 1 层(工具输出截断)已做
- [ ] 记忆是分层外置的,上下文里只放索引
- [ ] 记忆有过期/淘汰机制,不存在"不朽记忆"
- [ ] 工具数量在 8 个以内,职责不重叠
- [ ] 能区分四种失败模式,并说出各自的对策
- [ ] 有上下文的回归测试:已知 query 能否召回对的文档、是否超预算、PII 是否脱敏
九、常见误区
| 误区 | 事实 |
|---|---|
| "窗口够大,资料全塞进去最稳妥" | 填充率过高反而降准确率;聚焦的检索结果稳定优于大段堆料 |
| "模型变笨了就换个更强的模型" | 先查上下文:变长了还是变脏了。换模型常常只是把问题推迟 |
| "压缩就是让模型总结一下" | 那是第 3 层。先做工具输出截断与规则驱逐,便宜且无损 |
| "记忆越多 Agent 越聪明" | 过期记忆是污染的主要来源。记忆要能死 |
| "工具挂全一点,需要时它自己会选" | 工具越多越容易选错。删工具的收益常被低估 |
| "上下文是免费的" | 长上下文的边际成本更高,且有延迟代价 |
| "一次性把系统提示写全就一劳永逸" | 超长系统提示里,靠后的约束会被忽略。原则是写原则,不要写剧本 |
| "压缩完就不用管了" | 压缩有损,可能丢掉后面要用到的细节。关键产物要外置,不要只活在上下文里 |
十、动手练习
练习 A:给现有系统做一次"上下文体检"
挑一个你在跑的(或 demo)Agent,记录每一轮的 token 构成,回答:
- 哪一类的增长最快?
- 有没有"完全没被模型用到"的 token?(业内经验是常有 30%~50% 是浪费的
[易变]) - 到第几轮开始明显变笨?
这个练习的价值在于先测量再优化。凭感觉调上下文,几乎一定是在做无用功。
练习 B:把"加法"改成"减法"
找一个效果不好的 Agent 任务,先不要加提示词、加示例、加资料。 而是删:删掉一半工具、删掉历史里最旧的几轮、把系统提示砍到原来的 1/3。再测一次。
多数人会发现效果变好。这个练习是为了建立"减法优先"的肌肉记忆。
练习 C:设计三层级联
为一个"会调用 5 个工具、跑 20 轮以上"的 Agent 设计压缩策略: 每层的触发条件是什么?各保留什么?第 3 层的摘要用哪些结构化字段?
延伸方向
- 上下文工程与提示词/RAG/微调的层次区分 → 02 · 模型能力增强技术
- 多轮循环与子任务拆分的工程实现 → 实操 06 · Agent 实战:循环到多智能体
- Agent 协议与工具接入的标准化 → 03 · Agent 智能体与协议生态
- 上下文质量的评测与回归 → 05 · 工程化与 LLMOps、实操 05 · 评测集构建与回归
- 检索侧怎么把资料选对 → 实操 02 · RAG 实战、实操 08 · 检索进阶
- 上下文层的注入风险 → 09 · 安全治理与合规
- 具体模型的窗口与定价 → 附录 · 版本与产品速查表