1. 从一道面试题说起:为什么“手写 Tool Use”成了高频考点
面试官让我手写一个 Tool Use,我用了 3 种方案。这句话听起来像是一道刁钻的面试题,但它背后折射的是当下大模型应用开发中最核心的一环——让模型学会调用外部工具。不管你是做智能助手、自动化工作流,还是搭建一个能查天气、能算数、能查数据库的对话系统,Tool Use 都是绕不开的基础能力。面试官考这个,不是想看你背 API,而是想看你对“模型如何与外部世界交互”这件事有没有真正的理解。
所谓 Tool Use,说白了就是让语言模型在生成文本的过程中,能够判断“这个问题我需要借助外部函数来解决”,然后输出一个结构化的调用请求,由外部程序执行后再把结果喂回模型,最终生成自然语言回答。它解决的是模型本身“只会说、不会做”的痛点——模型不知道今天天气、算不准复杂数学、查不到实时库存,但它可以调用工具去获取这些信息。
这篇文章适合三类人看:第一类是要准备相关岗位面试的开发者,第二类是想给自己的应用加上工具调用能力的工程师,第三类是单纯想搞明白“模型调工具”底层到底怎么回事的技术爱好者。我会把当时面试现场写的三种方案完整还原出来,从最原始的字符串解析,到基于 JSON Schema 的结构化调用,再到利用模型原生能力的高级方案,每一种都给出可运行的代码、设计理由和实测踩坑记录。
先交代一下背景。当时面试官给的需求很朴素:实现一个函数,输入用户的一句话,模型判断是否需要调用工具,如果需要就返回工具名和参数,如果不需要就直接回答。工具集也很简单,一个查天气的、一个做加法的。听起来不难,但真正动手写,你会发现里面全是细节。
2. 方案一:纯字符串解析,最笨但最能看清本质
2.1 为什么先写这个“原始版本”
很多人一上来就想用现成的框架,但面试官往往更想看你从零搭建的能力。我选择先写字符串解析方案,理由很直接:它能让你彻底看清 Tool Use 的本质就是一次“约定格式的文本生成”。模型输出的不过是一段文本,我们通过约定好的标记去提取工具名和参数,就这么简单。这个方案虽然粗糙,但它是理解后续所有高级方案的基础。
具体做法是:在系统提示词里明确告诉模型,如果你需要调用工具,就按照[TOOL:工具名|参数1=值1|参数2=值2]这样的格式输出。然后我用正则表达式去解析模型返回的文本,提取出工具名和参数,执行对应函数,再把结果拼回对话历史,发起第二轮请求让模型生成最终回答。
import re def parse_tool_call(text): pattern = r"\[TOOL:(\w+)\|(.+?)\]" match = re.search(pattern, text) if not match: return None tool_name = match.group(1) params_str = match.group(2) params = {} for pair in params_str.split("|"): key, value = pair.split("=") params[key.strip()] = value.strip() return {"tool": tool_name, "params": params}2.2 提示词设计的坑:模型不听话怎么办
这个方案最大的问题不是代码,而是模型经常不按格式输出。我实测下来,即使提示词写得再清楚,模型也有大概 15% 到 20% 的概率会自由发挥,比如把方括号写成圆括号、参数分隔符用错、甚至直接用人话描述“我要查天气”而不给结构化格式。这时候正则就匹配不到,整个流程就断了。
我的应对策略是加一层“格式纠正”逻辑:如果第一次解析失败,就把模型的原始输出和格式要求一起再发一次,明确说“你上次的输出格式不对,请严格按照[TOOL:...]格式重新输出”。这个重试机制能把成功率拉到 95% 以上。但代价是多一次请求,延迟增加,token 消耗也上去了。
提示:字符串解析方案在生产环境基本不可用,但它是理解 Tool Use 原理的最佳教具。面试时如果你能主动说出它的缺陷和重试机制,反而比直接上高级方案更能体现思考深度。
2.3 参数类型的隐形陷阱
还有一个容易被忽略的问题:参数类型丢失。模型输出的所有参数都是字符串,但实际工具可能需要整数、浮点数、布尔值。比如加法工具需要两个数字,模型输出a=3|b=5,你拿到的是字符串"3"和"5",直接相加会变成"35"。所以必须做类型转换,而转换规则又得提前约定好,比如在工具定义里标注每个参数的类型。
这个坑我在面试现场就踩了,面试官看着我把"3" + "5"算成"35"的时候笑了一下。后来我补了一个类型映射表,根据工具定义把字符串转成对应类型。这也让我意识到,Tool Use 的核心难点从来不是调用,而是“模型输出”和“程序期望”之间的格式对齐。
3. 方案二:JSON Schema 结构化输出,工程上最稳的路子
3.1 用 JSON 替代自定义标记的理由
方案一跑通之后,我马上转向了 JSON 方案。原因很简单:JSON 是通用的结构化数据格式,解析库成熟,类型表达能力强,而且大多数模型对 JSON 的生成质量远高于自定义标记。我把提示词改成“请输出一个 JSON 对象,包含tool和arguments两个字段”,然后直接用json.loads解析。
import json def parse_json_tool_call(text): try: data = json.loads(text) if "tool" in data and "arguments" in data: return data return None except json.JSONDecodeError: return None这个方案的好处是类型清晰,arguments可以是一个嵌套对象,整数就是整数,布尔就是布尔,不需要额外转换。而且 JSON 的容错性可以通过提示词里的示例来强化,模型看到 few-shot 示例后,输出格式的稳定性明显提升。
3.2 工具定义的标准化:让模型“看懂”每个工具
JSON 方案真正的威力在于工具定义的标准化。我按照 JSON Schema 的规范,为每个工具写了一份描述,包括工具名、功能说明、参数列表、每个参数的类型和是否必填。这份定义会作为系统提示词的一部分发给模型,模型根据这份“说明书”来决定调用哪个工具、传什么参数。
{ "name": "get_weather", "description": "查询指定城市的当前天气", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如北京、上海" } }, "required": ["city"] } }这里有个关键经验:工具描述的质量直接决定调用准确率。我试过把description写得很简略,结果模型经常把“查天气”和“查温度”搞混。后来我把描述写得更具体,甚至加上“当用户询问某地天气状况时使用此工具”这样的触发条件说明,准确率立刻上来了。这其实就是提示词工程在 Tool Use 里的具体体现。
3.3 多工具场景下的选择逻辑
当工具数量超过一个时,模型需要先做“工具选择”。JSON 方案下,我把所有工具定义放在一个数组里发给模型,模型输出的 JSON 里tool字段就是它选中的工具名。实测下来,只要工具描述足够清晰、彼此职责不重叠,模型的选择准确率很高。
但有个坑:工具数量多了之后,提示词会变得很长,token 消耗大,而且模型在长列表中做选择的准确率会下降。我的做法是加一层“工具预筛选”,根据用户输入的关键词先缩小候选工具范围,再让模型在缩小后的列表里选。这个优化在工具超过 10 个之后效果非常明显。
| 对比项 | 字符串解析方案 | JSON Schema 方案 |
|---|---|---|
| 格式稳定性 | 差,需重试 | 好,few-shot 后很稳 |
| 类型支持 | 需手动转换 | 原生支持 |
| 工具定义 | 混在提示词里 | 标准化 Schema |
| 多工具选择 | 容易混淆 | 清晰可控 |
| 生产可用性 | 低 | 高 |
4. 方案三:借力模型原生 Tool Use 能力,最省心的选择
4.1 原生能力到底做了什么
前两个方案都是“我在教模型怎么输出”,而方案三是“模型本身就支持这么输出”。现在主流的大模型 API 大多提供了原生的工具调用能力,你只需要在请求里传入工具定义,模型会在需要时返回一个结构化的工具调用对象,包含工具名和参数,而且这个对象是 API 层面保证格式正确的,不需要你自己解析字符串。
response = client.chat.completions.create( model="your-model", messages=messages, tools=tool_definitions, tool_choice="auto" ) tool_call = response.choices[0].message.tool_calls[0] print(tool_call.function.name) print(tool_call.function.arguments)这个方案最大的好处是格式由 API 保证,你拿到的一定是合法的结构化数据,不用写正则、不用处理解析失败、不用做类型转换。而且模型在训练阶段就见过大量工具调用的样本,对“什么时候该调工具”的判断也更准。
4.2 多轮调用的状态管理
原生方案下,多轮工具调用的流程是这样的:第一轮模型返回工具调用请求,你执行工具拿到结果,把结果以特定角色(通常是tool角色)追加到消息历史里,再发起第二轮请求,模型根据工具结果生成最终回答。这个流程需要你维护好消息历史,确保每一轮的工具调用和结果都正确对应。
我踩过的一个坑是:工具执行失败时怎么处理。如果工具抛异常,你不能直接把异常信息塞回去,否则模型可能会困惑。我的做法是把错误信息包装成结构化的结果,比如{"error": "城市名称无法识别"},让模型知道调用失败了,它可以选择重试或者告诉用户。这个细节在面试时被面试官追问了,说明它确实是实际开发中的关键点。
4.3 三种方案的适用边界
把三种方案放在一起看,它们的适用场景其实很清晰。字符串解析适合教学和理解原理,或者在一些极端受限、无法使用高级 API 的环境里做降级方案。JSON Schema 方案适合需要精细控制、或者模型本身不支持原生工具调用的情况,它的灵活度最高。原生方案适合生产环境,省心省力,但依赖特定 API 的能力。
注意:原生工具调用虽然方便,但它把“工具选择”的决策权完全交给了模型。如果你的业务对工具调用有严格的权限控制或审计要求,可能需要在原生方案外面再包一层校验逻辑,确保模型不会调用不该调用的工具。
5. 面试现场的真实追问与我的应对
5.1 “如果模型调用了不存在的工具怎么办”
这是面试官在我写完三种方案后抛出的第一个问题。我的回答是分两层:第一层是预防,在工具定义阶段就确保工具名唯一且清晰,减少模型幻觉出不存在工具的概率;第二层是兜底,在解析出工具名后先做一次校验,如果工具名不在注册表里,就返回一个错误提示给模型,让它重新选择。原生方案下,API 通常会限制模型只能从传入的工具列表里选,所以这个问题相对小一些,但自己实现时一定要有校验。
5.2 “工具执行很慢,怎么优化用户体验”
这个问题考的是工程思维。我的思路是:对于可以并行执行的工具调用,让模型一次性返回多个工具调用请求,然后并发执行,减少总等待时间。另外,对于耗时较长的工具,可以先给用户一个“正在查询”的反馈,等结果回来再更新。原生方案支持模型在一次响应里返回多个tool_calls,这个特性要利用起来。
5.3 “怎么保证工具调用的安全性”
安全性是 Tool Use 绕不开的话题。我的做法是:所有工具调用都经过一层权限校验,根据当前用户的身份和上下文决定哪些工具可用;工具的参数要做合法性检查,防止注入类问题;对于有副作用的工具(比如写数据库、发消息),要加确认机制,不能模型说调就调。这几点在面试时说出来,面试官明显比较认可。
6. 从面试题到生产系统:我总结的几条实战经验
6.1 工具描述要像写给新人看的文档
我后来在真实项目里最大的体会是:工具描述的质量比模型能力更重要。你把工具描述写得越清楚、越具体,模型调用得越准。我现在的习惯是,每写一个工具,都假设是一个刚入职的新人来看这份说明,他能不能只看描述就知道什么时候该用、参数怎么传。如果描述里有歧义,模型一定会踩坑。
6.2 永远要有降级和重试路径
不管用哪种方案,都要假设“模型可能输出不符合预期”。字符串方案要重试,JSON 方案要处理解析失败,原生方案要处理工具执行异常。我的经验是,至少准备一条降级路径:当结构化调用失败时,退回到让模型用自然语言回答,虽然功能弱了,但至少不会让整个流程崩掉。
6.3 日志和可观测性不能省
Tool Use 的调试比普通对话难得多,因为中间多了工具执行这一环。我在项目里会把每次工具调用的请求、参数、结果、耗时都记下来,出问题的时候能快速定位是模型选错了工具、参数传错了,还是工具本身执行失败了。这个习惯帮我省了大量排查时间。
6.4 参数校验要前置
模型传的参数不能直接信。我现在的做法是,在工具执行前先用 JSON Schema 做一次参数校验,类型不对、必填项缺失、取值范围越界,都在这一步拦下来,返回明确的错误信息给模型。这样既安全,又能让模型根据错误信息自我纠正。
三种方案写下来,我最大的感受是:Tool Use 看起来是“让模型调函数”,实际上考的是你对模型输出不确定性的管理能力。字符串方案教你理解本质,JSON 方案教你工程化,原生方案教你借力。面试官想看的,从来不是你用了多高级的 API,而是你能不能把这条链路想清楚、写扎实、兜住底。