
年初我维护的一个 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真的拦住那些“小概率”的错误。这一层很薄但它会在关键时候替你挡下很多不必要的线上事故。