1. 从一道面试题说起:为什么“手写 Tool Use”成了高频考点
面试官让我手写一个 Tool Use,我用了 3 种方案。这句话听起来像是一道刁钻的面试题,但它背后折射的是当下大模型应用开发中最核心的一环——让模型学会调用外部工具。不管你是做智能助手、自动化工作流,还是搭建一个能查天气、能算数、能操作数据库的对话系统,Tool Use 都是绕不开的基础设施。面试官考这个,不是想看你背 API,而是想看你是否真正理解“模型决策 + 工具执行 + 结果回传”这条链路的每一个环节。
我前后用过三种不同的实现思路,从最原始的字符串解析,到基于结构化输出的函数调用,再到用现成框架做编排。每一种方案都有它的适用场景和坑点。这篇文章就把这三种方案完整拆开,讲清楚它们各自的原理、代码实现、性能差异,以及我在实际项目中踩过的坑。如果你正在准备面试,或者正准备给自己的应用加上工具调用能力,这篇内容应该能帮你省下不少试错时间。
先说结论:三种方案没有绝对的优劣,选哪种取决于你的模型能力、延迟要求、以及你对可控性的需求。但如果你只掌握一种,面试官稍微换个角度追问,你就容易卡壳。所以我的建议是——至少把前两种亲手写一遍,第三种知道什么时候用就够了。
2. 方案一:基于提示词工程的字符串解析方案
2.1 核心思路与适用场景
这是最原始、最不依赖任何框架的方案。核心逻辑很简单:在系统提示词里告诉模型“你有哪几个工具可用,每个工具的参数格式是什么”,然后要求模型在需要调用工具时,输出一段特定格式的文本,比如[TOOL_CALL: get_weather, {"city": "北京"}]。后端拿到模型输出后,用正则或字符串匹配把工具名和参数提取出来,执行对应的函数,再把结果拼回对话历史,发起第二轮请求。
这个方案最大的好处是通用性极强。不管你用的是哪家模型,只要它能生成文本,就能用这套方案。不需要模型支持 function calling,不需要特定的 API 参数,甚至本地跑的小模型也能凑合用。缺点是解析稳定性差,模型可能不按格式输出,可能多输出一个空格,可能把 JSON 写成单引号,这些都需要你在代码里做容错。
我最早做的一个内部工具查询助手就是用这个方案。当时用的模型还不支持结构化输出,只能靠提示词约束。实测下来,在提示词写得足够清晰的情况下,格式遵守率大概能到 90% 左右,剩下的 10% 需要靠重试和兜底逻辑来补。
2.2 提示词设计与格式约定
提示词的设计是这个方案成败的关键。我一般会把工具描述放在系统提示词的最前面,用清晰的分隔符隔开,然后给出 2 到 3 个完整的调用示例。示例非常重要,模型会模仿示例的格式。下面是我常用的一个模板结构:
你是一个可以使用工具的助手。当用户的问题需要调用工具时,请严格按照以下格式输出: [TOOL_CALL: 工具名, {"参数名": "参数值"}] 可用工具列表: 1. get_weather - 查询指定城市的天气 参数:city (字符串,城市名称) 2. calculate - 执行数学计算 参数:expression (字符串,数学表达式) 示例: 用户:北京今天天气怎么样? 助手:[TOOL_CALL: get_weather, {"city": "北京"}] 用户:帮我算一下 23 乘以 47 助手:[TOOL_CALL: calculate, {"expression": "23 * 47"}] 如果不需要调用工具,直接回答用户即可。这里有几个细节值得注意。第一,工具名和参数名尽量用英文,避免模型在中文和英文之间混淆。第二,JSON 部分一定要用双引号,并且在提示词里明确强调。第三,示例要覆盖不同类型的工具,让模型理解“什么时候该调用”和“什么时候不该调用”。
2.3 解析代码实现与容错处理
解析部分我写了一个相对健壮的函数,核心是用正则先匹配[TOOL_CALL: ...]这个外层结构,然后再尝试解析里面的 JSON。如果 JSON 解析失败,会尝试用更宽松的方式提取参数。下面是一个简化版的实现:
import re import json def parse_tool_call(text): pattern = r'\[TOOL_CALL:\s*(\w+),\s*(\{.*?\})\]' match = re.search(pattern, text, re.DOTALL) if not match: return None, None tool_name = match.group(1) args_str = match.group(2) try: args = json.loads(args_str) except json.JSONDecodeError: # 容错:尝试修复常见的 JSON 格式问题 fixed = args_str.replace("'", '"') try: args = json.loads(fixed) except json.JSONDecodeError: return tool_name, None return tool_name, args这个函数在实际使用中还会遇到一些边界情况,比如模型输出了多个工具调用、参数值里包含特殊字符、或者工具名拼写错误。我的做法是:如果解析失败,就把原始输出截断后作为普通回复返回给用户,同时记录日志,方便后续优化提示词。不要试图用越来越复杂的正则去覆盖所有情况,那样维护成本会失控。
2.4 这个方案在面试中的追问点
面试官如果看到你用这个方案,很可能会追问:“如果模型输出格式不对怎么办?”“你怎么保证参数类型正确?”“多轮对话中工具结果怎么拼接?”这些问题其实都是在考察你的工程思维。我的回答思路是:格式不对就重试,重试两次还不行就降级为普通回复;参数类型在工具函数入口做校验,不合法就返回错误信息让模型重新生成;工具结果用固定的模板拼回对话,比如[TOOL_RESULT: get_weather] 北京今天晴,25度,这样模型能清楚知道这是工具返回的内容。
3. 方案二:基于结构化输出的函数调用方案
3.1 从“让模型输出文本”到“让模型输出结构”
方案一的核心问题是把解析压力放在了后端。而方案二的思路是:让模型直接输出结构化的数据,后端不需要做复杂的字符串解析。现在主流的大模型 API 基本都支持 function calling 或 tool use 参数,你只需要在请求里定义好工具的 JSON Schema,模型就会在需要调用时返回一个结构化的对象,包含工具名和参数。
这个方案的优势非常明显:解析零歧义、参数类型有保障、多工具并行调用支持好。缺点是对模型有要求,不是所有模型都支持,而且不同厂商的 API 格式略有差异,切换模型时可能需要改代码。另外,工具的定义需要遵循 JSON Schema 规范,写起来比纯文本提示词要繁琐一些。
我在一个需要高可靠性的客服自动化项目里用了这个方案。当时每天要处理上万次工具调用,方案一的解析失败率虽然只有几个百分点,但乘以基数后每天的异常量还是很可观。换成结构化输出后,解析环节基本没有再出过问题。
3.2 工具定义的 JSON Schema 怎么写
工具定义的核心是parameters字段,它遵循 JSON Schema 规范。下面是一个查询天气工具的定义示例:
{ "name": "get_weather", "description": "查询指定城市的实时天气信息", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,例如:北京、上海" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位,默认摄氏度" } }, "required": ["city"] } }这里有几个经验点。第一,description要写得具体,模型会根据它来判断什么时候该调用这个工具。第二,能用enum就用enum,这样模型不会输出意料之外的值。第三,required字段要准确,不要把可选参数写进去,否则模型可能会强行填一个不存在的值。
3.3 调用流程与多轮工具编排
结构化输出的调用流程和方案一类似,但每一步都更清晰。第一次请求时,你把用户消息和工具定义一起发给模型。模型返回的响应里会有一个tool_calls数组,每个元素包含id、function.name和function.arguments。你根据name找到对应的本地函数,把arguments解析后传进去执行,然后把执行结果以tool角色的消息追加到对话历史里,再发起下一次请求。
这里有一个容易忽略的细节:工具调用的id必须原样回传。模型靠这个id来匹配哪条结果对应哪次调用。如果你在拼接消息时把id弄丢了或者改了,模型可能会混乱。另外,如果模型一次返回了多个tool_calls,你可以并行执行它们,然后把所有结果一起回传,这样能显著降低延迟。
# 伪代码示意 response = model.chat(messages, tools=tool_definitions) if response.tool_calls: for call in response.tool_calls: func = tool_registry[call.function.name] args = json.loads(call.function.arguments) result = func(**args) messages.append({ "role": "tool", "tool_call_id": call.id, "content": str(result) }) response = model.chat(messages, tools=tool_definitions)3.4 实测中的坑:参数幻觉与工具选择错误
即使有了结构化输出,也不代表万事大吉。我遇到最多的问题是参数幻觉:模型会编造一个工具定义里不存在的参数,或者给一个enum之外的值。这种情况在工具描述写得不够清晰时尤其常见。我的应对策略是在工具函数入口做严格的参数校验,不合法就返回一个明确的错误信息,比如"错误:unit 参数只支持 celsius 或 fahrenheit",模型收到这个错误后通常会在下一轮修正。
另一个问题是工具选择错误。当你有多个功能相近的工具时,模型可能会选错。比如同时有search_flights和search_trains,用户说“查一下去上海的票”,模型可能随机选一个。解决办法是在工具描述里写清楚区别,比如“search_flights 仅用于查询航班,search_trains 仅用于查询火车票”,并且在系统提示词里加一句“如果不确定用户意图,先询问澄清”。
4. 方案三:基于编排框架的声明式工具集成
4.1 框架帮你做了什么
第三种方案是用现成的编排框架,比如 LangChain、LlamaIndex 或者一些云厂商提供的 Agent 开发平台。这些框架把工具定义、调用解析、结果回传、多轮循环都封装好了,你只需要用装饰器标注一个函数,框架会自动生成工具描述并处理调用逻辑。
这个方案的最大价值是开发效率。原本需要几百行代码才能跑通的流程,用框架可能几十行就搞定了。而且框架通常会处理很多边界情况,比如超时重试、并行调用、错误传播等。但代价是灵活性下降,你被框架的抽象层挡住了底层细节,遇到奇怪的问题时排查起来更麻烦。另外,框架的版本更新可能带来不兼容的变更,维护成本需要考虑。
我在一个快速原型项目里用了这个方案,从零到能跑通只花了不到一个小时。但后来因为需要自定义工具调用的重试策略和缓存逻辑,又不得不深入框架源码去改,反而花了不少时间。所以我的建议是:原型阶段可以用框架快速验证,生产环境如果对可控性要求高,还是自己实现更稳妥。
4.2 一个最小可用的框架集成示例
以常见的装饰器风格为例,工具定义大概长这样:
from some_framework import tool, Agent @tool def get_weather(city: str, unit: str = "celsius") -> str: """查询指定城市的实时天气信息。 Args: city: 城市名称,例如:北京、上海 unit: 温度单位,支持 celsius 或 fahrenheit """ # 实际查询逻辑 return f"{city}今天晴,25度" agent = Agent(tools=[get_weather]) response = agent.run("北京今天天气怎么样?")框架会自动从函数的类型注解和文档字符串里提取工具描述,生成 JSON Schema,然后处理调用循环。你不需要手写任何解析代码。这种声明式的写法确实很优雅,可读性也好。
4.3 什么时候该用框架,什么时候不该用
我的判断标准是看三个维度:项目复杂度、团队熟悉度、以及长期维护预期。如果只是做一个内部工具或者验证一个想法,框架能帮你省下大量时间,直接用就好。如果是核心业务系统,需要精细控制每一次调用的超时、重试、降级策略,那自己实现更合适。另外,如果团队里没人熟悉某个框架,引入它反而会增加沟通成本,不如用最朴素的方式写。
还有一个容易被忽略的点:框架的抽象层可能会掩盖 token 消耗。自己实现时你能清楚知道每次请求发了多少消息、工具定义占了多少 token,但框架可能会在背后加一些系统提示词或额外的消息,导致成本上升。如果你对成本敏感,这一点要特别留意。
5. 三种方案的横向对比与选型建议
5.1 关键维度对比
| 维度 | 方案一:字符串解析 | 方案二:结构化输出 | 方案三:编排框架 |
|---|---|---|---|
| 模型要求 | 任意文本模型 | 支持 function calling | 取决于框架 |
| 解析稳定性 | 低,需容错 | 高 | 高 |
| 开发速度 | 中 | 中 | 快 |
| 可控性 | 高 | 高 | 低 |
| 多工具并行 | 需自己实现 | 原生支持 | 框架支持 |
| 维护成本 | 中 | 低 | 取决于框架稳定性 |
| 适用场景 | 模型能力受限、快速验证 | 生产环境、高可靠性 | 原型、内部工具 |
5.2 我的选型决策树
如果模型支持结构化输出,优先用方案二。这是目前最平衡的选择,稳定性和可控性都好。如果模型不支持,或者你需要兼容多个不同厂商的模型,那就用方案一,但一定要把容错逻辑写扎实。如果项目周期紧、团队对底层细节不关心,方案三可以快速出活,但要接受抽象层带来的不确定性。
面试的时候,我建议你先说方案二,因为这是最“标准”的答案。然后主动提一下方案一的适用场景,展示你对底层原理的理解。最后提一句方案三,说明你知道有更高效的工程化路径。这样面试官会觉得你既有深度又有广度。
5.3 一个容易被忽略的细节:工具结果的截断
不管你用哪种方案,工具返回的结果都可能很长。比如查询数据库返回了几百行数据,或者调用搜索接口返回了一大段文本。如果直接把整个结果拼回对话历史,会迅速消耗 token,甚至超出上下文限制。我的做法是在工具函数里做截断,只返回最相关的部分,或者返回一个摘要。如果确实需要完整数据,就把它存到外部存储,只把引用 ID 返回给模型。
这个细节在面试中经常被追问,因为很多人只关注“怎么调用”,忽略了“调用之后怎么处理结果”。能主动提到这一点,会是一个加分项。
6. 面试现场怎么写:时间分配与表达策略
面试官让你手写 Tool Use,通常不会要求你写出完整可运行的代码,而是看你的思路是否清晰。我的建议是先用两三句话说明整体流程:定义工具、模型决策、解析调用、执行回传、循环直到不需要工具。然后问面试官:“您希望我重点写哪一部分?是工具定义、解析逻辑,还是调用循环?”这样既能展示你的全局观,又能把时间花在面试官最关心的部分。
如果时间有限,优先写工具定义和解析逻辑,因为这两块最能体现你对细节的把握。调用循环可以用伪代码带过。写的时候边写边解释,比如“这里我用 JSON Schema 来定义参数,因为这样模型能更准确地理解参数类型”。不要闷头写代码不说话,面试是沟通,不是代码比赛。
另外,提前准备一两个踩坑故事。比如“我之前用字符串解析方案时,模型把 JSON 里的双引号写成了单引号,导致解析失败,后来我加了一层容错逻辑”。这种真实经历比背八股文更能打动面试官。
7. 我在实际项目中的几点体会
踩过几次坑之后,我现在的做法是:核心链路自己实现,边缘功能用框架。工具调用的解析和执行循环自己写,保证可控性和可调试性;一些辅助功能比如日志、监控、重试策略,可以用现成的库来减少工作量。这样既不会失去对关键环节的掌控,又能享受工程化带来的效率提升。
还有一个体会是:工具描述的质量比工具本身的数量更重要。我见过很多项目堆了几十个工具,但每个工具的 description 都写得很模糊,导致模型经常选错。与其这样,不如先把最常用的三五个工具打磨好,把描述写清楚,把参数约束写严格。工具不在多,在于模型能不能准确理解和使用。
最后分享一个小技巧:在系统提示词里加一句“如果你不确定是否需要调用工具,先向用户确认”。这句话能显著减少误调用,尤其是在用户意图模糊的时候。实测下来,加上这句话之后,不必要的工具调用减少了大概三成,用户体验也更自然。