面试的时候被要求手写 Tool Use,可能不少做 LLM 应用开发的同人都遇到过。我前阵子也撞上了,面试官当场把编辑器页面推过来,让我写一个 Tool Use 的调度机制。这道题不算偏,但当场手写容易露怯。
我没急着动手,先把范围定了:Tool Use 至少对应三条完全可以落地的技术路线。于是跟面试官说,我按三种方案给你写。面试官一听来了兴趣,让我每个方案都讲清楚。
这篇文章就把现场讲的三种方案完整还原:规则引擎式、提示词工程式、原生函数调用式。每种方案我都给出能跑通的示意代码、适用边界和实际踩过的坑。无论你是马上要面大模型应用岗,还是自己动手搭 Agent,这篇文章都可以直接参考。
1. 面试题本质:Tool Use 到底在考什么
1.1 拆题:工具调用层的四个核心要素
Tool Use 这个名字,在 LLM 应用圈子里指的就是让大模型能够“使用外部工具”。通俗说,模型自己算不了天气、查不了库存、发不了邮件,但你可以给它提供工具,让它按照你的指令去调用。
不管用什么方案,一个完整的 Tool Use 层都绕不开四个要素:
- 工具定义:告诉系统“有什么工具可用”,包括工具名称、描述和参数结构。
- 意图识别:判断用户的问题需要调用哪个工具。
- 参数提取:从用户问题里抽出工具需要的参数。
- 结果回填:把工具执行结果返回给模型或用户,必要时让模型基于结果继续作答。
当你把问题拆到这个粒度,面试题就变成了一道论证题,而不是背诵题。你不会再去记某个框架的 API,而是会思考“如果让我自己设计,我会怎么分模块”。这也是我后来复盘时觉得最有价值的地方——拆解问题的能力,比记住某个高级特性重要得多。
1.2 面试官真正想听的,不是“会不会调包”
现在市面上的 Agent 框架不少,调包十分钟就能搭出一个带工具调用的聊天机器人,那面试官为什么要你手写?我个人的理解是,框架屏蔽了太多关键决策点。
比如工具描述怎么组织、参数提取错了怎么兜底、连续多次调用怎么终止循环,这些在框架里都成了“默认值”。面试官要看的恰恰是这些你自己做的取舍。如果你只会说“我用某个框架的某个类”,那只能证明你做过 demo,不能证明你理解系统内部发生了什么。
我当时跟面试官摊牌也说得很直白:框架可以简化交付,但如果要控制细节和成本,自己写一遍是有价值的。面试官点头了。这个“开场定调”很关键,面试不是背答案,而是展示你在技术选项上的判断力。你想用哪一种方式解决哪一类问题,背后一定有一组约束条件,把它讲清楚,比背十个接口更有说服力。
2. 方案一:规则引擎 + 工具注册表(最朴素,胜在可控)
2.1 核心思路:把“意图识别”交给规则
第一种方案,我管它叫“轻模型版本”。不用大模型做意图判断,直接用规则匹配关键词,命中哪个工具就调哪个工具。
这个思路听着简单,但很实用。你做一个客服机器人,用户说“我要退款”,你直接匹配“退款”关键词,把售后工单工具调出来就行,为此调用一次大模型,反而慢了还费钱。很多团队把工具调用做得很复杂,恰恰忘了规则引擎才是性价比最高的起点。
规则方案听起来不像 AI,但它其实是真实系统里最抗造的兜底层。用户表达稳定、系统权限要求清晰的业务,往往第一版就用规则顶上,而不是一上来就调大模型,成本和延迟都低到可以忽略。
规则引擎适合三种场景:
- 意图数量少,且用户表达相对固定。
- 对响应延迟有硬性要求,比如端上交互。
- 离线环境跑不了大模型,或者大模型成本预算有限。
别觉得它 Low,很多生产环境里,规则层反而是最稳的那一层。我做过一个内部运维助手,工具就五个,用户翻来覆去就那么几类问题,用规则方案跑了大半年,稳得很。
2.2 代码实现:一个最小可用的工具注册表
我先给面试官写了一个工具注册表类,核心是 register 和 dispatch 两个方法。注意,这个类不依赖任何模型 SDK,纯手写,面试时可以直接默写出来。
class ToolNotFoundError(Exception): pass class ToolRegistry: def __init__(self): self._tools = {} def register(self, name, description, parameters_schema, handler): """ name: 工具名 description: 工具用途描述,规则方案里也可用来匹配 parameters_schema: 参数说明,规则方案里不强制用 handler: 实际执行的函数 """ self._tools[name] = { "name": name, "description": description, "parameters": parameters_schema, "handler": handler, } def dispatch(self, name, **kwargs): tool = self._tools.get(name) if tool is None: raise ToolNotFoundError(f"tool not found: {name}") return tool["handler"](**kwargs)注册几个工具:
def get_weather(city: str): return f"{city} 当前气温 26 度,多云" def create_refund(order_id: str): return f"订单 {order_id} 已提交退款申请" registry = ToolRegistry() registry.register("get_weather", "查询城市天气", {"city": "string"}, get_weather) registry.register("create_refund", "创建退款工单", {"order_id": "string"}, create_refund)然后写一个基于关键词的规则匹配器:
import re def match_tool(user_input: str, rules): """ rules: {"get_weather": ["天气", "气温"], "create_refund": ["退款", "退货"]} """ for tool_name, keywords in rules.items(): for kw in keywords: if kw in user_input: return tool_name return None def extract_params(user_input: str, tool_name: str): if tool_name == "get_weather": # 简单从文本中提取城市,这里只做示意 match = re.search(r"([\u4e00-\u9fa5]{2,}市)", user_input) return {"city": match.group(1) if match else ""} if tool_name == "create_refund": match = re.search(r"订单号[:: ]*([A-Za-z0-9]+)", user_input) return {"order_id": match.group(1) if match else ""} return {}完整的调用流程:
def run_rule_based(user_input: str): tool_name = match_tool( user_input, { "get_weather": ["天气", "气温"], "create_refund": ["退款", "退货"], }, ) if tool_name is None: return "当前无法理解你的请求,请换个说法" params = extract_params(user_input, tool_name) return registry.dispatch(tool_name, **params)这个二三十行的小结构,就是规则引擎式 Tool Use 的全部了。整个流程没有模型参与,工具命中靠关键词表,参数提取靠正则,执行靠注册表分发。你把它缩成一个函数,本质上就是一个极简的、确定性的技能分发器。我当时写到这里,面试官已经明白我是真写过,不是背概念。
2.3 这个方案的关键细节与局限
现场写到这一段,面试官问了一个很现实的问题:参数提取错了怎么办?我的回答是:加校验。
参数校验可以用 JSON Schema,也可以用最笨的手写检查。对规则方案来说,工具注册表本身要内置参数校验逻辑,宁可少传参数,也不能让类型错误的参数被透传到底层函数:
def dispatch(self, name, **kwargs): tool = self._tools.get(name) if tool is None: raise ToolNotFoundError(f"tool not found: {name}") # 简单类型检查:字符串参数必须是 str declared = tool["parameters"] for k, expected_type in declared.items(): if k in kwargs: if expected_type == "string" and not isinstance(kwargs[k], str): raise TypeError(f"参数 {k} 期望 string,实际 {type(kwargs[k])}") return tool["handler"](**kwargs)规则方案的局限也很明显:它本质上是“固化的意图表”,遇到没见过的表达就失效。比如用户说“今天要不要带伞”,规则匹配不到“天气”,就会走到兜底分支。所以在规则方案落地的项目里,我通常会加一个“未命中时走模型兜底”的路由,这也是很多真实产品的做法——先免费命中,再花钱兜底,整体成本可控,体验也不算差。
3. 方案二:提示词工程 + 结构化输出解析(纯 LLM 方案)
3.1 核心思路:让模型自己决定调用
第二种方案,也就是面试官最希望看到的“最正统手写方式”。在不依赖厂商特定协议的前提下,把工具描述写进 system prompt,让模型在需要时输出一段固定格式的结构化内容,由代码解析后执行。
这个方案比方案一灵活得多,它可以覆盖各种自然语言表达。“今天要不要带伞”这种问题,模型理解后会自动调用天气工具,不需要你穷举所有说法。
它的核心价值在于模型无关。不管换模型还是换厂商,提示词模板可以基本不变,这是很多团队愿意自己维护这套逻辑的原因。你只要保证模型具备基本的指令跟随能力,这套方案就能跑起来,不绑定任何厂家的私有协议。
代价也很明显:你需要自己处理模型输出格式不稳定、JSON 解析失败、幻觉出工具名、参数漏传错传这一大堆问题。但这些问题恰恰是面试官想看到的实战细节,也是我在这篇文章里最想展开的部分。
3.2 代码实现:提示词模板 + JSON 解析 + 执行回填
我现场写的结构分三步:第一步,构建工具描述;第二步,调用模型并要求输出固定 JSON;第三步,解析 JSON 并调度执行。
def build_tool_schema(tools): lines = [] for name, desc, params in tools: param_str = ", ".join(f"{k}: {v}" for k, v in params.items()) lines.append(f"- {name}({param_str}): {desc}") return "\n".join(lines) TOOL_DESC = build_tool_schema([ ("get_weather", "查询城市当天天气。", {"city": "城市名(str)"}), ("create_refund", "创建退款申请。", {"order_id": "订单号(str)"}), ])然后定义 system prompt:
SYSTEM_PROMPT = f""" 你是一个智能客服助手。当用户的问题需要调用工具时,请输出一个 JSON 对象,不要输出任何额外文字。 可用工具: {TOOL_DESC} 输出格式: {{"tool": "工具名", "arguments": {{"参数名": "参数值"}}}} 如果无需调用工具,请直接输出: {{"reply": "你的回答"}} """调用链路:
import json def run_llm_tool_call(user_input: str): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input}, ] llm_response = llm.chat(messages) # llm 为任意模型 API 的封装 try: parsed = json.loads(llm_response) except json.JSONDecodeError: return "模型输出非合法 JSON,已进行兜底处理" if "tool" in parsed: tool_name = parsed["tool"] args = parsed.get("arguments", {}) tool_result = registry.dispatch(tool_name, **args) # 第三步:把工具结果回填给模型,让模型组织最终答案 messages.append({"role": "assistant", "content": llm_response}) messages.append({"role": "user", "content": f"工具返回结果:{tool_result}\n请据此回答用户。"}) final_answer = llm.chat(messages) return final_answer return parsed.get("reply", "处理失败")看到没,这整条链路就是很多 Agent 框架内部在帮你做的“那件事”。自己写一遍,才能明白每一步为什么存在。比如为什么要回填工具结果再调一次模型?因为用户要的是最终答案,不是“工具执行成功”这种中间状态。又比如为什么要把 assistant 消息也带回去?因为模型需要上下文读出它自己刚才生成的调用指令,否则多轮对话会断片。
3.3 结构化解析的对抗与兜底:防幻觉和无格式输出
方案二最折磨人的,是模型不一定严格按照你的 JSON 格式输出。
模型“幻觉”出一个不存在的工具名,怎么办?最常见的做法是注册表里找不到就发起一次纠正对话:
def safe_dispatch(tool_name, args, messages): try: return registry.dispatch(tool_name, **args) except ToolNotFoundError: corrected = "你刚才指定的工具不存在,请从可用工具中选择,只重新输出 JSON。" messages.append({"role": "user", "content": corrected}) new_resp = llm.chat(messages) return parse_and_run(new_resp)还有模型输出大段解释文字,JSON 藏在中间,这会导致 json.loads 直接失败。实战经验是:先去两个反引号之间找 JSON,找不到再按大括号从两端裁剪:
import re def extract_json(raw: str): fenced = re.findall(r"```json\s*(.*?)\s*```", raw, re.S) if fenced: return json.loads(fenced[0]) start = raw.find("{") end = raw.rfind("}") if start != -1 and end > start: return json.loads(raw[start : end + 1]) raise ValueError("no json found")这个“先提取再解析”的技巧,几乎是所有提示词式方案必须内置的防御逻辑。面试时能主动讲出这一点,比背十遍“提示词要写清楚”有用得多。因为这是真实线上系统才会遇到的问题,也是你在文档里看不到的东西。
4. 方案三:原生函数调用(主流厂商的 Function Calling)
4.1 核心思路:协议层直接返回工具指令
第三种方案,是把前两种方案的“意图识别 + 参数提取”完全交给模型层处理。主流大模型服务现在普遍支持一个能力:你在请求参数里声明一批工具,模型在回答时会直接返回一个工具调用指令,告诉你“该调哪个函数、参数是什么”。
这意味着你不需要自己设计输出格式约定,也不用写 JSON 提取的防御逻辑。工具的声明走标准 JSON Schema,模型返回的结果里带工具调用标记,你把标记解析出来直接 dispatch。
这个方案最核心的好处是稳定。因为工具调用的结果是协议层强约束的,模型会尽力把参数格式对齐到 schema,而不是靠你写一套提示词去“求”它。对于工具数量多、参数结构复杂的 Agent 来说,这是最省心的一条路。
4.2 代码实现:tools 参数 + 调用循环
定义一个工具的 types 参数,这是主流协议里的标准写法:
tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的当前天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,例如 某城市"}, }, "required": ["city"], }, }, }, { "type": "function", "function": { "name": "create_refund", "description": "为指定订单发起退款申请", "parameters": { "type": "object", "properties": { "order_id": {"type": "string", "description": "订单号"}, }, "required": ["order_id"], }, }, }, ]然后发起一轮带工具声明模型的对话请求:
def run_function_calling(user_input: str): messages = [{"role": "user", "content": user_input}] # 第一次请求:声明可用工具 resp = llm.chat(messages, tools=tools, tool_choice="auto") assistant_msg = resp.message messages.append(assistant_msg) if assistant_msg.tool_calls: for tc in assistant_msg.tool_calls: tool_name = tc.function.name args = json.loads(tc.function.arguments) tool_result = registry.dispatch(tool_name, **args) messages.append({ "role": "tool", "tool_call_id": tc.id, "content": str(tool_result), }) # 第二次请求:把工具结果发回模型,让它生成最终回答 final_resp = llm.chat(messages, tools=tools, tool_choice="auto") return final_resp.message.content return assistant_msg.content这里有个细节很多人会漏:messages 里要按顺序回填 tool_call_id。协议要求工具结果必须通过 tool 角色绑定到对应的调用 ID 上,漏掉的话,模型会认为结果不属于哪次调用,最终回答就会乱。我当时特意把这个点拎出来讲,面试官的反馈是“你真的读过协议文档”。
4.3 原生方案的高级用法:多工具选择与并行调用
原生函数调用还有一个实用能力:在一轮回答里返回多个工具调用。比如用户说“查一下 A 地的天气,顺便帮我查 B 地”,模型可以直接返回两个 get_weather 调用,参数分别是两个城市。
代码里需要注意,多个 tool_calls 要循环处理,而且如果这些工具之间没有依赖关系,应该并发执行,减少总响应时间:
from concurrent.futures import ThreadPoolExecutor def dispatch_many(tool_calls): def _run(tc): tool_name = tc.function.name args = json.loads(tc.function.arguments) return tc.id, registry.dispatch(tool_name, **args) with ThreadPoolExecutor(max_workers=4) as pool: return list(pool.map(_run, tool_calls))这一类细节是框架文档里一般不会写透的。面试时能讲到并发执行这个点,面试官基本会认为你踩过真实生产环境的坑。我甚至遇到过把 tool_call_id 对应关系写错的情况,结果模型把两个工具的结果混在一起回答,排查了很久,最后发现是消息顺序问题。
5. 三种方案对比与选型建议
5.1 横评:延迟、成本、可控性、可维护性
面试讲到这一步,我给面试官画了一张对比表。这里也整理给各位:
| 维度 | 规则引擎式 | 提示词工程式 | 原生函数调用式 |
|---|---|---|---|
| 延迟 | 最低,纯本地规则 | 中间,走一次模型 | 中间,走一至两次模型 |
| 成本 | 几乎为零 | 按 token 计费,非工具场景也消耗 | 按 token 计费,有固定格式开销 |
| 可控性 | 最高,逻辑写死 | 中,依赖模型遵循格式 | 中高,依赖协议约束 |
| 可维护性 | 规则越多越难维护 | 提示词与解析器需要共同演进 | 工具定义变更走标准 JSON Schema |
| 模型兼容性 | 无需模型,任意环境可用 | 基本兼容所有模型 | 仅支持支持该协议的主流服务 |
5.2 我的选型经验:从项目阶段倒推方案
我的个人经验是,选哪种方案不要按“哪个更高级”走,而要看项目阶段与场景约束。
内部工具数量少于五条,而且交互路径清晰,直接用方案一,又快又省。我做过一个报表查询机器人,用户问“这个月转化率多少”,我直接匹配“转化率”关键词查指标接口,整链路 20 毫秒搞定,用户感受比接大模型还顺滑。
需要让模型具备理解自然语言的能力,但不想绑定某个厂商的私有协议,选方案二。这套方案对模型选择自由度最高,适合做产品原型验证或者私有化部署场景,只要模型能遵循指令输出 JSON,你就能跑起来。
项目已经上了多工具、多轮调用的复杂 Agent,工具数量几十个甚至上百个,强烈建议直接用方案三。协议层把参数约束做得很死,你不需要自己写复杂的 JSON 校验和幻觉拦截,工具调用的生命周期交给平台管理会省很多事。
面试时主动说出“我会根据工具数量和数据敏感度来选”,比只说“我会用某个框架”更像有工程经验的人。面试官考察的其实是判断力,不是你的 API 记忆能力。
6. 面试复盘与避坑清单
6.1 现场口误与补救话术
面试时有一个地方我差点翻车:方案二的 JSON 解析里,一开始我直接 json.loads,没有处理模型多输出文字的情况。面试官问“如果模型在 JSON 前后加了说明呢”,我卡了两秒,然后补上了 extract_json 的逻辑。这个补救其实花不了几行代码,但很能体现临场变通能力。
还有一次,讲方案三的时候我提到 tool_call_id 需要按顺序回填,面试官追问“这个 ID 是模型生成还是系统生成的”,我明确答复由服务端生成、模型侧透传。这个细节如果没看协议文档,很容易答反。我推荐大家面试前把主流协议里关于 role=tool 的那一段仔细读一遍,不要只看示例代码。
6.2 给准备面试同学的四个建议
- 别只背流程,要把三种方案的边界条件捋一遍。面试官最喜欢从边界切入追问,比如“规则匹配不上怎么办”“JSON 解析失败怎么办”“工具本身抛异常怎么办”。
- 代码不必跑起来,但核心分支要能背下来。找不到工具、解析失败、ID 错配、参数类型错误,这四个分支记熟,面试时基本能过关。
- 主动讲取舍。你可以说“我为什么不用规则、为什么选用某协议的某个特性”,这比罗列功能更能体现经验。取舍意味着你动过脑子,而不是抄过 demo。
- 如果提到框架,一定要说清楚框架底层帮你做了什么。你可以说“框架帮我做了工具注册、消息回填和循环控制,但我自己实现过一遍这些流程”,这句话比一百行背诵有用。
我把三个方案现场写完的那一刻,面试官说了一句让我印象很深的话:“你能写出第三种方案我不意外,能把第一种方案的适用场景讲明白才是加分项。” 后来复盘我也觉得,好的回答不是谁方案炫,而是谁能讲清楚“在什么场景下选什么方案”。
最后分享一个小技巧:面试前可以在本地把三种方案的最小代码都写一遍,不用跑通,但要能快速在空白文档里默写出来。我这次能稳住节奏,靠的就是这个。Tool Use 这道题,背任何标准答案都不如自己能动手拆一遍。