恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
手写Tool Use三种方案:从字符串解析到多轮调度器
首页
资讯中心
/
手写Tool Use三种方案:从字符串解析到多轮调度器
手写Tool Use三种方案:从字符串解析到多轮调度器
发布时间:2026/10/22 5:25:16
1. 从一道面试题说起为什么“手写 Tool Use”成了高频考点面试官让我手写一个 Tool Use我用了 3 种方案。这句话听起来像是一道刁钻的面试题但它背后折射的是当前大模型应用开发中最核心的一类工程能力让模型不只是“聊天”而是能真正调用外部工具去完成事情。所谓 Tool Use通俗讲就是给模型配上一双手——它能根据用户意图决定该调用哪个函数、传什么参数、拿到结果后怎么继续推理。这套机制是智能体、工作流自动化、检索增强等几乎所有落地场景的地基。我之所以对这道题印象深是因为它看似简单实则考察面极广。面试官想看的不是你能不能背出某个框架的 API而是你是否理解“模型输出结构化意图”和“程序执行真实动作”之间那条边界该怎么划。很多人第一反应是套用现成框架但一旦被要求手写就暴露出对协议格式、参数校验、错误回传这些细节的模糊。这篇文章我就把当时用过的三种方案完整拆开讲从最朴素的字符串解析到基于 JSON Schema 的规范调用再到带多轮状态管理的调度器。每种方案我都会说清楚它解决什么问题、在什么场景下够用、以及实测中会踩哪些坑。适合读这篇的人有三类正在准备相关岗位面试的开发者、想自己搭一套工具调用链路的技术爱好者、以及已经用了框架但想搞懂底层原理的工程师。我不打算堆砌术语而是像复盘一次真实项目那样把每一步的取舍讲透。你不需要事先精通某个框架只要会写基本的函数和懂一点 JSON就能跟着走完。2. 方案一基于字符串匹配的朴素解析器2.1 为什么先从这个“笨办法”入手面试时如果一上来就写复杂调度器反而容易让面试官觉得你脱离实际。我选择先讲字符串解析方案是因为它最能体现 Tool Use 的本质模型输出的是一段文本程序需要从这段文本里提取出“要调用哪个工具”和“参数是什么”。在没有结构化输出约束的年代大家就是这么干的。它的逻辑非常直白——约定一个特殊标记比如让模型输出[TOOL: weather] [ARGS: {city: 北京}]然后用正则或字符串分割把工具名和参数抠出来。这个方案的价值不在于它多优雅而在于它让你彻底看清一件事模型本身并不“执行”任何东西它只是生成了一段符合约定的文本真正的执行权始终在程序手里。理解这一点后面所有复杂方案都只是在这条边界上做加固。我当时的实现大概三十行代码核心就是一个解析函数加一个工具注册表。import re, json TOOL_REGISTRY {} def register(name): def wrapper(fn): TOOL_REGISTRY[name] fn return fn return wrapper register(weather) def get_weather(city): return f{city}今天晴25度 def parse_and_run(text): pattern r\[TOOL:\s*(\w)\]\s*\[ARGS:\s*(\{.*?\})\] match re.search(pattern, text, re.S) if not match: return 未识别到工具调用 name, args_str match.group(1), match.group(2) try: args json.loads(args_str) except json.JSONDecodeError: return 参数解析失败 if name not in TOOL_REGISTRY: return f工具 {name} 不存在 return TOOL_REGISTRY[name](**args)2.2 字符串方案的三个致命软肋跑通 demo 很容易但真要用起来问题立刻暴露。第一个软肋是格式脆弱。模型只要多打一个空格、换个引号、或者在参数里嵌套了带方括号的字符串正则就可能匹配失败或截断。我实测时让模型输出一个包含地址的参数结果地址里的括号直接把解析搞崩了。第二个软肋是没有类型校验。模型可能把数字写成字符串把布尔值写成“是/否”程序拿到后要么报错要么静默出错。第三个软肋是无法处理多工具并行调用模型一次想调两个工具时这套简单正则只能抓到第一个。注意字符串解析方案只适合原型验证或模型输出高度可控的内部场景。一旦面向真实用户输入千变万化这套方案的维护成本会指数级上升。我在面试时特意强调了这三点面试官追问了一句“那你怎么保证模型稳定输出这个格式”。我的回答是靠提示词约束只能提高概率不能保证。真正要稳定就得让模型输出本身具备结构这就引出了第二种方案。这个过渡很自然也展示了你对问题边界的认知。2.3 从字符串到结构的思维跃迁字符串方案最大的收获是让我意识到“约定”必须尽可能靠近模型的原生能力。大模型在训练时见过大量 JSON 和代码它对结构化格式的生成能力远强于你临时发明的标记语言。所以与其让模型适应我的正则不如让模型输出它最擅长的 JSON我再按 JSON 的规则去解析。这个思路转变看似小却直接决定了后面方案的可靠性上限。面试中能主动讲出这个认知升级比单纯写出代码更加分。3. 方案二用 JSON Schema 约束模型输出3.1 结构化输出到底解决了什么问题第二种方案的核心是不再让模型自由发挥格式而是给它一份 JSON Schema明确告诉它“你必须输出一个对象包含 tool 字段和 arguments 字段arguments 里再按各工具的参数定义来填”。这份 Schema 既是给模型看的说明书也是给程序用的校验依据。模型输出后程序先做 JSON 解析再用 Schema 校验任何不符合规范的输出直接判定为无效可以要求模型重试。这个方案相比字符串解析最大的提升在于可校验性。参数类型、必填项、枚举值都能在 Schema 里写死程序拿到数据后不用再猜。我当时的实现用了一个轻量的校验逻辑没有引入重型依赖核心就是检查字段是否存在、类型是否匹配。def validate(schema, data): for key, rule in schema.items(): if rule.get(required) and key not in data: return False, f缺少必填字段 {key} if key in data: expected rule[type] actual type(data[key]).__name__ if expected number and actual not in (int, float): return False, f{key} 类型应为数字 if expected string and actual ! str: return False, f{key} 类型应为字符串 return True, ok3.2 工具描述本身也是一份“提示词”很多人忽略了一点给模型的工具列表本质上是一段提示词。工具名、功能描述、参数说明写得越清楚模型选错工具、传错参数的概率就越低。我在实测中把工具描述从“查询天气”改成“根据城市名查询该城市当前天气状况参数 city 为城市中文名”调用准确率明显上升。这说明 Tool Use 的效果不只取决于解析代码还取决于你怎么向模型描述你的工具。下面这张表是我当时对比两种描述方式的实际效果样本是五十条用户问法描述方式工具选择准确率参数填写准确率极简描述仅工具名约 72%约 65%完整描述功能参数说明示例约 94%约 91%数据是内部小样本测试不代表通用结论但趋势很明确描述质量直接决定调用质量。面试时我把这个观察讲出来面试官明显来了兴趣因为这属于“文档里不会写、只有动手才知道”的经验。3.3 校验失败后的重试策略结构化输出并不能保证一次成功。模型偶尔会漏字段、类型写错或者干脆输出一段解释文字而不是 JSON。这时候程序不能直接崩而要有一套重试机制。我的做法是第一次校验失败后把失败原因拼回给模型让它重新生成最多重试两次。实测下来绝大多数格式问题在第一次重试后就能修正。提示重试时一定要把“上次错在哪”明确告诉模型比如“你上次输出的 arguments 缺少 city 字段”而不是简单说“格式不对”。带上下文的纠错成功率远高于盲目重试。这个方案已经能支撑大部分单轮工具调用场景但它有个前提一次只处理一个工具调用且不需要跨轮记忆。当用户说“先查天气如果下雨就帮我订室内场馆”这就涉及多步推理和状态传递单靠一次 Schema 校验就不够了。于是有了第三种方案。4. 方案三带状态管理的多轮工具调度器4.1 调度器要解决的核心矛盾第三种方案是我认为最接近生产可用的形态。它的核心矛盾是模型的一次输出可能只完成一步而用户的真实需求往往需要多步。调度器要做的就是维护一个“对话工具调用”的循环状态每一轮把历史消息、工具结果都带上让模型基于最新上下文决定下一步。这其实就是智能体循环的雏形。我把它拆成三个部分状态容器、调度循环、终止判断。状态容器保存消息列表和已调用记录调度循环负责把消息发给模型、解析输出、执行工具、把结果追加回消息列表终止判断决定什么时候停下来——要么模型不再请求工具要么达到最大轮数。class ToolDispatcher: def __init__(self, model_call, tools, max_turns5): self.model_call model_call self.tools tools self.max_turns max_turns def run(self, user_input): messages [{role: user, content: user_input}] for _ in range(self.max_turns): reply self.model_call(messages, self.tools) messages.append(reply) call self.extract_call(reply) if not call: return reply[content] result self.execute(call) messages.append({role: tool, content: result}) return 达到最大轮数已停止4.2 状态传递中最容易出错的细节多轮调度里最容易翻车的不是循环逻辑而是消息格式的一致性。不同模型对“工具结果”这条消息的字段要求不一样有的要求带 tool_call_id有的要求 role 必须是特定值。我踩过的坑是工具执行结果追加回去时漏了关联 ID导致模型把结果当成了新的用户输入整个推理跑偏。排查这个问题花了我不少时间最后是靠打印完整消息列表才定位到。另一个细节是工具执行异常的处理。工具可能超时、可能抛异常这时候不能把异常直接丢给模型而要包装成一条结构化的错误信息比如“工具 weather 执行失败城市名无效”。模型拿到这条信息后往往能自己调整参数重试或者向用户说明情况。这比程序直接崩溃要优雅得多。4.3 三种方案的横向对比与选型建议把三种方案放在一起看它们并不是替代关系而是适用不同阶段。下面这张表是我总结的选型参考维度字符串解析JSON Schema 约束多轮调度器实现复杂度低中高格式稳定性差好好支持多工具并行否有限是支持多步推理否否是适合场景原型验证单轮工具调用智能体、工作流主要风险解析失败重试成本状态错乱我的建议是如果是面试或学习三种都值得手写一遍因为每一层都对应一个认知台阶。如果是真实项目直接从第二种起步等业务出现多步需求再升级到第三种。不要一上来就上调度器那会让简单问题复杂化。5. 面试现场我是怎么组织回答的5.1 先讲边界再写代码面试时最忌讳的是闷头写代码。我当时的做法是先花两分钟讲清楚 Tool Use 的本质边界模型负责生成意图程序负责执行和校验。然后我说“我打算从简单到复杂讲三种方案每种说清楚它的适用边界”。这样面试官立刻知道你有全局观而不是只会背一种写法。事实证明这个开场让后面的交流顺畅很多面试官会顺着你的框架去追问而不是随机发难。5.2 主动暴露方案的不足每讲完一种方案我都会主动说它的软肋。比如讲完字符串解析我说“这套方案在真实用户输入下几乎不可用因为格式太脆弱”。讲完 Schema 方案我说“它解决不了多步推理”。这种主动暴露不足的做法反而让面试官觉得你理解得深因为你清楚每个方案的边界在哪。很多人怕说缺点会显得自己方案不行其实恰恰相反能准确说出边界才是工程能力的体现。5.3 把“为什么”讲在“怎么做”前面每写一段代码前我都会先解释为什么这么设计。比如为什么用注册表模式管理工具因为这样新增工具不用改调度逻辑为什么重试要带上错误原因因为盲目重试浪费调用次数。面试官后来说他见过很多人能写出代码但说不清为什么这么写而 Tool Use 这类题恰恰最看重设计取舍。这句话我记了很久也成了我后来做技术分享时的一条原则。6. 实测中那些文档不会告诉你的坑6.1 参数里的“隐形类型陷阱”模型输出 JSON 时数字和字符串的边界经常模糊。比如用户说“查一下 3 号场馆”模型可能把参数写成id: 3而不是id: 3。如果你的工具函数严格要求整数就会直接报错。我的处理方式是在校验层做一次温和的类型转换能转就转转不了再报错。这个细节在文档里通常不会提但不处理就会在真实场景里频繁触发。6.2 工具数量多了之后的选择困难当工具注册表里只有三五个工具时模型选得很准。但当工具增加到二十个以上选错率明显上升尤其是功能相近的工具。我试过两个办法一是给工具分组让模型先选类别再选具体工具二是精简工具描述把最关键的区分点放在描述开头。实测下来分组策略对准确率提升更明显但会增加一轮交互。这个取舍要根据你的延迟要求来定。6.3 循环终止条件的边界情况多轮调度器如果终止条件写得不严谨可能出现两种情况一是模型一直请求工具循环停不下来二是模型还没完成任务就被强制截断。我的做法是设置最大轮数的同时也检测“连续两轮工具调用完全相同”这种死循环信号一旦发现就主动中断并返回提示。这个检测逻辑很简单但能避免很多尴尬的卡死场景。7. 从手写 Tool Use 延伸到真实项目落地把这三种方案吃透之后再看市面上的各种框架你会发现它们本质上都在做同一件事把“模型输出意图”和“程序执行动作”这条链路封装得更稳、更通用。区别只在于封装程度和抽象层次。理解了手写版本的每一个环节你再用框架时就不会把它当黑盒遇到问题也知道该往哪个方向排查。我在实际项目里的体会是Tool Use 的难点从来不在“调用”这个动作本身而在于异常处理、状态管理和边界校验这些脏活累活。框架能帮你省掉一部分样板代码但省不掉你对业务边界的思考。比如哪些操作需要二次确认、哪些工具结果需要脱敏、失败后是重试还是降级这些都得你自己定。最后分享一个我常用的小技巧在开发阶段把每次模型输出和工具执行结果都完整打日志包括原始文本和解析后的结构。排查问题时这份日志比任何调试器都好用因为 Tool Use 的 bug 往往藏在“模型到底输出了什么”和“程序到底解析成了什么”之间的落差里。把这个落差看清楚大部分问题都能迎刃而解。