
Function Calling 用起来很爽但你要是真把它当成“稳如狗”的接口来对接后面有一堆坑在等着你。最让我头疼的就是模型填参数明明定义好的参数名它给你传个近似字段明明要求 number它给你塞个字符串枚举值更是想一出是一出。后来我意识到光在函数定义里写好 JSON Schema 根本不够真正能兜住的是运行时的 schema 校验。这篇文章就把我这段时间踩出来的经验整理一下尤其是怎么用 schema 加校验层把模型那些“不靠谱”的参数行为扛下来。如果你正在做 LLM 应用开发比如智能助手、自动化 Agent、客服机器人凡是涉及 Function Calling / Tool Use 的场景这篇内容都适合你。不需要你有多深的前端基础但至少你应该已经在某个平台里定义过函数、跑通过一次模型调用。下面讲的都是我在实际项目里遇到的问题和最终落地的方案可以直接抄作业。1. 先把“参数填错”这件事拆清楚1.1 模型填错参数的具体表现我一开始以为模型填参错误是少数情况结果一上日志统计发现概率比想象中高得多。不是说每次都错而是当用户输入稍微偏一点、描述稍微模糊一点错误率就直线上升。常见的错误形态我整理成了一张表错误类型具体表现示例必填缺失该传的参数没传定义必须要user_id模型只给了message类型错乱把数字当字符串、字符串当数字max_price要求 number模型传了high枚举越界传了不在允许范围内的值状态只允许pending/done模型传了in_progress命名漂移参数名和定义相似但不一样定义userId模型传了user_id多余参数传了一堆 schema 里没有的字段定义只需要两个参数模型擅自加了debug: true结构损坏嵌套对象缺字段数组越界address里没有cityitems直接传了个空字符串这些错误在传统 API 调用里是不可想象的但在 LLM 场景里就是“日常”。模型本质上是在做概率生成它并不是一个严格的数据序列化器它的输出只是在模仿你给它的函数定义和用户请求之间的关系。所以别指望它能像编译器检查一样遵守类型系统。1.2 为什么 Function Calling 会填错参数要兜底得先明白根因。我对着一堆日志分析下来发现至少有这几个原因第一模型对参数名的语义理解不够。你定义了measurement_value模型可能觉得value就够了。它跟你不是“代码智能体”它是“语言智能体”它更擅长从语言上猜意思而不是从类型上严格推导。第二上下文不足。函数描述写得再清楚如果用户指令本身不够明确模型就只能猜。比如用户说“帮我订个靠窗的座位”你函数里有seat_preference字段模型确实会填但如果你的枚举是window/aisle它可能会填window seat这种“半匹配半发散”的输出就是典型。第三JSON Schema 本身只是描述性约束它不是运行时校验器。你在 API 定义里把类型、枚举写得很完整平台也会做一层验证但这个验证通常只发生在模型输出解析阶段而且不同平台对“解析失败”的处理方式不一样。有的平台会直接报错让你重试有的平台会尝试修复但修复逻辑不可控。更关键的是你自己的业务逻辑里对参数还有额外要求这些是函数定义里的 schema 表达不出来的。第四指令跟随能力的限制。即使是目前很强的模型也会在复杂任务中“遗忘”某些参数要求。尤其是当函数很多、参数很复杂的时候模型为了服从主要意图会牺牲次要字段的准确性。1.3 为什么要用 schema 校验兜底搞清楚了原因你会发现一个很扎心的事实你不可能通过在模型侧做提示词就让参数 100% 正确。提示词工程只能降低错误率但永远有长尾问题。所以工程上必须做防护性编程而防护的核心就是 schema 校验。我理解的“schema”有两层第一层是函数声明里的 JSON Schema它的作用是告诉模型“这个世界有什么”。第二层是运行时的校验 Schema比如 TypeScript 里的 zod、Python 里的 Pydantic它的作用是在你自己的系统里“决定怎么处理”。第二层才是真正兜底的东西。有了它你可以把模型输出转换成干净、可靠、符合业务预期的数据结构再交给下游逻辑执行。没有它你的代码会遍布各种防御式 if else而且隐患无穷。2. schema 应该怎么设计才不容易被填错2.1 从命名到结构让模型“一看就懂”先说设计层面。一个好的函数定义能大幅减少参数错误甚至比后置校验更有效。我总结了几条设计原则参数名用语义清晰的 snake_case 或 camelCase不要用缩写。模型不是编译器它对usr_id这种缩写会非常困惑。宁可长一点也别让它猜。描述里给足上下文。参数描述里要说明这个参数的预期格式、范围、示例值。举例来说{ name: max_price, type: number, description: 用户能接受的最高价格单位是人民币元必须是数字。例如用户说‘别超过一百块’这里传 100。 }这段描述看起来啰嗦但对于后面的模型推理非常关键。它把用户口语和参数取值之间的映射关系直接写明白了。尽量扁平化不要嵌套太深。模型生成多层嵌套 JSON 时错误率会指数上升。如果一个对象有三级嵌套模型很容易漏掉中间层。能拆成多个函数就拆或者把结构体拍平。必填字段一定要放在required里。别依赖“模型默认会传”模型没有默认。你显式声明required至少平台解析层会多一次检查。用过additionalProperties: false。这可以向模型传递一个信号不允许自由添加字段。虽然它不是硬拦截但实测能让模型收敛一点点。不过要注意有些平台对additionalProperties的处理并不严格所以这只能是辅助。2.2 用 JSON Schema 的高级关键词约束JSON Schema 不是摆设它有非常丰富的关键词能直接减少错误。下面这几个我基本每个项目都会用到type写好参数类型。注意 number 和 integer 的差异模型有时候会传小数如果你不想要小数就得写成type: integer。enum能列出来的值就列出来别让模型自由发挥。比如城市列表、状态列表、支付方式都要是枚举。pattern字符串格式用正则约束比如电话号码、身份证、邮箱。但要注意模型对正则的跟随能力有限复杂的 pattern 偶尔会生成不合法的值所以它只能降低错误率不能替代运行时校验。minimum/maximum/minLength/maxLength给数值和字符串划边界。比如价格不能为负数、备注长度不能超过 200 字。required必填列表这个不多说。曾经我踩过一个坑用anyOf把字段定义成 “字符串或者数字”本意是兼容输入结果模型更迷惑一会给字符串一会给数字下游处理更麻烦。后面我统一成type: string然后在描述里明确写“统一用字符串”反而好很多。给模型的选择越少它犯错的空间就越小。3. 核心实操用 zod 做运行时校验和兜底3.1 为什么选 zod设计好函数定义后就要进入运行时校验环节。我在 TypeScript 项目里最先接触的校验库是 zod后面也用过 ajv 和 joi但对 LLM 场景来说zod 的体验最好。原因有几条它是 TypeScript 原生类型推断和定义天然融合写起来非常顺。错误信息清晰很容易把校验失败的原因转化为给模型的重试反馈。自带.coerce()、.default()、.catch()、.transform()这些能力做参数修正非常方便。如果你是 Python 项目等价的选择是 Pydantic。核心思路一样下面的代码示例我用 TypeScript 展示你换到 Python 也能看懂逻辑。3.2 基础用法定义与解析假设我有一个“创建工单”的函数模型需要传入title、priority、labels三个参数。第一步在模型平台里定义 JSON Schema。第二步在自己的代码里定义 zod 校验 Schema。import { z } from zod; const createTicketSchema z.object({ title: z.string().min(1).max(200), priority: z.enum([low, medium, high]).default(medium), labels: z.array(z.string().max(50)).max(10).default([]), });调用模型拿到 function call 参数后用一个统一函数解析function parseToolArgs(schema: z.ZodTypeAny, rawArgs: unknown) { const result schema.safeParse(rawArgs); if (result.success) { return { ok: true, data: result.data }; } return { ok: false, error: result.error }; }safeParse不会抛出异常它会把所有校验问题聚合到error对象里。你可以把这个错误信息格式化后重新发送给模型让它修正。3.3 加“兜底”的三种策略这里要聊重点了校验失败后怎么办我试过几种策略分别适用不同场景。策略一宽松清洗coerce default对于一些“非关键字段”用户可以接受默认值就没必要非得让模型重新生成。zod 的.coerce()可以把字符串转数字.default()可以给缺省字段填充默认值。这就是“能救就救”的思路。const searchSchema z.object({ keyword: z.string().min(1), page: z.coerce.number().int().positive().default(1), pageSize: z.coerce.number().int().min(1).max(50).default(10), });如果真的传了2进来zod 会自动转成数字2没传page就用默认值1。这样就不会出现下游因为NaN或undefined崩掉的情况。策略二严格校验 自动重试对于核心参数例如支付金额、身份证号如果模型传错了绝对不能蒙混过关。这种情况下应该让模型自己意识到错误并重新生成。怎么做呢捕获错误后把错误信息塞回对话上下文里再让模型调用一次函数。策略三校验失败 降级处理当重试次数已经上限可以走降级丢弃错误调用返回给用户一个提示“没有完全理解你的请求请重新描述”或者直接用默认参数执行一个安全动作。这个兜底逻辑可以让整个流程不死循环。我实际的标准做法是先用策略一清洗再按字段重要程度决定是策略二还是策略三。3.4 实战示例一个“天气查询”工具的完整流程用天气查询当例子演示一个完整闭环。这个工具需要两个参数城市和日期范围。先定义模型侧的函数 JSON Schema{ type: function, function: { name: get_weather, description: 查询指定城市在指定日期范围内的天气信息, parameters: { type: object, properties: { city: { type: string, description: 中文城市名例如北京、上海、广州 }, start_date: { type: string, description: 开始日期格式 YYYY-MM-DD }, end_date: { type: string, description: 结束日期格式 YYYY-MM-DD不早于开始日期 } }, required: [city] } } }注意我并没有把start_date和end_date设为必填因为用户可能只问“今天天气怎么样”我可以在代码里给默认值。再定义 zod schemaconst weatherArgsSchema z.object({ city: z.string().min(1, 城市不能为空).max(50), start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 日期格式必须是 YYYY-MM-DD) .default(() new Date().toISOString().slice(0, 10)), end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 日期格式必须是 YYYY-MM-DD) .default(() new Date().toISOString().slice(0, 10)), }).refine((data) data.end_date data.start_date, { message: 结束日期不能早于开始日期, path: [end_date], });这里用到了.refine()做跨字段校验这是superRefine的轻量替代用来校验结束日期和开始日期的关系。这个关系是 JSON Schema 里很难表达的但在 zod 里很简单。接下来在模型返回参数后统一走一个handleToolCall方法async function handleToolCall(name: string, rawArgs: unknown) { if (name get_weather) { const parsed weatherArgsSchema.safeParse(rawArgs); if (!parsed.success) { return { type: retry, message: formatZodError(parsed.error), }; } const { city, start_date, end_date } parsed.data; const weather await fetchWeather(city, start_date, end_date); return { type: success, result: weather }; } }formatZodError把 zod 的ZodError转换成自然语言方便模型理解。它的实现可以很简单function formatZodError(error: z.ZodError) { return error.issues.map((issue) { const path issue.path.join(.); return 参数 ${path}: ${issue.message}; }).join(); }模型接收到这个消息后会根据错误描述重新生成参数这时候你再调用一次函数。这个“失败—反馈—重试”的循环在工程上很管用。4. 除了 zod其他兜底手段和排查实录4.1 工具选型ajv、joi、pydantic并不是所有场景都适合 zod。我做了一个小的横向对比列在这里给你选型参考。校验库适用语言特点与本场景匹配度zodTypeScript类型推导强、API 友好、支持 transform/default/refine极高ajvJavaScriptJSON Schema 标准实现性能高但需要写较多胶水逻辑中joiJavaScript老牌校验库链式 API 易懂但类型体验不如 zod中高pydanticPython数据类 校验一体LLM 生态常用极高voluptuousPython轻量但功能相对少中低我的建议很直白你项目是 TypeScript 就选 zod是 Python 就选 pydantic。这两个库都有很活跃的社区而且对复杂的嵌套对象支持好。4.2 常见问题与排查技巧实录在真实项目里我遇到过的坑远不止“校验失败”那么简单。有些问题表面上是校验没过实际上隐藏着更难查的逻辑。分享几个典型案例。问题一模型传了undefined而不是null。有些平台序列化后会给对象里塞undefinedJSON.stringify 会直接忽略导致下游解构出错。这种情况 zod 的safeParse会认为字段缺失。如果字段有default就会用默认值这是好事如果没有默认值就会报错。我原来的处理方式是给所有可有可无的字段都加上default这样避免很多无谓重试。问题二数组长度过大。有一次用户输入特别长模型给labels传了一个 200 项的数组。虽然没触发类型错误但下游存数据库时直接把服务搞超时了。所以我在 zod 里给数组加.max(10)。凡是需要模型生成数组的地方都务必设置最大长度防止模型“上头”给你塞一堆。问题三日期格式混乱。模型最擅长的就是把日期格式搞乱。用户说“后天”如果你只给start_date的示例它可能输出后天也可能输出周五。我的经验是函数描述里直接写明“必须输出具体日期格式 YYYY-MM-DD不要输出相对描述”。同时在 zod 里用正则强制校验一旦不符合格式就直接重试。问题四枚举值“中英混合”。我有个函数允许priority传low/medium/high结果模型传了中。这种错误靠 JSON Schema 的enum很难完全拦住。可以使用 zod 的.transform()把中英文映射一次例如z.enum([low, medium, high]).transform((v) { const map { 低: low, 中: medium, 高: high }; return map[v] ?? v; });但这要求模型传的还得是三个值之一。如果模型传了完全没有匹配的值校验照样失败只能重试。4.3 在日志中追踪修正率兜底方案上线后我加了一套日志统计。每个handleToolCall里记录三个指标原始参数长度清洗后参数长度是否发生了默认值填充 / transform 修改 / 重试日志长这样[TOOL] get_weather | retry1 | statuscoerced | missingend_date统计一周之后我发现了有意思的规律start_date的缺失率高达 30%原因是用户经常问“北京今天天气”模型只填了城市。于是我把函数描述改成了“如果用户没有明确日期默认使用今天”并且给start_date设置了.default()结果重试率直接降到了 5% 以下。这就是数据驱动优化的价值。别猜模型怎么想让日志告诉你它实际怎么填的。5. 别忘了和模型沟通重试与提示词联动5.1 校验错误作为上下文反馈给模型当你走“严格校验 重试”策略时错误信息能不能被模型充分利用决定了重试成功率。我一开始只是把 error 信息字符串塞回 messages模型基本不知道怎么改。后来我改成一段固定的反馈模板效果明显变好工具调用参数校验失败请根据以下原因修正参数后重新调用 {errors} 注意不要改变原始用户意图只修正参数问题。模板里有明确指令“不要改变原始用户意图”可以防止模型为了迎合错误信息而把用户原意改没了。这个细节很重要。5.2 避免重试死循环模型可能连续三次都填错千万不能让它无限循环。我一般在调用层加一个计数器let retryCount 0; const maxRetry 2; while (retryCount maxRetry) { const result await runFunctionCall(conversation); const parsed parseToolArgs(schema, result.arguments); if (parsed.ok) break; conversation.push({ role: tool, tool_call_id: result.id, content: 参数校验失败: ${parsed.error}, }); retryCount; }达到上限后直接回复用户“我没能正确处理麻烦换一种描述”。这样做既保证体验又不会让账户烧掉太多 token。5.3 用户提示词工程层面的配合最后还要提一句函数定义和校验是一个层面系统提示词是另一个层面。我会在系统提示词里加一句“调用工具时必须确保所有参数值与用户请求一致如果用户没有提供某些可选参数请使用合理默认值。”这种通用规则可以覆盖很多没写在单个函数描述里的场景。但注意提示词只是软约束。真正的硬约束还是运行时校验。两条腿走路才能把参数错误率压到真正可以上线的水平。我在实际项目里跑了快两个月经历过被模型气到想笑的时候也经历过因为漏了一个校验导致线上数据全乱套的惨剧。现在回头看最值得庆幸的就是在早期就引入了 schema 校验兜底。它不是一个锦上添花的工具而是 Function Calling 应用能不能稳定运行的生命线。最后再分享一个小技巧你可以在开发环境里把“模型原始参数”和“清洗后参数”全部打出来对比着看很多让你一脸懵的输出其实都是规律性错误。把这些规律反馈回函数描述和 zod 默认值里几轮迭代之后你的工具调用成功率能高到让你怀疑之前是怎么活过来的。