<!-- title: 宽校验即默错：入参契约五栈实测 -->

## 概述

工具调用（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 在这条链路里**只是一份写给模型看的说明书**，运行时没有任何代码去核对它。

```python
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 起步的项目，第一笔线上事故几乎都来自这里。

### 二、LangChain `@tool`：宽校验的拍平陷阱

LangChain 的 `@tool` 装饰器用 Pydantic 把函数签名转成 Schema，并在调用前做一次校验，看似安全。问题在于**默认 `strict=False`，且 Pydantic 的默认配置是 lax**：它会强转 `"5000"`→`5000`，会用 `extra="ignore"` 静默丢弃模型多塞的字段，更隐蔽的是——**对嵌套对象，模型常常把深层字段拍平到浅层**。

```python
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` 查不到东西、或查全表**，函数照样返回一个"看似正常"的结果。这就是默错税：你拿到了一个错误答案，但没有任何异常被抛出。

### 三、CrewAI `BaseTool`：表层过、深层漂移

CrewAI 用 `BaseTool` + `args_schema`（一个 Pydantic 模型）描述入参契约，校验发生在 `_run` 之前。它的特点是**校验只到表层字段名，对值的语义不做约束**，且默认沿用 Pydantic lax 行为。

```python
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：strict 默认开的代价

OpenAI Agents SDK 是少数**默认开严格模式**的栈。`@function_tool` 装饰器从函数签名生成 Schema 时，默认 `strict_mode=True`：每个对象都加 `additionalProperties: false`、所有字段标为 required、移除 `$ref`、仅保留 OpenAI Structured Outputs 支持的类型子集。模型回填的参数，先经过 `pydantic.TypeAdapter` 校验，再喂进函数体。

```python
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：强类型闭环的原生派

Pydantic AI 把"函数签名即契约"做到了端到端原生。`@agent.tool` 直接从 Python 类型注解生成 Schema，且鼓励用 `StrictStr`/`StrictInt` 这类**强类型基元**——`StrictInt` 不会把 `"5000"` 转成 `5000`，类型必须精确匹配。整个往返闭环是强类型的：签名 → 严格 Schema → 模型填充 → 强类型校验 → 强类型调用。

```python
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 模式下"模型多产出的字段会被接受但被忽略"，这正是默错的温床。凡是涉及嵌套筛选条件、多层配置的工具，宽模式几乎必然失真。

### 七、默错 vs 拒调：两种代价的权衡

把五栈沿一条光谱排开：原生裸调（不报错）→ LangChain/CrewAI（宽校验，默错）→ OpenAI Agents SDK（严格，拒调）→ Pydantic AI（严格+回喂修复）。这条光谱本质是在两种代价之间选址。

**默错**的代价是"看不见的偏差"——函数返回了一个结果，结果错了，但没有异常。在转账、计费、数据库写入这类场景里，默错等于事故：模型以为成功、永不重试、错误扩散。**拒调**的代价是"看得见的卡顿"——校验失败抛错，run 中断，用户看到错误或长时间无响应；好处是错误可观测、可重试、可兜底。判断该用哪种，看一条：**这个工具调错的后果，是可逆还是不可逆**。可逆（查询、读取、格式化）容忍宽校验，默错损失小；不可逆（扣款、发送、删除）必须严格，宁拒勿默。多数框架把这条开关留给开发者，却很少提示其后果量级。

### 八、修复回路：验不过就重喂

严格闸门拦下错误只是第一步，**没有修复回路的严格，等于把默错换成死循环**。五栈里只有 Pydantic AI 默认把校验错误回喂给模型；OpenAI Agents SDK 需要显式配置；LangChain/CrewAI/裸调则要自己拼。

一个最小可用的修复回路长这样：

```python
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 上空转烧钱；超限后**必须显式失败**而非放行——放行就回到了默错。这条回路把"严格"从"会卡住"变成"会自我修复"，是工具调用走向生产可用的最后一块拼图。

## 入参契约往返闭环架构

```mermaid
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、接不接回路。

---

## 参考来源

1. [LLM Not Following Nested Tool Schema? Use strict=True — LangChain Knowledge Base](https://kb.langchain.com/articles/2731242163-llm-not-following-nested-tool-schema-use-strict-true) — 实录 LangChain `@tool` 嵌套 Pydantic 字段被模型拍平（`vendor_guid` 错放至 `params`）的故障与 `strict=True` 修复
2. [Function calling — OpenAI API Docs](https://developers.openai.com/api/docs/guides/function-calling) — 非严格函数调用下"模型多产出的字段会被接受但被忽略"的官方说明，宽校验默错的机制根因
3. [Pydantic AI Tutorial 2026: Type-Safe Python Agents With Automatic Validation and Self-Correction](https://baeseokjae.github.io/posts/pydantic-ai-tutorial-2026/) — Pydantic AI 强类型工具 + 校验失败自动回喂触发自修正的端到端示例
4. [JSON Schema for Function Calling: OpenAI, Claude — Jsonic](https://jsonic.io/guides/json-schema-function-calling) — 严格模式对 `additionalProperties:false`、全必填、支持类型子集的约束对比
5. [Complete Guide to Defining Parameterized Tools in LangChain — Buzhou](https://www.buzhou.io/en/articles/complete-guide-to-defining-parameterized-tools-in-langchain) — `@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 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
