面试官让手写一个 Tool Use这事我在模拟面试里遇到过不止一次。说实话第一次听到这个题的时候我愣了一下——不是不会是没想到对方会把问题压得这么具体。Tool Use 说白了就是让模型调用外部函数把“只动嘴”的大模型变成“能动手”的执行者。但现在很多同学背了一堆 Agent 概念真到白板面前让他从零写一个工具调用环路反而容易卡壳。这篇文章我就把当时现场写的三种方案完整复盘一遍从最朴素的提示词加正则方案到原生 Function Calling 协议再到带自动恢复能力的 Agent 循环。每种方案我都会讲清楚适用场景、完整代码、以及面试官会追问的细节。内容不依赖任何特定平台适合正在准备大模型应用方向面试的开发者也适合刚入门 Agent 开发、想搞懂 Tool Use 底层原理的朋友。1. 面试官为什么爱考“手写 Tool Use”先聊点虚的但也是最关键的部分这个题到底在考什么。如果只是考“你会不会调模型的 function calling 接口”那直接问 API 参数就行了没必要让你手写。面试官真正想看的是你对 LLM 应用架构的理解深度——模型和外部系统之间到底是怎么协作的边界在哪里出了问题怎么兜底。1.1 一句话讲清 Tool Use 的本质Tool Use 的本质是给大模型开一个受限的“外部动作通道”。模型本身是个静态的推理器它没有手不能查数据库不能发请求不能操作文件系统。但我们可以约定一种协议模型在生成回复的中间过程中输出一段结构化的指令比如“我要调用函数 get_weather参数是 city: 北京”。宿主程序解析这段指令替模型执行真实动作再把结果塞回给模型让模型基于结果继续推理。这就好比你是一个只能说话、不能动手的专家你身边坐了一个助手。你说“帮我把那份报表拿过来”助手跑过去拿回来给你看你看完继续说“里面第三页的数据好像不对你帮我核对一下”。模型是专家助手就是 Tool Use 的执行层。整个链路里最关键的不是那一次调用而是“说指令—执行—回传—再决策”这个循环通道是否通畅、是否可靠。1.2 “手写”三个字的含义从协议到执行的完整链路面试题写着“手写”很多人以为就是把 API 示例抄一遍其实不是。一个完整的手写实现包含三层第一层是协议层也就是你和模型之间怎么约定调用的格式。是用 JSON 还是用 XML是让模型输出固定标记还是自由文本这个格式必须让模型容易生成、让程序容易解析。第二层是执行层。解析出工具名和参数之后怎么映射到真实的 Python 函数怎么做参数校验工具执行出错时怎么处理。第三层是循环层。一次工具调用往往不够模型可能需要根据上一次结果连续调用多个工具最后才能给出最终回答。这个多轮循环什么时候该停怎么防止模型“钻牛角尖”死循环都需要设计。面试官说“手写一个 Tool Use”潜台词其实是“你能否从零把这三层搭出来并且说清楚每一层为什么这么设计。”接下来三种方案正好对应从简到繁的三层实现思路。2. 方案一提示词约定 正则解析零依赖也能跑第一种方案是面试中最常被想到的因为它不需要模型任何特殊接口只要模型能生成文本就行。核心思路是把工具调用的格式约定写进 system prompt让模型在需要调用外部工具的时候输出一段特定格式的 JSON然后用正则从回复里把这段 JSON 抠出来执行。2.1 提示词怎么写模型才愿意配合这里有个关键技巧你必须在提示词里告诉模型“什么时候该调用工具”而不是让它猜。比如现在你是一个智能助手你可以使用以下工具get_weather(city: string): 查询城市天气calculate(expression: string): 计算数学表达式当且仅当用户的需求需要外部信息或计算时你必须输出以下格式的 JSON 作为你的全部回复 {tool: get_weather, params: {city: 北京}}除此之外正常回复用户即可。注意“作为你的全部回复”这句话很重要。如果不加模型往往会先写一段解释再输出 JSON那样正则解析的复杂度会直线上升。让工具调用指令成为回复的全部内容解析就简单得多。2.2 正则解析与执行的最小实现接下来是执行端。我用 Python 写一个最简版本思路非常直白用正则从模型回复中提取 JSON 块解析出工具名和参数查表执行。import re import json import datetime # 工具注册表名字 - 函数 TOOLS { get_weather: lambda city: f{city}今天晴气温18~26℃, calculate: lambda expr: str(eval(expr, {__builtins__: {}}, {})) } def parse_tool_call(text: str) - dict | None: # 匹配一个 JSON 对象容忍措辞变化 pattern r\{.*?\} match re.search(pattern, text, re.DOTALL) if not match: return None try: return json.loads(match.group()) except json.JSONDecodeError: return None def execute_tool(call: dict) - str: name call.get(tool) params call.get(params, {}) func TOOLS.get(name) if not func: return f错误未知工具 {name} try: return func(**params) except Exception as e: return f工具执行失败: {e} # 模拟对话 def run_once(model_output: str) - str: call parse_tool_call(model_output) if call: return execute_tool(call) return model_output # 没有工具调用直接作为最终回复代码本身只有三十行左右但已经形成了一个完整的“协议—解析—执行”闭环。面试时能从零写出这个结构说明你理解了 Tool Use 的第一个层面。2.3 方案一的天花板为什么只能算“能跑”这个方案的优点非常明显零依赖、兼容任何文本模型、代码可控性强调试起来非常直观。但它的问题也很致命。首先是格式不稳定。模型偶尔会在 JSON 前后加一些说明文字或者把键名写成 “Tool”“params” 混用或者多一个逗号导致解析失败。你会发现做提示词工程的时间比写代码的时间还长。其次是缺少结构化约束。模型并不知道“函数参数要严格符合 JSON Schema”它只是模仿你给的例子。换一个场景、换一套工具就可能出现参数名幻觉——比如实际工具参数是 city模型输出了 location。严格来说方案一适合作为兜底方案或者在没有 Function Calling 能力的模型上做低成本接入。它的价值在于让你理解“工具调用本质上只是文本协议”这一点。但如果要做得稳得看方案二。3. 方案二原生 Function Calling 协议让模型自己输出结构化指令如果面试官只让你“手写”一次大概率期待的是这个方案利用模型自带的函数调用能力。相比方案一靠提示词约束方案二让模型在生成时从工具定义列表里“选一个函数、填参数”输出已经是结构化数据不需要正则去碰运气。3.1 工具定义的本质一份 JSON Schema 说明书先说清楚 Function Calling 是怎么运作的。你在请求里传一个 tools 数组每个工具包含名称、描述和参数 JSON Schema。模型会根据用户问题和这些定义决定是否调用、调用哪个、填什么参数。注意模型不会真正执行任何函数它只是输出一个“调用请求”的结构体。[ { type: function, function: { name: get_weather, description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: {type: string, description: 城市名如北京} }, required: [city] } } }, { type: function, function: { name: calculate, description: 计算数学表达式支持四则运算, parameters: { type: object, properties: { expression: {type: string, description: 数学表达式如12*53} }, required: [expression] } } } ]这里最核心的字段是 description。它对模型来说就是“函数的函数签名”——写得越清楚模型越不容易选错工具、填错参数。我在实际项目中甚至看到过这么一个现象同一个工具description 里说明“仅当用户明确提到查询天气时使用”工具误调用率能下降一半左右。3.2 完整调用环路从发起到执行看代码。以某主流模型的接口风格为例整个环路分两步发起带工具定义的对话请求收到 tool_calls 后解析执行然后把结果回传。import json def chat(messages, toolsNone): # 这里封装 API 请求返回响应对象 # 真实实现需替换为具体 SDK 调用 resp api_client.chat(messagesmessages, toolstools) return resp # 步骤1发请求带上工具定义和用户问题 messages [ {role: user, content: 北京今天适合穿短袖吗} ] resp chat(messages, tools) # 步骤2判断响应是不是工具调用 if resp.tool_calls: tool_call resp.tool_calls[0] name tool_call.function.name args json.loads(tool_call.function.arguments) # arguments 是字符串需要二次解析 # 步骤3执行本地工具 result TOOLS[name](**args) # 步骤4把工具结果以 tool 角色消息回传继续对话 messages.append({ role: tool, tool_call_id: tool_call.id, content: json.dumps(result, ensure_asciiFalse) }) final_resp chat(messages, tools) print(final_resp.content) # 模型基于工具结果回答 else: print(resp.content) # 没有工具调用直接回答注意几个容易被忽略的细节。第一arguments是个 JSON 字符串必须二次解析直接当对象用会报错。第二回传时必须带tool_call_id否则模型不知道这条结果对应哪个调用。第三如果同时有多个tool_calls并行必须逐条回传结果保持 ID 对应关系。3.3 面试加分细节并行调用与参数校验方案二还有一个隐藏考点模型可能在一个回复里发起多个工具调用。比如用户问“北京和上海明天天气分别是多少”模型可能同时输出两个 get_weather 调用、ID 各不相同。实现时最好循环执行所有调用而不是只处理第一个。每次执行结果都以独立的 tool 角色消息回传。另外参数校验要做到执行层。永远不要信任模型输出的参数——它可能把数字传成字符串把必填字段遗漏。我在生产环境里踩过这么个坑模型在测天气时输出了一个不存在的城市名函数查不到数据直接抛异常。后来我给每个工具加了一层 Pydantic 模型做入参校验非法参数先报错给模型模型看懂了会自己纠正这个叫“错误回传让模型自我修正”。方案二比方案一稳定得多但它只解决了“单次调用”的问题。复杂任务需要模型连续调用多个工具并根据前面的结果决定下一步动作。这就要进入第三种方案——完整的 Agent 循环。4. 方案三带自动恢复能力的 Agent 循环生产级闭环第三种方案才是我面试时的重头戏。面试官要的是“Tool Use”但真正的 Agent 应用就是在这个方案上演进的。核心差别在于方案二只管一次工具调用就结束方案三则把“调用—回传—再决策”变成循环直到模型给出最终回答。4.1 用 while 循环把工具调用包起来我先给一个最简的 Agent 循环实现。整体结构就是一个 while 循环每次迭代都检查模型这次是不是需要调用工具需要就执行、回传、继续下一轮不需要就把内容作为最终答案返回。import json def agent_loop(messages, tools, max_iterations5): for i in range(max_iterations): resp chat(messages, tools) if not resp.tool_calls: # 模型没有调用工具说明思考完毕直接返回 return resp.content # 有调用逐个执行 for tc in resp.tool_calls: name tc.function.name try: args json.loads(tc.function.arguments if tc.function.arguments else {}) result TOOLS[name](**args) except Exception as e: # 关键工具报错也要回传给模型让它决定怎么处理 result {error: str(e)} messages.append({ role: tool, tool_call_id: tc.id, content: json.dumps(result, ensure_asciiFalse) }) # 继续下一轮模型会看到所有工具结果 # 达到最大迭代限制返回提示 return 已达最大迭代次数请简化问题或补充信息。这个代码看起来很短但已经覆盖了生产级 Agent 的所有核心要素最大迭代限制、异常回传、多工具调用支持。面试时能写出这个结构面试官基本就会开始追问细节了。4.2 停止条件设计怎么防止模型死循环手写 Agent 循环最容易暴露的设计问题就是停止条件。如果没有终止机制模型遇到矛盾信息可能会无限调用工具烧掉几十次 API 费用。我在设计时一般设置三层保险第一层是模型自己说“结束”。即这一轮返回的tool_calls为空或者finish_reason为正常的停止状态循环自然退出。这是最理想的情况。第二层是最大迭代轮数。我习惯设置为 5 轮既能覆盖绝大多数多步工具任务又不会让单次请求失控。遇到需要更多步的场景可以提示模型“一次请求最多调用 5 次工具请尽量合并查询”。第三层是超时控制。在完整的工程实现里我会给整个循环加一个总超时时间比如 60 秒。超过时间直接抛出异常由上层任务系统接管重试或降级。4.3 错误恢复机制让模型自己“擦屁股”这是方案三最值得展开讲的部分。很多初学 Agent 的人把工具执行失败当成 fatal error 直接终止但生产环境里错误信息本身就是非常有价值的上下文。你把错误信息回传给模型模型通常能自己调整参数或更换策略。举个我实际遇到的例子查天气时传的城市名是英文 “beijing”但我写的天气查询工具只认中文或城市编码返回了 “未知城市”。如果不回传错误用户就会得到一句“抱歉我查不到”。但我在 tool 回传里带上了{error: 未知城市请用中文城市名重试}模型下一轮自动改成“北京”正常拿到结果。这个“失败—回传—重试”的自我纠错模式是人机交互和 Agent 交互最大的思维差异。在服务端代码里错误不是用来异常抛出的而是作为上下文喂回给模型的。4.4 生产级还要补什么鉴权、日志与并发控制面试写到方案三我通常会顺带提两句生产化改造哪怕不写完整代码也让面试官知道你见过真实场景。改造点主要有四个。第一是工具注册表加元数据。每个工具不该只是一个函数而应该有超时时间、权限级别、调用频率限制。比如某个工具只能由高权限用户触发这个判断要在执行层拦截不能指望模型自觉。第二是完整日志追踪。每轮循环的输入输出、工具名、参数、结果耗时都要记录。出问题时靠日志还原整个决策链路。我在项目里把这套日志叫做“思考轨迹”查线上事故的时候价值极大。第三是并发控制。多用户同时调用同一个工具要在服务端做限流和队列防止外部接口被打爆。第四是成本控制。每次循环都是一次模型调用都要计费。上线前根据任务复杂度调整 max_iterations避免“能用但太贵”。5. 三种方案放在一起怎么选、怎么跟面试官聊三种方案都写完了面试官大概率会问你觉着哪个更好这时候最忌讳的回答是“方案三最好”因为脱离场景谈优劣没有意义。我把它们的核心差异整理一下你现场可以直接用。维度方案一正则解析方案二Function Calling方案三Agent 循环协议来源提示词约定模型原生结构化输出基于方案二做循环依赖条件任何文本模型需支持工具调用接口需支持工具调用接口稳定性一般靠提示词调教高结构约束强高但结构更复杂多步能力不支持单步需自行扩展原生支持循环决策错误处理解析失败直接放弃可回传错误但只一轮支持自我纠错与重试适用场景低成本演示、老模型接入普通工具问答复杂多步任务、Agent 应用面试官想听的答案不是“哪个好”而是你“基于什么条件做选型”。我的口头禅是没有最好的方案只有当前场景下的最优选。5.1 一条完整的回答路径从分层到选型如果现场时间宽裕我建议按三步来组织你的回答。第一步先给全貌。Tool Use 的实现本质上分协议层、执行层、循环层。我这三种方案正好是每一层不同成熟度的组合。第二步讲边缘情况。方案一适合模型不支持结构化输出时兜底方案二适合单轮工具问答方案三适合真正的 Agent 工作流。选型的判断标准只有一个你的任务需不需要多轮决策。需要就得上方案三不需要就用方案二别为了炫技过度设计。第三步说改进方向。我会主动提一句如果要把方案三真正部署上线还要补监控、鉴权、成本控制这些工程化组件。这样显得你不只懂代码还懂落地。5.2 面试官常用的追问方向据我观察围绕这个题的高频追问不出下面这几类。第一类是“循环怎么终止”。听到这个问题要开心因为说明你写到了循坏方案。直接答三层保险模型自主结束、最大轮数限制、超时熔断。第二类是“工具返回出错怎么办”。这个我已经写进代码了把错误作为 tool 消息回传。当场可以补充一个“模型自我纠错”的例子增强说服力。第三类是“多个工具并行调用怎么处理”。答循环遍历所有 tool_calls每个结果回传时带各自的 tool_call_id。第四类是“如何防止模型注入恶意参数”。这个要郑重回答工具执行层必须做白名单校验、参数类型校验和权限校验永远不要直接执行模型传进来的代码。我在上面的calculate工具里特意限制了eval的内置函数只允许纯表达式计算这就是安全边界的考虑。6. 实战踩坑与排查技巧实录三种方案在面试里写得再漂亮真正跑起来还是会遇到各种问题。我把平时调 Tool Use 时最容易翻车的几个坑集中拉一遍附带排查思路你可以直接当速查表用。6.1 JSON 解析失败模型永远不按格式出牌症状方案一场景下正则抠出来的 JSON 块 json.loads 直接报错有时多一个逗号有时键名带了双引号转义符。处理思路是别挣扎让解析器更宽容一点。我用过两个技巧一是把正则匹配从“匹配 JSON”改成“匹配三引号代码块”让模型输出更规范二是解析失败时模板化重试一次提示模型“上次输出格式有误请重新输出严格的 JSON”。不过规律是这些技巧治标不治本。只要模型不支持结构化输出格式稳定性永远靠运气。所以做工程选型时我通常把方案一当作降级备用而不是主线。6.2 参数幻觉模型硬编了一个不存在的城市症状工具定义里明确写了 city 参数接受城市编码模型却输出了城市中文名查询接口返回空。这种情况处理最稳的办法是参数校验环节把错误包装成合法结果回传给模型让它自己纠正。我在某些场景还会给工具函数加上同义词映射比如把常见的英文地名转成中文编码模型出错概率明显降低。6.3 上下文越搅越乱工具结果太长模型开始胡说八道症状某工具返回了一大段日志回传后模型下一轮回答开始引用无关内容甚至编造日志里不存在的字段。原因很简单上下文长度超了模型的有效注意力窗口。解决办法之一是对工具结果做截断只保留关键字段办法之二是让工具本身支持参数化输出比如加一个limit参数只返回最新 N 条办法之三是顶层做摘要用一次模型调用把长结果压缩成两三句话再喂回主循环。日志查询、日报生成这类应用里我用第三种办法最多压缩后模型决策准确率明显回升。6.4 模型不上路明明该调用工具它非要硬答症状用户问“现在上海气温怎么样”模型直接回答“上海位于中国东部气候温和”之类正确的废话。常见原因有两个工具定义里的 description 写得太抽象或者提示词没有强约束“当需要实时数据时必须调用工具”。我在部署阶段会在 system prompt 里加一条硬规则凡是涉及天气、股票、新闻等动态信息的问题必须先调用工具再基于工具结果回答。加上这条规则之后工具触发率能从五成拉到九成以上。6.5 死循环模型反复调用同一个工具结果没变化症状Agent 循环里模型多次调用同一个查询工具拿到相同结果然后继续调用跟复读机一样。除了设置最大迭代轮数我还会做一层“结果缓存去重”如果某一轮工具参数和上一轮完全相同直接把上次结果原样返回并追加一条“你已经查过这个数据了请基于现有信息继续”。这个技巧能在不改模型的情况下显著减少无效循环用户侧感知也更好。6.6 一个快速排查清单把上面的坑汇总成一张图线上排查时可以按这个顺序过一遍先确认有没有“该调用工具却硬答”再确认“调用的工具名和参数是否合法”再确认“工具结果是否正确回传并且和 tool_call_id 对应”最后确认“循环有没有终止”。绝大多数问题都出在第二和第三环节不是模型不行是回传链路上丢了信息。收尾我的几点体会面试这个题之后我自己又把三种方案在本地跑了好几轮有些体会想多说两句。第一Tool Use 的复杂度永远藏在细节里。表面上看就是“模型输出一个 JSON程序执行一下”但真正稳定运行需要协议设计、参数校验、错误回传、循环终止这一整套机制。手写一遍你对“为什么 Agent 框架那么复杂”的理解会深很多。第二选方案的本质是选“你把多少控制权交给模型”。方案一里所有格式靠提示词管控制权在开发者方案二里格式靠模型原生能力管控制权在模型方案三里连“下一步做什么”都交给模型决策。控制权越多系统越灵活但也越不可控。生产环境一定是混合的——常规路径走方案二复杂分支才进方案三的循环简单降级走方案一。第三面试时手写代码不是目的展示“边界感”才是。你能写出来工具调用说明会 API你能说清楚每种方案的局限和适用前提说明在真实项目里踩过坑。这个分寸感比代码本身更能打动面试官。这几种方案绝大多数场景下是够用的。如果后面遇到特别复杂的需求——比如工具数量超过几十个、工具之间还有依赖关系——那会涉及更重的语义路由和任务编排就是另一篇文章要聊的话题了。