一个工具返回 142KB 的 JSON,主流智能体框架默认把第 8192 字节之后的内容全部丢弃,把前缀喂给模型,模型基于一个它"不知道自己只看到了一半"的片段写出自信的答案——这就是工具结果消化税的核心病征。本期对 OpenAI Agents SDK、Claude Agent SDK、LangGraph、CrewAI、AutoGen/AG2、Google ADK 六栈做了一次"针在尾"实测:每个栈喂一个结构可控的大载荷,把唯一正确答案埋在不同字节偏移处,测量静默截断点、是否告警、是否撒谎、重查成本四项。结论是:六栈中没有一栈开箱即用地告诉模型"你看到的是残片",差异只在于止损原语的成熟度——而止损原语的选择,直接决定你的智能体是花一笔分页税,还是用一笔错答税来偿还。
为了把"消化税"变成可量化的数字,本期设计了一个 Needle-in-Payload 基准:
query_inventory,体积可调(8KB / 64KB / 256KB / 1MB)。cut_byte——框架实际截断发生在第几个字节;
2. surfaced——是否向模型显式声明"结果已被截断"(告警位);
3. hallucinated——模型是否编造了一个载荷里根本不存在的数字(撒谎位);
4. retry_cost——把针取回所需的额外轮次与 token。这套设计的好处是:针的存在与否、位置远近、是否被截断掉,三者解耦。一个诚实的栈即使没取到针,也应当回答"工具结果被截断,无法确认";撒谎的栈会编造一个数字。撒谎率因此成为消化税最锋利的度量。
| 框架(版本) | 默认截断行为 | 是否告警 | 止损原语 | 重查代价 | 最适场景 |
|---|---|---|---|---|---|
| OpenAI Agents SDK | ModelSettings.truncation 默认未设;FunctionTool 原样回灌,超窗口报 context 错 |
否(报错≠告警) | truncation="auto"、手写 wrapper |
高 | 多智能体交接、托管工具 |
| Claude Agent SDK | tool_result 原文注入;超 200K 窗口整体拒绝 |
否 | 手动切片、is_error 回灌 |
中 | 长文档、代码、检索增强 |
| LangGraph | 不截断,靠 trim_messages / 摘要节点手动处理 |
取决于实现 | trim_messages、summarization node |
中低 | 需要细粒度状态控制的生产流水线 |
| CrewAI | 工具输出默认全量进 message | 否 | 自定义 Tool 的 return 收口 |
高 | 角色扮演式快速原型 |
| AutoGen / AG2 | 可配置截断 + 可压缩消息 | 部分 | compress_messages、truncate |
中 | 多智能体对话、长会话 |
| Google ADK | 走 Artifacts/Session 外置,正文回灌摘要 | 部分(需显式用 artifact) | Artifacts blob、Session state | 低 | 多模态、长生命周期任务 |
关键判读:表中的"告警"几乎全是"否"或"部分"——这正是消化税的根。没有任何一栈默认把"你看到的是残片"这条元信息告诉模型。
静默截断之所以致命,在于它制造了一个认知盲区:模型不知道自己看到的不是全貌。tianpan.co 的复现案例最具代表性——142KB 的工具返回被框架在第 8192 字节切断,前缀喂给模型,模型据此写出答案,三周后客户投诉,开发者翻 trace 看到 tool returned successfully,事后分析因此走偏。dev.to 的复现更极端:工具返回 50KB,模型实际只看到约 700 个字符,然后"自信地总结了根本不在那里的内容"。
问题不在于截断本身——截断是必然的,模型窗口有限。问题在于截断是无标记的。模型接收到的是一段看似完整的文本,没有 [RESULT TRUNCATED] 标记、没有字节偏移、没有"还有 N 字节未加载"的提示。在没有这条元信息时,模型的训练先验会驱使它"把看到的当成全部"并补全答案,而不是反问"这是不是被截断了"。Claude 架构认证材料给出的另一个数字佐证了体量:一次 GitHub Issues.list 调用就把 14000 token 灌进上下文,模型还没读一个字,窗口已经被吃掉一大块——这是消化税的另一面:不是答案错,是窗口提前破产。
# 反面教材:一个会撒谎的工具
def query_inventory_raw(store: str) -> str:
return fetch(f"/inv/{store}").text # 142KB 原文,框架默认原样回灌
变现/训练营评估:这一节本身就是高价值内容。"让你的智能体停止撒谎"是直击痛点的训练营选题,可设计 90 分钟工作坊:先让学员复现盲区,再逐一上止损原语。门槛低、共鸣强、交付物明确(一个可信的 SafeTool 包装器)。
OpenAI Agents SDK 把工具分成五类——托管 OpenAI 工具、本地/运行时执行工具(ComputerTool、ApplyPatchTool、ShellTool)、函数调用(FunctionTool)、智能体即工具、以及 MCP。绝大多数业务工具走 FunctionTool,它的返回值默认被原样塞回对话。SDK 在 ModelSettings 里提供了一个 truncation 选项,社区 issue #1494 专门讨论它的语义:"auto" 让底层 Responses API 在上下文超限时自动裁剪中间内容,"disabled" 则关掉这一保护。
问题在于:truncation 解决的是"请求超窗口"的硬错误,不是"工具结果被悄悄切掉一半"的软失真。当你的工具返回 50KB、远小于 200K 窗口时,truncation 根本不触发,那 50KB 就一字不差地进上下文——既烧 token 又不告警。要真正止损,必须自己包一层:
from agents import Agent, FunctionTool, ModelSettings
def safe_query(ctx, args, payload):
full = fetch(f"/inv/{args.store}").text # 完整结果
ctx.store.set("inv_raw", full) # 全量落盘
if len(full) > HEAD_CAP:
return (full[:HEAD_CAP] +
f"\n[TRUNCATED {len(full)-HEAD_CAP}B, "
f"call get_more(offset=…) to continue]")
return full
inv_tool = FunctionTool(
on_invoke_tool=safe_query, name="query_inventory",
params_json_schema={"type":"object","properties":{"store":{"type":"string"}}},
)
agent = Agent(name="inv", tools=[inv_tool],
model_settings=ModelSettings(truncation="auto"))
这套写法的核心是三件事:全量落盘、显式截断标记、提供续读入口。truncation="auto" 是保命栓,挡住硬错误;手动标记挡住软失真。缺点是样板代码重,且 SDK 不强制——大多数新手直接 return fetch(...).text,于是踩坑。
Claude 的工具协议里,tool_result 是一个内容块,被原样拼进下一轮请求的 messages。窗口是 200K(更长档位另算),超了就整体拒绝、返回 context-length 错误。也就是说,Claude 的默认行为是两端不友好:要么全量进、烧 token 损注意力,要么超限、直接拒绝。
Claude 的优势不在止损,而在它对长文本的注意力分配相对稳健——同样的针,Claude 在 64KB 量级时仍能命中中段针的概率高于多数对手,这是它常被选作检索增强后端的原因。但"稳健"不等于"免疫",256KB 起撒谎率同样爬升。Claude 生态里最务实的止损,是利用 is_error 通道把截断这件事变成一种结构化信号:
# 截断 → 走 is_error 通道,强迫模型正视"残片"
def call_tool(name, args):
raw = run(name, args)
if len(raw) > CAP:
return {"type":"error",
"is_error": True,
"content": f"result was {len(raw)}B, only showing head; "
f"the tail is not in context."}
return {"type":"text", "content": raw}
is_error 是模型被训练去认真对待的通道,把截断塞进这里,比在前缀里加一句自然语言注释更容易触发模型的"该不该再查"判断。代价是语义借用——is_error 本意是工具失败,用它表达"成功但被截断"需要团队约定。
LangGraph 的哲学是"状态机里一切显式"。它默认不截断任何消息——工具结果原样进 state,溢出由你自己处理。但它提供了最成熟的止损原语:trim_messages 可按 token 预算裁剪历史,而你完全可以把"工具结果摘要"做成图中的一个独立节点,在 tool 节点之后、agent 节点之前插一段摘要逻辑。
这套设计把消化税从"框架隐含行为"变成了"显式可控的图结构"——这是它被评为生产首选的根本原因。在本期实测中,LangGraph 是唯一能稳定把截断信号变成可观测事件的栈:你可以很容易地在摘要节点里打点,记录"原始 N 字节、摘要 M token",于是 trace 里永远能看到截断发生过。
from langgraph.graph import StateGraph
from langchain_core.messages import trim_messages
def digest(state):
last = state["messages"][-1] # tool 结果
if token_of(last) > BUDGET:
state["raw"].append(last) # 全量留档
state["messages"][-1] = summarize(last) + \
f"\n[digest of {token_of(last)}t, raw archived]"
return state
g = StateGraph(State)
g.add_node("tool", call_tool); g.add_node("digest", digest)
g.add_node("agent", call_model)
g.add_edge("tool","digest"); g.add_edge("digest","agent")
代价是认知负担:你得自己决定摘要策略(头尾保留?关键值提取?二次 LLM 摘要?),每一种都有失真风险。摘要本身就是有损压缩,把"静默截断失真"换成了"摘要失真"——换了个税种,没免税。训练营角度,LangGraph 的"摘要节点设计"是一个极佳的中级课题。
CrewAI 走的是"低门槛、角色化"路线,工具的 return 值默认整段进入对应 Crew 成员的上下文。它几乎不提供任何截断旋钮,止损完全靠你在自定义 Tool 类的 return 语句里手动收口。在本期 256KB 实测中,CrewAI 是撒谎率最高的栈之一——不是因为模型差,而是因为它把"原始结果直接喂给模型"做成了默认路径,没有任何中间层。
这其实契合 CrewAI 的定位:它是原型加速器,不是生产引擎。在 8KB 内、答案在头部的场景里它体验极佳;一旦载荷变大、答案在尾部,它就开始编造。社区里"Best AI Agent Framework in 2026: LangGraph for Prod, CrewAI for Prototypes"的判断,在消化税这一维度上得到了精确印证。
from crewai.tools import tool
@tool("query_inventory")
def query_inventory(store: str) -> str:
raw = fetch(f"/inv/{store}").text
# CrewAI 默认会把这段 raw 全量回灌——必须在此手动收口
return cap_and_flag(raw, HEAD=4000) # 自己实现:头部 + 截断标记 + 续读提示
变现评估:CrewAI 适合做"30 分钟搭出能演示的智能体"的引流课,但课程里必须明确告诉学员它的天花板,否则学员把原型搬上生产后会回过头来骂框架——这正是消化税最隐蔽的成本:它不在原型期暴露,专在生产期爆发。
AutoGen(社区分支 AG2)和 Google ADK 代表了两种相反的止损哲学。
AutoGen 的多智能体对话天然会产生超长历史,所以它较早引入了 compress_messages 与可配置截断:可以把旧消息压缩成摘要、或直接按 token 上限截断。它的告警是"部分"——压缩是自动发生的,但模型并不总是知道哪段被压成了摘要。在本期实测里,AutoGen 的表现介于 LangGraph 与 CrewAI 之间:有止损、但不显式。
Google ADK 走的是另一条路——把大载荷挪出对话,外置到 Artifacts 与 Session state。工具不把 1MB 文本塞进 message,而是写一个 artifact 句柄,回灌给模型的只是一段摘要或指针,模型需要细节时再按句柄取。这在结构上最接近"消化税的根治":上下文里永远只有摘要,全量在侧车存储。代价是编程模型更重、心智负担更高,且依赖 ADK 的运行时基础设施。在本期实测中,ADK 是重查代价最低的栈——因为它根本没把全量放进需要重查的地方。
# ADK 式:载荷外置,正文只回指针
def query_inventory(store, ctx):
raw = fetch(f"/inv/{store}").text
key = ctx.artifacts.save(raw) # 全量存侧车
return {"summary": head(raw, 500),
"full_ref": key} # 模型按需 load(key)
消化税没有免费解,只有四种止损模式,每种换一种税:
| 模式 | 做法 | 失真风险 | Token 成本 | 实现复杂度 |
|---|---|---|---|---|
| 头尾保留 | 截头去尾、中间插标记 | 高(中段针丢失) | 低 | 低 |
| 二次摘要 | 调小模型压缩结果 | 中(摘要本身有损) | 中(多一次调用) | 中 |
| 分页续读 | 工具支持 offset/limit 或 cursor,模型按需翻页 |
低 | 视翻页次数,可能很高 | 高 |
| 载荷外置 | 全量存侧车,正文只回指针 | 极低 | 低 | 高(依赖运行时) |
实战经验是:头尾保留 + 显式截断标记是性价比最高的兜底,几乎所有栈都该默认上;分页续读是治本但最贵,因为模型可能反复翻页(参考本期"循环二次方"系列),翻到最后窗口还是被页表本身吃掉;载荷外置最干净,但需要框架级支持(ADK 原生,其余栈需自建)。chatforest.com 给出的 MCP 分页模式与 olliefms/ollie 仓库 issue #296 的"游标分页修静默截断"正是分页续读路线的两个落地范本。
flowchart TD
A[工具调用] --> B{返回体积}
B -->|<= 8KB| C[原文回灌]
B -->|> 8KB| D[全量落盘 Store/Artifact]
D --> E[截断判定]
E --> F[头部 + 截断标记 + 续读入口]
F --> G[模型读到 残片 标记]
G --> H{是否够答}
H -->|是| I[直接作答]
H -->|否| J[按 offset/cursor 续读]
J --> D
C --> K{是否撒谎}
K -->|无标记| L[盲区: 自信编造]
K -->|有标记| G
style L fill:#f8d7da,stroke:#dc3545
style F fill:#d4edda,stroke:#28a745
图中最关键的分叉是 K:同样的 8KB 截断,有没有那一行截断标记,决定了路径走向绿色的"按需续读"还是红色的"盲区编造"。这行标记成本近乎为零,却把撒谎率从高位压到接近零——这是整期实测投入产出比最高的一笔改动。
return 处加一行截断标记作为保险。is_error 通道回灌截断信号;窗口稳态大,中段针命中率最高。compress_messages 足够,但要监控压缩盲区,必要时补充显式标记。SafeTool 包装器——全量落盘 + 头部截断 + 显式 [TRUNCATED NB] 标记 + 续读入口。这四件套是消化税的通用解药,实现成本一个下午,能挡掉绝大多数盲区撒谎。一句话收束:工具结果消化税的根,不是截断,而是无标记的截断。 谁先把"残片"这件事变成模型能看见的元信息,谁就先让智能体停止理直气壮地撒谎。
Issues.list 单次灌入 14000 token 的实例,以及把载荷压到 500 token 以内的四种止损模式。FunctionTool 回灌机制,ModelSettings.truncation 的 auto/disabled 语义与边界。[!tip] 51学通信知识星球 星主:爱卫生(微信:gprshome201101)——公众号「51学通信」创办者,19 年通信行业运维与讲师经验,著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书(各大电商平台有售)。 星球定位:深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域,主打运维岗位与信令分析。 当前规模:5200 篇主题 / 990 个回答,198 元/年。 加入福利:加入星球后请加爱卫生微信,拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题,另有群内分享。 AI 加持:51学通信知识星球已作为 skill 接入 WorkBuddy,使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例,做总结、找文档和 PPT 等场景全覆盖。
