
面试官突然甩过来一个问题不用框架给我手写一个 Tool Use。我第一反应是这不是调一下接口就完事了吗等我真坐下来写才发现这里面的门道比想象中深得多。Tool Use在大模型开发里几乎是避不开的核心话题它解决的是大模型只能“说”不能“做”的尴尬——模型再聪明生成的文字也无法直接替你查天气、发邮件、改数据库。把模型输出和外部动作打通的过程就是 Tool Use。后来在那场面试里我先后写了三种方案从最笨的提示词约束到结构化输出再到一个相对完整的 Agent 雏形。写完之后面试官明显表情不一样了他说前面那几个人只会背 function calling 的概念你是真能写。这篇文章我就把这三个方案完整拆出来包括代码、踩坑和面试追问的思路。不管你是准备面试还是正在做 Agent 落地应该都能拿到点东西。1. Tool Use 到底在干什么面试题背后的真实考点先聊一个基础问题为什么需要 Tool Use大模型本质是一个概率语言模型给它一段文本它把可能的下一段文本预测出来。这个过程非常强但它有一个天然短板——模型内部没有“执行环境”。它可以告诉你今天北京可能下雨但它无法真的去气象台拉数据它可以给你写一封邮件草稿但它不能把邮件发送出去。所谓 Tool Use就是在模型和外部世界之间架一座桥模型负责思考、决策、生成参数系统负责真正执行动作再把执行结果喂回给模型让模型继续推理。你可以这样理解模型是个只能说话、不能动手的实习生你给他配了一个手脚麻利的助理。实习生说出“帮我订个明天十点的会议室”助理去执行回来告诉他“订好了在A区3层”实习生再基于这个结果汇报给你。1.1 一个最简单的工作流程一个典型的 Tool Use 调用链长这样开发者预先声明一批工具包括工具名称、用途描述、参数结构。用户输入问题。模型根据问题判断需要调用哪个工具生成结构化的调用请求。系统解析请求执行对应函数。返回结果给模型模型生成最终回复。这个流程里最核心的两个环节是模型怎么“说”我要调工具以及系统怎么“听懂”并执行。两种能力的实现深度就是三种方案的差异所在。1.2 面试官真正想听到的三个层次这种手写题目面试官一般有三个考察层次对应不同水平的候选人第一个层次基础应用。你有没有用过各家模型厂商提供的 function calling 接口能不能说清楚工具描述怎么写、参数结构怎么定义这个层次背过文档就能过。第二个层次协议设计。如果不依赖厂商的特殊接口你能不能从零设计一套“模型输出某种固定格式文本程序解析后执行”的协议这里面包含格式约定、解析策略、容错处理考察的是你对工程细节的把控。第三个层次系统思维。工具调用了之后结果怎么进入下一轮对话多轮调用怎么终止工具报错了怎么反馈有没有对循环的安全防护这个层次往往能把“熟练工”和“真正做过 Agent 系统的人”区分开。我当时从第一个层次直接写到了第三个层次用的正好是三种递进的方案。下面我一个个展开。2. 方案一Prompt 硬约束 正则兜底最快跑通的第一版这是最原始、也最能体现“手写”精神的做法。思路很简单我不依赖任何模型厂商的特殊接口只靠提示词约定一个输出格式然后用正则把格式从模型回复里抠出来。2.1 核心思路让模型把动作“说”出来既然模型只能输出文本那我就在系统提示词里规定一个特殊格式比如如果用户的问题需要调用工具请严格输出以下格式 TOOL_CALL: {name: 工具名, arguments: {参数名: 参数值}}然后我在程序里用正则去搜这段文本一旦命中就执行对应函数。这本质上就是在用文本协议模拟 function calling。2.2 完整实现示例先写一个最小可运行版本import json import re from typing import Any, Callable, Optional # 模拟一个查询天气的工具 def get_weather(city: str) - str: # 真实场景这里会请求天气服务演示直接返回 return f{city} 天气晴气温 25 度微风。 # 工具注册表第一版先用 dict 简单维护 TOOLS: dict[str, Callable] { get_weather: get_weather, } SYSTEM_PROMPT 你是一个智能助手。当用户的问题需要调用工具时 请严格按照以下格式输出不要输出多余内容 TOOL_CALL: {name: get_weather, arguments: {city: 城市名}} 如果没有特殊需求正常回答用户即可。 def parse_tool_call(text: str) - Optional[dict[str, Any]]: # 核心正则匹配 TOOL_CALL: 后面的 JSON 对象 pattern rTOOL_CALL:\s*(\{[^{}]*\}) match re.search(pattern, text) if not match: return None try: return json.loads(match.group(1)) except json.JSONDecodeError: return None def handle_tool(data: dict[str, Any]) - str: name data.get(name) args data.get(arguments, {}) func TOOLS.get(name) if not func: return f错误找不到工具 {name} return func(**args) def simple_agent(user_input: str) - str: # 第一版单次调用没有循环 messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input}, ] response llm_chat(messages) # 替换成实际模型调用 parsed parse_tool_call(response) if parsed: return handle_tool(parsed) return response这里我故意把“注册表”做得很简单就是一个 dict 映射工具名到函数。对于第一版方案这是完全够用的因为它只需要证明“模型能输出你能解析”这条链路是通的。2.3 为什么用正则而不是直接 json.loads我试过直接让模型输出一个纯 JSON 对象然后整体解析实战中发现很不稳定。模型有时候会在 JSON 前后加解释文字比如“好的我来帮你查询{...}”。如果直接再用整段文本做json.loads一定会报错。用正则只抽取TOOL_CALL:标记后面的{...}部分等于先把噪音丢掉了再进入解析成功率会高很多。还有一种更激进的做法——把解析逻辑包装成“找不到标记就原样返回”我强烈建议保留这个兜底因为用户不会总在触发工具场景里模型偶尔也会自主决定不调用工具没必要强制让它命中正则。2.4 这个方案踩过的三个坑第一上下文污染。第一版本没有把工具结果塞回对话里。模型调用完工具后第二句话完全是“凭空继续”因为它看不到查询结果。这个如果不解决方案一只配做 Demo。第二模型幻觉输出不存在的方法名。用户说“帮我关一下灯”我注册表里根本没有关灯工具但模型硬是生成了一个。后来我加了没有可用工具时直接拒绝的提示词情况好一些但彻底解决还是要靠方案二的机制。第三JSON 截断。长输出场景下模型生成到一半被截断正则匹配到的{...}是残片json.loads直接崩。后来我在解析里加了 try-except并提示用户重新描述问题才不至于让整个系统炸掉。方案一最大的价值是快整个实现不到五十行就能跑通用来理解 Tool Use 的本质逻辑很合适。但它离“能上生产”还差得远。紧接着我就写了第二个方案。3. 方案二结构化输出JSON Schema 函数调用机制写第二个方案的时候我决定不再自己跟“自由文本正则”搏斗而是把格式约束交给模型供应商提供的结构化接口。理论上不同模型叫法不同但底层思路一致你给模型一份工具清单模型在内部编码阶段就直接决定是否调用工具并返回一个结构化的 tool_calls 字段不需要你从一段自然语言里“猜”。3.1 思路升级把格式约束从提示词层面提升到协议层面方案一的问题在于模型“说人话”和“输出工具指令”混在同一个文本流里解析只能靠运气。方案二则把两者分开模型返回的内容里有一个专门的tool_calls字段里面是结构化的 JSON 数组程序可以直接遍历。关键区别是模型供应商在训练和服务阶段就对这类调用做了对齐输出稳定性远超自由文本。每个工具的描述通常包含以下几项name工具名程序里调用函数的标识description描述工具能做什么帮助模型判断什么时候用parameters参数结构符合 JSON Schema 规范包含类型、必填项、枚举范围有一个很关键的经验description写得越具体模型选错工具的概率越低。比如“get_weather”不要只写“获取天气”要写“根据用户提供的城市名获取实时天气信息支持国内主要城市”模型才知道这个工具的适用范围、边界和触发条件。3.2 完整实现示例下面是模拟实现不依赖特定厂商 SDKfrom typing import Any, Callable, Optional # 工具定义附带 JSON Schema 参数描述 WEATHER_TOOL { name: get_weather, description: 根据城市名获取实时天气信息支持国内主要城市, parameters: { type: object, properties: { city: {type: string, description: 城市名如 北京、上海} }, required: [city] } } OPEN_TOOLS [WEATHER_TOOL] # 模拟模型服务返回的响应结构 class MockResponse: def __init__(self, content: str , tool_calls: Optional[list] None): self.content content self.tool_calls tool_calls or [] def llm_chat_with_tools(messages: list, tools: list) - MockResponse: # 真实场景调用模型服务的结构化输出接口 # 这里演示返回一个需要调用天气工具的响应 return MockResponse( content, tool_calls[{ id: call_001, name: get_weather, arguments: {city: 北京} }] ) def execute_tool_call(tool_call: dict, registry: dict[str, Callable]) - str: name tool_call[name] args tool_call.get(arguments, {}) func registry.get(name) if not func: raise ValueError(f未注册的工具: {name}) return func(**args) def agent_v2(user_input: str) - str: messages [{role: user, content: user_input}] response llm_chat_with_tools(messages, toolsOPEN_TOOLS) if response.tool_calls: results [] for tool_call in response.tool_calls: result execute_tool_call(tool_call, TOOLS) results.append(result) # 这里应该把结果作为 tool 消息传回模型再生成最终回复 return f工具执行结果{results} return response.content你注意我在代码里留了一个和方案一完全不同的点tool_calls是一个独立字段而且里面有id。这个id很关键稍后模型会用它来对应工具结果多个并发工具调用时也不会搞混。3.3 和方案一相比质变在哪里最直观的体验是解析率。方案一不管怎么调提示词总有模型忍不住在输出里加几句客套话正则一旦被带偏工具调用直接失败。方案二从机制上就绕开了这个问题因为模型服务方已经把“生成工具调用”变成了一种受控行为。我自己的一个测试印象很深拿同一批 100 条测试用例方案一解析成功率大概在七成左右有些较长的句子或带数字的句子特别容易翻车方案二的调用命中率能到接近满分只有极少数情况下模型会给出一个不太准确的参数值。3.4 但它仍然缺失了关键一环多轮循环如果用面试官的视角看方案二它只能证明你“会用工具调用接口”。但真正的 Agent 远不止这一次调用它要求系统能看到工具结果之后决定下一步是继续调用工具还是回复用户。比如用户问“北京和上海哪个更适合穿短袖逛大街”第一个工具获取两地天气模型看了结果后还得调用一个“周末黄历”工具判断是否下雨再综合输出。这个过程是循环不是单次。方案二在单轮场景下很稳但到了多轮场景状态管理、终止条件、异常重试这些全都没有。这也是我紧接着要做的第三个方案的出发点。4. 方案三注册中心 循环执行一个可落地的 Agent 雏形第三次我决定把牌全打出来。方案三的本质是两层设计一是工具注册中心统一管理工具描述和执行函数二是运行循环让模型在多轮决策中反复调用工具直到得出最终答案。4.1 工具注册中心设计思路工具注册中心要解决三个问题怎么收集工具、怎么描述工具、怎么执行工具。我见过很多 repo 把工具定义散落在一堆常量里维护起来非常痛苦。更干净的方式是用一个装饰器批量注册函数名自动变成工具名docstring 自动变成描述参数注释可以提取成 JSON Schema。TOOL_REGISTRY: dict[str, Callable] {} def register_tool(name: str): def decorator(func: Callable) - Callable: TOOL_REGISTRY[name] func return func return decorator register_tool(get_weather) def get_weather(city: str) - str: 根据城市名获取实时天气信息支持国内主要城市 return f{city} 天气晴气温 25 度。 register_tool(calculate) def calculate(expression: str) - str: 计算一个数学表达式例如 1 2 * 3 return str(eval(expression))注意我在calculate里用了eval这在生产环境非常危险只适合演示后面要换成安全表达式解析器。这里用它是为了让代码短一点也顺便提醒各位用 Tool Use 暴露函数给模型时一定要对参数做严格校验模型拿到的是文本你根本不知道它会在表达式里塞什么。4.2 核心执行循环从单次调用到多轮对话真正的改变在下面这个循环。它的伪代码极其简洁但承载了 Agent 的全部状态逻辑def run_agent(user_input: str, max_steps: int 5) - str: messages [{role: user, content: user_input}] last_step_output for step in range(max_steps): response llm_chat_with_tools(messages, toolslist_tool_schemas()) if not response.tool_calls: return response.content # 如果有多个工具调用按顺序或并行执行 for tool_call in response.tool_calls: try: tool_result execute_tool_call(tool_call) messages.append({ role: tool, tool_call_id: tool_call[id], content: tool_result }) except Exception as e: messages.append({ role: tool, tool_call_id: tool_call[id], content: f执行出错{e} }) # 把模型本轮内容也塞回去保证上下文完整 messages.append({role: assistant, content: response.content}) last_step_output f第 {step1} 轮调用完成 return 已达最大执行轮次请尝试简化问题或补充信息后再试。有几个细节非常关键。第一工具结果不是简单拼到回复里而是以tool角色关联到tool_call_id放回消息列表模型才能准确知道“这是刚才那次调用的返回”。第二我在循环里显式做了 try-except工具异常不能打断整个 Agent而是把错误文本交给模型模型看到错误后自己决定是重试、换一个工具、还是向用户解释。第三max_steps是硬性保险否则遇到一个反复调用失败工具的死循环系统就僵住了。4.3 循环终止条件一场看不见的“谈判”工具调用循环不会永远跑下去它要在一个合适的位置停。我的经验里终止条件主要靠三层信号模型不再发起工具调用直接给出 final answer。这是最常见的正常出口。达到max_steps兜底防止死循环。某个工具返回一个“结束标记”例如STATUS: FINISHED这是业务层的特殊约定适合真实的业务流程。比如订酒店的多步流程模型先调搜索酒店工具拿到候选再调下单工具成功后工具返回FINISHED模型就可以收尾了。如果模型一直看不到结束标记它就会一直尝试这时候max_steps兜底就派上了用场。4.4 为什么面试官看到这里会点头方案三的价值不在于代码量有多少而在于它展示了一个完整的闭环注册、调度、执行、结果回填、异常反馈、终止条件、安全兜底。面试官真正想听的系统思维在这里全都有了。我记得自己写到这里的时候面试官追问了一句“如果同一个用户连续问多个问题中间上下文状态怎么处理”这个其实我已经踩过坑了答案是不要把所有历史状态堆在 messages 里而是用一个独立的 session 状态存储关键业务数据messages 只保留对话结构。我一讲对方就明白我是真做过这种东西的。5. 三种方案的横向对比与选型建议三套方案都写完了做个横向对比。用表格看得很清楚对比维度方案一提示词正则方案二结构化输出方案三注册中心循环解析稳定性低格式稍变就失效高协议层保证高依赖方案二或更强的协议多轮支持无单次调用半支持需额外开发完整支持自带循环管理异常处理基本无可捕获工具执行错误错误回传模型自动重试或降级工具注册维护dict手动更新需手工维护列表装饰器自动注册开箱即用上下文状态管理无无完整 messages 管理实现复杂度低中中高适用场景教学 Demo、给老板画饼单轮工具调用、数据提取多步 Agent、生产级流程在实际项目里我不会只用某一个方案。线上单据提取这种单轮的场景直接上方案二又快又稳需要多步编排的客服机器人直接上方案三。方案一我保留着只作为模型不支持工具调用时的降级通道也就是“万能备胎”。选型这里有一个容易弄反的认知不是方案复杂度越高越好而是看你的场景到底需要几步工具调用。很多场景只调用一次就完事根本不需要循环强行上方案三反而增加出问题的地方。6. 模拟面试追问实录面试官看的是边界写完代码之后面试官出人意料地没用背书式问题而是连问了好几个实操向的题。我挑几个最典型的复盘一下。第一个追问“Tool Use 和普通 API 调用有什么区别”这个问题其实不好答很多人会卡在“都是调函数嘛”。我当时的回答是普通 API 调用是程序主动去拉数据或触发动作调用链路由开发者写死而 Tool Use 是模型在推理过程中自己决定要调什么、什么时候调、调用后下一步做什么。调用决策权从开发者手上转移到了模型手上所以你对工具的约束能力、对输出的校验能力就成了新的工程重点。面试官点了下头这个题过了。第二个追问“如果模型输出了一个完全不存在的工具名怎么办”我说这个我在方案一里见过解决思路有两道防线。第一道是工具描述收敛描述写得清楚模型幻觉输出没有工具的频率能明显下降第二道是执行器兜底注册表里不存在就直接回传报错文本让模型看到报错后修正自己的输出。最不济还有max_steps不会让错误无限循环下去。这题考的就是异常处理意识。第三个追问“多个工具都能满足用户请求时模型怎么选”这个问题特别实战。我讲了个经验模型选用户描述里和某个工具 description 高度匹配的那个。比如用户说“帮我看看北京冷不冷”候选的get_weather命中“冷热温度”候选的get_calendar完全不沾边模型基本不会选错。但若有两个工具描述高度重叠比如search_stock和search_stock_event模型就会开始乱猜。解决办法要么手动调整工具描述要么在工具 schema 里把适用场景写窄通常都能解决。第四个追问“工具本身执行出错比如第三方接口挂了Agent 怎么办”这个问题我很喜欢答因为方案三的代码里已经体现了这个设计异常文本作为tool消息回传给模型。模型看到“接口超时”之后可能会说“服务暂时不可用已为你转文字说明”而不是直接抛 stack trace 给用户。如果在网上做过 Agent 的朋友应该懂这种回传式的容错有多重要。这些追问表面上是在问异常、问选型、问状态实际上全是对“系统能不能落地”的拷问。背概念只能撑过第一层能答到第三层面试官才会真正放宽标准。7. 常见问题与避坑实录最后把我长期实现 Tool Use 过程中遇到的频率最高的坑整理成速查表方便你直接对号入座现象可能原因排查思路模型频繁调用不存在的工具工具 description 写得模糊补全每个工具的触发场景、参数限制模型输出工具的格式变来变去没有用结构化协议只靠提示词升级到方案二或方案三JSON 解析失败模型输出被截断或带多余文字用正则锁目标 JSON加 try-except工具参数类型不对比如 int 传成了 string模型对参数 schema 理解偏差在 schema 里加 description 和默认值约束Agent 陷入死循环不停调工具缺少 max_steps 和重复调用检测加步数限制并记录每个工具被调用的次数工具结果不参与后续决策结果没有正确以 tool 角色回填检查 messages 堆栈结构多轮对话上下文越攒越乱不加状态管理所有内容全塞 messagessession 中单独维护业务状态再讲三个我在生产环境踩过的具体坑每一个都够喝一壶。第一个坑是工具并发调用的结果对应不上。早期实现里我让多个工具结果都塞进 messages结果模型把 A 工具的结果当成 B 工具的答案。后来我严格使用tool_call_id匹配每个tool消息都关联自己的调用 ID问题才消失。这个细节如果你的系统里工具是串行执行可能根本不会暴露但一旦并发就会出事。第二个坑是工具参数里的类型漂移。模型生成{max_price: 500}我的函数签名要求 int直接func(**args)就崩了。后来我写了参数清洗层把数字字符串转成 int枚举类型做白名单校验布尔类型只收布尔值。工具暴露给模型之前参数校验是第一道安全门。第三个坑是盲信工具返回结果。第三方接口有时会返回“成功”但内容诡异的数据Agent 如果直接把这个结果转给用户很容易出笑话。我的处理是在工具函数返回前做一次我自己的数据合理性检查不合理的返回某种 N/A 标记让模型知道“这个结果不可信请换种方式处理”。这听起来像业务自定义但很多真正落地的 Agent 都有类似的隐秘经验。还有一个小技巧强烈推荐每个工具函数都写一个轻量级 unit test覆盖正常、边界、异常三条路径。因为模型可能传进来空字符串、超长文本、负数字你写的函数要先扛得住模型的各种奇怪输入再说别的。这些坑都不是一次踩完的。我每次上线一个 Agent都会先拿一批高难度的边界测试请求轰一遍看到报错再补规则比用真实用户试错划算得多。个人体会是手写 Tool Use 这件事真正的难点不在写代码那一下而在于你要在几分钟内把“系统闭环”的意识完整展现在面试官面前。你不需要写出一个完美的轮子你只需要证明你理解模型输出、工具执行、结果反馈、异常容错、循环终止这几层之间的协同关系。哪一层漏了后面都会花更大的代价补回来。如果你最近也在准备这类面试我建议按这篇文章的顺序把三种方案都自己敲一遍尤其是方案三的循环终止和异常回传值得反复打磨。写完之后你会发现下一次面试官再让你手写 Tool Use你脑子里浮现的就不再是惶恐而是一整套你亲手验证过的系统图景。