资讯动态

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

发布时间:2026/10/13 19:35:32 来源:尧图企业网站定制
前几天跟一个准备面试大模型应用开发的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(rtool_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(schemasjson.dumps(TOOL_SCHEMAS, ensure_asciiFalse)) 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()], keylambda 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 是模型输出到你系统动作之间那层翻译你的全部工作就是让这层翻译在脏输入、弱模型、慢接口、坏参数面前依然稳定。把这条主线想清楚了无论面试官怎么变形考察你都能接得住。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑