☰
Tool Use手写指南:三种方案从提示词到Agent循环
2026/10/13 9:45:11 网站建设 项目流程

上周有个朋友跟我一起复盘面试,说面试官让他不用任何Agent框架,白板手写一个 Tool Use。他第一反应是“这不就是调接口时传 tools 参数吗”,可真到动手才发现,里面藏着大量细节:模型返回的调用长什么样、工具执行完结果怎么塞回上下文、循环什么时候停、消息历史怎么维护。这篇文章我就用三种递进的方案把这个高频考点彻底拆开:先是一个纯提示词加正则就能跑的通吃方案,再是平台原生 Function Calling 的标准写法,最后收敛到一个自带容错的最小 Agent 循环。不管你是刚接触大模型开发,还是要准备面试,照着这套思路走一遍,Tool Use 基本就吃透了。

1. 先把问题拆开:Tool Use 到底在做什么

1.1 Tool Use 的内核链路

很多人把 Tool Use 理解成“在请求里加一个 tools 参数”,这只是表面的 API 用法。本质上,Tool Use 解决的是大模型自身能力边界的问题:模型只是一个文本生成器,它不知道实时天气,不会精确计算四则运算,也不能帮你查数据库、发邮件、改订单状态。当你需要它完成这些操作时,必须给它一条“外接手脚”的通路。

这条通路的内核链路其实非常固定,一共五步:

  1. 模型收到用户问题,在推理过程中判断“仅靠文本回答搞不定这件事”。
  2. 模型不直接编答案,而是产出一个结构化的“工具调用意图”,包括工具名、参数。
  3. 程序解析这个结构化输出,找到对应的真实函数或 API。
  4. 程序执行工具,拿到结果(可能成功,可能失败抛异常)。
  5. 结果以消息的形式重新放回对话上下文,模型看到结果后继续推理,直到不再需要调用工具,输出最终回答。

用生活里的例子类比:这就像你不会做饭的助理接了一个“做一桌菜”的需求,他自己不掌勺,但他会写采购清单(工具调用),把清单交给采购员(程序),采购员买回来食材(工具结果),他再根据食材情况安排下一步做法。助理本身不会切菜,但他能主导整个流程。

这个链路里,最容易被面试官挖坑的地方是第 5 步:工具结果不是简单丢给模型就行,它必须放在正确的位置、用正确的角色身份、保留正确的对应关系,否则模型看到的就是一堆没有上下文来历的文本,回答质量会直线下降。

1.2 面试官真正想考察的能力点

如果你以为面试官只是要你背出某个 SDK 的写法,那就错了。Tool Use 这个题目背后,至少藏着四个层面的考察:

第一,你是否理解模型输出的多样性。模型可能输出普通文本,也可能输出调用指令,这套“输出协议”本身是应用层定义的,你作为开发者要负责解析和执行。

第二,你是否理解对话状态的管理。工具调用的中间结果也是对话历史的一部分,不能随手丢,也不能无脑拼。多轮工具调用的顺序、结果截断、上下文长度控制,都属于状态管理。

第三,你是否具备工程容错意识。模型输出不一定符合预期,工具执行不一定成功,网络有可能超时。一个好的实现不是“调通即可”,而是要能在异常时体面恢复。

第四,你是否能区分平台帮你做了什么、哪些还得自己写。原生 Function Calling 帮你稳定输出 JSON,不代表你不需要处理工具编排、鉴权、审批、日志、最大步数这些问题。

所以后面三种方案,难度其实是递增的:方案一帮你理解协议本质,方案二带你进入标准实践,方案三才接近生产环境真实要用的东西。

2. 方案一:提示词协议加正则解析,不依赖任何平台特性

2.1 设计思路

第一个方案是最纯粹“手写”的方式:我不依赖模型平台提供的专门字段,只用普通文本对话接口,通过提示词约定一种调用格式,然后用正则加 JSON 解析去识别。

核心约定可以这样设计:当模型需要调用工具时,输出一个带标记的 JSON 块,比如:

[CALL] {"name": "calc", "arguments": {"expression": "365*24"}} [/CALL]

当不需要调用工具时,模型直接输出自然语言。系统提示词里写清楚这个约定,并强调“除了调用块之外不要输出任何多余解释文本”,因为解析端会忽略块外内容,但多余的文本容易干扰模型自己的后续推理。

这个方案的魅力在于:任何能输出文本的模型都能用,它只依赖提示词的约束能力,不要求平台支持什么特殊字段。对于刚入门的人,这也是理解 Tool Use 本质的最好切入点——你会发现,所谓“工具调用能力”,本质上只是模型输出内容的一种特殊语法而已。

2.2 关键代码

我用 Python 演示一个最小实现。首先是一个工具注册表,用装饰器把普通函数挂载进来:

import json import re TOOLS = {} def tool(name): def decorator(fn): TOOLS[name] = fn return fn return decorator @tool("calc") def calc(expression: str): # 注意:这里仅用于演示工具执行原理。 # 生产环境绝对不要直接 eval 模型生成的表达式,风险极高。 return str(eval(expression))

然后写解析函数,从模型回复里提取调用块:

def parse_tool_call(text): match = re.search(r"\[CALL\](.*?)\[/CALL\]", text, re.S) if not match: return None try: data = json.loads(match.group(1).strip()) return data["name"], data.get("arguments", {}) except (json.JSONDecodeError, KeyError): # 解析失败时返回一个特殊标记,方便上层做兜底 return False

最后是主循环。这里call_llm代表任何平台的普通文本对话接口,你只需要把 messages 传过去,拿到文本:

def run_with_tools(user_query): messages = [{"role": "user", "content": user_query}] for _ in range(3): resp = call_llm(messages) parsed = parse_tool_call(resp) if parsed is None: return resp # 没有工具调用,直接返回模型回答 if parsed is False: # 模型输出了不合法格式,把错误信息反馈给它,让它重来 messages.append({ "role": "user", "content": "你输出的工具调用格式无法解析,请重新只输出一个 [CALL] JSON [/CALL] 块。" }) continue name, args = parsed if name not in TOOLS: messages.append({ "role": "user", "content": f"工具 {name} 不存在,可选工具有:{list(TOOLS)},请重新选择。" }) continue try: result = TOOLS[name](**args) except Exception as e: result = f"调用失败: {e}" messages.append({ "role": "user", "content": f"工具 [{name}] 返回结果: {result},请基于该结果继续回答。" }) return "已达到最大调用轮次,请简化问题或联系人工处理。"

注意,这个方案里我把工具结果以user角色追加回去,是因为很多老模型不支持tool角色。现实里你可以视平台能力决定用user还是tool角色。

2.3 实战中的坑

方案一最典型的坑有三个。

第一个坑是模型“不老实”。即使提示词写了“只输出调用块”,模型仍然可能在 JSON 前后加上“好的,我来调用……”这类废话。正则\[CALL\](.*?)\[/CALL\]可以容忍这种废话,因为块外内容被忽略了;但如果模型压根没有用方括号标记,而是直接输出一段 JSON,你就得兜底到“提取最后一个 JSON 对象”的启发式方法,或者干脆反馈错误让它重新生成。

第二个坑是 JSON 格式漂移。模型可能把双引号写成中文引号、在 arguments 里多一个逗号、把布尔值写成了字符串。遇到解析错误不要慌,直接把解析异常作为新消息交给模型:“刚才的工具调用格式有误,请重新输出合法的 JSON 调用块。”实测下来,模型被纠正一次之后大都能自我修复。

第三个坑是整个方案的 token 开销偏高。因为你每次都要把“需要调用工具时的输出约定、工具列表描述、最近的解析错误”重复写在提示词里,长对话下 token 消耗会比较快。所以方案一适合工具量少、对话轮次短的场景,不适合作为高并发生产的唯一方案。

2.4 适用边界

方案一最吃香的地方是模型兼容性。当你手里只有一个不支持 Function Calling 的开源模型,或者公司的网关层不支持 tools 参数时,方案一是唯一能快速顶上的选择。教学演示、必答一个“底层原理”的场景,也建议先用方案一讲清楚链路。它的劣势也明显:稳定性取决于模型对提示词的服从度,指令稍微被系统提示词冲突干扰,调用成功率就会波动。

3. 方案二:原生 Function Calling,写好多轮 tool 消息

3.1 设计思路

第二个方案就是大多数人熟悉的原生 Function Calling。几乎所有主流模型平台现在都支持一种标准的工具调用协议:请求里带tools数组,模型如果判断需要调用工具,返回对象里就会出现tool_calls字段,而不是普通文本。

协议本身不复杂,核心是三个部分:

  • tools:描述每个工具的名字、用途、参数 JSON Schema。
  • tool_calls:模型返回的调用请求,一般包含id、function.name、function.arguments(JSON 字符串)。
  • tool消息:你执行完工具之后,以单独的角色消息回传给模型,并且必须带上对应的tool_call_id。

这个方案的设计动机很明显:平台在模型层做了一次“结构化输出保障”,模型更倾向于生成合法 JSON,调用格式的稳定性比方案一高一个量级。你自己不用写正则,不用反复在提示词里演练工具格式,只需要管理好消息数组和调度逻辑。

3.2 关键代码

下面这段代码展示了完整的原生 Function Calling 循环:

def chat_with_tools(user_query, client, model): messages = [{"role": "user", "content": user_query}] while True: resp = client.chat.completions.create( model=model, messages=messages, tools=build_tools_schema(), # 由工具注册表自动生成 ) msg = resp.choices[0].message # 没有工具调用,说明模型已经给出了最终回答 if not msg.tool_calls: return msg.content # 先把模型这条“带工具调用意图”的消息原样放回历史 messages.append({ "role": "assistant", "content": msg.content, "tool_calls": msg.tool_calls, }) # 执行所有工具调用,把结果都收集起来 for tc in msg.tool_calls: fn = json.loads(tc.function.arguments) if tc.function.name not in TOOLS: result = f"错误:工具 {tc.function.name} 不存在" else: try: result = TOOLS[tc.function.name](**fn) if not isinstance(result, str): result = json.dumps(result, ensure_ascii=False) except Exception as e: result = f"调用异常: {e}" messages.append({ "role": "tool", "tool_call_id": tc.id, "content": result, })

注意到几个关键细节没有:

  • messages.append那一条 assistant 消息,必须完整保留content和tool_calls字段。很多人踩的坑就是这里把tool_calls丢了,导致模型下一轮不知道这些工具结果对应的是哪次调用。
  • 每条tool消息里的tool_call_id必须和上面tool_calls里的id严格对应。顺序无所谓,但 id 绝对不能错。
  • function.arguments是 JSON 字符串,不是对象,先json.loads再传参。
  • 如果工具返回了非字符串对象,尽量序列化成 JSON 字符串再放进消息,因为接口的消息字段通常只接受字符串。

3.3 容易被忽略的细节

第一个细节是并行调用。现在很多模型支持在一个回合里返回多个tool_calls,比如“既查天气又查航班”。程序完全可以并行执行这些工具,再把结果一次性追加到消息列表。但要注意,并行执行时每个结果仍然要单独成一条tool消息,不能合并成一条。

第二个细节是强制调用工具。有些平台支持tool_choice参数,你可以指定模型必须调用某个工具。这个能力在做“必须先经过某个中台服务”的流程约束时非常有用,比如用户提问前必须先调用一个鉴权工具。不要把这种用法想成是给模型添麻烦,它是业务规则落到模型层的好手段。

第三个细节是切换模型时的兼容性。方案二看起来标准,实际各家平台的字段名、错误码、最大并行数都不完全一致。如果团队会频繁切换模型供应商,建议在代码里做一层薄薄的适配层,把底层 SDK 的差异收敛到自己的llm.invoke()接口后面。面试时能提到这一点,通常会让面试官觉得你不是只会死记 API 的人。

3.4 适用边界

方案二适合绝大多数线上常规 Agent 场景:工具数量几十个以内、模型支持 Function Calling、团队不需要在多个异构模型之间切换。它比方案一稳定,开发速度也快。唯一的硬约束是“模型必须支持 Function Calling”,如果遇到一个内部部署的老模型不支持,就得退回方案一,或者在模型前面套一个“强制 JSON 输出 + 自己解析”的适配器。

4. 方案三:一个自带容错的最小 Agent 循环

4.1 设计目标

方案三表面上只是一个功能更全的循环,但它解决了方案二没说的事:工具越来越多之后,你需要一个统一的管理入口;生产环境出问题之后,你需要日志和拦截;模型抽风不断调用工具时,你需要止损。

所以我定义方案三的设计目标有四条:

  1. 工具注册自动生成 Schema:写工具函数时不用手写 JSON Schema,装饰器内部根据类型提示和默认规则生成。
  2. 循环控制与止损:设置最大步数,达到后强制停止,避免模型陷入无限工具调用。
  3. 错误即信息:工具执行异常不应该让整个进程崩溃,而是转成一条错误消息返回给模型,让模型有机会自我纠正。
  4. 扩展点清晰:在调用前后预留钩子,用于日志、审计、人工审批、限流。

说白了,方案三是把方案二“能用”的代码,升级成“生产环境敢上”的最小框架。

4.2 关键代码

我写一个精简版本,重点是结构而不是具体 SDK:

class ToolUseAgent: def __init__(self, llm_client, model, max_steps=6): self.llm_client = llm_client self.model = model self.tools = {} self.max_steps = max_steps def register(self, fn, name=None, description=None): spec = build_tool_spec(fn, name, description) self.tools[spec["name"]] = spec return fn def run(self, user_input): messages = [{"role": "user", "content": user_input}] for step in range(self.max_steps): resp = self.llm_client.invoke( model=self.model, messages=messages, tools=list(self.tools.values()), ) if not resp.tool_calls: return resp.content messages.append(to_assistant_message(resp)) for tc in resp.tool_calls: result = self._safe_execute(tc) # 钩子:记录每次调用 log_tool_call(tc.function.name, tc.function.arguments, result) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": result, }) return "已达最大执行步数,请简化任务后再试。" def _safe_execute(self, tc): if tc.function.name not in self.tools: return "工具不存在,请重新选择" try: args = json.loads(tc.function.arguments) result = self.tools[tc.function.name]["fn"](**args) return result if isinstance(result, str) else json.dumps(result, ensure_ascii=False) except Exception as e: return f"工具执行异常: {e}"

这里最关键的一行是_safe_execute里的异常捕获。很多手写实现的失败案例是:工具抛了一个异常,整个循环直接退出了。但如果你把异常信息转成普通字符串结果返回给模型,模型通常会看到“计算超时”或者“参数不合法”,然后自己换一个参数、换一个工具再试一次。这种“把错误变成可恢复信息”的思路,是 Agent 工程里非常重要的一课。

4.3 可以继续扩展的点

如果面试官或者实际需求继续追问,方案三还有几个高价值的扩展方向。

一个是上下文压缩。工具结果可能很长,多轮下来 messages 会越来越大,最终超出模型窗口。常见做法是:当历史总长度超过阈值时,把最旧的几轮对话用模型做一个摘要,替换成一小段背景说明;或者对工具结果做截断,只保留前几百字符。这个动作要小心,不能无脑截断,最好保留最后一次相关工具结果。

一个是人工确认钩子。对于删除文件、发送消息、修改订单这类危险操作,可以在_safe_execute之前加一个before_tool_call回调,返回False就拦截掉,并把“该操作需要人工审批”作为结果返回给模型。这是生产落地时非常受欢迎的设计,也是面试中能拉开差距的加分点。

还有一个是可观测性。给每个工具调用加上耗时、输入摘要、输出摘要、调用链追踪,线上出问题时你能快速定位是模型判断错了,还是工具本身报错。日志记录 agent 的思考步骤比记录最终回复重要得多。

4.4 适用边界

方案三不是每种场景都必要。如果你只是写一个“根据文档回答问题”的 Demo,方案二就够了。但当你的工具数量超过二十个、工具之间存在依赖关系、或者 Agent 要面向外部用户提供服务时,方案三的止损、日志、审批这些能力就会变成硬需求。面试时提到这个方案,本质上是告诉面试官:我不只会调接口,我还知道一个 Tool Use 系统在真实运行时会遇到哪些问题。

5. 三种方案横向对比与选型参考

5.1 对比表格

对比维度方案一 Prompt+正则方案二 原生Function Calling方案三 Agent循环框架
稳定性中低,依赖提示词服从度高,平台有结构化保障高,额外做了容错兜底
模型兼容性几乎任何文本模型需支持Function Calling可通过适配层兼容两者
开发成本低中中高
上下文管理成本需手动拼装结果相对标准,字段繁琐框架统一处理
可控性中中高
可观测性弱中强
适合场景教学、原型、兼容旧模型生产常规Agent复杂多步、强管控的Agent

这张表告诉你一个核心观点:三种方案不是“谁替代谁”,而是由场景驱动的选型。方案一强在兼容性,方案二强在稳定性与开发效率,方案三强在管控与扩展。

5.2 面试答题策略

如果面试现场让我手写,我会按下面这个顺序答题:

  1. 先说链路:模型判断需要工具,输出调用,程序解析,执行工具,结果回填,模型继续推理。这一句能让面试官知道你理解的是全链路而不是某个 API。
  2. 先写方案一:用提示词 + 正则,证明你知道最朴素的原理。代码量最小,逻辑最直观。
  3. 再补一句“但生产上我更推荐原生 Function Calling”,然后写出tool_calls和role: tool的循环。这能展示你对平台标准能力的熟悉。
  4. 最后提一嘴“如果要支持多工具、需要审批日志,我会收敛成一个带最大步数和异常捕获的小框架”。这里不一定要写完所有代码,画出循环框架、说出关键设计点就够了。

这四步串下来,面试官的印象会是:你不是背题库的人,你是真正写过 Agent 的人。

6. 常见问题与排查技巧实录

6.1 格式解析不稳、模型多输出废话

方案一最常见的翻车现场是模型输出了这样一段内容:

好的,我来计算一下。[CALL]{"name": "calc", "arguments": {"expression": "365*24"}}[/CALL]

正则提取可以正常工作,因为它只抓取[CALL]块。比较麻烦的是模型把[CALL]标签弄错大小写,或者输出成了代码块包裹:

```json {"name": "calc", "arguments": {"expression": "365*24"}}
遇到这种情况,我的兜底策略是两层:第一层用正则精确提取;第二层如果没匹配到,就启用 `extract_last_json_object` 工具函数,把回复里最后一个形如 `{...}` 的 JSON 对象提取出来尝试解析。还不行就把错误信息回吐给模型,让它重新生成合法调用块。 ### 6.2 工具结果太大,上下文被撑爆 模型调用一个“查全量订单”的工具,返回了几千行数据,下一步模型就开始胡说八道,因为关键信息被淹没在冗长文本里了。 我的建议是工具返回前先做裁剪或摘要。比如数据库查询工具,强制 `LIMIT`;文档搜索工具,只返回 Top K 个片段;API 返回结果,截断到 1000 字符以内。宁可让模型说“信息太多,请你补充更多筛选条件”,也不要让它吃进一坨注定超出窗口的文本。如果场景确实需要在长上下文里干活,就在 Agent 循环里加摘要替换机制。 ### 6.3 工具异常导致死循环或卡死 模型连续三次调用同一个工具,每次都在最后一步抛异常,Agent 就一直转圈。问题出在两处:一是异常信息没有被用于纠正模型,二是循环没有止损。 方案三里的 `_safe_execute` 已经把异常变成文本返回给模型,模型通常能自己调整参数。但万一模型抽风,就必须靠 `max_steps` 硬止损,达到步数后直接返回“无法完成”而不是无限循环。还有一个小技巧:对于连续重复的同名工具调用,计数超过 N 次就直接打断,并把“您反复调用某工具未成功,请更换策略或停止”写回上下文。 ### 6.4 如何验证 Tool Use 的正确性 手写完了不代表能上线,我习惯做三层验证: 第一层是单测。用 mock 的模型返回固定 JSON,分别测试正常调用、参数缺失、工具不存在、工具异常、无工具调用五种情况,确保解析和循环分支逻辑正确。 第二层是回归测试。准备一小组真实问题集,比如“计算 2 的 10 次方”“查某城市天气”“让模型随便聊聊天”,把每一次模型输出和工具调用轨迹记录下来,对比是否有意外调用、是否超过轮次。 第三层是人肉抽查。尤其是涉及工具副作用(发通知、写文件)的场景,必须检查日志里每个工具调用的触发理由是否合理。工具调用权限宁可收紧,也不要给模型过大的自由空间。 我个人在实际项目里的体会是:Tool Use 看似是个小功能,但涉及协议设计、状态管理、容错、可观测性,这四块做好之后,它就能从“能跑”变成“敢上线”。如果你正在准备面试,与其死记工具调用 API 的字段,不如把上面三种方案各写一遍,写到第二遍、第三遍的时候,你自然会发现面试官真正要看的不是代码长度,而是你有没有把“模型会犯错、工具会失败、系统要止损”这件事当回事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询