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,等于每次请求都破坏整个前缀缓存,命中率直接归零。时间这类信息应该放到历史区的末尾。


五、生产级踩坑清单

结合实践,列几条容易被忽略的:

  1. 压缩不是免费的。压缩本身消耗一次完整调用。频率控制在 0.7~0.8 水位触发,且要防抖(避免每次添加都触发)。
  2. 压缩会丢失"隐性共识"。用户说过的"别用那个库"如果没被显式提取成约束,压缩后就没了。所以压缩提示词里必须显式要求保留硬约束。
  3. 工具 schema 是隐形大户。50 个工具的详细 schema 轻松吃掉 8~10k。做法是按阶段动态挂载,或者只给工具名 + 一行描述,详情按需加载。
  4. 并行工具调用会放大上下文。一次并行 10 个搜索,返回全进上下文。要么限制并行度,要么强制子代理处理。
  5. 一定要加可观测性。记录每次请求的 token 构成(system/tools/retrieval/history)、缓存命中率、压缩次数。没有这些数据,上下文优化就是盲人摸象。
  6. 别用截断兜底。截断破坏的是结构(半截 JSON),比超窗口更危险。宁可提前压缩。

六、结语

上下文工程这门学科出现的时间不长,但它解决的问题非常古老——有限注意力资源下的信息取舍。

我的判断是:未来 Agent 框架的竞争力,会越来越少地体现在"支持多少工具""接了多少模型",而越来越多地体现在"能不能在 100 步之后依然清醒"。模型能力是上限,上下文工程决定你能不能摸到那个上限。

具体到落地,建议按这个顺序推进,投入产出比从高到低:

  1. 先做工具返回裁剪(成本最低,收益立竿见影)
  2. 再做子智能体隔离(架构改动,但收益最大)
  3. 然后上压缩(兜底长尾任务)
  4. 最后调缓存布局(规模化后的成本优化)

把上下文当成预算来管理,Agent 才真正具备跑长任务的能力。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.410693s