1. 面试现场还原:Tool Use 到底在考什么
那天面试官问题一出来,我脑子里的第一反应不是“怎么写”,而是“他到底想听我怎么讲”。原话大概是:“既然简历里写了做过 Agent,那现在不查文档、不联网,你手写一个 Tool Use 给我看看,几种方案都行。”
很多人一听“手写”两个字就慌,其实面试官不是真让你把整个生产级框架默写出来,他考察的是三件事:第一,你知不知道 Tool Use 的本质是“模型输出到外部动作”的桥接;第二,你能不能在没有框架依赖的情况下,自己定义一套“模型要不要调用工具、调用哪个工具、传什么参数”的协议;第三,你清不清楚每种写法的边界在哪里,什么时候该用重方案,什么时候用轻方案就够了。
我自己平时在做大模型应用的时候,Tool Use 这个词其实已经快被说烂了,但它本质上就干一件事:让语言模型把“文本回复”扩展成“可执行的动作”。举个例子,用户问“北京今天适合穿什么”,模型如果只看训练数据,它只能说“建议查看天气”,但接上了 Tool Use 以后,模型可以主动触发一个查询天气的函数,拿到结构化数据,再基于真实温度给出建议。你可以把它理解成“给模型装了一只手”,之前它只会动嘴,现在它能动手。
我当时听完题,没有立刻写代码,而是先跟面试官确认了一个边界:允不允许依赖外部库。他说脚手架可以直接用系统自带的库,但核心协议要自己写。这个边界非常重要,因为“手写”和“手搓”的尺度不一样。很多人在这一步就翻车了,上来就写一个几十行的复杂抽象,面试官其实只是想看你有没有模块化的思路,而不是比谁代码长。
所以我的策略很简单:按复杂度从低到高,给三套方案。第一套是纯字符串解析,第二套是让模型输出 JSON 指令,第三套是完整的 ReAct 循环。三套方案对应不同产品阶段,也对应不同的智商门槛。接下来我把每一套的实现思路和踩坑点都拆开讲。
2. 方案一:纯字符串解析的“裸写”路线
2.1 先不要上框架,从字符串协议开始
第一套方案,也是我觉得面试时最稳妥的开场:不走任何格式化协议,直接约定“模型输出一段带标记的文本,程序解析标记,提取工具名和参数,然后执行”。它本质上是一个最朴素的约定式接口,你不需要让模型输出 JSON,也不需要维护复杂的 Schema,只需要写一个解析函数。
为什么先从这套讲?因为它最能体现你对 Tool Use 最小模型的理解——工具调用的本质是“模型生成字符串,代码决定怎么理解这段字符串”。很多新人一上来就上 Function Calling 的 SDK,结果对底层机制完全没概念,遇到英文格式、多行参数、引号冲突就不知道怎么调。从字符串协议开始写,反而能把核心链路打通。
我当时用的思路是这样的:约定模型在需要调用工具时,输出一个特定格式的片段,比如用双中括号包一层,像[[get_weather(city=北京, date=today)]]。程序侧用正则把这段摘出来,解析成函数名和参数字典,再分发到对应的执行函数。不需要消息历史,不需要循环调用,一次生成,一次解析,一次执行。
2.2 一个 60 行能跑通的最小实现
这里给你一份我当年在实际项目里简化过的版本,语言用 Python,标准库正则就能搞定:
import re import json # 工具执行函数池子 def get_weather(city: str, date: str = "today"): # 模拟返回,真实项目里会在这里调外部 API return {"city": city, "date": date, "temperature": 26, "unit": "celsius"} def calculator(expression: str): # 仅演示,真实项目中绝对不能直接 eval return {"result": round(eval(expression), 4)} TOOL_REGISTRY = { "get_weather": get_weather, "calculator": calculator, } def parse_tool_call(text: str): # 匹配 [[工具名(k=v, k2=v2)]] pattern = r"\[\[(\w+)\(([^)]*)\)\]\]" match = re.search(pattern, text) if not match: return None tool_name, raw_args = match.group(1), match.group(2) args = {} if raw_args.strip(): for pair in raw_args.split(","): key, value = pair.split("=", 1) args[key.strip()] = value.strip().strip("'\"") return {"name": tool_name, "arguments": args} def execute_tool_call(text: str): call = parse_tool_call(text) if not call: return None func = TOOL_REGISTRY.get(call["name"]) if not func: return {"error": f"unknown tool: {call['name']}"} try: result = func(**call["arguments"]) return {"name": call["name"], "result": result} except Exception as e: return {"error": str(e)} # 模拟一次调用流程 model_output = "北京今天天气不错,[[get_weather(city=北京, date=2025-01-15)]] 适合出门。" print(execute_tool_call(model_output))这套代码跑出来的结果很直观:你把模型输出文本丢进去,它会自动定位到调用标记、解析参数、查注册表、分发执行,最后把结果返回。整个过程没有任何外部依赖,标准的re加一个字典就够了。
注意parse这里有个隐藏问题:如果参数值本身含逗号或者括号,这个正则就会错。我在面试时主动提了这个坑,面试官明显很满意,因为这说明我不是只会背代码,而是知道自己方案的弱点。实际上要处理引号包住的参数,我的建议是用栈式解析或者改用 JSON 片段,但那就往方案二靠了。
2.3 这套方案的定位:不是最优,但是地基
你可能会问,既然有更规范的做法,为什么还要用这种“脆皮”方案?答案是为了理解链路。在真正的聊天机器人早期版本里,这种标记式 Tool Use 非常常见,因为实现成本极低,模型也容易学会输出这种格式。你只需要在 Prompt 里写清楚“当需要查询时,请输出 [[工具名(参数)]]”,很多模型一次就能学会。
但它的缺点同样明显:模型的输出不一定稳定,尤其当参数是中文、特殊字符多的时候,解析很容易出错;而且它不支持连续的多次工具调用,只能处理“一句话里一个调用”的场景。所以这套方案的适用面很窄,更适合作为理解原理的第一课,或者做一个快速 Demo 时的临时做法,并不能作为生产系统的长期选择。
我后面在真实项目里确实也没有直接用这套方案上生产,但它给我带来的收益很大:因为写过最朴素的解析,后面理解 Function Calling 的各类格式规范时,几乎没有任何理解障碍。
3. 方案二:JSON Function Calling 的标准路线
3.1 核心思路:把工具调用当成“结构化输出”
如果说方案一是“人肉正则”,那方案二就是在方案一的基础上向前走了一步:不再让模型输出任意格式的文本,而是约束它输出一个 JSON 片段,里面包含工具名和参数对象。这个方案对应的是目前各类大模型平台里最常见的 Function Calling(有时也叫 Tool Calling)设计。
它的核心原理可以拆成三层:第一层,你把工具的“说明书”(名称、功能描述、参数结构)扔给模型,模型看到用户问题后,自己判断要不要调用、调用哪一个;第二层,如果模型决定调用,它会在回答里附加一个结构化的tool_calls字段,里面是工具名和参数;第三层,你的程序解析这个字段,执行真实工具,把结果回填给模型,模型再基于结果生成最终回复。
这套方案比方案一严谨的地方在于:工具描述(Schema)是结构化的,模型输出也是结构化的,两端都有标准可循。你需要维护的工具列表,本质上就是一份 JSON Schema,模型能看懂,程序也能做校验。
3.2 关键模块:工具注册表 + 参数校验 + 执行生命周期
如果你要完全手写这套,不需要调用任何平台的 SDK,自己定义一个工具注册表就可以了。我在面试时写了这样一个简化版本:
import json from typing import Callable, Any class Tool: def __init__(self, name: str, description: str, parameters: dict, func: Callable): self.name = name self.description = description self.parameters = parameters # 这里存 JSON Schema 格式 self.func = func class ToolRegistry: def __init__(self): self._tools = {} def register(self, tool: Tool): self._tools[tool.name] = tool def list_schema(self): return [ { "type": "function", "function": { "name": t.name, "description": t.description, "parameters": t.parameters, } } for t in self._tools.values() ] def execute(self, name: str, arguments: dict): tool = self._tools.get(name) if not tool: raise KeyError(f"Tool {name} not found") # 这里实际项目中还会用 jsonschema 库做参数校验 return tool.func(**arguments) # 定义一个实际工具 def get_weather(city: str, date: str): return {"temp": 28, "condition": "sunny"} registry = ToolRegistry() registry.register(Tool( name="get_city_weather", description="查询某个城市某一天的天气情况,支持晴天、雨天等判断", parameters={ "type": "object", "properties": { "city": {"type": "string", "description": "城市名,如北京"}, "date": {"type": "string", "description": "日期,格式为 YYYY-MM-DD"}, }, "required": ["city", "date"], }, func=get_weather, )) # 模拟模型返回的调用指令 model_call = { "name": "get_city_weather", "arguments": {"city": "上海", "date": "2025-02-01"}, } print(registry.execute(model_call["name"], model_call["arguments"]))这一版代码最核心的部分不是那个执行函数,而是list_schema和ToolRegistry的设计。前者决定了模型能不能“看懂”你的工具池,后者决定了你的工具能不能被安全地、可追溯地执行。真实的 Function Calling 流程中,模型侧拿到的工具描述就是一份 JSON Schema 数组,你把它拼到 Prompt 或者 API 参数里,模型就知道有哪些工具、各自参数是啥。
3.3 串行循环:一次调用解决不了所有问题
方案二真正开始变复杂的地方,是引入多轮循环。这个点必须重点讲。
初版实现最常见的问题是“只执行一次工具就结束了”。比如用户问“北京和上海哪个更热”,你的工具池里有一个查温度的接口,模型第一次输出可能只调用了北京的天气查询,那么程序应该把结果返回给模型,让它补查上海,再比较两个温度。这个链路走下来,需要经历至少两轮“模型输出 → 工具执行 → 结果回填 → 模型再输出”的循环。
伪代码实现大概是这样的:
def run_agent(user_message, max_rounds=5): messages = [{"role": "user", "content": user_message}] for _ in range(max_rounds): response = call_llm(messages, tools=registry.list_schema()) messages.append(response_message) if response.get("tool_calls"): for call in response["tool_calls"]: result = registry.execute(call["name"], call["arguments"]) messages.append({"role": "tool", "tool_call_id": call["id"], "content": json.dumps(result)}) else: # 没有工具调用,说明模型已经准备好直接回复 return response["content"] return "max rounds exceeded"这个循环是整套系统的灵魂。面试官如果想往深处问,大概率会问下面这几个问题:循环最多跑几轮?怎么判断可以停了?如果工具执行报错,是直接中断还是把错误喂回给模型让它自己纠正?我在真实工程里的做法是:默认上限 5 轮,超出就报错;工具执行报错时,把错误信息作为 tool 消息回填,让模型有机会重新发起一次更正确的调用。这样做有时候会多消耗一轮 token,但比直接失败体验好很多。
3.4 方案二的优势与代价
方案二的最大优势是标准化:平台 API、开源模型、甚至在本地部署的量化模型,基本都兼容这套 JSON Schema 协议。你写一次注册表,可以平滑迁移到不同的模型底座上。
但它也有明显的代价:第一,结构化输出的稳定性和模型本身能力强相关,小模型经常出现 JSON 格式破损,需要做容错解析;第二,工具描述写得好不好,直接决定模型调用准确率,描述太啰嗦模型会困惑,描述太简略模型会漏参。这里我吃过一个典型的亏:某次做产线维护工具时,两个工具的 description 写得太像,模型总是调错那个,后来把其中一个加上“注意:仅用于XX场景,如不确定优先选择另一个”才把准确率拉回来。
4. 方案三:ReAct 风格的多轮决策循环
4.1 从“模型被调用”到“模型自己规划工具链”
方案一和方案二有一个共同的隐含前提:工具调用的触发是“一次一调”,模型每次只决定要不要调用,调用完了就结束。但真实的复杂任务往往需要连续使用多个工具,而且可能要在工具之间做条件判断。
比如用户说“帮我查一下最近三天北京的天气,并告诉我哪天最适合户外跑步”。这个任务至少需要三步:查今天天气、查明天天气、查后天天气,然后比较三天数据、给出推荐。如果用方案二,模型可能一次发起三个并行的天气查询,这还好办;但如果用户说“先查北京,如果下雨就查带伞建议,如果晴天就查跑步路线”,那你必须让模型具备链式决策的能力。
ReAct 风格的核心就是把这个决策流程显式化:模型不再只是输出一个工具调用 JSON,而是输出一段“思考轨迹”,包括Thought(为什么要用工具)、Action(用哪个工具)、Action Input(传什么参数),代码解析出 Action 后去执行,再把执行结果作为Observation回填给模型,如此循环。你可以把它理解成“让模型一边自言自语、一边动手操作”。
这个模式最早是学术圈提出的一种 Prompt 技巧,后来被大量 Agent 框架吸收。它和方案二的核心区别在于:ReAct 的每一步都是模型显式推理的结果,动作之间有上下文连贯性;而方案二更像是“模型根据当前问题直接出调用指令”,对多跳逻辑的把控弱一些。
4.2 实现一个简版 ReAct:核心循环加动作解析
我自己面试时写的 ReAct 简化实现,核心点不是模型调用,而是那套“思考文本解析器”加“循环控制器”。模型输出一段文本,我从中抽取Action和Action Input,执行后把结果拼到下一轮消息里。
import re import json # 假设 llm_response 是模型返回的完整文本,格式可能如下: # Thought: 用户想知道今天能不能跑步,我需要查一下天气。 # Action: get_city_weather # Action Input: {"city": "北京", "date": "2025-02-01"} def parse_react_output(text: str): action_pattern = r"Action:\s*([\w_]+)" input_pattern = r"Action Input:\s*(\{.*?\})" action_match = re.search(action_pattern, text, re.DOTALL) input_match = re.search(input_pattern, text, re.DOTALL) if not action_match: return None action = action_match.group(1).strip() action_input = {} if input_match: try: action_input = json.loads(input_match.group(1)) except: action_input = {"raw": input_match.group(1)} return {"action": action, "action_input": action_input} def react_loop(user_query, registry, max_steps=5): messages = [{"role": "user", "content": user_query}] for step in range(max_steps): response = call_llm(messages) messages.append({"role": "assistant", "content": response}) parsed = parse_react_output(response) if not parsed: # 没有 Action 了,说明模型已经给出最终回答 return response # 执行工具 try: observation = registry.execute(parsed["action"], parsed["action_input"]) except Exception as e: observation = {"error": str(e)} messages.append({"role": "system", "content": f"Observation: {json.dumps(observation, ensure_ascii=False)}"}) return "Reach max steps" registry = ToolRegistry() registry.register(Tool(name="get_city_weather", description="...", parameters={...}, func=get_weather)) print(react_loop("北京今天适合跑步吗?", registry))这个代码最需要留意的点是Observation消息的角色。在一些实现里,观察结果会作为user或tool消息回填,具体怎么选取决于你用的模型对角色定义的敏感度。我实测下来,大部分模型对这些角色兼容性都还行,但如果你调一个对角色区分非常严格的新模型,最好先跑几个样例看它在哪个角色下更容易“接住”工具结果继续思考。
4.3 ReAct 的边界条件:什么时候循环停,什么时候认输
ReAct 看起来酷,但实际使用时最大的坑是停不下来。模型有时候会陷入“反复调用同一个工具”的循环,比如它查了一次天气,Observation 返回下雨,按理说它应该直接回答“不适合跑步”,但某些模型会再发起一次同样的查询,似乎想再确认一次。我把这种问题叫“强迫症式循环”。
解决思路有三层:第一,在 Prompt 里明写“如果你已经拿到足够信息,请直接输出最终回答,不要再调用工具”;第二,设置严格的循环上限,超过就直接掐断,然后让模型基于已有 Observation 做总结;第三,对同参数同工具的重复调用做去重,一旦发现两次调用完全一样,就把第二次直接置为失败,并提示模型“你已经查过了,请基于已有数据回答”。
个人的经验是:循环上限一定要做成可配置的参数,不要写死。因为不同任务复杂度差别很大,简单问天气 1~2 轮就能收尾,复杂数据分析可能 6~8 轮才够。上线后先用一个保守值跑灰度,再根据实际日志调整。
5. 三套方案对比与选型逻辑
5.1 一张表看清三套方案的本质差异
面试现场我写完三套方案后,面试官问了一个很实战的问题:“你平时到底用哪套?别跟我说全都要。”我当时是这么回答的:看产品阶段和模型能力,不同场景用不同方案。下面这张对比表基本上就是我当时画在纸上的内容:
| 维度 | 方案一:字符串标记 | 方案二:JSON Function Calling | 方案三:ReAct 循环 |
|---|---|---|---|
| 实现复杂度 | 低,正则即可 | 中,需要 Schema 注册表 | 高,需要循环控制器 |
| 模型输出稳定性要求 | 低,模型只要会输出标记 | 中,需要稳定输出 JSON | 较高,需要稳定输出 Thought/Action 格式 |
| 支持多工具链式调用 | 不支持 | 支持但较弱 | 最强 |
| 调试可观测性 | 差,解析黑盒 | 中,JSON 字段可追溯 | 好,Thought 过程可查看 |
| 上线成本 | 极低 | 中 | 高 |
| 适合场景 | 快速 Demo、内部原型 | 生产环境常见的单轮/并行调用 | 复杂多跳推理、Agent 产品 |
这张表的核心价值是:不要为了“高级”而用 ReAct,也不要因为“简单”而鄙视字符串方案。方案选择完全由业务场景驱动。如果你的产品只是做一个“语音助手查天气”,方案一都够用;如果你做的是“企业数据问答机器人”,用户会连续提几十个条件,那方案三才可能撑得起来。
5.2 一个系统里三种方案能不能共存
有经验的面试官通常会追问:这三个方案是不是互斥的?我的答案是完全可以共存,而且真实系统里经常是“外套一个方案二,内嵌一个方案三”。
举个例子,我做过一个内部客服问答系统,整体入口走的是标准 JSON Function Calling——模型根据用户问题先决定要不要调高德天气接口、要不要查订单数据库。但当用户提出“帮我退订并退款”这类多步任务时,方案二就可能执行失败,因为它很难在一次调用里完成“先查订单、再判断可退、再发退款请求”这一整个链路。这时候我在代码里做了一个分支:如果方案二的响应里出现了某个特定标志(比如一个叫handle_refund的工具),就切换到这个工具自己的 ReAct 子流程里跑。
这个混合式设计听起来复杂,但每层的实现都不需要重新发明轮子。方案二解决“把用户的自然语言翻译成工具调用协议”的问题,方案三解决“多个工具之间的控制流逻辑”的问题。两个协议之间用一个统一的ToolResult结构体做桥接,很干净。
6. 手写过程中最容易翻车的几个细节
6.1 工具描述文本写得“太像”导致模型串台
这个坑我在方案二部分提过,现在展开讲。很多人手写 Tool Use 时,注意力全放在调用逻辑上,对工具描述文本却敷衍了事。但实际上,由于模型是靠文本理解工具语义的,描述文字的质量直接决定调用准确率。
我自己的血泪教训是有一次给内部成员画像系统写工具,注册了两个接口:一个叫“查询用户基础画像”,一个叫“查询用户消费偏好画像”。由于描述都包含“查询用户画像”这几个字,模型的调用准确率长期在 80% 以下。后来我把描述改造成职责更聚焦的版本,一个强调“返回年龄、性别、地区、会员等级”,另一个强调“返回近30天消费类目、客单价、偏好品牌”,准确率立刻提到了 95% 以上。本质上,你写工具描述的时候应该把自己想象成“在教一个新人分辨两个长得差不多的按钮”。
6.2 工具结果回填时的格式污染问题
很多刚上手的人会在工具结果回填时偷懒,直接把 Python dict 的str()结果塞给模型。一旦 dict 里面有中文、嵌套结构,模型的解读就会很吃力。正确做法是统一用 JSON 序列化回填,并且控制长度。
我见过最夸张的一个案例:某个工具返回一个超长的列表,几百行 JSON 全被塞进了上下文,模型接着就开始“答非所问”,因为上下文被淹没了。处理方式是做结果摘要与截断:从工具返回的原始数据中只提取关键字段,比如只取前 5 条记录,或者用大模型做一轮“提炼摘要”再回填。这个操作如果做得好,能同时解决 token 超限和上下文污染两个问题。
6.3 JSON 解析不能只做一次 try-except
方案二和方案三都依赖模型输出 JSON,但模型生成的 JSON 远没有你想象的干净。常见问题包括:多余的前后缀文本、单引号代替双引号、缺少右大括号、字段名不带引号,等等。
我在真实工程里做的容错是三级递进:第一级直接json.loads;失败后进入第二级,用正则先截取成对的{}再解析;第三级是“修复模式”,用文本清洗逻辑把明显的问题(如把单引号替换成双引号)修掉再解析。如果三级都失败,就把错误信息回填给模型,让它重新输出一个严格 JSON。这一步很值得手写,因为很多开源的 JSON 修复库在服务器上不一定装得了,自己写一个最可靠。
6.4 循环上限和安全的隐含约束
最后提醒一个很多人面试时容易忽略的点:Tool Use 不只是功能,更是权限边界。当你把工具注册表开放给模型时,你要想清楚哪些工具是“危险工具”,要不要做权限校验。
比如你注册了一个“执行 SQL”的工具,模型可能因为 Prompt 注入攻击被诱导执行DROP TABLE。这时候你必须在执行层做一层安全校验:工具注册表里加"requires_approval": true之类的元字段,程序在执行前弹人工确认。我在面试时主动提了这一点——不是每个候选人都能意识到模型输出不应该被无条件信任。这个细节给我加了不少分,因为它说明我不只写功能,还想到了边界。
我自己家里有一个自建的智能家居控制工程,里面就用了方案二的变体:模型可以控制灯光和窗帘,但所有“锁定门锁”类操作都在工具层做了二次确认。因为一旦模型被注入恶意指令,风险是实打实的。
6.5 测试驱动:准备一份最小样本集再上模型
手写完了以后,很多人直接拿线上 Prompt 去测,结果发现模型一顿乱调用。我的建议是:先手工构造 10~20 条测试样本,覆盖“不需要调用”“需要调用一次”“需要并行调用”“需要链式调用”“工具执行报错”这几类典型场景,跑一遍全套逻辑,再拿真实流量去验证。
这个做法可以在你手写方案二的早期就过滤掉一半的解析 bug。比如我特别爱用的一条样本是“给我推荐一部评分最高的电影,还要告诉我搞笑程度排第二的那部是什么”,这条样本会同时触发不同工具调用和排序逻辑,对协议设计的完整性要求极高。用它测一轮,基本就能知道你注册表的设计哪里漏了。
最后说点实在的
如果你现在让我给一个“面试用”的优先级建议,我会说:先把方案二吃透,它是生产环境的主流,也是面试官最愿意听的“标准答案”;方案一用来展示你懂底层,讲一分钟思路即可;方案三用来展示你做过复杂 Agent,只要能把循环控制和安全边界聊明白,这段含金量最高。
手写 Tool Use 这件事真正锻炼的并不是代码能力,而是“协议设计能力”。你能不能让模型和代码在一个相对简单、可控的协议下高效协作,很大程度上决定了你后面做任何 Agent 类产品时能不能把抽象和实现拆得清楚。哪怕后来自动化工具越来越完善,手写一遍底层的理解依然不过时,因为框架可以帮你省时间,但替代不了你判断一个方案值不值得用的直觉。