Function Calling避坑实测:strict模式与兼容层
发布日期: 2026/08/08 阅读总量: 0

一个让我凌晨爬起来修的事故

生产环境,凌晨1:47,下单链路挂了。手机连着公司群,十几条报警:GET /api/order/create 500

查日志,崩溃点在这行代码:

# python 3.11
args = json.loads(tool_call.function.arguments)
order_id = args["order_id"]
address = args["address"]
pay_method = args["pay_method"]

报错是 KeyError: 'address'。模型返回的 arguments 里确实有 address 字段,但值是 null。后端数据库 address NOT NULL,直接爆炸。

我当时用的还是 OpenAI 官方 function calling,没开 strict 模式。模型返回了 null,json.loads 能过,pydantic 的 type: str 也能过(pydantic 默认会把 null 转成 None?不,v2 里 str | None 才过,我的 schema 写的是 str,应该报错——但我的外层校验只检查了「是否 JSON 可解析」,没做 schema 校验)。问题出在自己身上:我没有对 model 输出做完整的 schema 验证。

于是我把线上所有 Function Calling 链路从「拿到 JSON 直接用」改成「加载 schema、严格校验、失败重试」。这个过程中测了三条路线:OpenAI strict 模式、DeepSeek 兼容 API、vLLM 本地部署。花了两周,下面是完整记录。

Function Calling 到底难在哪

先说结论:Function Calling 的难点不在「调用函数」,而在「模型输出的 arguments 不一定合法」。模型是概率生成的,它可能输出:

  • JSON 语法不合法(少个引号、多了逗号)
  • 字段缺失、字段类型错误(字符串传成数字)
  • 多余字段(模型自己加了 schema 里没有的 key)
  • 枚举值不在允许范围内(把 "wechat" 写成 "wechat_pay")
  • 字符串超长、正则不匹配(order_id 格式错误)

OpenAI 2024 年 8 月发布的 strict function calling,用 TSON(流式 JSON Schema 解析器)在采样阶段约束输出,理论上能保证 100% 合法。我拿它和 DeepSeek 兼容层、vLLM 本地部署做了对比。

方案一:OpenAI strict function calling

环境:Python 3.11.7、openai 1.51.0、模型 gpt-4o-2024-08-06

核心代码:

# openai_strict_demo.py
import json
import os
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

tools = [{
    "type": "function",
    "function": {
        "name": "submit_order",
        "description": "用户确认后创建订单,返回订单号",
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {
                    "type": "string",
                    "description": "订单号,格式 ORD-四位年份-四位序号",
                    "pattern": "^ORD-\\d{4}-\\d{4}$"
                },
                "address": {
                    "type": "string",
                    "description": "收货地址,至少5个字符",
                    "minLength": 5
                },
                "pay_method": {
                    "type": "string",
                    "enum": ["wechat", "alipay", "card"]
                }
            },
            "required": ["order_id", "address", "pay_method"],
            "additionalProperties": False
        }
    }
}]

resp = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "你是订单助手。调用 submit_order 函数时必须严格按 schema 输出。"},
        {"role": "user", "content": "把订单 ORD-2024-0923 配送到 北京市朝阳区望京SOHO T1 12层,用微信支付。"}
    ],
    tools=tools,
    tool_choice="required",   # 强制模型调用函数
    strict=True               # 关键开关:开启严格模式
)

注意 strict=True 这个参数。OpenAI 会把我的 JSON Schema 转换成内部 TSG(Token Sampling Graph),模型每个 token 生成时都会检查「下一个合法 token 是什么」。TSON 保证输出的 arguments 一定符合 schema。

实测返回的 arguments 长这样:

// gpt-4o strict 返回
{
  "order_id": "ORD-2024-0923",
  "address": "北京市朝阳区望京SOHO T1 12层",
  "pay_method": "wechat"
}

注意:additionalProperties: false 在 strict 模式下是必需项。不写会报 400。

代价

绑定 OpenAI 生态(兼容层理论上也能用,但 strict 只对 OpenAI 自己的模型生效);贵;schema 必须能被 OpenAI 的转换器接受,有些复杂 schema 会直接报错。比如 anyOf 嵌套太多会被拒。

方案二:DeepSeek + 自定义 schema 校验

环境:deepseek-chat(2025-01-25 checkpoint)、openai 1.51.0(base_url 指向 DeepSeek)、pydantic 2.8.2。

DeepSeek 的 API 兼容 OpenAI 的 tools 格式,但有个坑:它不支持 strict=True 参数。传了直接报 Error code: 400 - Invalid parameter: strict。所以只能用「普通 function calling + 自己校验」的方式。

代码分两层。第一层是调用:

# deepseek_call.py
from openai import OpenAI
import os

client = OpenAI(
    api_key=os.environ["DEEPSEEK_API_KEY"],
    base_url="https://api.deepseek.com/v1"
)

tools = [{
    "type": "function",
    "function": {
        "name": "submit_order",
        "description": "用户确认后创建订单",
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string"},
                "address": {"type": "string"},
                "pay_method": {"type": "string", "enum": ["wechat", "alipay", "card"]}
            },
            "required": ["order_id", "address", "pay_method"]
        }
    }
}]

resp = client.chat.completions.create(
    model="deepseek-chat",
    messages=[
        {"role": "system", "content": "你是订单助手,只调用 submit_order 函数,不要额外输出。"},
        {"role": "user", "content": "订单是 ORD-2024-0923,送到北京市朝阳区望京SOHO T1 12层,微信支付。"}
    ],
    tools=tools,
    tool_choice={"type": "function", "function": {"name": "submit_order"}}
)

第二层是校验。因为 DeepSeek 可能返回非法 JSON,必须用 pydantic 兜底:

# validate_args.py
import json
from pydantic import BaseModel, Field, ValidationError

class SubmitOrderArgs(BaseModel):
    order_id: str = Field(pattern=r"^ORD-\d{4}-\d{4}$")
    address: str = Field(min_length=5)
    pay_method: str = Field(pattern="^(wechat|alipay|card)$")

def validate_and_normalize(raw_arguments: str) -> SubmitOrderArgs:
    """把模型的 arguments 字符串解析并校验,非法则抛异常"""
    try:
        data = json.loads(raw_arguments)
        return SubmitOrderArgs(**data)
    except (json.JSONDecodeError, ValidationError) as exc:
        raise ValueError(f"模型输出不符合 schema: {exc}") from exc

# 用法
try:
    args = validate_and_normalize(tool_call.function.arguments)
except ValueError:
    # 进入重试逻辑(见下文)
    pass

这套方案的好处是:模型可以随便换(DeepSeek、Qwen、GLM 都行),校验逻辑在自己手里。坏处是:模型输出不合法的概率是真实存在的,必须有重试机制兜底。

方案三(补充):vLLM 本地部署 + xgrammar 引导解码

如果你是私有化部署,vLLM 0.6.3+ 支持 guided decoding。原理和 OpenAI 的 TSON 类似,但用的是 xgrammar 库,在解码时用 DFA(确定性有限自动机)约束输出。

启动配置:

# vllm_config.yaml
model: ./models/llama-3.1-8b-instruct/
enforce_eager: true
max-model-len: 8192
gpu-memory-utilization: 0.95
guided-decoding-backend: xgrammar

启动命令:

# vLLM 0.6.3 + CUDA 12.1
python -m vllm.entrypoints.openai.api_server \
  --config vllm_config.yaml \
  --port 8000

请求时传 guided_json 参数:

curl -s http://localhost:8000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama-3.1-8b-instruct",
    "messages": [
      {"role": "user", "content": "把订单 ORD-2024-0923 送到北京市朝阳区望京SOHO T1 12层,微信支付"}
    ],
    "tools": [
      {
        "type": "function",
        "function": {
          "name": "submit_order",
          "parameters": {
            "type": "object",
            "properties": {
              "order_id": {"type": "string"},
              "address": {"type": "string"},
              "pay_method": {"type": "string", "enum": ["wechat", "alipay", "card"]}
            },
            "required": ["order_id", "address", "pay_method"]
          }
        }
      }
    ],
    "guided_json": {
      "type": "object",
      "properties": {
        "order_id": {"type": "string"},
        "address": {"type": "string"},
        "pay_method": {"type": "string", "enum": ["wechat", "alipay", "card"]}
      },
      "required": ["order_id", "address", "pay_method"]
    }
  }'

注意:guided_jsontools 同时传时,vLLM 会优先用 guided_json 做约束。这里 tools 其实可以省略,但保留可以让 OpenAI 协议兼容层正常工作。

完整生产代码:带重试的 Function Calling 链路

把上面三条路线的经验合并起来,我最终的生产代码如下。逻辑是:调用模型 → 拿到 tool_call → 解析 JSON → pydantic 校验 → 失败则带着错误信息重试一次 → 再失败就降级为人工处理。

# function_calling_pipeline.py
# Python 3.11 + openai 1.51.0 + pydantic 2.8.2
import json
import time
from typing import Callable

from openai import OpenAI

from validate_args import SubmitOrderArgs, validate_and_normalize

MODEL = "gpt-4o-2024-08-06"  # 或 "deepseek-chat"
MAX_RETRY = 1

TOOLS = [{
    "type": "function",
    "function": {
        "name": "submit_order",
        "description": "用户确认后创建订单",
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string", "description": "订单号"},
                "address": {"type": "string", "description": "收货地址"},
                "pay_method": {"type": "string", "enum": ["wechat", "alipay", "card"]}
            },
            "required": ["order_id", "address", "pay_method"],
            "additionalProperties": False
        }
    }
}]

client = OpenAI(
    api_key=os.environ.get("OPENAI_API_KEY"),
    # 如果用 DeepSeek: base_url="https://api.deepseek.com/v1", api_key=os.environ["DEEPSEEK_API_KEY"]
)

def call_with_tool_retry(user_message: str, handler: Callable[[SubmitOrderArgs], dict]) -> dict:
    messages = [
        {"role": "system", "content": "你是订单助手。调用 submit_order 函数处理用户请求。"},
        {"role": "user", "content": user_message}
    ]

    for attempt in range(MAX_RETRY + 1):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=TOOLS,
            tool_choice="required",
            # strict=True  # OpenAI 专用,DeepSeek 不能开
        )

        tool_call = resp.choices[0].message.tool_calls[0]
        if not tool_call:
            raise RuntimeError("模型没有返回 tool_call")

        try:
            args = validate_and_normalize(tool_call.function.arguments)
            return handler(args)
        except ValueError as exc:
            if attempt == MAX_RETRY:
                raise
            # 把错误信息回传给模型,要求重新生成合法输出
            messages.append({
                "role": "assistant",
                "content": None,
                "tool_calls": [tool_call]
            })
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": f"参数校验失败: {exc}. 请重新生成符合 schema 的参数"
            })

    raise RuntimeError("重试次数用尽")

# 业务处理函数
def submit_order_handler(args: SubmitOrderArgs) -> dict:
    # 这里写真实的订单创建逻辑
    return {"status": "ok", "order_id": args.order_id}

if __name__ == "__main__":
    result = call_with_tool_retry(
        "把订单 ORD-2024-0923 送到北京市朝阳区望京SOHO T1 12层,微信支付",
        submit_order_handler
    )
    print(result)

Node.js 端的接收和校验可以这样写:

// server.js - Node 20.15 + zod 3.23.8
import { z } from 'zod';

const submitOrderSchema = z.object({
  order_id: z.string().regex(/^ORD-\d{4}-\d{4}$/),
  address: z.string().min(5),
  pay_method: z.enum(['wechat', 'alipay', 'card'])
});

export function parseToolCall(rawArguments) {
  try {
    const data = JSON.parse(rawArguments);
    return submitOrderSchema.parse(data);
  } catch (err) {
    throw new Error(`Tool call arguments invalid: ${err.message}`);
  }
}

效果数据:三套方案压测对比

测试方法:500 次真实用户请求(从线上日志抽样),同一批 prompt,分别发给三套方案。记录三个指标:JSON 可解析率(模型输出能被 json.loads)、schema 校验通过率(严格匹配字段类型/枚举/正则)、端到端延迟(从发出请求到拿到校验通过的完整 tool_call)。

方案 JSON 可解析率 schema 校验通过率 P50 延迟 P95 延迟 成本(千次调用)
gpt-4o strict 499/500 (99.8%) 498/500 (99.6%) 1.7s 2.4s 约 2.7 美元
DeepSeek + pydantic 校验 486/500 (97.2%) 463/500 (92.6%) 1.9s 3.1s 约 0.29 美元
vLLM + Llama-3.1-8B + xgrammar 471/500 (94.2%) 421/500 (84.2%) 0.9s 1.8s 硬件成本另算

补充说明:

  • gpt-4o strict 唯一一次失败是 max_tokens 截断。strict 保证格式,不保证内容的 token 数量够用。
  • DeepSeek 的 37 次 schema 失败里,12 次是多输出了解释文字(模型在 tool_call 之外还 call 了别的),15 次是枚举值写错("wechat_pay"、"微信支付"),10 次是 order_id 正则不匹配(把 "ORD-2024-0923" 写成了 "20240923")。
  • vLLM 的失败主要集中在小模型本身指令遵循能力差,跟引导解码无关。换成 Qwen2.5-14B-Instruct 后成功率提高到 92.4%。

原理:TSON、xgrammar 和 guided decoding 到底做了什么

很多人以为 Function Calling 是「模型学会了调用函数」。严格说,是「模型学会了在输出 token 时遵循一个 JSON Schema 约束」。这个约束不是在 prompt 里写一句「请输出 JSON」就完事,而是在解码阶段强制过滤非法 token。

正常 LLM 解码是逐个 token 采样,每个 token 从词表按概率分布选。guided decoding 做的是:把 JSON Schema 编译成一个有限状态机,每生成一个 token 就推进状态,下一个 token 的候选集合被「死限制」在状态机允许的 token 里。非法 token 的概率直接改成 0。

OpenAI 的 TSON 是流式 JSON Schema 解析器,专为 strict 模式设计。它把 schema 转成 token 级约束,additionalProperties: false 时,模型根本没有机会生成 schema 之外的 key。

xgrammar 是微软出的引导解码库,vLLM 0.6.3 起支持。它比老的 outlines 库快的点在于:语法分析结果(DFA)可以复用,多请求共享同一份编译后的状态机。实测单请求首次编译耗时 68ms,之后复用耗时不到 1ms。这个数据来自 vLLM 官方 benchmark,我自己的 500 次压测也验证了:只有头几个请求有额外延迟。

避坑指南

这 8 个坑,全部是在开发过程中确实遇到的:

  1. OpenAI strict 模式要求 additionalProperties 必须显式为 false。不写,默认是 true,strict 模式直接报 400。错误信息是 “additionalProperties: true is not supported”。记住:strict 模式下你的 schema 里必须有这个字段,且值必须是 false。

  2. DeepSeek 不支持 strict=True 参数,但不会主动告诉你。它返回 400,错误信息是 “Invalid parameter: strict”。如果你用同一套代码切换模型,要把 strict 参数从请求里剥掉。我在代码里用了个小技巧:把 strict 参数放在模型配置字典里,而不是统一传。

  3. DeepSeek 的 tool_choice 只支持对象形式。OpenAI 支持 "required" 字符串,DeepSeek 只支持 {"type": "function", "function": {"name": "..."}}。传 "required" 会 400。而且 DeepSeek 的 tool_choice 必须指定具体的 function name,不支持 auto 之外的自由组合。

  4. max_tokens 截断时,strict 模式返回的是空 arguments。gpt-4o strict 那一次失败就是这样:arguments 是空字符串,不是非法 JSON。所以即使 strict 模式,也必须检查 arguments 非空。不要只 try json.loads。

  5. vLLM 的 guided_json 和 tools 同时传时行为不一致。vLLM 的 OpenAI 兼容层在存在 guided_json 时会忽略 tools 里的参数约束。如果你两者都传了,实际生效的是 guided_json。我在调试时发现工具参数明明定义限定了 enum,返回的枚举却不在列表里——因为 tools 被忽略了。

  6. Pydantic v2 的 ConfigDict(strict=True) 不会递归生效。你需要在每个字段或嵌套 model 上显式加 strict=True。最稳的方式是用 TypeAdapter 配合完整配置:TypeAdapter(SubmitOrderArgs, config=ConfigDict(strict=True))。我一开始只在模型类上写了 ConfigDict,结果嵌套字段照样宽松通过。

  7. 模型返回的 tool_call 消息要完整回传才能重试。重试时,messages 列表要追加 assistant 的 tool_calls 原文和对应的 tool 消息,缺一不可。OpenAI 会校验 tool_call_id 是否存在。DeepSeek 更严格:id 不存在或 tool_call_id 与前面的不匹配,直接报 400。

  8. description 字段太长反而降低模型遵循率。别在 JSON Schema 里写长篇大论。我把字段 description 从 50 字压到 20 字后,DeepSeek 的枚举准确率从 88% 升到了 93%。模型对「描述里的信息」的记忆优先级远低于「字段名和类型」。字段名起得好,比 description 写得多有用。

选型建议

如果你在公有云上,预算充足,要极致的稳定性:选 OpenAI strict 模式。schema 校验失败率可以压到 0.5% 以内。

如果成本敏感,能接受 7% 左右的失败率并愿意写重试逻辑:选 DeepSeek + pydantic 自校验。成本是 OpenAI 的十分之一。

如果必须私有化部署,或者要在边缘端离线跑:建议 vLLM + xgrammar + Qwen2.5-14B-Instruct。成功率和延迟的平衡最好。

无论选哪条路,核心原则只有一条:永远不要信任模型输出的 JSON。校验、重试、人工兜底,一个都不能少。