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北京, datetoday)]]。程序侧用正则把这段摘出来解析成函数名和参数字典再分发到对应的执行函数。不需要消息历史不需要循环调用一次生成一次解析一次执行。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): # 匹配 [[工具名(kv, k2v2)]] 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: funknown 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北京, date2025-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(fTool {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( nameget_city_weather, description查询某个城市某一天的天气情况支持晴天、雨天等判断, parameters{ type: object, properties: { city: {type: string, description: 城市名如北京}, date: {type: string, description: 日期格式为 YYYY-MM-DD}, }, required: [city, date], }, funcget_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_rounds5): messages [{role: user, content: user_message}] for _ in range(max_rounds): response call_llm(messages, toolsregistry.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 rAction:\s*([\w_]) input_pattern rAction 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_steps5): 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: fObservation: {json.dumps(observation, ensure_asciiFalse)}}) return Reach max steps registry ToolRegistry() registry.register(Tool(nameget_city_weather, description..., parameters{...}, funcget_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 类产品时能不能把抽象和实现拆得清楚。哪怕后来自动化工具越来越完善手写一遍底层的理解依然不过时因为框架可以帮你省时间但替代不了你判断一个方案值不值得用的直觉。