年初我维护的一个 agent 服务,在 Function Calling 场景里连续出了三起事故,都是参数问题:一次把日期传成了字符串“下周一”,一次把温度系数填成 -40,一次把必填的订单号直接漏掉。排查到最后发现,根子不在模型,而在我们自己的代码——函数定义里写着 schema,运行时却没有任何 schema 校验兜底。后来我在工具调用链路上加了一层校验,问题立刻少了大半。这篇东西就聊聊我怎么用 schema 给 Function Calling 参数兜底,以及踩坑复盘之后我认为哪些做法真正有效。
1. 生产环境里最常见的四类参数错误
在展开校验方案之前,先把问题定义清楚。Function Calling 的本质,是让模型根据函数定义里的参数结构,输出一段结构化的 JSON 参数。但模型不是数据库,它对“类型正确、范围合法、字段必填”这些约束的理解,远没有我们想象中那么可靠。我在生产环境里观察到的错误,基本可以归成四类。
1.1 类型与格式的“差不多先生”
这是最普遍的一类。函数定义里写明order_id是 string,模型给你传一个 number;写明date是 ISO 8601 字符串,模型传一个“2025年4月1日”;写明status枚举是pending|paid|refunded,模型传一个小写带空格的paid。
我最早遇到的一次线上故障,就是因为模型把优惠金额字段传成了字符串"9.9",而下游 Java 接口直接按 BigDecimal 解析,字符串入参触发了异常。当时我还在想“模型为什么不按 schema 来”,后来想明白了:对 LLM 来说,JSON Schema 只是一个文本描述,它对类型的感知来自训练数据和上下文推断,不是来自一个强类型编译器。所以 TypeScript 里的string,在模型眼里仅仅是“像字符串的东西”。
1.2 越界数值:模型对范围没有真实感知
比类型错误更隐蔽的是范围错误。业务参数往往有边界:温度系数 0 到 1,分页大小不超过 100,折扣比例不超过 0.3。但函数定义里如果不写minimum、maximum,模型就只能靠训练数据里的常见值去猜。
我遇到过模型给分页接口传page_size = 5000000的情况,下游数据库查询直接超时。也见过模型给一个百分比字段传95,而业务方约定的是 0 到 1 的小数。这类错误最麻烦的地方在于:它类型没错、字段名没错,但值根本不能用。如果你只做typeof级别的检查,根本拦不住。
1.3 字段缺失与可选字段的业务必填陷阱
还有一类错误是“该传的没传”。JSON Schema 里标了required的字段,模型偶尔也会漏掉,尤其在函数参数非常多、上下文很长的时候。更麻烦的是另一种情况:schema 里把字段标成了optional,但业务逻辑中某些场景下它其实非有不可。
举我实际遇到的例子:我们有个邮件发送工具,to和cc都是可选字段,但业务规定至少填一个。模型在某个请求里只填了主题和正文,收件人为空,工具直接空指针。这类约束没法用简单的required表达,属于跨字段的业务校验逻辑,Function Calling 的 schema 描述不了,你只能在运行时自己补。
1.4 字段名幻觉和拼接错误
最后一种比较“高级”的错法:模型生成一个和定义里相似但不完全相同的字段名。比如定义里是user_id,模型输出userId;定义里是callback_url,模型输出callbackUrl。这其实是 token 级别的问题——模型在处理具体字段名时,偶尔会因为命名风格不同而产生“幻觉”。
更常见的是路径拼接问题。有些 agent 在多次连续工具调用时,会把上一个函数返回的字段名直接拼进下一个函数的参数里,导致order_order_id这种叠加错误。这类错误如果只靠人工看日志,排查效率很低;但用 schema 校验去兜,一秒钟就能抓到。
2. 为什么“模型契约”和“业务函数签名”不能直接画等号
既然错误这么多,很多人第一个想法是“把函数定义写好一点不就行了吗”。我最初也这么想,后来反复调 schema、调描述,效果始终有限。真正让我改变做法的,是把“模型看到的契约”和“业务代码执行的契约”当成了两套东西。
2.1 模型只知道语义,不知道业务规则的硬性约束
函数定义里的 JSON Schema,本质上是写给模型看的“使用说明书”。它的作用是让模型理解参数的含义、用途和格式,辅助模型做出正确的工具选择与参数生成。但说明书不等于校验规则——模型读完说明书,输出结果仍然可能不满足业务里的精确约束,比如长度范围、枚举值、正则、跨字段依赖。
我见过不少团队,以为函数定义里写了"type": "string", "enum": ["a", "b"],模型就一定会输出a或b。实际情况是:模型大概率会遵循,但小概率给出"A"或"c"。这个概率在长上下文、多工具混用时会显著上升。所以定义里的 schema 是“提高正确率的工具”,不是“保证正确性的机制”。
2.2 函数定义是给模型看的,运行时校验是给系统看的安全网
我现在的理解是:函数定义管“生成”,运行时 schema 管“执行”。模型生成参数之后,至少要有一道拦截器,把非法参数挡在业务函数之外。这道拦截器做的事情包括:
- 类型检查与合法转换(string 到 number、正则匹配等)
- 范围约束(minimum、maximum)
- 缺失字段判断(required、default、跨字段依赖)
- 结构化错误信息(供模型自我修正,也供日志排查)
一个很形象的类比是:函数定义相当于给模型提供了一份问卷,模型填完答案;运行时校验则是收卷老师。你总不能因为问卷上写了“请用阿拉伯数字填写”,就相信学生不会填汉字。
2.3 三个常见的错误观念
我复盘时发现,很多人(包括我自己)卡住是因为脑子里有几个默认假设,这些假设在 Function Calling 场景下统统不成立。
第一个假设:“框架已经帮我校验了。”确实,LangChain、Vercel AI SDK 这些框架在工具调用时会有基础的类型检查,但它们通常只做 JSON 解析层面的校验,很多不会做枚举、范围、跨字段逻辑校验。框架的职责是“让调用能够发生”,不是“让调用符合你的业务规则”。
第二个假设:“校验失败直接报错给用户就行。”这等于把修正机会全部放弃了。实际上,模型收到结构化错误提示后,重新生成合法参数的准确率非常高。你要做的不是让调用失败,而是把失败变成一次有质量的“重试上下文”。
第三个假设:“schema 写严一点,错误就少了。”schema 写太严,模型生成合法参数的难度会上升,反而可能导致模型频繁编造值来凑约束。这里有一个平衡点,后面我会展开讲。
3. 用 Zod schema 加一层“运行时保险丝”:落地过程详解
搞清楚“为什么必须校验”之后,剩下的问题就是“怎么校验”。我在项目里用的是 Zod,主要因为它和 TypeScript 结合得很顺,错误信息结构化,支持跨字段 refine,而且可以直接用来生成工具定义,避免维护两套 schema。下面按落地步骤讲。
3.1 选型:为什么选 Zod 而不是手写 JSON Schema 校验
手写 JSON Schema 校验逻辑当然可行,Node 生态里有 Ajv 这样成熟的库。但在一个 TypeScript 代码库里,我更愿意用一个能同时表达“类型”和“运行期校验”的方案。
Zod 的好处有三点:
.parse()/.safeParse()开箱即用,错误信息带路径,方便格式化.refine()支持跨字段依赖校验,比如“cc 和 to 至少填一个”- 配合
zod-to-json-schema,可以从同一个 schema 生成给模型看的函数定义,保证“模型读到的”和“运行时校验的”是同一份规则
如果你用的是 Python,对应的选择是 Pydantic,思路完全一样。核心不是某个库,而是“校验层必须存在”,工具库只是实现方式。
3.2 用同一个 schema 生成函数定义和运行时校验规则
我踩过的最大坑,就是函数定义里手写了一份 JSON Schema,运行时又用 Zod 另外写了一份。结果改了一处、忘了另一处,两边不一致,模型按旧格式输出,新校验把它拦下来,来回折腾。
现在我的做法是反过来的:以 Zod schema 为唯一数据源,函数定义从 schema 生成。示例:
import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; const queryOrderParams = z.object({ order_id: z .string() .min(6) .max(20) .regex(/^ORD-\d{4}-\d{4}$/, "订单号格式必须为 ORD-2025-0001"), include_detail: z.boolean().optional().default(false), }); const queryOrderTool = { name: "query_order", description: "按订单号查询订单状态,订单号格式 ORD-2025-0001", parameters: zodToJsonSchema(queryOrderParams, "queryOrderParams"), };这样一来,模型看到的parameters里已经有了格式约束、枚举和范围信息,同时运行时又可以用同一份queryOrderParams去做实际校验。两边永远同步,不会出现“定义说我接受 X,校验却拒绝 X”的尴尬。
3.3 写一个统一的参数校验关卡
工具多了以后,不能每个工具单独写校验逻辑。我习惯做一个统一的入口,在调用真实业务函数之前,先跑一遍 schema 校验。
type ToolDefinition = { name: string; description: string; parameters: z.ZodTypeAny; }; async function executeTool(tool: ToolDefinition, rawArguments: unknown) { const result = tool.parameters.safeParse(rawArguments); if (!result.success) { return { ok: false as const, error: formatValidationError(result.error), }; } // 校验通过,才把干净参数传给业务逻辑 return dispatchTool(tool.name, result.data); }这里有个容易被忽略的细节:模型返回的arguments往往是一个 JSON 字符串,不是对象。所以接收方要先用JSON.parse解析,再交给safeParse。解析失败本身也要做兜底,因为这说明模型输出了截断的 JSON 或非法 JSON,这也是一种“参数填错”。
3.4 校验错误信息格式化:让反馈能回到模型手里
safeParse返回的ZodError是给人看的,里面有path、message、code等结构化信息。但如果你直接把整个 error 对象丢回给模型当提示词,模型很可能被一堆细节带走。更好的做法是提炼成简短、明确、可执行的错误描述。
import { z } from "zod"; function formatValidationError(error: z.ZodError): string { return error.issues .map((issue) => { const path = issue.path.join("."); return `参数${path ? ` ${path} ` : ""}不正确:${issue.message}`; }) .join(";"); }比如模型给order_id传了12345,格式化后的信息是:
参数 order_id 不正确:订单号格式必须为 ORD-2025-0001这句话再加上一句“请基于约束重新生成完整参数”,拼进模型的下一次请求上下文里,模型修正的准确率会高很多。这是我在实际使用中发现效果最好的一个细节。
4. 校验失败之后的动作:反馈、重试与止损
加了校验层之后,最直观的变化是非法参数不会再穿透到业务层了。但紧接着出现一个新问题:校验失败之后怎么办?是直接报给用户,还是让模型重试?重试多少次?如果模型一直修不对,怎么止损?这些问题不解决,校验层反而会成为新的瓶颈。
4.1 把校验失败转化为一次“带反馈的重试机会”
我的默认做法是:校验失败不清空整个对话,而是把错误信息注入模型上下文,让它重新生成一次参数。在 OpenAI 的函数调用流程里,这通常体现为:把上一次的tool_call标记为失败,返回一条包含错误详情的tool消息,然后模型会重新发起一次调用。
关键点在于,反馈信息要包含三部分:
- 具体是哪个参数错了
- 期望的格式/范围/枚举是什么
- 明确的动作指令:“请重新生成参数”
不要给模型太多无关信息。我见过有人把整个 ZodError 的 JSON 原样塞回去,结果模型开始“道歉”而不是修正参数。保持反馈短而精确,模型修正的成功率明显更高。
4.2 重试上限与循环退出条件
重试不是无限的。每个工具调用最多重试两次,第二次仍失败就把控制权交还给用户层,让用户明确看到“模型无法生成合法参数”的状态。这个上限可以按工具场景微调,比如低风险工具试两次,高风险工具直接不开重试。
我设置的伪代码如下:
let attempts = 0; const MAX_ATTEMPTS = 2; while (attempts <= MAX_ATTEMPTS) { const result = await callModelWithTools(context); const toolCall = extractToolCall(result); if (!toolCall) break; const parsed = safeParseArguments(toolCall.arguments); if (parsed.ok) return runTool(toolCall.name, parsed.data); const feedback = formatValidationError(parsed.error); context.appendToolError(toolCall, feedback); attempts++; }这里还有一个微妙的地方:如果你是用类似gemini-2.5-pro这类支持多条并行函数调用的模型,可能出现多条调用里只有一条参数非法的情况。此时不应该整批重来,而是只把非法的那条带反馈回传,其余正常执行,避免拖慢整个链路。
4.3 落日志与样本收集,反哺函数描述
校验层还会带来一个意外收益:你会得到一批“模型填错参数”的样本。这些样本非常宝贵,因为它们是模型对函数定义理解偏差的最直接证据。
我之后做了一件很有用的事:把每条校验失败的原始参数、schema 定义和错误信息全部写入日志。每周看一次,总结出高频错误类型,针对性优化:
- 如果某个枚举值频繁出错,多半是描述不够清晰,在 description 里加一句“可选值只有这些”
- 如果某个数字频繁越界,检查是否在定义里写了
minimum/maximum - 如果模型经常漏填某个字段,考虑把它改成必填,或者在描述里强调“该字段在所有场景下必填”
这种“日志反哺定义”的闭环,其实比单纯加校验带来的长期价值更高。因为校验只能拦住错误,不能减少错误;而优化函数描述,能让模型一开始就少犯错。
5. 复盘后我留下的几条判断
最后分享几个这次改造之后沉淀下来的判断,不一定对,但都是在真实流量里验证过、踩过坑才得出的。
5.1 校验层要薄,但位置要准
校验层不是业务逻辑层,不要把所有业务规则都塞进去。它只负责回答一个问题:模型生成的参数,是否满足调用真实函数前的最低契约。至于订单号是不是真的存在、用户有没有权限,那是业务层要做的事,不该放到这里。
如果把业务校验也塞进 schema 层,会导致校验逻辑越来越复杂,模型重试时无从下手,而且会把真正的契约校验和业务校验混在一起,排查问题的时候特别头疼。
5.2 不要试图用“更严格的 schema”彻底消除错误
schema 太严,模型会开始“凑”合法值,也就是为了通过校验而编数据。比如你要求page_size必须是 1 到 100 的整数,模型可能老老实实传 50;但如果你要求description字段必须匹配一个很复杂的正则,模型可能生成一个表面满足正则但语义无关的字符串。
所以我的原则是:schema 只约束业务真正关心的硬边界,不给模型制造过多的表面束缚。能用描述引导解决的事情,不要用更苛刻的校验去逼。
5.3 最终兜底还是要回到“人能看到、模型能修正”
校验层做再完善,也不能让 agent 变成一个永远不犯错的黑盒。我的收尾动作是:
- 每条失败样本都保留原始输入与上下文快照,方便复现
- 当重试耗尽时,返回给用户的信息要清楚说明“模型当前无法生成合法参数”,不要把锅甩给下游服务
- 每隔一段时间用失败日志做一次函数定义的回归评审,而不是只加新工具
换句话说,schema 校验是“兜住底线”,真正让系统稳定下来的,是一套持续观察、反馈、修正的循环。我自己在实际运维中体会到,加了这套机制之后,我反而摆脱了“时刻盯着模型参数”的状态,因为我知道出错的地方会被拦住,而且还会有日志告诉我哪里该改。
如果你也在做 Function Calling 相关的 agent 服务,我建议你从今天起就给每个工具加上运行时校验。不要相信模型“大概率会遵守 schema”,要相信你能用一个safeParse真的拦住那些“小概率”的错误。这一层很薄,但它会在关键时候替你挡下很多不必要的线上事故。