
1. 为什么我要自己造一个工具调用引擎Agent 这个词这两年火得不行但真正动手搭过的人都知道最让人头疼的不是模型本身而是工具调用这一层。你让模型输出一段 JSON 去调某个 API它可能给你多吐一个逗号可能把参数名拼错可能在流式输出的时候把 JSON 切成两半——前半段是合法的后半段还没到你拿去做JSON.parse直接炸掉。我最初用的是现成框架LangChain、Dify、CrewAI 都试过一圈。LangChain 的 tool 抽象确实方便但一旦你要做流式分片解析、要做参数级别的增量校验、要在工具执行到一半的时候插入人工确认就会发现框架的抽象层太厚改起来比从头写还累。Dify 适合做低代码编排但它的工具调用是黑盒你没法控制 JSON Schema 的校验时机。CrewAI 的多 Agent 协作很香可单工具调用的细粒度控制依然不够。所以这个项目的核心目标很明确实现一个可控、可观测、支持流式分片的 Agent 工具调用引擎。它要能接住模型吐出来的不完整 JSON边收边解析要能根据 JSON Schema 做参数校验和类型转换要能把工具执行结果以统一格式回灌给模型还要在工具调用出错时给出可读的纠错信息让模型有机会自我修正。适合谁来参考如果你正在做 Agent 应用开发已经过了“调个 API 就完事”的阶段开始遇到流式解析、参数校验、多工具编排这些脏活累活那这篇内容应该能帮你省掉不少踩坑时间。如果你还在学习 Agent 开发路线也可以把它当作一个“工具调用层到底要处理哪些边界情况”的清单来看。2. 整体架构设计与核心思路拆解2.1 为什么选择“解析器 校验器 执行器”三段式工具调用引擎的本质是把模型输出的自然语言或半结构化文本转换成一次可执行的函数调用再把执行结果转回模型能理解的格式。这个转换过程我拆成了三段解析器负责从流式文本中提取工具调用意图校验器负责用 JSON Schema 验证参数合法性执行器负责实际调用工具并处理异常。为什么不是一段式因为这三段的失败模式完全不同。解析失败是格式问题校验失败是语义问题执行失败是运行时问题。如果混在一起出错时你根本不知道是模型没按格式输出还是参数类型不对还是工具本身挂了。分开之后每一段都可以独立测试、独立替换。比如解析器可以从“正则匹配”换成“增量 JSON 解析器”校验器可以从“手写 if-else”换成“ajv”执行器可以从“同步调用”换成“带超时和重试的异步调用”互不影响。另一个关键考量是流式分片。模型输出工具调用时通常是边生成边返回的。如果你等完整 JSON 再解析用户体验就是“卡住不动然后突然出结果”。而流式分片解析可以让参数逐个就绪比如{city: 北的时候你就知道 city 参数开始传了虽然还不能执行但可以做 UI 上的进度提示。等北京}闭合立刻触发校验和执行。这个体验差异在交互式 Agent 里非常明显。2.2 JSON Schema 作为契约层的设计取舍工具定义我用 JSON Schema 来描述而不是简单的参数列表。原因很简单JSON Schema 能表达类型、必填项、枚举值、默认值、嵌套对象、数组元素类型这些在真实工具里全都会遇到。比如一个“发送邮件”工具收件人可以是字符串或字符串数组附件是对象数组且每个对象有 filename 和 content 两个必填字段——这种结构用参数列表根本描述不了。但 JSON Schema 也有代价它太灵活了灵活到模型有时候会生成符合 Schema 但语义荒谬的参数。比如{age: -5}在type: integer下是合法的但业务上不合理。所以我在校验器里加了一层业务规则钩子允许在 Schema 校验通过后再跑自定义的语义检查。这个钩子不是必须的但当你需要校验“日期不能早于今天”“金额必须大于零”这类规则时它比硬塞进 Schema 里要干净得多。还有一个取舍是是否允许额外参数。JSON Schema 默认允许additionalProperties但我在工具调用场景下默认禁止。因为模型经常会“自作聪明”地加一些它以为有用的字段比如调天气 API 时加个unit: celsius但工具根本不支持这个参数。禁止额外参数后校验器会直接报错模型收到错误信息后通常会修正。这比默默忽略未知参数要好因为默默忽略会让模型以为参数生效了下次还这么干。2.3 流式分片解析的状态机设计流式解析的核心是一个增量 JSON 状态机。它不需要等完整字符串而是逐字符喂入维护当前解析栈。遇到{入栈遇到}出栈遇到进入字符串模式遇到\进入转义模式。每完成一个键值对就触发一次“部分参数就绪”事件。这个状态机最难处理的是字符串中的转义和 Unicode。比如模型输出{path: C:\\Users\\test}反斜杠需要转义输出{emoji: }Unicode 代理对需要正确处理。我一开始用正则做分片结果遇到转义就崩后来换成手写状态机才稳定。状态机的另一个好处是可以在解析过程中检测“非法字符”比如 JSON 里不允许单引号如果模型用了单引号状态机可以在早期就报错而不是等完整解析失败。状态机还需要处理不完整输入。比如流式输出到{city: 北就暂停了这时候状态机应该处于“字符串模式当前键为 city当前值为 北”的状态。下次收到京}时继续从字符串模式退出完成键值对。这个“可暂停可恢复”的特性是流式解析和一次性解析的本质区别。3. 核心细节解析与实操要点3.1 工具注册表的字段设计与命名规范每个工具在注册表里至少包含这几个字段name、description、parametersJSON Schema、handler执行函数、timeout超时毫秒数、retry重试次数。name用蛇形命名法比如get_weather、send_email因为模型对蛇形命名的工具名识别率最高。description要写清楚“这个工具做什么、什么时候用、参数含义”不要写“这是一个获取天气的工具”这种废话而要写“根据城市名查询当前天气返回温度和天气状况城市名用中文如北京、上海”。parameters的 JSON Schema 里每个参数都要有description。我试过不写 description结果模型经常把city和location搞混。写了 description 之后模型选参数的准确率明显提升。对于枚举参数一定要用enum列出所有可能值比如{type: string, enum: [celsius, fahrenheit]}这样模型不会自由发挥。handler的签名统一为async (params, context) result。params是校验后的参数对象context包含会话 ID、用户 ID、调用链 ID 等元信息。返回值统一为{ success: boolean, data?: any, error?: string }。这个统一格式让执行器可以无差别处理所有工具的结果也方便回灌给模型时做格式化。3.2 参数校验的时机与错误信息构造校验分两个时机流式解析过程中的增量校验和完整参数后的全量校验。增量校验只做类型检查比如发现age字段开始传了但传的是字符串abc而 Schema 要求 integer就可以提前标记“类型不匹配”。全量校验在 JSON 闭合后执行做必填项检查、枚举值检查、嵌套结构检查。错误信息的构造非常关键。模型收到错误后要能理解并修正所以错误信息不能是ValidationError: /age must be integer这种机器格式而要写成参数 age 应该是整数但你传的是字符串 abc请修正后重新调用。我实测下来这种自然语言错误信息让模型的自我修正成功率从 40% 提升到 75% 左右。还有一个细节是错误信息的粒度。如果一次调用有多个参数错误不要一次性全抛给模型而是按优先级排序先报最关键的。比如必填项缺失比类型错误更严重先报缺失。模型一次修正一个问题的成功率远高于一次修正多个。3.3 流式分片与工具执行的并发控制流式分片解析和工具执行之间有一个微妙的并发问题如果模型在输出工具调用的同时还在输出其他内容比如解释文字你需要决定什么时候开始执行工具。我的策略是等工具调用的 JSON 完全闭合后再执行而不是边解析边执行。因为参数可能相互依赖比如start_date和end_date需要一起校验提前执行可能导致参数不完整。但解析和准备可以并行。当 JSON 闭合的瞬间校验器立刻启动同时执行器可以预加载工具依赖比如数据库连接、HTTP 客户端。这样校验通过后执行器可以立即开始减少等待时间。这个优化在工具执行耗时较长时效果明显比如一个需要调用外部 API 的工具预加载连接能省掉几百毫秒。并发控制的另一个点是多工具调用的顺序。模型可能一次输出多个工具调用比如先查天气再查航班。这些调用之间可能有依赖关系也可能没有。我的做法是默认串行执行但允许工具定义里声明parallel_safe: true表示该工具可以和其他工具并行。串行执行的好处是结果顺序确定回灌给模型时逻辑清晰并行执行则需要在结果里标注每个工具调用的 ID让模型知道哪个结果对应哪个调用。4. 实操过程与核心环节实现4.1 从零搭建增量 JSON 解析器增量 JSON 解析器的核心是一个字符级状态机。我用 TypeScript 实现状态定义如下enum ParseState { IDLE, // 等待开始 OBJECT_START, // 遇到 { KEY, // 解析键 COLON, // 遇到 : VALUE, // 解析值 STRING, // 字符串内部 ESCAPE, // 转义字符 NUMBER, // 数字 BOOLEAN, // true/false NULL, // null OBJECT_END, // 遇到 } ARRAY_START, // 遇到 [ ARRAY_END, // 遇到 ] DONE // 解析完成 }每次feed(char)调用时根据当前状态和输入字符决定下一个状态。比如在STRING状态下遇到\进入ESCAPE遇到退出字符串回到VALUE或KEY。在VALUE状态下遇到{进入OBJECT_START遇到[进入ARRAY_START遇到数字进入NUMBER。解析器维护一个路径栈记录当前解析到哪个字段。比如解析{user: {name: 张时路径栈是[user, name]当前值是张。当字符串闭合时触发onPartialValue(path, value)回调。这个回调可以用来做增量校验或 UI 更新。关键实现细节数字解析要支持科学计数法和负数。模型有时候会输出1e3或-0.5状态机需要正确处理。我的做法是在NUMBER状态下累积字符直到遇到非数字字符逗号、}、]、空白然后用parseFloat转换。如果转换失败标记为解析错误。另一个细节是嵌套对象的闭合检测。当遇到}时需要检查栈顶是否是对象如果是则弹出并触发onObjectComplete(path, object)回调。如果栈为空说明整个 JSON 解析完成触发onComplete(fullObject)。4.2 JSON Schema 校验器的实现与业务规则注入校验器我用的是ajv因为它性能好、支持 Draft 7、有详细的错误信息。初始化时import Ajv from ajv; const ajv new Ajv({ allErrors: true, strict: false });allErrors: true让 ajv 收集所有错误而不是遇到第一个就停方便我按优先级排序。strict: false是为了兼容一些模型生成的宽松 Schema。编译 Schemaconst validate ajv.compile(tool.parameters); const valid validate(params); if (!valid) { const errors validate.errors.map(e ({ path: e.instancePath, message: e.message, keyword: e.keyword })); // 按优先级排序required type enum 其他 errors.sort((a, b) priority(a.keyword) - priority(b.keyword)); // 构造自然语言错误信息 const humanReadable errors.map(e 参数 ${e.path} ${translateError(e)} ).join(); throw new ValidationError(humanReadable); }业务规则钩子的注入方式interface BusinessRule { name: string; check: (params: any) string | null; // 返回 null 表示通过返回字符串表示错误信息 } const rules: BusinessRule[] [ { name: date_not_past, check: (params) { if (params.date new Date(params.date) new Date()) { return 参数 date 不能早于今天; } return null; } } ];校验通过后依次跑业务规则任何一个失败就抛出对应的错误信息。这个设计让 Schema 保持通用业务规则可以按工具单独配置。4.3 工具执行器的超时、重试与结果格式化执行器的核心逻辑async function executeTool(tool, params, context) { const startTime Date.now(); let lastError null; for (let attempt 0; attempt tool.retry; attempt) { try { const result await Promise.race([ tool.handler(params, context), new Promise((_, reject) setTimeout(() reject(new Error(TIMEOUT)), tool.timeout) ) ]); return { success: true, data: result, meta: { duration: Date.now() - startTime, attempt } }; } catch (err) { lastError err; if (err.message TIMEOUT) { // 超时不重试直接返回 break; } // 其他错误等待后重试 if (attempt tool.retry) { await sleep(100 * Math.pow(2, attempt)); // 指数退避 } } } return { success: false, error: formatError(lastError), meta: { duration: Date.now() - startTime, attempt: tool.retry } }; }超时用Promise.race实现简单直接。重试用指数退避避免连续快速重试打爆下游服务。注意超时不重试因为超时通常意味着下游服务已经过载重试只会加重负担。结果格式化回灌给模型时统一成{ tool_call_id: call_abc123, tool_name: get_weather, success: true, data: { temperature: 25, condition: 晴 }, error: null }如果失败error字段填自然语言错误信息data为 null。模型看到这个格式后能清楚知道哪个调用成功了、哪个失败了、失败原因是什么。5. 常见问题与排查技巧实录5.1 流式解析中 JSON 被截断的典型场景最常见的截断场景是模型输出到一半遇到 token 限制。比如{city: 北京, unit: c就停了。这时候状态机处于STRING模式当前键是unit当前值是c。我的处理策略是如果流结束但状态机未完成触发onIncomplete事件把已解析的部分参数和未完成路径记录下来。然后给模型发一条系统消息“你上次的工具调用未完成已解析到参数 unit请补全剩余部分。”模型通常会接着输出elsius}状态机恢复后继续解析。另一个场景是网络中断导致流式数据丢失。这时候状态机会卡在某个中间状态无法恢复。我的做法是设置一个解析超时比如 30 秒内没有新数据就重置状态机并通知模型重新发起调用。这个超时时间需要根据实际网络情况调整太短会误杀慢速模型太长会让用户等太久。还有一个隐蔽的坑是模型输出多个 JSON 对象。比如它先输出一个工具调用然后又输出一段解释文字再输出另一个工具调用。如果状态机在第一个 JSON 闭合后没有正确重置第二个 JSON 会被当成第一个的延续导致解析错误。我的解决方案是每次onComplete后重置状态机并记录已完成的调用 ID后续 JSON 作为新的调用处理。5.2 参数类型不匹配的自动转换与拒绝策略模型经常把数字传成字符串比如{age: 25}而不是{age: 25}。我的策略是尝试自动转换但记录转换日志。对于type: integer或type: number如果收到字符串且内容可解析为数字自动转换并继续如果不可解析报错。对于type: boolean如果收到true或false字符串自动转换收到yes或no报错并提示“请使用 true 或 false”。自动转换的边界要清晰。我试过把1转成true结果模型以为布尔值可以用数字表示后面全传1和0。所以现在只做“字符串到数字”和“字符串到布尔”的严格转换其他一律拒绝。对于数组类型如果模型传了单个值而不是数组比如{tags: 重要}而 Schema 要求type: array我会自动包装成[重要]。这个转换很实用因为模型经常忘记数组语法。但反过来如果 Schema 要求单个值而模型传了数组我不做自动解包因为数组可能有多个元素解包哪个不确定直接报错让模型修正。5.3 工具执行失败的分类处理与模型纠错引导工具执行失败分三类参数错误、运行时错误、业务错误。参数错误在校验阶段就拦截了不会到执行器。运行时错误包括网络超时、连接拒绝、权限不足等这类错误通常需要重试或换工具。业务错误是工具逻辑返回的失败比如“城市不存在”“余额不足”。对模型纠错引导的关键是错误信息的可操作性。比如“城市不存在”要写成“城市 北京 不存在请检查城市名是否正确或换一个城市”。模型收到后可能会修正城市名或者问用户要正确的城市名。而“余额不足”要写成“账户余额不足当前余额 10 元需要 50 元请充值或换一个支付方式”。模型收到后可能会提示用户充值或者换一个不需要余额的工具。我整理了一个常见问题速查表问题现象可能原因排查方法解决策略JSON 解析失败模型输出格式错误打印原始输出检查引号、逗号、括号返回自然语言错误让模型重新生成参数校验失败类型不匹配或必填缺失打印校验错误详情按优先级返回错误引导模型逐个修正工具执行超时下游服务慢或网络问题检查工具 handler 耗时超时不重试返回超时错误建议换工具工具执行报错运行时异常打印异常堆栈分类处理运行时错误可重试业务错误引导修正流式解析卡住数据截断或状态机 bug检查状态机当前状态和路径栈设置解析超时超时后重置并通知模型多工具调用顺序错乱并发执行未标注 ID检查结果中的 tool_call_id默认串行并行时标注 ID 并排序5.4 实操心得三个让我少走弯路的经验第一个经验是永远不要相信模型输出的 JSON 是合法的。我一开始用JSON.parse直接解析结果线上环境 30% 的调用都因为格式问题失败。后来换成增量状态机失败率降到 5% 以下。剩下的 5% 主要是模型输出了非 JSON 内容比如纯文本解释这时候状态机会检测到第一个非{字符并报错引导模型重新输出。第二个经验是错误信息要短、要具体、要可操作。我试过把 ajv 的原始错误信息直接返回给模型模型完全看不懂因为它不知道instancePath是什么。后来改成“参数 age 应该是整数但你传的是字符串”模型立刻就能修正。错误信息长度控制在 50 字以内太长了模型会忽略后半部分。第三个经验是工具描述比工具实现更重要。我花在写工具 description 上的时间比写 handler 还多。一个好的 description 应该包含工具做什么、什么时候用、每个参数的含义和格式、返回值的结构。我甚至会在 description 里写示例比如“示例get_weather({city: 北京}) 返回 {temperature: 25, condition: 晴}”。模型看到示例后参数格式错误率明显下降。6. 工具调用引擎的扩展方向与性能优化6.1 从单工具调用到多工具编排的演进路径单工具调用跑通后下一步自然是多工具编排。我的做法是在引擎层加一个调用图结构每个节点是一个工具调用边表示依赖关系。模型输出多个工具调用时引擎根据参数引用关系自动构建调用图。比如工具 B 的参数引用了工具 A 的输出那 B 依赖 AA 先执行。调用图的执行用拓扑排序无依赖的节点可以并行。并行执行时每个节点的结果单独存储下游节点执行时从存储中读取上游结果。这个设计让多工具编排变得可预测、可调试。我实测下来一个包含 5 个工具调用的复杂任务串行执行需要 3 秒并行执行只需要 1.2 秒。但并行也带来了新的问题部分失败。如果 A 和 B 并行A 成功了但 B 失败了C 依赖 A 和 B那 C 应该执行吗我的策略是默认不执行返回“依赖 B 失败”的错误。但允许工具定义里声明partial_ok: true表示即使部分依赖失败也可以执行。这个灵活性在处理“尽力而为”的任务时很有用。6.2 缓存与去重避免重复调用相同工具Agent 经常会在多轮对话中重复调用相同的工具比如用户问“北京天气”后又问“北京明天天气”两次都调了天气 API。我在引擎层加了调用缓存key 是工具名加参数哈希value 是执行结果TTL 默认 60 秒。相同调用在 TTL 内直接返回缓存结果不实际执行。缓存的关键是参数哈希的稳定性。JSON 对象的键顺序可能不同{a:1,b:2}和{b:2,a:1}应该哈希到同一个值。我的做法是递归排序键后再序列化。对于数组顺序敏感不排序。对于浮点数做精度截断比如保留 6 位小数避免1.0000001和1.0000002被当成不同参数。去重则是针对同一轮对话中的重复调用。如果模型在同一轮里输出了两个完全相同的工具调用引擎只执行第一个第二个直接返回“重复调用已忽略”。这个策略避免了模型“复读”导致的资源浪费。6.3 可观测性日志、指标与调用链追踪工具调用引擎的可观测性直接决定了你排查问题的速度。我在三个层面做了埋点日志记录每次调用的完整生命周期包括原始输出、解析结果、校验结果、执行结果、耗时指标统计调用次数、成功率、平均耗时、错误分布调用链用 trace ID 串联一次对话中的所有工具调用方便追踪多工具编排的执行路径。日志用结构化格式每条日志包含trace_id、tool_name、stageparse/validate/execute、status、duration、error。这样你可以用trace_id过滤出一次对话的所有日志按时间排序看完整流程。指标用 Prometheus 格式暴露方便接入监控面板。调用链用 OpenTelemetry 标准每个工具调用是一个 span嵌套在对话的根 span 下。我踩过的一个坑是日志量太大。每次调用都打完整参数和结果一天下来几个 G。后来改成只打参数哈希和结果摘要完整内容只在出错时打。这样日志量降了 90%排查问题时依然够用。6.4 安全边界工具权限与参数注入防护工具调用引擎的安全边界经常被忽视。模型可能被诱导调用不该调用的工具比如删除数据的工具。我的做法是工具分级read、write、admin三级。read工具任何场景都可调用write工具需要用户确认admin工具只在特定会话中可用。确认机制是在执行前插入一个“待确认”状态等用户点击确认后才真正执行。参数注入是另一个风险。如果工具 handler 直接把参数拼接到 SQL 或 shell 命令里模型可能生成恶意参数。我的防护是参数白名单每个工具定义里声明哪些参数可以包含特殊字符其他参数只允许字母、数字、下划线、连字符。对于必须包含特殊字符的参数比如文件路径做转义处理后再传给 handler。还有一个边界是工具调用频率限制。模型可能陷入循环反复调用同一个工具。我设置了每轮对话最多 10 次工具调用超过后强制终止并返回“工具调用次数超限”。这个限制可以根据场景调整但必须有否则模型可能无限循环烧 token。7. 我踩过的坑与最终选型这个引擎我迭代了三个版本。第一版用正则匹配工具调用简单但脆弱遇到嵌套 JSON 就崩。第二版用JSON.parse加 try-catch能处理完整 JSON 但流式场景下体验很差。第三版才是现在的增量状态机加 Schema 校验加执行器稳定性和体验都上了一个台阶。选型上解析器我坚持手写状态机而不是用现成库因为现成库要么不支持流式要么不支持增量回调。校验器用 ajv 是因为它成熟、快、错误信息详细。执行器用原生 Promise 加Promise.race做超时没有引入额外依赖。整个引擎核心代码不到 800 行但覆盖了工具调用的所有关键路径。如果你要自己实现我的建议是先把解析器写扎实。解析器是地基地基不稳后面全白搭。写解析器的时候多写单元测试覆盖各种边界空对象、嵌套对象、数组、转义字符、Unicode、数字、布尔、null、截断、多余字符。测试覆盖率上去了线上问题就少了。最后分享一个小技巧在工具 handler 里加一个dry_run参数默认 false。当dry_run为 true 时handler 只校验参数不实际执行返回“预演成功”。这个功能在调试新工具时特别有用可以快速验证参数传递是否正确而不用真的调用下游服务。等确认无误后再把dry_run设为 false 正式执行。