那场面试进行到一半,面试官把电脑转过来,屏幕上是四个字:手写 Tool Use。
我第一反应愣了一下。倒不是不会,而是大部分人在项目里都是直接调现成接口,真正从零写一个“让模型调用外部工具”的完整链路,真没几个人细想过。面试官补了一句:“不要跟我讲调 SDK,从协议层讲清楚,模型怎么知道该调哪个函数、参数怎么传、结果怎么拿、调用完了怎么继续。”
我大概花了四十多分钟,在他面前从最原始的正则解析方案一路讲到带循环控制的多轮 Agent 方案。面完出来我自己复盘了一遍,觉得这三套递进方案其实值得好好整理一下。今天把它完整写出来,既复盘这场面试,也希望能帮到正在准备类似题目的人。
这篇内容会讲清楚三件事:第一,Tool Use 的本质是什么,面试官到底在考什么;第二,三种由浅入深的实现方案,附能直接跑通的核心代码;第三,真实的坑和面试里值得主动讲的加分点。
1. Tool Use 到底在解决什么问题
1.1 给模型装外挂的本质逻辑
大语言模型本质上是一个“只动嘴、不动手”的系统。它能理解自然语言、能生成看起来合理的回答,但它不能真的去查天气、查数据库、算复杂表达式、调用业务系统。Tool Use 解决的就是这个矛盾:在模型说话之前,给它提供一次调用外部工具的机会,让答案建立在真实执行结果之上,而不是模型“猜”出来。
你可以把它理解成一个秘书和一堆工具的关系。秘书不能亲自跑腿,但秘书知道哪些工具大概能办什么事,也知道工具的“说明书”长什么样。当老板(用户)抛来一个问题时,秘书意识到需要查某个数据,就填一张单子,写上“用什么工具、传入什么参数”,交给跑腿员(代码)去执行。跑腿员把结果拿回来,秘书再基于这个结果组织语言,给老板一个最终答复。
这个协作过程拆成代码逻辑,就是四步:
- 把工具的语义描述(名称、用途、参数)提前告诉模型。
- 模型在回答时,如果判断需要调用工具,就输出一个“调用意图”。
- 代码侧解析这个意图,找到对应函数,传入参数,执行。
- 把执行结果塞回上下文,让模型基于结果组织最终答案。
任何一个 Tool Use 实现,无论封装得多花哨,底层跑不出这四个步骤。
1.2 面试官为什么要现场手写
面试官让你手写 Tool Use,考的不是你会不会用某个框架。说白了,市面上工具调用能力强的模型平台都有现成方案,会用的人一抓一大把。他真正想确认的是三件事:
第一,你有没有理解格式约束这件事。模型只输出文本,你要让它输出“结构化的意图”,本质上是给它加一套格式纪律,然后靠程序去解析。这是底层逻辑。
第二,你有没有工程处理意识。模型输出不一定是规范的,解析可能失败、参数可能少传、工具可能报错、循环可能停不下来。这些边界你怎么兜。
第三,你有没有完整的系统视野。从单次工具调用到多轮 Agent 循环,中间哪一步最关键,每一步可能会出什么问题。一个只知道调接口的人,是无法回答这些追问的。
所以“手写”只是形式,“讲清楚边界和取舍”才是真正的考点。
2. 方案一:提示词约束加正则解析,最原始但最通用
2.1 核心思路:让模型在文本里夹带“私货”
既然模型只会输出文本,第一种最直觉的思路就是:告诉模型,当你需要调用工具时,必须且只能输出一行特殊格式的文本。程序这边用正则或者 JSON 解析把这行文本抠出来,识别出工具名和参数,然后调用对应的 Python 函数。
这个方案的优点是极其通用。它对模型没有任何特殊要求,只要模型能遵循提示词里的格式指令就行。不管是强模型还是弱模型,只要“听劝”都能跑。而且你完全掌控输出格式,解析逻辑是自己写的,出了问题知道去哪里排查。
缺点是模型不一定守规矩。如果你用的模型理解能力一般,它可能忘记输出格式、把 JSON 写错、把多个工具调用挤在同一句话里。所以提示词的措辞要写得很死,甚至要把格式示例直接给出来。
2.2 一个能跑通的最小实现
我先定义两个工具函数。正常项目里这里是接真实业务接口,我面试时为了演示就用两个模拟函数。
import json import re def get_weather(city: str) -> str: # 实际项目里这里调用天气服务,演示时直接返回模拟数据 return f"{city} 今天多云,气温 18~24℃" def calculator(expr: str) -> str: # 实际业务里不可能直接上 eval,这里仅用于面试演示 try: result = eval(expr, {"__builtins__": {}}, {}) return f"计算结果: {result}" except Exception as e: return f"计算失败: {e}" TOOLS = { "get_weather": get_weather, "calculator": calculator, }然后构造提示词,核心是给出工具清单,并严格约定输出格式。
TOOL_DESC = "\n".join([ "- get_weather(city: str): 查询指定城市的天气情况", "- calculator(expr: str): 计算数学表达式,例如 '1 + 2 * 3'", ]) def build_prompt(user_input: str) -> str: return f"""你是智能助手,可以调用外部工具回答问题。 可用工具: {TOOL_DESC} 当你的回复需要调用工具时,必须且只能输出一行 JSON,格式如下: TOOL_CALL {{"name": "工具名", "args": {{...}}}} 除此之外不要输出任何其他内容。 如果不需要调用工具,直接回复普通文本。 用户问题:{user_input}"""解析侧用正则从模型回复里捞TOOL_CALL开头的 JSON:
TOOL_CALL_RE = re.compile(r"TOOL_CALL (\{.*?\})", re.S) def parse_tool_call(text: str): match = TOOL_CALL_RE.search(text) if not match: return None, None try: data = json.loads(match.group(1)) return data.get("name"), data.get("args", {}) except json.JSONDecodeError: return None, None主流程就是“先让模型回答,发现要调工具就去调,调完把结果喂回去再答一次”:
def run_once(question: str) -> str: # call_llm 表示调用大模型接口,在你自己的环境里替换为具体实现即可 messages = [{"role": "user", "content": build_prompt(question)}] reply = call_llm(messages) name, args = parse_tool_call(reply) if name is None: # 模型没有调用工具,直接返回 return reply fn = TOOLS.get(name) if fn is None: return f"未知工具: {name}" result = fn(**args) messages.append({"role": "assistant", "content": reply}) messages.append({"role": "user", "content": f"工具返回结果: {result}\n请根据结果回答用户问题。"}) return call_llm(messages)2.3 这个方案的坑可以说得非常细
我在面试现场专门挑了三个坑讲,面试官果然来了兴趣。
第一个坑是正则贪婪匹配。如果模型输出的 JSON 里有嵌套大括号,比如args里塞了一个字典,上面那个正则的\{.*?\}是典型的非贪婪写法,遇到{"city": "上海", "extra": {"pm25": 55}}这种嵌套结构时,可能只匹配到{"city": "上海"就草草收场。真要在生产环境里做,更稳的做法是先不依赖正则,而是找到TOOL_CALL标记之后,用“从TOOL_CALL后第一个{开始,做括号配对扫描,直到括号深度清零”的方式截取完整 JSON 片段。这个细节很少有人写,但处理不好就是线上 bug。
第二个坑是json.loads对模型输出的容忍度有限。模型喜欢在 JSON 里补注释、用单引号、输出多余的逗号,这些都会导致解析失败。一个折中的办法是解析失败后,把错误信息作为“观察结果”回传给模型,明确告诉它“格式错误,请重新按规范输出”。实测这一招能把不少模型的自我纠错能力调动起来。
第三个坑是参数类型。模型输出的是字符串,但工具函数可能需要int、float、list。所以参数透传之前,最好做一层类型约束校验,或者干脆在工具函数内部做一次防御性转换。
提示:用
eval做计算器只适合面试演示,生产环境里千万不要这么写,要么用ast模块做安全解析,要么直接用专门的表达式计算库。
3. 方案二:结构化工具声明协议,生产环境的主流姿势
3.1 为什么需要一份工具 Schema 而不是自然语言描述
方案一虽然能跑,但存在一个明显问题:自然语言描述工具,模型只能凭“语感”去猜。一旦工具多了,名字相近、参数相似,模型很容易混。而且解析结果完全依赖模型对格式纪律的遵守情况,不确定性很大。
生产环境的主流做法是给每个工具一份结构化的 Schema,像说明书一样把名称、描述、参数类型、必填项目全部用 JSON 格式定义清楚。模型拿到这份 Schema 后,不仅能理解工具语义,还能被平台内部的约束机制强制按结构化格式输出调用意图。
主流大模型的函数调用能力,本质上就是这套逻辑的服务化封装:请求时把工具 Schema 附加在消息里,模型如果决定调用某个工具,返回的内容里会有一个结构化的tool_calls字段,里面是工具名和一个 JSON 字符串形式的参数。代码侧要做的事就是解析这个字段、执行函数、回传结果。
这个方案相比方案一,最大的提升是把格式约束从“提示词里约定、靠模型自觉”变成了“协议层面结构化”。只要模型支持函数调用协议,输出格式就是稳定的,不需要正则去猜。
3.2 协议流转过程与核心代码
工具 Schema 的定义大概是这个样子:
TOOL_SCHEMA = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的天气情况", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名"} }, "required": ["city"], }, }, }, { "type": "function", "function": { "name": "calculator", "description": "计算数学表达式", "parameters": { "type": "object", "properties": { "expr": {"type": "string", "description": "数学表达式"} }, "required": ["expr"], }, }, }, ]请求时把 Schema 传给模型侧的接入层。为了让你聚焦核心逻辑,我这里用一个占位函数演示返回结构:
def call_llm_with_tools(messages, tools): # 实际环境:请求大模型平台,把 messages 与 tools 一起传过去, # 模型返回的 assistant 消息里会带有 tool_calls 字段。 # 这里直接给一个模拟返回结构。 return { "role": "assistant", "tool_calls": [ { "id": "call_001", "name": "get_weather", "arguments": '{"city": "上海"}', } ], }分发执行的核心代码就是一个循环,只要模型说要调工具,就执行并回填结果,直到模型不再发起工具调用。
def execute_tool(name: str, args: dict): if name == "get_weather": return get_weather(args["city"]) if name == "calculator": return calculator(args["expr"]) raise ValueError(f"Unsupported tool: {name}") def run_tool_loop(user_input: str) -> str: messages = [{"role": "user", "content": user_input}] while True: resp = call_llm_with_tools(messages, TOOL_SCHEMA) # 模型没有调用工具,直接返回最终回答 if not resp.get("tool_calls"): return resp["content"] # 注意:一次回复里可能带多个工具调用,这里为了演示只取第一个 tool_call = resp["tool_calls"][0] messages.append(resp) name = tool_call["name"] args = json.loads(tool_call["arguments"]) messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": execute_tool(name, args), })3.3 分发执行阶段的工程细节
执行阶段看着简单,但实际做的时候有几个细节值得注意。
一是arguments是一个字符串,必须json.loads后才能用。这个字符串偶尔会出现 JSON 不合法的情况,所以解析要包在try except里,解析失败就回传一条报错消息给模型,让模型重新生成参数。
二是一次请求可能返回多个tool_calls,比如模型同时想查天气和算表达式。有些平台支持并行执行多个工具,有些必须串行。如果平台支持并行,最好先全部解析成功再一起执行,最后把多个结果按tool_call_id回填,避免顺序错位。
三是tool_call_id必须和工具结果一一对应。模型是靠这个 ID 把“某个调用结果”对应到“某个调用请求”的。我见过不少人图省事直接省略这个字段,结果模型在多轮工具调用时完全失忆。
四是工具数量要控制。有些团队把几十个工具一次性全塞给模型,且不说上下文的 token 消耗,模型在这么多工具里做选择本身就可能挑错。最好做一层工具路由,先按用户问题做意图粗筛,只把相关的三五个工具丢给模型。
4. 方案三:ReAct 循环,从单次调用升级成完整 Agent
4.1 ReAct 循环和前面两种方案的本质区别
前面两种方案有一个隐含假设:模型调用一次工具,拿到结果,然后就能给出最终答案。但很多真实问题需要连续调用多步才能解决。
举个最简单的例子:用户问“北京和上海哪个更冷”。正确的流程是先查北京天气,再查上海天气,拿到两个结果之后对比,然后组织回答。如果你只给模型一次工具调用机会,它只能查一个城市,回答必然不完整。
ReAct 的思路是让模型把“推理”和“行动”交替进行。每一步模型都可以输出一个行动(调哪个工具、传什么参数),程序执行完工具后把观察结果(Observation)放回对话上下文,模型看到结果后决定下一步是继续调工具还是给出最终答案。这就像一个人解决问题的完整过程:先想想缺什么信息,查一步,看到结果,再思考,再查,直到信息足够。
所以方案一和方案二解决的是“一次工具调用”,方案三解决的是“一连串工具调用”。这也正是 Agent 和单次 Function Calling 的分水岭。
4.2 一个能跑通的最小 ReAct 循环
我把 ReAct 的提示词写得非常简单,只支持四种输出前缀:思考(可省略)、行动、观察、最终回答。核心是“行动 - 观察”的循环结构。
REACT_PROMPT = """你有以下三种输出格式: 1. 需要调用工具时,输出两行: ACTION: 工具名 ACTION_INPUT: {"参数名": "参数值"} 2. 当你看到工具结果后,直接输出: OBSERVATION: 工具返回的内容 3. 当你已经获得足够信息回答用户时,输出: FINAL: 给用户的最终答案 可用工具: - get_weather(city: str) - calculator(expr: str)""" def run_react(user_input: str) -> str: context = [{"role": "user", "content": f"{REACT_PROMPT}\n\n用户问题: {user_input}"}] for step in range(6): # 最大执行 6 步,防止死循环 reply = call_llm(context) # 模型给出最终答案 if reply.startswith("FINAL:"): return reply[len("FINAL:"):].strip() # 模型想调用工具 if reply.startswith("ACTION:"): lines = reply.splitlines() tool_name = lines[0].replace("ACTION:", "").strip() action_input = {} for line in lines: if line.startswith("ACTION_INPUT:"): action_input = json.loads(line.replace("ACTION_INPUT:", "").strip()) result = TOOLS[tool_name](**action_input) context.append({"role": "assistant", "content": reply}) context.append({"role": "user", "content": f"OBSERVATION: {result}"}) continue # 没识别出格式,强制要求重新输出 context.append({"role": "user", "content": "格式不识别,请严格遵守 ACTION / FINAL 格式输出。"}) return "超过最大执行轮次,已自动终止"4.3 循环终止与安全防护是不能省的
ReAct 方案上手容易,但生产环境的坑比前两种都要深。面试时我把循环控制和安全防护放在一起讲,面试官明显更认可。
第一个问题是死循环。模型可能因为上下文理解偏差,反复调用同一个工具,或者来回调用两个工具停不下来。所以必须设置最大循环步数。但我还要补一句:如果只是简单地截断,用户会得到一个“抱歉无法完成”的半成品。更好的做法是当循环次数接近上限时,主动把“当前已获取的部分信息”发给模型,让它基于已有信息尽量给出一个可用的回答,而不是直接失败。
第二个问题是上下文膨胀。每一步 ReAct 都会把模型回复和工具结果追加进消息列表。有些工具结果非常长,连续十几轮后上下文会被撑爆。解决办法有两个层面:一是对工具结果做截断,只保留关键信息;二是在消息历史里做压缩,把较早的“行动-观察”轮次合并成一句摘要。
第三个问题是并发和安全。Agent 一旦具备连续调用能力,就意味着模型可以通过多次调用来组合出一些非预期的行为。工具权限管控、敏感操作二次确认、全链路日志,这些在 Agent 落地时都不能省。风险控制永远要在能力之前做。
5. 三种方案放一起对比,怎么选
我自己平时选型时会用一张表快速判断:
| 评估维度 | 方案一:正则解析 | 方案二:结构化协议 | 方案三:ReAct 循环 |
|---|---|---|---|
| 实现成本 | 最低 | 中等 | 最高 |
| 对模型能力要求 | 低,但需要模型守格式 | 要求模型支持函数调用协议 | 要求模型推理和指令遵循能力强 |
| 解析稳定性 | 弱,模型容易不守规矩 | 强,协议层保证结构化输出 | 强,但循环控制复杂度高 |
| 支持单次调用 | 可以 | 可以 | 可以 |
| 支持多轮连续调用 | 需要外部循环辅助 | 需要外部循环辅助 | 原生支持 |
| 适用场景 | 快速 Demo、模型平台不支持函数调用 | 生产环境的标准工具接入 | 需要工具组合、多步推理的 Agent 场景 |
选型逻辑其实不难。如果模型平台不支持函数调用协议,或者你是在做模型适配层,必须兼容五花八门的弱模型,那就老老实实走方案一,把格式约束做到极致。如果接的是主流大模型平台,工具调用是标准能力,那直接用方案二,把精力花在工具解析和分发上,而不是自己发明协议。如果需要让模型自主决定“先查什么、再查什么”,那就必须上方案三。
不过这三个方案不是互斥关系。真实系统中它们经常叠加:底层用结构化协议接入模型,Agent 循环层用 ReAct 思路编排,对个别不守规矩的模型再退化成提示词加强制解析。理解每一层的取舍,组合起来才不容易出问题。
6. 面试复盘:我是怎么跟面试官讲这三套方案的
6.1 先讲边界,再讲实现
面试的时候我没有上来就贴代码,而是先把边界讲清楚:方案一适用什么、方案二改进在哪里、方案三又解决了什么新的问题。讲完之后,面试官自然能看出你对这个话题有过系统思考。
我的表述逻辑大概是这样的:Tool Use 的本质是“格式约定加工具分发”。方案一是最原始的格式约定,用正则做分发;方案二是把约定协议化,用 Schema 做分发,减少不确定性;方案三是把分发改成循环,加入了 Agent 编排。三个方案不是三个独立答案,而是一条递进的主线,面试官想看到的就是这条线。
6.2 主动聊坑,比背方案加分
面试官抛出追问的时候,我主动说“这个话题我踩过很多坑”,然后快速列了几个:解析失败怎么兜底、多工具调用怎么防错位、循环怎么防死锁。这些不是面试前背的,而是真实经历过才会有的细节。
面试官通常不会满足于“我这个方案能跑”。他会追问“如果模型输出格式不对怎么办”“如果一个工具里又要调另一个工具怎么办”“上下文太长怎么处理”。遇到这种追问,能举出具体的兜底策略,比如“解析失败就把错误回传给模型让它自我纠正”“长上下文就做历史压缩”,效果远好于只说“我再让模型重新生成一次”。
6.3 加分项:主动说清楚未来演进方向
最后一个问题是面试官问我“如果工具数量从五个涨到五百个,这个方案会有什么问题”。这个追问其实特别有价值。五百个工具全部塞进 prompt,token 会爆,模型选择准确率也会下降。我的回答是三层:先做工具分组和意图路由,只加载相关子集;再做工具描述的向量化召回,用相似度检索把候选工具压缩到十个以内;最后做一次候选工具重排,确保丢给模型的工具顺序合理。
这种面对规模问题主动给出分层方案的表达,往往比背标准答案更能体现系统设计能力。
我个人在实际面试和后续复盘里的最大体会是:Tool Use 不是一个“调接口”的题目,而是一个“格式约定、协议设计、循环控制”三者叠加的系统题。你能写到第几层,基本就代表了你在 AI 应用层的水位在第几层。
最后再分享一个小技巧。面试前别光看理论,亲手把上面三种方案各写一遍,然后把“解析失败”“循环不退出”“参数类型不对”这几个 bug 故意造出来一次,再亲手修掉。这个过程比背十篇技术文档都有用。因为这些 bug 一旦自己踩过一遍,你再跟人讲的时候,说的每一个字都是有底的。