ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

手写 Tool Use 的三条路线:从硬编码到模型调度再到向量检索

手写 Tool Use 的三条路线:从硬编码到模型调度再到向量检索

前几天跟一个准备面试大模型应用开发的A同学复盘,他提到面试官现场甩了一道题:手写一个 Tool Use。我听完反而很兴奋——这种题没有标准答案,但它能把一个人对 Agent 的理解深度扒得干干净净。现在做 AI 应用的人多半知道 Function Calling、Tool Use 这些词,但大多数时候只是调某个框架封装好的接口,真让你从零手写一个,很多人会愣住。

这篇文章就把我自己整理过的三条实现路线完整拆开:从最简单的硬编码路由,到让大模型自主决策的调度循环,再到面向大规模工具集的向量检索路由。每条都会给可运行的代码骨架、设计理由和实际踩坑记录,适合正在准备大模型应用开发面试的人,也适合那些想在内部项目里从零搭一套工具调度机制的工程师。

1. 先想清楚:这道题到底在考什么

1.1 拆掉"Tool Use"这个词的表层光环

Tool Use 翻译过来是工具调用,但它不是让你把某个模型 SDK 里的call_function抄一遍,而是要你实现一个完整链条:模型看到工具的描述,产生调用某个工具的意图,系统把模型的输出解析成结构化指令,执行外部函数,再把执行结果回填给模型,让模型基于事实生成最终回答。

很多人在面试时死记硬背 API 流程,结果现场写代码时脑子只剩一个印象:"模型能调函数"。但手写 Tool Use 的本质,是把模型和你系统之间的那层"翻译"造出来。这层翻译里包含调度逻辑、参数校验、错误处理、多轮对话状态、安全边界。面试官抛这种题,核心就是想看你能不能主动识别这五件事,而不是背出某个接口签名。

1.2 面试官最想看到的四个层次

第一层是意图识别。模型怎么知道用户说"北京明天会不会冷"应该调天气工具?意图可以来自关键词规则,也可以来自模型推理,甚至可以来自语义检索,这决定了整个架构的走向。

第二层是结构化解析。模型输出的是一段文本,你怎么从这段文本里拿到准确的工具名和参数?如果模型的输出不是标准 JSON,你是直接崩溃还是设计一层容错机制去兜底?

第三层是执行与回填。工具执行失败怎么办?参数缺失怎么办?工具结果太大怎么把它压缩后再扔回给模型?这是 Agent 工程里最容易翻车的地方。

第四层是安全与扩展性。工具本质上是让模型接触带有外部副作用的能力,你不能让模型随心所欲地调用带 shell 命令的工具,也不能每加一个工具就去改一遍路由代码。这四个层次能回答清楚,代码写得不完美都没关系。

1.3 我为什么不只准备一种方案

因为这类面试题没有标准答案,不同方案代表不同技术认知阶段。硬编码路由是最朴素的系统,模型决策调度是当前主流 Agent 框架的核心抽象,向量检索路由是工具数量爆炸后的必选项。我在面试复盘时一般会按"方案一 -> 方案二 -> 方案三"的顺序讲,让面试官看到我怎么从最简单可靠的实现开始,再逐步升级到更高维度的架构。这比一上来就甩出一套复杂架构更能看出工程判断力。

2. 方案一:硬编码路由,把一切都写死,但先跑起来

2.1 最朴素的思路:一个注册表加一堆判断

如果你只想让系统跑通一遍,最快的办法是维护一个字典,把工具名映射到函数对象,然后用关键词匹配做路由。这个方案不依赖模型的推理能力,也不依赖任何外部框架,适合内部一次性 Demo 或者工具数量极少的稳定场景。

TOOLS = {} def tool(name: str): def decorator(func): TOOLS[name] = func return func return decorator @tool("get_weather") def get_weather(city: str, date: str) -> str: # 实际执行时调用第三方天气服务 return f"{city} 在 {date} 的天气:晴,气温 24℃,东南风 3 级" @tool("get_stock_price") def get_stock_price(code: str) -> str: # 实际执行时调用行情接口 return f"{code} 当前价格 12.34 元"

这个TOOLS字典就是你的工具注册中心。装饰器的好处是让工具函数可以散落在不同模块里,只要被 import 到,就会自动注册进字典,主路由逻辑完全不用感知具体函数的 import 路径。

2.2 路由逻辑:关键词加正则抽取参数

注册表解决的是"工具在哪",接下来还要解决"用户想调哪个工具"和"参数从哪来"。最直接的办法是写一堆if "天气" in text这种判断,再配合正则从句子里抠出城市和日期。

import re CITY_PATTERN = r"(北京|上海|广州|深圳|杭州|成都|武汉|西安)" def route(user_input: str): text = user_input.lower() if any(k in text for k in ["天气", "下雨", "气温", "冷", "热"]): city = re.search(CITY_PATTERN, text) date = "今天" if "明天" in text: date = "明天" elif "后天" in text: date = "后天" if not city: return None, {"message": "请告诉我查询哪个城市"} return "get_weather", {"city": city.group(), "date": date} if any(k in text for k in ["股票", "股价", "行情"]): code = re.search(r"[0-9]{6}", text) if not code: return None, {"message": "请提供六位股票代码"} return "get_stock_price", {"code": code.group()} return None, {"message": "暂不支持该请求"}

这样写的好处是肉眼可见的,每一条链路都完全可控,你能精准预测同一个句子永远输出同一个结果,也方便写单元测试。但它的问题也很明显:用户只要换个说法,比如"明天出门要不要带伞",你的规则就失效了,因为触发词里根本没有"带伞"。规则越积越多,最后变成一个谁也改不动的毛线球。

2.3 这个方案能扛住什么,又会在哪塌方

我实际在内部某个模拟项目X里真的用过这套方案,当时只挂了两个工具,效果还不错,响应速度极快,也不烧模型 token。但后来需求扩张到八个工具,新增的每个工具都要写两套正则加三种触发词,那段时间我几乎每天都要处理"用户为什么这样问却没触发"的 bug。后来我总结出一个判断标准:如果你的工具不超过三个,且用户话术高度模板化,硬编码是完全没问题的;但一旦跨过这个线,你就要开始考虑让模型参与决策。

2.4 面试加分点:先给工具定义一份契约

就算写硬编码路由,我也不会真的把工具变成零散的字典条目。我会顺带定义一个TOOL_SCHEMA结构,把每个工具的名字、描述、参数声明、是否允许直接调用这些信息放进去。这套东西现在用不上,但它是后面升级方案二和方案三的地基。

TOOL_SCHEMAS = { "get_weather": { "name": "get_weather", "description": "查询任意城市在某个日期的天气情况", "parameters": { "city": {"type": "string", "required": True}, "date": {"type": "string", "required": False} } }, "get_stock_price": { "name": "get_stock_price", "description": "查询 A 股某只股票的实时价格", "parameters": { "code": {"type": "string", "required": True} } } }

面试官看到你主动抽出了这份契约,他会觉得你不只是在写应付题目的代码,而是在思考如何把工具调用平台化。这个细节很加分。

3. 方案二:让模型做决策,调度器只做执行者

3.1 核心思路:把工具选择权交给模型的推理能力

方案一最大的瓶颈在于:你不可能用几条规则覆盖人类无穷无尽的表达方式。所以方案二换个思路,让模型直接看工具清单,自己决定调哪个工具、传什么参数,然后输出一个机器可解析的结构。我们只需要在 prompt 里把工具清单描述清楚,再在代码里写一个调度器去消费模型的输出。

这一步其实是现在所有主流 Agent 框架的统一抽象,只是大多数时候你直接用了封装好的接口,没机会自己实现。自己写一遍会有完全不同的理解。

3.2 一个最小可跑的结构化调用循环

我先给一个能跑通的最小骨架,不引入任何重量级框架。核心就是:把工具 schema 拼进 system prompt,让模型在需要调用工具时输出一个 JSON block,调度器解析 JSON,执行函数,再把结果回填给模型。

import json import re MAX_ROUNDS = 3 SYSTEM_PROMPT = """你是一个智能助手。你可以根据用户问题选择调用工具。 工具清单如下: {schemas} 如果你的回复需要调用工具,请严格输出以下格式的 JSON 块: <tool_call> {{"name": "工具名", "arguments": {{"参数名": "参数值"}}}} </tool_call> 除此之外不要输出任何多余内容。 如果你不需要调用工具,直接回答用户。""" def extract_tool_call(text: str): match = re.search(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", text, re.S) if not match: return None try: return json.loads(match.group(1)) except json.JSONDecodeError: return None def run_agent(user_input: str): prompt = SYSTEM_PROMPT.format(schemas=json.dumps(TOOL_SCHEMAS, ensure_ascii=False)) prompt += f"\n用户问题:{user_input}\n" for _ in range(MAX_ROUNDS): reply = chat_model(prompt) call = extract_tool_call(reply) if call is None: return reply result = execute_tool(call) prompt += f"\n工具调用结果:{result}\n请根据结果生成最终回答,或继续处理。" return "已达到最大轮次,终止调用。"

这里我特意让模型输出带标签的 JSON block,而不是要求它输出一个纯 JSON 字符串。原因很简单:很多模型在回答问题时自动加点解释性前缀,如果没有标签定位,你很难从一段自然语言里捞 JSON。有了明确的<tool_call>包裹,解析的稳定性高很多。

3.3 参数校验与模型回来补参数

调度器不能满心欢喜地相信模型给的参数一定是对的。我处理过最典型的场景:用户说"帮我查下杭州天气",模型却输出{"city": "杭州", "date": ""}。空参数值不能直接往后端传,正确的做法是做一个校验层,把缺参数的信息当成工具返回结果再喂回给模型。

def validate_arguments(name: str, args: dict): schema = TOOL_SCHEMAS.get(name) if not schema: return "不存在的工具名" missing = [p for p, spec in schema["parameters"].items() if spec.get("required") and not args.get(p)] return None if not missing else f"缺少参数:{','.join(missing)}" def execute_tool(call: dict): name = call.get("name") args = call.get("arguments", {}) error = validate_arguments(name, args) if error: return f"参数错误:{error},请让用户补充后再调用" func = TOOLS.get(name) if not func: return f"工具 {name} 不存在" return func(**args)

这一段代码看起来简单,但它把一个关键机制落地了:模型生成工具调用之后,系统不是无条件执行,而是有一个"退回去重来"的循环。这个循环在真实 Agent 系统里对应的是多轮槽位填充,用户第一轮只说"查天气",模型追问"查哪个城市",用户回复"杭州",模型再执行。没有这个校验层,整个链路就是纸糊的。

3.4 方案二的硬伤与边界

方案二把所有决策压力都给了模型,所以它的问题也出在模型身上。我在真实测试里遇到过三种令人崩溃的场景:

第一种是弱模型乱调用工具,用户问"今天几号",模型反而去调天气工具。第二种是参数幻觉,模型把用户没提过的城市名给编出来。第三种是 JSON 格式频频崩坏,原因可能只是模型在 JSON 里多了一个尾逗号。

所以方案二不是无脑把一切交给模型,它必须要配三样东西:清晰的工具 schema 描述、强容错的解析器、以及外部校验规则。面试时如果你能主动说出这三样,面试官会立刻知道你不是只会调 API 的人。

4. 方案三:向量检索路由,工具多到写不下时的正解

4.1 什么时候必须上向量检索

工具一旦超过十个,方案二就开始变得不优雅了,因为每轮对话你都要把所有工具 schema 拼进 prompt。token 消耗还是小事,更要命的是模型关注力被稀释,工具越多,它越容易把工具 A 的参数塞给工具 B。我当时在某个内部平台把工具数量做到二十多个之后,单次请求的 prompt 长度直接膨胀到几千甚至上万 token,且每次新增工具都要整套回归测试,痛苦得不行。

方案三的思路是把工具选择这个动作从模型推理改为检索:先把工具的描述离线转成向量,用户提问时把用户输入也转成一个向量,然后做相似度计算,召回路近的 top_k 个工具,再把这几个候选工具的 schema 传给方案二的调度器去做精调用工具决策。也就是说,方案三是方案二的前置过滤器,两者不是互斥关系。

4.2 实现思路:建立一个工具描述向量库

import numpy as np # 假设 embed() 是对接某个 Embedding 模型 def embed(text: str) -> np.ndarray: # 实际工程中这里会调用 embedding 接口 pass TOOL_VECTORS = {} def index_tools(): for name, schema in TOOL_SCHEMAS.items(): desc = schema["description"] TOOL_VECTORS[name] = embed(f"{name}: {desc}") def cos_sim(a: np.ndarray, b: np.ndarray) -> float: return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))) def select_tools(user_input: str, top_k: int = 3) -> list[str]: query_vec = embed(user_input) scores = sorted( [(name, cos_sim(query_vec, vec)) for name, vec in TOOL_VECTORS.items()], key=lambda x: -x[1] ) return [name for name, score in scores if score > THRESHOLD][:top_k]

这里有一个特别重要的细节:工具描述向量必须包含工具名本身。你想想,如果不把名字放进去,光靠描述"查询任意城市天气情况",检索用户一句"北京明天会不会冷"确实也能匹配上,但加了名字会让匹配更稳定,因为很多用户提问里会直接包含工具名。

4.3 召回后的二次过滤:确定性的规则兜底

纯向量检索不是百分百可靠,我还见过一个尴尬案例:用户问"帮我查下快递到哪里了",向量召回了"get_weather",因为"到哪里了"和天气描述里的"在哪"语义相近。纯靠相似度很容易踩这种语义陷阱。

所以我在检索后面永远挂一个规则兜底层:先做高置信度关键词硬匹配,如果命中直接走那个工具;没有命中才走向量检索;检索结果再经过一个最低相似度阈值过滤,同时配合一个关键词白名单,里面记录每个工具必须出现的强相关词。这个混合策略在实际工程里非常稳,它把规则的可解释性和向量的泛化能力结合起来了。

4.4 三种方案放在一张表里对比

维度方案一:硬编码路由方案二:模型决策调度方案三:向量检索路由
路由依据关键词/正则规则模型对工具清单的推理语义相似度召回
扩展性每加工具都要改代码只需更新工具 schema只需补充描述并重新索引
容错性取决于正则覆盖度取决于模型稳定性与外部校验取决于索引质量和阈值
成本低,无模型推理调用中高,多轮模型调用中等,额外一次 embedding 调用
适用场景少于 3 个工具、话术固定工具不超过 20 个、模型能力强工具几十上百个、需要冷启动过滤
面试定位展示工程基础展示 Agent 核心理解展示系统级架构思维

表格里的成本判断很重要:方案三每轮查询都要多一次 embedding 调用,并且离线索引也需要维护,所以它不是所有场景的无脑答案。面试时如果你能把这个取舍说清楚,比单纯说"我用了向量检索"更能打动面试官。

5. 面试复盘:从"手写"到"产品级",还差哪一层

5.1 面试官一定会追问的四个角落

手写代码只是第一关,面试官通常还会顺着代码继续深挖四个角落。

第一个是工具并发调用怎么做。你实现的调度器一次只能执行一个工具,但现实场景里用户可能同时要查天气和股票,这时你需要在 schema 里给工具加一个标志位表示"允许并行",调度器发现连续两个工具调用且没有依赖关系时,就并发执行。

第二个是工具执行失败之后怎么兜底。注意,不是简单返回 error 字符串就完事,而是要把失败信息喂回给模型,让模型自己决定是换个参数重试,还是向用户道歉并引导其他方案。这就是 Agent 系统的自我修正能力。

第三个是安全边界。模型很容易被诱导去把用户输入拼成 shell 命令执行,所以所有参数都必须经过 schema 的白名单校验,必要时给工具加needs_confirm标志,触发敏感操作前先让用户确认。

第四个是 JSON 解析的鲁棒性。真实模型输出永远比你想象的脏,前后有解释文字、JSON 里混入转义字符、甚至用中文引号,你的解析器要有剥除噪音再尝试解析的能力,而不是报一个JSONDecodeError就死掉。

5.2 手写 Tool Use 时最容易踩的坑

我在维护某个内部工具平台时踩过很多坑,挑几个高频的讲。

第一个坑是把工具名设计成中文。中文自然语言模型确实能匹配上,但函数注册、日志分析、递归调用时到处编码不一致,非常痛苦。工具名一律用英文小写加下划线,中文描述放在 description 字段里就行。

第二个坑是不设置最大轮次。调度循环一旦没有MAX_ROUNDS,模型在某个错误路径里可能会无限循环调用工具,烧掉大量 token。我在内部压测时见过一个死循环请求,连续调了二十多次天气工具,最后账单出来特别难看。

第三个坑是忽略审计日志。每个工具调用都必须带上 trace_id,记录调用方、输入参数、执行耗时、返回摘要。生产环境一出问题,没有日志你根本没法定位是模型决策错了还是外部服务挂了。

第四个坑是不做超时控制。外部 API 不稳定时,一个慢接口可以把整个 Agent 流程卡死,所以每个工具调用建议在契约层配一个timeout_ms,超时后立刻返回"调用超时"给模型,让模型尽快换路。

5.3 我当时最后补的那段话:工具协议应该是一份完整契约

我在方案一已经展示过最简单的TOOL_SCHEMAS,但如果要做产品级实现,这份契约会扩展成一个更完整的工具定义。一个合格的工具协议需要包含以下字段:工具唯一标识 name、语义描述 description、参数 JSON Schema、执行超时 timeout_ms、是否要求用户确认 needs_confirm、权限级别 required_permission、调用是否记录 audit。有了这套统一契约,路由、校验、安全、审计、并发控制才能全部在调度层一体化实现,而不是散落在各个工具的代码细节里。

我当时在面试时把话筒往这个方向引,面试官的兴趣明显提升,因为这意味着我不只是会写一个玩具代码,而是真的思考过工具调用平台的完整形态。

说到底,Tool Use 手写题的名字已经剧透了一切:工具是你暴露给系统的外部能力,Use 是模型输出到你系统动作之间那层翻译,你的全部工作,就是让这层翻译在脏输入、弱模型、慢接口、坏参数面前依然稳定。把这条主线想清楚了,无论面试官怎么变形考察,你都能接得住。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进