Function Calling 参数校验实战:用 zod 在入口拦截模型编造的非法参数
发布时间:2026/9/30 5:02:03来源:尧图网络
做过 Function Calling 接入的朋友应该都有过这种体验明明工具定义里写清楚了参数格式模型却给你编一个根本不在枚举里的城市名或者把一个必填的date字段传成2024-13-08再或者干脆把整数传成字符串。我第一次遇到这种情况的时候函数已经发出去了。用户问“北京明天天气怎么样”工具调用的参数里city被模型写成了北京市海淀区而我定义的工具只接受标准城市名数据库查不到接口直接 500用户看到的是“服务开小差”。排查到最后发现问题不在业务代码不在数据库而在模型生成的参数本身。从那天起我养成了一个习惯Function Calling 的参数绝对不直接信任。所有经过大模型生成、即将进入业务函数的参数必须在入口处过一道 schema 校验。这篇文章就把我这套“入口校验”方案完整拆开来讲模型为什么会编参数、为什么校验要放在入口而不是函数内部、用 zod 做 schema 校验的完整落地代码以及我在生产环境里踩过的坑。如果你正在用 OpenAI 的 tools、Anthropic 的 tool use或者国产模型的 function calling 能力并且开始觉得“模型有时候传参不靠谱”那这篇文章应该能帮你省下不少调试时间。1. Function Calling 参数被编造一个典型事故现场1.1 模型为什么会一本正经地编参数先说一个很多人忽略的事实大模型不是程序它不会“读取”你的工具定义然后按规则执行它是在预测一段最像样的工具调用输出。你给的 tools JSON Schema 对它来说不是执行规范而是一段“参考文本”。它根据这段文本去猜一个合理的参数应该长什么样。这就带来两个后果。第一当你的工具描述写得不够精确或者参数名有歧义模型就会用自己的“常识”补全。比如你定义了一个get_stock_price(symbol: string)模型可能把“苹果公司”直接翻译成AAPL也可能真的填苹果。第二模型的概率采样机制在 temperature 偏高的时候会更“发散”它会在多个看似合理的候选中随机选一个。大多数情况下这个选择是对的但只要有一次错你的业务接口就遭殃。我见过最离谱的一次是模型在调用一个删除数据库记录的工具时把id参数编成了0而且confirm字段传了true。用户当时只是问“测试数据里那条订单是怎么回事”模型却准备帮用户把订单删了。还好入口校验发现id: 0在数据库里不存在直接拦下来了。所以你看参数编造不只是“体验问题”在删除、写入、扣费这类有副作用的工具面前它是个实打实的安全风险。1.2 入口校验挡的到底是什么“挡在入口”这四个字核心是把校验点放在模型输出和业务函数之间。你拿到模型返回的tool_calls之后不要急着分发执行先做三件事检查模型输出能不能被正确解析成 JSON 对象检查解析后的字段类型、必填项、枚举值、嵌套结构是否符合工具定义检查有没有多余字段也就是模型擅自添加的不在白名单里的参数只有全部通过了才把参数交给真正的业务函数。任何一步失败都走“错误反馈”分支把问题描述回传给模型让它重新生成。有人会问OpenAI 这些平台不是已经要求你传 JSON Schema 了吗模型生成的时候不是应该遵循这个 schema 吗这就是最大的认知误区平台传的 JSON Schema 只是“提示”不是“约束”。模型该编照编。真正能保证“入参合法”的只有你自己在运行时加一道强校验。你可以把这理解成前端表单校验和后端校验的关系——前端的校验是给用户看的后端校验才是兜底的。2. 为什么要把校验挡在入口而不是丢给函数内部2.1 函数内部校验的三个致命问题最开始我图省事把校验写在每个函数内部想着“反正业务函数反正也要用参数顺手查一下就行”。实践证明这是非常错误的设计这里说三个最痛的点。第一个是副作用没法回滚。很多工具不是纯查询比如“发送告警短信”“关闭工单”“修改数据库状态”。这些操作一旦执行到一半才发现参数不对该发的消息发出去了、该改的状态也改了这时候你再报错也没用只能靠人工补救。而入口校验在触发任何副作用之前就能拦截这才叫“挡在门外”。第二个是错误处理碎片化。如果你有一百个工具函数每个函数内部写一套if (!params.id) throw ...那么错误格式、错误级别、错误信息风格完全无法统一。当你要把错误信息回传给模型让它“重新做人”时你会发现有的错误返回的是中文有的是英文还有的直接把堆栈抛出来。模型看了这种乱七八糟的错误提示根本不知道该怎么修正。第三个是重复代码爆炸。每个函数都写一次“参数检查”逻辑重复不说还容易漏。你很难审计“到底哪个函数没有做参数校验”而漏掉的那个恰恰就是事故发生的那个。2.2 schema 校验 vs 满屏 if-else有人会说我也可以写一个公共的validateParams(params, rules)函数用 if-else 一个个判断类型、枚举、长度这样不也统一了吗可以但没必要。手写校验有两个明显问题一是容易漏边界情况比如你检查了 string 类型但忘了处理超长字符串二是代码里长满了各种“魔法逻辑”比如Object.prototype.hasOwnProperty.call(params, id)这种可读性很差。schema 校验的本质是用声明式规则替代命令式逻辑你只需要定义“数据应该长什么样”而不需要写“如何判断数据长什么样”。我用的是 zod它能在定义 schema 的同时推导出 TypeScript 类型这样模型输出的参数会被校验成一个类型安全的对象业务函数里拿到手就是干净的、已经验证过的数据不需要再做任何类型断言或者防御性判断。你可以把它类比成机场安检每个乘客参数都要过一道安检门schema只有证件、行李、身份都符合要求才能登机进入业务函数而不是等乘客上了飞机再一个个查证件。3. 技术选型zod 够用但要注意和 JSON Schema 的关系3.1 为什么选 zod 而不是 JSON Schema先说结论如果你在 Node.js 或者 TypeScript 生态里做 Function Calling 的运行时校验zod 是综合体验最好的选择。如果你在多语言团队、需要跨语言共享校验规则那可以考虑 JSON Schema ajv这类库。zod 的优点有三个。第一它的 API 非常直观z.object({...})、z.string()、z.enum([a,b])、z.array(z.string())几乎不需要学就能上手。第二它自带类型推导z.infertypeof schema能直接拿到对应的 TS 类型省去维护两套类型的麻烦。第三safeParse方法不会抛异常而是返回一个带有成功/失败标志的结果对象这在给模型做错误反馈时极其顺手。这里要提醒一个新手特别容易踩的坑传给模型 API 的 tools 参数本身是 JSON Schema 格式而你运行时校验用的 schema 如果是手写的 zod两套规则很容易不一致。比如你给 API 传的 JSON Schema 里写的枚举是[celsius, fahrenheit]但 zod 里你手滑写成了[Celsius, Fahrenheit]结果模型按照 API 的 schema 生成了正确的小写参数到你自己的校验这里反而被误杀。解决方法是尽量从同一份定义生成两份格式。zod 生态里有zod-to-json-schema这个库可以直接把 zod schema 转成 JSON Schema 传给模型 API。这样你就只有一个事实来源不会再出现两边对不上的问题。3.2 一份可运行的 tool schema 定义我用一个天气查询工具来做例子。首先定义业务规则这个工具接受城市名、日期、温度单位三个参数城市名必须是预置列表里的日期必须是YYYY-MM-DD格式而且不能是过去的日期单位要么是celsius要么是fahrenheit缺省时默认celsius。用 zod 定义如下import { z } from zod; import { zodToJsonSchema } from zod-to-json-schema; const weatherToolSchema z .object({ city: z.enum([beijing, shanghai, guangzhou, shenzhen]), date: z .string() .regex(/^\d{4}-\d{2}-\d{2}$/, date must be YYYY-MM-DD) .refine((val) new Date(val) new Date(new Date().toDateString()), { message: date cannot be in the past, }), unit: z.enum([celsius, fahrenheit]).default(celsius), }) .strict();.strict()方法很关键它的意思是 schema 里没定义的字段一律视为非法。默认情况下 zod 会丢弃多余字段但丢弃不等于拦截某些场景下这会掩盖模型胡乱加参的行为。我用.strict()就是要让模型知道你多传一个字段我就让你重来。注意date这个字段的校验分成两层。第一层是格式正则先把“2024-13-08”这种非法日期拦下来第二层是业务规则用refine检查日期是否在过去。这两种错误类型不同回传给模型时要区分清楚格式错误说明模型连基本规范都没遵守业务规则错误说明模型理解了格式但不了解业务约束。模型 API 传的 JSON Schema 可以直接生成const toolJsonSchema zodToJsonSchema( weatherToolSchema, weather_query ); const tools [ { type: function as const, function: { name: weather_query, description: 查询指定城市在指定日期的天气情况, parameters: toolJsonSchema.definitions?.weather_query ?? { type: object, properties: toolJsonSchema.properties ?? {}, }, }, }, ];这样不管你怎么调整 zod schema模型看到的多余字段、枚举值、格式规则都和你运行时校验用的规则保持完全一致杜绝双份规则漂移。4. 完整实操从模型输出到安全执行的五步链路4.1 第一步先处理非法 JSON拿到模型的响应后第一关其实是“能不能解析”。别小看这一步大模型输出的arguments字段在大多数 API 里是一个 JSON 字符串但它完全有可能在两边加上多余的空格、换行甚至像某些本地模型那样直接输出 Markdown 代码块包裹的 JSON。我见过模型输出过这样的{city: beijing, date: 2024-06-01, unit: celsius}听起来很正常对吧但如果你用JSON.parse直接解析前面带了一行json的字符串会直接抛异常。所以处理解析时我会先做一层“清洗”把常见的代码块围栏去掉然后提取第一对花括号之间的内容再尝试解析。function safeParseJson(raw: string): Recordstring, unknown | null { // 去掉可能的 markdown 代码块围栏如 json 和 const withoutFence raw.replace(/(?:json)?/g, ).trim(); // 提取第一个 { 到最后一个 } 之间的内容 const start withoutFence.indexOf({); const end withoutFence.lastIndexOf(}); if (start -1 || end -1 || end start) { return null; } try { return JSON.parse(withoutFence.slice(start, end 1)) as Recordstring, unknown; } catch { return null; } }这段代码的思路是先用正则把所有形如json或的围栏剥掉再暴力取第一个左花括号到最后一个右花括号之间的子串最后再JSON.parse。这种方式对绝大多数模型输出都够用。如果清洗后仍然解析失败直接进入错误反馈流程让模型重新生成不要尝试自己修复 JSON——你永远不知道模型在别处还埋了什么雷。4.2 第二步用 safeParse 做白名单校验解析成功后就轮到 schema 出场了。我统一用safeParse它返回的结果对象里success字段告诉你校验结果。如果失败error字段里带了一份结构化的错误详情你可以把每个字段名、期望类型、实际值都提取出来拼成人能读懂、模型也能看懂的提示。import type { ToolCall } from some-ai-sdk; function validateToolCall(toolCall: ToolCall) { const rawArgs safeParseJson(toolCall.arguments ?? {}); // JSON 都解析不出来说明模型输出质量太差必须重来 if (rawArgs null) { return { ok: false as const, message: 模型输出的 arguments 不是合法 JSON请重新生成完整的 JSON 参数, }; } const result weatherToolSchema.safeParse(rawArgs); if (!result.success) { const issues result.error.issues .map((issue) { const path issue.path.join(.) || (root); return - ${path}: ${issue.message}实际收到 ${JSON.stringify(rawArgs)}; }) .join(\n); return { ok: false as const, message: 参数校验未通过请根据以下问题修正后重新生成\n${issues}, }; } return { ok: true as const, data: result.data }; }注意这里我把实际收到的原始值也放进了错误信息里。模型和人都需要一个“对照参考”才知道自己哪里写错了。只报“字段 path 不合法”而不报当前值模型往往会按照同样的错误逻辑再生成一遍。这个result.data就是已经通过校验、类型安全的参数对象可以直接传给业务函数。我在实际项目中业务函数接收的参数类型就是z.infertypeof weatherToolSchema从源头杜绝了参数类型不匹配的问题。4.3 第三步把校验错误喂回给模型修正入口校验的意义不只是“拦”更在于“引导修正”。当校验失败后正确的姿势是把错误信息作为一条工具执行结果返回给模型让模型根据错误描述重新生成参数。大部分模型在收到明确、结构化的错误反馈后都能在下一轮生成中自我修正。这里有个很典型的消息结构。假设你的工具执行框架是模型返回 tool_calls - 你执行工具 - 把结果以role: tool的消息回传。当校验失败时你也按照这个格式回传只是content是错误信息const messages [ // ...之前的历史消息 { role: assistant, content: null, tool_calls: [toolCall], // 模型上一轮发起的工具调用 }, { role: tool, tool_call_id: toolCall.id, content: JSON.stringify({ status: error, message: validateResult.message, }), }, // 再追加一条系统/用户提示要求它基于错误信息重新生成 { role: user, content: 根据上面的错误说明重新生成一个正确的工具调用参数。, }, ];关键点在于错误信息必须可操作不能是“参数不合法”这种句废话。模型需要知道三个信息哪个字段错了、期望是什么、你实际给了我什么。比如下面的信息就非常有效date: date must be YYYY-MM-DD实际收到 2024-13-08模型看到“2024-13-08”和“YYYY-MM-DD”放在一起就很容易意识到自己把月份写成了 13下一轮修正成合法日期。但如果你只告诉它“date 字段不合法”它大概率会把整条参数全部推翻重来浪费 tokens还可能把原本正确的 city 也带偏。有一点必须注意重试不是无限的。我设置最多让模型修正两次两次之后再失败就直接向用户返回“暂时无法处理请换个说法再问”而不是继续无限循环。因为每次修正都在消耗 token如果模型连续三次都生成同样的非法参数说明工具定义本身可能有问题循环下去只会持续烧钱。5. 常见问题与排查技巧实录5.1 高频校验失败类型速查表我统计了一个月内入口校验拦截下来的失败类型帮你心里先有个数遇到同类问题不用惊慌。以下表格基本覆盖了 Function Calling 参数校验的绝大多数情况失败类型典型例子处理思路类型错误整数 id 传成了字符串123回传时明确指出“期望 number实际收到 string”模型基本都能改对必填项缺失需要date模型只给了city除了提示缺失还要告诉模型“date 是必填字段格式为 YYYY-MM-DD”枚举越界unit传了Centigrade在报错里列出所有合法值如“请从 celsius、fahrenheit 中选择一个”多余字段模型擅自添加了{ time: 09:00 }严格模式直接拦截。报错说明只能传 schema 定义中的字段日期/时间格式错误2024-6-1而不是2024-06-01用正则严格匹配格式并在错误信息里给出合规示例嵌套结构错误location应该是对象模型传成了字符串对复杂嵌套结构建议单独写子 schema 并给出结构示例数组长度超限一次给批量工具传了 10000 个 ID用.max(100)限制长度防止模型“图省事”一把梭空值问题模型传null给非空字段用.nullable()或.optional()显式声明允许空值否则就报错5.2 嵌套、枚举、数组这些特殊结构怎么处理前面讲的方法是针对平铺结构但真实场景里工具参数往往有嵌套。比如一个“创建订单”的工具参数里包含用户地址对象{ city, street, zip }。这时候 zod 的嵌套能力就体现出来了你可以定义子 schema 然后复用const addressSchema z.object({ city: z.string().min(2), street: z.string().min(1), zip: z.string().regex(/^\d{6}$/, zip must be 6-digit), }); const createOrderToolSchema z .object({ user_id: z.number().int().positive(), items: z .array( z.object({ sku: z.string(), quantity: z.number().int().min(1).max(99), }) ) .min(1) .max(50), address: addressSchema, remark: z.string().max(200).optional(), }) .strict();items数组和address嵌套对象的校验对象都在这里呈现。每次校验失败zod 的issue.path会变成类似items.0.quantity这种完整路径把错误定位到“第几个商品的哪个字段”模型修正起来非常精准。另外我建议给所有枚举字段都通过.describe()加一段说明文字比如z.enum([celsius,fahrenheit]).describe(温度单位默认 celsius)。这段描述不只是给人看的传给模型 API 时也会成为它理解参数的参考能显著降低枚举值编错的概率。描述写得越具体模型猜错的概率越小。5.3 我踩过的三个坑希望你避开第一个坑前面提过就是两套 schema 不一致。早期我图省事给模型 API 传 JSON Schema 手写一份运行时校验又用 zod 另写一份。有一周里我一直在排查用户反馈的“某一天突然工具不可用”最后发现是我在 zod 里把枚举值从celsius改成了Celsius而模型 API 的 JSON Schema 没同步改。模型按旧的生成按新的校验每条都被拦。后来我把 API 的 JSON Schema 改成由 zod 直接生成这个问题彻底消失。第二个坑是错误信息里忘了附上实际值。最开始我回传给模型的错误信息只有“字段类型不正确请修改”这种干巴巴的文字结果模型连续四次生成一模一样的错误参数。后来我把“期望 实际”都写进每条错误里情况马上好转。模型是很吃“示例”的它需要具体的参照物才知道怎么改而不是一堆抽象规则。第三个坑是重试次数没有上限。我有一个内部工具链在一次异常流量中连续触发了十几轮“生成-失败-再生成”循环账单出来的时候人都是傻的。从那以后我强制加了两条规则单次调用最多自动修正两次如果两次失败直接给用户返回提示。这个限制放在框架层所有工具统一生效再也没出过类似的烧钱事故。最后我再分享一个特别实用的操盘习惯在入口校验通过之后我会把这个工具调用和校验结果打印成一条结构化日志。日志里只记工具名、参数摘要、校验耗时不记完整参数防止敏感信息泄露。这轮日志配合后续的业务执行日志能帮你非常快地复盘某一天的异常到底是模型编参数、校验规则设置还是业务接口本身的问题。从入口打点到出口整条链路清清楚楚排查效率会比盲猜高好几个数量级。
网站建设高端定制企业官网