一个让我凌晨爬起来修的事故
生产环境,凌晨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_json 和 tools 同时传时,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 个坑,全部是在开发过程中确实遇到的:
-
OpenAI strict 模式要求 additionalProperties 必须显式为 false。不写,默认是 true,strict 模式直接报 400。错误信息是 “additionalProperties: true is not supported”。记住:strict 模式下你的 schema 里必须有这个字段,且值必须是 false。
-
DeepSeek 不支持 strict=True 参数,但不会主动告诉你。它返回 400,错误信息是 “Invalid parameter: strict”。如果你用同一套代码切换模型,要把 strict 参数从请求里剥掉。我在代码里用了个小技巧:把 strict 参数放在模型配置字典里,而不是统一传。
-
DeepSeek 的 tool_choice 只支持对象形式。OpenAI 支持
"required"字符串,DeepSeek 只支持{"type": "function", "function": {"name": "..."}}。传 "required" 会 400。而且 DeepSeek 的 tool_choice 必须指定具体的 function name,不支持 auto 之外的自由组合。 -
max_tokens 截断时,strict 模式返回的是空 arguments。gpt-4o strict 那一次失败就是这样:arguments 是空字符串,不是非法 JSON。所以即使 strict 模式,也必须检查
arguments非空。不要只 try json.loads。 -
vLLM 的 guided_json 和 tools 同时传时行为不一致。vLLM 的 OpenAI 兼容层在存在 guided_json 时会忽略 tools 里的参数约束。如果你两者都传了,实际生效的是 guided_json。我在调试时发现工具参数明明定义限定了 enum,返回的枚举却不在列表里——因为 tools 被忽略了。
-
Pydantic v2 的 ConfigDict(strict=True) 不会递归生效。你需要在每个字段或嵌套 model 上显式加 strict=True。最稳的方式是用 TypeAdapter 配合完整配置:
TypeAdapter(SubmitOrderArgs, config=ConfigDict(strict=True))。我一开始只在模型类上写了 ConfigDict,结果嵌套字段照样宽松通过。 -
模型返回的 tool_call 消息要完整回传才能重试。重试时,messages 列表要追加 assistant 的 tool_calls 原文和对应的 tool 消息,缺一不可。OpenAI 会校验 tool_call_id 是否存在。DeepSeek 更严格:id 不存在或 tool_call_id 与前面的不匹配,直接报 400。
-
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。校验、重试、人工兜底,一个都不能少。