1. 项目概述:当“手写 Tool Use”成为面试新分水岭
最近在多个技术社区和求职群看到高频讨论:“面试官让我现场手写一个 Tool Use”,“Tool Use 已经替代 Promise 成为前端/大模型应用岗的必考题”,甚至有某大厂校招面试官在内部分享中明确提到:“不考察你调 API 的熟练度,而要看你能否在 15 分钟内,从零构建一个可被 LLM 理解、可被用户信任、可被系统调度的 Tool”。这不是玄学,也不是概念炒作——它直指当前智能体(Agent)开发中最真实、最落地的一环:工具定义与调用协议的工程实现能力。
所谓 “Tool Use”,本质是让大语言模型具备“动手能力”的桥梁。它不是让模型自己写代码,而是让它能准确理解用户意图、自主判断是否需要调用外部能力、精准生成符合规范的调用请求、并安全可靠地解析返回结果。这个过程涉及三重契约:人与模型之间的语义契约(用户说“查北京天气”,模型要懂这是查询类动作)、模型与工具之间的结构契约(必须输出 JSON 格式且字段名、类型、必填项完全匹配)、工具与系统之间的执行契约(HTTP 接口或函数调用需满足鉴权、超时、重试等生产级要求)。我带过的几位应届生在模拟面试中栽在这一步,不是因为不会写 fetch,而是卡在“怎么让模型知道该调哪个工具”“怎么让工具返回的结果不被模型胡乱编造”“怎么防止用户一句话触发 5 个工具导致雪崩”这些看似微小却决定成败的细节上。
这篇文章不讲抽象理论,也不堆砌论文术语。我会以一名经历过 20+ 场 AI 工程方向技术面试的从业者身份,复盘我在真实场景中为解决“手写 Tool Use”这一命题所尝试的三种递进式方案:从最轻量的字符串模板法(适合 3 分钟快速验证),到结构化 Schema 驱动法(兼顾可读性与可扩展性),再到生产就绪的注册中心 + 运行时解析法(支撑多模型、多工具、多环境的复杂调度)。每一种方案我都附上了完整可运行的代码片段、关键参数的设计逻辑、调试时踩过的具体坑点,以及它们分别适用于什么面试阶段、什么岗位层级、什么业务场景。如果你正在准备大模型应用开发、智能体架构、AI 产品工程化等方向的面试,或者正面临从 Prompt 工程向 Agent 工程转型的实际项目,这篇内容就是你打开工具链大门的第一把钥匙——它不教你“背答案”,而是帮你建立一套可迁移、可验证、可演进的 Tool Use 设计思维。
2. 方案一:字符串模板法——3 分钟内跑通的“最小可行体”
2.1 为什么从这里开始?面试场景下的生存逻辑
很多候选人一听到“手写 Tool Use”,第一反应是去翻 OpenAI 的 Function Calling 文档,然后试图抄一段带tools和tool_choice的完整请求体。这在实际面试中往往适得其反:面试官给的时间通常只有 10–15 分钟,而你花 8 分钟配好 OpenAI SDK、处理 API Key、写完异步调用逻辑,最后只剩 2 分钟写核心逻辑,结果连最基本的“识别出用户要查天气”都漏掉了。真正的考察点从来不是你会不会调第三方服务,而是你能否在资源极度受限(时间、环境、依赖)的前提下,清晰表达出 Tool Use 的核心契约。
字符串模板法就是为此而生。它的核心思想极其朴素:把工具调用看作一次受控的字符串拼接,而非一次复杂的运行时决策。我们不依赖任何框架、不引入任何外部库、不连接真实 API,只用原生 JavaScript(或 Python)完成三件事:(1)定义工具的描述文本;(2)编写一个能从用户输入中提取关键词并匹配工具的简单规则;(3)按预设格式拼出调用字符串。它牺牲了灵活性,但换来了确定性、可预测性和极高的演示成功率。
提示:面试中当你听到“请手写一个 Tool Use”时,先别急着写 async/await。直接问一句:“当前环境是否允许联网?是否可以使用第三方库?” 如果回答是否定的,字符串模板法就是你的最优解。它能在白板、本地编辑器、甚至手机备忘录里完成,且逻辑一目了然。
2.2 具体实现:一个可直接粘贴运行的完整示例
我们以“查询城市天气”和“获取股票实时价格”两个工具为例,目标是让模型在用户输入“北京今天多少度”时,输出类似TOOL:weather(city="北京")的字符串;在用户输入“苹果股票现在多少钱”时,输出TOOL:stock(symbol="AAPL")。
// 定义工具集合:每个工具包含名称、描述、参数列表(用于后续提示词注入) const tools = [ { name: "weather", description: "查询指定城市的当前天气情况,包括温度、湿度、风速", parameters: ["city"] }, { name: "stock", description: "获取指定股票代码的最新交易价格和涨跌幅", parameters: ["symbol"] } ]; // 简单关键词匹配函数:基于硬编码规则判断用户意图 function matchTool(input) { const lowerInput = input.toLowerCase(); if (lowerInput.includes("天气") || lowerInput.includes("度") || lowerInput.includes("湿度")) { // 提取城市名:这里用最简策略,找“北京”“上海”等常见词,实际可替换为更鲁棒的 NER const cities = ["北京", "上海", "广州", "深圳", "杭州", "成都"]; for (const city of cities) { if (lowerInput.includes(city)) { return { name: "weather", args: { city } }; } } return { name: "weather", args: { city: "北京" } }; // 默认 fallback } if (lowerInput.includes("股票") || lowerInput.includes("股价") || lowerInput.includes("多少钱")) { // 提取股票代码:同样用简单关键词映射,如“苹果”→“AAPL” const symbolMap = { "苹果": "AAPL", "特斯拉": "TSLA", "微软": "MSFT", "谷歌": "GOOGL" }; for (const [keyword, symbol] of Object.entries(symbolMap)) { if (lowerInput.includes(keyword)) { return { name: "stock", args: { symbol } }; } } return { name: "stock", args: { symbol: "AAPL" } }; // 默认 fallback } return null; // 未匹配到任何工具 } // 拼接调用字符串:严格遵循预设格式,便于后续正则解析 function buildToolCall(toolMatch) { if (!toolMatch) return null; const { name, args } = toolMatch; const argStr = Object.entries(args) .map(([k, v]) => `${k}="${v}"`) .join(", "); return `TOOL:${name}(${argStr})`; } // 主函数:模拟一次完整的 Tool Use 流程 function handleUserInput(input) { console.log(`[用户输入] ${input}`); const toolMatch = matchTool(input); if (!toolMatch) { console.log("[模型响应] 我暂时无法处理这个请求,请换一种说法。"); return; } const toolCall = buildToolCall(toolMatch); console.log(`[工具调用] ${toolCall}`); // 此处可模拟调用真实接口,但面试中只需说明“下一步将解析此字符串并执行” // 例如:const response = await fetch(`/api/${toolMatch.name}`, { method: 'POST', body: JSON.stringify(toolMatch.args) }); } // 测试用例 handleUserInput("北京今天多少度"); // 输出:[用户输入] 北京今天多少度 // [工具调用] TOOL:weather(city="北京") handleUserInput("苹果股票现在多少钱"); // 输出:[用户输入] 苹果股票现在多少钱 // [工具调用] TOOL:stock(symbol="AAPL")这段代码只有 60 行,但它完整覆盖了 Tool Use 的四个核心环节:工具注册(tools数组)、意图识别(matchTool)、参数提取(args对象)、调用构造(buildToolCall)。它没有用正则捕获组,没有用 AST 解析,甚至没用switch语句——因为面试现场手写正则容易出错,而if/else链最直观、最容易被面试官快速理解你的设计思路。
2.3 实操心得:为什么这个方案在面试中“稳如老狗”
我带过的一位应届生在某一线大厂终面中,被要求“不用任何框架,手写一个能调用天气和股票 API 的 Tool”。他没有选择炫技写一个通用解析器,而是用了这个模板法,并在现场补充了两段关键说明,最终拿到了 Offer:
第一段说明是关于“为什么不用正则”:他说:“正则虽然简洁,但在面试手写时容易漏转义、错括号,而且一旦需求变更(比如增加‘查询未来三天预报’这个新工具),正则规则会变得难以维护。而现在的
if/else结构,新增一个工具只需要加一个if块和一个tools条目,逻辑边界清晰,可测试性强。”第二段说明是关于“如何应对模糊输入”:他当场改写了
matchTool函数,增加了对“北京天气怎么样”“北京的天气”“北京今天天气”等多种变体的支持,并指出:“真实用户不会按教科书语法说话。我的匹配策略优先保证高召回(尽量不漏掉有效请求),再通过默认参数兜底(如城市默认北京),避免因匹配失败导致整个流程中断。这比追求 100% 精确但经常返回 null 更符合用户体验。”
这两段话,比代码本身更能体现他的工程素养。字符串模板法的价值,不在于它多高级,而在于它让你能把注意力 100% 放在“契约设计”上——工具该怎么描述才能让模型理解?参数该怎么命名才能避免歧义?失败时该怎么降级才能保持对话流畅?这些问题的答案,恰恰是高级工程师和初级工程师的核心分水岭。
3. 方案二:Schema 驱动法——让模型真正“看懂”你的工具
3.1 从“人看懂”到“模型看懂”:为什么字符串模板不够用了
字符串模板法在 3 分钟内能跑通,但它有一个致命短板:它只解决了“调用端”的问题,没解决“理解端”的问题。在真实的大模型应用中,我们不是自己写matchTool去判断用户意图,而是把工具描述喂给模型,让模型自己决定该调哪个、传什么参数。这就要求工具描述必须是一种模型能“读懂”的语言——不是自然语言描述,而是结构化的、带约束的 Schema。
举个例子:如果只告诉模型“这是一个查天气的工具”,它可能在用户说“给我北京的天气预报”时正确调用,但当用户说“北京现在热不热”“北京体感温度多少”“北京适合穿什么衣服”时,它就可能犹豫、错误调用,甚至瞎编一个温度值。而如果我们给模型提供这样的 Schema:
{ "name": "weather", "description": "查询指定城市的当前天气实况,返回温度、湿度、风速、天气现象(如晴、雨、多云)", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "要查询的城市中文全称,如'北京市'、'上海市',不接受缩写或英文" } }, "required": ["city"] } }模型就能基于这个 JSON Schema 理解:(1)这个工具只处理“当前实况”,不处理“未来预报”;(2)city是必填项,且必须是中文全称;(3)返回结果中一定会包含temperature字段。这种结构化描述,才是模型进行可靠推理的基础。这也是为什么 OpenAI 的functions参数、Anthropic 的tool_use、Google 的function calling都强制要求提供 JSON Schema——它们不是为了好看,而是为了让模型的“思考过程”可约束、可验证、可审计。
3.2 Schema 驱动法的核心实现:一个自包含的运行时解析器
方案二的目标,是构建一个既能被人类阅读、又能被模型消费、还能被代码执行的统一工具定义体系。它包含三个关键组件:
- Schema 注册中心:一个集中管理所有工具 Schema 的对象,支持动态增删;
- Prompt 注入器:将 Schema 转换为模型能理解的自然语言提示(如“你可以使用以下工具:1. weather:查询城市天气……”);
- 调用解析器:接收模型返回的 JSON 格式调用请求(如
{"name": "weather", "arguments": {"city": "北京"}}),校验其合法性,并执行对应函数。
下面是一个精简但完整的实现(仍使用 JavaScript,无外部依赖):
// 1. Schema 注册中心:支持添加、获取、列出所有工具 class ToolRegistry { constructor() { this.tools = new Map(); } // 添加工具:传入 name 和完整 Schema 对象 add(name, schema) { this.tools.set(name, { ...schema, name // 确保 name 字段存在 }); } // 获取指定工具的 Schema get(name) { return this.tools.get(name) || null; } // 获取所有工具 Schema 的数组,用于构建系统提示词 list() { return Array.from(this.tools.values()); } } // 2. Prompt 注入器:将 Schema 转为模型友好的自然语言描述 function buildSystemPrompt(registry) { const tools = registry.list(); if (tools.length === 0) return "你不能调用任何外部工具。"; let prompt = "你是一个强大的 AI 助手,可以调用以下工具来帮助用户解决问题。请严格遵守以下规则:\n"; prompt += "1. 只有当用户请求明确需要外部数据时,才调用工具;\n"; prompt += "2. 调用工具前,必须确认所有必填参数都已获得;\n"; prompt += "3. 如果参数缺失或模糊,先向用户提问,不要猜测。\n\n"; prompt += "可用工具列表:\n"; tools.forEach((tool, index) => { prompt += `${index + 1}. ${tool.name}:${tool.description}\n`; prompt += ` 参数:${JSON.stringify(tool.parameters)}\n`; }); return prompt; } // 3. 调用解析器:校验并执行模型返回的工具调用 class ToolExecutor { constructor(registry) { this.registry = registry; } // 校验模型返回的调用请求是否合法 validateCall(call) { if (!call || typeof call !== 'object') { return { valid: false, error: "调用格式不是 JSON 对象" }; } const { name, arguments: args } = call; if (!name || typeof name !== 'string') { return { valid: false, error: "缺少 name 字段或类型错误" }; } const toolSchema = this.registry.get(name); if (!toolSchema) { return { valid: false, error: `工具 '${name}' 未注册` }; } // 校验参数 if (!args || typeof args !== 'object') { return { valid: false, error: "arguments 字段缺失或不是对象" }; } const { properties, required = [] } = toolSchema.parameters; if (!properties || typeof properties !== 'object') { return { valid: false, error: "工具 Schema 中缺少 properties 定义" }; } // 检查必填参数是否齐全 for (const req of required) { if (!(req in args)) { return { valid: false, error: `缺少必填参数 '${req}'` }; } } // 检查参数类型(简化版:只检查 string/number/boolean) for (const [key, value] of Object.entries(args)) { const propDef = properties[key]; if (!propDef) continue; const expectedType = propDef.type; const actualType = typeof value; if (expectedType === 'string' && actualType !== 'string') { return { valid: false, error: `参数 '${key}' 应为字符串,但得到 ${actualType}` }; } if (expectedType === 'number' && actualType !== 'number') { return { valid: false, error: `参数 '${key}' 应为数字,但得到 ${actualType}` }; } if (expectedType === 'boolean' && actualType !== 'boolean') { return { valid: false, error: `参数 '${key}' 应为布尔值,但得到 ${actualType}` }; } } return { valid: true, toolSchema, args }; } // 执行已校验的调用(此处用模拟函数代替真实 API) async execute(call) { const validationResult = this.validateCall(call); if (!validationResult.valid) { throw new Error(`工具调用校验失败:${validationResult.error}`); } const { toolSchema, args } = validationResult; // 模拟真实工具函数:根据工具名分发 switch (toolSchema.name) { case 'weather': return this._simulateWeather(args.city); case 'stock': return this._simulateStock(args.symbol); default: throw new Error(`未知工具:${toolSchema.name}`); } } // 模拟天气查询(返回固定 JSON) _simulateWeather(city) { return { city: city, temperature: Math.floor(Math.random() * 10) + 20, humidity: Math.floor(Math.random() * 30) + 40, condition: ["晴", "多云", "小雨", "阴"][Math.floor(Math.random() * 4)] }; } // 模拟股票查询 _simulateStock(symbol) { const basePrice = { AAPL: 190, TSLA: 240, MSFT: 330, GOOGL: 135 }; const change = (Math.random() - 0.5) * 2; return { symbol: symbol, price: (basePrice[symbol] || 100) + change, changePercent: change.toFixed(2) }; } } // 使用示例 const registry = new ToolRegistry(); // 注册天气工具(使用标准 JSON Schema) registry.add("weather", { "description": "查询指定城市的当前天气实况,返回温度、湿度、风速、天气现象", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "要查询的城市中文全称,如'北京市'、'上海市'" } }, "required": ["city"] } }); // 注册股票工具 registry.add("stock", { "description": "获取指定股票代码的最新交易价格和涨跌幅", "parameters": { "type": "object", "properties": { "symbol": { "type": "string", "description": "股票代码,如'AAPL'、'TSLA'" } }, "required": ["symbol"] } }); // 构建系统提示词(可直接喂给模型) console.log("=== 系统提示词 ==="); console.log(buildSystemPrompt(registry)); // 创建执行器 const executor = new ToolExecutor(registry); // 模拟模型返回的调用请求(这是模型在 function calling 模式下输出的 JSON) const modelOutput = { "name": "weather", "arguments": { "city": "北京" } }; // 执行调用 executor.execute(modelOutput) .then(result => console.log("=== 工具返回结果 ===", result)) .catch(err => console.error("执行失败:", err));这段代码约 200 行,但它实现了真正的 Schema 驱动:工具定义即 Schema,Schema 即文档,文档即执行依据。它不再依赖关键词硬匹配,而是让模型基于语义理解做决策;它不再拼接字符串,而是用标准 JSON 通信;它不再忽略参数校验,而是内置了类型检查和必填项验证。这已经非常接近生产环境中LangChain或LlamaIndex的底层工具管理逻辑。
3.3 关键参数设计原理:为什么required和type必须显式声明
在上面的 Schema 中,required: ["city"]和type: "string"看似简单,但它们背后是大量线上事故换来的经验:
required字段是防止“空参数调用”的保险丝。我曾参与过一个电商客服 Agent 项目,初期 Schema 没设required,结果模型在用户说“查一下订单”时,自作主张传了{"order_id": ""},导致后端接口返回 500 错误,整个对话流中断。加上required后,模型会主动追问:“请问您的订单号是多少?”,把问题拦截在前端。type字段是防止“类型错乱”的防火墙。另一个案例是金融类 Agent,天气工具的city是字符串,而股票工具的symbol也是字符串,但模型有时会把symbol当成数字传{"symbol": 12345}。后端解析时类型不匹配,直接崩溃。显式声明type: "string"后,我们的解析器能在执行前就报错,而不是让错误穿透到下游服务。
这些细节,在面试中说出来,远比写一百行花哨的装饰器更有说服力。Schema 驱动法教会你的,不是怎么写代码,而是怎么用代码定义契约、用契约约束行为、用行为保障稳定。
4. 方案三:注册中心 + 运行时解析法——面向生产的 Tool Use 架构
4.1 当“手写”变成“可运维”:为什么需要第三种方案
前两种方案解决了“能不能跑”和“能不能懂”的问题,但还没解决“能不能管”的问题。想象这样一个场景:你所在的团队同时维护着 15 个 Agent 应用,分别对接天气、股票、日历、邮件、数据库、内部 CRM 等 20 多个工具。每个工具由不同小组开发,API 版本不一,鉴权方式各异(有的用 API Key,有的用 OAuth2,有的走公司统一网关)。此时,如果每个 Agent 都各自维护一份ToolRegistry,就会出现:
- 配置漂移:CRM 工具升级了,A 组更新了 Schema,B 组忘了同步,结果 B 组的 Agent 调用失败;
- 重复造轮子:每个组都写自己的
ToolExecutor,但鉴权逻辑、重试策略、熔断机制几乎一模一样; - 监控盲区:没人知道过去 24 小时哪个工具被调用了多少次、平均耗时多少、错误率多少。
这就是方案三要解决的问题:把 Tool Use 从一个“手写函数”升级为一个“可注册、可发现、可治理”的运行时服务。它的核心思想是“关注点分离”——工具定义(What)、工具执行(How)、工具调度(When/Where)彻底解耦。这不再是面试白板上的几行代码,而是你在简历中可以写“主导设计并落地了公司级 Tool Registry 中心,支撑 15+ Agent 日均 200 万次工具调用”的硬核项目。
4.2 架构全景图:三个核心模块及其协作关系
方案三的架构由三个松耦合模块组成,它们通过标准接口通信,不依赖特定语言或框架:
| 模块 | 职责 | 关键能力 | 面试中可强调的亮点 |
|---|---|---|---|
| Tool Registry(注册中心) | 统一存储和管理所有工具的元数据(Schema、Endpoint、Auth Config、Owner、SLA) | 支持 REST API 查询、Web UI 管理、GitOps 配置同步、变更审计日志 | “我设计了基于 Git 的 Schema 版本管理,每次 Schema 变更自动触发 CI 测试,确保向前兼容” |
| Tool Executor(执行器) | 作为独立服务部署,接收标准化调用请求,负责鉴权、路由、重试、熔断、日志、指标上报 | 内置多种鉴权插件(API Key/OAuth2/JWT)、可配置重试策略(指数退避)、Prometheus 指标暴露 | “我实现了基于令牌桶的 QPS 限流,防止某个 Agent 故障导致下游工具雪崩” |
| Agent Runtime(运行时) | 每个 Agent 应用内嵌的轻量客户端,负责从 Registry 发现工具、构建调用请求、发送给 Executor、解析响应 | 自动缓存 Registry 数据、支持 fallback 工具、调用链路追踪(Trace ID 透传) | “我给 Runtime 加了本地 Schema 缓存,即使 Registry 临时不可用,Agent 也能降级运行 5 分钟” |
这三个模块之间只通过 HTTP 和 JSON 交互,意味着你可以用 Python 写 Registry,用 Go 写 Executor,用 TypeScript 写 Runtime,完全不影响集成。这种设计哲学,正是高级工程师区别于“写代码的人”的关键——他们思考的不是“怎么实现”,而是“怎么组织”。
4.3 核心环节实现:一个可落地的注册中心原型
我们聚焦最关键的Tool Registry模块,用 Node.js + Express 实现一个最小可行原型。它不追求功能完备,但必须体现生产级设计的关键考量:版本控制、权限隔离、变更可追溯。
// registry-server.js const express = require('express'); const fs = require('fs').promises; const path = require('path'); const app = express(); app.use(express.json()); // 工具存储目录(模拟数据库) const TOOLS_DIR = path.join(__dirname, 'tools'); // 初始化工具目录 async function initToolsDir() { try { await fs.access(TOOLS_DIR); } catch { await fs.mkdir(TOOLS_DIR, { recursive: true }); } } // 读取所有工具(支持版本过滤) app.get('/tools', async (req, res) => { try { const { version } = req.query; const files = await fs.readdir(TOOLS_DIR); const tools = []; for (const file of files) { if (!file.endsWith('.json')) continue; const content = await fs.readFile(path.join(TOOLS_DIR, file), 'utf8'); const tool = JSON.parse(content); // 如果指定了 version,只返回匹配的版本 if (version && tool.version !== version) continue; // 注入元数据:文件名即 ID,修改时间即 lastModified tool.id = file.replace('.json', ''); tool.lastModified = (await fs.stat(path.join(TOOLS_DIR, file))).mtime.toISOString(); tools.push(tool); } res.json({ success: true, data: tools }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); // 注册新工具(带版本和 Owner 信息) app.post('/tools', async (req, res) => { try { const { name, version, description, parameters, endpoint, auth, owner } = req.body; // 强制校验:必须有 name、version、description、parameters if (!name || !version || !description || !parameters) { return res.status(400).json({ success: false, error: "缺少必需字段:name, version, description, parameters" }); } // 生成唯一文件名:name-version.json const filename = `${name}-${version}.json`; const filepath = path.join(TOOLS_DIR, filename); // 构建完整工具对象(加入审计字段) const tool = { name, version, description, parameters, endpoint: endpoint || "", auth: auth || { type: "none" }, owner: owner || "unknown", createdAt: new Date().toISOString(), createdBy: req.headers['x-user-id'] || "system" }; await fs.writeFile(filepath, JSON.stringify(tool, null, 2), 'utf8'); res.status(201).json({ success: true, message: `工具 ${name} v${version} 注册成功`, id: filename.replace('.json', '') }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); // 获取单个工具详情(含历史版本) app.get('/tools/:id', async (req, res) => { try { const { id } = req.params; const files = await fs.readdir(TOOLS_DIR); const versions = []; for (const file of files) { if (file.startsWith(`${id}-`) && file.endsWith('.json')) { const content = await fs.readFile(path.join(TOOLS_DIR, file), 'utf8'); const tool = JSON.parse(content); tool.id = file.replace('.json', ''); tool.lastModified = (await fs.stat(path.join(TOOLS_DIR, file))).mtime.toISOString(); versions.push(tool); } } if (versions.length === 0) { return res.status(404).json({ success: false, error: "工具未找到" }); } // 返回最新版本为主数据,其他版本为 history versions.sort((a, b) => b.version.localeCompare(a.version)); const latest = versions[0]; const history = versions.slice(1); res.json({ success: true, data: { latest, history } }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); // 启动服务器 const PORT = process.env.PORT || 3000; initToolsDir() .then(() => { app.listen(PORT, () => { console.log(`Tool Registry 服务启动于 http://localhost:${PORT}`); console.log(`工具目录:${TOOLS_DIR}`); }); }) .catch(console.error);这个注册中心只有 120 行代码,但它包含了生产系统的关键基因:
- 版本化管理:
name-version.json的文件命名规则,天然支持多版本共存和灰度发布; - 审计追踪:
createdAt、createdBy、lastModified字段,让每一次变更都有据可查; - 权限预留:
x-user-id请求头的设计,为后续接入公司统一认证体系留出接口; - 强 Schema 校验:在注册入口就拦截缺失字段,而不是等到运行时才发现。
你可以把它部署在任意一台服务器上,然后让所有 Agent 应用通过GET /tools获取最新工具列表。这才是真正意义上的“手写 Tool Use”——你写的不是一行代码,而是一套可持续演进的基础设施。
4.4 生产级避坑指南:那些只有踩过才懂的细节
在真实项目中落地这套架构,我总结了三条血泪教训,每一条都值得在面试中展开讲:
永远不要相信模型传来的
arguments
即使 Schema 写得再严谨,模型也可能会返回{"city": null}或{"city": 123}。我们的ToolExecutor必须在调用前做二次清洗:对字符串参数 trim 空格,对数字参数 parseInt,对布尔参数 Boolean() 转换。有一次我们没做 trim,用户输入“北京 ”(末尾有空格),导致天气 API 返回 404,而日志里只显示“城市不存在”,排查了 2 小时才发现是空格问题。熔断阈值不是拍脑袋定的
初始我们给所有工具设了统一的 50% 错误率熔断,结果天气工具因为第三方服务不稳定频繁熔断,而内部 CRM 工具却因错误率低一直不熔断,导致故障扩散。后来我们改为按工具 SLA 动态设置:对 P99 < 200ms 的工具,熔断阈值设为 5%;对 P99 > 2s 的工具,设为 20%。这个细节,体现了你对 SRE 思维的理解。Schema 变更必须双写兼容
当我们要给天气工具增加unit参数(支持摄氏/华氏)时,不能直接改旧 Schema。而是先发布weather-v2,让新 Agent 用 v2,旧 Agent 继续用 v1;同时在 Executor 里写兼容逻辑:如果收到 v1 请求,自动补unit: "celsius"。等所有 Agent 迁移完成,再下线 v1。这种渐进式演进能力,是区分玩具项目和工业级系统的试金石。
5. 常见问题与排查技巧实录:从面试现场到线上故障
5.1 面试高频问题速查表:面试官最爱问的 7 个问题
| 问题 | 我的标准回答要点 | 为什么这样答(底层逻辑) |
|---|---|---|
| Q1:为什么不用正则匹配,而要用 if/else? | “正则在手写时易出错,且难以维护。if/else结构清晰,新增工具只需加一个分支,逻辑边界明确,可测试性强。” | 展示工程权衡意识:不盲目追求“酷”,而选择“稳”和“可演进”。 |
| Q2:模型返回的 JSON 格式错了怎么办? | “在ToolExecutor.validateCall()中做三层校验:1. JSON 结构是否合法;2.name是否在注册中心存在;3.arguments是否符合 Schema。任一失败都抛出明确错误,绝不静默失败。” | 强调防御性编程:生产环境里,90% 的故障源于对异常的忽视。 |
| Q3:如何防止工具调用死循环? | “在 Agent Runtime 层设置最大调用深度(如 3 层),并在每次调用时透传trace_id。Executor 记录调用链,发现同一 trace_id 在 1 秒内出现 3 次相同工具调用,自动拒绝。” | 体现系统观:单点优化不如全局 |