1. 面试现场为什么“手写 Tool Use”成了高频考题1.1 从“背八股”到“现场造轮子”的转变这两年技术面试的风向变了。以前聊大模型面试官顶多问问 Transformer 结构、注意力机制怎么算或者让你讲讲 RAG 的流程。现在不一样了只要简历上写了“做过 Agent”“搞过 LLM 应用”面试官大概率会甩出一句“那你手写一个 Tool Use 吧。”我第一次遇到这个要求的时候脑子里的第一反应是这东西平时都是用框架一把梭谁没事手写啊。但面试官的表情很明确——他不是要看你调包的能力而是想看你是否真正理解Function Calling的底层链路。说白了Tool Use 就是让模型学会“打电话叫外援”模型自己判断什么时候该调用外部函数、调用哪个函数、传什么参数然后拿到结果继续往下推理。这个能力为什么重要因为大模型本身有几个硬伤知识有截止日期、算数容易出错、没法直接操作数据库或发请求。Tool Use 就是给它装上手和脚。面试官让你手写其实是在考察三件事你对模型输出格式的控制能力、对参数校验与执行的工程化思维以及对多轮循环的调度理解。1.2 三种方案的分层逻辑我当时在面试现场一共给出了三种方案从“最原始”到“最工程化”递进。第一种是纯 Prompt 解析流完全靠提示词约束模型输出 JSON然后自己写解析器第二种是原生 Function Calling 流利用模型厂商提供的工具调用接口让模型直接返回结构化的调用请求第三种是ReAct 循环调度流把推理和行动拆成多轮模型边想边做直到任务完成。这三种方案没有绝对的优劣关键看场景。面试官后来跟我说他出这道题就是想看候选人能不能根据约束条件做取舍而不是背一个标准答案。下面我把每种方案的实现细节、踩过的坑和实测效果都摊开讲你拿去就能复现。2. 方案一纯 Prompt 解析流——最笨但最可控2.1 核心思路与提示词设计这个方案的核心就一句话用提示词逼模型输出固定格式的 JSON然后自己解析。听起来很土但它有一个巨大优势——不依赖任何特定厂商的 API 特性换模型也能跑。提示词的设计是关键。我当时的写法是这样的你是一个可以使用工具的助手。可用工具如下 - get_weather(city: str)查询指定城市的天气 - calculator(expression: str)计算数学表达式 当需要调用工具时请严格输出以下 JSON 格式不要输出任何其他内容 {tool: 工具名, args: {参数名: 参数值}} 如果不需要调用工具直接回答用户问题。这里有个细节我特意强调了“不要输出任何其他内容”。因为实测下来如果不加这句模型很喜欢在 JSON 前面加一句“好的我来帮你查询”解析器直接崩掉。2.2 解析器的健壮性处理解析器看起来简单但坑特别多。我一开始用json.loads直接解析结果模型偶尔会输出带 markdown 代码块的格式比如json {...}。后来我加了一层清洗逻辑import json import re def parse_tool_call(text): # 去掉 markdown 代码块标记 text re.sub(rjson\s*|\s*, , text.strip()) # 尝试直接解析 try: data json.loads(text) if tool in data and args in data: return data except json.JSONDecodeError: pass # 兜底用正则提取第一个 JSON 对象 match re.search(r\{.*\}, text, re.DOTALL) if match: try: return json.loads(match.group()) except json.JSONDecodeError: return None return None这段代码我后来在面试复盘时又优化过。关键点是永远不要相信模型输出的格式。哪怕提示词写得再死模型也有概率跑偏。所以解析器必须有多层兜底先清洗、再直接解析、最后正则提取。2.3 参数校验与执行闭环解析出工具名和参数后不能直接执行。我见过太多人在这里翻车——模型传了个city: 北京朝阳区但你的函数只认北京。所以参数校验层必须有TOOL_REGISTRY { get_weather: { func: get_weather, required: [city], types: {city: str} }, calculator: { func: calculator, required: [expression], types: {expression: str} } } def execute_tool(tool_name, args): if tool_name not in TOOL_REGISTRY: return f错误未知工具 {tool_name} spec TOOL_REGISTRY[tool_name] for param in spec[required]: if param not in args: return f错误缺少参数 {param} if not isinstance(args[param], spec[types][param]): return f错误参数 {param} 类型不对 try: return spec[func](**args) except Exception as e: return f工具执行异常{str(e)}注意工具执行一定要包 try-except并且把异常信息返回给模型而不是直接抛给用户。模型看到错误信息后有机会自我修正比如换个参数重新调用。这个方案的实测效果在简单任务上准确率能到 85% 左右但一旦工具数量超过 5 个模型选错工具的概率明显上升。而且每次都要把工具描述塞进提示词token 消耗比较大。3. 方案二原生 Function Calling——最省心但最挑模型3.1 接口调用与工具描述格式原生 Function Calling 的思路是把工具定义成结构化的 schema传给模型 API模型直接返回结构化的调用请求。以常见的接口风格为例工具描述大概长这样tools [ { type: function, function: { name: get_weather, description: 查询指定城市的天气, parameters: { type: object, properties: { city: { type: string, description: 城市名称如北京、上海 } }, required: [city] } } } ]然后调用的时候把tools传进去模型返回的tool_calls字段就是结构化的。这个方案最大的好处是不用自己解析文本模型输出的就是 JSON 对象直接取字段就行。3.2 多工具并行调用的处理原生接口还有一个隐藏福利支持并行工具调用。比如用户问“北京和上海今天天气怎么样”模型可能一次性返回两个tool_calls。这时候你的执行层要能处理数组def handle_tool_calls(tool_calls): results [] for call in tool_calls: name call.function.name args json.loads(call.function.arguments) result execute_tool(name, args) results.append({ tool_call_id: call.id, role: tool, content: str(result) }) return results这里有个关键点每个工具结果都要带上tool_call_id否则模型不知道哪个结果对应哪个调用。我第一次写的时候漏了这个字段模型直接懵了回复说“我没有收到工具结果”。3.3 模型兼容性与降级策略原生方案最大的问题是挑模型。不是所有模型都支持 Function Calling支持的模型里schema 的严格程度也不一样。我实测下来有些模型对required字段不敏感你不写它也可能不传有些模型对嵌套参数支持不好。所以工程上一定要有降级策略先尝试原生调用如果模型返回的格式不对或者根本不支持自动切换到方案一的 Prompt 解析流。这个切换逻辑可以封装成一个统一的ToolUseEngine对外暴露同一个接口。实操心得在传工具 schema 的时候description一定要写清楚。我试过把description写成“查询天气”模型经常把城市名和日期搞混。后来改成“查询指定城市当天的天气参数 city 只填城市名不要带‘市’字”准确率明显提升。4. 方案三ReAct 循环调度——最像 Agent 的方案4.1 推理与行动的交替循环ReAct 的核心是Thought → Action → Observation的循环。模型先输出一段思考然后决定调用哪个工具拿到结果后再思考直到得出最终答案。这个方案最接近真实 Agent 的工作方式。提示词大概是这样你可以使用以下工具 - get_weather(city) - calculator(expression) 请按以下格式回复 Thought: 你的思考过程 Action: 工具名 Action Input: 参数 JSON 然后等待 Observation ... 当你有足够信息回答时输出 Thought: 我现在可以回答了 Final Answer: 最终答案解析的时候要按行匹配Action:和Action Input:拿到后执行工具把结果作为Observation:拼回上下文再次调用模型。4.2 循环终止条件与最大轮次控制这个方案最容易出问题的地方是死循环。模型可能反复调用同一个工具或者一直在 Thought 里打转。所以必须设置最大轮次比如 5 轮超过就强制终止并返回当前结果。MAX_TURNS 5 for turn in range(MAX_TURNS): response call_model(context) if Final Answer: in response: return extract_final_answer(response) action, action_input parse_action(response) if action is None: return 无法解析工具调用 observation execute_tool(action, action_input) context f\nObservation: {observation}\n return 达到最大轮次任务未完成4.3 上下文管理与 token 控制ReAct 的上下文会越来越长因为每一轮的 Thought、Action、Observation 都要拼进去。实测下来3 轮之后 token 消耗就很可观了。优化手段有两个一是只保留最近 N 轮的完整记录更早的做摘要二是把工具描述放在系统提示里不要每轮重复。注意ReAct 方案在需要多步推理的任务上表现最好比如“帮我查北京天气如果温度低于 10 度就计算 100 减去温度的值”。这种任务用方案一或方案二模型往往只调一次工具就停了而 ReAct 能自然地串起来。5. 三种方案横向对比与选型建议5.1 关键指标对照表维度纯 Prompt 解析原生 Function CallingReAct 循环实现难度低中高模型依赖无强无多步推理弱中强Token 消耗中低高格式稳定性低高中并行调用不支持支持不支持适合场景简单单工具生产环境复杂任务5.2 我的选型逻辑如果面试官只让我选一种我会选原生 Function Calling因为它是工程上最稳的。但面试现场我特意把三种都讲了一遍因为选型本身就是考察点。我的逻辑是工具数量少于 3 个、模型不支持原生调用用方案一生产环境、模型支持原生调用用方案二任务需要多步推理、工具之间有依赖关系用方案三。实际项目中我经常把方案二和方案三结合——用原生接口做工具调用用 ReAct 的循环做任务调度。6. 面试现场踩过的坑与排查实录6.1 模型输出格式跑偏的三种典型情况第一种是JSON 前后带废话比如“好的我来调用工具{...}”。第二种是参数类型错误比如把数字写成字符串expression: 11没问题但city: 123就离谱。第三种是工具名拼写错误模型把get_weather写成getWeather。排查思路很简单先把模型原始输出打印出来不要急着解析。我面试时就在纸上画了个流程把“模型输出 → 清洗 → 解析 → 校验 → 执行”每一步的失败点都标出来面试官看了之后点了点头。6.2 工具执行超时与异常兜底工具执行一定要设超时。我试过调用一个外部接口结果卡了 30 秒整个对话都挂住了。后来加了timeout参数超时后返回“工具执行超时请稍后重试”让模型决定是重试还是换方案。异常兜底的原则是永远不要把原始异常抛给用户。把异常信息格式化后返回给模型模型往往能自己修正。比如参数缺失模型看到“缺少参数 city”后下一轮就会补上。6.3 多轮对话中的上下文污染这个问题在 ReAct 方案里特别明显。如果上一轮的工具结果里包含了干扰信息模型下一轮可能会被带偏。我的做法是工具结果只保留关键字段不要一股脑全塞回去。比如天气接口返回了一大坨 JSON我只提取temperature和condition两个字段拼成一句话。独家技巧在工具结果的末尾加一句“以上是工具返回结果”能有效减少模型把工具结果当成用户输入的情况。这个技巧我在三个项目里都验证过很稳。7. 可直接复用的最小实现骨架7.1 统一工具注册与调度层不管用哪种方案工具注册层是共用的。我把它设计成一个字典每个工具包含函数、参数说明、必填项class ToolRegistry: def __init__(self): self.tools {} def register(self, name, func, description, required): self.tools[name] { func: func, description: description, required: required } def get_schema(self): return [ { name: name, description: info[description], parameters: { type: object, properties: { p: {type: string} for p in info[required] }, required: info[required] } } for name, info in self.tools.items() ] def execute(self, name, args): if name not in self.tools: return f未知工具{name} try: return self.tools[name][func](**args) except Exception as e: return f执行失败{str(e)}7.2 三种方案的统一入口对外暴露一个run方法内部根据配置选择方案class ToolUseEngine: def __init__(self, registry, modenative): self.registry registry self.mode mode def run(self, user_input): if self.mode prompt: return self._run_prompt(user_input) elif self.mode native: return self._run_native(user_input) elif self.mode react: return self._run_react(user_input)这样切换方案只需要改一个参数面试时也能体现你的架构思维。7.3 测试用例设计我准备了三个测试用例来验证实现单工具调用查天气、多工具串行查天气后计算、无工具调用纯聊天。这三个用例能覆盖 80% 的边界情况。实测下来方案一在第三个用例上容易误触发工具方案二表现最稳方案三在第二个用例上最自然。8. 从面试题到生产系统的延伸思考8.1 工具数量膨胀后的检索问题真实系统里工具可能有几十上百个不可能全塞进提示词。这时候需要工具检索先用向量检索找出最相关的几个工具再传给模型。这个思路和 RAG 很像只是检索对象从文档变成了工具描述。8.2 权限控制与审计日志生产环境还要考虑权限。不是所有用户都能调用所有工具比如删除数据的工具就得限制。我的做法是在工具注册时加一个permission字段执行前校验。同时每次调用都记审计日志方便排查问题。8.3 从单 Agent 到多 Agent 协作当任务复杂到单个 Agent 搞不定时就需要多 Agent 协作。比如一个负责查数据一个负责分析一个负责写报告。这时候 Tool Use 就变成了 Agent 之间的通信协议。这个方向我在后续项目里做过一些探索核心难点在于任务拆分和结果汇总。面试官最后跟我说他其实不指望候选人在 20 分钟内写出生产级代码他想看的是思路是否清晰、边界是否考虑到、踩坑是否有预案。我把这三种方案讲完又补了测试用例和降级策略他才满意地笑了。后来我复盘的时候想这道题真正的价值不在于手写 Tool Use 本身而在于它逼着你去思考模型和外部世界之间的那层胶水到底该怎么设计才既灵活又可靠。
阅读完成 · 觉得有帮助?