工具使用(Tool Use)的所有讨论几乎都聚焦在"调对工具"——选型、召回、参数、幂等、编排——却几乎没人正视工具执行完之后那坨返回值。而恰恰是这坨返回值,才是真正吃窗口、烧 token、还会让智能体撒谎的元凶。本期实测发现,主流六栈对"工具返回什么、怎么进上下文"的默认姿态差异巨大:Claude Code 设 10K token 告警 / 25K token 硬截两道闸门;OpenAI Assistants 走线程级 truncation:auto 智能压缩;AutoGen 依赖 condenser 事后蒸馏;而 LangChain、CrewAI、原生 MCP server 几乎"零默认"——返回多大就灌多大,裁不裁全靠开发者自觉。最危险的是一类"静默截断":框架悄悄砍掉尾部字节,模型却把残缺结果当成完整事实自信作答。一篇实测记录了 142KB JSON 被截到 8192 byte 的真实事故,作者称之为"让智能体撒谎的无声 bug"。本期拆解五栈的裁剪姿态、五式裁剪策略的取舍,以及根治它的工程范式。
| 框架 / 运行时 | 默认截断阈值 | 截断方式 | 截断是否可感知 | 默认防护强度 | 典型坑 |
|---|---|---|---|---|---|
| Claude Code(MCP 客户端) | 10K token 告警 / 25K token 硬截 | 头部保留,尾部丢弃 | 告警可见,硬截静默 | 强(可调 MAX_MCP_OUTPUT_TOKENS) |
25K 一刀切,长返回尾部全丢 |
| OpenAI Assistants / Responses API | 线程超限时触发 | truncation:auto(保留系统指令+近期消息,中段智能压缩) |
自动,日志可见 | 中 | 历史工具结果被压缩成摘要,丢细节 |
| LangChain / LangGraph | 无默认阈值 | 不截断,原样灌入 messages | 无任何提示 | 极弱(开发者全责) | 一个大文件返回直接撑爆窗口 |
| CrewAI | 无默认阈值 | 工具输出进 task output,不截断 | 无提示 | 弱 | 长 task output 累积拖慢推理 |
| AutoGen | 无默认阈值(靠 condenser) | 消息全量入历史,可选 condenser 蒸馏 | condenser 触发时可见 | 中(需显式配置) | 不配 condenser 则线性膨胀 |
| MCP server(服务端) | 由客户端决定 | 返回 preview + cursor(推荐)或全量 dump | server 不感知客户端阈值 | 取决于实现 | 全量 dump 必撞客户端闸门 |
核心结论先行:没有一个栈在"返回值该多大、该不该进上下文"上给出开箱即用的安全默认值。差异只在于"撞墙时谁先拦、拦得粗不粗暴"。
工具调用最常见的两种失败是"调用失败"和"返回错误",二者都会抛异常、进日志、被监控捕获。但还有第三种——返回成功,只是不完整。一篇被广泛引用的实测记录了典型场景:一次工具调用返回 142KB 的 JSON,框架静默丢弃 byte 8192 之后的所有内容,把头部前缀交给模型,模型基于这个"它根本不知道是片段的片段"写出了一个自信满满的答案。三周后客户投诉,运维翻 trace,看到的却是 tool returned successfully。
dev.to 上另一篇同名主题的文章给了一个更刺眼的数字:工具返回 50KB,模型实际只看到约 700 字符,然后它"自信地总结了那些根本不存在的内容"。作者把这种现象命名为 "the silent bug that makes agents lie"——让智能体撒谎的无声 bug。
它阴险在三处:第一,HTTP 状态码是 200,trace 里是成功,没有任何 error 通道;第二,模型不知道自己看到的是残缺数据,于是把它当成完整事实去推理,错误被放大到下游决策;第三,截断点往往是按字节/token 硬切,恰好可能切在 JSON 中段、截断一个数组或一个字段,于是模型看到的甚至不是"少了尾巴的合法数据",而是"结构破损的数据",但它照样硬解析。这类 bug 不会在开发期暴露——小数据集时永远不会触发阈值——只在生产环境的真实大返回上炸。
Claude Code 是目前少数在工具返回上设了"两道闸门"的运行时。官方文档明确:当单个 MCP 工具输出超过 10,000 token 时,Claude Code 显示一条 warning;当超过 25,000 token 时,触发硬截断,超出部分直接丢弃。这个 25K 上限可以通过环境变量 MAX_MCP_OUTPUT_TOKENS 调高。
# 调高单次工具返回的硬截上限
export MAX_MCP_OUTPUT_TOKENS=100000
这套机制值得拆解:10K 的告警是"软提示",它告诉模型和开发者"这个返回偏大,你可能想分页或提取",但不阻止;25K 的硬截是"硬墙",过墙即丢。问题在于,硬截是头部保留、尾部丢弃——而工具返回的尾部往往是结构化数据里最有信息密度的部分(比如一个查询结果的最后几条记录、一个日志的最近时间戳)。一刀切头部保留,等于默认"前面的比后面的重要",这对绝大多数时序型、追加型返回是反的。
更要命的是,Claude Code 的 UI 渲染层还有一道独立的显示截断——GitHub issue #2638 报告 MCP 工具响应在界面里被截到约 700 字符。这意味着同一个返回值,可能经历"服务端返回全量 → 客户端上下文层 25K 截断 → UI 渲染层 700 字符截断"三道刀,开发者看 trace 看到的、模型实际读到的、工具实际返回的,是三个不同长度的东西。调试时若只看 UI,会得出完全错误的结论。
与 Claude Code 形成鲜明对比的是 LangChain 生态。LangChain 的 @tool 装饰器对返回值不做任何默认处理——函数 return 什么,字符串就原样进入 ToolMessage,再原样拼进下一次模型调用的上下文。CrewAI 同理:工具输出直接进 task output,没有截断、没有摘要、没有分页。
这不是 bug,是设计哲学:框架把"返回多大合适"的决定权完全交给开发者。但实测中这是最大的坑——开发期用 5 行测试数据,一切正常;上了生产,工具去读一个 2 万行的日志文件,返回一个 8000 行的 SQL 结果,一次 web 抓取带回 1.3MB 的 DOM(约 154K token,见 mcp-cache 实测),整个上下文瞬间被一个工具返回淹没,后续推理全部在噪声上滑行。
LangChain 官方推荐的解法是分页——返回首屏 N 条 + 一个 next_offset 游标,让模型自己决定要不要翻下一页:
from langchain_core.tools import tool
@tool
def search_logs(query: str, offset: int = 0, limit: int = 100) -> dict:
"""分页检索日志,返回首屏与下一页游标。"""
total, rows = db.search(query)
chunk = rows[offset:offset + limit]
return {
"rows": chunk,
"total": total,
"next_offset": offset + limit if offset + limit < total else None,
}
这种模式的代价是多轮往返的延迟税——但换来的是"返回永远小而完整"。CrewAI 没有等价的一等公民抽象,开发者只能自己在工具内部切,或借助 handle_tool_error 兜底。没有默认防护 = 开发者必须自己写防护,这是这两个栈最大的隐性成本。
OpenAI 走的是另一条路。Assistants API 的 Run 对象有个 truncation_strategy 字段,模式 auto 会在线程总长度逼近模型上下文上限时,自动压缩中段历史——保留系统指令和最近的若干条消息(含最近的工具返回),把中间部分蒸馏成摘要。Responses API 延续了类似的思路。
这种"线程级智能压缩"和 Claude Code 的"单返回级硬截"是两种截然不同的哲学:Claude Code 在单个工具返回层面动刀,每次都拦;OpenAI 在整个对话线程层面动刀,只在总量超限时才压缩。前者粒度细、可预测(每次返回独立判定),后者粒度粗、更省事(不用关心单个返回多大),但代价是历史工具结果会被压成摘要——三天前那次工具调用返回的详细数据,在线程变长后可能只剩一句"此前查询到 47 条记录",细节永久丢失。
azure AI 的文档把这种策略归入更大的 compaction 家族:滑动窗口(sliding window,只留最近 N 轮)、截断(truncation,硬切)、工具结果压缩(tool-result compaction,专门针对 ToolMessage 做蒸馏)、摘要(summarization)。四者本质都是在"保留多少历史工具结果"上做取舍。OpenAI 选了摘要,Claude Code 选了截断,没有银弹——摘要丢细节、截断丢尾部,两者都会让模型在某些追问上"失忆"或"误信"。
AutoGen 的处理最能体现"事后 vs 事前"两条路线的根本分歧。默认情况下,AutoGen 把每一条消息(含工具返回)全量追加到对话历史,没有任何截断。它提供的解法是 condenser——一个专门的"压缩代理",在历史膨胀到阈值时触发,把整段历史蒸馏成摘要再继续。
这是一个"事后压缩派"的典型代表:先让返回值原样进上下文,膨胀了再压。好处是保留了完整的中间状态供压缩代理精挑细选;坏处显而易见——压缩代理本身要消耗一次模型调用,且压缩发生在"已经膨胀之后",那一次峰值 token 消耗躲不掉,账单照样付。更关键的是,condenser 不是默认开启的,必须显式配置,很多团队上线后才发现"为什么 token 用量线性增长"——因为根本没配 condenser。
与之相对的是"事前裁剪派"(Claude Code、MCP 客户端闸门):在返回值进入上下文之前就拦掉超限部分。事前派省 token、可预测,但丢的是原始数据;事后派保完整、可智能取舍,但贵且不可预测。两派没有谁对谁错,但生产环境应明确站队:成本敏感、可接受丢尾部的选事前;数据完整敏感、预算充足的选事后。最差的是 AutoGen 默认态——既不事前裁也不事后压,全量堆积,是最容易在长会话里 OOM 或超 token 上限的姿态。
把所有手段摊开,工具返回的处理本质就五种范式,langcopilot 把它们归纳为 Offload / Summarize / Isolate / Cache 四大类,加上最原始的盲截,凑成五式:
实测经验:返回体量越大,越该往右走(分页→抽取→落盘)。盲截和压缩是"治标",分页和抽取是"治本但需 schema",落盘是"终极方案但最重"。大多数事故的根因,是把本该分页或落盘的返回,用盲截糊弄了——省了开发的脑子,赔了生产的数据。
真正彻底的解法不在客户端,而在工具(尤其 MCP server)这一侧。问题的根源是:工具把自己当成了"数据源",把全量数据一股脑 dump 出去,把"该返回多大、该返回哪些"的难题甩给了客户端的截断闸门。正确的设计是把工具当成"带游标的 API"——永远只返回首屏 preview + 一个 cursor,让模型在需要时主动翻页或下钻。
MCP 官方推荐的正是这个范式:
@mcp.tool()
def search_code(query: str, limit: int = 20, cursor: str | None = None) -> dict:
"""返回首屏结果与下一页游标,而非全量 dump。"""
items, next_cursor = index.search(query, limit=limit, after=cursor)
return {
"items": items, # 只返回首屏
"count": len(items),
"next_cursor": next_cursor, # 模型按需翻页
"truncated": next_cursor is not None, # 明确告知"还有更多"
}
注意三个关键设计:第一,默认 limit 小(20 而非无界),从源头控制单次返回体量;第二,返回 next_cursor,让翻页是模型主动选择而非客户端被动截断;第三,显式 truncated 标志,告诉模型"你看的不是全部,要更多请翻页"——这一条直接破解了"静默截断让模型撒谎"的根源,模型不再误信残缺数据是完整事实。
这套范式把"返回多大"的决定权从客户端闸门(粗、按字节切、破坏结构)移到了 server(细、按业务语义切、保持结构完整)。客户端从此只需一个宽松的硬上限做兜底,日常返回永远小而完整。这才是工具结果问题的根治路径——不是把闸门调得更聪明,而是让源头不再倾泻。
flowchart TD
A[工具执行 返回大数据] --> B{MCP server 设计}
B -->|全量 dump| C[客户端闸门 1: Claude 10K告警]
B -->|preview + cursor| Z[小而完整 直接注入]
C --> D{超过 25K?}
D -->|否| E[原样注入上下文]
D -->|是| F[硬截 头部保留 尾部丢弃]
F --> G[模型把残缺当完整事实]
G --> H[自信输出错误答案]
E --> I{线程总量超限?}
I -->|是 OpenAI auto| J[中段压缩成摘要 丢细节]
I -->|否| K[模型正常推理]
Z --> K
H -.事故三周后.-> L[客户投诉 trace 却显示成功]
J -.后续追问.-> M[模型失忆 细节已蒸馏]
style F fill:#f66,color:#fff
style G fill:#f96,color:#fff
style H fill:#f96,color:#fff
style Z fill:#6c6,color:#fff
style K fill:#6c6,color:#fff
图中红色路径(硬截→误信→自信撒谎→事故)正是 142KB→8192 byte 案例的真实轨迹;橙色路径(摘要→失忆)是 OpenAI auto / AutoGen condenser 的副作用;绿色路径(preview+cursor→小而完整→正常推理)是根治方案。
按返回体量分层选型:
MAX_MCP_OUTPUT_TOKENS 但不要无限调——那只是把闸门推后,没解决结构破坏。按栈选型:
一句话工程准则:永远不要让一个工具返回超过模型一次能消化的量;返回多大不是工具的自由,而是工具和模型共同协商的结果。 截断闸门是最后一道兜底,不是第一道设计——把"返回多大"的决策推到闸门,就等于把"丢哪部分数据"的决策交给了一个不懂业务、只懂字节的切刀。
MAX_MCP_OUTPUT_TOKENS 调参。本文由「爱卫生」(微信:gprshome201101)整理输出,聚焦 AI 智能体实战与工程化拆解。如需转载请联系原作者。
[!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 等场景全覆盖。
