
1. 问题现场模型为什么总在参数上“自由发挥”第一次被 Function Calling 的参数坑到是我做一个工单自动分派功能的时候。用户说“帮我把上周那个服务器告警的工单转给网络组”模型返回的 JSON 里assignee_group字段直接编了一个“网络运维一组”而系统里实际存在的组叫“网络组”。更离谱的是priority字段我明明在描述里写了只能是P0/P1/P2/P3它给我返回了一个high。代码里json.loads一跑字段全在类型也对但业务逻辑直接炸了——查不到这个组优先级映射表里也没有high这个键。这就是 Function Calling 最典型的翻车场景模型不是在“查询”你的系统而是在“猜”你的系统。它根据训练语料和上下文生成一个“看起来合理”的参数值。字段名对了、JSON 格式对了但值的域完全不在你的预期内。如果你只在代码里做json.loads然后直接取字段用等于把模型的幻觉直接灌进了业务逻辑。我后来统计了一下在一个中等复杂度的工具调用场景里大概 8 个工具、每个工具 3 到 6 个参数如果不做任何入口校验参数值层面的错误率能到 15% 到 25%。其中大部分不是格式错误而是枚举值编造、ID 格式不对、必填字段缺失、类型漂移这四类。格式错误反而好办json.loads自己就抛了真正难防的是“结构合法但语义非法”。所以我的结论很直接Function Calling 的参数校验不能等到业务层再做必须在模型输出和业务逻辑之间加一道 schema 校验的闸门。这道闸门要能在参数进入任何业务代码之前就把编造的值挡回去并且给模型一个明确的错误反馈让它有机会重试。2. 方案选型为什么我最终选了 JSON Schema 而不是手写 if-else2.1 手写校验的三个致命伤最开始我也是手写校验的大概长这样def validate_params(params): if assignee_group not in params: return False, missing assignee_group if params[assignee_group] not in [网络组, 系统组, 安全组]: return False, invalid assignee_group if params.get(priority) not in [P0, P1, P2, P3]: return False, invalid priority # ... 还有十几个字段写了不到一周就受不了了。第一个问题是工具一多校验代码爆炸。8 个工具、每个工具 5 个参数就是 40 个 if 分支改一个枚举值要翻半天。第二个问题是嵌套结构没法优雅处理。有的工具参数是一个对象数组比如filters: [{field, op, value}]手写校验要写两层循环很容易漏掉边界情况。第三个问题是错误信息不统一。有的返回字符串有的返回布尔值模型拿到错误后不知道怎么修正。2.2 JSON Schema 的核心优势换成 JSON Schema 之后上面三个问题一次性解决。Schema 本身就是一份声明式的契约你只需要描述“合法参数长什么样”校验逻辑由库来执行。我用的 Python 库是jsonschema安装就一行pip install jsonschema它的工作方式很直观你定义一个 dict 作为 schema然后调用validate(instance, schema)不合法就抛ValidationError错误信息里会带具体的路径和原因。比如枚举值不对它会告诉你high is not one of [P0, P1, P2, P3]。这个错误信息直接可以回传给模型模型看到之后通常能自己修正。提示jsonschema的ValidationError有.path、.validator、.validator_value等属性做错误归类的时候非常有用不要只取str(e)。2.3 和 Zod、Pydantic 的对比如果你用 TypeScriptzod是更自然的选择它的类型推导和 schema 定义是一体的z.object({...})写起来比 JSON Schema 更紧凑。Python 这边pydantic也很强尤其是配合 FastAPI 的时候。但我最终选jsonschema的原因有三个第一Function Calling 的工具定义本身就是 JSON Schema。OpenAI、Anthropic 这些平台的 tools 参数里parameters字段就是一个标准 JSON Schema。我直接用同一份 schema 做两件事传给模型做工具描述拿回来做参数校验。一份定义两处使用不会出现“描述和校验不一致”的问题。第二跨语言通用。我的校验层是 Python但前端有时候也要做预校验JSON Schema 在 JS 里也有对应实现复制粘贴就能用。第三错误信息结构化程度高。jsonschema的错误对象能直接映射成“哪个字段、什么规则、期望什么”回传给模型做重试提示非常方便。方案定义方式与工具定义一致性错误信息适用场景手写 if-else代码低容易漂移需自己设计字段极少的小工具JSON Schemadict高可直接复用结构化带路径多工具、多参数Pydantic类定义中需转换结构化Python 后端为主Zod链式调用中需转换结构化TypeScript 项目3. 核心实现把 schema 校验做成一道可复用的闸门3.1 定义工具 schema 的正确姿势先看一个我实际在用的工具定义这是一个“创建工单”的工具CREATE_TICKET_SCHEMA { type: object, properties: { title: { type: string, minLength: 1, maxLength: 200 }, assignee_group: { type: string, enum: [网络组, 系统组, 安全组, 数据库组] }, priority: { type: string, enum: [P0, P1, P2, P3] }, tags: { type: array, items: {type: string, maxLength: 32}, maxItems: 10 }, deadline: { type: string, pattern: ^\\d{4}-\\d{2}-\\d{2}$ } }, required: [title, assignee_group, priority], additionalProperties: False }这里有几个关键点值得展开。enum是防编造的第一道防线所有“系统里有固定集合”的字段都必须用 enum不要用type: string然后指望模型自己收敛。pattern用来约束格式类字段比如日期、ID、手机号。additionalProperties: False很重要它能挡住模型“自作主张加字段”的行为——我遇到过模型在参数里塞了一个note字段业务代码没处理结果被静默忽略了排查了半天。注意additionalProperties: False在部分平台的工具描述里可能不被支持但校验层一定要开。描述层可以宽松校验层必须严格。3.2 校验函数的封装校验函数不要散落在各个工具处理逻辑里统一封装成一个入口from jsonschema import validate, ValidationError, Draft7Validator def validate_tool_args(tool_name, args, schema): validator Draft7Validator(schema) errors sorted(validator.iter_errors(args), keylambda e: e.path) if not errors: return True, None error_list [] for err in errors: error_list.append({ field: ..join(str(p) for p in err.path) or (root), rule: err.validator, expected: err.validator_value, actual: err.instance, message: err.message }) return False, error_list用iter_errors而不是validate的原因是validate遇到第一个错误就抛异常而iter_errors能一次性收集所有错误。模型拿到完整的错误列表一次重试就能修正多个问题比一个一个试效率高得多。sorted按路径排序是为了让错误信息稳定方便做缓存和日志分析。3.3 把错误反馈给模型做重试校验失败之后不要直接抛给用户而是把错误信息格式化后回传给模型让它重新生成参数。我用的格式是这样的def build_retry_message(errors): lines [参数校验失败请修正以下问题后重新调用] for e in errors: lines.append( f- 字段 {e[field]}{e[message]}期望 {e[expected]}实际 {e[actual]} ) return \n.join(lines)实测下来模型看到这种结构化错误后第一次重试的修正成功率大概在 70% 到 80%。剩下的 20% 到 30% 通常是模型对某个枚举值理解有偏差比如它坚持认为“紧急”应该映射到P0但你的系统里“紧急”对应的是P1。这种情况需要在工具描述里把映射关系写清楚或者在校验失败两次后直接降级到人工确认。4. 实操全流程从模型输出到业务逻辑的完整链路4.1 完整调用链路拆解我把整个链路拆成六步每一步都有明确的输入输出和失败处理模型返回 tool_calls拿到function.name和function.arguments注意是字符串不是 dict。解析 argumentsjson.loads如果解析失败直接返回“JSON 格式错误”给模型重试。查找工具 schema根据function.name从工具注册表里取对应的 schema取不到说明模型编了工具名直接拒绝。执行 schema 校验调用上面的validate_tool_args收集错误。校验失败则重试把错误格式化后作为 tool 消息回传最多重试 2 次。校验通过则执行业务逻辑此时参数已经保证结构合法、枚举合法、格式合法业务层可以放心取用。这个链路里第 3 步和第 4 步是核心。第 3 步防的是“工具名编造”第 4 步防的是“参数值编造”。两者缺一不可。4.2 参数解析的坑arguments 是字符串很多人第一次写 Function Calling 会踩这个坑function.arguments是一个 JSON 字符串不是 dict。你得先json.loads。但模型有时候会返回一个“几乎合法”的 JSON比如末尾多了个逗号或者用了单引号。这种情况json.loads会直接抛异常。我的处理方式是加一层容错解析import json def safe_parse_args(raw): try: return json.loads(raw), None except json.JSONDecodeError as e: # 尝试修常见问题末尾逗号、单引号 cleaned raw.strip().rstrip(,) cleaned cleaned.replace(, ) try: return json.loads(cleaned), None except json.JSONDecodeError: return None, fJSON 解析失败{e.msg}位置 {e.pos}提示不要用eval去解析哪怕它“能跑”。模型输出的内容不可信eval会执行任意代码这是安全红线。4.3 重试机制的设计重试不是无限次的。我的策略是最多重试 2 次每次把上一次的错误信息带上。如果 2 次之后还是失败就不再让模型重试而是走降级路径——要么返回一个“无法理解您的请求请换个说法”的提示要么把原始参数和错误记录到日志人工介入。为什么是 2 次因为实测下来第 1 次重试能修掉大部分格式和枚举错误第 2 次能修掉一部分嵌套结构错误。到第 3 次还在错的基本是模型对工具语义理解有根本偏差再重试也是浪费 token。MAX_RETRY 2 for attempt in range(MAX_RETRY 1): ok, errors validate_tool_args(tool_name, args, schema) if ok: break if attempt MAX_RETRY: return fallback_response(errors) retry_msg build_retry_message(errors) args call_model_with_retry(retry_msg)4.4 嵌套结构的校验要点嵌套结构是校验里最容易出问题的地方。比如一个“批量更新”工具参数是{ updates: [ {ticket_id: T-001, field: priority, value: P1}, {ticket_id: T-002, field: status, value: closed} ] }对应的 schema 要这样写{ type: object, properties: { updates: { type: array, minItems: 1, maxItems: 50, items: { type: object, properties: { ticket_id: {type: string, pattern: ^T-\\d{3,6}$}, field: {type: string, enum: [priority, status, assignee_group]}, value: {type: string} }, required: [ticket_id, field, value], additionalProperties: False } } }, required: [updates] }这里minItems和maxItems很重要。模型有时候会生成一个空数组或者生成 200 条更新前者业务上没意义后者可能把数据库打爆。items里的pattern和enum会逐条校验错误信息里会带updates.0.ticket_id这样的路径定位非常准。5. 常见问题与排查技巧实录5.1 模型编造枚举值的三种典型模式我整理了一下实际遇到的枚举编造模式大概分三类模式例子原因对策同义替换期望P1返回high模型用自然语言理解替代了枚举在描述里写“必须使用 P0/P1/P2/P3不要用 high/medium/low”大小写漂移期望P1返回p1模型对大小写不敏感schema 里加enum精确匹配或在校验前统一转大写层级混淆期望网络组返回网络运维一组模型补全了它认为“更完整”的名字描述里列出所有合法值并强调“只能从以下值中选择”第一类最常见也最好修。第二类可以在校验前做一次规范化比如args[priority] args[priority].upper()但要注意只对已知枚举字段做不要全局转。第三类最难因为模型是“善意地”补全它觉得自己在帮忙。这种情况只能靠 schema 的 enum 硬挡挡回去之后模型看到错误信息里的合法值列表通常能改对。5.2 必填字段缺失的排查必填字段缺失通常有两种原因一是模型漏了二是模型把字段放到了错误的位置。比如它把priority放到了metadata对象里而不是顶层。这种情况required校验会报“缺少 priority”但模型看到错误后可能会在顶层补一个而metadata里那个还在如果additionalProperties没关就会多出一个冗余字段。排查这类问题的技巧是在错误信息里明确告诉模型“字段应该放在哪一层”。比如字段 priority 缺失。注意priority 是顶层字段不要放在 metadata 里。这句话是我在踩了几次坑之后加上的加上之后这类错误的重试成功率明显提升。5.3 类型漂移的处理类型漂移指的是模型返回的类型和 schema 定义的不一致。最常见的是数字和字符串互转schema 要integer模型返回3schema 要string模型返回123。jsonschema默认是严格类型检查的3不会通过integer校验。我的处理方式是在校验前做一次轻量类型归一化只对已知字段做def normalize_args(args, schema): props schema.get(properties, {}) for key, spec in props.items(): if key not in args: continue if spec.get(type) integer and isinstance(args[key], str): if args[key].isdigit(): args[key] int(args[key]) if spec.get(type) string and isinstance(args[key], (int, float)): args[key] str(args[key]) return args注意归一化只做“无损转换”abc转integer这种不要做让它校验失败把问题暴露出来。5.4 常见问题速查表现象可能原因排查动作解决方式JSON 解析失败模型输出非法 JSON打印原始 arguments 字符串容错解析 重试枚举值不在列表模型编造同义词看错误信息里的 actual 值强化描述 enum 硬挡必填字段缺失模型漏字段或放错层级看错误路径错误信息里说明层级类型不匹配数字/字符串漂移看 validator 类型校验前归一化多余字段模型自作主张加字段看 additionalProperties开启 additionalProperties: False工具名不存在模型编造工具看 function.name工具注册表校验拒绝未知工具5.5 两个我踩过的坑第一个坑是校验通过但业务仍然出错。有一次 schema 里ticket_id只写了type: string没写pattern模型返回了一个T-ABC校验通过了但数据库里查不到。后来我把所有 ID 类字段都加上了pattern这类问题才消失。教训是schema 的严格程度要匹配业务的实际约束不能只写类型。第二个坑是错误信息太长导致模型重试时上下文爆炸。有一次一个批量更新工具50 条数据里有 30 条错误错误信息拼出来几千字模型重试的时候直接把上下文撑满了。后来我加了一个限制错误信息最多列前 5 条剩下的用“等 N 个错误”概括。模型修完前 5 条之后下一轮再报剩下的。6. 进阶把校验层做成可观测的入口6.1 记录校验失败率校验层不只是挡错误还是一个很好的观测点。我在校验函数里加了一个计数器按工具名和错误类型统计失败率。跑了一周之后发现assignee_group的枚举错误占了所有错误的 40%而且集中在“网络组”和“网络运维组”的混淆上。于是我直接在工具描述里加了一句“注意组名是‘网络组’不是‘网络运维组’”错误率直接降了一半。这个统计不需要很复杂一个内存里的 dict 加定期打日志就够了from collections import defaultdict stats defaultdict(lambda: defaultdict(int)) def record_failure(tool_name, errors): for e in errors: stats[tool_name][e[rule]] 16.2 用失败样本反哺工具描述校验失败的错误信息是最好的工具描述优化素材。我现在的习惯是每周看一次失败率最高的三个字段然后回去改工具描述。改的方向通常是三个把枚举值列得更全、把格式要求写得更具体、把容易混淆的值做显式排除。比如原来的描述是“优先级P0 到 P3”改成“优先级必须是 P0、P1、P2、P3 之一。P0 表示最高优先级P3 表示最低。不要使用 high、medium、low 等词”。改完之后这个字段的错误率从 18% 降到了 4%。6.3 降级路径的设计校验重试两次都失败之后不能直接把错误抛给用户。我的降级路径是如果错误集中在少数几个字段就生成一个“确认卡片”把模型理解的参数展示给用户让用户手动修正如果错误很分散就返回“我没太理解您的请求能换个说法吗”。这个降级路径的关键是不要让用户看到原始的 schema 错误。用户不关心enum和pattern他们只关心“哪里不对、怎么改”。所以降级路径里要把技术错误翻译成自然语言。7. 一些个人体会这套 schema 校验的闸门我从最开始的手写 if-else到后来换成jsonschema再到加上重试和观测前后迭代了大概两个月。最大的体会是Function Calling 的可靠性不取决于模型有多强而取决于你在模型和业务之间加了多少道闸门。模型一定会编参数这是它的工作方式决定的不是 bug。你能做的是让编造的参数在进入业务之前就被挡住并且给模型一个清晰的修正信号。另一个体会是schema 的严格程度要“渐进式”提升。一开始可以只写type和required跑一段时间看失败日志再逐步加enum、pattern、additionalProperties。一上来就写最严格的 schema可能会因为描述和 schema 不一致导致大量本来能用的调用被挡回去反而影响体验。最后分享一个小技巧如果你的工具参数里有“自由文本”字段比如description或note不要给它加maxLength之外的约束。模型在自由文本上反而很少出错因为那里没有“正确答案”可以编。真正需要严防的是那些有明确值域的字段——枚举、ID、日期、金额。把这些字段的 schema 写死Function Calling 的稳定性会有质的提升。