面试的时候被要求手写 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(ftool 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(ftool 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 promptSYSTEM_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(rjson\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 Calling4.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, toolstools, tool_choiceauto) 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, toolstools, tool_choiceauto) 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_workers4) 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 是模型生成还是系统生成的”我明确答复由服务端生成、模型侧透传。这个细节如果没看协议文档很容易答反。我推荐大家面试前把主流协议里关于 roletool 的那一段仔细读一遍不要只看示例代码。6.2 给准备面试同学的四个建议别只背流程要把三种方案的边界条件捋一遍。面试官最喜欢从边界切入追问比如“规则匹配不上怎么办”“JSON 解析失败怎么办”“工具本身抛异常怎么办”。代码不必跑起来但核心分支要能背下来。找不到工具、解析失败、ID 错配、参数类型错误这四个分支记熟面试时基本能过关。主动讲取舍。你可以说“我为什么不用规则、为什么选用某协议的某个特性”这比罗列功能更能体现经验。取舍意味着你动过脑子而不是抄过 demo。如果提到框架一定要说清楚框架底层帮你做了什么。你可以说“框架帮我做了工具注册、消息回填和循环控制但我自己实现过一遍这些流程”这句话比一百行背诵有用。我把三个方案现场写完的那一刻面试官说了一句让我印象很深的话“你能写出第三种方案我不意外能把第一种方案的适用场景讲明白才是加分项。” 后来复盘我也觉得好的回答不是谁方案炫而是谁能讲清楚“在什么场景下选什么方案”。最后分享一个小技巧面试前可以在本地把三种方案的最小代码都写一遍不用跑通但要能快速在空白文档里默写出来。我这次能稳住节奏靠的就是这个。Tool Use 这道题背任何标准答案都不如自己能动手拆一遍。