☰
大模型Tool Use手写实战:从字符串解析到Agent调度循环
2026/10/14 7:29:50 网站建设 项目流程

1. 从一道面试题说起:为什么"手写 Tool Use"成了高频考点

面试官让我手写一个 Tool Use,我用了 3 种方案。这句话听起来像是一道刁钻的面试题,但如果你最近在关注大模型应用开发这个方向,就会发现它其实已经成了绕不开的基本功。所谓 Tool Use,通俗讲就是让语言模型在对话过程中,能够识别出"这个问题我需要调用外部工具才能回答",然后输出一个结构化的调用请求,由外部程序执行后再把结果喂回给模型,最终生成自然语言回复。它解决的核心问题是:模型本身的知识是静态的、有限的,而真实业务里的数据是动态的、私有的、需要实时计算的。

我之所以对这道题印象深,是因为它表面上考的是"你会不会写代码",实际上考的是你对模型能力边界、协议设计、错误处理、上下文管理这几件事的综合理解。很多人第一反应是"不就是拼个 JSON 吗",但真到白板上写的时候,才发现要考虑的东西远比想象中多:工具描述怎么给模型看?模型返回的调用格式不合法怎么办?多个工具同时被调用怎么处理?调用失败了要不要重试?这些细节才是区分"会用框架"和"懂原理"的分水岭。

这篇文章适合三类人:一是正在准备大模型应用岗面试、想把这题答漂亮的同学;二是已经在用现成框架做 Agent、但想搞清楚底层到底发生了什么的一线开发者;三是想自己从零搭一套轻量工具调用逻辑、不想被框架绑死的工程师。我会把当时在面试现场写的三种方案完整复盘出来,从最朴素的字符串解析,到基于结构化输出的约束解码,再到带完整调度循环的 Agent 式实现,每一种都讲清楚它的设计动机、核心代码、适用边界,以及我在实际落地时踩过的坑。

需要先说明一点:下面所有代码和场景都是我基于常见工程实践做的合理还原与补充,不涉及任何真实公司或项目的内部实现。方案本身是通用的,你可以直接拿去改吧改吧用在自己的项目里。

2. 方案一:字符串解析派——最土但最能看清本质

2.1 为什么先写这个"笨办法"

面试的时候,面试官往往希望你先给出一个能跑通的最小实现,而不是一上来就堆框架。字符串解析方案就是这样一个"最小可用原型":我们约定模型必须按照某种固定格式输出工具调用意图,然后用正则或者字符串切分把它抠出来。它的价值不在于生产可用,而在于它能让你彻底看清 Tool Use 的本质——模型输出的只是一段文本,所谓"调用工具"完全是外部程序赋予这段文本的语义。

这个认知非常关键。很多人误以为模型真的"执行"了什么,其实模型从头到尾只做了一件事:根据你给的提示词,生成了一段符合格式的文本。真正去查数据库、调接口、算数学的,是你自己写的代码。想通这一点,后面所有方案的设计思路就都顺了。

2.2 约定的输出格式与解析逻辑

我当时在白板上定的格式是这样的:模型如果需要调用工具,就输出一段被特殊标记包裹的内容,里面用类似键值对的方式描述工具名和参数。比如:

<tool_call> name: get_weather args: {"city": "杭州", "unit": "celsius"} </tool_call>

解析的时候,先用正则把<tool_call>...</tool_call>之间的内容抓出来,再逐行解析name和args。args部分直接交给 JSON 解析器处理。核心代码大概长这样:

import re import json TOOL_CALL_PATTERN = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL) def parse_tool_call(text): match = TOOL_CALL_PATTERN.search(text) if not match: return None body = match.group(1).strip() name = None args = {} for line in body.splitlines(): line = line.strip() if line.startswith("name:"): name = line[len("name:"):].strip() elif line.startswith("args:"): raw = line[len("args:"):].strip() try: args = json.loads(raw) except json.JSONDecodeError: args = {} if not name: return None return {"name": name, "args": args}

这段代码很短,但它已经包含了 Tool Use 最核心的三步:识别意图、提取参数、结构化返回。识别意图靠的是标记匹配,提取参数靠的是字段切分,结构化返回是为了让后续调度代码能统一处理。

2.3 这个方案的致命弱点在哪

字符串解析最大的问题是脆弱。模型是概率生成的,它今天心情好给你输出标准格式,明天可能就多打一个空格、少写一个引号、把args写成arguments,甚至把整个 JSON 拆成多行。你写的正则稍微严格一点就匹配不上,宽松一点又容易误伤正常对话内容。

我在实际项目里就遇到过:模型在解释"什么是 tool_call 标记"的时候,自己把示例标记也输出了一遍,结果解析器误以为它要调用工具,直接触发了一次莫名其妙的调用。这种"示例污染"问题在字符串方案里几乎无解,只能靠更复杂的上下文判断去规避。

还有一个隐藏问题是参数类型丢失。JSON 里"count": "3"和"count": 3是两回事,模型经常把数字写成字符串。如果你的下游函数严格校验类型,就会直接报错。字符串方案里你只能手动做类型转换,而且转换规则得一个个字段写死,维护成本很高。

所以我的结论是:字符串解析适合做 Demo、做教学、做面试第一问的铺垫,但绝不适合直接上生产。它教会你的是"Tool Use 的本质是文本到结构的映射",而不是"怎么把这件事做稳"。

3. 方案二:结构化输出派——把格式约束交给解码层

3.1 从"事后解析"转向"事前约束"

写完方案一,面试官通常会追问一句:"如果模型不按格式输出怎么办?"这时候就该抛出第二种思路了:与其等模型生成完再去解析、解析失败再重试,不如在生成阶段就把格式约束住。这就是结构化输出(Structured Output)的核心思想,也是现在很多模型 API 直接支持的能力。

它的原理是:在解码的每一步,根据预先定义好的 schema(比如 JSON Schema),把那些会导致格式非法的 token 直接屏蔽掉,只允许模型在合法路径上继续生成。打个比方,字符串解析像是"让学生自由答题,答完老师再挑格式错误",结构化输出则是"直接给学生一张填空题答题卡,想写错格式都没地方写"。

3.2 用 JSON Schema 定义工具调用协议

在方案二里,我不再让模型自由发挥,而是给它一个严格的 schema。工具调用的输出被定义成一个对象,包含tool_name和parameters两个必填字段,parameters又是一个根据具体工具动态生成的子 schema。示意如下:

{ "type": "object", "properties": { "tool_name": { "type": "string", "enum": ["get_weather", "search_docs", "calculate"] }, "parameters": { "type": "object", "additionalProperties": true } }, "required": ["tool_name", "parameters"] }

注意tool_name用了enum约束,这意味着模型只能从你注册的工具列表里选,不可能编造出一个不存在的工具名。这一条约束直接消灭了字符串方案里"工具名拼错"的一整类问题。parameters这里为了简化用了宽松定义,实际生产中应该针对每个工具单独生成精确的子 schema,把参数名、类型、是否必填都约束死。

调用的时候,代码会变成这样:

def build_tool_schema(tools): return { "type": "object", "properties": { "tool_name": {"type": "string", "enum": [t["name"] for t in tools]}, "parameters": {"type": "object"} }, "required": ["tool_name", "parameters"] } response = model.generate( prompt=user_input, response_schema=build_tool_schema(registered_tools) ) tool_call = json.loads(response)

3.3 结构化输出解决了什么,又留下了什么

它解决的核心问题是格式可靠性。实测下来,只要 schema 定义得当,模型输出的 JSON 合法率能接近百分之百,你基本不用再写一堆 try-except 去兜底解析错误。这对生产环境意义重大,因为解析失败意味着要么重试(浪费 token 和时间),要么降级(用户体验变差),两种都不划算。

但它并没有解决语义正确性。模型可能输出了一个完全合法的 JSON,工具名也在枚举里,但参数填得驴唇不对马嘴——比如用户问"北京天气",它却把city填成了"上海"。格式对不代表内容对,这是两码事。结构化输出管的是"语法",管不了"语义"。

另外还有一个工程上的现实问题:不是所有模型 API 都支持严格的结构化输出,有些只支持"建议性"的 JSON 模式,实际还是会偶尔跑偏。而且 schema 越复杂,对模型能力的消耗越大,生成速度也会受影响。所以方案二适合那些对格式稳定性要求高、且所用模型支持约束解码的场景,比如企业内部的表单填写、工单分类、参数抽取这类任务。

4. 方案三:调度循环派——真正的 Agent 骨架

4.1 为什么还需要一个"循环"

前两个方案都只解决了"模型怎么表达调用意图",但一个完整的 Tool Use 系统还有后半段:谁来执行工具、执行结果怎么回传、模型拿到结果后怎么继续。这就是方案三要补上的部分,也是它被称为"Agent 骨架"的原因。

完整的流程是一个循环:模型输出调用意图 → 外部执行工具 → 把执行结果作为新消息追加到对话历史 → 再次调用模型 → 模型要么继续调用下一个工具,要么给出最终回答。这个循环会一直转,直到模型不再请求工具、或者达到预设的最大轮次上限。理解这个循环,你就理解了市面上绝大多数 Agent 框架的内核。

4.2 调度器的核心结构与代码

调度器的职责很清晰:维护消息历史、解析模型输出、分发工具执行、处理异常、控制循环终止。核心结构大概是这样:

class ToolDispatcher: def __init__(self, tools, model, max_turns=8): self.tools = {t["name"]: t for t in tools} self.model = model self.max_turns = max_turns def run(self, user_input): messages = [{"role": "user", "content": user_input}] for turn in range(self.max_turns): reply = self.model.generate(messages, tools=list(self.tools.values())) tool_call = self._extract_call(reply) if tool_call is None: return reply # 没有工具调用,直接返回最终回答 result = self._execute(tool_call) messages.append({"role": "assistant", "content": reply}) messages.append({"role": "tool", "content": json.dumps(result)}) return "达到最大轮次限制,未能完成任务" def _execute(self, tool_call): name = tool_call["name"] if name not in self.tools: return {"error": f"未知工具: {name}"} try: return self.tools[name]["func"](**tool_call["args"]) except Exception as e: return {"error": str(e)}

这段代码里有两个设计点值得展开说。第一是max_turns,它是防止死循环的保险丝。模型有时候会陷入"调用工具→结果不满意→再调用同一个工具"的循环,没有轮次上限的话,你的 token 账单会很难看。第二是_execute里的异常捕获,工具执行失败绝不能直接抛出去中断整个流程,而应该把错误信息作为工具结果回传给模型,让模型自己决定是换个参数重试还是换条路走。

4.3 工具描述怎么写,模型才用得对

方案三能不能跑好,很大程度上取决于工具描述的质量。模型选不选对工具、参数填得对不对,全靠你给它的描述。我总结了几条实操经验:

  • 描述要写"什么时候用",而不只是"是什么"。比如search_docs的描述不要只写"搜索文档",而要写"当用户询问产品功能、使用方式、政策条款等需要查阅内部资料的问题时使用"。
  • 参数描述要带示例。city参数写"城市名称,如:杭州、成都",比只写"城市"的准确率高不少。
  • 工具数量要克制。一次给模型几十个工具,它的选择准确率会明显下降。实践中我一般控制在十个以内,多了就做分层路由,先让模型选大类,再选具体工具。
  • 命名要语义清晰。get_user_info比gui好,calculate_shipping_fee比calc好,模型对完整语义的敏感度比你想的高。

4.4 三种方案的横向对比

把三种方案放在一起看,各自的定位就很清楚了:

维度方案一 字符串解析方案二 结构化输出方案三 调度循环
格式可靠性低,依赖正则高,解码层约束取决于前两者
实现复杂度低中高
是否含执行逻辑否否是
适用场景Demo、教学参数抽取、表单完整 Agent 应用
主要风险格式漂移、误触发语义错误死循环、成本失控

实际项目里,方案二和方案三通常是组合使用的:用结构化输出保证格式,用调度循环保证流程完整。方案一更多是理解原理的起点。

5. 面试现场没写出来的那些坑

5.1 并行工具调用与结果顺序

面试时我漏讲了一个重要场景:模型可能一次性请求调用多个工具。比如用户问"对比杭州和成都的天气",模型可能同时输出两个get_weather调用。这时候调度器要能识别出多个调用、并发执行、然后按顺序把结果回传。如果串行执行,延迟会翻倍;如果并发执行但结果顺序乱了,模型可能会把两个城市的数据对错号。

我的处理方式是给每个调用分配一个唯一 id,执行结果里带上这个 id,回传时按 id 匹配。这样即使并发完成顺序不同,模型也能正确对应。这个细节在面试里说出来,基本能体现你是真做过而不是背过。

5.2 工具执行超时与降级

真实环境里工具调用可能很慢,甚至卡死。如果不设超时,整个对话就挂在那里了。我的做法是给每个工具设一个超时阈值,超时后返回一个明确的错误结果,让模型知道"这个工具暂时不可用",它可以选择换工具或者直接告诉用户稍后再试。这比让用户干等要体面得多。

5.3 上下文膨胀问题

调度循环每转一圈,消息历史就长一截。工具返回的结果如果很大(比如一次搜索返回了几千字),几轮下来上下文就爆了。我一般会在回传工具结果前做一次截断或摘要,只保留和当前问题最相关的部分。这个取舍很考验经验:截多了模型信息不够,截少了上下文爆炸。

5.4 安全边界:别让模型为所欲为

工具调用意味着模型可以间接操作你的系统,这本身就是个风险面。我的原则是:所有工具的参数都要在服务端做二次校验,不能因为模型说user_id是 123 就真去查 123 的数据,得先确认当前登录用户有没有权限。模型可以被诱导,但你的后端校验不能省。这一点在面试里主动提出来,是很加分的。

6. 如果让我重新答这道题

现在回头看,如果面试官再问我一次"手写一个 Tool Use",我不会急着写代码,而是先把问题拆成三层:表达层(模型怎么说出调用意图)、约束层(怎么保证格式和语义可靠)、执行层(谁来跑、怎么回传、怎么收尾)。然后按这三层依次给出方案,从字符串解析讲到结构化输出再讲到调度循环,每一层都点明它解决了什么、留下了什么。

这样答的好处是,面试官能清楚看到你的思维是有结构的,而不是零散地堆知识点。而且这个三层框架本身就是通用的,你换任何一个 Agent 相关的题目,都能套进去分析。

至于代码,白板上写不了太多,重点是把调度循环的骨架和异常处理写清楚,剩下的细节用嘴补充。面试考的是判断力,不是打字速度。

最后分享一个我自己的习惯:每次做完一个 Tool Use 相关的功能,我都会专门造一批"刁钻输入"去测它——比如让用户故意问一个需要三个工具接力才能回答的问题,或者问一个工具描述里没覆盖的边界情况。跑完这些用例,你才会真正知道自己的实现到底稳不稳。工具调用这东西,能跑通 Demo 和能上生产之间,隔着的就是这些不起眼的边界处理。

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

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

立即咨询