长程 Agent 的上下文工程:截断、摘要压缩与检索回放
系列导航:00 系列导航 · 01 单 Agent 总论 · 02 ReAct 原理 · 03 手写内核 · 04 ACI 工具设计 · 05 上下文工程 · 06 两个增强变体 · 07 上线前清单 小提一句: 由于平台每日发布笔记数量限制,目前系列笔记还没全部上传。后续内容会持续更新,大家可以先点个关注、收藏,以免错过~
引子:上下文是唯一不可扩容的资源
算力可以加钱买,模型可以换更强的,工具可以慢慢补。只有上下文窗口是硬的,它是单 Agent 的物理天花板,且 Agent 的历史只增不减。
第 1 篇给过测算公式:
可支撑步数 N ≈ (窗口 W × 安全系数 α − 固定开销 F) / 单步增量 k
这篇讲的就是三个变量怎么调:F 怎么压、k 怎么压、W 怎么用满而不失效。
先明确一个反直觉的事实:长上下文不等于长记忆。把 200k token 塞进窗口,模型并不会平等地对待每一个 token,Lost in the Middle(Liu et al., TACL 2024)等研究表明,模型对开头和结尾的内容关注度高,对中间的利用率显著下降。这意味着:
上下文不是储物间,是工作台。堆在台面上的东西越多,每件东西被用到的概率越低。
所以上下文工程的目标不是"塞更多信息",而是提高单位 token 的决策价值。
一、上下文的四类内容与预算分配
先把上下文拆开。一次 Agent 调用的 prompt 由四部分组成,它们的性质完全不同:
| ① 固定区 | system prompt、工具定义 | 每步完全相同 | 5~15% | 精简工具集(第 4 篇)、放前缀最前面吃缓存 |
| ② 任务区 | 任务描述、约束、输出格式 | 每次任务固定 | 1~5% | 与①一起构成稳定前缀 |
| ③ 压缩区 | 远期轨迹的摘要 / 检索回放的片段 | 低频变化 | 10~30% | 本文主题:只在压缩点更新 |
| ④ 热区 | 最近 N 步的完整 T/A/O | 每步增长 | 50~80% | 截断 Observation、控制 N |
这四类的排列顺序比内容本身更要紧(第 6 节前缀缓存会解释为什么):
#mermaid-svg-uaBiNKrf5zR6SEf5{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uaBiNKrf5zR6SEf5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uaBiNKrf5zR6SEf5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uaBiNKrf5zR6SEf5 .error-icon{fill:#552222;}#mermaid-svg-uaBiNKrf5zR6SEf5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uaBiNKrf5zR6SEf5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uaBiNKrf5zR6SEf5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uaBiNKrf5zR6SEf5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uaBiNKrf5zR6SEf5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uaBiNKrf5zR6SEf5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uaBiNKrf5zR6SEf5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uaBiNKrf5zR6SEf5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uaBiNKrf5zR6SEf5 .marker.cross{stroke:#333333;}#mermaid-svg-uaBiNKrf5zR6SEf5 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uaBiNKrf5zR6SEf5 p{margin:0;}#mermaid-svg-uaBiNKrf5zR6SEf5 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-uaBiNKrf5zR6SEf5 .cluster-label text{fill:#333;}#mermaid-svg-uaBiNKrf5zR6SEf5 .cluster-label span{color:#333;}#mermaid-svg-uaBiNKrf5zR6SEf5 .cluster-label span p{background-color:transparent;}#mermaid-svg-uaBiNKrf5zR6SEf5 .label text,#mermaid-svg-uaBiNKrf5zR6SEf5 span{fill:#333;color:#333;}#mermaid-svg-uaBiNKrf5zR6SEf5 .node rect,#mermaid-svg-uaBiNKrf5zR6SEf5 .node circle,#mermaid-svg-uaBiNKrf5zR6SEf5 .node ellipse,#mermaid-svg-uaBiNKrf5zR6SEf5 .node polygon,#mermaid-svg-uaBiNKrf5zR6SEf5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-uaBiNKrf5zR6SEf5 .rough-node .label text,#mermaid-svg-uaBiNKrf5zR6SEf5 .node .label text,#mermaid-svg-uaBiNKrf5zR6SEf5 .image-shape .label,#mermaid-svg-uaBiNKrf5zR6SEf5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-uaBiNKrf5zR6SEf5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-uaBiNKrf5zR6SEf5 .rough-node .label,#mermaid-svg-uaBiNKrf5zR6SEf5 .node .label,#mermaid-svg-uaBiNKrf5zR6SEf5 .image-shape .label,#mermaid-svg-uaBiNKrf5zR6SEf5 .icon-shape .label{text-align:center;}#mermaid-svg-uaBiNKrf5zR6SEf5 .node.clickable{cursor:pointer;}#mermaid-svg-uaBiNKrf5zR6SEf5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-uaBiNKrf5zR6SEf5 .arrowheadPath{fill:#333333;}#mermaid-svg-uaBiNKrf5zR6SEf5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-uaBiNKrf5zR6SEf5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-uaBiNKrf5zR6SEf5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uaBiNKrf5zR6SEf5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-uaBiNKrf5zR6SEf5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uaBiNKrf5zR6SEf5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-uaBiNKrf5zR6SEf5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-uaBiNKrf5zR6SEf5 .cluster text{fill:#333;}#mermaid-svg-uaBiNKrf5zR6SEf5 .cluster span{color:#333;}#mermaid-svg-uaBiNKrf5zR6SEf5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-uaBiNKrf5zR6SEf5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-uaBiNKrf5zR6SEf5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-uaBiNKrf5zR6SEf5 .icon-shape,#mermaid-svg-uaBiNKrf5zR6SEf5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-uaBiNKrf5zR6SEf5 .icon-shape p,#mermaid-svg-uaBiNKrf5zR6SEf5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-uaBiNKrf5zR6SEf5 .icon-shape .label rect,#mermaid-svg-uaBiNKrf5zR6SEf5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-uaBiNKrf5zR6SEf5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-uaBiNKrf5zR6SEf5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-uaBiNKrf5zR6SEf5 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
稳定前缀 · 可命中 prompt caching
① 固定区system prompt · 工具定义
② 任务区任务描述 · 约束 · 输出格式
③ 压缩区远期摘要 / 检索回放片段只在压缩点更新
④ 热区最近 N 步完整 T/A/O每步增长 50~80%
二、三种压缩策略
按保真度从低到高排列:
| 截断 / 滑动窗口 | 丢弃最早的 T/A/O,只留最近 N 步 | 低 | 极低 | 早期关键事实永久丢失,且模型不知道自己忘了 |
| 摘要压缩 | 用 LLM 把历史压成结构化笔记 | 中 | 中(多一次调用) | 摘要引入二次幻觉;摘要本身会累积漂移 |
| 检索回放 | 轨迹全量落盘,按需检索注入 | 高 | 中高(需存储与检索) | 检索质量决定成败;模型不知道该检索什么 |
下面逐个讲清楚实现与坑。
2.1 截断 / 滑动窗口
def sliding_window(steps: list[Step], n: int = 6) –> list[Step]:
return steps[–n:]
简单到不值一提,但它是所有策略的兜底。任何系统最终都需要一个"实在装不下就丢最旧的"的保底机制。它的真实问题不在丢东西,而在:
模型不知道自己丢了什么。 它会基于"我从没查过 X"来做决策,而实际上它查过,只是被丢了。
一个便宜的缓解:丢弃时插入一条系统提示:
[系统] 为控制上下文长度,step 1-8 的详细内容已被移除。
这些步骤中确认过的事实摘要:<摘要>。
如果你需要某一步的完整 Observation,调用 recall("关键词")。
这就把它从"截断"升级成了"截断 + 检索回放",代价极小。
2.2 摘要压缩
关键设计:增量式 + 结构化 + 带溯源。
SUMMARY_PROMPT = """请把以下 Agent 轨迹片段压缩成结构化笔记。
已有笔记(若为空则忽略):
{previous}
新增轨迹:
{turns}
输出格式(严格遵守):
## 已确认事实
– [step {i}] {事实} ← 每条必须标注来自哪一步
## 已排除/已证伪
– [step {i}] {假设} → 已证伪,理由:{…}
## 待办与未决
– {还差什么}
## 关键句柄
– {handle} → {内容简述}
规则:
– 只记录 Observation 中出现过的事实,不要补充任何推理或背景知识;
– 无法确定的写"未确认",不要猜测;
– 与已有笔记冲突时,保留更晚的 step,并在"已证伪"中记录被推翻的旧结论。
"""
async def summarize(turns: list[Step], llm, previous: str = "") –> str:
body = "\\n".join(render_brief(s) for s in turns)
return await llm(SUMMARY_PROMPT.format(previous=previous or "(无)", turns=body))
四个设计要点,每一个都对应一个真实的坑:
⚠️ 摘要的最大风险:二次幻觉。 摘要本身是一次 LLM 生成,它可能把"疑似"写成"确认"。在事实敏感的任务(故障排查、合规)上,我建议摘要只压缩 Thought 和 Action,Observation 中的关键事实原文保留,Observation 是环境给的,它是唯一不该被模型重写的内容。
2.3 检索回放
思路:轨迹全量落盘(便宜,磁盘不值钱),上下文里只放一个 recall(query) 工具,模型需要时自己取。
class TrajectoryStore:
"""全量轨迹落盘 + 关键词检索。生产可换成向量库或 BM25。"""
def __init__(self):
self.items: list[dict] = []
def add(self, step: Step):
self.items.append({
"i": step.i,
"text": f"{step.thought}\\n{step.call.name} {step.call.args}\\n{step.observation}",
})
def search(self, query: str, k: int = 3) –> str:
# 演示用:词频打分;生产建议 BM25 / embedding
scored = sorted(
self.items,
key=lambda it: –sum(it["text"].count(w) for w in query.split() if len(w) > 1),
)[:k]
if not scored:
return "未命中任何历史步骤。可换用更具体的关键词(服务名、错误码、文件名)。"
return "\\n—\\n".join(f"[step {it['i']}]\\n{it['text'][:1500]}" for it in scored)
把它做成工具:
@tools.register(
name="recall",
description="从本次任务的完整历史中检索之前的步骤内容(含已移出上下文的早期步骤)。",
params={"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]},
returns="命中的历史步骤原文(最多 3 条)",
)
async def recall(args):
return store.search(args["query"])
保真度最高,因为回放的是原文。但它有一个结构性弱点:
检索的前提是"知道自己要找什么"。模型不会去检索它压根不记得自己见过的东西。
所以检索回放不能单独使用,必须与摘要搭配——摘要负责告诉模型"你见过什么"(元知识),检索负责在需要时提供原文(细节)。这是下一节组合架构的核心思路。
三、组合架构:热 / 温 / 冷三层
生产实践中,三种策略几乎总是组合使用:
#mermaid-svg-DJvZJuFkMo4Ly1SJ{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .error-icon{fill:#552222;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .marker.cross{stroke:#333333;}#mermaid-svg-DJvZJuFkMo4Ly1SJ svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DJvZJuFkMo4Ly1SJ p{margin:0;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .cluster-label text{fill:#333;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .cluster-label span{color:#333;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .cluster-label span p{background-color:transparent;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .label text,#mermaid-svg-DJvZJuFkMo4Ly1SJ span{fill:#333;color:#333;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .node rect,#mermaid-svg-DJvZJuFkMo4Ly1SJ .node circle,#mermaid-svg-DJvZJuFkMo4Ly1SJ .node ellipse,#mermaid-svg-DJvZJuFkMo4Ly1SJ .node polygon,#mermaid-svg-DJvZJuFkMo4Ly1SJ .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .rough-node .label text,#mermaid-svg-DJvZJuFkMo4Ly1SJ .node .label text,#mermaid-svg-DJvZJuFkMo4Ly1SJ .image-shape .label,#mermaid-svg-DJvZJuFkMo4Ly1SJ .icon-shape .label{text-anchor:middle;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .rough-node .label,#mermaid-svg-DJvZJuFkMo4Ly1SJ .node .label,#mermaid-svg-DJvZJuFkMo4Ly1SJ .image-shape .label,#mermaid-svg-DJvZJuFkMo4Ly1SJ .icon-shape .label{text-align:center;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .node.clickable{cursor:pointer;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .arrowheadPath{fill:#333333;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DJvZJuFkMo4Ly1SJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DJvZJuFkMo4Ly1SJ .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DJvZJuFkMo4Ly1SJ .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .cluster text{fill:#333;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .cluster span{color:#333;}#mermaid-svg-DJvZJuFkMo4Ly1SJ div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DJvZJuFkMo4Ly1SJ rect.text{fill:none;stroke-width:0;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .icon-shape,#mermaid-svg-DJvZJuFkMo4Ly1SJ .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .icon-shape p,#mermaid-svg-DJvZJuFkMo4Ly1SJ .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .icon-shape .label rect,#mermaid-svg-DJvZJuFkMo4Ly1SJ .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DJvZJuFkMo4Ly1SJ .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DJvZJuFkMo4Ly1SJ .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DJvZJuFkMo4Ly1SJ :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
上下文窗口(有限)
每步写入
每 4~6 步压缩
需要细节时检索
固定区system · 工具定义
任务区task
温区 · 摘要已确认 / 已证伪 / 待办每 4~6 步更新一次
热区 · 最近 6 步原文T/A/O 完整保留每步追加
冷区 · 全量轨迹落盘磁盘 / 对象存储通过 recall(query) 按需取回原文
三层各管一件事:热区给细节,温区给元知识,冷区给保真备份。
实现:
@dataclass
class CtxConfig:
window: int = 200_000
reserve: int = 8_000 # 给生成留的空间
hot_turns: int = 6 # 热区步数
compress_every: int = 4 # 每新增多少步做一次压缩
obs_limit: int = 1_200 # 单条 Observation 的 token 上限
class ContextManager:
def __init__(self, task: str, tools: ToolRegistry,
llm, cfg: CtxConfig = CtxConfig()):
self.task, self.tools, self.llm, self.cfg = task, tools, llm, cfg
self.turns: list[Step] = [] # 全量(内存)+ 落盘
self.summary: str = "" # 温区
self.store = TrajectoryStore() # 冷区
self._since_compress = 0
def add(self, step: Step):
self.turns.append(step)
self.store.add(step)
self._since_compress += 1
async def maybe_compress(self):
"""只在达到阈值时压缩——这是为前缀缓存考虑的关键设计"""
if self._since_compress < self.cfg.compress_every:
return
cold = self.turns[: –self.cfg.hot_turns]
if not cold:
return
self.summary = await summarize(cold, self.llm, self.summary)
self._since_compress = 0
def render(self) –> str:
hot = self.turns[–self.cfg.hot_turns:]
parts = [
SYSTEM, # ① 固定区
"# 可用工具\\n" + self.tools.render(),
f"# 任务\\n{self.task}", # ② 任务区
]
if self.summary:
parts.append(f"# 早期步骤摘要\\n{self.summary}") # ③ 温区
parts.append("# 近期轨迹")
for s in hot: # ④ 热区
parts.append(f"[step {s.i}]")
if s.thought:
parts.append(f"<think>{s.thought}</think>")
parts.append("Action: " + json.dumps(
{"action": s.call.name, "args": s.call.args}, ensure_ascii=False))
if s.observation:
parts.append(f"Observation: {s.observation}")
parts.append(f"[step {self.turns[–1].i + 1}]")
return "\\n".join(parts)
def usage_ratio(self) –> float:
return approx_tokens(self.render()) / (self.cfg.window – self.cfg.reserve)
usage_ratio() 应该被监控起来:超过 0.7 就该告警,说明你的任务正在逼近窗口,需要调 hot_turns 或 obs_limit 了。
四、上下文污染与 checkpoint 回滚
第 2 篇推导过:Observation 是推理链上的重置点,只重置后续,不清除既有。一条早期错误事实一旦进入上下文,会被后续 Thought 反复引用、自我强化。这就是上下文污染。
三种治理手段,按代价从小到大:
① 显式标注证伪。在摘要里用"已证伪"字段记录,同时在热区插入一条系统更正:
def mark_refuted(ctx: ContextManager, step_i: int, reason: str):
ctx.summary += (f"\\n## 已证伪\\n- [step {step_i}] 该步的结论已被推翻:{reason}。"
f"后续推理不得再引用该步的事实。")
② Checkpoint + 回滚。在关键决策点打点,出错时回退:
class ContextManager:
# … 接上文
def checkpoint(self) –> int:
return len(self.turns)
def rollback(self, mark: int, reason: str):
"""回退到 mark。注意:不是简单丢弃,而是留一条'已作废'记录"""
discarded = self.turns[mark:]
self.turns = self.turns[:mark]
self.summary += (
f"\\n## 已作废的尝试\\n"
f"- 曾执行 {len(discarded)} 步(step {mark+1}~{mark+len(discarded)}),"
f"因「{reason}」被作废。结论:不要重复该路径。"
)
注意第 ② 条里那个容易被忽略的细节:回滚不能"干净地丢弃"。如果完全不留痕迹,模型不记得自己走过这条路,很可能原样再走一遍。你付出了回滚的代价,却没买到任何信息。留一条简短的"已作废"记录(几十 token),既省空间又保留了负反馈。
③ 结构性隔离。对高污染风险的来源(如 LLM 生成的中间结论),不直接放进主上下文,而是先写入 scratchpad 由代码校验后再注入。这条成本最高,只在事实敏感场景使用。
五、把单步增量 k 压下来:最便宜的优化
回到公式 N ≈ (W×α − F) / k。降低 k 是三个变量里投入产出比最高的,因为它不需要任何压缩机制。
k 的构成:len(Thought) + len(Action) + len(Observation),其中 Observation 通常占 70% 以上。所以压 k 就是压 Observation:
| 结构化摘要而非原文 | 2000 → 200 token | 工具内部(第 4 篇军规 7) |
| 句柄 + 按需读取 | 20000 → 150 token | 工具返回 + read_range 工具 |
| 强制 limit / 分页 | 直接封顶 | 工具参数(poka-yoke) |
| 截断标注 | 不改变大小,但避免误判 | truncate()(第 3 篇) |
| Thought 三段式模板 | 抑制废话,省 30~50% | system prompt(第 2 篇) |
一个真实量级的对比:
优化前:单步 k ≈ 2,800 token(日志原文 2000 + 思考 500 + 动作 300)
→ 200k 窗口能撑 ≈ 67 步
优化后:单步 k ≈ 900 token(结构化摘要 200 + 句柄 100 + 思考 400 + 动作 200)
→ 同样窗口能撑 ≈ 210 步,且每步的注意力更集中
同样的模型、同样的窗口、同样的工具,只因为返回形状不同,可用步数翻了三倍。第 4 篇的返回设计是整个系列投入产出比最高的部分。
六、前缀缓存:一个影响架构顺序的硬约束
几乎所有主流厂商都提供 prompt caching(前缀缓存):如果两次请求的前缀完全相同,命中部分按折扣价计费(通常 0.1~0.5×)且延迟更低。
这对上下文策略有两个直接推论:
推论 1:把稳定内容放在最前面,且顺序固定。
system prompt → 工具定义 → 任务描述 → [摘要] → 热区轨迹
└─────────── 稳定前缀,跨步可缓存 ──────────┘
任何放在前缀里的东西发生变化,从变化点开始全部缓存失效。所以:
- 工具定义不要动态增删(别做"每步只加载相关工具",那会破坏缓存,除非你按任务类型一次性加载并保持稳定);
- 不要把时间戳、随机数、请求 ID 等易变内容放进前缀。
推论 2:摘要不要每步重算。
如果摘要每步都更新,那么"摘要"及其之后的所有内容每步都失效,缓存命中率归零。所以第 3 节的 compress_every 不只是为了省调用——它是为了让摘要在压缩周期内保持稳定,从而让前缀可缓存。
每步重算摘要: 前缀 = [system + tools + task] → 缓存只有固定区
每 N 步重算: 前缀 = [system + tools + task + summary]
→ 在 N 步内,前缀完全稳定,缓存命中率大幅提升 ✅
这笔账很好算:N=4 时,热区(4 步 × 900 = 3600 token)走全价,前缀(假设 6000 token)走折扣价。步数越多、固定区越大,这个优化的收益越显著。
七、怎么衡量上下文做得好不好:四个指标
| 有效信息密度 | 被后续 Thought 实际引用过的上下文 token / 总上下文 token | 回溯 trace:对每个 Observation 片段,检查后续 Thought 是否提及其内容(可用关键词/embedding 匹配近似) | ≥ 40%;低于 25% 说明上下文里堆了大量无用内容 |
| 压缩失真率 | 因压缩导致关键事实丢失,进而使任务失败的比例 | 构造一组"答案依赖早期步骤"的任务,分别在无压缩 / 压缩下跑,比较成功率差 | ≤ 3% |
| 上下文水位 P95 | approx_tokens(prompt) / 窗口 的 P95 | trace 统计 | ≤ 0.7;> 0.85 说明任务已超界,该分层或分治了 |
| 污染事件率 | 出现"引用已被证伪事实"的步数占比 | 标注已证伪条目后,检测后续 Thought 是否仍引用 | ≤ 2% |
其中有效信息密度是我最推荐的指标,因为它直接回答了"我塞进去的东西有没有用"。测起来也简单(近似版):
def info_density(trace: list[Step]) –> float:
total = sum(approx_tokens(s.observation or "") for s in trace)
cited = 0
for i, s in enumerate(trace):
later = " ".join((t.thought or "") for t in trace[i + 1:])
# 近似:抽取 observation 中的关键 token,看是否出现在后续 thought 中
keys = {w for w in re.findall(r"[A-Za-z_]{4,}|[\\u4e00-\\u9fff]{2,}", s.observation or "")}
hits = sum(1 for w in list(keys)[:20] if w in later)
if hits >= 2:
cited += approx_tokens(s.observation or "")
return cited / total if total else 0.0
跑一批 trace,如果这个数字是 20%,那你的第一优先级不是换压缩策略,而是少往上下文里放东西。
八、反思记忆也是一种上下文
第 6 篇的 Reflexion 会把历史反思注入下一次尝试。请注意:反思记忆同样服从本文的全部约束。
- 反思会随尝试次数线性增长 → 必须限制条数(K=3~5)并做相关性检索,不能全量注入;
- 一条错误的反思就是一条高浓度的上下文污染(它是"经验",模型会格外信任它)→ 必须允许人工清洗;
- 反思应放在哪个区?温区(与摘要并列,作为"跨尝试的元知识"),而不是热区。
把反思库当作"跨会话的温区"来管理,第 6 篇的三条约束(限条数、相关性注入、允许清洗)就有了统一解释。
九、小结
上下文工程从头到尾都在跟一个硬约束打交道:窗口有限,历史只增不减,而长上下文并不等于长记忆。所有招式都指向同一个目标——提高单位 token 的决策价值。
四区按稳定性分层、三种压缩策略组合成热温冷三层、把单步增量 k 压到最低、为前缀缓存把稳定内容前置、摘要按阈值而不是每步更新。这几件事看着各自独立,其实是同一条思路的不同侧面。
其中性价比最高的是压 k。同样的模型、窗口和工具,只改工具的返回形状,可用步数就能翻几倍,而且不需要上任何压缩机制。
把反思也当作受约束的上下文(温区)来管理,前面几篇看起来零散的约束就有了统一解释。
做得好不好,用四个数字衡量:有效信息密度 ≥40%、压缩失真率 ≤3%、上下文水位 P95 ≤0.7、污染事件率 ≤2%。其中信息密度最能直接回答那句话——塞进去的东西到底有没有用。
⚠️ 本系列明确存疑项:三种压缩策略在 >100 步任务上没有公开定量对比基准,上表阈值均为工程经验值,请按自己的任务集校准。
上一篇:04 ACI工具设计方法论 下一篇:06 Plan-and-Execute 与 Reflexion:给 ReAct 装上全局锚和经验回放
参考
- Liu et al., Lost in the Middle: How Language Models Use Long Contexts, TACL 2024.
- Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models, ICLR 2023, arXiv:2210.03629(上下文只增不减的原始设定)。
- Shinn et al., Reflexion: Language Agents with Verbal Reinforcement Learning, NeurIPS 2023, arXiv:2303.11366(情景记忆作为上下文)。
- Anthropic, Building Effective Agents, 2024-12。

