AI Agent 上下文工程实战:从上下文腐烂到 Compaction、Offloading 与多智能体隔离
过去两年,业界对 AI Agent 的注意力几乎全部集中在"模型够不够强"和"工具够不够多"上。但真正把 Agent 从 demo 推到生产环境的人都会撞上同一堵墙:任务跑得越久,Agent 越笨。
不是模型退化了,是上下文坏了。
这篇文章想讲清楚一件事——在长时运行的 Agent 系统里,上下文(context window)不是一条可以无限往里塞东西的传送带,而是一份有预算、有边际收益递减、会被污染的稀缺资源。管理它的能力,已经从"提示词技巧"升级成了一门独立的工程学科:上下文工程(Context Engineering)。
一、Agent 为什么会变笨:四种上下文失败模式
一个跑 50 步以上的 Agent,会话里通常塞着:系统提示词、几十个工具的 schema、每一轮的思考、每一次工具调用的原始返回(可能是一整个 JSON 或几千行日志)、中间结论、失败重试的残骸。
这些堆积物会以四种方式失效:
| 失败模式 | 现象 | 典型触发 |
|---|---|---|
| 上下文中毒(Poisoning) | 幻觉或错误进入上下文,被后续步骤反复引用、放大 | 工具返回错误信息、模型自己编造了不存在的路径 |
| 上下文分心(Distraction) | 历史过长,模型开始"照抄历史动作"而不是重新推理 | 超过 30~50 轮的长会话 |
| 上下文混淆(Confusion) | 无关的工具定义 / 冗余内容参与了决策 | 一次挂载 50+ 工具 schema |
| 上下文冲突(Clash) | 上下文里存在互相矛盾的信息,模型左右为难 | 多轮需求变更、部分完成的子任务 |
这里有个容易被忽略的事实:长上下文的性能衰减不是线性的。即便模型宣称支持 1M token,实际表现上也存在明显的 "lost in the middle" 效应——信息放在开头和结尾时召回率高,放在中段时显著下降。这意味着"把窗口撑大"并不能解决问题,只是把问题推迟并且让它更难复现。
还有一条硬约束是成本与延迟。注意力计算的开销随序列长度增长,KV Cache 线性膨胀。一个 200k token 的会话,每一步的推理成本和首 token 延迟都远高于 20k。在需要多轮迭代的生产任务里,这个差距会直接反映在账单和用户体验上。
所以上下文工程的核心命题是:在给定窗口内,最大化"高信噪比 token"的密度。
二、第一性原理:把上下文当预算来管
我喜欢用预算表来建模。假设一个 200k 窗口的生产 Agent:
| 分区 | 预算 | 说明 |
|---|---|---|
| 系统提示词 | ~3k(硬上限) | 写清"做什么"和"不做什么",避免堆砌边界 case |
| 工具定义 | ~5k | 只挂载当前阶段可能用到的工具 |
| 检索 / 外部知识 | ~40k | 按需注入,带引用来源 |
| 对话与工具历史 | ~100k | 会被压缩,是主要的可优化区域 |
| 结构化输出预留 | ~10k | 给最终答案和结构化字段留空间 |
| 安全余量 | ~40k | 防止溢出截断导致的结构破坏 |
关键是:系统提示词和工具定义是"固定成本",历史是"可变成本"。绝大多数优化空间在历史区。而历史区里最大的浪费,往往是工具返回的原始数据——一次 cat 一个 3000 行的文件,就把 40k token 塞了进去,而真正有用的可能只有三行。
三、六条可落地的策略
我把上下文工程的手段归纳为六个动词:Write、Select、Compress、Isolate、Cache、Forget。
1. Write:把上下文卸载到外部
Agent 需要"工作记忆之外"的草稿纸。典型做法是给 Agent 一个 scratchpad 文件(或数据库表),让它把中间结论、计划、待办写出去,上下文里只保留指针。
这一步的收益不只是省 token——它把"易失的推理过程"变成了"可审计的资产"。任务失败重跑时,可以从 scratchpad 恢复,而不是从头再来。
2. Select:按需检索,而非全量预加载
检索的最小单位是"当前这一步需要什么",而不是"未来可能用到什么"。实践中两条经验:
- 工具返回必须先裁剪再入上下文。给工具加
limit、offset、grep之类的参数,让 Agent 学会精准索取。 - 检索结果带轻量标识(路径、行号、时间戳),保留 3~5 个 token 的元数据,能极大提升模型引用和回溯的准确性。
3. Compress:压缩而非截断
这是最关键的一环。要注意:截断(truncation)不是压缩。截断会丢掉任务目标、丢掉已完成的约束,直接导致 Agent 迷失方向。真正的压缩是有损但保语义的重写。
4. Isolate:用子智能体隔离上下文
最干净的做法:主 Agent 只负责规划和验收,脏活累活派给子 Agent。子 Agent 在独立上下文里翻一万行日志,只回传 200 token 的结论。
这是性价比最高的一招——它把"上下文消耗"从 O(全部原始数据) 降到 O(结论),还天然支持并行。
5. Cache:前缀缓存的布局纪律
主流推理服务都支持 prefix caching(前缀相同的请求复用 KV Cache)。但缓存命中有一个前提:前缀必须逐 token 一致。
这带来一条工程纪律:把稳定内容放前面,把易变内容放后面。系统提示词 → 工具定义 → 长期背景 → 检索结果 → 对话历史(最新在末尾)。任何一处插入时间戳、随机 ID、动态排序的工具列表,都会让缓存整体失效。
6. Forget:主动遗忘
不是所有历史都值得保留。失败路径、被否决的方案、已修复的错误,这些留在上下文里只会增加干扰。压缩时应当显式丢弃它们。
四、代码实战
下面给出一个可运行的最小实现。
4.1 带预算的上下文管理器
from dataclasses import dataclass, field
from typing import Callable
import tiktoken
@dataclass
class Budget:
system: int = 3_000
tools: int = 5_000
retrieval: int = 40_000
history: int = 100_000
reserve: int = 10_000
@dataclass
class Turn:
role: str
content: str
tokens: int = 0
pinned: bool = False # 钉住的关键决策,压缩时不丢弃
kind: str = "dialog" # dialog | tool_result | scratch
class ContextManager:
def __init__(self, window: int = 200_000, budget: Budget = Budget(),
encoder_name: str = "cl100k_base"):
self.window = window
self.budget = budget
self.enc = tiktoken.get_encoding(encoder_name)
self.turns: list[Turn] = []
self.scratch: dict[str, str] = {} # 外部草稿纸
def count(self, text: str) -> int:
return len(self.enc.encode(text))
def add(self, role: str, content: str, kind: str = "dialog", pinned: bool = False):
t = Turn(role, content, self.count(content), pinned, kind)
self.turns.append(t)
self._enforce()
def usage(self) -> int:
return sum(t.tokens for t in self.turns)
def _enforce(self):
"""超预算时优先裁剪 tool_result,其次压缩历史。"""
while self.usage() > self.budget.history:
victim = self._pick_victim()
if victim is None:
break
self._offload(victim) # 先卸载到 scratch,再移除
self.turns.remove(victim)
def _pick_victim(self):
# 优先级:未钉住的 tool_result > 未钉住的早期 dialog
cands = [t for t in self.turns if not t.pinned]
if not cands:
return None
tool_outs = [t for t in cands if t.kind == "tool_result"]
pool = tool_outs or cands
return min(pool, key=lambda t: t.tokens if t.kind == "tool_result" else -t.tokens)
def _offload(self, t: Turn):
"""把大块内容写进草稿纸,上下文里只留指针 + 摘要。"""
key = f"offload_{id(t)}"
self.scratch[key] = t.content
head = t.content[:400]
t.content = (f"[已卸载到 scratch:{key},共 {t.tokens} tokens]\n"
f"摘要:{head}...\n如需完整内容调用 read_scratch('{key}')")
t.tokens = self.count(t.content)
t.kind = "scratch"
t.pinned = True
这段代码的思路是:永不直接丢弃,先卸载再引用。Agent 需要原文时通过 read_scratch 工具精准取回,避免了"要么全留、要么全扔"的两难。
4.2 两阶段压缩
压缩本身的调用也是成本,所以生产上一般用两阶段:
COMPRESS_LIGHT = """请把以下对话压缩为结构化摘要,必须保留:
1. 用户的原始目标与所有硬约束(不得改写)
2. 已经完成的步骤及产出物(路径/ID/结论)
3. 当前未完成步骤与下一步动作
4. 尚未解决的阻塞与错误
丢弃:失败重试过程、被否决的方案、重复的寒暄。
输出不超过 {n} tokens 的 Markdown。"""
COMPRESS_FULL = """这是一次任务重置。请基于下方摘要生成一份新的任务简报,
使接手的 Agent 无需查看历史即可继续工作。
包含:目标、约束、已完成、进行中、待办、关键上下文指针(scratch key)。
不要包含推理过程。"""
def compress(ctx: ContextManager, llm: Callable[[str], str], threshold: float = 0.75):
if ctx.usage() < ctx.budget.history * threshold:
return False
payload = "\n".join(f"[{t.role}] {t.content}" for t in ctx.turns if not t.pinned)
summary = llm(COMPRESS_LIGHT.format(n=1500).format(payload=payload))
brief = llm(COMPRESS_FULL.format(summary=summary))
ctx.turns = [Turn("system", brief, ctx.count(brief), pinned=True, kind="brief")]
return True
要点:压缩产物必须是"可接手的简报",不是"流水账摘要"。区别在于简报能让一个全新上下文的 Agent 立刻干活。
4.3 子智能体隔离
def delegate(prompt: str, tools: list, llm, max_steps: int = 8) -> str:
"""在独立上下文里跑脏活,只回传结论。"""
sub_ctx = ContextManager(window=32_000, budget=Budget(
system=1_000, tools=5_000, retrieval=10_000, history=12_000, reserve=4_000))
sub_ctx.add("system", "你是调研子代理。只输出结论,不要输出过程。", pinned=True)
sub_ctx.add("user", prompt)
for _ in range(max_steps):
reply = llm(sub_ctx.render(), tools)
if reply.is_final:
return reply.text[:2000] # 硬性截断回传长度
sub_ctx.add("tool_result", reply.tool_output, kind="tool_result")
compress(sub_ctx, llm, threshold=0.8)
return "[子代理超时,无有效结论]"
主 Agent 侧拿到的永远是一个短字符串。这是把上下文复杂度从"乘性增长"压回"加性增长"的关键。
4.4 前缀缓存友好的装配顺序
def render(ctx: ContextManager, tools, history) -> list[dict]:
# 稳定前缀:顺序与内容必须逐 token 稳定
head = [
{"role": "system", "content": SYSTEM_PROMPT}, # 不含时间戳
{"role": "system", "content": render_tools(tools)}, # 排序固定
{"role": "system", "content": LONG_TERM_BACKGROUND},
]
# 半稳定区:检索结果(同任务内多次调用保持一致)
mid = [{"role": "system", "content": r} for r in ctx.retrieval]
# 易变区:对话历史,最新在末尾
tail = [{"role": t.role, "content": t.content} for t in history]
return head + mid + tail
反例警示:在系统提示词里插 当前时间:2026-09-29 15:56,等于每次请求都破坏整个前缀缓存,命中率直接归零。时间这类信息应该放到历史区的末尾。
五、生产级踩坑清单
结合实践,列几条容易被忽略的:
- 压缩不是免费的。压缩本身消耗一次完整调用。频率控制在 0.7~0.8 水位触发,且要防抖(避免每次添加都触发)。
- 压缩会丢失"隐性共识"。用户说过的"别用那个库"如果没被显式提取成约束,压缩后就没了。所以压缩提示词里必须显式要求保留硬约束。
- 工具 schema 是隐形大户。50 个工具的详细 schema 轻松吃掉 8~10k。做法是按阶段动态挂载,或者只给工具名 + 一行描述,详情按需加载。
- 并行工具调用会放大上下文。一次并行 10 个搜索,返回全进上下文。要么限制并行度,要么强制子代理处理。
- 一定要加可观测性。记录每次请求的 token 构成(system/tools/retrieval/history)、缓存命中率、压缩次数。没有这些数据,上下文优化就是盲人摸象。
- 别用截断兜底。截断破坏的是结构(半截 JSON),比超窗口更危险。宁可提前压缩。
六、结语
上下文工程这门学科出现的时间不长,但它解决的问题非常古老——有限注意力资源下的信息取舍。
我的判断是:未来 Agent 框架的竞争力,会越来越少地体现在"支持多少工具""接了多少模型",而越来越多地体现在"能不能在 100 步之后依然清醒"。模型能力是上限,上下文工程决定你能不能摸到那个上限。
具体到落地,建议按这个顺序推进,投入产出比从高到低:
- 先做工具返回裁剪(成本最低,收益立竿见影)
- 再做子智能体隔离(架构改动,但收益最大)
- 然后上压缩(兜底长尾任务)
- 最后调缓存布局(规模化后的成本优化)
把上下文当成预算来管理,Agent 才真正具备跑长任务的能力。

发表评论 取消回复