面试官把电脑转过来屏幕上一个空白的编辑器旁边飘着一句话手写一个 Tool Use。我当时心里先是一紧然后又松了口气。紧是因为这题看着简单其实坑很深松是因为 Tool Use工具调用这个东西我平时在生产环境里天天打交道已经从最早的拼 JSON进化到跑 Agent 循环了。面试官想看的绝对不只是你会不会调大模型接口而是你对模型输入输出、函数分发、多轮状态、异常兜底这一整套链路有没有真正理解过。所以我没有直接写一个函数就交差而是跟面试官简单对齐了一句我给三种方案从最轻量到最重您看哪种符合这次岗位的方向。他没说话但点了点头。接下来我就在编辑器里写了三版 Tool Use也就是今天这篇文章想完整拆给你的东西。这篇文章适合两类人一类是在准备面试、需要把 Tool Use 讲出层次感的同学另一类是已经上了生产、但每次写完 Agent 总觉得哪里不对劲的开发者。我会把三种方案的核心思路、完整代码、选型逻辑、以及我当时被追问后补上的那些细节全部摊开来讲。1. 先搞清楚题目在考什么Tool Use 的三种能力层级1.1 为什么面试官爱考手写 Tool Use现在做 AI 应用绕不开 Function Calling 和 Agent而这两者的地基都是 Tool Use。所谓 Tool Use本质就是让大模型不只会说话还会动手——你要查个天气、算个数学题、拉一份订单数据光靠模型自己的参数是不可能做到的它需要调用外部函数把结果拿回来作为上下文继续推理。这个机制看起来简单模型返回一个函数名和参数你本地执行把结果回填。但真正落地时从模型说什么你就干嘛到模型在复杂任务里自主决定调什么工具、什么时候调、调错了怎么办中间隔着一整套工程问题。面试官出这道题往往就是想通过你现场写代码的方式观察你怎么拆解这套链路。我当时在脑子里快速过了一遍把 Tool Use 拆成了三个能力层级也建议你先这样理解第一层会分发。模型输出结构化指令系统能正确地把指令路由到具体函数并执行。第二层会循环。模型能根据工具返回结果决定下一步动作形成思考-行动-观察的闭环。第三层会编排。系统能管理多个工具的生命周期、上下文长度、失败重试甚至为不同任务组合不同工具。我给面试官的三种方案正好对应这三层。1.2 这也是为什么背一个库不管用有人可能会想现成的框架一大把LangChain、OpenAI Function Calling 都有封装为什么还要手写我个人的理解是面试官想看到的是你脱了框架还能干活的能力。因为工具调用环节最容易出问题的往往就是框架帮你隐藏掉的那些部分——比如参数格式校验、工具执行异常、上下文窗口溢出。这些东西你只有手写过一遍才会真正体会到它们的分量。后面我会持续回到这个观点Tool Use 真正的难点不在让模型返回一个函数名而在模型返回了你不认识的东西之后你的系统还能不能稳稳地兜住。这是三种方案共通的灵魂。2. 方案一最轻量的 JSON 函数分发2.1 核心思路把调用退化成协议匹配第一种方案我用的是最基础的思路让模型先输出一段 JSON里面包含函数名和参数然后本地根据函数名做匹配分发。先说为什么这算一种合格的 Tool Use 方案。模型本身不会真的去执行工具它只会模拟调用动作。所以我们要做的就是把模型的文本输出转换成可编程的指令。最直接的办法就是约定一种 JSON 协议让模型把我想调哪个函数、传什么参数写清楚。这里有个关键误区要提前说很多教程会让你用正则去匹配模型输出里的函数名比如搜get_weather这种字符串。我强烈不推荐。因为模型很容易在别的地方也提到这个词导致误匹配。规范的做法是要求模型输出严格 JSON然后一次性解析。2.2 手写实现定义工具清单和分发器我当时的第一版代码大概长这样非常短但已经是完整的闭环import json from typing import Callable, Any # 1. 定义工具函数 def get_weather(city: str) - str: # 生产环境这里接天气 API return f城市 {city} 的天气数据晴温度 26°C def calculate(expression: str) - str: # 生产环境这里需要做安全校验 return str(eval(expression)) # 2. 工具注册表函数名 - (函数, 参数说明) TOOL_REGISTRY { get_weather: { func: get_weather, description: 查询某个城市的天气参数 city 为城市名, }, calculate: { func: calculate, description: 计算数学表达式参数 expression 为运算式, }, } # 3. 给模型看的工具说明 TOOL_DESC \n.join( [f{name}({params}): {meta[description]} for name, meta in TOOL_REGISTRY.items()] )然后是在你写应用时实际上会这样把整个流程串起来# 4. 核心分发逻辑 def dispatch_action(raw_response: str) - str: 把模型输出解析成函数调用 try: action json.loads(raw_response) except json.JSONDecodeError: return f解析失败无法识别模型输出: {raw_response} tool_name action.get(name) args action.get(arguments, {}) if tool_name not in TOOL_REGISTRY: return f工具 {tool_name} 不存在请从 [get_weather, calculate] 中选择 tool_func: Callable TOOL_REGISTRY[tool_name][func] try: result tool_func(**args) return f工具执行成功结果是: {result} except Exception as e: return f工具执行失败错误信息: {str(e)} # 5. 在对话主循环中调用简化示意 # prompt f你是一个助手当遇到问题时可以调用工具。工具列表\n{TOOL_DESC}\n用户说...如果你需要调用工具请输出 JSON... # raw llm_chat(prompt) # 模型的返回 # final_output dispatch_action(raw)这段代码的核心就一句话提前告诉模型有哪些工具可用再解析它返回的 JSON。分发动作其实只是一张字典查表。你可能会问为什么不让模型直接返回一段自然语言描述然后正则匹配因为自然语言充满歧义。用固定 JSON 协议模型在大多数主流大模型上都能理解得很好而且你的解析逻辑可以写得很干净这也是现代 Function Calling 的前身思路。2.3 一个坑模型输出的 JSON 不一定干净这里要特别提醒第一次尝试这个方案的同学模型输出 JSON 时经常带 Markdown 代码块前缀比如json ...... **。你一用json.loads直接解析立刻报错。我当时为了让这个方案在面试中显得严谨专门加了一个清洗函数去掉可能存在的 Markdown 代码块标记再定位{到}的区间进行解析。生产环境里我甚至见过模型把 JSON 里的双引号写成了中文引号这种事情只能靠防御性代码兜底。这个方案的优点是简单、直观、容易调试适合任务只需调一次工具就回答的场景比如查个天气、算个题、查个订单号。缺点是没有循环模型不会根据工具结果继续思考所以在多步任务面前它基本等于半残。3. 方案二ReAct 循环让模型学会想一步做一步3.1 ReAct 到底在干什么面试官看到第一个方案第一反应是如果工具返回的结果还需要进一步处理呢这就是第二个方案要解决的问题。ReActReasoning Acting的核心是把大模型原本的思维链Chain of Thought和工具调用结合成一种循环模型先输出一段思考Thought说明为什么需要调工具然后输出动作Action也就是调用什么工具、传什么参数系统执行工具把结果作为观察Observation回填模型看到观察结果继续输出下一轮思考……这样循环直到模型认为不需要再调用工具给出最终答案Final Answer。3.2 手写一个迷你版 ReAct我当时的代码写的是一个 while 循环核心是用一个文本模板把历史动作和观察拼接起来再喂给模型# ReAct 主循环简化实现 def react_loop(user_query: str, max_rounds: int 5): history [] round_idx 0 final_result while round_idx max_rounds: # 组装 ReAct 提示模板 prompt build_react_prompt(user_query, history) response llm_chat(prompt) # 追加模型的 Thought 和 Action 到历史 history.append(response.strip()) # 判断是否结束 if Final Answer: in response: final_result response.split(Final Answer:)[-1].strip() break # 从 response 中提取 Action 行 action_line extract_action(response) if not action_line: # 如果模型没给 Action 也没给 Final Answer说明输出有问题 history.append(Observation: 模型输出格式不正确请重新给出 Action 或 Final Answer) round_idx 1 continue tool_name, args parse_action_line(action_line) # 查注册表执行 obs dispatch_action({name: tool_name, arguments: args}) history.append(fObservation: {obs}) round_idx 1 if not final_result: final_result 已达到最大循环次数无法完成请求。 return final_result这段代码的灵魂在build_react_prompt里。当年我在自己的项目里是这么构造的def build_react_prompt(user_query: str, history: list[str]) - str: context \n.join(history) return f你是智能助手可以调用工具解决问题。请按以下格式输出 Thought: 你的推理过程 Action: 工具名(参数) Observation: 工具返回结果由系统提供 最终当你可以回答时输出 Final Answer: 最终回答 工具列表及参数说明 {TOOL_DESC} 当前任务{user_query} {context} 接下来请继续输出 这里的巧妙处在于每次把上一轮的 Thought/Action/Observation 全部填回提示里让模型看见自己的执行过程然后继续推理。你不需要维护复杂的状态机所有的状态都在文本里。3.3 循环的边界和安全问题用 ReAct 方案就不得不考虑两个问题卡死和失控。先说卡死。如果模型反复输出同一个 Action比如搜索了三次还是同样的查询说明它在一个死循环里。我当时处理的办法是加一个简单的去重判断如果连续两轮 Action 完全相同就强制插入一条 Observation 提示该操作已执行过请换一种方式或直接给出 Final Answer。这招在面试里很加分因为面试官认为你考虑到了模型的行为不可控性。再说失控。这里的失控有两种一是模型乱调用风险工具比如eval一个用户输入的表达式二是循环次数无上限导致接口成本爆炸。所以一定要加两个硬性约束工具白名单 最大循环次数。尤其像示例代码里的calculate在生产环境绝对不能直接eval用户输入我当时在代码注释里也标注了。我个人觉得ReAct 是三种方案里性价比最高的一个因为它用非常简单的文本拼接就实现了接近 Agent 的思考闭环。很多开源项目里所谓的 Agent核心循环也就是这么来的只是外面包装了一层又一层。4. 方案三工具编排系统把 Tool Use 工程化4.1 从能用到好用注册中心与 Schema第三个方案面试官当时没主动提是我自己加的理由是前两个方案是面试版第三个是我生产环境真正在用的版。这一版解决的核心问题是把 Tool Use 从手工作坊升级成标准化流水线。首先需要一个工具注册中心所有工具的定义、参数说明、鉴权信息、超时时间全部集中管理。你可以用装饰器来做这件事非常优雅# 工具注册中心用装饰器声明工具 TOOL_SCHEMAS [] TOOL_FUNCS {} def register_tool(name, description, parameters: dict, timeout10): def decorator(func): TOOL_FUNCS[name] func TOOL_SCHEMAS.append({ type: function, function: { name: name, description: description, parameters: parameters, } }) return func return decorator # 使用示例 register_tool( nameget_order_status, description查询订单状态输入订单号返回当前物流与处理状态, parameters{ type: object, properties: { order_id: {type: string, description: 订单编号}, }, required: [order_id], } ) def get_order_status(order_id: str) - str: return query_order_status_api(order_id)这样做的好处是你每一次新增工具只需要在函数头上加一个装饰器所有工具列表会自动汇总然后直接转成模型需要的 OpenAPI Schema。不需要像方案一那样手工维护一个字典还能确保参数描述、鉴权配置不散落在各个代码文件里。4.2 上下文裁剪与多轮状态维护工具一多、循环一长问题就来了上下文窗口会被撑爆。每个工具返回结果都往 history 里塞三轮循环之后可能剩下不到几百 token 给模型思考了。我在生产环境里用的是一套打折策略面试时我讲了个大概但这里可以完整分享出来第一档固定裁剪每个工具返回结果只保留前 200 个字符超长的被截断并加...(已截断)。第二档摘要替换如果工具结果长度超过 500 字先把结果丢给一次轻量模型调用生成一句摘要再填回上下文。第三档过期丢弃超过 5 轮的历史 Thought 直接压缩成一行前面已调用工具 XY结果正常不保留完整内容。这套机制在两个地方特别有用一是要控制成本二是要保持模型推理的注意力集中在最近的决策上。很多 Agent 跑着跑着变笨不是模型问题而是上下文被历史噪音污染了。这属于实战里才踩得到的坑。4.3 失败重试与兜底策略第三个方案另一个核心是对工具执行本身的治理。工具调用不是每次都会成功API 超时参数类型错误比如模型把 int 传成了 string工具内部抛异常多个工具并发时部分失败。我当时给面试官画了一个四层兜底的逻辑文字描述没画图参数校验层在真正执行函数前用工具注册表里的required字段和类型定义做一次本地校验不通过就直接返回错误 Observation不让异常污染内部链路超时控制层每个工具调用默认 10 秒超时超时返回工具超时请尝试其他方式重试层对幂等的查询工具允许网络异常重试一次写操作绝不自动重试这是底线降级层当某个核心工具连续失败时系统可以主动提示用户当前查询服务不可用而不是让模型硬编一个答案。这一套如果你要听我讲得更细其实就是把后端微服务那套熔断、限流、降级的思想搬到了模型工具层。面试官看到这个思路时明显态度不一样了因为这说明你不只是在写代码而是在设计系统。5. 三种方案对比面试现场怎么讲才加分5.1 横向对比表写到这里我想把三种方案放在一起做个对比。方便你自己按场景去套。也方便你要面试的时候在脑子里先画一个表维度方案一JSON 函数分发方案二ReAct 循环方案三工具编排系统核心思路模型输出 JSON本地查表分发模型自主思考-行动-观察循环标准化注册 上下文治理 失败兜底实现复杂度低一两百行代码中核心循环可控高需要较多工程组件是否支持多轮工具调用不支持支持支持且可控上下文管理无简单拼接裁剪、摘要、过期丢弃失败处理基本靠 try-except靠循环内观察回写参数校验超时重试降级适用场景一次性查询、简单问答需要模型动态决策的中型任务生产级 Agent 或复杂业务流程面试/需求匹配度基本功证明体现对 Agent 机制的理解体现系统设计能力我当时给面试官总结的一句话是方案一是入门方案二是进阶方案三是工业界的标准套路。但你要问哪个最常用我现在的答案是组合着用——主体跑 ReAct 循环但工具的注册和兜底全部走方案三的治理体系。5.2 面试官最可能追问的三个问题第一种方案写完面试官大概率会问如果模型就是返回了错误格式怎么办这时候你要答出清洗 重试提示而不是说不会的模型很聪明。模型不聪明的时候多了去了。第二种方案写完大概率会被问你的循环如果停不下来怎么办答案就是最大轮次限制 重复动作检测。第三种方案写完大概率会被问你怎么防止模型调用危险的工具答案是工具白名单 权限标签。生产环境里每个工具都要有requires_auth之类的元信息简单工具直接拒绝未授权调用。我印象里最刁钻的一个追问是如果两个工具参数重叠模型选错了工具怎么办我当时愣了一下然后给出的思路是在工具描述前后增加对比说明比如当用户要查订单如果输入的是订单号用 A 工具如果是手机号用 B 工具把决策依据前置写进描述里。实测下来模型选错工具的概率会明显下降这个技巧后来在我的项目里也一直在用。5.3 真到了生产环境我会怎么选如果是一天之内要上线的内部小工具我选方案一简单直接。如果是一个面向用户的智能客服需要多轮查询和处理我选方案二 方案三的部分治理。也就是核心用 ReAct 循环但工具的注册、超时、参数校验全部走标准体系。如果是一套复杂的业务流程比如订单售后 Agent需要查询订单、判断权益、计算费用、调用退款接口那我至少要上方案三的完整版。因为这种场景工具之间往往有依赖关系调用顺序错了会给用户算错账这种责任不是一两行 try-except 能扛住的。6. 手写 Tool Use 的排坑记录这些坑我替你踩过了6.1 高频问题与排查速查表面试时我只挑了重要的讲但实际写代码的过程里坑远不止这些。我整理了一张排查速查表你手写实现遇到问题时可以直接查现象根因处置方式JSON 解析报错传进来的模型输出有明显代码块后缀模型输出了 Markdown 代码块解析前先剥掉 前缀和 json 标识工具返回内容特别长把上下文撑爆工具结果没有限制长度保留前 N 字 摘要超长截断标注模型连续 3 次调用同一个同名工具模型陷入重复动作检测到完全相同的 Action 时强制提示换思路模型说我要调用工具但没给出工具名输出格式不完整程序侧直接回写一条 Observation 要求补全 Action工具执行报错被模型无视继续硬编答案观察信息对模型不够可信工具异常要在 Observation 里明确该操作失败不要假设结果多轮后模型开始忘记用户本意上下文被历史观察污染把最初用户意图在每个循环前重复注入一次重点说说第二个坑。我见过不少半路出家写 Agent 的同学习惯把每次工具返回的完整 JSON 直接塞进历史导致模型上下文连续几轮都是几百行的业务数据。模型不是不能用这些数据而是它在海量低价值信息里找不到当前决策需要的关键字段。我自己的经验是工具结果是给模型看一眼做决策用的不是给它当数据库用的。需要引用的细粒度数据宁可二次查询也不要一口气塞满。6.2 三条独家经验比代码本身更重要的东西第一工具描述是给模型看的说明书不是给 IDE 看的注释。描述里一定要写清楚什么时候用这个工具甚至可以直接写当用户提到退货时使用此工具不要使用查询工具。我见过很多人的工具描述只写参数含义模型根本不知道什么场景该用它结果就是模型拿起所有工具挨个试一遍。选错工具的根因一半在描述不够决策导向。第二永远让工具返回结构化结果。能返回 JSON 就不要返回纯文本能返回{status: success, data: {...}}就不要返回成功了。因为模型特别擅长读取结构化的键值对它会根据status字段快速判断这个结果是否可信再决定下一步动作。文本软化表达看起来自然机器处理起来全是坑。第三手写代码时一定要保持最小可用闭环。说白了就是第一版先让我后台能跑通用户问题 - 模型 - 工具 - 模型这个链路再逐步加超时、重试、权限那些高级功能。面试时我可以直接在白板上给出核心循环而不是在一堆无关的配置代码里找主线。清晰的闭环面试官一眼就能看出你是真懂还是背过概念。6.3 关于手写这件事的最后一个体会面试结束之后我自己回去又把三种方案全部重写了一遍才发现现场写出来的版本跟生产环境差距有多远——主要差在那些虽然少见但一发生就致命的边界情况比如工具抛错后模型开始编造数据、工具返回了超长列表导致系统崩溃、以及用户一句歧义输入让模型把所有工具都调用了一遍。这些案例我在前几节里都提到了。如果你真的要手写 Tool Use我的建议是不要追求一步到位而是按着方案一先跑通方案二增加思考方案三补工程性的顺序一点点给它加护栏。当你发现自己用三行代码就能挡住模型八十种胡闹的时候你对 Tool Use 的理解就已经超过大多数只会调框架的人了。