☰
手写 Tool Use 的三种方案:规则路由、Schema 调度与语义路由
2026/10/22 4:34:02 网站建设 项目流程

1. 从一句面试题说起:Tool Use 到底在考什么

前阵子面一家做 AI 应用的公司,技术面聊到一半,对方突然在共享文档里敲下一行字:“手写一个 Tool Use,不限语言。”我愣了一下,不是因为不会,而是这个问题太大了。Tool Use 表面上是个“让模型调用函数”的机制,背后面临的却是“自然语言到结构化指令、再到机器可执行动作”的一整条链路。

面试官既然让我“手写”,说明他不是想听我背某个框架的 API,而是想看我能不能脱离现成轮子,把工具调用的核心逻辑讲清楚、写出来。我当时在文档里先后写了三版方案:第一版是最朴素的规则映射,第二版是带参数校验的通用调度器,第三版是纯语义驱动的路由方案。三版代码都不长,但每一版解决的都是不同层级的问题。

这篇文章就按当时的思路把三套方案完整复盘一遍。适合这几类人看:准备 AI 应用方向面试的开发者、要自己搭 Agent 或工具调度模块的工程师、以及只想理解 Tool Use 底层逻辑的好奇读者。我保证不堆概念,直接给能跑的代码和踩坑记录。

2. 先拆清楚:Tool Use 包含哪几个关键环节

2.1 Tool Use 绝不是一个函数调用

很多人把 Tool Use 理解成“API 网关转发”,这是最大的误解。你只调用一个函数当然简单,但 Tool Use 要解决的是“模型怎么知道该调哪个函数、参数从哪来、调错了怎么办”。

完整拆开,一个 Tool Use 链路至少包含五个环节:

  • 意图识别:模型从用户的话里判断需要调用工具。例如“北京今天冷不冷”背后是查天气工具。
  • 工具发现:在候选工具集合里找到最匹配的那个。工具多了以后,这一步会变成检索问题。
  • 参数抽取:从对话内容里提取结构化参数,例如城市名“北京”对应 city 字段。
  • 执行与校验:调用真实函数,对返回结果做类型检查、异常兜底。
  • 结果回填:把执行结果返回给模型,让模型基于真实结果生成最终回答。

面试官让我写 Tool Use,本质上就是让我把这五个环节串起来。第一版方案只实现了前三个环节的简化版,第二版把校验补上了,第三版则换了一种思路彻底重做。

2.2 我当时给自己定的三条前提

手写这类问题最忌讳一上来就铺大而全的架构。我当时在文档里写清楚了三条假设,也让面试官确认我没有跑偏:

  • 不依赖任何现成的 function calling 接口和第三方封装,全部自己实现;
  • 工具数量按 20 个以内的轻量场景来设计,不搞分布式网关;
  • 先支持单轮调用,多轮调度只做说明。

把边界划定之后,后面写代码就轻松多了。遇到面试或者实际项目里的技术方案,我建议都先做这一步,它逼着你把问题限定在可控范围内,也方便对方看到你拆解问题的方式。

2.3 核心难点不在“调用”,而在“让模型输出规范”

工具执行本身没什么技术含量,真正的难点是:大模型是自然语言模型,它天然不擅长输出“机器可读的严格格式”。你在提示词里写“请输出 JSON”,它就可能在你的 JSON 外面包一层 markdown 代码块,或者多写两句解释。

所以三套方案的第一个差异,就是“怎么从自由文本里稳定地拿到工具名和参数”。这也是整个 Tool Use 设计里最先要解决的问题,后面每个方案都绕不开它。

3. 方案一:规则路由,最朴素的“函数映射表”

3.1 核心思路:让模型输出固定 JSON,代码硬解析

第一版方案最简单:在系统提示词里约定好输出格式,让模型直接返回一个 JSON 字符串,JSON 里包含 tool_name 和 arguments 两个字段。再用一个全局字典维护工具名到函数的映射,收到 JSON 后直接查表调用。

import json def get_weather(city: str): # 模拟天气查询 return {"city": city, "temperature": 18, "condition": "多云"} def calculate(expression: str): # 演示用,真实场景不要直接用 eval return {"result": eval(expression)} TOOL_MAP = { "get_weather": get_weather, "calculate": calculate, } def parse_tool_call(raw: str) -> dict: # 清理模型输出:去掉可能的 markdown 代码块包裹 cleaned = raw.strip() if cleaned.startswith("```"): cleaned = cleaned.split("```")[1] if cleaned.startswith("json"): cleaned = cleaned[4:] return json.loads(cleaned) def dispatcher(raw: str): call = parse_tool_call(raw) tool_name = call["tool_name"] arguments = call.get("arguments", {}) func = TOOL_MAP.get(tool_name) if func is None: return {"error": f"未知工具:{tool_name}"} try: result = func(**arguments) return {"tool_name": tool_name, "result": result} except TypeError as e: return {"error": f"参数错误:{e}"}

调用流程也很直接:模型输出字符串,parse_tool_call 把它变成字典,dispatcher 查表调用函数。这套代码跑通 Demo 只要几分钟,我实际测的时候最耗时间的是让模型“老老实实按格式输出”。

3.2 让模型输出稳定 JSON 的三个提示词技巧

如果你自己试过,会发现模型偶尔会在 JSON 外面加一句“好的,我来帮你调用”。这时候不要急着改代码,先改提示词。我总结下来比较有用的三个技巧:

  • 给一个完整的输出示例,写清楚“只输出 JSON,不要解释”;
  • 在示例里标明 arguments 的 key 必须和函数参数名完全一致;
  • 交待清楚“如果你不知道某个参数,就用 null 占位”。
当你需要调用工具时,只输出如下格式的 JSON,不要输出任何其他内容: {"tool_name": "get_weather", "arguments": {"city": "北京"}}

实测下来,按这个方式约束,绝大多数情况都能得到干净 JSON。但注意我说的是“大多数”,因为模型的输出随机性永远存在,这也是方案一最脆弱的环节。

3.3 软肋:模型不听话时,你要么写正则,要么崩溃

方案一最大的问题是容错性太差。我当时在测试里遇到过三种典型的“模型不听话”:

  • 把参数名从 city 写成了 city_name;
  • 在 JSON 末尾跟了一句 “如果需要,我可以继续帮你查询”;
  • 温度值 “18” 被写成了 “十八”。

前两种还能靠清理逻辑勉强兜住,第三种直接让整个链路崩掉。你当然可以写一堆正则表达式去处理异常格式,但工具一多,正则就变成了维护噩梦,这也是为什么我在实际项目中很少只用方案一。

不过方案一的价值在于思路清晰、无依赖。如果你的场景里工具就三五个、使用者固定、模型版本不频繁切换,它反而是最轻量可控的选择,毕竟面试和 Demo 里“能跑”比“极致健壮”更重要。

4. 方案二:Schema 驱动的通用调度器,补上参数校验

4.1 升级思路:从“约定格式”到“结构约束”

方案一的本质是“信模型”,完全依赖模型输出符合约定。方案二的关键升级是把约定变成代码层面的结构约束,也就是工具定义用 JSON Schema 描述,代码里先校验再执行。

你调用大模型的函数调用能力时,厂商做的其实就是这件事:你提供工具定义,模型按照定义生成结构化 tool_call,再由调度器校验执行。方案二完全可以把这套模式复刻出来,不依赖任何具体厂商。

于是我先定义一个工具注册表,每个工具带 description、params(JSON Schema)和 func 三部分:

TOOL_REGISTRY = { "get_weather": { "description": "查询指定城市的实时天气", "params": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,例如北京/上海"} }, "required": ["city"] }, "func": get_weather }, "calculate": { "description": "计算数学表达式", "params": { "type": "object", "properties": { "expression": {"type": "string", "description": "数学表达式,例如 1+2"} }, "required": ["expression"] }, "func": calculate } }

然后写一个不依赖外部库的简化版 JSON Schema 校验器,工具执行前先检查字段类型和必填项。校验通过才执行函数,执行结果统一包一层结构,异常则转换成标准错误消息回填给模型。

def validate_params(schema: dict, arguments: dict): errors = [] props = schema.get("properties", {}) required = schema.get("required", []) for field in required: if field not in arguments: errors.append(f"缺少必填参数:{field}") for key, value in arguments.items(): if key not in props: continue expected_type = props[key].get("type") if expected_type == "string" and not isinstance(value, str): errors.append(f"参数 {key} 应为字符串") elif expected_type == "number" and not isinstance(value, (int, float)): errors.append(f"参数 {key} 应为数字") return errors def execute_tool(tool_name: str, arguments: dict): definition = TOOL_REGISTRY.get(tool_name) if not definition: return {"status": "error", "message": f"未知工具:{tool_name}"} errors = validate_params(definition["params"], arguments) if errors: return {"status": "error", "errors": errors} try: result = definition["func"](**arguments) return {"status": "success", "result": result} except Exception as e: return {"status": "error", "message": f"工具执行异常:{e}"}

代码里我用 isinstance 判断类型,并没有引入完整 JSON Schema 校验库。真实项目中建议直接使用现成的校验库,但你如果是为了面试或者理解原理,手写这个简化版反而更能把逻辑讲清楚。

4.2 多轮调用中真正的坑:错误消息要“喂回去”

方案二可以做多轮交互了,但这时候容易出现一个隐蔽问题:工具执行失败后,系统直接把错误抛给用户,这是不合理的。正确做法是让模型看到错误信息后自行修正,再发起下一轮调用。

举个例子:用户说“帮我算一下 1+2”,模型生成 tool_call,假设 execute_tool 返回了一个参数类型错误。我不能直接把这个 JSON 错误展示给用户,而是要把错误拼进对话上下文,提醒模型“你的工具调用有误,请修正后重新调用”。模型看到错误消息后会重新生成 tool_call,通常两三轮之内就能自愈。

这一步是整个调度器设计里最容易被忽略、也最影响体验的细节。它意味着 execute_tool 的返回值设计必须兼顾“机器可读”和“模型可读”,我上面的 status 字段和 message 字段,就是为了让模型能够理解并继续处理。

4.3 比方案一强在哪,又还差在哪

方案二解决了方案一两大痛点:arguments 字段名写错时会被校验器抓出来,而不是直接崩溃;工具扩展时只需要往注册表里加一条记录,调度逻辑本身不用改动。

但它仍然依赖“模型能输出结构化的 tool_call”,哪怕这种输出是通过提示词或者 API 机制得到的。如果你手里的模型压根不支持结构化输出(比如某些本地部署的开源模型),方案二就无从谈起了。这也是我接下来说方案三的原因。

5. 方案三:语义路由 + 参数抽取,不依赖模型原生能力

5.1 解决什么场景:模型没有 function calling 能力

第三版方案解决的问题是:你的模型可能是私有化部署、自训练接口,或者某个不支持工具调用功能的模型。它只会纯文本生成,但你仍然需要 Tool Use。

我的做法是把“工具选择”和“参数抽取”拆成两个独立模块。工具选择交给 embedding 向量匹配,参数抽取交给生成模型按模板输出 JSON。两个模块各干各的,这样就把对模型能力的依赖降到了最低。

5.2 第一步:用向量相似度做工具路由

离线阶段,先把每个工具的描述文本(也就是 TOOL_REGISTRY 里的 description 字段)送入 embedding 模型,生成向量存起来。在线阶段,用户 query 进同一个 embedding 模型生成向量,然后计算用户 query 与每个工具向量的余弦相似度,取最高的那个作为目标工具。

def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float: dot = sum(a * b for a, b in zip(vec_a, vec_b)) len_a = sum(a * a for a in vec_a) ** 0.5 len_b = sum(b * b for b in vec_b) ** 0.5 return dot / (len_a * len_b) def select_tool(query: str, tool_vectors: dict[str, list[float]], k: int = 1) -> list[str]: query_vec = embed_text(query) scores = { tool_name: cosine_similarity(query_vec, tool_vec) for tool_name, tool_vec in tool_vectors.items() } ranked = sorted(scores.items(), key=lambda item: item[1], reverse=True) return [name for name, _ in ranked[:k]]

embed_text 函数可以用常见的文本向量模型封装,这里不展开。实际项目里还可以把用户画像、历史调用记录拼接进描述文本,提高召回准确率。

5.3 第二步:让模型按 Schema 模板抽参数

选中工具之后,需要从对话里抽取参数。实现方式是构造一段 prompt,把工具的 JSON Schema 和用户 query 一起放进去,要求模型只输出符合 Schema 的参数 JSON。

def extract_arguments(tool_schema: dict, query: str) -> dict: schema_text = json.dumps(tool_schema, ensure_ascii=False) prompt = f""" 请根据用户请求,抽取调用工具所需的参数。 只输出 JSON,不要输出解释。 参数格式要求如下: {schema_text} 用户请求:{query} """ raw = llm_complete(prompt) return parse_tool_call(raw)

你可能会问:这不又回到方案一的“让模型输出 JSON”问题了吗?确实,但这一步比方案一的范围窄得多,模型只需要输出参数 JSON,不再需要判断选哪个工具,失败概率大大降低。只要工具路由选得准,参数抽取对生成模型来说是一个典型的“按模板填槽位”任务,比开放问答稳定得多。

5.4 方案三的精度问题和补救措施

向量召回有一个典型问题:当两个工具的描述比较接近时,容易选错。比如“查天气”和“查空气质量”描述里都有“城市”“天气”这类词,向量距离会很近。

我的补救方案是:不取 k=1,而是取 k=3 召回候选工具,然后让生成模型从候选工具里选出最匹配的一个。这样等于把纯向量匹配的“硬选”升级成了“向量粗排 + 模型精排”,准确率能明显提升。

def route_tool_with_rerank(query: str, tool_vectors: dict, k: int = 3) -> str: candidates = select_tool(query, tool_vectors, k) candidate_desc = "\n".join( f"{name}: {TOOL_REGISTRY[name]['description']}" for name in candidates ) prompt = f"根据用户请求,从下列工具中选择最匹配的一个,只输出工具名。\n{candidate_desc}\n用户请求:{query}" return llm_complete(prompt).strip()

方案三的完整链路就变成了:向量召回候选工具 → 模型精排 → 参数抽取 → 执行校验 → 结果回填。这条链路在真实部署里效果很稳,即使模型完全没有原生 function calling 支持,也能做出可用的工具调用体验。

6. 三案横评:面试官想看到的,其实是你的取舍逻辑

6.1 横向对比:代码量、鲁棒性、依赖条件

写完全部三版方案,我顺手在文档里列了一张对比表,这里直接贴出来:

对比维度方案一:规则路由方案二:Schema 调度器方案三:语义路由
代码规模最小,约 40 行中等,约 100 行中等,约 120 行
对模型的依赖依赖输出固定 JSON依赖结构化 tool_call仅依赖文本生成能力
参数校验无强,Schema 驱动强,Schema 驱动
工具扩展成本注册到字典即可注册 Schema 即可需同时更新描述向量
多轮自愈能力弱强,错误可回填中,需额外设计
鲁棒性低高中
适用场景工具少、可控 Demo主流模型 + 工具较多私有模型 / 无原生能力

单看参数,方案二最均衡,它也是目前大多数生产系统的主流选择。方案三的价值在于补足了方案二覆盖不到的那部分模型场景。

6.2 现场我是怎么回答取舍的

面试官在我写完三版之后问了一个很刁的问题:“如果只让你保留一个方案上线,你怎么选?”

我的回答是:优先保留方案二,原因是它能覆盖大部分主流模型通道,而且错误处理闭环已经完整。方案三作为备选方案,只在模型不支持结构化输出时才启用。至于方案一,它更适合用来快速验证想法,比如新产品试错阶段先跑通流程,确认需求后再切换。

这种取舍逻辑比代码本身更重要。面试官问“手写 Tool Use”,表面要的是代码,实际想看的是:你有没有能力在约束条件下做出技术决策,而不是无脑堆方案。

6.3 一个加分细节:把工具调用失败设计成交互闭环

当场我还补了一个改进点:如果工具执行失败,不直接把原始报错抛给用户,而是生成一段自然语言解释,同时给出下一步建议。举个例子:查天气接口超时,系统回复“天气服务暂时不可用,你可以稍后再试或换个城市”,而不是“连接超时: TimeoutError”。

这个细节让调度器从“工具调用框架”变成了“对话体验的一部分”。面试官听完之后明显状态变了,问的问题也更深了,我感觉到这一步才是真正踩在他想要的方向上。

7. 我的复盘:如果再答一次,我会从链路图开始

面完出来,我自己复盘了一遍。第一版我直接写了代码,虽然思路对,但顺序可以更好。如果再遇到同样的题目,我会先在文档里画一条链路图:意图输入 → 工具路由 → 参数抽取 → 执行校验 → 结果回填 → 模型生成回复,然后在链路图的每个节点标注“这里可以用方案几来解决”。这样对方能第一时间看到全局架构,而不是等我一行行写代码才反应过来。

另外,我在实际项目里跑过这三套方案之后,最大的体会是:Tool Use 的瓶颈从来不在调度器本身,而在输入侧的稳定性。模型输出格式飘忽、参数名不一致、工具描述模糊,这些问题每个都比调度代码难缠。所以如果你要自己搭一套工具调用机制,我建议先把时间和精力花在输入约束上,包括提示词模板、Schema 定义和工具描述文本的质量,调度器反倒是最容易实现的部分。

最后再分享一个小技巧:面试或者项目里展示 Tool Use 时,不要只展示“调用成功”的路径,一定要展示“调用失败后模型如何自愈”。这个环节最容易出彩,因为它说明你不是纸上谈兵,而是真的见过工具调用在生产环境里翻车的那些瞬间。

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

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

立即咨询