1. 面试现场:从“八股文”到“手写 Tool Use”的转变
1.1 为什么 Tool Use 成了面试新宠
这两年技术面试的风向变得特别快。前几年大家还在卷 Transformer 的注意力公式怎么推导、BatchNorm 在训练和推理阶段的区别这些“八股文”,现在面试官直接把电脑推到你面前,说:“来,手写一个 Tool Use 的调度逻辑,三种方案,给你四十分钟。”
我第一次遇到这种题的时候,脑子里第一反应是:这东西平时不都是调 API 吗?tools参数往里一塞,模型自己就返回tool_calls了,有什么好手写的?但面试官接下来的话让我意识到事情没那么简单——“假设你现在没有现成的 SDK,底层只有最原始的文本生成接口,你怎么让模型学会调用工具?”
这个问题一下子把难度拉到了原理层面。Tool Use,或者叫 Function Calling,本质上解决的是一个非常核心的问题:如何让一个只会输出文本的模型,去触发外部世界的动作。模型本身没有手没有脚,它不能查天气、不能读数据库、不能发邮件,它只能输出 token。Tool Use 就是在这两者之间架一座桥。
面试官考察的从来不是你背没背过某个 API 的调用方式,而是你对这座桥的理解深度。桥怎么设计、怎么保证模型走对方向、走错了怎么兜底,这些才是真正区分“会用”和“懂”的地方。
1.2 三种方案的核心差异与适用边界
在面试场景下,面试官通常期待你从三个层次来回答这个问题,分别对应不同的抽象级别和工程复杂度。
第一种是基于提示词工程的纯文本协议方案。核心思路是:不依赖任何模型原生的 function calling 能力,完全通过 system prompt 定义一套文本格式,让模型按照格式输出工具调用请求,然后由外部代码解析并执行。这种方案最原始,但也最能体现你对协议设计的理解。
第二种是基于模型原生能力的结构化输出方案。现在主流的大模型平台基本都支持 JSON Schema 约束下的结构化输出,模型会直接返回一个符合 schema 的 JSON 对象,里面包含工具名和参数。这种方案依赖模型本身的能力,工程上最省事,但面试官会追问:如果模型不按 schema 输出怎么办?如果工具数量超过一百个怎么办?
第三种是基于多轮对话状态机的编排方案。这种方案把工具调用看作一个状态流转的过程:用户输入 -> 模型决策 -> 工具执行 -> 结果回填 -> 模型再决策,直到模型认为不需要再调用工具为止。这种方案最接近生产环境的真实形态,也是面试官最喜欢深挖的方向。
三种方案没有绝对的优劣,关键在于你能否说清楚每种方案的适用场景和代价。面试官想听的不是“哪个最好”,而是“在什么条件下选哪个”。
2. 方案一:纯文本协议下的提示词工程实现
2.1 协议设计的核心原则
当你没有模型原生 function calling 能力时,唯一能依靠的就是提示词。这时候你需要设计一套人机都能读懂的文本协议。听起来简单,但实际做起来坑非常多。
我试过好几种协议格式,最后总结出几条设计原则。第一,分隔符必须唯一且不易混淆。你如果用:或者,做分隔,模型很可能在参数值里也输出这些符号,导致解析失败。我一般用|||或者<<< >>>这种多字符组合,冲突概率极低。
第二,工具描述必须包含足够的语义信息。你不能只写“工具名:get_weather,参数:city”,模型根本不知道这个工具是干嘛的、什么时候该用。你得写清楚:“当用户询问某个城市的天气情况时,调用 get_weather 工具,参数 city 为城市名称字符串。”
第三,必须给出明确的输出格式示例。模型是模仿大师,你给它一个完整的输入输出示例,它照着做的准确率会高很多。示例要覆盖正常调用、不需要调用、参数缺失这几种情况。
下面是我在面试现场写的一个简化版协议定义:
你可以使用以下工具: 工具名:get_weather 功能:查询指定城市的实时天气 参数:city (string, 必填) - 城市名称 调用格式:<tool_call>{"name": "get_weather", "arguments": {"city": "城市名"}}</tool_call> 工具名:search_flight 功能:查询两个城市之间的航班信息 参数:from_city (string, 必填), to_city (string, 必填), date (string, 必填, 格式YYYY-MM-DD) 调用格式:<tool_call>{"name": "search_flight", "arguments": {"from_city": "出发城市", "to_city": "到达城市", "date": "日期"}}</tool_call> 当用户的问题需要调用工具时,请输出上述格式的 tool_call 标签。 当不需要调用工具时,直接回答用户问题。这套协议的关键在于:用 XML 风格的标签包裹 JSON 内容。标签让外部代码可以精准定位工具调用片段,JSON 让参数解析变得标准化。两者结合,既保证了可解析性,又保证了可读性。
2.2 解析器的容错设计
协议定好了,接下来是解析器。面试官在这个环节一定会问:“如果模型输出的格式不标准怎么办?”这个问题非常现实,因为模型不是机器,它会有各种奇怪的输出。
我踩过的坑包括:模型把tool_call拼成toolcall、JSON 里用了单引号、参数值里带了换行符、标签没有闭合、一次输出了多个 tool_call 但格式不一致。这些问题在真实环境里出现的频率远比你想象的高。
所以解析器必须做多层容错。我的做法是分三步走:
第一步,正则粗提取。用宽松的正则表达式把可能包含工具调用的片段全部抓出来,不要求标签完全匹配。比如<tool_?call>这种写法就能同时匹配tool_call和toolcall。
第二步,JSON 修复。把抓出来的内容尝试用标准 JSON 解析,失败的话依次尝试:替换单引号为双引号、去除尾随逗号、补全缺失的括号、转义控制字符。这些修复逻辑不需要很完美,能覆盖百分之九十的常见错误就够了。
第三步,参数校验。解析出来的参数对象要和工具定义做比对,检查必填参数是否缺失、参数类型是否正确。如果校验失败,不要直接报错,而是把错误信息回填给模型,让它重新生成。
import re import json def parse_tool_calls(text): pattern = r'<tool_?call>(.*?)</tool_?call>' matches = re.findall(pattern, text, re.DOTALL) results = [] for match in matches: cleaned = match.strip() try: obj = json.loads(cleaned) except json.JSONDecodeError: cleaned = cleaned.replace("'", '"') cleaned = re.sub(r',\s*}', '}', cleaned) cleaned = re.sub(r',\s*]', ']', cleaned) try: obj = json.loads(cleaned) except json.JSONDecodeError: continue if "name" in obj and "arguments" in obj: results.append(obj) return results这段代码看起来简单,但每一步都是血泪教训换来的。尤其是那个re.DOTALL标志,不加的话,如果 JSON 里有换行,正则根本匹配不到。
2.3 提示词工程的实操心得
纯文本协议方案最大的优势是通用性——只要模型能输出文本,这套方案就能跑。但它的劣势也很明显:稳定性完全取决于模型的理解能力。
我在实际项目里总结了几条提升稳定性的技巧。第一,把工具定义放在 system prompt 的最前面,不要塞在中间或者最后。模型的注意力对开头和结尾的内容更敏感,工具定义放前面,调用准确率会高一些。
第二,用 few-shot 示例锚定输出格式。不要只给一个示例,给三个:一个正常调用、一个多工具调用、一个不需要调用的。模型看到这三种情况,边界判断会清晰很多。
第三,温度参数调低。工具调用是一个确定性任务,不需要创造力。温度设成 0 或者 0.1,输出格式的稳定性会显著提升。
第四,给模型一个“思考”的机会。在协议里加一个<thought>标签,让模型先输出它为什么要调用这个工具,再输出 tool_call。这个思考过程不参与实际执行,但能显著提升工具选择的准确率。原理很简单:模型在生成思考内容时,相当于做了一次链式推理,后续的工具选择会更合理。
注意:纯文本协议方案在工具数量超过二十个之后,准确率会明显下降。因为 system prompt 会变得非常长,模型在长上下文中定位正确工具的能力会衰减。这时候需要考虑方案二或者方案三。
3. 方案二:结构化输出与 Schema 约束
3.1 JSON Schema 的设计要点
如果你的模型支持结构化输出,那事情会简单很多。你只需要定义一个 JSON Schema,模型就会按照这个 schema 来生成输出。但“简单”是相对的,schema 设计不好,照样一堆问题。
我见过很多人把 schema 写成这样:
{ "type": "object", "properties": { "tool": {"type": "string"}, "params": {"type": "object"} } }这种 schema 等于没写。tool可以是任何字符串,params可以是任何对象,模型输出什么你都只能照单全收。正确的做法是用 enum 约束工具名,用 oneOf 约束参数结构。
{ "type": "object", "properties": { "tool_name": { "type": "string", "enum": ["get_weather", "search_flight", "send_email"] }, "arguments": { "oneOf": [ { "type": "object", "properties": { "city": {"type": "string"} }, "required": ["city"] }, { "type": "object", "properties": { "from_city": {"type": "string"}, "to_city": {"type": "string"}, "date": {"type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$"} }, "required": ["from_city", "to_city", "date"] } ] } }, "required": ["tool_name", "arguments"] }这样写的好处是:模型在生成时就被约束在合法的工具名和参数结构里,外部代码几乎不需要做额外的校验。enum限制了工具名的取值范围,oneOf保证了参数结构和工具名之间的对应关系,pattern甚至能约束日期的格式。
但这里有一个面试官特别喜欢追问的点:如果工具数量很多,enum 会变得非常长,怎么办?这是一个真实的工程问题。当工具有上百个时,把所有工具名塞进一个 enum 里,schema 会变得巨大,模型的生成质量反而会下降。
我的解法是分层路由。先让模型选择一个工具类别,再在类别内部选择具体工具。比如第一层 schema 的 enum 是["weather", "travel", "communication"],模型选了travel之后,第二层 schema 的 enum 才是["search_flight", "book_hotel", "rent_car"]。这样每层的 enum 都控制在十个以内,准确率有保障。
3.2 结构化输出的边界处理
结构化输出听起来很美好,但实际用起来有几个边界情况必须处理。
第一个是模型拒绝输出。有些模型在遇到不确定的情况时,会输出一个空对象或者直接报错。这时候你需要有一个 fallback 逻辑,把控制权交还给用户,或者触发一次重试。
第二个是嵌套结构过深。JSON Schema 支持嵌套,但模型对深层嵌套的生成能力是有限的。我建议嵌套层级不要超过三层,超过的话就拆成多次调用。
第三个是数组类型的参数。如果某个工具的参数是一个数组,比如send_email的recipients参数,模型有时候会输出一个字符串而不是数组。这时候需要在 schema 里明确"type": "array",并且在提示词里强调“多个收件人请用数组格式”。
def validate_and_execute(tool_call, tool_registry): tool_name = tool_call.get("tool_name") arguments = tool_call.get("arguments", {}) if tool_name not in tool_registry: return {"error": f"未知工具: {tool_name}"} tool_def = tool_registry[tool_name] for param in tool_def["required"]: if param not in arguments: return {"error": f"缺少必填参数: {param}"} try: result = tool_def["function"](**arguments) return {"result": result} except Exception as e: return {"error": f"工具执行失败: {str(e)}"}这段代码展示了结构化输出方案下的标准执行流程:先校验工具名,再校验必填参数,最后执行并捕获异常。每一步的失败信息都会回填给模型,让它有机会自我修正。
3.3 结构化方案的性能考量
结构化输出方案在工程上最省事,但它有一个隐藏的成本:生成速度。因为模型需要在生成过程中实时校验 schema 约束,token 的生成速度会比自由文本慢一些。在工具调用频繁的场景下,这个延迟会被放大。
我实测过,同一个模型,自由文本生成的速度大约是结构化输出的 1.5 到 2 倍。如果你的场景对延迟敏感,比如实时对话系统,这个差异是需要考虑的。
另一个考量是模型兼容性。不是所有模型都支持完整的 JSON Schema 约束。有些模型只支持简单的类型约束,不支持oneOf和pattern。在选择方案二之前,一定要确认你用的模型支持哪些 schema 特性。
提示:结构化输出方案最适合工具数量适中(十个到五十个)、对稳定性要求高、对延迟不太敏感的场景。如果工具数量超过五十个,建议结合分层路由或者转向方案三。
4. 方案三:多轮对话状态机与编排引擎
4.1 状态机的核心状态定义
方案三是三种方案里最接近生产环境形态的。它的核心思想是:把工具调用看作一个多轮对话过程,每一轮都有明确的状态和转移条件。
一个完整的状态机至少包含以下几个状态:
- IDLE:等待用户输入
- THINKING:模型正在决策是否需要调用工具
- TOOL_CALLING:正在执行工具调用
- OBSERVING:工具结果已回填,模型正在生成最终回答
- DONE:本轮对话结束
- ERROR:出现不可恢复的错误
状态之间的转移条件非常关键。比如从 THINKING 到 TOOL_CALLING 的条件是“模型输出了有效的工具调用请求”,从 TOOL_CALLING 到 OBSERVING 的条件是“工具执行完成且结果已回填”。如果工具执行失败,应该转移到 ERROR 还是回到 THINKING 让模型重新决策?这取决于错误的类型。
class ToolUseStateMachine: def __init__(self, model_client, tool_registry, max_turns=5): self.model = model_client self.tools = tool_registry self.max_turns = max_turns self.state = "IDLE" self.history = [] def step(self, user_input=None): if self.state == "IDLE": self.history.append({"role": "user", "content": user_input}) self.state = "THINKING" if self.state == "THINKING": response = self.model.generate(self.history, tools=self.tools) if response.has_tool_call: self.history.append(response.message) self.state = "TOOL_CALLING" else: self.state = "DONE" return response.content if self.state == "TOOL_CALLING": tool_call = response.tool_call result = self.tools.execute(tool_call) self.history.append({"role": "tool", "content": result}) self.state = "OBSERVING" if self.state == "OBSERVING": if len([m for m in self.history if m["role"] == "tool"]) >= self.max_turns: self.state = "ERROR" return "达到最大工具调用轮次" self.state = "THINKING"这个状态机的核心是max_turns参数。没有这个限制,模型可能会陷入无限循环:调用工具、看到结果、再调用同一个工具、再看结果……我见过最夸张的一次,模型连续调用了十七次同一个搜索工具,每次参数都略有不同,但本质上是在做无用功。
4.2 工具结果的回填策略
工具执行完之后,结果怎么回填给模型,这是一个非常讲究的环节。回填得太简单,模型理解不了;回填得太复杂,又会占用大量上下文。
我的经验是:结构化回填 + 自然语言摘要。结构化部分用 JSON 保证机器可读,自然语言部分用一句话概括结果,帮助模型快速理解。
{ "tool_name": "get_weather", "status": "success", "data": {"city": "北京", "temperature": "25°C", "condition": "晴"}, "summary": "北京当前天气晴朗,气温25摄氏度。" }这个summary字段看起来多余,但实际上非常有用。模型在生成最终回答时,会优先参考summary而不是原始data,因为自然语言对模型来说更友好。而且summary可以控制长度,避免原始数据过长导致上下文溢出。
如果工具执行失败,回填的内容要包含错误类型和建议的修正方向。比如参数缺失,就回填“缺少必填参数 city,请补充后重试”。模型看到这个信息,下一轮就会补上参数。
4.3 多轮编排的终止条件
状态机最怕的就是停不下来。除了max_turns之外,我还设置了几个终止条件。
第一个是重复调用检测。如果模型连续两次调用了同一个工具,且参数完全相同,直接终止并返回错误。这说明模型陷入了死循环。
第二个是结果收敛检测。如果工具返回的结果和上一轮完全相同,说明这个工具已经无法提供新的信息,继续调用没有意义。
第三个是用户中断。在多轮对话中,用户随时可能插入新的输入。状态机需要支持从任意状态跳转到 IDLE,重新开始一轮对话。
def should_terminate(self): tool_messages = [m for m in self.history if m["role"] == "tool"] if len(tool_messages) >= self.max_turns: return True, "达到最大轮次" if len(tool_messages) >= 2: last_two = tool_messages[-2:] if last_two[0]["content"] == last_two[1]["content"]: return True, "工具结果收敛" return False, None这些终止条件看起来简单,但每一个都是实际踩坑之后加上的。没有它们,状态机在生产环境里就是一个定时炸弹。
5. 三种方案的横向对比与选型建议
5.1 多维度对比分析
把三种方案放在一起对比,差异非常明显。我从六个维度做了整理:
| 维度 | 方案一:纯文本协议 | 方案二:结构化输出 | 方案三:状态机编排 |
|---|---|---|---|
| 实现复杂度 | 中 | 低 | 高 |
| 模型依赖度 | 低 | 高 | 中 |
| 输出稳定性 | 中 | 高 | 高 |
| 工具数量上限 | 约20个 | 约50个 | 无硬上限 |
| 延迟表现 | 快 | 中 | 慢 |
| 适用场景 | 原型验证、模型不支持原生调用 | 工具数量适中、稳定性优先 | 生产环境、复杂多轮任务 |
这张表里的每一个数字都是我在实际项目中测出来的。比如方案一的工具数量上限,我试过在 system prompt 里塞三十个工具定义,模型的调用准确率从百分之九十五掉到了百分之七十左右。方案二的上限取决于 schema 的复杂度,如果每个工具的参数都很简单,五十个工具也能跑得不错。
5.2 面试场景下的回答策略
回到面试场景。面试官让你“手写一个 Tool Use”,他真正想考察的是什么?
第一,你对协议设计的理解。你能不能设计出一套既能让模型理解、又能让代码解析的格式。这是方案一的核心。
第二,你对模型能力的认知边界。你知道模型原生支持什么、不支持什么,知道什么时候该依赖模型、什么时候该自己兜底。这是方案二的核心。
第三,你对工程复杂度的把控。你能不能把一个看似简单的功能,拆解成可管理、可扩展、可容错的状态流转。这是方案三的核心。
我的建议是:先讲方案一,再讲方案二,最后讲方案三。这个顺序本身就是一种叙事:从最原始的实现出发,逐步引入模型能力,最后落到工程编排。面试官能从中看到你的思考路径,而不仅仅是一个答案。
在讲每种方案的时候,一定要主动说出它的局限性。比如讲完方案一,你可以说:“这套方案在工具少的时候够用,但工具一多,提示词就会膨胀,模型定位正确工具的能力会下降。”这句话一出来,面试官就知道你不仅会写,还真正用过。
5.3 生产环境的混合策略
实际生产环境里,很少只用一种方案。我目前负责的系统用的就是混合策略:核心工具用方案二的结构化输出保证稳定性,边缘工具用方案一的文本协议做兜底,整个流程用方案三的状态机来编排。
这种混合策略的好处是灵活。核心工具调用频繁、对稳定性要求高,值得为它设计严格的 schema。边缘工具调用频率低、参数简单,用文本协议快速接入就行。状态机负责整体的流程控制,保证不管底层用哪种方案,上层的交互体验是一致的。
代价是维护成本。三套逻辑并存,代码复杂度上去了,测试用例也要覆盖三种路径。所以我的建议是:如果你的工具数量少于十个,直接用方案二就够了,不要过度设计。工具数量在十个到三十个之间,可以考虑方案二加方案三。超过三十个,再引入方案一的混合策略。
6. 实操中的常见问题与排查技巧
6.1 模型不调用工具怎么办
这是最常见的问题。用户问了一个明显需要工具的问题,模型却直接用自己的知识回答了。排查思路分三步。
第一步,检查工具描述是否清晰。模型不调用工具,很多时候是因为它不知道这个工具能解决当前问题。工具描述里要包含“什么时候用”的说明,而不仅仅是“这个工具是什么”。
第二步,检查提示词里有没有强制调用的指令。在 system prompt 里加一句“如果用户的问题涉及实时数据或外部信息,你必须调用相应的工具,不要依赖你自己的知识”,效果立竿见影。
第三步,检查温度参数。温度太高,模型倾向于自由发挥;温度太低,模型又可能过于保守。工具调用场景下,温度设在 0.1 到 0.3 之间比较合适。
6.2 工具调用参数错误怎么修
参数错误分几种情况:参数名拼错、参数类型不对、必填参数缺失、参数值格式不对。每种情况的修复策略不同。
参数名拼错和类型不对,通常是 schema 定义不够严格导致的。把 schema 里的type和enum写清楚,这类错误会大幅减少。
必填参数缺失,需要在提示词里强调“调用工具前请确认所有必填参数都已提供”。如果模型还是漏,就在解析器里做校验,把缺失的参数名回填给模型,让它补全。
参数值格式不对,比如日期写成了“明天”而不是“2024-01-01”,这需要在工具描述里给出明确的格式示例。模型是模仿大师,你给它看一个正确格式的例子,它照着做的概率很高。
6.3 多工具调用的顺序问题
有时候一个任务需要调用多个工具,而且有先后顺序。比如先查天气,再根据天气决定要不要带伞。这种场景下,模型可能会一次性输出两个工具调用,但顺序是反的。
我的解法是在提示词里明确依赖关系。工具描述里加一句“此工具依赖 get_weather 的结果,请在获取天气信息后再调用”。同时,在状态机里做串行化处理:一次只执行一个工具调用,执行完把结果回填,让模型决定下一步。
如果确实需要并行调用,比如同时查三个城市的天气,那就在提示词里说明“以下工具之间没有依赖关系,可以并行调用”。状态机检测到多个无依赖的工具调用时,可以并发执行,提升效率。
6.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 模型不调用工具 | 工具描述不清、提示词无强制指令 | 检查 system prompt | 补充使用场景说明和强制调用指令 |
| 参数名拼错 | schema 约束不足 | 检查工具定义 | 用 enum 约束参数名 |
| 必填参数缺失 | 模型遗漏、提示词未强调 | 检查解析器校验逻辑 | 回填缺失参数名让模型补全 |
| 日期格式错误 | 工具描述无格式示例 | 检查工具描述 | 添加格式示例和 pattern 约束 |
| 多工具顺序错误 | 依赖关系未声明 | 检查工具描述 | 声明依赖关系,串行化执行 |
| 无限循环调用 | 无终止条件 | 检查状态机 | 添加 max_turns 和重复检测 |
| 工具结果过长 | 回填策略不当 | 检查回填内容 | 用 summary 字段控制长度 |
这张表里的每一行都对应着我实际踩过的坑。尤其是“无限循环调用”那一行,我第一次遇到的时候排查了整整一个下午,最后发现是状态机缺少终止条件,模型在“调用工具-看到结果-再调用”的循环里出不来。
注意:工具调用的调试信息一定要打日志。把每一轮的模型输出、解析结果、工具执行结果都记录下来。出问题的时候,日志是唯一能帮你还原现场的东西。
7. 从面试题到生产系统的距离
面试的时候手写一个 Tool Use,和真正把它部署到生产环境,中间隔着十万八千里。面试官考察的是你的原理理解,但生产环境考验的是你的工程能力。
我在实际项目里遇到的最大挑战不是协议设计,也不是状态机编排,而是可观测性。当系统里有几十个工具、每天处理上万次调用时,你怎么知道哪个工具调用失败了、失败的原因是什么、失败率是多少?这些问题没有现成的答案,只能自己搭监控、打日志、做告警。
另一个挑战是工具版本管理。工具的参数会变、返回值会变、甚至工具本身会被下线。怎么保证模型在工具变更后还能正确调用?我的做法是给每个工具加版本号,schema 里带上版本约束,工具下线时保留一个过渡期,过渡期内旧版本仍然可用但会返回弃用警告。
还有一个容易被忽视的问题是安全边界。工具调用意味着模型可以触发外部动作,这些动作可能是发邮件、改数据、甚至调用支付接口。怎么保证模型不会误调用敏感工具?我的做法是给工具加权限等级,低等级工具直接执行,高等级工具需要二次确认。确认的方式可以是让模型输出一个确认请求,由用户点击确认后再执行。
这些经验在面试的时候不一定有机会讲,但它们才是真正让一个 Tool Use 系统从“能跑”变成“可靠”的关键。面试官如果问到“你觉得这套方案上生产还缺什么”,把这些点抛出来,基本就能镇住场子了。
最后分享一个我在实际调试中总结的小技巧:给每个工具调用加一个唯一的 trace_id。从模型输出 tool_call 开始,到工具执行完成、结果回填,整个链路用同一个 trace_id 串起来。出问题的时候,拿着 trace_id 去日志系统里一搜,整条链路一目了然。这个习惯帮我省了无数排查时间,也让我在面试时多了一个可以展开讲的实操细节。