面试官突然甩过来一个问题:不用框架,给我手写一个 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 = r"TOOL_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 完整实现示例
下面是模拟实现,不依赖特定厂商 SDK:
from 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, tools=OPEN_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, tools=list_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"第 {step+1} 轮调用完成" 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 堆栈结构 |
| 多轮对话上下文越攒越乱 | 不加状态管理,所有内容全塞 messages | session 中单独维护业务状态 |
再讲三个我在生产环境踩过的具体坑,每一个都够喝一壶。
第一个坑是工具并发调用的结果对应不上。早期实现里我让多个工具结果都塞进 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,你脑子里浮现的就不再是惶恐,而是一整套你亲手验证过的系统图景。