面试官让我在白板上手写一个 Tool Use 时我其实有点意外。这道题不算偏但真正动手写起来和平时在业务代码里直接调接口完全是两码事。我当时没有急着写代码而是先想清楚了三个层次最省事的写法、最能体现原理的写法、最接近真实生产环境的写法。这篇文章就把这三个方案完整复盘一遍每种方案解决什么问题、为什么这么设计、核心代码长什么样、以及面试官最可能追问的坑在哪里。如果你正在准备 Agent / Function Calling 相关的面试或者刚接触工具调用这个概念这篇应该能帮你在五分钟内把整条链路一次理清。1. 面试官的这道题到底在考什么1.1 “Tool Use”不是一个 API 参数而是一个完整闭环先说一个很多人容易忽略的事实大模型本身是“算”不了外部世界的。它既不能帮你查天气也不能去数据库里捞订单它只会根据上下文生成下一个 token。所谓 Tool Use工具使用 / 函数调用本质上是让模型把“意图”输出出来然后由我们的程序代替它执行动作再把真实结果以消息的形式喂回给模型。整个闭环长这样用户提出一个问题比如“滨江市明天适合出门吗”模型判断自己不知道天气于是输出一个“工具调用意图”我要查天气参数是“滨江市”程序截获这个意图真正去调用天气服务拿到结果程序把结果作为一条新消息回传给模型模型看到结果再生成最终回答所以无论你用什么方案底层都是同一个循环模型生成调用意图 → 程序执行工具 → 结果回传 → 模型继续生成。面试官让你手写核心就是想看你能不能在不依赖任何框架的情况下独立把这个闭环搭出来。1.2 为什么这种题适合作为手写题因为它可以考察的点非常多。消息结构怎么组装、结构化输出怎么解析、工具执行出错怎么办、循环如何保证终止一个手写题可以把你的工程基本功全部拉出来。我后来复盘面试官其实是在三个维度上层层递进第一个维度是“接入层”你有没有用过平台自带的函数调用能力知道消息该按什么格式传第二个维度是“控制层”你能不能掌握这个循环本身而不是只复制官方示例第三个维度是“工程层”你写出来的东西如果遇到解析失败、工具异常、上下文过长还能不能稳定工作。三个方案恰好对应这三个维度这也是我现场回答时的结构。1.3 动笔之前我先反问了一句面试官让我手写时我没有立刻拿笔。我先确认了一个关键问题“这里是希望我直接用平台上现成的函数调用能力还是要求我自己实现整套协议不依赖平台封装”这个问题很值得问。因为两种诉求不一样前者只要调通 SDK 就够难度低后者要自己定义工具描述的文本协议自己写解析器自己维护多轮循环难度完全不是一个级别。把边界问清楚比闷头写一堆面试官不想要的东西聪明得多。那次面试官听完就笑了说“你先说思路能写多深写多深”于是我按由浅到深的顺序给了三个方案。2. 方案一从业务接入角度用平台自带的函数调用先跑通2.1 为什么先写这个方案方案一的核心思路是走最短路把工具列表通过标准格式传给模型让模型返回结构化的 tool_calls 字段程序解析后执行再把结果作为 tool 角色消息传回去。我先写它是因为这是目前主流大模型接口都支持的能力也是实际业务里用得最多的接入方式。面试官听到“我知道平台原生工具调用的消息结构”至少说明你不是只会写提示词。2.2 一段能说明思路的最小实现先声明一个重要前提chat_completion是我用来占位的函数实际项目中它会替换成具体的大模型接口调用。在面试现场你甚至不用写出真实 SDK只要把协议结构画清楚就算过关。import json # 假设这是对某个兼容 messages 协议的大模型接口的封装 def chat_completion(messages, toolsNone): # 返回结构固定包含 content 和 tool_calls 字段 pass def get_weather(city: str) - str: return f{city} 当前温度 26 度多云 def calculator(expr: str) - float: # 仅作演示真实系统绝不能用 eval return eval(expr) TOOLS [ { 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] } } } ] TOOL_IMPL { get_weather: get_weather, calculator: calculator, } def run_with_platform(messages, max_rounds3): for _ in range(max_rounds): response chat_completion(messages, toolsTOOLS) messages.append({ role: assistant, content: response.get(content, ), tool_calls: response.get(tool_calls, []), }) tool_calls response.get(tool_calls, []) if not tool_calls: return response.get(content, ) for tc in tool_calls: fn_name tc[function][name] args json.loads(tc[function][arguments]) result TOOL_IMPL[fn_name](**args) messages.append({ role: tool, tool_call_id: tc.get(id, ), content: json.dumps(result, ensure_asciiFalse) }) return 已超过最大轮数停止调用这段代码的关键不在于调了什么 SDK而在于它展示了三件事工具列表如何描述、多轮循环如何组织、工具结果如何回传。这三件事恰好是后面两个方案的地基。2.3 这个方案的边界在哪里方案一其实是“借用平台的判断力”来实现 tool use。平台替你做了工具选择、参数提取、结构化输出你只需要执行和回传。所以它接入快、容错率高但有一个致命弱点如果模型平台不支持 function calling或者你需要换一个更轻量的模型这套就废了。还有一个隐患是平台帮你定了协议你就不太容易看到协议内部发生了什么。一旦出现参数类型错误、工具不存在、循环不终止的问题很多人只能瞪着报错干着急。面试官如果继续深挖就会从这里切入你接下来必须给他看你的“控制层”。3. 方案二从协议解析角度手写一个不依赖封装的 ReAct 循环3.1 思路转换把工具调用当成普通文本来设计方案二的核心是丢掉平台专用的 tool_calls 通道转而使用一种“文本协议”。具体来说我们在系统提示词里定义一个约定格式让模型想调用工具时输出一段可解析的 JSON程序解析出这段 JSON执行工具再把结果塞回历史消息。这种思路本质上是简化版的 ReActReasoning Acting模型先生成推理和行动意图程序给出观察结果模型再基于观察继续推理。只要模型能遵循格式任何聊天模型都能跑通 tool use不挑平台。我用的协议是自定义标签包住 JSON比要求模型“只输出 JSON”更好解析因为中间可能出现推理文字有了标签就能精准抽取。import json import re TOOL_IMPL { get_weather: get_weather, calculator: calculator, } TOOL_DESCRIPTIONS \n.join([ - get_weather(city: str): 查询某个城市的实时天气, - calculator(expr: str): 计算数学表达式的结果, ]) SYSTEM_PROMPT f你是智能助手你可以调用以下工具来完成用户任务 {TOOL_DESCRIPTIONS} 当你需要调用工具时请输出如下格式不要输出多余内容 tool_call{name: 工具名, args: {参数名: 参数值}}/tool_call 当你拿到工具结果后再直接回答用户的问题。一次只能调用一个工具。 def parse_tool_call(text: str): if not text: return None # 兼容模型把 JSON 包在 markdown 代码块里的情况 text re.sub(r(?:json)?\s*([\s\S]*?), r\1, text) pattern rtool_call(.*?)/tool_call m re.search(pattern, text, re.S) if m: try: return json.loads(m.group(1).strip()) except json.JSONDecodeError: return None # 少数情况下模型会直接输出 JSON也尝试解析 try: data json.loads(text.strip()) if name in data and args in data: return data except json.JSONDecodeError: pass return None3.2 循环主体把观察结果当成“新的证据”喂回去解析函数写好后核心循环并不复杂但每一步都承担一个职责。def run_tool_loop(user_input: str, max_steps: int 5): history [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input}, ] for _ in range(max_steps): resp chat_completion(history) history.append({role: assistant, content: resp}) call parse_tool_call(resp) if call is None: # 模型没有生成工具调用说明它可以直接回答 return resp if call[name] not in TOOL_IMPL: history.append({ role: tool, content: 错误: 工具不存在请核实工具名后重新调用, }) continue try: result TOOL_IMPL[call[name]](**call.get(args, {})) except Exception as ex: result f工具执行失败: {ex} history.append({ role: tool, content: json.dumps(result, ensure_asciiFalse), }) return 已达到最大迭代步数停止运行这里最关键的一步是工具执行出错时我没有直接中断而是把错误信息当作普通观测结果回传给模型。这么做的好处是模型看到“缺少参数 city”这类错误后下一轮会自动修正参数再试一次。这是 Agent 自愈的基础也是面试官最想听到的工程判断。3.3 手写方案最容易栽的三个坑第一个坑是解析不够鲁棒。模型给的输出经常带 markdown 代码块、多余的空格、甚至前后有解释性文字你只写一个json.loads(resp)一定会挂掉。所以我在parse_tool_call里先剥离代码块再用正则提取标签内容最后才尝试直接解析 JSON。第二个坑是没有终止策略。模型可能因为工具结果太复杂而陷入反复调用。设一个max_steps是底线我现场写的是 5 步因为多数工具链走到 3 步以内就能出结果。第三个坑是提示词没有约束“一次只能调用一个工具”。如果模型一次输出两个 JSON你的解析器就会漏掉。为了避免多工具并行带来的复杂度我在方案二里明确要求一次只调用一个等方案三再讨论并行执行。3.4 方案二的价值判断方案二的代码量比方案一多但它不依赖任何平台的“专用通道”协议完全由你定义。面试官可以从你的代码里看到你会设计结构化输出、你会处理解析异常、你知道循环必须终止。更重要的是这一步展示了你并不是只会“传几个 tools 参数”的调包侠。你有能力在一个无封装的环境里把 Tool Use 从协议层搭起来。这也是我认为手写题里最值得优先展示的方案。4. 方案三从工程落地角度把它扩展成一个可上线的迷你 Agent4.1 为什么方案二还不能直接上线方案二能跑通闭环但离“能上线”还有距离。真实生产环境里的 Agent 要考虑的问题包括工具的注册和管理、工具执行超时、参数校验、上下文长度控制、多轮对话的状态隔离甚至并发调用工具。散装函数和全局变量硬凑出来的代码维护三个月后就是一锅粥。方案三的思路不是增加复杂功能而是把方案二里所有的散装逻辑归位工具统一定义并注册到注册表模型调用统一经过执行器Agent 循环统一管理消息和终止条件。结构清晰、可测试、可扩展。4.2 核心骨架工具注册表与迷你 Agentfrom dataclasses import dataclass, field from typing import Callable, Any dataclass class ToolSpec: name: str description: str func: Callable[..., Any] class ToolRegistry: def __init__(self): self._tools {} def register(self, spec: ToolSpec): self._tools[spec.name] spec def list_prompt(self) - str: return \n.join( f- {name}: {spec.description} for name, spec in self._tools.items() ) def execute(self, name: str, args: dict) - str: if name not in self._tools: return 错误: 工具不存在 spec self._tools[name] try: result spec.func(**args) return json.dumps(result, ensure_asciiFalse) except TypeError as e: return f参数错误: {e} except Exception as e: return f执行异常: {e}对应的 Agent 类只负责一件事维护消息列表循环调用模型直到模型不再请求工具。class MiniAgent: def __init__(self, registry, model_fn, max_steps5): self.registry registry self.model_fn model_fn self.max_steps max_steps self.messages [] def run(self, user_input: str) - str: self.messages [ {role: system, content: 你可以调用工具完成任务。\n工具列表:\n self.registry.list_prompt()}, {role: user, content: user_input}, ] for _ in range(self.max_steps): resp self.model_fn(self.messages) self.messages.append({role: assistant, content: resp}) call parse_tool_call(resp) if call is None: return resp observation self.registry.execute( call.get(name, ), call.get(args, {}) ) self.messages.append({role: tool, content: observation}) return 已达到最大步数任务未完成请考虑拆分问题和方案二对比区别在于工具不再散落在一个全局字典里而是通过注册表统一管理参数错误、工具不存在、执行异常都在执行器内部被捕获并返回成文本观测。Agent 本身不需要关心工具是怎么实现的只关心消息循环。4.3 面试中值得展开讲的三个工程细节第一个是“错误回传”的粒度。我在ToolRegistry.execute里区分了参数错误和执行异常因为模型看到“参数错误”和“执行异常”之后的修正路径完全不同。参数错误对应 JSON 补齐执行异常往往需要换一种工具或换一个参数。第二个是工具结果太长的问题。真实生产里一个工具可能返回几万行日志直接塞回消息列表下一轮调用模型时上下文会爆掉。我通常会加一个截断策略保留结果的前 2000 个字符并在截断处打上标记让模型知道信息不完整。第三个是并行调用。方案二禁止一次调多个工具但真实场景里“查询 A 城市天气 查询 B 城市天气”完全可以并行执行。要做到这点解析器要支持模型返回多个 JSON执行阶段用线程池并发跑再把多个观测按顺序回传。这是我当时主动提的扩展点面试官明显更愿意听这样的设计延伸。4.4 这个方案的上限与下限方案三的下限是你在白板上能画出一个注册表加一个 Agent 类代码逻辑半小时内可以讲完上限是它可以继续长出工具参数校验、权限白名单、工具超时控制、对话记忆压缩等能力直接往框架方向演进。面试时讲方案三重点不在于把代码全部写完而在于讲清楚职责边界谁描述工具、谁执行工具、谁维护循环、谁终止循环。职责边界清楚了代码自然就是按这个骨架填肉。5. 三种方案怎么选面试现场的取舍和表达策略5.1 三个方案到底差在哪里维度方案一方案二方案三对平台的依赖强依赖专用函数调用能力弱任何聊天模型都能跑中底层仍是通用模型代码量最少中等较大可控性低中高解析鲁棒性平台保证自己维护有风险自己维护但错误可观测循环终止需要自己补必须自己写强制内置工程化程度低中高面试展示点你熟悉消息协议你理解控制循环你有 Agent 架构设计观这个表格并不是说方案三永远最好而是说三个方案对应不同诉求。如果面试官问“你在业务里怎么布局的”你就讲方案一加方案三的组合如果问“不依赖平台你怎么实现”就重点展开方案二。面试的本质不是背一个答案而是让对方看到你能根据约束条件灵活选择。5.2 我在现场的组织顺序我实际在白板上写方案时不是从代码开始而是先画了一个循环图模型生成意图、程序执行、结果回传、再生成。把这张图画完面试官基本上就确认你理解了 Tool Use 的本质。接下来我按照“接入最快 → 协议自控 → 工程落地”的顺序讲。方案一用一分钟带过因为它没有太多可写内容方案二是重点我把parse_tool_call和循环代码写完整方案三只写了类名和方法签名剩下的用口述讲职责划分。现场时间通常只有二十分钟把宝贵的代码时间留给最能体现原理的方案二是我建议你采用的分寸。5.3 面试官追问最密的地方根据我这段准备经历复盘面试官通常会在三个地方连续追问第一“如果模型一直调用工具怎么办”答案不是简单地设置max_steps而是说明你的终止条件可以包含达到最大轮数、模型连续两次调用同一个工具、工具结果为空、或者用户主动打断。第二“如果模型输出的 JSON 解析不了怎么办”建议强势一点回答解析失败本身也是一种观测结果把“格式错误请重新输出 JSON”作为 tool 消息回传给模型让模型自己重写。这一招比在代码里用正则硬救更贴近生产真相。第三“工具执行失败是直接抛给用户还是回传给模型”关键点在于区分失败类型。参数类错误回传模型让它修正权限类错误直接拒绝并告知模型“该操作被禁止”而不可恢复的系统级错误则中断并对接兜底逻辑。能这样区分的人说明真的处理过异常。6. 这类手写题我的复盘和准备建议6.1 记住“一个循环 两个接口”就够把三个方案全部拆开揉碎后留下的骨架其实很简洁大模型与工具执行器之间不断交换消息这是一个循环模型到程序的“调用意图解析”是一个接口程序到模型的“结果注入”是另一个接口。所有平台的 function calling 封装本质都是在这四个节点上做规范化。你能用自己的话把这个模型解释清楚任何变体题都拦不住你。6.2 建议你亲手跑一遍的两个实验第一个实验是用顺手能拿到的任意模型跑通方案二然后故意构造几个极端输入模型输出前后带解释文字、模型输出 markdown 代码块、模型把 JSON 写成了多行、模型调用了不存在的工具。观察你的解析器会怎么处理这个实验能快速暴露你对鲁棒性的理解。第二个实验是给循环加上重试逻辑和最大步数后测“工具连续失败两次”的场景。比如让 calculator 工具收到一段非法表达式看模型是在错误观测的引导下修正参数还是陷入死循环。你会真正理解为什么错误回传比中断更符合 Agent 的自愈逻辑。6.3 写在最后的一点体会写这种手写题我最怕的不是写不出来而是把“调包”当成了“实现”。方案一能帮你活着方案二能帮你出彩方案三能帮你和正在做 Agent 的人对上暗号。面试官想看的其实是你在没有任何 SDK 兜底的前提下能不能独立把这条路走通。先画循环再写解析再谈终止这串动作练熟了下次遇到“请你手写一个 Tool Use”你也能像我一样从最开始就知道自己会被问到哪里。