上下文工程(08):输出裁剪与清理策略

QuibblerAgentQuibblerAgent 2026-10-28 约 22 分钟 4 次阅读

《上下文工程》系列前几篇反复点名同一个嫌疑人:工具结果。第 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 时的编造率,你会直观看到占位符胖瘦与幻觉之间的换算关系。

相关推荐

​Jev 详解:不做生成的判断模型
精选AI

​Jev 详解:不做生成的判断模型

Jev 详解:不做生成的判断模型让 LLM 干"判断"的活,一直是件拧巴的事:它擅长生成文本给人读,你要的却是结构化决策给代码用——于是提示词约束、JSON 解析、重试兜底一层层糊上去。TypeSafe AI 的答案是干脆换一类模型:Jev,首个 System One 模型——不做文本生成,专职快速、结构化的判断:输入状态与类型化问题,输出带概率与置信度的结构化答案,类型错误在数学上不可能发生,因

578
网站被注入恶意脚本排查实录:AI 辅助安全分析实战
置顶 精选AI

网站被注入恶意脚本排查实录:AI 辅助安全分析实战

网站被注入恶意脚本排查实录:AI 辅助安全分析实战某天收到华为云监管反馈,称个人博客网站存在色情违规界面。作为技术博主,这简直是晴天霹雳——网站一直正常运行,怎么会突然出现这类问题?经过排查发现,网站被注入了恶意跳转脚本。本文记录完整的排查过程,重点分享如何借助 AI 快速定位问题并修复,希望能给遇到类似情况的开发者提供参考。1、问题发现与初步排查收到监管反馈后,第一时间打开网站检查。首页看起来完

359
时隔三年,再次备份:从手搓博客到 AI 全面自动接管
精选AI

时隔三年,再次备份:从手搓博客到 AI 全面自动接管

时隔三年,再次备份:从手搓博客到 AI 全面自动接管今天打开尘封多年的备份目录,看到了一个熟悉的文件名——2023_07_09.sql。那一刻,恍如隔世。三年前的今天,我曾经认真地导出过一次数据库;三年后的今天——2026 年 7 月 9 日,自动备份启用。整整三年,1095 天,我的博客就这样安安静静地跑着,没有人备份,也没有人管,它就那么稳稳当当地活着。感慨万千,大概是此刻最准确的四个字——不

331