面试官把电脑转过来,屏幕上一行字:手写一个 Tool Use。我盯着题目愣了两秒,然后笑了,这种题看起来简单,实际是最容易翻车的——很多候选人能把“调用工具”四个字说出来,却写不出一个能跑通的多轮闭环。Tool Use,也就是工具调用,本质上是让大模型在执行任务时能够调用外部函数、查询接口、操作数据的一层桥梁。它解决的是“模型只能说话、不能做事”的痛点:模型本身不联网、不读数据库、不执行代码,但业务场景偏偏需要它去查天气、算价格、调订单。这篇文章就用我当时的真实思路,拆三种手写方案,从最朴素的提示词解析到生产级的函数注册表,再到 ReAct 推理循环。无论你是在准备面试,还是想理解 Agent 框架里最核心的那块拼图,都值得往下看。
1. 先把 Tool Use 这件事想清楚
1.1 面试官问 Tool Use,到底在问什么
先别急着写代码,得先想明白:面试官让你手写 Tool Use,考察的根本不是你会不会调某些厂商的 function calling 接口,而是你对“模型如何与外部世界协作”的理解深度。
LLM 本质上是一个概率语言模型,你给它输入 token,它输出 token,所有知识都藏在参数里。但真实的业务系统不会只让模型“聊天”,更多时候需要它“做事”。比如用户说“帮我查一下最近的订单”,模型自己读不到数据库,它必须先产生一个“我要调用订单查询接口”的意图,程序检测到这个意图后去执行真实的查询函数,再把查询结果以文本形式塞回上下文,模型基于真实数据组织最终回答。这一整套“意图表达 → 工具执行 → 结果回传”的闭环,就是 Tool Use。
面试官真正想看三件事:
- 第一,你知不知道模型输出的不一定是最终答案,可能只是一个调用意图。
- 第二,你知不知道工具调用是一个多轮循环,而不是一问一答就结束了。
- 第三,面对工具参数非法、工具报错、模型瞎编参数这些边界情况,你有没有防御意识。
我当时听到题目,脑子里直接冒出三条路线。最简单的是纯提示词解析,标准的是函数注册表加 Schema 校验,进阶的是 ReAct 式的推理循环。三种方案复杂度递增,适配场景完全不同。下面一个一个拆开说。
1.2 一个最小 Tool Use 闭环包含哪些环节
画张图在脑子里比直接开写重要得多。一个完整的 Tool Use 系统,不管用什么协议实现,都逃不开五件事:
- 工具注册:把外部函数的名字、描述、参数结构暴露给模型,让模型知道“有哪些工具可用”。
- 意图表达:模型根据当前对话,产出一个结构化的工具调用请求,比如 JSON 或专门的 tool_call 对象。
- 意图解析:程序解析模型输出,提取出要调用的工具名和参数,但不直接信任这些参数。
- 工具执行:把参数传给真实函数,拿到真实结果,同时处理超时、异常等风险。
- 结果回传:把工具执行结果拼回对话上下文,让模型基于结果继续推理,或生成最终答案。
有人会把 tool loop 理解成一次性的“模型说调用就调用、说结束就结束”,这是个常见的认知误区。真实场景里,一次用户问题往往要经过好几轮工具调用才能收尾。比如“帮我查一下上周的销售数据,再分析同比变化”,模型得先调查询接口拿数,再调分析接口做处理,最后才能组织出人话。所以手写实现时,循环结构从一开始就要设计进去,否则写到一半你会发现根本处理不了连续调用。
2. 方案一:提示词驱动的 JSON 输出解析(最简实现)
2.1 思路:把工具规则写进 System Prompt
方案一的思路非常朴素:我不依赖任何特殊协议,只把可用工具用自然语言描述清楚,写进 System Prompt,然后硬性规定“当你需要调用工具时,必须输出固定格式的 JSON”。
假设只有两个工具:一个是查天气的get_weather,一个是查股价的get_stock_price。System Prompt 大概长这样:
你是一个智能助手。当用户的问题需要查询实时数据时,你必须输出一个 JSON 对象,格式如下: {"tool": "工具名", "args": {"参数名": "参数值"}} 可用工具: - get_weather: 查询天气,参数 city(城市名,必填) - get_stock_price: 查询股票价格,参数 symbol(股票代码,必填) 除此之外,不要输出任何多余内容。这样做的最大好处是零依赖,任何只支持文本补全的模型都能跑。坏处同样明显,模型经常不听话:把 JSON 包进 Markdown 代码块、输出前后夹带解释文字、参数名写错、JSON 格式非法。所以方案一的重点并不在“定义规则”,而在“怎么把不守规矩的输出救回来”。
2.2 容错解析器:正则加 JSON 修复
既然模型输出不可控,解析层就要皮实一点。我当时写了个很直接但好用的提取函数,分三步走:先剥掉 Markdown 代码块标记和首尾空白,然后用正则把最内层的大括号 JSON 区域抠出来,最后用降级策略一层一层试着解析。
import json import re def extract_json_from_text(text: str) -> dict: # 第一步:去 Markdown 代码块围栏 text = re.sub(r"```(?:json)?", "", text).strip() # 第二步:定位最外层大括号 match = re.search(r"\{.*\}", text, re.DOTALL) if not match: raise ValueError("no json found") raw = match.group() # 第三步:层层降级解析 try: return json.loads(raw) except json.JSONDecodeError: pass # 去掉尾逗号再试 cleaned = re.sub(r",\s*([}\]])", r"\1", raw) try: return json.loads(cleaned) except json.JSONDecodeError: pass # 单引号替换再试 try: return json.loads(cleaned.replace("'", '"')) except json.JSONDecodeError as e: raise ValueError(f"json parse failed after retry: {e}") from e这个函数看着简陋,但它覆盖了实际跑模型时最常撞上的两类问题:Markdown 包裹和尾逗号。解析出字典之后,用tool字段查一个注册表 dict,找到后用args展开调用即可。
2.3 双轮调用:把结果拼回 Prompt 再请求一次
只做“解析后调用函数、把结果打印出来”还不叫 Tool Use,因为结果没回传给模型,用户根本拿不到自然语言回答。正确做法是:第一轮让模型输出 JSON 意图,程序解析并执行工具,然后把执行结果拼进对话上下文,再发起第二轮请求,让模型基于真实结果组织最终答案。
def run_with_tool_once(user_query: str, tool_result: str) -> str: messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_query}, { "role": "user", "content": f"工具执行结果如下,请基于该结果回答用户:\n{tool_result}", }, ] # 这里调用任意的文本补全接口 return llm_chat(messages)这一步是面试中最容易被忽略的点。很多候选人写 Tool Use 只写到“我去查数据库、查到结果了”就停住,没有把结果喂回给模型。面试官看到这种实现,基本可以判定你对 tool loop 的理解不到位。
2.4 方案一的适用边界
方案一的定位是“最小可用”和“理解原理”。优点是简单、直观、兼容所有模型,缺点是笨重和不稳定。工具数量超过十个、参数结构变复杂之后,靠 Prompt 描述会让模型选错工具的概率明显上升,而且它本质上只能支撑“一轮调用、一轮回答”,很难编排多工具连续调用。
面试时时间紧张,先用方案一讲清楚流程、动手把代码写出来,同时把“结果回传”这个环节补上,就够合格了。但千万别停在这一层,因为面试官大概率会追问一句:“如果工具执行失败了怎么办?如果模型连续调用多个工具怎么办?”这两个问题正好引向后面的进阶方案。
3. 方案二:函数注册表 + JSON Schema 调度器(标准实现)
3.1 思路:把工具的“说明书”结构化
方案二是当今主流大模型 Function Calling 协议的标准做法。模型不再靠自然语言理解工具,而是接收一份类似 JSON Schema 的结构化定义,里面清楚写明白工具名、工具描述、参数名、参数类型、是否必填、枚举范围。模型读到这份定义后,如果判断需要调用工具,就会在响应里带回一个结构化的 tool_call 对象,而不是一段需要正则去抠的 JSON 文本。
这种做法的核心价值在于把“模型如何表达意图”这件事标准化了。模型输出自带工具名和参数,解析层不需要做文本修复,校验层可以直接用 jsonschema 库统一跑校验,可靠性提升一个量级。
3.2 工具协议与注册表设计
我实现方案二时,定义了三层结构:ToolSpec(静态说明书)、ToolExecutor(实际执行函数)、ToolRegistry(注册与分发表)。核心代码如下:
from typing import Callable, Any class ToolRegistry: def __init__(self): self._specs: dict[str, dict] = {} self._executors: dict[str, Callable] = {} def register( self, name: str, description: str, parameters_schema: dict, fn: Callable, ): self._specs[name] = { "type": "function", "function": { "name": name, "description": description, "parameters": parameters_schema, }, } self._executors[name] = fn def get_schemas(self) -> list[dict]: return list(self._specs.values()) def execute(self, name: str, args: dict) -> Any: if name not in self._executors: raise KeyError(f"unknown tool: {name}") return self._executors[name](**args)这里有个面试官特别爱看的细节:get_schemas返回的结构要完全对齐主流 LLM API 的tools参数格式。这样写出来的注册表可以直接塞进任意一个支持 function calling 的模型请求里,不需要额外做协议转换,代码的复用性一下就体现出来了。
注册两个工具作为示例:
def get_weather(city: str, unit: str = "celsius"): """模拟查天气,实际开发时替换为真实 API 调用。""" data = { "CityA": {"celsius": 28, "fahrenheit": 82}, "CityB": {"celsius": 30, "fahrenheit": 86}, } info = data.get(city, data["CityA"]) return {"city": city, "unit": unit, "temperature": info.get(unit, 28)}注意,工具函数本身的 Python 签名就是第二道参数闸门。模型传参不对时,fn(**args)会直接抛 TypeError,你要做的是捕获它,并把错误信息包装成“工具执行失败”的消息返回给模型,让它自己修正,而不是让整个程序崩掉。
3.3 参数校验:别把脏参数直接喂给业务函数
模型输出的参数不一定合法,可能类型不对、字段缺失、枚举越界。把这些脏参数直接传给业务函数,轻则运行时异常,重则在真实生产环境里触发副作用。所以在 execute 之前,我会先用 jsonschema 统一校验一遍。
import jsonschema def validate_args(name: str, args: dict, registry: ToolRegistry) -> None: schema = registry._specs[name]["function"]["parameters"] try: jsonschema.validate(instance=args, schema=schema) except jsonschema.ValidationError as e: # 包装成工具层可识别的校验异常 raise ValueError(f"invalid args for {name}: {e.message}") from eBy the way,这步在面试里是明显的加分项。很多人会说“做一些参数校验”,落到代码里却只是 if 判断非空。真正用 jsonschema 对着 Schema 做统一校验,体现的是你对生产环境的理解:工具数量一多,手写一堆 if 根本维护不过来,必须靠声明式校验。
3.4 主循环:处理多轮 tool call
方案二的核心是主循环。用户请求进来后,模型第一轮可能返回 tool_call,也可能直接返回文本结束。如果返回的是 tool_call,程序就执行对应工具,把结果以 role=tool 的消息追加到 messages,再发起第二轮请求。循环往复,直到模型不再返回 tool_call。
import json def run_tool_loop(messages: list[dict], registry: ToolRegistry, max_steps: int = 5) -> str: for step in range(max_steps): response = llm_with_tools(messages, registry.get_schemas()) if response.get("tool_calls"): messages.append(response) # 完整保留模型输出,含 tool_call for tc in response["tool_calls"]: name = tc["function"]["name"] args = json.loads(tc["function"]["arguments"]) try: validate_args(name, args, registry) result = registry.execute(name, args) error_text = "" except Exception as e: result = None error_text = f" {e}" messages.append( { "role": "tool", "tool_call_id": tc["id"], "content": f"{json.dumps(result, ensure_ascii=False)}{error_text}", } ) else: return response["content"] return "reached max steps"这段代码有三个容易被忽略的坑,必须强调。
第一,messages 里要追加原始 response,而不是只追加解析后的工具名和参数。主流协议要求 tool_call 消息后面跟 role=tool 的结果消息,并且 tool_call_id 必须一一对应,顺序一旦乱了,模型就分不清哪个结果对应哪个调用。
第二,每个 tool_call 都要有独立 id。模型有时会一次返回多个 tool_call,你用 uuid4 或者“工具名加时间戳”生成都行,关键是不能所有调用共用一个 id,否则结果回传就会错位。
第三,max_steps 必须存在。模型在工具循环里确实会“犯轴”,反复调用同一个工具,或者在工具报错后不断重试。没有一个上限,一次请求就能把你请求预算和限流全部打穿。设 5 到 8 轮比较合理,到顶后返回兜底回答。
3.5 方案二的优势与代价
方案二目前是生产环境里最主流的实现形态。表达能力强,可以同时发起多个工具调用,也就是 parallel tool calls;多轮迭代天然支持;校验清晰;异常信息能回传给模型做自我修正。代价是依赖特定协议,不同模型的 tool_call 输出结构会有些许差异,不过只要在解析层做一层薄薄的适配封装,切换模型并不麻烦。
面试时,方案二值得花最多时间展开。面试官看到你写出 tool loop,并处理了 tool_call_id、参数校验、循环上限这三件事,基本可以认定你是真正碰过 Agent 系统的人,而不是只会背概念。
4. 方案三:ReAct 模式下的 Tool 推理循环(进阶方案)
4.1 从 Function Calling 到 ReAct
方案二很可靠,但有一个前提:模型厂商在 API 层内置了 tool_call 的结构化输出能力。如果有一天你要接入一个不支持 function calling 的模型,或者想在极简协议下构建一个能“边推理边调用工具”的 Agent,就要回到 ReAct 模式。
ReAct 全称是 Reasoning and Acting,核心思想是把“推理”和“行动”交替组合,让模型输出固定格式的文本,里面包含三段信息:Thought(思考:我现在该做什么)、Action(行动:调用哪个工具)、Action Input(行动参数)。程序解析 Action 后执行工具,把结果以 Observation(观察结果)的形式拼回上下文,模型基于 Observation 继续输出下一轮 Thought,如此循环,直到模型输出 Final Answer。
用一个生活化的类比:就像人解一道复杂的数学应用题,先想“这题需要用到速度公式”,然后动笔算,看到结果再想“下一步该求时间了”,一步一步逼近最终答案。这种思考与行动交替的方式,比一次性输出答案要靠谱得多。
4.2 手写一个最小的 ReAct 循环
如果一个模型不支持原生 function calling,也不支持 JSON 模式,你依然可以用 ReAct 文本协议实现 Tool Use。关键在于定义一段程序好解析、模型也好生成的文本约定。我用的是最经典的三行式:
Thought: 说明当前推理过程 Action: 工具名 Action Input: {"参数名": "参数值"}主循环长这样:
import json import re def run_react_loop(user_query: str, registry: ToolRegistry, max_steps: int = 6) -> str: messages = [ {"role": "system", "content": react_system_prompt()}, {"role": "user", "content": user_query}, ] for _ in range(max_steps): text = llm_chat(messages) # 是否已经输出最终答案 if "Final Answer:" in text: return text.split("Final Answer:", 1)[1].strip() # 解析 Action 和 Action Input action_match = re.search(r"Action:\s*(\w+)", text) input_match = re.search(r"Action Input:\s*(\{.*?\})", text, re.DOTALL) if not (action_match and input_match): # 解析失败:把提示语写回,让模型重新组织输出 messages.append({ "role": "user", "content": "输出格式不符合要求,请重新输出 Thought/Action/Action Input 或 Final Answer。" }) continue name = action_match.group(1) try: args = json.loads(input_match.group(1)) result = registry.execute(name, args) observation = json.dumps(result, ensure_ascii=False) except Exception as e: observation = f"tool execution failed: {e}" messages.append({ "role": "user", "content": f"Observation: {observation}\n请继续思考并输出下一步 Action 或 Final Answer。" }) return "reach max steps, please try again"这段代码里有两个细节值得单独讲。
第一,Observation 是作为 user 消息追加的,而不是 assistant 消息。在 ReAct 文本协议里这是最常见的做法,因为不支持 function calling 的模型没有“系统注入工具结果”的概念,你只要把 Observation 放进对话上下文,模型自然就会基于它继续推理。
第二,解析失败时不要直接崩溃,而是发一条“格式不对,请重新输出”的纠错消息。模型偶尔会漏掉 Action Input,或把参数写成非 JSON,给它一次纠错机会通常就能回来。真实使用中,这个机制能明显提升循环的健壮性。
4.3 ReAct 循环的三个防守策略
ReAct 是文本协议,自由度大,翻车点也多。我实际用过之后总结了三个必须做的防守。
第一,最大轮数必须设。ReAct 循环里模型可能陷入死循环,反复输出同一个 Action,或在一个工具报错后翻来覆去重试。没有上限,一次请求就能烧掉大量 token。6 到 10 轮是合理区间,再往上不是解决问题,是在纵容模型空转。
第二,停用词处理。部分模型输出时会不停追加不相关内容,如果生成接口支持 stop token,把Observation:和Final Answer:设成停止词,可以避免模型把上一轮观察内容原样复述一遍,也能省不少 token。
第三,工具副作用要可控。ReAct 常用于开放式任务,模型可能会自行决定去调用任何已注册工具。因此所有工具最好实现成无状态或可重入,涉及写操作、发消息、扣费这类带副作用的工具,必须加二次确认,防止模型在长循环里触发不该触发的动作。
4.4 方案三该怎么定位
方案三的代码量比方案二更大,面试时未必有时间完整写完,但它能体现你对 Agent 范式的理解深度。如果面试官问你“了解 ReAct 吗”“如果模型不支持 tool call 怎么办”,你能直接说出 Thought/Action/Observation 的循环结构,再讲清楚解析与回灌的流程,就已经能压住场面。
实际开发中,方案三通常用来做轻量级 Agent 原型,或者临时给不支持 function calling 的模型补上 Tool Use 能力。缺点也很明显:输出格式靠文本约定约束,解析层始终有兼容成本;长对话下模型可能丢失早期 Observation,需要做摘要或外部记忆。所以它更适合快速验证思路,生产环境我还是优先推荐方案二。
5. 三种方案对比与选型建议
5.1 一张表看透差异
三种方案的核心差异,用一张表就能讲清楚。面试时直接讲这张表,既简洁又有信息量:
| 对比维度 | 方案一:提示词 JSON | 方案二:注册表 + Schema | 方案三:ReAct 循环 |
|---|---|---|---|
| 模型依赖 | 任意文本模型 | 支持 tool call 的模型 | 任意文本模型 |
| 实现难度 | 最低 | 中等 | 较高 |
| 调用可靠性 | 低,需正则兜底 | 高,结构化输出 | 中,文本协议易解析失败 |
| 多轮调用 | 困难 | 天然支持 | 支持 |
| 并行调用 | 不支持 | 支持多 tool_call | 不支持,需串行 |
| 错误回传修正 | 手动拼装 | 协议原生支持 | 拼 Observation 实现 |
| 生产推荐度 | 原型验证 | 推荐 | 特殊接入场景 |
你如果能当着面试官的面把这张表讲明白,说明你不是只会写三段代码,而是对 Tool Use 这一技术谱系有全局认知。
5.2 实际选型的判断标准
在我做过的项目里,我从不一上来就选最重的方案,而是按这个顺序判断:
- 如果只是做一个 Demo 或内部小工具,且模型没开放 function calling:用方案一,半小时跑通。
- 如果是生产服务,且模型 API 支持 function calling:闭眼选方案二。它是主流厂商的标准协议,踩坑的人多,问题基本都被人趟平了。
- 如果正在做 Agent 框架,要支持“自主规划、逐步执行”的任务:方案二给工具层,方案三给推理层,两者并不互斥。很多成熟的 Agent 框架正是这样:Function Calling 负责稳定接入工具,ReAct 负责高层规划。
选型的本质是在可靠性和灵活性之间做平衡。工具调用是落地动作,可靠性永远优先。一个自主循环做得再花哨,如果连参数校验都做不好,最后跑起来就是各种报错。
6. 面试现场的一点实操体会
6.1 先讲原理,再写代码,顺序很重要
那次面试大约持续了四十分钟。复盘时我最深的体会是:功劳不在于某一段代码写得漂亮,而在于给自己定了一个节奏——先用三分钟讲清楚 Tool Use 是什么,再动手写方案二的核心循环,最后用方案一和方案三展示扩展认知。面试官要的不是最终的代码产物,而是你组织思路的方式。
如果一上来就闷头写代码,写到一半才发现没考虑结果回传,场面会非常尴尬。我在模拟面试里见过太多次这种场景:候选人几分钟就写出了工具执行列表,却不知道怎么让模型看到工具结果,最后只能硬撑。先花时间把闭环画出来,代码只是对那张图的翻译,心里有图,写起来自然不会乱。
6.2 边界处理是你的加分项
“工具调用失败怎么办”基本是必问题。如果你只回答“抛异常让上层处理”,面试官绝不会满意。更好的答案是:捕获异常后把错误信息写进 tool 结果消息里回传,让模型知道“这个工具报错了,原因是什么”,模型会尝试修正参数后再次调用。这个机制在方案二里只要几行代码,体现的却是生产级思维。
另一个加分细节是可观测性。我给主循环加过日志,每次工具调用都记录工具名、入参、出参、耗时、是否异常。面试时随口提一句“生产环境没有日志,就没法排查模型乱调用工具的问题”,这比背十页八股文更有说服力。
6.3 别掉进过度设计的坑
有些候选人一上来就画架构图,事件总线、任务队列、插件机制全堆上去,结果代码写到一半崩了。手写 Tool Use 的考点从来不是“你能否设计一个分布式 Agent 平台”,而是“你能否在短时间内跑通一个最小闭环”。我当时用方案二打底,能跑通、能讲清楚,就已经赢了。那些更高级的设计放到口头补充里,面试官有兴趣自然会追问,没必要用代码硬撑。
还有一个我踩过的坑:手写代码时把模型接口的参数名记混了。比如把tools写成了functions,把 tool 消息的字段名写错。这种细节在面试里很致命,因为报错会打断你的思路。我的习惯是对着主流 LLM API 的接口签名,提前默写一遍 tools 请求结构和 tool_call 响应结构,相当于把最常用的接口格式刻进脑子里,到了现场才不会卡壳。
那次面试最后拿到了通过的结果。我个人觉得,Tool Use 看起来是一个具体的技术点,实际上考察的是工程师对“大模型和外部世界如何协作”这个核心命题的理解。只要把工具注册、意图表达、执行校验、结果回传这条闭环彻底吃透,不管面试官后续把问题引向 Agent、RAG 还是多轮对话编排,你都能接得住。
最后再分享一个小技巧:面试前,先在草稿纸上把工具注册表和主循环的伪代码写一遍,五分钟都用不了。这个动作能帮你把接口字段名顺一遍,也能让你在正式落笔时心里有底。如果你也在准备类似的面试,希望你读完这篇之后,能把 Tool Use 从“听说过”变成“我亲手写过”,这套逻辑才是真正属于你的东西。