大模型面试高频题:手写Tool Use三种方案与工程实践 1. 面试现场为什么“手写 Tool Use”成了高频考题“手写一个 Tool Use”——这六个字放在两三年前很多做应用层的开发者可能还会愣一下Tool Use 不是模型自带的能力吗调用一下接口不就行了但如果你最近一年参加过中大型团队的面试尤其是跟大模型应用、Agent 平台、智能助手相关的岗位就会发现这道题出现的频率高得离谱。面试官不再满足于你“会调 API”而是想看你“能不能把工具调用这套机制从零搭出来”。我先把这个概念说清楚。Tool Use中文一般叫“工具调用”或“函数调用”指的是让大语言模型在对话过程中根据用户意图自主决定去调用某个外部函数或接口拿到结果后再组织成自然语言回复。比如用户问“帮我查一下明天这个城市会不会下雨”模型自己判断需要调用天气查询工具传入城市和日期参数拿到结构化结果再翻译成人话返回。它解决的核心问题是模型的知识是静态的、有限的而真实世界的任务需要实时数据、需要执行动作、需要访问私有系统。这道题适合谁来参考三类人。第一类是正在准备大模型应用岗面试的开发者你需要知道面试官到底想考什么、怎么答才能拿高分。第二类是已经在做 Agent 产品、但工具调用这块一直是“调库黑盒”的工程师想搞清楚底层到底发生了什么。第三类是对大模型应用感兴趣、想自己动手搭一个最小可用系统的爱好者。不管你属于哪一类这篇内容都会把三种方案的取舍、实现细节、踩坑经验讲透。面试官出这道题本质上是在考察三件事你对模型输出格式约束的理解、你对解析与执行链路的设计能力、以及你对异常处理和安全性的敏感度。很多人第一反应是“我直接调某个框架的 function calling 不就行了”但面试官要的是“手写”意思是你得把中间那层黑盒拆开自己实现一遍。接下来我就按三种方案从最简单到最工程化逐一拆解。2. 方案一基于提示词约束的纯文本解析方案2.1 核心思路与适用场景第一种方案是最“原始”但也最能体现基本功的完全靠提示词Prompt约束模型输出格式然后用字符串解析提取工具调用意图。这个方案不依赖任何模型原生的函数调用能力理论上任何能返回文本的模型都能用。核心逻辑是这样的在系统提示词里明确告诉模型“当你需要调用工具时请严格按照以下 JSON 格式输出{tool: 工具名, args: {...}}不要输出任何其他内容”。然后程序拿到模型返回的文本用正则或 JSON 解析器提取出工具名和参数执行对应函数再把结果拼回对话历史发起第二轮请求让模型生成最终回复。这个方案的优势非常明显通用性极强不挑模型本地部署的小模型也能跑可控性高格式完全由你定义调试直观出问题了直接看模型输出的原始文本就知道哪一步错了。它特别适合模型能力有限、或者你需要对输出格式做极度定制化的场景。2.2 提示词设计与格式约束的关键细节提示词这块是方案一的命门。我试过很多版本最后总结出一个比较稳的模板结构分四段角色定义、工具清单、输出格式规范、边界约束。工具清单不能只写工具名必须把每个工具的功能、参数名、参数类型、是否必填都列清楚。比如你可以使用以下工具 1. get_weather 功能查询指定城市指定日期的天气 参数 - city (string, 必填)城市名称 - date (string, 必填)日期格式 YYYY-MM-DD 2. search_flight 功能查询航班信息 参数 - from_city (string, 必填)出发城市 - to_city (string, 必填)到达城市 - date (string, 必填)出发日期输出格式规范要给出正例和反例。只给正例模型有时候会自作聪明加解释只给反例模型又不知道正确长什么样。我一般会写“如果需要调用工具只输出一个 JSON 对象不要有任何前后缀文字。正确示例{tool: get_weather, args: {city: 北京, date: 2025-06-01}}。错误示例好的我来帮你查询。{tool: ...}——这是错误的因为包含了多余文字。”注意提示词里一定要强调“只输出 JSON”否则模型大概率会在 JSON 前后加一句“好的我来帮你查一下”。这一句话就能让你的解析器直接崩掉。2.3 解析器的健壮性处理解析器这块新手最容易写得太脆。我见过有人直接json.loads(response)模型输出稍微带点 markdown 代码块标记就报错。正确的做法是分层兜底。第一层先尝试直接解析。第二层如果失败用正则提取第一个{到最后一个}之间的内容再解析。第三层如果还失败尝试去掉 markdown 的json 和标记。第四层如果全部失败就把这次输出当作普通回复返回给用户同时记录一条异常日志。import json import re def parse_tool_call(text): # 第一层直接解析 try: return json.loads(text) except json.JSONDecodeError: pass # 第二层提取花括号内容 match re.search(r\{.*\}, text, re.DOTALL) if match: try: return json.loads(match.group()) except json.JSONDecodeError: pass # 第三层去掉 markdown 标记 cleaned re.sub(r(?:json)?|, , text).strip() try: return json.loads(cleaned) except json.JSONDecodeError: pass # 全部失败返回 None 表示不是工具调用 return None这个解析器我实测下来能覆盖 95% 以上的情况。剩下的 5% 基本是模型彻底跑偏这时候与其硬解析不如把原始输出记下来回头优化提示词。2.4 方案一的局限与优化方向方案一最大的问题是稳定性依赖模型能力。小模型经常不遵守格式或者参数填错。优化方向有两个一是加 few-shot 示例在提示词里塞两三个完整的调用样例能显著提升格式遵守率二是加校验层解析出参数后先做类型和必填校验不合法就重新请求模型修正。另一个坑是多轮工具调用。用户的问题可能需要连续调用多个工具比如先查天气再根据天气推荐穿搭。方案一处理这个需要你在提示词里说明“如果一次调用不够可以连续输出多个 JSON每行一个”然后解析器要支持批量解析。这块复杂度上来了但原理不变。3. 方案二利用模型原生 Function Calling 能力3.1 原生能力的本质与调用链路第二种方案是大多数生产环境实际在用的利用模型服务商提供的原生 Function Calling函数调用接口。你不再需要自己解析文本而是把工具定义成结构化的 schema随请求一起发给模型模型会返回一个结构化的tool_calls字段里面直接包含函数名和参数 JSON。这个方案的本质是模型在训练阶段就被专门微调过学会了识别工具定义并生成符合 schema 的调用请求。所以它的格式遵守率远高于纯提示词方案参数准确率也更高。调用链路是你定义工具 schema → 随 messages 一起发送 → 模型返回 tool_calls → 你执行函数 → 把结果以 tool 角色消息追加 → 再次请求模型生成最终回复。工具 schema 一般遵循类似这样的结构{ type: function, function: { name: get_weather, description: 查询指定城市指定日期的天气, parameters: { type: object, properties: { city: {type: string, description: 城市名称}, date: {type: string, description: 日期格式 YYYY-MM-DD} }, required: [city, date] } } }3.2 工具 Schema 设计的经验法则Schema 设计有几个我踩过坑才明白的点。description 要写“什么时候用”而不只是“是什么”。比如“查询天气”不如“当用户询问某地某天天气、气温、是否下雨时使用”。模型是靠 description 来判断该不该调这个工具的写得太简略会导致该调不调、不该调乱调。参数描述也要具体。date参数如果只写“日期”模型可能传“明天”“下周一”这种自然语言写成“日期格式必须为 YYYY-MM-DD例如 2025-06-01”模型就会规规矩矩转格式。枚举值一定要用 enum 约束比如性别参数写成{type: string, enum: [male, female]}能避免模型自由发挥。还有一个容易忽略的点工具数量不要太多。我做过测试当工具超过 20 个时模型的调用准确率会明显下降因为它要在众多选项里做选择。生产环境里我一般会把工具按场景分组每组不超过 10 个根据对话上下文动态注入相关工具。3.3 多轮调用与并行调用的处理原生方案天然支持并行工具调用。模型可能一次返回多个 tool_calls比如用户问“北京和上海明天天气怎么样”模型会同时返回两个 get_weather 调用。你的代码要能遍历 tool_calls 数组逐个执行然后把所有结果都追加到消息历史里。# 假设 response 是模型返回 tool_calls response.choices[0].message.tool_calls for call in tool_calls: func_name call.function.name args json.loads(call.function.arguments) result execute_tool(func_name, args) messages.append({ role: tool, tool_call_id: call.id, content: json.dumps(result, ensure_asciiFalse) })这里有个细节tool_call_id 必须原样回传模型靠这个 id 来匹配哪个结果对应哪个调用。我见过有人自己生成 id结果模型对不上号回复就乱了。3.4 方案二的成本与依赖问题原生方案不是没有代价。第一它绑定了模型服务商换模型可能 schema 格式就不一样了。第二它增加了 token 消耗工具 schema 每次请求都要带上工具多了 token 成本很可观。第三调试透明度低模型为什么没调某个工具你只能靠猜。我的应对策略是在应用层做一层抽象把工具定义和模型调用解耦。工具用统一的内部格式定义发送请求前再转换成目标模型服务商要求的 schema 格式。这样换模型时只需要改转换层业务代码不动。4. 方案三自建 Agent 调度框架的工程化方案4.1 从“单次调用”到“调度循环”的思维转变第三种方案是前两种的工程化升级自建一个 Agent 调度框架把工具调用纳入一个可循环、可中断、可观测的执行引擎。前两种方案本质上都是“一问一答式”的模型说要调工具你调完再问一次。但真实任务往往需要多步推理、条件分支、甚至人工介入。这个方案的核心是一个while 循环只要模型还在请求工具调用就继续执行、继续喂结果直到模型给出最终回复或者达到最大轮次限制。听起来简单但要做好需要处理状态管理、超时控制、错误重试、权限校验、日志追踪等一堆工程问题。我把它拆成几个核心模块工具注册中心管理所有可用工具及其元信息、调度器决定下一步执行什么、执行器实际调用工具并处理异常、记忆模块维护对话历史和中间状态、观测模块记录每一步的输入输出和耗时。4.2 工具注册中心的设计工具注册中心是整个框架的基础。我一般用一个装饰器来注册工具这样定义和使用都很自然TOOL_REGISTRY {} def register_tool(name, description, parameters): def decorator(func): TOOL_REGISTRY[name] { name: name, description: description, parameters: parameters, func: func } return func return decorator register_tool( nameget_weather, description当用户询问天气时使用, parameters{ city: {type: string, required: True}, date: {type: string, required: True} } ) def get_weather(city, date): # 实际查询逻辑 return {city: city, date: date, temp: 25, condition: 晴}这样做的好处是工具的定义、元信息、实现三合一新增工具只需要写一个函数加装饰器调度器自动就能发现它。参数校验也可以基于parameters字段自动生成不用手写一堆 if-else。4.3 调度循环与状态机调度循环的伪代码大概长这样def run_agent(user_input, max_turns10): messages [{role: user, content: user_input}] for turn in range(max_turns): response call_model(messages, toolsget_tool_schemas()) if response.has_tool_calls: for call in response.tool_calls: # 权限校验 if not check_permission(call.name): result {error: 无权限调用该工具} else: try: result execute_with_timeout(call.name, call.args, timeout10) except Exception as e: result {error: str(e)} messages.append({role: tool, tool_call_id: call.id, content: json.dumps(result)}) else: return response.content return 任务轮次超限请简化你的问题这里有几个关键设计点。max_turns 必须有否则模型可能陷入无限调用循环烧钱又烧时间。超时控制必须有某个工具卡住了不能拖垮整个请求。权限校验必须有尤其是涉及写操作、支付、删除的工具不能让模型随便调。4.4 可观测性与调试支持工程化方案最大的价值之一就是可观测性。我一般会在每一步记录结构化日志第几轮、模型原始输出、解析出的工具调用、执行耗时、返回结果、是否出错。这些日志在排查“模型为什么没调工具”“为什么调错了工具”时是救命稻草。更进一步我会做一个回放功能把一次完整的对话日志存下来可以在本地重新跑一遍逐步查看每一步的状态。这在优化提示词和工具描述时特别有用你能直观看到改动前后的差异。5. 三种方案的横向对比与选型建议5.1 关键维度对比表维度方案一提示词解析方案二原生 Function Calling方案三自建调度框架实现复杂度低中高模型依赖任意文本模型支持 FC 的模型任意可切换格式稳定性依赖提示词质量高取决于底层方案多轮调用支持需自行实现原生支持完整支持可观测性中低高生产可用性小规模可用中大规模可用大规模可用调试难度低中低有日志成本低中schema token中高5.2 不同场景下的选型逻辑快速验证阶段我建议直接用方案二。原生 Function Calling 能让你在半天内跑通一个可用的 demo把精力放在业务逻辑上而不是解析器上。模型受限场景比如你要用本地部署的小模型或者某些不支持 FC 的模型那就只能上方案一。这时候提示词工程和解析器健壮性是重点。生产环境、多工具、多轮任务方案三是必然选择。但注意方案三不是替代方案二而是包裹方案二。底层还是用原生 FC 拿结构化输出上层用调度框架做状态管理和工程保障。面试场景我的建议是三种都要能讲清楚。面试官让你“手写”往往是想看你能不能从方案一讲到方案三展示你对这个问题的完整认知。只讲一种深度不够三种都讲还能对比取舍基本就稳了。5.3 混合使用的实战策略实际项目里我经常混用。比如主流程用方案二的原生 FC但对某些格式要求特别严格的工具比如必须输出特定 SQL 语法的查询工具在工具内部再用方案一的提示词做二次约束。或者在方案三的框架里根据当前模型能力动态选择底层用方案一还是方案二。这种混合策略的关键是抽象层要清晰。工具定义、模型调用、结果解析这三层要解耦任何一层换实现都不影响其他层。我一般会定义一个ToolCallProvider接口方案一和方案二分别是它的两个实现调度框架只依赖接口不依赖具体实现。6. 面试现场的高频追问与应对6.1 “模型不按格式输出怎么办”这是方案一必被问的问题。我的回答框架是分层兜底 主动修正 降级处理。分层兜底就是前面说的多级解析器主动修正是解析失败时把错误信息拼成新提示词让模型重新输出一次降级处理是连续失败后把这次当作普通对话回复不让整个流程崩掉。面试官如果继续追问“修正也不成功呢”你可以说记录失败样本离线分析是提示词问题还是模型能力问题前者优化提示词后者考虑换模型或加 few-shot。这个回答能体现你有闭环思维。6.2 “怎么防止模型调用危险工具”这是安全相关的高频追问。核心答案是权限分层 人工确认 白名单。读操作类工具可以直接放行写操作、删除、支付类工具必须走人工确认流程。具体实现上工具注册时打上风险等级标签调度器根据等级决定是自动执行还是挂起等待确认。另外参数校验也很重要。比如删除文件的工具参数里如果有路径穿越符号../必须拦截。这类校验不能靠模型自觉必须在执行器层面硬编码。6.3 “多轮调用怎么控制成本”成本控制有几个抓手。限制 max_turns一般 5 到 10 轮足够覆盖绝大多数任务。工具结果做摘要如果工具返回一大段文本不要原样塞回消息历史先做摘要或截断。缓存重复调用同样的工具同样的参数短时间内可以直接返回缓存结果。动态工具注入根据当前对话意图只注入相关工具减少 schema token 消耗。6.4 “怎么评估工具调用的准确率”评估这块我一般分两个指标调用决策准确率该调的时候调了没不该调的时候有没有乱调和参数准确率调了的话参数填对没。评估方法上准备一批标注好的测试用例每条包含用户输入、期望调用的工具、期望参数然后跑一遍统计准确率。优化的时候先看决策准确率因为决策错了参数再对也没用。决策准确率低通常是工具 description 写得不好或者工具之间功能重叠导致模型混淆。参数准确率低通常是参数描述不够具体或者缺少格式示例。7. 实操复盘我踩过的那些坑7.1 提示词里的“隐形陷阱”有一次我写提示词工具清单里用了中文顿号分隔参数结果模型有时候把两个参数合并成一个。后来改成每个参数单独一行问题就消失了。还有一次我在提示词里写“尽量使用工具”结果模型连“你好”都要调个工具改成“仅在必要时使用工具”才正常。另一个坑是示例的副作用。我在提示词里给了一个查询天气的示例结果模型遇到任何查询类问题都往天气工具上套。后来我把示例改成多个不同工具的并且明确标注“以上仅为格式示例实际根据用户意图选择工具”才缓解了这个问题。7.2 原生 FC 的“静默失败”用原生 Function Calling 时遇到过一个很隐蔽的问题模型返回了 tool_calls但 arguments 是空字符串。这种情况不会报错但你的json.loads会炸。后来我加了判断arguments 为空时当作参数缺失处理让模型重新生成。还有一个问题是模型偶尔会编造工具名。明明只注册了三个工具它返回一个不存在的工具名。这种情况必须在执行器里做白名单校验不在注册表里的工具直接返回错误不能让它随便执行。7.3 调度框架的“状态污染”自建框架时我一开始把对话历史存在全局变量里结果多个用户并发时状态串了。后来改成每次请求创建独立的上下文对象所有状态都挂在这个对象上请求结束就销毁。这个坑很典型做 Web 服务的同学应该都有体会。另一个坑是工具执行结果太大。有个工具返回了几万字的文本直接塞进消息历史下一轮请求直接超 token 限制。后来我加了一个结果处理器超过一定长度的结果先截断或摘要再放回历史。7.4 常见问题速查表问题现象可能原因排查方向解决方案模型不调工具description 不清检查工具描述是否说明使用场景补充“何时使用”的描述调错工具工具功能重叠对比相似工具的 description明确区分各工具边界参数格式错参数描述不具体检查是否有格式示例补充格式说明和示例解析失败输出含多余文字查看模型原始输出加强提示词约束分层解析无限循环缺少轮次限制检查 max_turns设置合理上限并降级结果超长工具返回未处理检查结果长度截断或摘要后再入历史并发串状态全局变量存状态检查状态存储位置改为请求级上下文对象8. 从面试题到生产系统的延伸思考面试时手写 Tool Use考的是你能不能把黑盒拆开。但真正到了生产环境工具调用只是 Agent 系统的一环还有记忆管理、规划分解、反思修正、多 Agent 协作等一堆问题。我个人的经验是工具调用这块的地基打牢了上层的东西都是水到渠成。比如记忆管理本质上就是对话历史的管理而对话历史在工具调用方案里已经涉及了。规划分解本质上是多轮工具调用的编排方案三的调度循环稍微改改就能支持。反思修正本质上是把执行结果作为反馈重新输入模型也是工具调用链路的自然延伸。所以我的建议是不要把这题当成一道孤立的面试题来准备而是把它当成一个最小可用的 Agent 内核来理解。你把这三种方案都亲手实现一遍再对比一下取舍对 Agent 系统的理解会上一个台阶。面试的时候你讲的不再是“我会调 API”而是“我理解这套机制的设计空间和工程权衡”这两者的差距面试官一听就听得出来。最后分享一个我自己的习惯每次实现完一个方案我都会故意制造一些异常输入去测它——空参数、超长参数、特殊字符、并发请求。这些测试用例后来都成了我代码里的单元测试也成了我面试时能随口说出来的实战细节。手写 Tool Use 这件事写一遍和写三遍理解深度完全不一样。