面试官让我手写一个 Tool Use,我用了 3 种方案
这两年 Agent 方向火成什么样,不用我多说。出去面试十个岗位里至少八个都在问函数调用和工具使用,很多面试官甚至不聊理论,直接扔一句“你现在手写一个 Tool Use,给你半小时”。我一开始以为他们在考 prompt 模板,后来发现完全不是那么回事——他们看的是你对“大模型如何连接外部世界”这条链路有没有真正吃透。
我前前后后参加过不少这类面试,也帮团队出过类似的笔试题。这么多次下来,我慢慢琢磨出三套手写 Tool Use 的思路:第一套是 JSON Schema 声明加函数路由表,最老实也最容易讲清楚;第二套是正则加模板解析,适合模型不太支持稳定输出 JSON 的场景,几乎零依赖;第三套是自写一个极简 DSL 做多工具编排,这已经不只是单次调用,而是在往 Agent 的调度层走。这篇文章就把这三套方案完整复盘一遍,从设计思路到核心代码再到踩坑点都写清楚。正在准备 Agent 方向面试的人可以直接拿去当复习提纲,想在自己项目里实现工具调用的开发者也能照着落地。
1. 面试官到底在考什么:Tool Use 的核心链路拆解
先说一个很多新人容易误解的地方。Tool Use 听起来像是个 API,实际上它是一整套工程机制。大模型本身就是个“纯文本进出”的系统,它能理解你问它“北京明天天气怎么样”,但它既不能访问天气服务,也不知道此刻是什么时间,更没法替你去订个票。Tool Use 解决的就是这个断层:模型先决定“我需要调用某个工具”,然后把工具名和参数以结构化形式吐出来,你的程序收到之后去执行真实函数,再把执行结果回填给模型,让模型基于真实结果继续生成回答。
这整个流程拆开来就四个环节:意图识别、工具路由、参数抽取、执行回填。
- 意图识别:模型判断当前用户问题是不是需要外部工具,比如“查天气”“算个表达式”“搜一下文档”。
- 工具路由:模型从你给的工具清单里选一个合适的,这个动作可能是输出一个函数名,也可能是一个 JSON 里的
tool_name字段。 - 参数抽取:模型把你自然语言里的信息转成工具要求的入参,比如“明天北京”转成
{"city": "beijing", "date": "2025-01-15"}。 - 执行回填:你的程序真正调用函数,拿到返回值之后,把结果拼成一段文本再次发给模型,让模型组织最终答案。
面试官让你手写 Tool Use,本质上不是考你能不能完整实现这四个环节,而是看你能不能把“模型输出”这个不稳定因素用工程手段约束住。你要让模型乖乖输出你能解析的结构,你的解析器还得能容忍模型偶尔抽风。这比单纯写几个if...else要难得多。
我在面试里最喜欢问候选人的一句话是:“如果模型返回的工具名不在你的注册表里,你怎么办?”很多人第一反应是报错,但真正合理的做法是先把错误信息回填给模型,让模型自己修正。这个细节恰恰能区分有生产经验的人和只会跑 demo 的人。所以我建议你面试前别光背概念,把四步链路每一步的异常分支都想清楚,面试官追问的时候你就有话可说。
2. 方案一:JSON Schema 声明加函数路由表,最经典的手写范式
2.1 整体设计思路:让模型按“填空题”输出
第一个方案是业界最常见的做法,本质上就是迫使用户按结构化格式输出。具体来说,你为每个工具写一份 JSON Schema,描述它叫什么、需要哪些参数、每个参数是什么类型、哪些必填哪些可选。然后你把这些 Schema 拼到系统提示词里,明确告诉模型:“需要调用工具时,输出一个 JSON,格式必须是{"tool": "get_weather", "args": {"city": "北京"}},只输出 JSON,不要输出解释。”
这套思路的优势在于,大模型对 JSON 的接受度极高。现在的模型大多经过海量代码和结构化数据的训练,你只要给出明确的格式示例,它对 JSON 的遵循能力相当稳定。我在单测里验证过,对一些常见的天气、计算、查询类工具,格式合规率能到 95% 以上;就算偶尔出错,后面再加一层容错处理也完全够用。
面试的时候,我会把代码写在白板上,通常从目录设计开始讲起。不要上来就写逻辑,先让面试官看到你有工程分层能力,这本身就是个印象分。
2.2 目录规划与核心代码实现
我习惯把一个完整的 Tool Use 模块拆成四个文件:
schemas/:存放每个工具的 JSON Schema,一个工具一个文件,或者集中在一个tool_schemas.py/tool_schemas.ts里也可以。functions/:真实的工具实现,比如get_weather()、calculate()、search_docs()。router.py:函数路由表,把“字符串工具名”映射到“真实函数”。executor.py:核心执行器,负责把模型输出解析成结构化指令、调用路由、回填结果。
以 TypeScript 为例,我会先把工具定义集中写在一起:
const TOOL_SCHEMAS = [ { type: "function", function: { name: "get_weather", description: "查询指定城市当前天气", parameters: { type: "object", properties: { city: { type: "string", description: "城市名,如:北京" }, date: { type: "string", description: "日期,格式 YYYY-MM-DD,可省略" } }, required: ["city"] } } }, { type: "function", function: { name: "calculator", description: "执行四则运算计算", parameters: { type: "object", properties: { expression: { type: "string", description: "数学表达式,如 (1+2)*3" } }, required: ["expression"] } } } ];然后是函数路由表:
const toolRegistry: Record<string, Function> = { get_weather: async (args: any) => { // 这里调用真实的天气 API return `北京今日多云,气温 -2℃~6℃`; }, calculator: (args: any) => { // 生产环境不建议直接用 eval,这里仅作示例 return String(eval(args.expression)); } };最后是执行器的核心逻辑:
async function handleToolCall(modelOutput: string) { let parsed; try { parsed = JSON.parse(modelOutput); } catch (e) { // 模型可能夹带了多余文本,尝试从花括号处截取 const start = modelOutput.indexOf("{"); const end = modelOutput.lastIndexOf("}"); parsed = JSON.parse(modelOutput.substring(start, end + 1)); } const { tool, args } = parsed; if (!toolRegistry[tool]) { // 工具不存在时,把错误信息还回给模型,让它换个工具或修正名字 return `工具 ${tool} 不存在,可用工具:${Object.keys(toolRegistry).join(", ")}`; } const result = await toolRegistry[tool](args || {}); return result; }这段代码看着简单,其实已经覆盖了两个关键点:第一是 JSON 解析失败时的容错处理,我直接从第一个{截到最后一个},能救回大量“模型在 JSON 外面多说了半句话”的情况;第二是未注册工具的处理,我没有抛异常中止会话,而是把可用的工具列表回给模型,让模型自己修正,这比直接报错友好得多。
2.3 参数校验与重试回退机制
一个坑我提醒新手注意:JSON Schema 写了归写了,但模型未必每次都老老实实按类型传参。它可能把数字42传成字符串"42",把必填的city漏掉,或者在数组和对象类型上自由发挥。所以执行器里一定要做一层参数清洗。
我给参数清洗定义了三个等级:
- 第一级,补齐默认值。
date这种可选参数没传,就用new Date()补上。 - 第二级,类型强制转换。字符串数字
"42"转成整数,"true"转成布尔。 - 第三级,校验失败触发二次尝试。如果必填参数确实缺失,就把缺失列表反馈给模型,要求它只输出修正后的 JSON,重新提交一次。
重试机制在面试里很加分,因为你展示了对“模型输出不可控”这个现实有清醒的认知。单纯解析一次能跑通的 demo 只能说明样例简单,重试回退才能说明你在生产环境摸爬滚打过。
3. 方案二:正则加模板解析,零依赖的轻量 Tool Use
JSON Schema 方案好用,但它有一个前提:模型得能稳定输出结构化 JSON。有些场景不满足这个前提,比如你用的是某个开源小模型,指令遵循能力一般,你让它在对话里输出一个 JSON,它偏要在 JSON 前后加“好的,我来帮你查询”这种废话;又比如目标部署环境网络受限,你没法依赖在线模型的服务端函数调用能力,只能通过纯文本模板做解析。
这时候第二套方案就派上用场了。它的思路完全反过来:不用模型去学会“输出 JSON”,而是你来定义一个足够简单的模板格式,让模型往里填内容。格式越简单,模型越不容易出错。
3.1 模板定义:宁可啰嗦,不能含糊
我常用的模板是方括号指令:
[TOOL: get_weather] city = 北京 date = 2025-01-15 [/TOOL]让模型在需要调用工具时,严格按照这个模板输出,其他时候正常说话。这个格式比 JSON 简单得多,没有引号、没有花括号、没有逗号,模型理解成本低,生成时也不容易断路。
解析逻辑用正则一行核心就够:
import re def parse_tool_call(text: str) -> list[dict] | None: pattern = re.compile( r"\[TOOL:\s*(\w+)\](.*?)\[/TOOL\]", re.DOTALL ) matches = pattern.findall(text) if not matches: return None results = [] for tool_name, body in matches: args = {} for line in body.strip().split("\n"): if "=" in line: key, val = line.strip().split("=", 1) args[key.strip()] = val.strip() results.append({"tool": tool_name, "args": args}) return results这套解析的逻辑非常简单:re.findall找到所有工具调用块,再把块内的key = value逐行解析成字典。整个模块没有第三方依赖,标准库里的re就够跑起来,非常适合做边缘部署。
3.2 正则解析的三个藏坑点
正则模板方案看着简单,但有几个地方不处理会踩坑。
第一个坑是点号匹配。如果工具参数值里含中文、空格或者空行,你的正则必须用.*?并且加re.DOTALL标志,否则换行处直接截断。我第一次实现时没加DOTALL,结果只要参数分成两行就解析失败,调了半天才发现是点号默认不匹配换行符。
第二个坑是参数值本身包含等号或换行。比如用户让模型搜索的问题是“什么是等价交换”,解析时question = 什么是等价交换没问题,但如果是“配置 a=b 的服务器”,就容易被split("=", 1)逻辑切错。我的对策是约定模板里参数值不换行,这属于格式契约,不依赖模型智能,模型照着示例生成就不会错。
第三个坑是模板的幂等性。模型有时候会在工具调用块前后重复解释,比如“我查询到北京天气如下:[TOOL: get_weather]...”,解析的时候不受影响,因为findall只找模板块。但我遇到过模型把模板嵌套两层,这在正则里会匹配到错误边界。我的方案是解析后检查参数数量是否符合预期,异常就触发重试。
3.3 为什么还需要模板方案:给 Option A 兜底
你可能会问:既然方案一更主流,我为什么还要准备方案二?
因为面试官问问题从来不按套路出牌。我遇到过一位面试官,直接说:“如果我们的模型连 JSON 都不稳定输出,你还有什么办法?”这个问题如果你脑海里只有一套方案,就会愣住。但如果你能顺着思路讲:先降级成模板,再降级成多轮对话引导,就能体现出你在有限条件下设计方案的能力。
模板方案的另一个实际场景是离线推理。有些私有化部署的模型跑在 CPU 集群上,你没法要求它做一个高难度的格式化生成,但填一个简单的模板几乎样样模型都行。我自己用这套逻辑在某个对话 Demo 里支持过 5 个内部工具,线上跑了两个多月,解析成功率稳定在 99% 以上——当然,这跟模板足够简单、模型指令遵循要求低有很大关系。
4. 方案三:极简 DSL,自己做工具编排调度
前两套方案都只解决“一次调用”的问题,也就是用户问一句,模型决定调一个工具,程序执行完直接把结果返回。但真正的 Agent 场景复杂得多:用户问“帮我看看北京天气,如果下雨就提醒我带伞”,这需要先调天气工具,再根据结果决定要不要走提醒分支。这种多步编排如果全写在 prompt 里让模型自己规划,结果往往不可控。
这就是第三套方案的价值。我用一套极简 DSL 去定义工具之间的串联关系,让模型只需要输出“步骤式”的计划,由你的调度器按计划执行。换句话说,你把“编排”这件事从模型手里接管过来,模型只负责决策,你的代码负责执行和控制流。
4.1 DSL 设计:管道符号是第一概念
我定义的 DSL 核心概念是“管道”。每一行是一步:
get_weather(city="北京") -> weather_result check_rain(weather=weather_result) -> rain_status if rain_status == "有雨": notify_user(message="记得带伞")VN 风格容易想复杂,我实际用的语法更朴素,核心支持三个操作:
- 工具调用:
tool_name(param=value, ...) -> 变量名,把调用结果存进变量。 - 变量引用:下一步的参数可以用
$变量名引用前序结果。 - 条件分支:极简
if判断,决定要不要继续执行后续步骤。
模型输出的是这个派生语言的一行行指令,你写一个解释器逐行执行。这样做最大的收益是:模型不需要“想清楚所有分支”,它只负责把当前看到的那一步写出来,你作为调度器按计划执行,执行结果再回填,模型看到真实结果再决定下一步。
4.2 调度器的实现:变量表加执行栈
调度器也不算复杂,核心就是两个数据结构:一个变量表scope存所有已执行步骤的返回值,一个执行栈存待执行的步骤列表。每执行一行,就把产物塞进scope,给后面步骤引用。
我用伪代码描述核心:
def execute_plan(steps): scope = {} for step in steps: if step["type"] == "tool": tool_name = step["tool"] args = { k: resolve(v, scope) for k, v in step["args"].items() } result = toolRegistry[tool_name](**args) scope[step["output_var"]] = result elif step["type"] == "condition": result = evaluate(step["expr"], scope) if not result: # 跳过整个分支块 skip_branch(step, steps) return scope关键在于resolve函数。它接收v,如果发现v以$开头,就当作变量名从scope里取值;否则当作字面量。这个设计很轻,但能表达“天气查询结果传给降雨判断”这种依赖关系。条件分支则用evaluate做简单的布尔判断,比如判断返回文本里是否包含“雨”字。
4.3 模板方法让模型学会 DSL
问题来了:开源模型能学会输出 DSL 吗?我一开始也担心语法太多模型记不住。
我的做法是“少给规则,多给示例”。在系统提示词里不写长长的语法说明,而是给一个完整的多步对话示例:
用户:明天北京会下雨吗?我要不要带伞? 助手:
get_weather(city="北京", date="tomorrow") -> w check_rain_report(weather="$w") -> r if r == "有雨": remind_umbrella(message="明天北京有雨,记得带伞")模型看到这种“一问一答”的示例,模仿能力通常比理解抽象语法强得多。实际测试中,GPT 级别的模型几乎一遍就学会;中小尺寸模型可能需要 2~4 个注入示例才能稳定,但只要示例覆盖到位,多步调用的成功率也能到 90% 以上。
4.4 为什么控制在极简能力以内,别追求完整编程语言
有人会想,既然可以做 DSL,那不如干脆让模型输出 Python 代码或者 SQL,执行完直接拿到结果。我在生产环境试过这类方案,结论是:看起来很美好,实际坑非常多。
让模型自由输出代码就意味着你得处理安全执行的问题。你不可能在自己的服务器上用eval跑模型生成的代码,那等于把后门敞开了。就算你做了沙箱,代码一旦编译不过或者运行超时,整个 Agent 会话就卡死在那里。相比之下,DSL 的语法极简,适配器做一层白名单校验,解释器只执行你定义好的几个操作,天然不存在任意代码执行的风险。
所以我给第三套方案的定位是:极简、符合直觉、安全可控。它不一定能覆盖极其复杂的工具编排,但它正确解决了生产环境里“可解释、可回滚、可审计”的硬性要求。面试时我讲这个方案,重点不是 DSL 有多强大,而是我清楚模型生成自由文本的危险边界,并主动在工程层做了约束——这种安全边界意识在 Agent 方向尤其重要。
5. 三套方案横向对比,以及面试里的加分表达
三种方案都写完,面试官通常会让你对比取舍。这时候别只说“方案一最好”,要讲清楚各自的适用边界。
| 维度 | 方案一 JSON Schema | 方案二 正则模板 | 方案三 DSL 编排 |
|---|---|---|---|
| 依赖 | 依赖模型稳定输出 JSON | 几乎零依赖,纯文本解析 | 需要自研极简解释器 |
| 适用模型 | 指令遵循能力强的主流模型 | 中小开源模型、离线部署 | 需要多步串联的 Agent 场景 |
| 复杂度 | 中,主要是 Schema 维护 | 低,一个正则加循环 | 中高,需考虑作用域和控制流 |
| 扩展性 | 每加一个工具写一份 Schema | 扩展性弱,参数类型单一 | 可表达多步依赖和条件分支 |
| 容错重点 | JSON 解析失败、参数类型漂移 | 模板嵌套、参数值含等号 | 变量引用不存在、分支死循环 |
面试时我会这么组织回答:先说我通常首选方案一,因为它是行业标准,各大模型厂商的 function calling 接口基本都基于类似思路,理解它意味着你上手真实 SDK 没有陌生感。然后补一句:如果目标模型输出不稳定或者部署环境受限,我会降级到方案二,保证最低限度的可用性。如果需求扩展到了 Agent 级多工具编排,我会升级到方案三,把控制流从模型手里夺过来,给我的执行引擎足够的确定性。
这个递进的表达方式非常重要。它展示的不仅是你知道三种代码怎么写,更展示了你具备按场景选型的能力,也就是架构师视角。面试官普遍喜欢这种“我有完整工具箱,且知道哪把钥匙开哪把锁”的候选人。
还有一个加分析的点:主动聊测试和评估。我会跟面试官说,三套方案我都配合着一组离线用例做回归,用例覆盖正常调用、参数缺失、工具不存在、模型输出废话文本这四类情况。不是为了证明代码没 bug,而是为了在模型升级之后能快速判断新版本是否改变了输出格式。有这种评估意识,比你多写十个工具函数更让面试官印象深刻。
6. 手写 Tool Use 的避坑清单:我把最常见的坑都列出来
最后这部分是我实际写代码、跑线上服务时真实踩过的坑,做一个速查清单。面试时能把这些细节随口讲出来,非常加分,因为这是刷题刷不来的经验。
第一个高频坑:JSON 解析时模型输出前后夹杂自然语言。我在线上统计过,即使指令要求“只输出 JSON”,仍有约 3%~5% 请求会在 JSON 前加一句“调用天气工具的结果如下:”。处理办法就是前文提过的:从前第一个{截到最后一个}再解析。注意一定要用lastIndexOf而不是indexOf,因为参数值里可能嵌套了对象或数组。
第二个高频坑:Schema 里的参数名称与代码入参不一致。比如你在 Schema 里写了city_name,代码函数里参数名却是city,执行时传错参数会直接报错。我建议加一层参数名映射配置,Schema 层叫city_name,代码层收到后统一转成city,或者干脆 Schema 和代码保持一致的命名规范,否则维护起来很痛苦。
第三个高频坑:工具执行超时导致整个对话卡死。如果天气 API 响应要 5 秒,而你的 Agent 前端只有 3 秒超时,用户就会看到转圈圈。我一般在执行器外层包一个Promise.race或者显式设置超时,超时后就回一句“该工具暂时不可用”,让模型可以换一种方式回答,而不是空等待。
第四个高频坑:参数类型被模型“口语化”。模型可能把2025-01-15输出成“明天”,把1000输出成“一千”。我在参数清洗阶段会用规则去处理常见单位的文本型数字,必要时提示模型“日期必须是 YYYY-MM-DD 格式”。这类格式强约束需要在 prompt 里反复出现,并且配合重试机制。
第五个高频坑:没有给工具设计“错误返回协议”。真实业务里工具返回往往是{code: 500, msg: "上游接口异常"}而不是纯文本。如果你直接把整个对象回填给模型,模型可能对错误信息产生误读。我建议工具层统一把错误折叠成一句话:“查询天气失败,原因:服务方返回 500”。这样模型就不会自己脑补一个天气结果。
一口气写了这么多,做个简单的个人总结:手写 Tool Use 真的不难,难的是在设计时就把模型的不可控性考虑进去。我面试时通常按“方案一打底,方案三秀深度,方案二作防御问答”的节奏来表现,效果一直不错。如果你正在准备 Agent 方向的面试,别只背函数调用的概念,拉一台机器亲手把这三套方案都跑一遍,你会对“模型输出不可信,工程来兜底”这句话有真正的体感。