工具调用(Tool Use / Function Calling)的可靠性瓶颈,不在模型选错工具,也不在 JSON 拼错括号——而在入参契约的往返闭环:开发者写一个带类型签名的函数 → 框架把它翻译成 JSON Schema → 模型按 Schema 填参数 → 框架拿回参数是否校验 → 函数真正执行。本论横评原生裸调、LangChain、CrewAI、OpenAI Agents SDK、Pydantic AI 五栈,核心发现:大多数框架默认运行在"宽校验"模式——静默强转类型("5000"→5000)、丢弃多余字段、自动拍平嵌套对象——把模型的失误消化成无声的错误行为,而非可捕获的异常。唯有少数提供 strict 模式(additionalProperties:false、不强转、全必填),能在入参落地前拦下错误,代价是牺牲模型自由度、并冒拒调循环的风险。本文给出每种模式的真实代码与代价曲线,并给出"严格 Schema + 类型闸门 + 失败回喂修复"的落地范式。
| 框架 / 栈 | 契约来源 | 校验时机 | 默认模式 | 默错风险 | 失败回喂 |
|---|---|---|---|---|---|
| 原生裸调(OpenAI client) | 手写 JSON Schema | 无(json.loads+**kwargs 直喂) |
极宽 | 极高:类型错即崩或静默错 | 无,需自建 |
LangChain @tool |
Pydantic 模型/类型注解 | 调用前 Pydantic 校验 | 宽(strict=False,lax 强转) |
高:嵌套拍平、多余字段丢弃 | 无自动 |
CrewAI BaseTool |
args_schema Pydantic |
调用前表层校验 | 宽(lax) | 中高:表层过、深层漂移 | 抛错给 Agent |
OpenAI Agents SDK @function_tool |
函数签名 + Pydantic | 调用前 TypeAdapter | 严格(strict_mode=True 默认) |
低:拦在落地前 | ModelBehaviorError 或回喂 |
Pydantic AI @agent.tool |
函数签名(Strict* 原生) |
调用前强类型校验 | 严格(StrictInt/Str) |
极低:不强转、不拍平 | 自动回喂触发自修正 |
json.loads 直喂即崩最低成本、也最危险的写法——直接用 OpenAI 官方 SDK,手写一份 parameters Schema,拿到模型的 tool_calls[0].function.arguments 字符串后 json.loads,再 **kwargs 灌进业务函数。Schema 在这条链路里只是一份写给模型看的说明书,运行时没有任何代码去核对它。
def transfer(from_acct: str, to_acct: str, amount_in_cents: int) -> str:
ledger.debit(from_acct, amount_in_cents) # 若 amount 是 "5000" 字符串
return "ok"
call = resp.choices[0].message.tool_calls[0]
args = json.loads(call.function.arguments) # 原样进来
transfer(**args) # 裸调:契约无人守
当模型把 amount_in_cents 填成字符串 "5000"(在非 strict 函数调用里完全可能),json.loads 不会报错,**args 也不会报错,ledger.debit 可能按字符串记账、可能在某次 + 运算时才崩。错误被推迟到了离调用现场最远的地方,且因为函数返回 "ok",模型永远不知道自己调错了,更不会重试。这是"宽校验即默错"最极端的形态:根本没有校验。任何用裸 SDK 起步的项目,第一笔线上事故几乎都来自这里。
@tool:宽校验的拍平陷阱LangChain 的 @tool 装饰器用 Pydantic 把函数签名转成 Schema,并在调用前做一次校验,看似安全。问题在于默认 strict=False,且 Pydantic 的默认配置是 lax:它会强转 "5000"→5000,会用 extra="ignore" 静默丢弃模型多塞的字段,更隐蔽的是——对嵌套对象,模型常常把深层字段拍平到浅层。
class Filter(BaseModel):
vendor_guid: str
class Params(BaseModel):
filters: list[Filter] = []
class QueryArgs(BaseModel):
params: Params
@tool
def query(args: QueryArgs) -> str:
return db.search(args.params.filters[0].vendor_guid)
模型实际返回的不是 {"params": {"filters": [{"vendor_guid": "..."}]}},而是 {"params": {"vendor_guid": "..."}}——vendor_guid 被放到了 params 下而非 params.filters 里。LangChain 官方知识库专门有一篇文章记这个坑,解法就是加 strict=True。在宽模式下,filters 是空列表,query 查不到东西、或查全表,函数照样返回一个"看似正常"的结果。这就是默错税:你拿到了一个错误答案,但没有任何异常被抛出。
BaseTool:表层过、深层漂移CrewAI 用 BaseTool + args_schema(一个 Pydantic 模型)描述入参契约,校验发生在 _run 之前。它的特点是校验只到表层字段名,对值的语义不做约束,且默认沿用 Pydantic lax 行为。
class SearchInput(BaseModel):
query: str
limit: int = 5
class SearchTool(BaseTool):
name = "search"
args_schema = SearchInput
def _run(self, query: str, limit: int = 5) -> str:
return index.search(query, limit)
模型传 limit: "five" 时,lax 模式无法把 "five" 转成 int,校验失败——CrewAI 把这个异常抛回给 Agent,靠下一轮模型自己改。这比裸调好(至少抛了错),但问题在于:抛错是否真能驱动修复,取决于框架有没有把"校验错误信息"原样回喂给模型;很多早期版本只把异常吞进日志,Agent 看到的是一个空结果,于是换一种同样错误的方式再调一次,陷入无效重试。CrewAI 的契约守门,止步于字段名匹配。
OpenAI Agents SDK 是少数默认开严格模式的栈。@function_tool 装饰器从函数签名生成 Schema 时,默认 strict_mode=True:每个对象都加 additionalProperties: false、所有字段标为 required、移除 $ref、仅保留 OpenAI Structured Outputs 支持的类型子集。模型回填的参数,先经过 pydantic.TypeAdapter 校验,再喂进函数体。
from agents import function_tool
from pydantic import BaseModel
class TransferArgs(BaseModel):
from_acct: str
to_acct: str
amount_in_cents: int
model_config = {"extra": "forbid"}
@function_tool # strict_mode=True 默认
def transfer(args: TransferArgs) -> str:
return ledger.move(args.from_acct, args.to_acct, args.amount_in_cents)
严格模式的收益是默错被拦在落地前——多余字段、错误类型、缺失必填,都在调函数前就报错。代价有二:一是模型自由度被压缩,遇到 Schema 表达力不够(如 union、复杂 oneOf)时只能绕路;二是校验失败默认 raise ModelBehaviorError,直接中断整个 run,除非你显式配置把工具错误回喂给 Agent。换句话说,strict 把"默错"变成了"拒调"——错误可见了,但处理错误的修复回路仍要你自己接。strict=true 并非银弹,它只是把问题从"看不见"挪到"看得见但会卡住"。
Pydantic AI 把"函数签名即契约"做到了端到端原生。@agent.tool 直接从 Python 类型注解生成 Schema,且鼓励用 StrictStr/StrictInt 这类强类型基元——StrictInt 不会把 "5000" 转成 5000,类型必须精确匹配。整个往返闭环是强类型的:签名 → 严格 Schema → 模型填充 → 强类型校验 → 强类型调用。
from pydantic_ai import Agent, RunContext
from pydantic import BaseModel, StrictInt, StrictStr
agent = Agent("openai:gpt-5")
class TransferArgs(BaseModel):
from_acct: StrictStr
to_acct: StrictStr
amount_in_cents: StrictInt
@agent.tool
def transfer(ctx: RunContext[None], args: TransferArgs) -> str:
return ledger.move(args.from_acct, args.to_acct, args.amount_in_cents)
它最关键的设计是校验失败不中断,而是自动把 Pydantic 的校验错误信息回喂给模型,触发 self-correction。模型收到"amount_in_cents 必须是整数,得到字符串 '5000'"这种精确提示后,下一轮就会改正。这是五栈里唯一把"严格闸门"和"修复回路"默认接通的——错误既被拦下,又被结构化地用于驱动重试。代价是开发心智:你要认真对待每一个类型注解,StrictInt 写成 int 就退回 lax 强转。原生派的优势,恰恰来自它对"契约不可糊弄"的坚持。
params.filters 被拍平的根因五栈共有一个高频失败点:嵌套对象越深,模型越倾向于拍平。这不是某个框架的 bug,而是函数调用 Schema 在"非 strict"路径下的结构性弱点。根因有三:其一,宽松模式下 additionalProperties 默认为 true(或被忽略),模型多塞一个字段不会受惩罚,于是它按"最省力"的方式把字段铺在浅层;其二,JSON Schema 的嵌套 $ref 在非 strict 渲染下信息密度高,小模型倾向于塌缩层级;其三,Pydantic 的 extra="ignore" 默认值会静默吃掉放错位置的字段,使错误不可见。
修复只有一条路:强制 strict(additionalProperties:false + 每层对象 all-required + 禁 $ref)。LangChain 的 KB 文章、OpenAI 的 Structured Outputs 指南、jsonic.io 的对比都指向同一结论——非 strict 模式下"模型多产出的字段会被接受但被忽略",这正是默错的温床。凡是涉及嵌套筛选条件、多层配置的工具,宽模式几乎必然失真。
把五栈沿一条光谱排开:原生裸调(不报错)→ LangChain/CrewAI(宽校验,默错)→ OpenAI Agents SDK(严格,拒调)→ Pydantic AI(严格+回喂修复)。这条光谱本质是在两种代价之间选址。
默错的代价是"看不见的偏差"——函数返回了一个结果,结果错了,但没有异常。在转账、计费、数据库写入这类场景里,默错等于事故:模型以为成功、永不重试、错误扩散。拒调的代价是"看得见的卡顿"——校验失败抛错,run 中断,用户看到错误或长时间无响应;好处是错误可观测、可重试、可兜底。判断该用哪种,看一条:这个工具调错的后果,是可逆还是不可逆。可逆(查询、读取、格式化)容忍宽校验,默错损失小;不可逆(扣款、发送、删除)必须严格,宁拒勿默。多数框架把这条开关留给开发者,却很少提示其后果量级。
严格闸门拦下错误只是第一步,没有修复回路的严格,等于把默错换成死循环。五栈里只有 Pydantic AI 默认把校验错误回喂给模型;OpenAI Agents SDK 需要显式配置;LangChain/CrewAI/裸调则要自己拼。
一个最小可用的修复回路长这样:
for attempt in range(3):
call = ask_model_for_tool_call(messages)
args = json.loads(call.function.arguments)
try:
validated = ArgsModel.model_validate(args) # 严格闸门
return run_tool(validated) # 通过 → 执行
except ValidationError as e:
messages.append(tool_result(call.id, f"参数错误: {e}. 请按 Schema 重调。"))
# 三次不过 → 升级人工,绝不静默放行
三个要点:校验错误要原样、结构化地回喂(Pydantic 的错误信息天然适合);要设重试上限,否则模型可能在一个它无法满足的 Schema 上空转烧钱;超限后必须显式失败而非放行——放行就回到了默错。这条回路把"严格"从"会卡住"变成"会自我修复",是工具调用走向生产可用的最后一块拼图。
flowchart TD
A[开发者: 带类型签名的函数] --> B[框架: 生成 JSON Schema]
B --> C{strict?}
C -->|否 宽模式| D1[additionalProperties 任意<br/>lax 强转 extra=ignore]
C -->|是 严格模式| D2[additionalProperties:false<br/>全必填 不强转]
D1 --> E[模型: 填参数]
D2 --> E
E --> F[框架: 拿回 arguments]
F --> G{校验闸门}
G -->|裸调: 无校验| H1[json.loads 直喂<br/>❌ 类型错即崩或默错]
G -->|宽校验| H2[静默强转/丢弃/拍平<br/>❌ 错误结果 无异常]
G -->|强类型校验| H3[类型不匹配 → 拒绝]
H1 --> Z[业务函数执行]
H2 --> Z
H3 --> I{修复回路?}
I -->|无 默认raise| J1[ModelBehaviorError<br/>⚠️ run 中断]
I -->|回喂错误| J2[校验信息回喂模型<br/>✓ 下一轮自修正]
J2 --> E
J2 -->|超过上限| K[显式失败 升级人工]
classDef danger fill:#fee,stroke:#c33,color:#900
classDef safe fill:#efe,stroke:#393,color:#060
classDef warn fill:#ffe,stroke:#cc3,color:#660
class H1,H2 danger
class H3,J2 safe
class J1,K warn
图中红色路径(裸调直喂、宽校验默错)是事故高发区;绿色路径(强类型校验 + 回喂修复)是生产推荐区;黄色路径(中断、升级)是严格但无修复时的兜底。
不可逆操作(扣款/发送/删除/写入):选 Pydantic AI 或 OpenAI Agents SDK,强制 strict + extra="forbid" + StrictInt/Str,并显式接修复回路、设重试上限。宁拒勿默,默错在这类操作里等于资金或数据事故。Schema 设计时把嵌套控制在两层以内,超过两层先用严格模式压测模型遵守率。
可逆操作(查询/读取/格式化):LangChain @tool 或 CrewAI 的宽校验可接受,默错损失小、开发更快。但对结果敏感的查询(如带权限筛选的列表),仍要在 @tool(strict=True) 下跑,避免 filters 被拍平导致越权返回。
快速原型 / 教学:裸 SDK 可用,但务必在 transfer(**args) 前加一层 ArgsModel.model_validate(args),把契约从"说明书"升级为"闸门",成本一行代码,能挡掉绝大多数默错。
统一原则:工具的入参契约,必须是可执行的代码而非文档。Schema 一旦不能在运行时拦下错误,它就只是建议;而当建议被模型违反时又无人告警,默错就发生了。把每一处工具调用都问一遍——"模型填错了,我是会崩、会默错、还是会自修复"——答案决定了你该用哪一栈、开不开 strict、接不接回路。
@tool 嵌套 Pydantic 字段被模型拍平(vendor_guid 错放至 params)的故障与 strict=True 修复additionalProperties:false、全必填、支持类型子集的约束对比@tool 装饰器从签名生成 Schema 与 Pydantic 参数校验的实践指南[!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 等场景全覆盖。
