当人们讨论 Agent 工具调用翻车时,目光几乎总盯着三个地方——选错工具、填错参数、调得太多。这三类问题在过去两年的工程化浪潮里已被反复拆解,从工具描述工程到参数 Schema 校验再到冗余调用克制,补丁打了一层又一层。但真正在生产环境里"杀人"的,是第四类、也是最隐蔽的一类:工具"成功"返回了,Agent 也"成功"地继续往下推理,可结果从头到尾就是错的。
这就是工具调用的假成功(false success / silent failure)。它不抛异常、不返回非 2xx、不触发任何重试逻辑——HTTP 200 裹着一个空数组、一个 null、一段"record not found"的字符串、一个只有 3 条数据的部分失败响应。Agent 读到的是"工具跑通了",于是在空结果上编织出一篇头头是道的分析。据 2026 年的生产数据,3%–15% 的工具调用会以这种静默方式失败,意味着一个 10 步的 Agent 流水线在不做任何结果校验时,跑通且不出错的概率只剩抛硬币。
本期我们抽六个主流 Agent 栈——LangGraph、LangChain、AutoGen、CrewAI、OpenAI Agents SDK、Pydantic AI——在"工具返回值校验"这个被集体忽视的维度上做了一次实测横评。核心结论先行:六个栈对"参数侧"的 Schema 校验都已做到 strict 模式开箱即用,但对"结果侧"的 Schema 校验几乎全是空白,只有 Pydantic AI 把返回值也纳入了类型系统。这中间的巨大落差,正是假成功税的来源。
在进入横评前,必须先界定清楚"假成功"到底长什么样,否则校验墙无从立柱。实测中我们把脏返回值归为三类,每一类都能让未经校验的栈安然通过:
第一类:空集伪装成功。 查询接口返回 {"status": "ok", "data": []} 或 {"result": null}。状态码 200,JSON 合法,但业务语义是"什么都没查到"。多数栈会把这条 ToolMessage 原样塞进上下文,Agent 看到结构完整,便把"没查到"当成"查到了且为空"继续生成,最经典的产物是对着空数据集做"趋势分析"。
第二类:错误消息包进 200。 第三方 API 习惯把业务错误编码在响应体里——{"code": 40401, "message": "rate limited", "data": null},HTTP 层依然是 200。这是最容易骗过框架的一类,因为 transport 层没有任何异常信号,连 try/except 都拦不住,校验只能下沉到对响应体 schema 的字段断言。
第三类:部分失败。 批量/并行工具调用返回 10 条里 7 条成功 3 条失败,失败项夹在成功项中间。Agent 若不做逐项校验,会基于那 7 条成功项给出"全部完成"的乐观结论,而 3 条失败的业务后果(漏发通知、漏记数据)被彻底掩盖。
理解这三类伪装,就理解了为什么"参数 strict 模式"救不了你——strict 模式只保证 Agent 调用工具时参数合法,对工具吐回来什么是聋的。
| 维度 | LangGraph | LangChain | AutoGen | CrewAI | OpenAI Agents SDK | Pydantic AI |
|---|---|---|---|---|---|---|
| 参数侧 Schema 校验 | ✅ strict | ✅ strict | ✅ 原生 | ✅ Pydantic | ✅ strict | ✅ Pydantic |
| 返回值 Schema 校验 | ❌ 手动 | ❌ 手动 | ❌ 手动 | ❌ 手动 | ❌ 手动 | ✅ 原生 |
| 假成功防护(空集/null) | ❌ 无 | ❌ 无 | ❌ 无 | ❌ 无 | ❌ 无 | ⚠️ 部分 |
| 部分失败可见性 | ⚠️ ToolNode 透传 | ❌ 字符串 | ❌ 字符串 | ❌ 直接返回 | ⚠️ 透传 | ⚠️ 透传 |
| 结果裁剪/压缩 | ❌ 需自建 | ❌ 需自建 | ❌ 需自建 | ❌ 需自建 | ❌ 需自建 | ❌ 需自建 |
| 错误码语义化注入 | ⚠️ 需手写回调 | ❌ | ⚠️ 反射提示 | ❌ | ❌ | ⚠️ 可挂校验 |
| 结果→上下文回灌税 | 高 | 高 | 中 | 中 | 中 | 低 |
| 补救成本(后置加校验) | 中(图节点) | 高(侵入) | 高 | 高 | 中 | 低 |
表格呈现的格局异常清晰:参数校验是六个栈的标配,返回值校验是六个栈的盲区。 这不是巧合——函数调用范式(OpenAI 2023 年定义、各家跟进)原生只约束"调用方向",把"返回方向"留给了开发者,而绝大多数框架在封装时偷了懒,把工具结果当成一段无类型的字符串/字典直接回灌进上下文。唯一把返回值也当成一等公民类型化的是 Pydantic AI,但即便它,也只能保证"返回值符合某个 Pydantic 模型的形状",对"形状对了但语义是空"的假成功仍需挂自定义校验器。
flowchart TD
A[LLM 决策调用工具] --> B[参数校验层<br/>Pre-Execution Gate]
B -->|strict mode / args_schema<br/>六个栈都覆盖| C[工具执行<br/>真实 API 调用]
B -->|参数非法| X1[拒绝执行 + 反馈 LLM]
C --> D{返回值校验层<br/>Post-Execution Gate<br/>六栈盲区}
D -->|HTTP 200 + schema 合法| E[语义校验层<br/>Semantic Gate]
D -->|非 200 / schema 不符| X2[标记异常 + 重试/降级]
E -->|非空 / 业务码 OK| F[结果裁剪<br/>截断大返回 + 提取关键字段]
E -->|空集 / null / 业务错误码<br/>假成功| X3[注入显式失败信号<br/>阻止盲目推理]
F --> G[ToolMessage 回灌上下文]
G --> H[LLM 下一轮推理]
X3 --> G
style D fill:#ffcccc,stroke:#c00
style E fill:#ffe4b5,stroke:#e80
style X3 fill:#ffd700,stroke:#e80
这张图揭示了当前工程实践的真实断层:Pre-Execution Gate 已经是行业标配,Post-Execution Gate 和 Semantic Gate 几乎全靠手写。 假成功税的全部重量,都压在那两个红色的、没有任何框架默认支撑的节点上。下面逐层拆解六个栈在这道墙上的真实表现。
LangGraph 把工具执行抽象成一个 ToolNode,它调用工具、把返回值包成 ToolMessage、原样挂回图状态。这套设计的优点是显式、可调试、状态可见;代价是它对返回值完全透传,零校验——ToolMessage.content 就是一坨字符串,图里下一个节点(通常是 LLM)会全盘接收。
from langgraph.graph import StateGraph
from langchain_core.messages import ToolMessage
def call_api(state):
result = my_tool.invoke(state["args"]) # 可能返回 {"data": []}
# 默认行为:原样塞回去,空数组照进不误
return {"messages": [ToolMessage(content=str(result), ...)]}
要在 LangGraph 里建返回值校验墙,得手动包一层后执行节点。好在图的显式性让这件事可行:你可以在工具节点后接一个 validate_node,对 ToolMessage.content 做 schema 解析和语义断言,失败则注入一条显式的失败信号并路由回 LLM 重试或转人工。补救成本中等——图的节点化结构天然适合插校验,痛点在于这是你必须记得手写的胶水,框架没有任何提示。一个常见反模式是直接信任 result["data"],遇到空列表时 Agent 反而"更自信"地输出"经分析,该项指标为零"。
LangChain 的 StructuredTool 让你用 Pydantic 定义 args_schema,参数校验一等公民;但工具函数的返回值没有任何对应的 result_schema 概念。StructuredTool.invoke() 返回的是 str(tool_func(...))——也就是说,无论你工具函数返回的是 dict、是 list、是某个 dataclass,最终回灌给 LLM 的都是它的字符串表示。
@tool
def search_orders(query: str) -> list[dict]:
return order_api.search(query) # 可能返回 []
# LLM 看到的是 "[]"——一行字符,没有任何类型/语义标记
这是 LangChain 假成功税最重的设计:返回值在进入上下文前被降维成无类型字符串,schema 信息全部丢失。空列表 [] 和非空列表 [...] 在 LLM 眼里只是长度不同的字符串,它无法从结构上区分"查无结果"和"查到了"。要止血,你必须在工具函数内部做防御:返回前断言非空,空则抛出自定义异常或返回一个带显式 status: "empty" 标记的结构。问题是这种约定全靠团队自觉,框架既不强制也不鼓励。这也是为什么"侵入式补救"对 LangChain 成本最高——返回值一旦字符串化,下游已无类型可抓。
AutoGen 把工具结果作为对话历史的一部分回灌,本质和 LangChain 类似——返回值被序列化成消息文本。但 AutoGen 有个独特机制:reflect_on_tool_result 这类钩子允许你在结果回灌前插入一段"反思"。这让假成功防护有了一个落点,但默认是关闭的,且反思本身又消耗一轮 LLM 调用(一笔不小的税)。
实测中 AutoGen 的部分失败处理最弱:当工具返回一个混合成功/失败的批量结果时,AutoGen 倾向于把整个结果块当成一条消息,Agent 极易只注意到开头的成功项而忽略夹在中间的失败项。补救路径是在工具包装层做解构——把批量结果拆成"成功列表"和"失败列表"两段显式文本分别回灌,强迫 Agent 同时面对两部分。这本质是在用"信息排版"补偿"类型缺失",治标但有效。AutoGen 的反思钩子适合做语义校验的兜底,但不要指望它替代结构化的 schema 校验。
CrewAI 的 Tools 设计极简:工具方法直接 return 一个值,CrewAI 把它原样交给 agent。这种"少即是多"的哲学在上手阶段体验极佳,但恰恰把假成功问题最大化——没有任何中间层可以插校验,返回值直接成为 agent 上下文的一部分。
CrewAI 的额外风险来自它的"角色心智"设定:你给 agent 写了一段"你是资深数据分析师"的 system prompt,这种强烈的人设会放大对空结果的合理化倾向。当工具返回 [],一个被设定为"分析师"的 agent 比 neutral 的 agent 更倾向于编造"基于现有数据的洞察",因为它的人设不允许它说"我没数据"。在 CrewAI 里建校验墙,最务实的做法是把校验逻辑塞进工具方法本身:返回前 if-empty 就 raise Exception("NO_DATA"),让 CrewAI 的错误处理把它转成 agent 可见的失败信号。这违背了 CrewAI"工具只管做事"的纯粹性,但这是它当前架构下唯一的硬防护点。
OpenAI Agents SDK 的 function_tool 配合 strict 模式,把参数校验做到了行业标杆——JSON Schema 全字段约束、拒绝多余参数、类型强校验。但即便如此,返回值依然是函数的原始返回值,SDK 不做任何 schema 校验。这是"参数侧的贵族、结果侧的裸奔"的最典型样本。
from agents import function_tool
@function_tool
def get_account(id: str) -> dict:
return bank_api.fetch(id)
# 可能返回 {"error": "account_closed", "balance": null} —— SDK 全盘接受
SDK 的 strict=True 只作用于工具的输入 schema(即 OpenAI function calling 的 parameters),对工具函数的返回值类型它甚至不强制声明——你可以 -> dict 但返回任何东西。建墙方式是在工具函数内包一层 Pydantic TypeAdapter,对返回值做 validate_python,校验失败则抛 ModelRetry,SDK 会把异常转成给模型的重试信号。这其实是一条相对干净的路径(SDK 有原生的 retry-on-error 机制),但它依然完全依赖开发者自觉,SDK 文档里对"结果校验"的指引近乎空白。
Pydantic AI 是六个栈里唯一把"返回值也是类型"贯彻到底的。它的工具用 Pydantic 模型定义,函数签名可声明返回类型,且整个 agent 的最终输出(result_type)也是 Pydantic 模型,带有完整的校验和重试。
from pydantic_ai import Agent, RunContext, Tool
from pydantic import BaseModel, field_validator
class Orders(BaseModel):
status: str
count: int
items: list[dict]
@field_validator("count")
def non_empty_check(cls, v, info):
if v == 0:
raise ValueError("EMPTY_RESULT") # 触发模型重试
return v
async def search(ctx: RunContext, q: str) -> Orders:
return Orders(**await api.search(q)) # 返回前即类型化校验
这段代码的关键不在语法,而在理念:返回值不是无类型字符串,而是一个有契约的模型。空结果通过 field_validator 直接被拦截并转化为结构化失败,模型收到的是带语义的"为何失败",而不是一行裸 []。但 Pydantic AI 也有它的天花板——它能保证"形状对",却不能保证"形状对但语义是空"被全覆盖(比如 count=1 但那条数据本身就是脏的),这类深层语义校验仍需自定义 validator。尽管如此,它是六个栈里唯一让你不必从零自建返回值校验的,假成功税最低。代价是它的抽象相对年轻,生态和 LangGraph 不可同日而语。
校验墙的最后一个、也是最容易被忽视的环节是回灌:即使工具返回值通过了校验,它进入上下文的方式本身也是一种税。一个返回 50KB JSON 的工具调用,会把上下文窗口撑大、稀释关键信息、推高后续每一步的 token 成本。六个栈在"结果裁剪/压缩"上全部默认缺失——没有任何一个栈会在回灌前自动截断大返回、提取关键字段、或对历史工具结果做摘要。
这条税的隐蔽性在于:它和假成功问题同源。当返回值是无类型字符串(LangChain、AutoGen、CrewAI),你既无法校验也无法高效裁剪——因为框架不知道哪个字段是"关键结果",哪个是"噪声元数据"。而一旦像 Pydantic AI 那样把返回值模型化,裁剪就有了抓手:你可以定义一个精简的 SummaryView 模型,把完整的 Orders 压缩成 {"count": 12, "total": 5400, "first_3_ids": [...]} 再回灌,上下文体积可降一个数量级。模型化是校验和裁剪的共同前提,这也是为什么 Pydantic AI 在"结果→上下文回灌税"一栏能拿到"低"——它的类型化设计同时解了两道题。
场景一:已有 LangGraph/LangChain 代码库,要事后补校验。 别动主流程,在工具函数外层套一个统一的装饰器:返回前用 Pydantic TypeAdapter 做 schema 校验 + 空集断言,失败则抛异常让框架走 retry。LangGraph 优先在图里加一个显式 validate_node,便于调试和监控。预估能把假成功率从 3–15% 压到 1% 以下,单工具改造工时约半天。
场景二:新项目、对可靠性要求高(金融/医疗/法律 Agent)。 直接上 Pydantic AI,把返回值类型化作为第一原则。它的 result_type + field_validator + 原生重试能让你用声明式代码覆盖 80% 的假成功场景,省下的自建校验墙工时远超它生态较新的代价。配合结构化输出(structured outputs / constrained decoding)进一步锁死最终产物格式,可以把失败率再压一截。
场景三:多工具、批量调用密集(数据分析/运维 Agent)。 部分失败是头号风险。无论用哪个栈,都必须在工具层把批量结果解构成"成功项/失败项"两段分别回灌,并对失败项做显式计数注入上下文("本次批量调用 10 项中 3 项失败,失败项 ID:…")。OpenAI Agents SDK 的 ModelRetry + Pydantic 校验组合在这里性价比最高。据实测,仅这一项改造就能把 JSON Schema 约束带来的失败率改善从 40–60% 进一步推向 70%+,因为部分失败是最容易被"全量乐观"掩盖的一类假成功。
场景四:低代码/快速验证原型。 CrewAI 或 OpenAI Agents SDK 裸奔可以,但务必在心里记一笔:所有工具函数 return 前加一行 if not result: raise。这是两行成本换 10%+ 假成功税的止损,没有理由不做。
[!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 等场景全覆盖。
