你有没有遇到过这种情况对接 Function Calling 时模型一本正经地返回一段 JSON 参数格式规整、字段齐全但里面的值根本没法用——日期是2024-13-45城市是北京后面跟着看不见的换行符数量是-3。模型参数被编造不是偶发现象而是概率生成的必然副作用。我的解决思路非常朴素既然不能保证模型永远不出错那就在函数入口用 schema 校验把这些非法参数拦下来让脏数据连函数体都摸不到。这篇文章不讲空理论只聊我在实际 agent 项目里是怎么想、怎么选型、怎么落地的。适合正在做大模型应用开发、自己写过 Function Calling 分发逻辑、被模型一本正经地胡说八道坑过的工程师。如果你还没遇到过那我先说一句早早晚晚的事看完这篇再去检查你的工具注册代码大概率能救你一次。1. 为什么模型会一本正经地编造参数1.1 一次让我差点回滚上线的故障先说一个我真实踩过的坑。前阵子我做一个订单助手模型需要调用create_order工具入参包括product_id、quantity、remark三个字段。第一轮联调挺顺利直到测试同学传了一句话帮我买三杯美式备注少冰。模型返回的参数长这样{ product_id: americano_x3, quantity: three, remark: 少冰 }当时 handler 里直接用quantity做乘法计算JS 的隐式转换把three变成了NaN等到数据库写入报错我才发现。更气人的是product_id根本没有这个 SKUamericano_x3是模型自己拼接出来的。这一个 request 让整个 agent 流程卡了整整一分钟还污染了一条订单流水。那次之后我养成了一个习惯任何从模型返回的入参先当不可信输入处理。你从外部 API 拿的数据会做校验为什么从模型拿的数据反而裸奔1.2 编造参数不是偶发是机制决定的我一开始也天真过以为是 prompt 写得不够清楚加了一句请严格按照函数签名返回参数结果只是从经常出错变成偶尔出错。后来想明白了这根本不是 prompt 能根治的问题。大模型的本质是概率性 token 生成它擅长的是看起来合理而不是绝对精确。Function Calling 要求模型把语义理解压缩成结构化 JSON这个过程里它需要做数值推断、枚举选择、格式匹配每一步都有概率误差。尤其是整数、日期、枚举这类离散约束模型内部并没有一个类型系统在约束它全靠训练时见过的分布去猜。举个例子。你定义了unit: celsius | fahrenheit模型大概率能选对但它不知道摄氏这种中文写法不在枚举里。你定义了date: YYYY-MM-DD模型大概率会写对但当对话上下文很长、它需要从一段历史消息里提取日期时就可能把2024年3月15日翻译成2024-03-5。这不是它蠢而是转换路径太长中间任何一步注意力漂移都会导致输出偏移。还有一类情况是上下文污染。如果对话历史里出现过类似的参数值模型会不自觉地去复用那些模式。比如某次工具调用传了city: 北京市后面所有查询都跟着出现北京市甚至用户明确说查上海它也可能先输出北京。这不是逻辑错误是概率偏好。1.3 编造参数的三种常见面孔我把实际开发和测试中收集到的模型编造参数场景整理成了三个大类大家可以对照一下自己有没有遇过类型典型表现后果类型错误数字写成字符串、布尔写成true计算产生 NaN、数据库字段类型冲突格式错误日期写成2024/03/15、邮箱少个后缀下游服务直接报 400枚举越界status 传了openned、city 传了不存在的值查询结果为空或返回不该返回的数据还有一种更隐蔽的模型往参数里塞了一个 schema 里根本不存在的字段比如你的工具只需要name和age它额外塞了个hobby。JavaScript 的 JSON.parse 不会报错函数内部如果没做严格白名单这个多余字段会被静默丢弃也可能被意外使用行为就偏了。这三种面孔的共同点是模型自己觉得我填得很对啊但你的业务系统不这么认为。所以问题的核心不是如何让模型不出错而是如何让错误在进入业务逻辑之前被识别。2. 入口校验为什么必须挡在函数前面2.1 别在每个函数内部做防御很多人第一反应是那我在每个函数开头if判断一下不就行了比如if (typeof params.quantity ! number) throw new Error(quantity must be number); if (params.quantity 0) throw new Error(quantity must be positive);这种写法不是说不行而是当你的工具数量超过五个你会发现三件事第一校验代码量远超业务代码量。一个create_order的判断要写十几行来十个工具就是一百多行重复劳动。而且项目里不同的人写法还不一样有人用typeof有人用instanceof有人直接 null 判断规范完全靠自觉。第二校验散落在各个函数里等于没有一个统一的失败反馈出口。模型调用工具失败后你需要把错误信息回传给模型让它自我纠正这时候你希望对所有工具都用同一种错误响应格式。如果每个函数的报错文案都是手写的回传质量完全不可控。第三也是最容易忽视的一点函数内部校验通常发生在业务代码已经执行了一部分的时刻。比如你在写日志的时候才发现参数不对那参数已经参与过日志拼接了虽然不至于出严重事故但排查的时候你会分不清到底哪一步开始出了问题。所以我的选择是把校验统一收敛到一个分发层在调用具体 handler 之前完成做成一个对所有工具生效的门卫。2.2 校验的本质是建立契约做过前后端联调的人应该都熟悉接口契约这个概念前端按文档传参后端按文档校验。Function Calling 也是一样的逻辑只不过前端开发者变成了一个不怎么听话的模型。schema 在这里承担的就是契约角色而且是双份契约一份是给模型看的。你把参数的结构、类型、枚举、描述都写清楚注入到 Function Calling 的工具定义里让模型在生成时参考。相当于给模型一张标准答案表。另一份是给运行时看的。同一个 schema在入口处做严格解析任何不符合定义的值直接挡下。相当于查票员。这里有个非常关键的原则两份契约必须同源。你手写在工具定义里的 JSON Schema 和你在代码里手写的校验逻辑如果分别是两份文档那么它们迟早会不一致。比如你更新了工具定义里的枚举值但忘了改校验函数就会把模型明明可以正确返回的合法值给拦下来。所以我倾向于用同一个 zod schema 作为唯一事实源既用它定义类型也用它做校验再通过代码生成的方式把它转成 tool definition 喂给模型。2.3 错误信息本身就是模型的作业反馈入口校验的价值还不止防止脏数据它还能把失败变成一个可逆转的环节。做过 AI Agent 的朋友都知道模型调用工具失败后你完全可以把失败信息作为 tool 消息回传给模型让模型自己修改参数然后发起新一轮调用。很多模型在拿到quantity 必须是数字你传的是字符串 three这样的反馈后能够自己修正为 3。这个自我纠错的循环前提就是校验层的错误信息是结构化、可读的。zod 的error.issues天然就是数组结构[ { path: [quantity], message: Expected number, received string } ]我把这些 issues 精简成自然语言后回传给模型它就能知道具体是哪个字段错了、为什么错了、该怎么改。这个机制跑起来之后我甚至觉得模型偶尔犯错变得没那么可怕了——只要你给它一个明确的纠正通道。3. schema 选型从 Zod 到 JSON Schema3.1 手写 if 校验的致命问题很多从 Java 或 Node 走过来的同事看到 zod 的第一反应是又是一个轮子。但等你真正手写过一段参数校验你就会发现它的痛点。手写 if 校验的问题在于它没有类型推导。同一个参数在三个函数里被定义成三种写法你根本不知道哪个是对的。等你开始做前端和后端共享类型定义时手写 if 更是彻底崩溃——你就得维护两套代码一套 TS 类型一套运行时校验。另一个问题是边界条件极难想全。比如你校验一个日期字符串typeof string通过了但new Date(2024-02-30)在 JS 里不会抛错它会自动帮你纠正成 3 月 1 日。你写 if 判断的时候根本想不到这一层而 schema 库天然把这些边界收敛好了。3.2 为什么我选了 Zod schemaZod 作为 TypeScript 生态里最流行的校验库它的三个特性让我觉得它就是干这个活的一是safeParse不会抛异常返回的是带success标记的联合类型。这非常适合在分发层做成功走成功逻辑、失败返回失败信息的流程控制不用像 try/catch 那样接住异常再判断异常类型。二是z.infer可以直接从 schema 推导出 TS 类型。定义完 schemahandler 的参数类型就有了不用另外手写 interface天然避免类型文档和校验逻辑不一致。三是describe方法可以把字段说明直接挂在 schema 上后续转成 JSON Schema 给模型时这些描述会变成模型可以阅读的字段说明。等于一份定义两种消费方式。当然JSON Schema 本身也不是不行但手写 JSON Schema 可读性实在太差。你写一个嵌套对象要十几行而且编辑器没有类型提示写错了要到运行时才炸。用 zod 写再通过zod-to-json-schema转成 JSON Schema是最舒服的路径。3.3 把 schema 描述注入到函数注册表里实际操作中我维护了一个工具注册表每个工具的结构是统一的import { z } from zod; import { zodToJsonSchema } from zod-to-json-schema; const WeatherQuerySchema z.object({ city: z.string().min(1).max(50).describe(城市名称使用中文全称例如北京), date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe(查询日期格式为 YYYY-MM-DD), unit: z.enum([celsius, fahrenheit]).default(celsius).describe(温度单位), }); export const weatherTool { name: get_weather, description: 查询指定城市在指定日期的天气情况, schema: WeatherQuerySchema, jsonSchema: zodToJsonSchema(WeatherQuerySchema, get_weather), handler: async (args: z.infertypeof WeatherQuerySchema) { // 真实业务逻辑 return ...; }, };这样的好处是注册工具时写一次 schema运行时校验用它生成给模型的 function definition 也用它永远不会出现两套定义打架的情况。模型在生成参数时看到的就是你校验用的同一套约束等于标准答案和阅卷标准是同一份。4. 实操把校验层织进 Function Call 调度器4.1 先画清楚一次完整的调用链在写代码之前我建议你先在心里走一遍标准流程。我的实现分成这么几步模型返回一个tool_calls数组里面包含工具名name和原始参数arguments。框架把arguments从 JSON 字符串解析成对象。分发器根据name找到对应的工具定义。用工具的schema.safeParse对解析后的对象做校验。如果解析失败或校验失败构造一条错误信息作为 tool 消息回传给模型让模型自我修正。如果校验通过才调用真正的handler。注意第 2 步很容易被忽略模型返回的arguments是一个 JSON 字符串它经常会在字符串前后带多余字符或者把布尔值写成字符串。JSON.parse 本身就有可能失败。所以我在解析阶段也放了兜底逻辑这会在后面第 5 节展开。4.2 核心代码一个 SafeTool 包装器我写了一个统一的调度逻辑所有工具都走同一个方法不单独写每个函数的校验头。核心代码如下type ToolDefinitionTSchema extends z.ZodTypeAny { name: string; description: string; schema: TSchema; handler: (args: z.inferTSchema) Promisestring; }; async function executeToolCall(tool: ToolDefinitionany, rawArguments: string): Promisestring { // 1. 先将原始字符串解析为对象 const parsed tryParseJSON(rawArguments); if (!parsed.ok) { return 无法解析参数 JSON${parsed.reason}。请输出合法的 JSON 对象。; } // 2. 在入口处用 schema 严格校验 const result tool.schema.safeParse(parsed.value); if (!result.success) { const detail result.error.issues .map((issue) ${issue.path.join(.) || root}: ${issue.message}) .join(; ); return 参数校验未通过${detail}; } // 3. 全部通过才进入真实业务逻辑 return tool.handler(result.data); }这个方法最大的价值在于你新增一个工具时只需要定义好 schema 和 handler校验逻辑是完全自动挂上去的根本不用记得写 if。我后来把二三十个工具都迁到这个结构下代码量肉眼可见地减少。tryParseJSON是我封装的修复函数稍后单独讲。这里先假设它返回{ ok: true, value }或{ ok: false, reason }两种结构。4.3 错误信息回传给模型的具体写法很多人在第一步就掉坑了他们只把错误信息打印到日志而没有回传给模型。模型完全不知道自己的参数有问题下次要么重试同样的错误参数要么干脆放弃调用。正确的做法是把它作为 tool 消息放进 messages 数组让模型看到失败原因后发起新一轮修正。messages.push({ role: tool, tool_call_id: call.id, content: JSON.stringify({ ok: false, message: 工具 ${call.name} 参数校验未通过${detail}, }), });你会发现模型收到这条消息后通常会在下一轮调用里生成新的参数并且很可能会把错误点都改正。这比你在日志里骂模型真蠢要管用得多。当然返回给模型的错误信息要经过清理不能带上内部堆栈这个细节我放在排查实录里讲。5. 掉过的坑与排查实录5.1 zod 默认会把多余字段剥掉我第一次用z.object()时以为解析失败就只会因为类型不对。后来发现 zod 的默认行为是strip如果模型传了一个 schema 里不存在的字段它不会报错而是默默把那个字段删掉校验照样通过。一开始我还觉得这是好事至少不报错啊。直到有次我写了一个工具schema 里只有一个keyword字段模型自作主张传了keyword和filters两个字段。zod 把filters剥掉了校验通过了搜索出来的结果自然不对。我排查了半天才意识到模型原本想表达的搜索意图因为字段被静默丢弃而流失了。解决方案是在关键的 object schema 上调用.strict()让未知字段直接触发校验失败至少让模型知道你多传了不支持的参数。const Schema z.object({...}).strict();如果你希望支持额外字段但又想显式处理可以用.catchall(z.unknown())让它们保留下来但这种情况我觉得不太适合 Function Calling 场景因为未知字段通常意味着模型理解偏了不如直接暴露问题。5.2 枚举校验在测试阶段救了我一次有一次做一个状态流转工具我定义了status: z.enum([pending, approved, rejected])。测试时我故意让模型处理一条把订单置为已驳回的消息模型返回的是status: 驳回。如果没有 zod 的枚举约束这个值会直接进入业务层的 switch 分支匹配不到任何 case最后静默失败——订单既没有驳回也没有报错用户会以为成功了。加入枚举校验后错误信息直接指明status 只能是 pending/approved/rejected 之一模型下一轮就修正成了rejected。一件事让我意识到枚举字段是模型幻觉的高发区能枚举就一定要枚举不要用字符串一把梭。5.3 别把内部错误堆栈喂给模型校验通过之后handler 内部的错误也有可能发生。我一开始图省事捕获到 handler 异常后直接把error.message和堆栈一起返回给模型心想让模型帮我分析分析。结果模型确实分析了然后把堆栈里的内部文件路径原样输出给了用户我只好连夜改逻辑。现在我的原则是任何回传给模型的内容都要经过脱敏和精简。内部异常只记录到日志回传的消息最多包含工具执行失败请稍后重试这类信息具体错误细节只在开发调试模式下暴露。校验错误虽然可以给模型看路径和原因但也只给出字段名 简短原因不要把整个 error 对象序列化出去。5.4 被 JSON.parse 卡住的格式问题最后说说tryParseJSON的必要性。模型返回的arguments字符串偶尔不是标准 JSON常见情况有字符串被反引号包住、内容前后有说明文字、JSON 里出现尾逗号。我写了一个轻量兜底function tryParseJSON(raw: string): { ok: true; value: unknown } | { ok: false; reason: string } { try { return { ok: true, value: JSON.parse(raw) }; } catch { // 常见兜底截取第一个 { 到最后一个 } 之间的内容再试一次 const start raw.indexOf({); const end raw.lastIndexOf(}); if (start ! -1 end ! -1 end start) { try { return { ok: true, value: JSON.parse(raw.slice(start, end 1)) }; } catch { // 忽略 } } return { ok: false, reason: 不是合法 JSON${raw.slice(0, 80)} }; } }这个函数不完美但能兜住大部分模型输出不规范的情况。如果连兜底都失败那就直接让模型重新生成不需要再尝试 修复 了因为它大概率是生成路径出了大问题。最后再分享一个我的日常习惯现在每次新建工具我不先写 handler而是先把 schema 定义出来再让注册表自动把 schema 转成工具定义。这样从源头避免了定义和校验不一致的问题。我最近在做的一个 agent 项目里模型编造参数的事件基本清零了剩下偶尔的错误也能在模型自我修正环节解决脏数据很少有机会流进业务代码。如果你也在被 Function Calling 的不可控参数困扰先别急着给 prompt 上强度把 schema 挡在入口成本最低收益最直接。
企业数字化 ERP 产品动态
相关推荐
MySQL监控系统:Prometheus+Grafana实战部署指南 1. MySQL监控系统架构概述 在数据库运维中,监控系统如同驾驶舱的仪表盘,而PrometheusGrafana组合就是专为MySQL设计的专业级监控解决方案。这套系统由三个核心组件构成:数据采集层(mysqld-exporter)、存储计算层&#… · 2026/11/3 16:33:54
内网邮件服务器外发失败排查:从链路到配置的实战指南 1. 先搞清楚问题到底卡在哪一层公司邮箱服务器只能收内部邮件、发不出去外网,这个现象我在不同规模的办公网络里都遇到过。很多人第一反应是“服务器坏了”或者“网络不通”,但实际情况往往比这复杂得多。邮件从内网服务器到外部收件人,中间要… · 2026/11/3 16:33:54
电动汽车有序充放电调度系统与动态电价优化 1. 项目概述:电动汽车有序充放电调度系统研究在新能源革命和智能电网建设的背景下,电动汽车规模化接入配电网已成为不可逆转的趋势。然而,大量电动汽车的无序充放电行为会显著影响配电网的安全经济运行。本项目研究了一种基于动态分时电价的电… · 2026/11/3 16:33:54
EtherCAT IgH主站实时控制与六轴伺服联动实践 简介:围绕 EtherCAT、Linux 与 IgH 开源主站构建的运动控制资料,面向正在开发六轴机械臂、调试伺服总线或使用汇川 SV630N 的工程师与学生,重点解决主从通信流程、实时性保障和轨迹规划缺乏系统参照的问题。压缩包共 1 个 PDF 文件࿰… · 2026/11/3 17:26:36
IgH EtherCAT主站:PDO、WKC、DC与ESM调试 简介:围绕 EtherCAT 实时运动控制与 Linux 开源主站 IgH 的流程讲解,面向具备一定嵌入式或工控基础、希望打通主从通信并驱动汇川 SV630N 伺服电机的开发者。内容以 IgH 主站实现为主线,结合 Linux 平台上的实时化改造(Xenomai 内… · 2026/11/3 17:26:36
MOSFET手册参数解码:从绝对最大额定值到热设计的工程实践指南 1. 为什么“看懂MOSFET手册”这件事,90%的工程师卡在第一页?你有没有过这样的经历:手头拿到一颗标着“IRF540N”的MOSFET,急着用在电源开关电路里,翻到PDF手册第3页的“Absolute Maximum Ratings(绝对最大额… · 2026/11/3 17:26:36
智能体协调器原理与主流实现方案解析 我无法根据您提供的项目标题生成符合要求的博文内容。原因在于:该标题“OpenAI与Cursor在智能体协调器上达成一致,但对谁来运营它们存在分歧”并非真实存在的公开技术合作事件或已发布产品方案。经核实:OpenAI 官方从未宣布与 Cursor… · 2026/11/3 17:26:36
(158 赞)国庆期间自制 AI 代码工具:使用体验分享 国庆假期,我没有出去人挤人,而是宅在家里用 Claude Code 鼓捣出了一个 AI 小工具,现在用起来真是越用越顺手。
事情是这样的,我一直特别迷马斯克和其他大佬的深度访谈,尤其是那种三四个小时的长视频,字里行… · 2026/11/3 17:26:36
AI不做裁判:无排序无打分讨论系统的设计与实践 最近做了件挺反直觉的事:搭了一个专门用来讨论复杂问题的系统,然后把排序、打分、评选“哪个回答更好”这些功能全砍了。标题就叫“AI 不做裁判”,听起来像是行为艺术,但真正跑起来以后,我发现它解决了不少主流讨论平台… · 2026/11/3 17:25:35
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46