《上下文工程》系列前几篇反复点名同一个嫌疑人:工具结果。第 2 篇的实测账本里,24 条日志 JSON 组成的工具结果吃掉了 73% 的窗口;第 4 篇的预算拆账又把它确认为单轮成本中两三倍的大头。它的膨胀方式也特殊:用户消息和模型回复写完就定形,工具结果却一轮一垛地往窗口里堆——搜索一次 5000 token,读个文件上万,20 轮跑完,窗口里大半是模型早就消化完、却还赖着不走的原料。
本篇是系列第 8 篇,讲 Anthropic 奠基文章里的 tool result clearing(工具结果清理):既让模型在当轮看得足够多,又不让旧结果永久占用窗口。全文按"膨胀模型 → 源头瘦身 → 清理机制 → 手写实现 → 保留策略 → 幻觉护栏 → 与压缩配合"组织,代码基于 LangGraph,可直接接进第 5 篇的 Agent 图。它也是 Agent 系列第 22 篇工具设计模式的上下文侧补充——那一篇讲工具怎么设计得好用,这一篇讲工具返回的东西怎么治理得省。
1、工具结果的双曲膨胀
先给膨胀建个模。工具结果的窗口占用是一个乘积:占用 = 单次体量 × 已过轮数。麻烦在于两个因子都在涨——任务越深,单次返回越大(第 1 轮搜关键词,第 15 轮读整段日志),轮数本身还在推进。两个自变量同时增长的乘积,增速远超线性,这就是双曲膨胀。
把这笔账算出来只要几行代码。
def tool_result_bill(rounds: int, per_result: int) -> None:
"""工具结果的窗口滞留量与累计输入账单"""
for r in range(5, rounds + 1, 5):
resident = r * per_result # 窗口内滞留总量
billed = per_result * r * (r + 1) // 2 # 每轮重付的累计输入
print(f"第 {r:2d} 轮: 滞留 {resident:>7,} | 累计输入 {billed:>9,}")
tool_result_bill(20, 5_000) # 单次搜索结果 5000 token
输出如下。
第 5 轮: 滞留 25,000 | 累计输入 75,000
第 10 轮: 滞留 50,000 | 累计输入 275,000
第 15 轮: 滞留 75,000 | 累计输入 600,000
第 20 轮: 滞留 100,000 | 累计输入 1,050,000
账单要分两栏看。左栏"滞留"是窗口占用:第 20 轮时 10 万 token 的旧结果还躺在窗口里,挤压注意力、加速 context rot(第 2 篇)。右栏"累计输入"是没开缓存时的总输入开销,是轮数的二次函数——每过一轮,此前所有旧结果都要再计费一次。开 prompt caching(第 6 篇)能把右栏砍掉大半,但缓存救得了账单,救不了注意力:左栏一点没少,窗口照旧被塞满。治理工具结果,治的就是左栏。
2、工具侧的源头瘦身
治理的第一刀不在上下文侧,在工具侧:进窗体积越小,后续每一轮的滞留基数就越小。Agent 系列第 22 篇讲过工具设计模式,本篇只聚焦其中一个维度——token。三招:摘要加句柄、分页、字段裁剪。以一个日志搜索工具为例。
from typing import Any
def search_logs(query: str, limit: int = 3) -> dict[str, Any]:
"""搜索日志:只返回摘要与句柄,单条详情按 id 再取"""
rows = backend_search(query) # 假设命中 12 条
briefs = [
{"id": r.id, "time": r.time, "level": r.level,
"summary": r.message[:80]} # 字段裁剪 + 单条截断
for r in rows[:limit] # 分页:默认 top 3
]
return {
"total": len(rows),
"briefs": briefs,
"hint": "其余记录用 get_record(id) 获取",
}
它的返回形态长这样。
{"total": 12, "briefs": [{"id": 3, "time": "10-24 03:12",
"level": "ERROR", "summary": "upstream timeout connecting to auth-service"}],
"hint": "其余记录用 get_record(id) 获取"}
三招各自压掉一块:摘要加句柄让模型"看过标题就知道要不要展开",详情入口 get_record(3) 显式写在返回里;分页把全量拉取变成模型的主动选择;字段裁剪砍掉内部 id、租户号这类本轮决策用不上的列。合起来,一次 5000 token 的原始 JSON 被压到几百 token,能力没有损失——模型想看第 3 条,调一句 get_record(3) 就是。
注意句柄的关键属性:可重取。源头埋下的这个属性,直接决定第 5 节里哪些旧结果敢清、清了之后模型怎么把内容找回来。
3、工具结果清理机制
源头瘦身压的是增量,已经进窗的存量要靠上下文侧的清理。Anthropic 在奠基文章里把这个动作定为"最安全、最轻量的压缩形式之一":工具结果一旦被模型消化,就从上下文里清除,需要时再取。
官方 API 已把它产品化:context editing(截至 2026 年 10 月仍为 beta)里的 clear_tool_uses_20250919 策略,由服务端在请求进入模型前自动清理旧工具结果,客户端保留完整历史。请求体的配置片段如下。
{
"context_management": {
"edits": [
{
"type": "clear_tool_uses_20250919",
"trigger": { "type": "input_tokens", "value": 30000 },
"keep": { "type": "tool_uses", "value": 3 },
"clear_at_least": { "type": "input_tokens", "value": 5000 },
"exclude_tools": ["web_search"]
}
]
}
}
四个参数各自管一段:trigger 是激活阈值,默认 10 万 input token;keep 是保留的最近调用-结果对数,默认 3;exclude_tools 是豁免名单;clear_tool_inputs 默认 false,只清结果、不清调用参数。Anthropic 用它在 100 轮 web search 评测里把 token 消耗降了 84%(第 4 篇引用过同一数字)。clear_at_least 是笔缓存经济账——清理会改写历史、打断前缀缓存,要么不清,要清就清够本。
官方策略按"余量"触发,手写实现更常见的机会点是轮末:每轮工具执行完、发起下一次模型调用前扫一遍历史,把超过保留窗口的旧结果替换成占位符——"已读取文件 X,共 2000 字,内容已省略"。这就是 turn-end clearing(轮末清理)。
无论哪个时机,有一条硬约束先立起来:assistant 消息里的 tool_calls 与紧随的 ToolMessage 必须成对出现且 id 对应(第 5 篇陷阱三,官方工具使用文档的硬性要求),直接删消息会被 API 拒收。所以清理的正确姿势是替换内容,而不是删除消息——ToolMessage 留在原位,只有 content 换成一行占位符。
4、手写轮末清理节点
4.1、占位符生成器
占位符不能只写一句"内容已省略",它要带走三样东西:调用了什么(工具名与参数,模型靠它判断要不要重取)、原来多大(字符数)、关键摘要字段(防幻觉,第 6 节展开)。实现如下。
from langchain_core.messages import ToolMessage
KEEP_HEAD = 200 # 占位符保留的原结果前缀长度(字符)
def make_placeholder(name: str, args: dict, result: ToolMessage) -> ToolMessage:
"""把一条工具结果降级为占位符,保留关键摘要字段"""
text = str(result.content)
argv = ", ".join(f"{k}={v}" for k, v in args.items())
head = " ".join(text.split())[:KEEP_HEAD] # 压平换行,取前 200 字符
note = (f"[{name}({argv}) 已执行,原结果 {len(text)} 字符已省略,"
"需要时可重新调用获取]")
return ToolMessage(
content=f"{note}\n首段:{head}",
tool_call_id=result.tool_call_id, # 与 assistant 调用保持配对
id=result.id, # 同 id:add_messages 原地覆盖
)
占位符分两行:一行元信息(工具名、参数、原长度、重取授权),一行原结果前缀。报错首行、首条记录、文件开头——模型回头看的高频细节大多在开头,压平换行截前缀是最便宜的摘要。id=result.id 原样带回是关键伏笔,见 4.2。
4.2、轮龄遍历与节点接线
主函数只做一件事:以 assistant 消息分轮,距最新轮超过 KEEP_TURNS 的工具结果替换为占位符。
from langchain_core.messages import AIMessage, AnyMessage
KEEP_TURNS = 2 # 最近 2 轮工具结果保留原文
def clear_stale_results(messages: list[AnyMessage]) -> list[AnyMessage]:
"""turn-end clearing:按轮龄替换旧工具结果"""
calls = {tc["id"]: (tc["name"], tc["args"]) # 调用 id → 名称与参数
for m in messages if isinstance(m, AIMessage)
for tc in m.tool_calls}
ages, turn = [], 0 # 轮龄:assistant 消息分轮
for m in messages:
if isinstance(m, AIMessage):
turn += 1
ages.append(turn)
out = []
for m, age in zip(messages, ages):
stale = isinstance(m, ToolMessage) and turn - age >= KEEP_TURNS
cleared = str(m.content).startswith("[") # 已清过的跳过
if stale and not cleared:
name, args = calls.get(m.tool_call_id, ("tool", {}))
out.append(make_placeholder(name, args, m)) # 同 id 覆盖
else:
out.append(m)
return out
三步走:先建 tool_call id 到调用信息的索引,供占位符引用参数;再给每条消息算轮龄;最后把老于保留窗口且未清过的 ToolMessage 换成占位符。turn - age >= KEEP_TURNS 保证最近两轮原文不动——最近轮的工具结果还挂着活跃上下文,模型大概率马上就要引用。
最后把清理挂成图上的一个节点。
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
def clear_node(state: AgentState) -> dict:
return {"messages": clear_stale_results(state["messages"])}
builder = StateGraph(AgentState)
builder.add_node("agent", agent_node) # 你的模型节点
builder.add_node("tools", tool_node) # 你的工具节点
builder.add_node("clear", clear_node) # 轮末清理
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", route, ["tools", END])
builder.add_edge("tools", "clear") # 工具执行完、回模型前清理
builder.add_edge("clear", "agent")
graph = builder.compile()
clear 节点插在 tools 与 agent 之间,正好落在"工具执行完、下一次模型调用前"这个轮末时机(路由函数沿用第 5 篇 3.3 节的写法)。真正的魔法在 add_messages 的合并规则:按 id 合并,同 id 的新消息原地覆盖旧消息而不是追加。make_placeholder 带回了原 id,返回的同 id ToolMessage 会精确替换窗口里那条旧结果——调用与结果的配对纹丝不动,一个 RemoveMessage 都不用写。这也是清理与第 5 篇 compaction 的实现差异:压缩删消息重建历史,清理原地改写消息内容。
注意:清理与压缩一样是单向门。被替换的原文无法从窗口恢复,工具输出若有审计价值,请在工具节点出口另行落盘。
两个旋钮值得盯。KEEP_TURNS 管清多少轮,KEEP_HEAD 管每个占位符多胖;另外清理会打断前缀缓存,从第一个被清的位置起重新计费——轮末按轮龄成段清理天然是攒批的,别退化成一轮清一条。
5、工具结果保留策略
不是所有工具结果都值得同等对待。判断标准一句话:重取成本 × 后续引用频率——重取便宜又少被引用的先清,重取不存在或每轮都在用的永不清。
| 工具类别 | 典型工具 | 清理策略 | 依据 |
|---|---|---|---|
| 浏览搜索 | web_search、list_dir | 最先清 | 中间产物,结论已写进回复 |
| 读取类 | read_file、get_record | 早清,占位符留句柄 | 路径与 id 可重取,成本低 |
| 命令输出 | bash、tail | 早清 | 时效性强,旧输出已失真 |
| 写操作确认 | create_issue、commit | 晚清或不清 | 工单号与 sha 是长期事实 |
| 任务输入数据 | 用户上传的分析对象 | 永不清理 | 每轮都在用,无句柄可重取 |
两条补充。任务输入数据是红线:它没有取回入口,清了等于失忆;太大塞不下时正解是外置——落盘成文件或结构化笔记,窗口里只留句柄与摘要,这是第 7 篇的主题。写操作确认类要么晚清,要么在产生当轮就让模型把工单号、commit sha 写进回复或任务状态,让事实离开工具结果这条易清的通道。API 侧的 exclude_tools 参数,对应的就是表里"永不清理"那一行。
6、回引幻觉与护栏
清理最大的风险:模型回头引用被清的内容。第 18 轮用户问"第 3 条日志的时间戳是多少",原文第 3 轮就清了,占位符若只有元信息,模型有一定概率凭"印象"编一个时间戳——不是它想编,是它分不清"记得"与"像真的"。护栏按成本从低到高排四道:
- 摘要进占位符:4.1 的首段 200 字符就是干这个的
- 显式重取授权:占位符写明"需要时可重新调用获取"
- 结论沉淀前置:重要发现在产生当轮就写进回复或笔记
- 观测兜底:开关清理跑同一组长任务,事后追问核对正确率
其中第二道最反直觉也最有效:给模型一条合法的重取路径,它就不必装作记得。占位符里的"可重新调用获取"不是客套话,它把"回头引用"重定向成"重新取回",正是第 1 篇 just-in-time 原则的落地。第三道说的是时机:清理只清已消化的原料,不该成为信息丢失的时机——发现重要就先沉淀,再谈清理。
一个对照:官方 context editing 的占位符只有一句删除声明,敢这么瘦是因为它按余量触发、保留的最近 3 对通常覆盖了绝大多数回引;手写按轮龄清得更激进,占位符就得更胖。占位符的胖瘦不是风格问题,是与清理激进度对冲的风险预算。
7、两段式压缩管线
清理与 compaction 不是二选一,是同一条管线的两段。
| 维度 | 工具结果清理 | compaction |
|---|---|---|
| 触发 | 每轮末按轮龄 | 窗口余量按阈值 |
| 信息损失 | 近似无损,句柄可重取 | 有损,摘要丢细节 |
| 作用对象 | 仅工具结果 | 全部历史 |
| 额外成本 | 纯代码,零模型调用 | 一次小模型总结调用 |
顺序是先清后压:每轮末清理把滞留压平,压缩的硬阈值(第 4 篇:历史占比 40% 或窗口剩余不足 20%)被自然推迟,很多任务跑完都不用触发 compaction;真触发了,待总结的历史也已经被清小——占位符一行字比 5000 token 原文好总结得多,总结调用更便宜、更聚焦。一句话:先把无损的子弹打完,再动有损的手术。
官方 API 是同款思路:edits 数组里 clear_tool_uses_20250919 与服务端 compaction 策略可以并存,官方的定位是 compaction 为主、context editing 做细粒度控制。再加上第 9 篇的子代理隔离——让脏结果污染子代理的窗口,主代理只收结论——长任务的上下文治理三板斧就齐了。
8、总结
回顾全文。工具结果的膨胀是单次体量与轮数的乘积,缓存救得了账单、救不了注意力;源头三招(摘要加句柄、分页、字段裁剪)压增量,轮末清理清存量;清理的实现心法是替换内容而非删除消息,LangGraph 里靠同 id 覆盖原地改写;保留策略按重取成本与引用频率分层,任务输入数据永不清理;占位符保留关键摘要、显式授权重取,对冲回引幻觉;先清后压组成两段式管线。
对于已经在跑多轮 Agent 的团队,建议本周先把 4.2 节的 clear 节点插进现有图里——它只是两个节点之间的一行接线——然后用第 4 篇的 TokenLedger 对比开关前后的账单;对于新项目,建议从源头做起,工具返回一律摘要加句柄,清理只做兜底。
留一道练习:给第 4 节的清理器加一个"回引测试"。任务跑到第 10 轮后,专问一个只存在于第 3 轮工具结果里的细节(某条日志的时间戳、某个文件的行数),重复 20 次,统计模型重新调用工具、凭占位符摘要作答、凭空编造三种行为的比例;再对比 KEEP_HEAD 取 0、200、500 时的编造率,你会直观看到占位符胖瘦与幻觉之间的换算关系。

