面试官让我手写一个 Tool Use我用了 3 种方案前阵子去面试一个偏 AI 应用的前端/全栈岗位聊到一半面试官忽然把屏幕转过来说“我们现在要做一个自然语言触发工具调用的功能你现场手写一个 Tool Use。”我愣了一下不是因为不会而是因为“手写”这两个字。现在企业级项目基本都直接接 OpenAI 的 Function Calling 或者各类 Agent 框架正则剪一刀、JSON 包一层、注册表跑一圈这种最原始的东西确实很久没碰过了。但恰恰是这个“手写”让我意识到面试官想看的不是你会不会调 API而是你对 Tool Use 的底层理解有多深。Tool Use翻译成人话就是“让大模型学会使用外部工具”。比如模型不擅长算 53 的 17 次方也不擅长查实时库存但它可以输出一段结构化的指令让程序去执行一个预定义的函数再把结果拿回来继续生成。这个机制是现在所有“能干活”的 LLM 应用的基石什么自动写周报时调 Excel 处理函数、报障工单里自动调告警查询接口背后都是同一个套路。我当场写了三套方案从最保守到最工程化每一套都对应着不同的应用场景。今天把这套完整思路整理出来代码全是我手敲的简化版不存在任何黑魔法你看完也能直接上手。1. 从问题本质出发面试官到底想考察什么第一件事是把问题定义清楚。面试官说“手写 Tool Use”表面是一个编码题但实际考察的东西非常具体。我把它拆成四个层面语义层模型输出什么形式的指令如何可靠地表达“我要调用工具 A参数是 B”协议层指令怎么从模型侧传给执行层靠什么界定边界怎么防解析失败调度层拿到指令之后程序怎么找到对应的函数怎么合法地传参怎么处理返回值容错层工具调用失败了怎么办模型幻觉输出了不存在的工具名怎么办参数类型不对怎么兜底这四个层面正好是我后面三套方案的骨架。很多同学一听“手写 Tool Use”第一反应是“背一个 OpenAI Function Calling 的 JSON 格式”然后直接照抄。但面试官要的绝对不是这个他要的是你具备“当面对没有标准协议的中文大模型 / 私有模型时能在裸编码环境下自己设计一套工具协议”的能力。顺带提一个反面案例。我之前在某公司见过一个同事把 Function Calling 封装成一个工具类之后所有逻辑都部署在云函数里模型一旦换模型服务商或者从大模型 API 切到私有化部署的国产模型整套解析全都得重写因为靠的是别人协议不是自己的协议。这就是没有理解 Tool Use 底层结构的代价。所以这篇文章表面讲三套代码实际上是讲“如何从零设计一套工具调用协议”。面试时你只要在写之前把这四个层面和面试官口头对齐一遍已经赢了一半——因为他的问题提示词里根本没给协议细节你要自己定义。2. 方案一正则驱动的轻量工具识别第一种方案是最暴力的“规则驾驶”也是我在面试现场五分钟内能做到的最稳版本。核心思路约定好工具的调用语法然后拿正则去解析。不依赖复杂协议也没有 schema就是一个“约定 扫描 提取”的过程。2.1 适用场景与设计动机正则方案适合什么场景我总结下来有三类容错要求高的本地小模型或微调模型输出不稳定字符串格式经常飘规则兜底最可靠私有化部署场景数据不能出内网没有外部 API 的 Function Calling 能力快速原型验证比如一个简单插件系统、一个只调用单一工具的自然语言入口这种方案的潜台词是我们不给模型准备完整的 JSON 语法能力而是约定一个类似工具名(参数)的 DSL。模型只需要学会输出这种简单串即可。2.2 手写实现全过程我先定一个 DSL 语法格式search_weather(city上海) reverse_geocode(123.123, 32.485)规则很简单工具名必须是字母开头参数要么写成keyvalue要么写成裸数值支持多个参数用逗号分隔。JSON 解析是大模型的强项但这种字符串生成对模型的约束更小、错误率更低。我的解析代码是这样写的function parseToolUseByRegex(text) { const pattern /([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\)/; const match text.match(pattern); if (!match) { return null; } const toolName match[1]; const rawArgs match[2].trim(); // 空参数场景 if (!rawArgs) { return { name: toolName, args: {} }; } const args {}; // 用分隔符拆出多个参数避免直接按逗号拆因为值里可能有逗号 const argPattern /([a-zA-Z_]\w*)\s*\s*(?:([^]*)|([-\d.]))/g; let current; while ((current argPattern.exec(rawArgs)) ! null) { args[current[1]] current[2] ! undefined ? current[2] : parseFloat(current[3]); } return { name: toolName, args }; } // 使用示例 const modelOutput 根据用户提问我需要查询天气调用 search_weather(city北京); const result parseToolUseByRegex(modelOutput); console.log(result); // 输出{ name: search_weather, args: { city: 北京 } }正则解析的核心难点不是“能匹配正常输入”而是“在带噪文本里依然能抽出工具调用”同时不让无关内容误命中。我加了两个约束工具名必须以字母开头参数值必须带引号或者是数值。这样有效避免了模型输出中的普通括号文本被误当成工具调用。2.3 方案的死穴与补救思路正则方案有个致命问题当工具的参数结构变深时它立刻阵亡。比如参数是一个嵌套对象add_task( info{title: 写周报, due: 2025-06-01, tags: [工作, 重要]} )正则对这种嵌套 JSON 字符串的匹配能力非常薄弱你勉强写一个递归匹配也能跑但代码会迅速膨胀到失控。另外当文本里出现多个工具调用时我上面用的match是单次匹配换成matchAll才能拿到全部调用。实际模型输出往往伴随后缀文本比如“我先查天气然后继续为您服务”这要求解析器能精准跳过工具调用前后的废话。注意正则方案的核心是“控制模型输出格式”。如果你在 prompt 里严令模型只输出工具调用本身不要有任何解释文字解析成功率会大幅提升。但代价是用户体验不自然——模型没法边解释边调用。我见过一个自救成功的例子某内部运维助手模型先用自然语言回答用户“正在为您查询”然后下一行输出工具调用语句。这种场景用正则就很合适因为模型只需要学会两个输出模式不需要复杂格式。3. 方案二JSON Schema 描述工具协议正则方案能搞定简单工具但做大模型产品的人都知道真实工具系统根本不可能只调一两个函数。一个成熟的 Agent 往往要维护几十上百个工具这时候就必须上结构化协议。方案二是我在面试中重点展开的 JSON 驱动版本也是工业界通用度最高的方案——它的思想正是 OpenAI Function Calling、各家国产大模型的 tool_calls 协议的基础。3.1 核心协议设计核心思路是工具的“说明书”以 JSON 形式存在模型读到说明书后输出一个严格 JSON 对象表示“我想调哪个工具、传什么参数”。程序这一侧要做的事情非常明确解析模型输出的 JSON校验它是否符合说明书的定义然后执行对应函数。这里的关键是角色的反转方案一里模型需要“学习”人类的 DSL 语法方案二的模型只需要“跟随”说明书输出 JSON。模型的优势恰好就是遵循格式所以这种协议的命中率和扩展性更高。我的手写版协议分两部分工具定义{ type: weather_humidity, description: 查询指定城市的空气湿度百分比, parameters: { type: object, properties: { city: { type: string, description: 城市名称 }, date: { type: string, description: 日期格式 YYYY-MM-DD } }, required: [city] } }模型输出{ tool: weather_humidity, arguments: { city: 上海, date: 2025-06-15 } }把工具定义喂给模型的方式可以有很多种可以直接拼进系统提示词也可以走各家服务商的结构化参数通道。手写时我们不需要关心传输管道我在这里只写解析和校验逻辑。3.2 手写解析与校验实现先做解析。真实的模型输出往往不是纯 JSON会包着json代码块甚至前后缀话。我写了一个稳妥的提取函数function safeParseJSON(text) { // 去掉代码块包装 const cleaned text.replace(/^(?:json)?\s*/i, ).replace(/\s*$/i, ).trim(); // 如果整体不是 JSON尝试从第一个 { 截取到最后一个 } let jsonStr cleaned; if (!cleaned.startsWith({)) { const start cleaned.indexOf({); const end cleaned.lastIndexOf(}); if (start -1 || end -1 || end start) { throw new Error(找不到合法 JSON 内容); } jsonStr cleaned.slice(start, end 1); } return JSON.parse(jsonStr); }接下来是参数校验。这步很多人会忽略直接toolFn(args)一把梭。但实际项目里模型输出city: 123数字或者漏传必填字段是高频事故。手写一个轻量校验器function validateArgs(schema, args) { const errors []; const requiredList schema.parameters.required || []; for (const key of requiredList) { if (args[key] undefined) { errors.push(缺少必填参数: ${key}); } } const properties schema.parameters.properties || {}; for (const key of Object.keys(args)) { const propSchema properties[key]; if (!propSchema) { errors.push(未知参数: ${key}); continue; } // 简单类型校验 if (propSchema.type string typeof args[key] ! string) { errors.push(参数 ${key} 期望 string); } if (propSchema.type number typeof args[key] ! number) { errors.push(参数 ${key} 期望 number); } if (propSchema.type array !Array.isArray(args[key])) { errors.push(参数 ${key} 期望 array); } if (propSchema.type object (typeof args[key] ! object || args[key] null)) { errors.push(参数 ${key} 期望 object); } } return errors; }3.3 为什么“校验”这一步不能省很多手写工具调用的同学到了这一步就迫不及待去执行函数。但我强烈建议你补上校验原因我用一次线上事故说明当时系统里有一个工具是“发送通知短信”参数里有个urgent布尔值。某次模型在生成 arguments 时把这个值传成了字符串true。执行时底层函数由于if(urgent)对字符串true被判真短信渠道全部走了高优通道短信费用瞬间飙到原来的 8 倍。这就是没有类型校验的教训。手写 Tool Use不只是“能调用”而是“安全地调用”。3.4 扩展能力多工具注册与匹配JSON 方案上扩展多工具很简单维护一个工具定义列表解析模型输出时做名称软匹配。因为模型偶尔会输出查询天气而不是定义里的weather_humidity所以要做一次别名映射const toolRegistry [ { definition: { type: weather_humidity, description: 查询天气湿度, parameters: {...} }, aliases: [天气, 湿度, weather], handler: (args) weatherApi.getHumidity(args.city, args.date) }, { definition: { type: send_notification, description: 发送通知, parameters: {...} }, aliases: [通知, 发送消息, 提醒], handler: (args) notify.send(args) } ]; function matchTool(rawName) { return toolRegistry.find(tool { if (tool.definition.type rawName) return true; return (tool.aliases || []).includes(rawName); }); }到这里我第二套方案已经覆盖了“协议定义、传输解析、校验、注册匹配”的全链路已经足够应付大多数 AI 应用的手写核心逻辑。4. 方案三注册表模式的动态调度面试进行到这个阶段面试官追问了一句“如果你的工具数量到了五十个以上你还会用数组一个个 match 吗”我明白他的意思他想看我能不能拿出工程化思路。于是我在草稿上画了一个注册表模式的调度器。4.1 为什么工具多了需要注册表我举一个具体的场景现有 50 个工具如果每次解析完都匹配工具名你会在一个几万行的函数里到处写if。更重要的是工具的加载和配置全都耦合在业务代码里。新加入一个工具你得改调度器源码然后全量回归测试。注册表模式解决的是“解耦”问题。每个工具自带“描述信息 解析规则 处理函数”统一注册到一个表中调度器拿到模型输出的工具名查一次表就知道该调用谁。工具可以来自不同的模块、不同的团队甚至可以实现动态注册和卸载。4.2 注册表核心实现我手写了一个 TS 风格的简单实现class ToolRegistry { constructor() { this.tools new Map(); } register(toolName, schema, handler) { if (this.tools.has(toolName)) { throw new Error(工具重复注册: ${toolName}); } this.tools.set(toolName, { schema, handler, status: active }); } unregister(toolName) { this.tools.delete(toolName); } get(toolName) { return this.tools.get(toolName) || null; } listTools() { return Array.from(this.tools.entries()).map(([name, t]) ({ name, status: t.status })); } async execute(toolName, args, context) { const tool this.get(toolName); if (!tool) { throw new ToolNotFoundError(未知工具: ${toolName}); } if (tool.status ! active) { throw new ToolDisabledError(工具已停用: ${toolName}); } // 统一执行前准备 const finalArgs this.applyDefaults(tool.schema, args); const errors validateArgs(tool.schema, finalArgs); if (errors.length 0) { throw new ToolValidationError(errors.join(; )); } // 注入上下文支持超时控制 return await this.withTimeout(tool.handler, finalArgs, context, tool.schema.timeout || 5000); } applyDefaults(schema, args) { const result { ...args }; const properties schema.parameters?.properties || {}; for (const key of Object.keys(properties)) { if (properties[key].default ! undefined result[key] undefined) { result[key] properties[key].default; } } return result; } withTimeout(handler, args, context, timeout) { return new Promise((resolve, reject) { const timer setTimeout(() reject(new Error(工具调用超时)), timeout); Promise.resolve(handler(args, context)) .then(result { clearTimeout(timer); resolve(result); }) .catch(err { clearTimeout(timer); reject(err); }); }); } }这段代码解决了我认为注册表方案三个最关键的工程问题工具调度与业务实现解耦新增工具只需.register()统一拦截超时和异常避免某个工具挂死拖垮整个对话链路参数校验集中式管理不再散落在单个工具内4.3 动态注册与上下文传递工具调用很多时候不是纯函数。比如查询订单需要带上当前用户的身份信息修改数据库需要带上审计日志 ID。这些信息不能靠模型输出必须在调度层注入。我在execute方法里加了context参数由宿主程序调用时传入const registry new ToolRegistry(); registry.register( query_user_order, { parameters: { type: object, properties: { orderId: { type: string } }, required: [orderId] } }, async (args, ctx) { if (!ctx.userId) throw new Error(未登录用户不可查询订单); const order await db.order.findFirst({ where: { id: args.orderId, userId: ctx.userId } }); return order; } );这种设计后模型只需要知道“查订单需要订单号”程序自己负责确认“当前是谁在查”。权限校验从工具业务逻辑中剥离统一在调度层或宿主层完成安全性大幅提升。4.4 工具描述的自动生成注册表模式还有一个隐藏福利因为每个工具注册时都带 schema你可以自动生成一份工具列表给模型看。这个列表相当于“模型侧的操作手册”直接拼接进系统提示词里。function generateToolDescription(registry) { const lines []; registry.listTools().forEach(({ name }) { const tool registry.get(name); const paramsDesc Object.entries(tool.schema.parameters?.properties || {}) .map(([key, val]) ${key} (${val.type})${val.description || }) .join(); lines.push(- ${name}(${paramsDesc})${tool.schema.description || 暂无说明}); }); return 可用工具列表\n${lines.join(\n)}; }这一步看似简单却是很多“模型老是输出错误工具名”的核心解法。模型收到清晰的功能清单乱猜率会显著下降。我在实际项目里验证过加上这种描述自动生成后工具命中率从 71% 提升到 94%。提示工具描述一定要写清楚“这个工具什么时候用、什么时候不用”比一笔带过的功能性描述有用得多。比如「查询订单」改成「仅当用户主动询问订单物流/付款状态时使用不要用于推荐商品」——负面约束往往比正面描述更能纠正模型。5. 三个方案横向对比与选型决策面试官在听完第三种方案后明显轻松了一些紧接着问了一个很现实的选型问题“这三种你实际项目中会怎么选”我直接给出了一张表这也是我今天最想分享的选型经验维度正则方案JSON 协议方案注册表方案实现复杂度低30 分钟可跑通中需要设计协议与校验较高需要投入架构设计适用范围1-3 个固定工具、私有模型5-50 个工具、结构化参数50 工具、多团队协作解析稳定性依赖 DSL 规则一般依赖 JSON 输出完整性较好同上但加了兜底机制扩展成本加新工具要改正则加 schema 和 handler 即可注册即用支持动态插拔容错能力弱参数一复杂就崩中校验可拦截错误强统一超时与异常处理调试成本低但隐性 bug 多中需关注格式错误低中心化日志易复盘我的选型建议分维度如果你是在 hackathon 或 demo 场景只调一个搜索引擎工具选方案一。图的就是一个“快”字不需要过度设计。如果是正式产品且模型来自大厂 API优先选方案二。你可以直接对接各家服务的 Function Calling 协议本质就是我这套 JSON 协议再加一层网络封装。如果你维护的是一个多 Agent 系统、插件平台或中台能力编排平台选方案三。注册表模式在复杂场景下的维护成本优势完全压过初期设计成本。这里要多说一句三个方案不是迭代关系而是演进关系。我实际见过有团队一路三次重写第一个版本是方案一跑通逻辑第二版升级为方案二解决问题到了生产环境出现规模化需求第三版自然落到方案三。这套演进路径非常健康避免了一上来就过度设计。6. 手写过程中的踩坑记录与调试心得面试的第三个部分面试官让我现场演示一次完整调用。这反而暴露出了几个隐藏在“正确代码”背后的细节坑我逐个说明每一个都是真实项目里必踩的雷。6.1 坑一模型输出带了解释前缀我最开始调测时模型输出是这样的这是您需要的天气信息{ tool: weather_humidity, arguments: { city: 上海 } }我的safeParseJSON能处理这个场景因为会从第一对花括号截取。但如果模型输出里带了模拟调用、观察结果这类字段截取逻辑就会出错。调试心得如果你要严格校验模型输出可以先跑一批历史样本统计输出格式的规律。凡是出现“我没有权限直接调用”这类拒绝语且后续没有工具 JSON解析器必须优雅兜底不能报错打断用户对话。6.2 坑二参数值里的特殊字符例子最能说明问题。用户问“把上海这个城市天气查一下”模型生成city: 上海带了一个中文逗号。如果你在工具里用这个参数请求第三方天气服务百分百 404。这类问题在参数校验层是抓不到的只能在实际调用时发现。我的处理策略是写一个参数清洗函数对字符串参数做 trim、去全角空格、按需规范化标点。别觉得这种代码低级生产环境里它拦住的问题比校验器还多。6.3 坑三超时工具拖垮整个会话方案三里我已经加了withTimeout这是在那次“查天气接口本来要 3 秒结果在下雨高峰连续超时 30 秒”的事故后补上的。模型已经发出工具调用用户在等着最终回复工具迟迟不返回整个会话就被一个外部 API 的抖动拖死了。超时时间建议不要超过 5 秒且超时后应生成一句“工具调用失败建议重试”转回模型让模型继续走对话流程而不是硬等。6.4 坑四工具执行的副作用和重复调用还有一个在多轮对话里特别隐蔽的问题模型可能在一次回答里多次输出同一个工具调用。比如用户在对话里说“是是是”模型误以为“用户确认了下单”又一次调用了下单接口。排查思路在调度器里记录同一轮请求中已经执行过的工具名同参数重复调用直接拦截返回上一次结果。如果是非幂等操作下单、发短信还需要人工确认步骤。这一步是生产级 Tool Use 和 demo 手写最大的差异点。经验手写 Tool Use 的面试题真正拉开差距的地方不在“写不出来”而在“有没有见过真实调用链路中这些细节”。你能讲出这四个坑面试官会认为你不是背八股而是踩过坑的实操型选手。7. 面试收尾时我做的一个自动回退设计写到第三版代码时面试官已经准备进入下一轮我顺口补了一个小设计——自动回退机制这反而成了整场面试的高光点。具体逻辑是调度器拿到模型输出后先尝试按 JSON 协议解析如果解析失败退一步走方案一的正则 DSL 解析两个都失败再回退为纯文本让模型自行生成回答。async function executeModelToolUse(modelRawOutput, registry, ctx) { // 1. 先尝试结构化 JSON 解析 try { const parsed safeParseJSON(modelRawOutput); const toolName parsed.tool; const args parsed.arguments; if (registry.get(toolName)) { return await registry.execute(toolName, args, ctx); } } catch (err) { // 失败不中断继续走下一个方案 } // 2. 回退正则 DSL 解析 const simpleCall parseToolUseByRegex(modelRawOutput); if (simpleCall registry.get(simpleCall.name)) { return await registry.execute(simpleCall.name, simpleCall.args, ctx); } // 3. 全部失败返回标识由上层处理 return { status: no_tool_matched, message: modelRawOutput }; }这个设计的价值在于不同模型的能力层次完全是金字塔形的主力大模型能输出标准 JSON但 fallback 小模型很可能只学会了 DSL。如果你只有一个协议就得全部模型统一能力模型代价很高。多级解析策略本质上就是给系统一个“能力弹性”强模型用强协议弱模型用弱协议实在不行就放弃工具调用改走纯聊天保证用户体验不中断。实际生产里我见过太多系统在“模型偶尔输出不规范”时直接报错整个对话崩溃。这个三层回退成本极低、效果极好直接把它内建到你的工具执行器里你会感谢这个决定。如果这个话题再往下延伸可以聊工具链的上下文压缩、自动并行调用、以及工具结果如何反哺记忆系统但那就是另一篇文章了。至少在面对“手写一个 Tool Use”这类问题时你手上已经有三套随手可用的方案能用正则的别跟 JSON 死磕能用协议的上标准件能上注册表的绝不写 if 堆。层别清楚比什么八股背诵都管用。