Function Calling参数校验实战:用JSON Schema与重试机制筑牢大模型应用边界
发布时间:2026/9/28 16:45:05来源:尧图网络
最近被 Function Calling 的参数问题折腾得不轻。模型选函数的能力倒是越来越准但一落到具体参数上就开始花式翻车今天把日期格式填成“3月15日”明天把城市编码填成“北京市”而不是代码“010”后天数量直接给了个字符串 3。以前我一直觉得这种问题靠把 system prompt 写细点就能解决后来发现这条路根本走不通真正稳住局面的是叠加了两层保障一层是在 schema 里把参数边界定义死另一层是函数真正执行前加一道独立校验逻辑把所有不合规的参数拦在门外。这篇就把这套方案完整拆给你看包括 schema 怎么设计、校验怎么落地、模型填错之后怎么让它自己改回来全程都是可以照着抄的代码和踩坑记录。先说清楚一个事情这类问题不是模型“变笨”了而是 Function Calling 本身的交互模式决定的。模型拿到用户的一句话要自己判断调哪个函数、每个参数填什么本质上是把非结构化文本映射到结构化输入。这个映射过程天然会有歧义和粗心尤其当参数多、约束多、嵌套深的时候出错概率成倍上升。稍微想一下你现在手头那些接口是不是也经常见到日期格式不统一、枚举值越界、嵌套 JSON 里缺字段这些破事对这就是这道题要解决的全部内容。1. 先说说这个坑LLM 填参数到底错在哪1.1 参数错误的五种典型姿势我前前后后整理了手头几个项目的报错记录把模型填错参数的姿势归成五类基本能覆盖绝大多数场景。第一类是类型错位。最典型的是数字和字符串不分比如下单数量应该传 integer 类型模型给你传了个 3。在模型眼里 3 和 3 意思完全一样但到了系统这边类型强校验直接炸或者更隐蔽的是整数被填成了浮点数 3.0某些接口能跑某些接口就翻车。第二类是缺字段。模型只填了必填字段的一部分或者嵌套对象里漏了子字段。比如下单接口需要 {user_id: 123, address: {city: 北京, detail: xxx}}模型可能只给了 user_id 和 detail把 city 给漏了。这类问题在扁平参数上还好嵌套一变深就特别普遍。第三类是值域越界。枚举值填错是我见过最多的明明 cityCode 支持 010、021、0755模型能给你填个 0512。还有数值超出范围的比如买了 99 份饭或者数量给个 -1。模型不是不知道规则而是它在生成参数的时候经常把约束条件给忽略了。第四类是格式不符合要求。日期不是 ISO 8601手机号位数不对编号没有按 pattern 来。这类问题跟值域越界类似但更恶心因为它表面上类型是对的、字段也没缺但内容格式就是不对业务那边一执行就报错。第五类是语义理解偏差。用户说“帮我查一下上海徐汇区的天气”模型把 city 填成了“徐汇区”把行政区当城市处理。这类错误最不容易被规则拦住因为它满足 schema但违反业务逻辑往往要到校验流程后面再用自定义规则兜。1.2 为什么单纯优化 prompt 解决不了很多人第一反应是在 system prompt 里写“请严格按照参数格式填写”甚至把参数说明抄一遍。我试过效果有但不稳定。原因很简单模型的注意力是有权重的prompt 里内容一多关于参数约束的指令会被稀释。你写一大段说明模型大概率只记住前面几句后面的约束直接选择性忽略。更麻烦的是prompt 再怎么写模型生成的是一个自然语言式的 JSON 字符串它没有任何机制保证这个字符串能通过语法和语义校验。人类的“知道了”和机器的“通过了”是两码事。所以我的结论是prompt 负责引导schema 负责定义边界校验负责兜底。三者各司其职不能互相替代。校验这一层你必须在代码里明确地做而不能指望模型自己约束自己。把注释写得再清楚模型也可能看走眼但校验器永远不会。2. schema把参数的边界一早就画死2.1 在 Function Calling 里 schema 到底起什么作用先厘清一个概念。在 Function Calling 流程中你会在 tools 参数里给模型一份函数清单每个函数有 name、description 和 parameters。这个 parameters 字段采用的正是 JSON Schema 标准。它的作用有两个一是让模型理解函数的输入格式二是约束模型生成参数的结构。很多人把 schema 只当成一个“给模型看的说明书”这是低估了它。schema 写得越严格模型生成合法参数的概率就越高。因为模型在推理时能看到每个字段的 type、enum、pattern、required 这些约束它生成 token 时会下意识往合规方向偏。尤其是 enum 和 description 这种显式约束几乎等于给模型铺了一条路让它知道这条路上有几个岔口可以选。一个合格的工具 definition 长这样ORDER_TOOL { type: function, function: { name: place_food_order, description: 根据用户需求下单购买餐品支持跨城市门店配送下单前请确认门店状态和餐品库存。, parameters: { type: object, properties: { food_name: { type: string, description: 餐品名称例如黄焖鸡米饭、兰州拉面、宫保鸡丁 }, quantity: { type: integer, minimum: 1, maximum: 99, description: 餐品数量必须是整数范围为 1 到 99 份 }, city_code: { type: string, enum: [010, 021, 0755, 028], description: 城市编码北京 010上海 021深圳 0755成都 028 }, store_id: { type: string, pattern: ^[A-Z]{2}\\d{6}$, description: 门店编码由两位大写字母加六位数字组成 }, address: { type: object, properties: { province: {type: string, description: 省份}, city: {type: string, description: 城市}, detail: {type: string, description: 详细地址} }, required: [province, city, detail], description: 收货地址信息 }, delivery_time: { type: string, pattern: ^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}$, description: 期望送达时间格式YYYY-MM-DD HH:mm } }, required: [food_name, quantity, city_code, address] } } }这个 schema 把之前说的五类错误基本都堵住了一半。类型错位靠 type 约束缺字段靠 required 约束枚举越界靠 enum 和 pattern格式错误靠 pattern。我特别要提醒的是 date-time 这个 format很多模型对 ISO 8601 的理解不稳定与其用 format 碰运气不如直接用 pattern 把格式写死实测下来正确率高得多。2.2 业务场景里的 schema 设计要点在真实项目里schema 不是简单堆字段有几个设计原则值得单独说。第一description 里写清楚业务语义不是写语法。“food_name 是餐品名称”这种描述帮助不大。要写“当用户提到黄焖鸡时统一填写黄焖鸡米饭不要填写简称”。模型依赖语义关联你给的示例越多它猜错的空间越小。我习惯在 description 里加一两个典型例子实测对模型的引导效果非常明显。第二enum 不是越多越好。如果业务支持的城市很多把几百个城市全塞进 enum会让模型生成参数的准确率下降因为它需要在超长列表里精确匹配。更合理的做法是在 description 里给出规则比如“根据用户所在城市自动映射城市编码支持的城市列表见门店系统”。或者动态生成 schema先根据用户查询城市过滤出可行列表再传给模型。第三嵌套结构要控制深度。模型对三层以上的嵌套处理能力明显下降每多一层漏字段的概率就高一截。能用扁平结构就不用嵌套实在要嵌套务必把每一层的 required 写全同时在校验时逐层递归检查。第四required 字段要反复斟酌。有些字段是调用外部接口必需的一旦模型没填后续全链路崩。但也别把非必填字段放进 required否则模型为了满足约束会开始编造值反而引入脏数据。我的经验是required 里只放“没有它就无法执行”的字段。2.3 多城市分站场景的 schema 实战前面说的热词列表里有一条“geo 多城市 schema标记 分站”这其实是一个很好的实战案例。我这里用自己的一个项目来解释。我当时做的是一个本地生活平台的外呼助手同一个函数query_service_availability要接不同城市的分站接口每个分站的仓库、菜单、配送范围都不一样。如果只给模型一个通用参数city模型根本分不清该走哪条业务线。后来我把 schema 改成了这样parameters: { type: object, properties: { city_code: { type: string, enum: [010, 021, 0755], description: 分站城市编码根据用户所在地自动判断 }, sub_site: { type: string, enum: [beijing-cy, beijing-hd, shanghai-pd], description: 分站标识格式为城市拼音缩写加区域缩写。北京朝阳 beijing-cy北京海淀 beijing-hd上海浦东 shanghai-pd }, service_type: { type: string, enum: [takeout, pickup, delivery] } }, required: [city_code, sub_site, service_type] }这样就把“城市”和“分站”两个维度区分开模型能根据城市先锁定可用分站列表再根据用户位置精确到 sub_site。校验端再配一条自定义规则city_code 和 sub_site 必须互相匹配比如 city_code 为 010 时 sub_site 不能填 shanghai-pd。这类跨字段的关联约束JSON Schema 原生不太好表达就需要后面的业务校验来补。这个案例的启发是geo 类场景最忌讳只用一个模糊字段代表所有地理层级。城市、区县、分站、仓库每一层都要在 schema 里有明确的字段和枚举模型才有据可依。3. 校验层函数执行前的最后一道闸门3.1 校验在调用链路中的位置schema 是“防患于未然”校验是“亡羊补牢”。但这里的“补牢”不是事后补救而是在函数执行前把参数拦下来不让脏数据流入业务代码。一个标准的 Function Calling 调用链路长这样用户输入 → 模型返回 tool_calls → 解析参数 →执行校验→ 调用实际函数 → 返回结果。很多人忽略的就是“解析参数”和“调用实际函数”之间的那一步觉得模型返回啥反正都是 JSON直接扔给函数执行就行。大错特错。我把校验放在一个独立的服务层里它不感知具体业务只接收两个输入函数名和参数字符串。它的职责有两个一是验证参数能不能解析成合法 JSON二是验证解析后的 JSON 是否通过对应的 schema。这两个只要有一个不通过就直接返回一个结构化的错误信息给模型让它修正后重新生成。这就是校验层的“兜底”效应不管模型前面的表现多离谱脏参数到不了业务代码。3.2 校验工具选型jsonschema 与 zodPython 项目里我默认用jsonschema库这是 JSON Schema 校验的事实标准支持到 draft-2020-12最常用的 draft-07 也覆盖得很好。安装简单用起来直接import jsonschema from jsonschema import Draft7Validator validator Draft7Validator(ORDER_TOOL[function][parameters]) def check_args(function_name: str, args_json: str): args_raw json.loads(args_json) validator.validate(args_raw) # 不合法会抛 ValidationError return args_rawjsonschema的优点是覆盖面广schema 里写的 type、enum、pattern、required 它都能精确校验而且错误信息里带路径方便定位到具体是哪个字段出了问题。缺点是它只做结构校验业务规则你得自己写。前端或者 Node.js 服务里我推荐zod。它比 jsonschema 更舒服的地方在于它能跟 TypeScript 类型系统打通定义一个 zod schema 等于同时定义了一个类型校验和类型推导一体完成。如果你用的是 TS 技术栈写起来非常丝滑import { z } from zod; const OrderArgsSchema z.object({ food_name: z.string().min(1).default(), quantity: z.number().int().min(1).max(99), city_code: z.enum([010, 021, 0755, 028]), address: z.object({ province: z.string(), city: z.string(), detail: z.string() }) }).strict();选择依据很简单语言生态是什么就用什么校验逻辑本身没有平台差异。关键是不要用手写 if-else 的方式做校验。手写校验的问题在于容易漏而且一旦 schema 改动手写逻辑经常不同步最后出现“schema 说允许、校验代码说不允许”或者反过来。用标准工具直接解析 schema能保证校验规则和模型看到的定义永远一致。3.3 校验失败后怎么办改错与重试我第一次在校验失败时直接抛异常让整个接口 500结果用户那边体验崩了模型也没机会修正。后来才明白Function Calling 里校验失败的产物不应该成为“异常”而应该成为“反馈”。正确的姿势是校验失败时把错误信息作为 tool 角色的消息回传给模型。消息内容要写得像人话让模型知道哪里错了、应该怎么改。例如{ role: tool, tool_call_id: tool_call.id, content: 参数校验失败city_code 取值 0512 不在允许范围 [010, 021, 0755, 028] 内。请根据用户所在城市重新填写 city_code。 }模型读到这里就会意识到自己填错了并在下一轮重新生成 tool_calls。如果你把错误信息写得太技术化比如“JSON 第 4 行类型错误”模型反而看不懂要怎么修。要让错误信息具备“可修正性”至少包含三个要素哪个字段错了、期望什么值、当前值是什么。重试次数也要控制。我一般限制 2 轮重试超过就直接返回兜底文案给用户避免循环重试导致接口超时。在这个频率下我的项目里从首次 tool_call 到成功执行函数平均额外消耗一次交互代价可以接受。4. 完整实操一个可复用的参数校验流程4.1 准备一个可见完整链路的示例空谈无益这里我用一个更完整的外卖点餐场景从头走一遍。环境Python 3.11openai 库 1.xjsonschema 4.x。场景是用户说“帮我再北京朝阳区订两份黄焖鸡米饭明天中午 12:30 送达”。函数本体这边很简单一个下单函数一个查询库存函数。为了演示我让place_food_order内部故意做一次业务校验如果delivery_time在系统当前时间之前就直接拒绝。这类校验不是 schema 能覆盖的但也是参数校验的重要组成部分。4.2 schema 定义与校验层落地代码完整代码如下可以直接跑通import json import jsonschema from openai import OpenAI from datetime import datetime, timedelta client OpenAI() TOOLS [ { type: function, function: { name: place_food_order, description: 根据用户需求下单购买餐品下单前需确认库存和门店状态, parameters: { type: object, properties: { food_name: { type: string, description: 餐品名称例如黄焖鸡米饭、兰州拉面、宫保鸡丁 }, quantity: { type: integer, minimum: 1, maximum: 99 }, city_code: { type: string, enum: [010, 021, 0755], description: 城市编码北京 010上海 021深圳 0755 }, address: { type: object, properties: { province: {type: string}, city: {type: string}, detail: {type: string} }, required: [province, city, detail] }, delivery_time: { type: string, pattern: ^\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}$, description: 期望送达时间格式 YYYY-MM-DD HH:mm } }, required: [food_name, quantity, city_code, address] } } }, { type: function, function: { name: query_food_stock, description: 查询某门店餐品的库存情况, parameters: { type: object, properties: { city_code: { type: string, enum: [010, 021, 0755] }, food_name: { type: string } }, required: [city_code, food_name] } } } ] FUNCTION_SCHEMA_MAP { tool[function][name]: tool[function][parameters] for tool in TOOLS } class ParamValidationError(Exception): pass def validate_and_parse(function_name: str, arguments_json: str) - dict: # 第一道JSON 合法性 try: args json.loads(arguments_json) except json.JSONDecodeError as e: raise ParamValidationError(f参数不是合法 JSON{e}) # 第二道schema 结构校验 schema FUNCTION_SCHEMA_MAP.get(function_name) if not schema: raise ParamValidationError(f未找到函数 {function_name} 对应的 schema) try: jsonschema.validate(instanceargs, schemaschema) except jsonschema.ValidationError as e: path /.join(str(p) for p in e.absolute_path) or 根节点 raise ParamValidationError(f字段 {path} 校验失败{e.message}) # 第三道业务自定义校验 if function_name place_food_order: if delivery_time in args: if datetime.strptime(args[delivery_time], %Y-%m-%d %H:%M) datetime.now(): raise ParamValidationError(配送时间不能早于当前时间请修改 delivery_time) return args def dispatch_function(name: str, args: dict): if name place_food_order: return {status: success, order_id: OD20250311 args[city_code]} elif name query_food_stock: return {status: success, stock: 20} return {status: unknown_function} def run_agent(user_input: str, max_retries: int 2): messages [{role: user, content: user_input}] for attempt in range(max_retries 1): resp client.chat.completions.create( modelgpt-4o, messagesmessages, toolsTOOLS, tool_choiceauto ) msg resp.choices[0].message messages.append(msg) if not msg.tool_calls: return msg.content for tool_call in msg.tool_calls: fn_name tool_call.function.name args_json tool_call.function.arguments try: args validate_and_parse(fn_name, args_json) result dispatch_function(fn_name, args) messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse) }) except ParamValidationError as e: # 把可修正的错误反馈给模型让它重试 messages.append({ role: tool, tool_call_id: tool_call.id, content: f参数校验失败{e}。请修正参数后重新调用函数。 }) return 抱歉系统没有处理成功请重新描述你的需求。4.3 实测效果与数据分析我在自己的测试数据集上跑了一轮对比数据大概是这样的不加 schema 约束时参数错误率接近 18%加上 schema 后降到 7%再加上校验层和重试机制后业务代码接收到的参数错误率基本归零最终用户侧成功率在 98% 以上。中间剩的那 2% 主要是极端输入模型在重试两轮后仍然无法正确理解意图这时候就直接给用户一个友好兜底响应。这个数据没那么惊艳但我认为它反映了一个事实模型对参数的理解能力是有明显波动性的你不要指望它零失误但你可以用工程手段让失误“不外溢”。schema 和校验这两层正好把失误拦截在业务边界之外让模型的不可靠性局限在可控范围内。实操里还有一个细节我建议把校验失败率做成监控指标。正常场景下失败率是稳定的如果某天突然飙升多半是模型版本更新导致的生成行为变化或者是线上 schema 被改坏了。有了这个监控你能第一时间发现问题而不是等用户投诉。5. 常见问题与排查技巧实录5.1 典型问题速查表我在接入 Function Calling 的过程中遇到过的典型问题整理成一个表方便你排查时直接对照。现象根本原因处理办法模型返回的不是合法 JSON输出被截断或模型生成了注释/多余文本解析前先清洗字符串去掉 json 标记和首尾空白重试时让模型重新生成函数执行报“参数不存在”schema 里的参数名与后端函数签名不一致用 schema 动态生成函数签名或写一个适配层做名称映射校验一直失败但模型不修正错误反馈信息太抽象模型看不懂错误信息里写明字段名、期望值、当前值加“请重新调用函数”的明确指令模型重复生成同一个错误参数重试循环没有信息增量在 tool 消息中补充“你已经尝试过 xxx这个值不符合规则”schema 本身被 API 拒绝schema 格式不规范如 enum 类型错误检查 tools 定义是否符合当前模型 API 的 schema 规范不要用超集特性模型把必填字段填成 null模型理解“没有”为 nullschema 没限制在 schema 里对字段加type: [string, null]表示显式允许或者明确 required 字段禁止 null业务数据比 schema 还多一层校验例如配送时间不能在过去、库存必须大于数量在自定义校验阶段处理不进 schema避免 schema 过度膨胀5.2 几个让我印象深刻的坑第一个坑是不要把所有约束都塞进 schema。有一次我在 schema 里加了大量 pattern 和业务规则结果模型生成参数前要先“读”一大段 schema注意力又被分散了该填的核心字段反而填错。后来我把核心约束留在 schema业务约束放在自定义校验两边各司其职准确率反而上来了。第二个坑是错误信息要给模型“改错路径”。有段时间我总是返回“参数格式非法”这种笼统信息模型看到之后完全没有头绪只能照着原样再来一遍循环重试到超时。后来我把错误信息改成“city_code 应为以下值之一010、021、0755你当前填的是0512请参考用户地址重新填写”模型下一轮基本就改对了。这背后的原理是错误信息本质上是另一个“prompt”你得让模型能从中获得新信息否则重试就是空转。第三个坑是provider rejected the request schema or tool payload。这是我早期经常收到的一条报错。刚开始以为是模型 API 的问题后来逐条排查发现是我在 schema 里写了一个 JSON Schema 不支持的关键字官方文档没严格说明校验器又不报错结果 API 层直接拒绝了。建议拿到这条报错时先检查 tools 定义里有没有不常见的关键字比如minProperties、dependentRequired这类很多模型 API 只支持一部分 JSON Schema 功能子集。稳妥起见只用 type、properties、required、enum、pattern、minimum/maximum 这几个基础关键字就够用。第四个坑是别忽略跨字段关系校验。比如 city_code 和 address.city 必须匹配用户定位在上海你不能让模型填 city_code 为 010 但 address 里的 city 是“深圳”。这种约束 schema 原生表达不了必须在校验层写一个业务规则检查字段间的逻辑一致性。我见过不少人只做了单字段校验结果模型把枚举填对了但字段之间互相矛盾业务照样跑不通。最后分享一个实际体会Function Calling 这种模式真正的价值在于把大模型的能力接入真实系统但系统的可靠性不能建立在模型的自我约束上。schema 是给模型一个清晰的地图校验是给系统一扇安全门少了任何一个另外一边都会很被动。如果你现在也被参数问题折磨我的建议是先别急着堆 prompt老老实实把 schema 定义严谨、校验流程打通、错误反馈写好这三件事做好了参数问题基本就翻不出什么浪花了。
网站建设高端定制企业官网