1. 从一道面试题说起:为什么“手写 Tool Use”成了高频考点
面试官让我手写一个 Tool Use,我用了 3 种方案。这句话听起来像是一道刁钻的面试题,但它背后折射出的,是整个行业对“模型如何与外部世界交互”这件事的认知升级。过去两年,大家聊大模型更多是聊“它能不能答对”,而现在聊的是“它能不能做事”。Tool Use,也就是工具调用,本质上是让语言模型不再局限于生成文本,而是能够识别用户意图、选择合适的工具、构造正确的参数、执行并拿到结果,最后把结果整合成自然语言回复。这个链条一旦跑通,模型就从“聊天机器人”变成了“智能体”。
我之所以对这道题印象深,是因为它不像“手写一个 Promise”那样有标准答案,也不像“实现一个防抖函数”那样边界清晰。Tool Use 的考察点非常综合:你要理解模型输出的结构化解析、要处理多轮对话中的上下文管理、要设计工具注册与路由机制、还要考虑错误处理和重试策略。面试官想看的不是你背了多少 API,而是你能否在没有任何框架辅助的情况下,把“模型决策—工具执行—结果回传”这个闭环用最朴素的方式搭出来。
这篇文章我会把当时面试现场写的三种方案完整复盘出来。第一种是最原始的“提示词+字符串解析”方案,第二种是“JSON Schema+函数注册”方案,第三种是“多轮循环+状态机”方案。三种方案没有绝对优劣,分别对应不同的工程阶段和业务场景。我会把每种方案的代码骨架、核心逻辑、踩过的坑、以及为什么在那个场景下选择它,都掰开揉碎讲清楚。如果你正在准备面试,或者正在从零搭建一个 Agent 系统,这篇内容应该能帮你少走不少弯路。
提示:本文所有代码均为示意性骨架,重点在于逻辑推演和方案对比,实际落地时需要根据你所用的模型接口做适配。
2. 方案一:提示词约束加字符串解析,最笨但最能看清本质
2.1 为什么先写这个“最土”的方案
面试的时候,面试官说“你先别用任何框架,就假设你只有一个最基础的语言模型接口,它只能接收一段文本、返回一段文本”。这个约束其实非常真实,因为很多团队在早期验证阶段,确实就是直接调一个 completion 接口,没有 function calling,没有 tool use 原生支持。这时候你要做的第一件事,就是让模型“愿意”按照你的格式输出。
我的做法是在系统提示词里硬编码一段工具说明,告诉模型有哪些工具可用、每个工具的参数是什么、以及必须按照什么格式返回。比如:
你可以使用以下工具: 1. get_weather(city: string):查询指定城市的天气。 2. search_flights(from: string, to: string, date: string):查询航班。 当你需要使用工具时,请严格按照以下格式输出: TOOL_CALL: {"name": "工具名", "arguments": {"参数名": "参数值"}} 当你不需要使用工具时,直接回复用户即可。然后我在代码里用正则表达式去匹配TOOL_CALL:后面的 JSON 字符串。匹配到了就解析、执行工具、把结果拼回对话历史,再让模型生成最终回复。这个方案的核心逻辑就是一个 while 循环:模型输出 → 检测是否有工具调用 → 有就执行并追加结果 → 再次调用模型 → 直到没有工具调用为止。
import re import json def run_agent(user_input, max_turns=5): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input} ] for _ in range(max_turns): response = call_llm(messages) tool_call = parse_tool_call(response) if tool_call is None: return response result = execute_tool(tool_call["name"], tool_call["arguments"]) messages.append({"role": "assistant", "content": response}) messages.append({"role": "user", "content": f"TOOL_RESULT: {result}"}) return "达到最大轮次限制"2.2 字符串解析方案里最容易翻车的三个细节
第一个坑是模型不听话。你明明写了“严格按照以下格式”,但模型有时候会加一句“好的,我将为你调用工具”,然后再输出 JSON。这时候你的正则如果写得太死,比如^TOOL_CALL:,就直接匹配失败了。我的处理方式是放宽正则,用TOOL_CALL:\s*(\{.*?\})并且开启非贪婪模式,同时在解析失败时做一次兜底:把整段回复丢给模型,让它“只输出 JSON,不要任何其他文字”。
第二个坑是 JSON 解析失败。模型输出的 JSON 里可能带单引号、可能少一个括号、可能在字符串里包含换行符。这时候直接用json.loads会抛异常。我的做法是先用json.loads尝试,失败后用ast.literal_eval兜底,再失败就做一次简单的字符串清洗,把单引号替换成双引号、把None替换成null。虽然不优雅,但在早期验证阶段足够用。
第三个坑是工具结果的回传格式。如果你直接把工具返回的原始 JSON 丢回给模型,模型有时候会“困惑”,因为它不知道这是工具结果还是用户输入。所以我在回传时会加一个明确的前缀,比如TOOL_RESULT:,并且在系统提示词里说明“当你看到 TOOL_RESULT 开头的内容时,说明这是工具执行结果,请基于它回答用户”。这个细节看起来很小,但实测下来对多轮调用的稳定性影响很大。
2.3 这个方案什么时候该被淘汰
字符串解析方案最大的问题是“脆弱”。它依赖模型对格式的严格遵守,而模型的行为是不可控的。你换一个模型、换一个温度参数、甚至换一个系统提示词的措辞,解析成功率就可能从 95% 掉到 70%。而且它无法处理并行工具调用,也无法处理工具参数的类型校验。所以这个方案只适合两种场景:一是面试时展示你对底层逻辑的理解,二是早期做概念验证时快速跑通闭环。一旦进入生产环境,你必须升级到结构化方案。
3. 方案二:JSON Schema 加函数注册,让模型输出可校验的结构
3.1 从“让模型自由发挥”到“给模型画好格子”
第二种方案的核心思路是:不再让模型输出一段包含工具调用的自然语言,而是让模型直接输出一个符合 JSON Schema 的结构化对象。这个方案的前提是你的模型接口支持某种形式的“结构化输出”或者“函数调用”协议。即使没有原生支持,你也可以通过提示词让模型输出 JSON,然后用 Schema 做校验。
我当时的做法是定义一个工具注册表,每个工具包含名称、描述、参数 Schema 和执行函数。然后在调用模型时,把这些工具定义转换成模型能理解的格式传进去。模型返回的不再是纯文本,而是一个包含tool_calls数组的对象。每个tool_call里有工具名和参数 JSON 字符串。我拿到之后先做 Schema 校验,校验通过再执行。
TOOL_REGISTRY = { "get_weather": { "description": "查询指定城市的天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名称"} }, "required": ["city"] }, "handler": lambda args: weather_api(args["city"]) }, "search_flights": { "description": "查询航班", "parameters": { "type": "object", "properties": { "from": {"type": "string"}, "to": {"type": "string"}, "date": {"type": "string", "format": "date"} }, "required": ["from", "to", "date"] }, "handler": lambda args: flight_api(args["from"], args["to"], args["date"]) } }这个方案相比第一种,最大的进步是“可校验”。模型输出的参数如果缺少必填字段、或者类型不对,你在执行之前就能发现,而不是等到工具报错才知道。而且工具的描述和参数定义是集中管理的,新增工具只需要在注册表里加一条,不需要改提示词。这在工程上是一个巨大的进步。
3.2 参数校验之外,真正难的是“工具选择”的准确性
很多人以为有了 JSON Schema 就万事大吉了,其实真正的难点在于:模型能不能选对工具。我实测下来,当工具数量超过 10 个、且工具描述有重叠时,模型的选错率会明显上升。比如“查天气”和“查空气质量”两个工具,如果描述写得不够区分,模型很容易混。我的经验是,工具描述里一定要写清楚“什么时候用”和“什么时候不用”。比如:
get_weather:查询天气状况,包括温度、湿度、风力。当用户明确问“天气”“气温”“下雨”时使用。不要用于查询空气质量或紫外线指数。另外,参数描述也要写清楚格式。比如日期参数,如果你不写format: date,模型可能返回“明天”“下周三”这种自然语言,导致你的工具执行失败。我在实际项目里会强制要求所有日期参数都走一次归一化处理,把“明天”转换成具体日期,再传给工具。这个归一化逻辑可以放在工具执行前的预处理层,也可以让模型在输出参数时就完成转换,取决于你的模型能力。
3.3 多工具并行调用时的顺序与依赖问题
第二种方案还有一个隐藏的复杂性:当模型一次性返回多个工具调用时,你怎么处理?有些工具调用之间是有依赖的,比如先查航班再查天气,你必须等第一个结果出来才能决定第二个的参数。但有些工具调用是独立的,可以并行执行。我的做法是先把所有工具调用拿出来,分析它们之间是否有参数依赖。如果没有依赖,就用并发执行;如果有依赖,就串行执行,并且把前一个的结果注入到后一个的参数里。
这个逻辑在面试时是一个很好的加分项,因为它展示了你对“工具编排”的理解。很多人写 Tool Use 只写到“执行单个工具”就停了,但真实场景里,用户一句话可能触发三四个工具调用,而且它们之间有先后顺序。你能不能把这个顺序理清楚,决定了你的 Agent 是“能用”还是“好用”。
4. 方案三:多轮循环加状态机,把对话当作一次可恢复的执行流程
4.1 为什么需要状态机:一次工具调用可能跨越多个回合
第三种方案是我在面试最后补充的,也是我认为最接近生产级实现的方案。前两种方案都有一个隐含假设:工具调用在一个回合内完成。但真实场景里,用户可能说“帮我订一张明天从北京到上海的机票”,模型先调用航班查询工具,拿到结果后需要用户确认,用户说“就选第二班”,模型再调用订票工具。这个过程跨越了多个用户回合,中间还可能有用户修改需求、取消操作、补充信息。
这时候你需要一个状态机来管理整个对话的状态。每个状态代表一个执行阶段,比如IDLE、WAITING_FOR_TOOL_RESULT、WAITING_FOR_USER_CONFIRMATION、COMPLETED。状态之间的转移由模型输出和用户输入共同驱动。我在面试时画了一个简单的状态转移图(当然这里不能用图,我用文字描述),核心思路是:每次模型返回工具调用时,不是立即执行,而是先检查当前状态是否允许执行这个工具。如果允许,执行并转移到下一个状态;如果不允许,返回错误提示或者请求用户确认。
class AgentState: def __init__(self): self.state = "IDLE" self.context = {} self.pending_tool = None def transition(self, event): if self.state == "IDLE" and event["type"] == "tool_call": self.pending_tool = event["tool"] self.state = "WAITING_FOR_TOOL_RESULT" elif self.state == "WAITING_FOR_TOOL_RESULT" and event["type"] == "tool_result": self.context.update(event["result"]) self.state = "IDLE" elif self.state == "IDLE" and event["type"] == "need_confirmation": self.state = "WAITING_FOR_USER_CONFIRMATION" elif self.state == "WAITING_FOR_USER_CONFIRMATION" and event["type"] == "user_confirm": self.state = "IDLE"4.2 状态持久化:让 Agent 在服务重启后还能继续
状态机方案还有一个好处:状态可以持久化。你可以把AgentState序列化成 JSON 存到数据库或缓存里,这样即使服务重启,用户的对话也能从上次中断的地方继续。这在长流程任务里非常重要,比如一个需要多步审批的报销流程,用户可能今天填一半、明天继续填。如果没有状态持久化,每次都要从头开始,体验会非常差。
我在实际项目里会把状态分成两部分:一部分是“对话历史”,这个直接存消息列表;另一部分是“执行状态”,包括当前处于哪个阶段、已经收集了哪些参数、下一步需要什么。这两部分分开存储的好处是,对话历史可以用于展示和审计,执行状态可以用于恢复和重试。恢复的时候,先加载执行状态,再把对话历史拼回去,模型就能接着往下走。
4.3 错误恢复与重试:工具执行失败后怎么优雅地继续
状态机方案最考验人的地方是错误处理。工具执行可能失败,比如网络超时、参数错误、权限不足。在简单方案里,失败就直接返回错误信息给用户。但在状态机方案里,你需要决定:这个错误是可恢复的还是不可恢复的?如果是可恢复的,比如网络超时,你可以自动重试;如果是参数错误,你可以让模型重新生成参数;如果是权限不足,你可能需要引导用户去授权。
我的做法是给每个工具定义一个错误处理策略。比如:
| 错误类型 | 处理策略 | 是否通知用户 |
|---|---|---|
| 网络超时 | 自动重试 2 次,间隔 1 秒 | 重试失败后通知 |
| 参数缺失 | 让模型重新生成参数 | 不通知,静默重试 |
| 参数格式错误 | 让模型重新生成参数 | 不通知,静默重试 |
| 权限不足 | 返回授权引导 | 通知用户 |
| 业务逻辑错误 | 返回错误原因 | 通知用户 |
这个表格是我在实际项目里总结出来的,面试时如果能把这张表画出来,面试官基本就能判断你是有实战经验的。因为只有真正踩过坑的人,才会去区分“哪些错误该重试、哪些错误该让模型重新生成、哪些错误该直接告诉用户”。
5. 三种方案横向对比:面试时怎么选,生产时怎么用
5.1 从开发成本、稳定性、扩展性三个维度打分
把三种方案放在一起对比,其实没有绝对的“最好”,只有“最适合当前阶段”。我整理了一个对比表格,从开发成本、稳定性、扩展性、可调试性四个维度来打分:
| 维度 | 方案一:字符串解析 | 方案二:JSON Schema | 方案三:状态机 |
|---|---|---|---|
| 开发成本 | 低,半天可跑通 | 中,需要定义 Schema | 高,需要设计状态转移 |
| 稳定性 | 低,依赖模型格式遵守 | 中高,有校验兜底 | 高,有状态管理和重试 |
| 扩展性 | 差,新增工具要改提示词 | 好,注册表集中管理 | 好,状态可持久化 |
| 可调试性 | 差,出错难定位 | 中,有 Schema 校验日志 | 好,状态转移可追踪 |
| 适用场景 | 面试演示、概念验证 | 中小型 Agent 应用 | 生产级长流程任务 |
面试的时候,我的建议是:先用方案一快速跑通,展示你对底层逻辑的理解;然后主动提出方案二的改进思路,展示你的工程思维;最后如果面试官继续追问“如果用户中途改需求怎么办”,你再抛出方案三的状态机设计。这个递进过程本身就是在展示你的思考深度。
5.2 面试官真正想听的三个关键词
复盘这场面试,我发现面试官在听我讲三种方案时,反复追问的点其实集中在三个关键词上:解析、校验、恢复。解析是问你怎么从模型输出里提取工具调用;校验是问你如何保证参数正确;恢复是问你在多轮对话和错误场景下如何保持状态一致。这三个词基本上覆盖了 Tool Use 的核心难点。
所以如果你也在准备类似的面试,不要一上来就写代码,先把这三个问题想清楚:你的解析策略是什么?你的校验机制是什么?你的恢复方案是什么?把这三个问题答好了,代码只是表达方式而已。
5.3 从面试题到生产系统:还差哪些工程化环节
面试时写的三种方案,放到生产环境里还差很多。比如权限控制,不是所有用户都能调用所有工具;比如审计日志,每次工具调用都要记录谁在什么时候调用了什么、参数是什么、结果是什么;比如限流和熔断,防止某个工具被高频调用打挂;比如工具版本管理,工具升级后如何保证旧对话还能正常执行。这些在面试里不一定会问,但如果你能主动提一句,面试官对你的评价会立刻上一个台阶。
6. 手写 Tool Use 时我踩过的那些坑
6.1 模型“假装”调用了工具,但格式完全不对
这是我在方案一里遇到的最频繁的问题。模型有时候会输出“我将调用 get_weather 工具查询北京天气”,但它并没有输出TOOL_CALL:前缀和 JSON。这时候你的解析器会认为“没有工具调用”,直接把这句话返回给用户。用户看到的就是“我将调用工具”这种半截话。我的解决办法是在系统提示词里加一句“如果你决定调用工具,必须输出 TOOL_CALL 前缀;如果你不调用工具,不要提及任何工具名称”。这个约束能大幅减少“假装调用”的情况。
6.2 工具返回的结果太长,把上下文撑爆了
有一次我写了一个搜索工具,返回了 20 条搜索结果,每条结果有标题、摘要、链接。模型拿到这个结果后,上下文直接超限了。后来我加了一个截断逻辑:工具返回结果超过一定长度时,只保留前 N 条,并且把每条结果的摘要截断到 200 字以内。如果模型需要更多信息,它可以再次调用工具并指定分页参数。这个“分页+截断”的策略,在真实项目里非常关键,因为模型的上下文窗口是有限的,你不能让一个工具调用就把窗口占满。
6.3 多轮对话里工具调用历史被模型“遗忘”了
在方案二里,我把工具调用和结果都追加到了消息历史里。但实测发现,当对话轮次超过 10 轮后,模型开始“忘记”前面调用过什么工具,甚至会重复调用同一个工具。我的解决办法是在系统提示词里加一个“已执行工具摘要”,把之前调用过的工具名和关键结果列出来,放在提示词的最后。这样模型每次都能看到“我已经查过天气了,结果是晴,不需要再查”。这个技巧在长对话里非常有效。
6.4 并发工具调用时,结果回传顺序错乱
方案三支持并行工具调用,但我一开始没有处理结果回传的顺序。模型返回了三个工具调用 A、B、C,我并发执行后,结果可能是 C、A、B 的顺序返回。如果我把它们按返回顺序追加到消息历史里,模型就会困惑:为什么工具 C 的结果在工具 A 前面?后来我改成按工具调用的原始顺序回传结果,即使 C 先执行完,也要等 A 和 B 都执行完,再按 A、B、C 的顺序追加。这个细节很小,但不处理的话,模型的理解准确率会明显下降。
7. 如果你也在准备这道题,我的练习建议
7.1 先用伪代码把闭环跑通,再考虑代码细节
很多人一上来就纠结“用哪个 HTTP 库”“怎么解析 JSON”,结果卡在细节里出不来。我的建议是先用伪代码把整个闭环写出来:接收用户输入 → 调用模型 → 解析工具调用 → 执行工具 → 回传结果 → 再次调用模型 → 返回最终回复。这个闭环用伪代码写出来可能就 20 行,但它能帮你理清所有关键节点。等你把闭环跑通了,再去填充每个节点的具体实现。
7.2 准备一个“工具注册表”的通用设计
不管你用哪种方案,工具注册表的设计都是核心。我建议你提前准备一个通用的工具注册表结构,包含工具名、描述、参数 Schema、执行函数、错误处理策略。这个结构在面试时可以直接画出来,展示你的设计能力。而且这个结构是跨方案通用的,方案一可以用它来生成提示词,方案二可以用它来做 Schema 校验,方案三可以用它来管理状态转移。
7.3 把“错误处理”当作第一优先级来设计
面试时很多人只讲“正常流程”,但面试官往往更关心“异常流程”。你在设计 Tool Use 时,应该把错误处理放在和正常流程同等重要的位置。工具执行超时怎么办?参数校验失败怎么办?模型返回了不存在的工具名怎么办?用户在中途取消了操作怎么办?把这些异常场景想清楚,你的方案就从“能跑”变成了“可靠”。
7.4 用真实场景做压力测试,而不是只跑 Demo
最后一点,如果你有条件,一定要用真实场景做压力测试。比如模拟 50 轮对话、模拟 10 个工具并发调用、模拟网络抖动和超时。我在实际项目里发现,很多问题只有在压力测试下才会暴露,比如状态机的状态泄漏、工具注册表的并发读写冲突、消息历史的无限增长。这些问题在 Demo 里永远看不到,但在生产环境里会直接导致系统崩溃。
注意:以上所有代码和设计均为示意性内容,实际落地时需要根据你所使用的模型接口、业务场景和团队规范做适配。特别是涉及用户数据和权限的部分,务必做好安全隔离和审计日志。
回头看这场面试,面试官让我手写 Tool Use,其实不是在考我“会不会写代码”,而是在考我“能不能把一个模糊的需求拆解成可执行的工程方案”。三种方案从简陋到完善,对应的正是从“能跑”到“可靠”的演进路径。如果你能把这条路径讲清楚,面试官基本就能确认你是一个有实战经验、有工程思维的人。而这,可能比写出完美代码更重要。