news 2026/10/16 8:13:07

手写Tool Use三种方案:从字符串解析到状态机,面试与生产实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写Tool Use三种方案:从字符串解析到状态机,面试与生产实践

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,其实不是在考我“会不会写代码”,而是在考我“能不能把一个模糊的需求拆解成可执行的工程方案”。三种方案从简陋到完善,对应的正是从“能跑”到“可靠”的演进路径。如果你能把这条路径讲清楚,面试官基本就能确认你是一个有实战经验、有工程思维的人。而这,可能比写出完美代码更重要。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 8:13:07

轻薄本27B大模型实战:Qwen3量化版+内存带宽调优

1. 写在前面:一台轻便笔记本挑战27B大模型的底气最近把主力机换成了 Ultra X7 358H 之后,我第一件想验证的事,就是这台机器到底能不能扛得住 27B 级别的本地大模型。折腾了整整两天,最终用 Qwen3.8-27B-Q4 量化版跑通,…

作者头像 李华
网站建设 2026/10/16 8:12:56

Python Asyncio实战:事件循环与协程如何实现高并发爬虫

1. 初识异步编程:Asyncio到底解决了什么问题做Python开发的人,早晚都会碰上Asyncio这个名字。我看了很多教程,上来就甩事件循环、协程、回调函数,结果新手一脸懵,老手也觉得隔靴搔痒。今天我用实际爬取、请求调度的场景…

作者头像 李华
网站建设 2026/10/16 8:11:40

2026 AI写作网站横评:五款工具实测与选型指南

先说个结论:2026届的同学们,如果还觉得AI写作网站就是“复制粘贴交作业”,那这个思路真的过时了。AI写作网站这几年迭代速度很快,现在它已经不是简单帮你凑一段话的工具,而是能覆盖选题、搭框架、写初稿、润色、降重全…

作者头像 李华
网站建设 2026/10/16 8:10:35

RAG与Agent合体上线:从原型到生产的工程化实战指南

1. 原型跑通不等于能上线,问题到底出在哪RAG 和 Agent 这两个词,过去一年在各种技术社区里几乎被聊烂了。单独拿 RAG 来说,用向量库加一个大模型,把文档切片、做嵌入、检索、拼 prompt,半天就能跑出一个能回答问题的 D…

作者头像 李华
网站建设 2026/10/16 8:09:30

Flink集群启动全流程解析:从脚本到作业可调度

先说个真实的体会:我最早接触Flink的时候,在本地执行./start-cluster.sh,看到 jps 里有两个 Java 进程,日志也没报错,就以为“集群启动成功”了。直到后来在真正离线的环境里要从零搭一套生产集群,才发现自…

作者头像 李华
网站建设 2026/10/16 8:08:56

Python+微信小程序:大学生心理咨询预约系统开发实战

去年我接到一个校园数字化服务项目,任务是为在校大学生搭建一套心理咨询预约系统。项目标题里写的是“python微信小程序的大学生心理咨询系统”,技术栈很直白:Python写后端,微信小程序做前端。但真正动手之后我才发现,…

作者头像 李华