1. 面试现场:为什么“手写 Tool Use”成了高频考题
1.1 从一道面试题说起
前阵子有个朋友去面一家做 AI 应用的公司,回来跟我吐槽:面试官没问八股,也没让手撕红黑树,直接甩了一句——“给你半小时,手写一个 Tool Use 的调度逻辑,让模型能调用外部函数,别用现成框架。”
他当时就懵了。平时用 LangChain、LlamaIndex 这些框架,tool一注册、agent一跑就完事了,真让自己从零写,反而不知道从哪下手。后来他硬着头皮写了三种不同思路,面试官看完点了点头,说“你至少知道底层在干嘛”。
这件事让我意识到一个趋势:Tool Use(工具调用)已经从“框架帮你搞定”变成了“你得知道它怎么跑”。尤其是做 AI 应用开发的岗位,面试官越来越看重你能不能脱离框架、理解模型和外部工具之间的那层“胶水”到底是怎么粘的。
这篇文章就把我当时帮他复盘、以及我自己后来动手实现的三种方案完整拆一遍。三种方案从“最裸的手动解析”到“半自动的注册调度”,再到“基于结构化输出的可靠调用”,难度递增,适用场景也不同。不管你是准备面试,还是想在自己的项目里加一个轻量的工具调用能力,都能直接抄作业。
1.2 Tool Use 到底在解决什么问题
先说清楚概念。大语言模型本身是个“文本进、文本出”的东西,它不能真的去查数据库、不能真的发请求、不能真的算一个复杂表达式。所谓 Tool Use,就是让模型输出一段结构化的指令,告诉外部程序“我要调用哪个函数、传什么参数”,然后由外部程序执行,再把结果塞回给模型。
打个比方:模型像一个坐在办公室里的顾问,他知识很渊博,但手脚被绑住了。Tool Use 就是给他配了一个跑腿的——顾问说“帮我查一下今天北京的天气”,跑腿的就去查,查完把结果递回来,顾问再基于结果给你建议。
这个过程中,核心要解决三件事:
- 模型怎么知道有哪些工具可用:你得把工具的名称、功能描述、参数格式告诉模型。
- 模型怎么表达“我要调用”:模型输出的必须是一段机器能解析的结构,不能是“我觉得你可以查一下天气”这种自然语言。
- 外部怎么执行并回传:解析出函数名和参数,执行,把结果按约定格式返回给模型。
面试官让你手写,考的就是你对这三件事的理解深度。用框架的时候这三件事都被封装了,但一旦出问题——比如模型输出的 JSON 解析失败、参数类型不对、工具描述写得不清楚导致模型选错工具——你就得知道去哪一层排查。
1.3 三种方案的选型逻辑
我后来实现的三种方案,分别对应三种不同的工程取舍:
| 方案 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 方案一:手动解析文本 | 让模型按固定格式输出,正则提取 | 零依赖、完全可控 | 脆弱、格式易崩 | 教学演示、极简场景 |
| 方案二:注册表+JSON解析 | 工具注册成字典,模型输出JSON | 结构清晰、易扩展 | 依赖模型输出稳定性 | 中小型项目、面试手写 |
| 方案三:结构化输出+校验 | 用 schema 约束模型输出 | 可靠性高、可校验 | 实现复杂、依赖模型能力 | 生产环境、多工具场景 |
这三种不是互斥的,实际项目里往往是方案二打底、方案三加固。面试的时候如果你能把这三种都讲清楚,并且说明什么场景选哪个,基本就稳了。
接下来我逐个拆解,每种方案都会给出完整的代码实现、关键细节说明,以及我在实操中踩过的坑。
2. 方案一:手动解析文本,最裸但最能看清本质
2.1 核心思路与提示词设计
方案一的核心思想特别朴素:我在系统提示词里告诉模型“你要调用工具的时候,就按[TOOL: 函数名(参数名=值)]这种格式输出”,然后我用正则去匹配这段文本。
为什么先讲这个最笨的方案?因为它把 Tool Use 的本质暴露得最彻底——模型本质上就是在“生成一段符合约定的文本”,所谓工具调用不过是这段文本被外部程序识别并执行了。你理解了这一层,后面用任何框架都不会迷糊。
提示词大概长这样:
你是一个助手,可以使用以下工具: 1. get_weather(city: str) - 查询指定城市的天气 2. calculate(expression: str) - 计算数学表达式 当你需要调用工具时,请严格按以下格式输出: [TOOL: 函数名(参数名=值)] 例如: [TOOL: get_weather(city=北京)] 如果不需要调用工具,直接回答即可。这里有个关键细节:示例一定要给。我试过只写格式说明不给例子,模型输出五花八门,有的用方括号有的用圆括号,有的把参数写成 JSON。给一个具体示例之后,格式遵从率会明显提升。
2.2 正则提取与执行逻辑
外部程序拿到模型输出后,用正则提取:
import re TOOL_PATTERN = re.compile(r"\[TOOL:\s*(\w+)\((.*?)\)\]") def parse_tool_call(text): match = TOOL_PATTERN.search(text) if not match: return None func_name = match.group(1) args_str = match.group(2) # 解析 key=value 形式的参数 args = {} for pair in args_str.split(","): if "=" in pair: k, v = pair.split("=", 1) args[k.strip()] = v.strip() return func_name, args执行部分就是一个简单的分发:
def execute_tool(func_name, args): if func_name == "get_weather": return get_weather(**args) elif func_name == "calculate": return calculate(**args) return f"未知工具: {func_name}"整个流程就是:模型输出 → 正则提取 → 分发执行 → 结果拼回对话 → 再次请求模型。一个最简的 Tool Use 循环就完成了。
2.3 这个方案的致命弱点
方案一能跑通,但我在实际测试里发现几个很要命的问题。
第一,格式脆弱。模型有时候会在工具调用前后加解释性文字,比如“好的,我来帮你查一下 [TOOL: get_weather(city=北京)]”,正则还能匹配到。但如果它写成“我准备调用 get_weather 工具,城市参数是北京”,正则就完全失效了。更麻烦的是参数里带逗号或括号,比如calculate(expression=(1+2)*3),我的简单 split 直接崩掉。
第二,参数类型全丢了。正则提取出来的全是字符串,city=北京是字符串没问题,但count=5提取出来也是字符串"5",传给函数如果期望 int 就会出错。你得在分发层再做一次类型转换,而类型信息从哪来?只能硬编码。
第三,多工具调用难处理。如果模型一次想调两个工具,输出两段[TOOL: ...],我的search只取第一个,后面的就丢了。
注意:方案一适合用来理解原理和做教学演示,真上生产环境会把你坑哭。但面试时如果你能主动说出这些弱点,反而是加分项——说明你不是背答案,是真跑过。
2.4 实操心得:怎么让方案一稍微稳一点
虽然方案一不推荐上生产,但有几个小技巧能让它稳不少,面试时说出来也很加分。
- 用更独特的定界符:别用方括号,用
<<<TOOL>>>...<<<END>>>这种几乎不会在正常文本里出现的标记,减少误匹配。 - 强制模型只输出工具调用:在提示词里加一句“如果需要调用工具,你的回复必须且只能包含工具调用格式,不要有任何其他文字”,能显著降低格式污染。
- 参数用 JSON 而不是 key=value:让模型输出
[TOOL: get_weather({"city": "北京"})],然后用json.loads解析参数部分,比手动 split 靠谱得多。这一步其实已经在向方案二过渡了。
我实测下来,加了这三条之后,方案一的成功率从大概六成提到了八成五左右。但剩下那一成五的失败,基本都是模型“自作主张”改了格式,这就不是提示词能完全解决的了,得靠方案二的结构化约束。
3. 方案二:注册表加 JSON 解析,面试手写的甜点区
3.1 工具注册表的设计
方案二是我认为面试手写的最优解——既不像方案一那么裸,又不像方案三那么重,代码量适中,逻辑清晰,能体现工程思维。
核心设计是一个工具注册表:每个工具用字典描述它的名称、描述、参数 schema 和实际执行函数。
TOOLS = { "get_weather": { "description": "查询指定城市的天气", "parameters": { "city": {"type": "string", "description": "城市名称"} }, "function": get_weather }, "calculate": { "description": "计算数学表达式", "parameters": { "expression": {"type": "string", "description": "数学表达式"} }, "function": calculate } }为什么用这种结构?因为它一举解决了方案一的三个痛点:参数类型有了(type字段),工具描述集中管理了,执行函数和元信息绑定了。而且这个结构可以直接序列化成 JSON 塞进提示词,让模型知道有哪些工具。
3.2 把工具描述注入提示词
有了注册表,生成提示词就是自动化的:
def build_system_prompt(tools): tool_desc = [] for name, info in tools.items(): params = ", ".join( f"{k}({v['type']}): {v['description']}" for k, v in info["parameters"].items() ) tool_desc.append(f"- {name}({params}): {info['description']}") return f"""你可以使用以下工具: {chr(10).join(tool_desc)} 需要调用工具时,输出如下 JSON: {{"tool": "工具名", "args": {{"参数名": "参数值"}}}} 不需要调用工具时,直接回答。"""这里有个我踩过的坑:JSON 示例里的花括号在 f-string 里要转义。我第一次写的时候忘了,Python 直接把{"tool": ...}当成变量去解析,报了一堆 KeyError。正确做法是用双花括号{{}},或者干脆用字符串拼接别用 f-string。
3.3 JSON 解析与容错处理
模型输出 JSON 之后,解析这一步是方案二的关键。理想情况下模型输出干净的 JSON,但现实是它经常给你包一层 markdown 代码块,或者在前面加一句“好的,这是调用”:
import json def extract_json(text): # 先尝试直接解析 try: return json.loads(text) except json.JSONDecodeError: pass # 尝试提取代码块里的 JSON match = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL) if match: try: return json.loads(match.group(1)) except json.JSONDecodeError: pass # 尝试提取第一个花括号对 match = re.search(r"\{.*\}", text, re.DOTALL) if match: try: return json.loads(match.group(0)) except json.JSONDecodeError: pass return None这个三层兜底是我实测下来最实用的:先直接解析,失败就找代码块,再失败就找花括号。三层下来,基本能覆盖九成以上的模型输出格式。
解析出 JSON 后,校验工具名是否存在、参数是否齐全,然后执行:
def run_tool_call(data): tool_name = data.get("tool") args = data.get("args", {}) if tool_name not in TOOLS: return f"错误:未知工具 {tool_name}" tool = TOOLS[tool_name] # 参数校验 for param in tool["parameters"]: if param not in args: return f"错误:缺少参数 {param}" return tool["function"](**args)3.4 完整对话循环的搭建
单次调用只是开始,真正的 Tool Use 是一个循环:模型输出工具调用 → 执行 → 结果回传 → 模型基于结果继续回答。
def chat_with_tools(user_input, max_turns=5): messages = [ {"role": "system", "content": build_system_prompt(TOOLS)}, {"role": "user", "content": user_input} ] for _ in range(max_turns): response = call_llm(messages) messages.append({"role": "assistant", "content": response}) data = extract_json(response) if not data or "tool" not in data: # 没有工具调用,直接返回 return response result = run_tool_call(data) messages.append({ "role": "user", "content": f"工具执行结果:{result}" }) return "达到最大轮次限制"这个循环里有个细节值得说:工具结果回传时,我用的是role: user而不是role: tool。因为不是所有模型接口都支持tool这个角色,用user兼容性最好。当然如果你用的是支持标准 function calling 的接口,用tool角色更规范。
max_turns这个限制也很重要。我见过模型陷入死循环的——工具返回结果它不满意,又调一次,又返回,又调。加个轮次上限,防止无限循环烧 token。
3.5 面试时怎么讲这个方案
如果你在面试现场写方案二,我建议按这个顺序讲:
- 先说设计:我用注册表管理工具,每个工具有描述、参数 schema 和执行函数,这样新增工具只需要加一个字典项。
- 再说提示词生成:工具描述从注册表自动生成,避免手写提示词和代码不同步。
- 然后说解析容错:模型输出 JSON 可能不干净,我做了三层兜底解析。
- 最后说循环控制:加了最大轮次限制,防止死循环。
这套讲下来,面试官能看出你不仅会写,还考虑到了工程上的健壮性。这比闷头写代码强多了。
4. 方案三:结构化输出加校验,生产环境的可靠选择
4.1 为什么还需要方案三
方案二已经能用了,但我在实际项目里发现它还有个隐患:模型输出的 JSON 结构本身可能不对。比如它可能输出{"tool": "get_weather", "arguments": {...}},把args写成了arguments;或者参数值类型不对,city传了个数字;或者干脆输出了一个嵌套结构。
方案二的校验是“事后校验”——解析完再检查。方案三的思路是“事前约束”——用 schema 强制模型只能输出符合结构的内容。这就是结构化输出(Structured Output)的核心价值。
现在很多模型接口都支持传入一个 JSON Schema,模型会保证输出符合这个 schema。即使接口不支持,你也可以用更严格的提示词加校验来逼近这个效果。
4.2 用 Schema 定义工具调用格式
先定义工具调用的 schema:
TOOL_CALL_SCHEMA = { "type": "object", "properties": { "tool": { "type": "string", "enum": ["get_weather", "calculate"] }, "args": { "type": "object", "additionalProperties": {"type": "string"} } }, "required": ["tool", "args"] }注意enum这个字段——它把工具名限定在注册表里已有的工具,模型不可能输出一个不存在的工具名。这一条就消灭了方案二里“未知工具”那类错误。
如果模型接口支持结构化输出,直接把 schema 传进去,模型输出必然符合。如果不支持,就把 schema 转成文字描述塞进提示词,然后自己用jsonschema库校验:
from jsonschema import validate, ValidationError def validate_tool_call(data): try: validate(instance=data, schema=TOOL_CALL_SCHEMA) return True, None except ValidationError as e: return False, str(e)4.3 参数级别的类型校验
工具调用格式对了,参数类型还得再校验一层。因为 schema 里args的值我统一写成了 string,但实际函数可能期望 int 或 float。所以执行前要做类型转换和校验:
def coerce_args(tool_name, args): tool = TOOLS[tool_name] coerced = {} for param, spec in tool["parameters"].items(): if param not in args: raise ValueError(f"缺少参数 {param}") value = args[param] expected = spec["type"] if expected == "integer": coerced[param] = int(value) elif expected == "number": coerced[param] = float(value) elif expected == "boolean": coerced[param] = value.lower() in ("true", "1", "yes") else: coerced[param] = str(value) return coerced这段代码看着简单,但它是方案三可靠性的关键。模型输出的参数值永远是字符串(因为 JSON 里我约束成了 string),到了执行层再按工具定义转成正确的类型。这样既保证了模型输出的稳定性,又保证了函数调用的正确性。
4.4 失败重试与降级策略
生产环境里,再好的约束也可能遇到模型抽风。所以方案三必须配一套失败处理机制。
我的做法是:校验失败时,把错误信息回传给模型,让它重新输出。
def call_with_retry(messages, max_retries=2): for attempt in range(max_retries + 1): response = call_llm(messages) data = extract_json(response) if data is None: messages.append({"role": "assistant", "content": response}) messages.append({ "role": "user", "content": "你的输出不是合法 JSON,请重新输出。" }) continue valid, error = validate_tool_call(data) if not valid: messages.append({"role": "assistant", "content": response}) messages.append({ "role": "user", "content": f"输出不符合格式要求:{error},请重新输出。" }) continue return data # 重试都失败,降级为直接回答 return None这个重试机制我实测下来,能把格式错误导致的失败率从百分之十几降到百分之一以下。因为模型看到具体的错误信息后,基本都能自我纠正。
提示:重试次数别设太多,两次足够。设多了不仅浪费 token,还可能让模型在错误的方向上越走越远。两次还不行,说明这个请求本身有问题,降级处理更明智。
4.5 三种方案的横向对比与选型建议
到这里三种方案都拆完了,我把它们的核心指标拉个表对比一下:
| 维度 | 方案一 | 方案二 | 方案三 |
|---|---|---|---|
| 实现复杂度 | 低 | 中 | 高 |
| 代码量 | 约50行 | 约150行 | 约300行 |
| 格式稳定性 | 差 | 中 | 好 |
| 参数类型安全 | 无 | 弱 | 强 |
| 多工具支持 | 难 | 易 | 易 |
| 失败恢复能力 | 无 | 弱 | 强 |
| 依赖外部库 | 无 | 无 | jsonschema |
| 面试推荐度 | 讲原理 | 手写首选 | 加分项 |
选型建议很直接:面试手写选方案二,讲清楚方案一和方案三作为对比;实际项目如果模型接口支持结构化输出,直接上方案三;如果只是做个 demo 或者教学,方案一足够。
我自己的项目里,通常是方案二打底,然后在关键的工具调用路径上加方案三的校验和重试。这样既有方案二的简洁,又有方案三的可靠。
5. 实操中踩过的坑与排查技巧
5.1 模型不调用工具怎么办
这是最常见的问题。你明明给了工具,模型却选择直接回答,或者用自然语言说“我建议你查一下天气”而不是真的调用。
排查思路分三层:
第一层,检查工具描述。工具描述写得太模糊,模型不知道什么时候该用。比如get_weather的描述写成“获取信息”,模型根本不知道这是查天气的。描述要具体:“查询指定城市的实时天气情况,包括温度和天气状况”。
第二层,检查提示词里的调用条件。要明确告诉模型“当用户询问需要实时数据或需要计算的问题时,必须调用工具”。我试过不加这句,模型倾向于用自己的知识回答,哪怕知识是过时的。
第三层,检查示例。在提示词里给一两个“用户问 X,你应该调用 Y”的示例,能大幅提升调用率。模型很擅长模仿示例。
5.2 JSON 解析失败的常见原因
即使做了三层兜底,偶尔还是会解析失败。我整理了几个高频原因:
| 现象 | 原因 | 解决 |
|---|---|---|
| 输出带 markdown 代码块 | 模型习惯性包裹 | 兜底解析已覆盖 |
| 花括号不匹配 | 模型漏写或多写 | 提示词强调格式 |
| 中文引号 | 模型用了全角引号 | 解析前替换全角为半角 |
| 参数值含特殊字符 | 未转义 | 提示词要求转义 |
| 输出被截断 | token 超限 | 提高 max_tokens |
其中中文引号这个坑我踩得最深。模型输出{"tool": "get_weather", "args": {"city": "北京"}}看着没问题,但有时候引号是全角的",json.loads直接报错。解决办法是在解析前做一次替换:
text = text.replace(""", '"').replace(""", '"')5.3 工具执行超时与异常处理
工具执行本身也可能出问题——网络请求超时、数据库连接失败、参数导致除零错误。这些异常如果不捕获,整个对话循环就崩了。
我的做法是给每个工具的执行包一层 try-except:
def safe_execute(tool_name, args): try: result = TOOLS[tool_name]["function"](**args) return {"success": True, "result": result} except Exception as e: return {"success": False, "error": str(e)}然后把结果(无论成功失败)都回传给模型。模型看到错误信息后,往往能给出合理的解释或者换个方式重试。这比直接抛异常给用户友好得多。
5.4 多工具场景下的选择困难
当工具有五六个以上时,模型选错工具的概率会上升。我遇到过用户问“帮我算一下 3 加 5”,模型却调了get_weather的情况。
解决办法有两个:一是工具描述要差异化,别让两个工具的描述有重叠词汇;二是给工具分组,如果工具特别多,先让模型选类别,再在类别内选具体工具。这个思路叫“层级路由”,在工具数量超过十个时特别有用。
还有个技巧是在提示词里加一句“仔细阅读每个工具的描述,选择最匹配用户需求的那个”。别小看这句话,实测能降低不少误选。
6. 从面试题到生产:Tool Use 的扩展方向
6.1 并行工具调用
方案二和方案三里,我都是串行处理工具调用的——调一个,等结果,再调下一个。但有些场景下,多个工具调用之间没有依赖关系,可以并行。
比如用户问“北京和上海今天天气怎么样”,模型可以一次性输出两个工具调用,外部并行执行,再一起回传。这能把响应时间从两次串行请求降到一次并行。
实现上,模型输出改成数组[{"tool": ...}, {"tool": ...}],外部用线程池或异步并发执行。不过要注意,并行调用对模型的输出能力要求更高,不是所有模型都能稳定输出多个调用。
6.2 工具结果的压缩与摘要
工具返回的结果可能很长,比如一个数据库查询返回几百行。全塞回给模型会占用大量 token,还可能超出上下文限制。
我的做法是在回传前做一次压缩:如果是列表,只取前几条加总数;如果是长文本,做一次摘要。这个压缩逻辑可以放在工具函数里,也可以单独做一个后处理层。
6.3 工具调用的可观测性
生产环境里,你需要知道每次工具调用的成功率、耗时、失败原因。我在项目里加了一个简单的日志层,记录每次调用的工具名、参数、结果状态和耗时。这些数据积累下来,能帮你发现哪些工具描述写得不好、哪些参数容易出错。
这个日志不用多复杂,一个 JSON 行日志就够了。关键是养成记录的习惯,出问题的时候有据可查。
6.4 安全边界:工具调用的权限控制
最后说一个容易被忽视的点:不是所有工具都该对所有用户开放。比如一个删除数据的工具,如果模型被诱导调用了,后果很严重。
我的做法是在工具注册表里加一个permission字段,执行前检查当前用户是否有权限。没有权限就返回“无权限执行此操作”,模型会据此调整回答。这层控制必须在外部程序里做,不能指望模型自己判断——模型是可能被绕过的。
面试的时候如果你能主动提到权限控制,面试官会眼前一亮。因为这说明你不只是会写功能,还考虑了安全。
7. 我个人的一些实操体会
三种方案写下来,我最深的体会是:Tool Use 的难点从来不在“调用”本身,而在“可靠地调用”。让模型输出一段文本去触发函数,十行代码就够了;但要让它稳定、安全、可恢复地触发,需要的是工程思维。
面试官让你手写,考的不是你能不能背出某个框架的 API,而是你能不能从零构建一个可控的调用链路,并且知道每一层的薄弱点在哪、怎么加固。方案一是理解本质,方案二是工程落地,方案三是生产可靠——这三层认知,比会用一个框架值钱得多。
如果你正在准备这类面试,我的建议是:别只背方案,动手把三种都写一遍,跑几个真实例子,故意制造一些格式错误看看你的兜底逻辑能不能扛住。跑过一遍之后,面试时你讲出来的东西是有细节的,面试官能听出来。
最后分享一个小技巧:面试现场如果时间紧,先写方案二的核心骨架,把注册表、提示词生成、JSON 解析、执行分发这四块写出来,基本就能跑。如果面试官追问可靠性,再补方案三的校验和重试。这样既保证了完成度,又留了深入的空间。