1. 从面试题到工程实践:一次手写 Tool Use 的完整还原
前阵子去某家公司面试 AI 应用开发岗,聊到一半,面试官突然把屏幕转过来,说:“别讲概念了,现场给我写一个 Tool Use 的调用框架,要求能注册工具、能解析用户意图、能执行并返回结果。”
说实话,AI Agent 的概念大家都会背,但让我在半小时内手写一个能跑、能演示、还能讲清楚设计取舍的 Tool Use 实现,确实把我逼出了一身汗。好在平时不少场景里真刀真枪调过这类逻辑,我现场写了三个版本:一个基于函数注册表的朴素实现,一个基于 Pydantic 的 Schema 描述实现,一个带对话记忆和动态工具选择的进阶版本。最终全部跑通,也顺利拿到了下一轮。
这篇文章我就把当时手写的三个方案完整还原出来,从设计思路、核心代码、调用流程到面试官追问的“为什么这么设计”,全部摊开来讲。无论你是准备 AI 应用开发岗的面试,还是实际要在项目里接一个 Tool Use 能力,这 3 套方案都值得你亲手敲一遍。
1.1 Tool Use 到底是什么
在展开代码之前,先对齐一下概念。大语言模型本身只能输出文本,它没有办法真正替你查数据库、发请求、读写文件。Tool Use 的核心,就是让模型在推理过程中“声明”自己需要调用哪个工具、传入什么参数,然后由我们自己的代码去执行这个工具,再把执行结果回传给模型,让它基于真实结果继续生成回答。
整个链路看起来很简单,但工程上要处理好几个关键点:工具的注册与发现、参数的格式化与校验、执行结果的回传、以及对话上下文中多轮调用之间的状态管理。这三套方案恰恰是在这几个维度上层层递进。
1.2 从面试场景到一个能落地的调用框架
面试官明确要求“手写”,所以第一版他并没有让我引入任何重的框架依赖,而是直接用基础语法实现一个可运行的调用闭环。后来在追问环节,我们聊到了生产环境中的结构化、并发、鉴权和可观测性,才逐步演进出后两个版本。
在写代码之前,先梳理清楚一个基本问题:这套框架的核心流程是什么?无论复杂还是简单,Tool Use 的调用闭环一定包含四个步骤:
- 模型根据用户输入,决定是否调用工具以及调用哪个工具
- 我们的程序解析模型输出的结构化指令
- 执行对应的工具函数并拿到结果
- 将结果回传给模型,模型生成最终回复
后面写代码时我会反复回到这个流程上。你把主流程想清楚了,不管上层怎么包装,内部逻辑都不会乱。
2. 方案一:基于函数注册表的朴素实现
2.1 为什么从函数注册表开始
第一版写的这个方案,核心思路其实特别朴素:用一个字典把工具名字和函数对象直接映射起来,模型只要输出一个固定的函数名和参数字典,程序就从字典里把这个函数取出来执行。
这是所有 Tool Use 框架里最轻量的一种实现方式。货真价实的裸写法,没有任何魔法,逻辑全部展开在眼前。面试官第一版要求的就是这个,因为他要确认你是真的理解整个调用链路的闭环,而不是背熟了某个框架的 API。
2.2 核心代码实现
直接用 Python 写,核心代码只有几十行。
import json from typing import Any, Callable, Dict class NaiveToolUse: """方案一:基于函数注册表的朴素 Tool Use 实现""" def __init__(self): self.tools: Dict[str, Callable] = {} def register(self, name: str, func: Callable): """注册工具函数到内部字典"""band self.tools[name] = func def call(self, name: str, arguments: Dict[str, Any]): """从注册表取出函数并执行,返回结构化结果""" if name not in self.tools: raise ValueError(f"Tool '{name}' 未注册") func = self.tools[name] try: result = func(**arguments) return { "success": True, "result": result, } except Exception as exc: return { "success": False, "error": str(exc), } # 示例工具函数 def get_weather(city: str) -> str: """模拟天气查询,实际场景中可替换为 API 调用""" data = { "北京": "晴,22°C", "上海": "多云,25°C", "广州": "雷阵雨,28°C", } return data.get(city, "暂无该城市天气数据") def calculate(expression: str) -> float: """仅用于 Demo 的简单计算器,生产中务必替换为安全解析方案""" return eval(expression) # 注意:仅限 Demo,不可用于生产 tool_use = NaiveToolUse() tool_use.register("get_weather", get_weather) tool_use.register("calculate", calculate) # 模拟模型输出的结构化调用指令 model_output = { "name": "get_weather", "arguments": {"city": "上海"}, } res = tool_use.call(model_output["name"], model_output["arguments"]) print(json.dumps(res, ensure_ascii=False))这段代码的逻辑非常清楚:先注册,再调用。你甚至不需要引入任何第三方库,一个字典加一个函数就能跑通。
2.3 执行流程拆解与控制逻辑
为了后面两个版本做铺垫,我画了一张逻辑图在自己脑子里:用户说“上海今天天气怎么样”,模型理解语义后输出一个 JSON,程序拿着 JSON 到注册表里找到天气函数,执行后把结果拼进回复模板,再交给模型组织最终答案。
整个流程的控制关键在两点:
第一点是参数的命名必须和函数定义的形参完全一致。模型输出的arguments字典里的键名,必须能直接展开成get_weather(city="上海")这行调用代码。如果不一致,就必须在注册阶段做参数名映射,这一步我们在第三个方案里会用 Schema 一次性解决。
第二点是异常捕获务必在工具的边界上完成。工具里面的业务代码可能抛出各种异常,如果让异常直接冒泡到上层,整个对话流程就断了。把异常吞掉并转成结构化错误信息返回给模型,让模型自己决定怎么处理错误,这才是 Tool Use 该有的容错方式。
2.4 这一版的坑点与适用边界
这里有一个非常关键的坑,不吐不快。我在计算器工具里用了eval,这只是为了展示一个可以接收任意参数的函数,面试时面试官也立刻追问了安全问题。这里必须郑重说明:生产环境绝对不能用eval执行模型输出的表达式,你应该用ast.literal_eval、正则表达式解析或者调用专业计算库。
方案一最大的局限在于:模型怎么知道有哪些工具可选?参数格式是怎样的?这些都完全依赖开发者在提示词里写清楚。一旦工具数量变多,提示词会变得又臭又长,模型也容易把工具名记混。面试官接下来的第二个问题,正是“如果我有 20 个工具,你打算怎么描述给模型听”。
3. 方案二:基于 Schema 描述的结构化注册
3.1 面向模型的设计转变
到了第二版,思路要做一个重要转变:不再把工具调用看作一个简单的 Python 函数字典,而是看作一份“给模型看的工具说明书”。
这时候我引入了 Pydantic,用它的Field描述参数约束,再通过model_json_schema()自动生成 OpenAI 风格的 JSON Schema。这个设计几乎成了后续所有 AI 工具框架的标准做法,因为模型的指令遵循能力依赖的就是清晰的 JSON 结构。
3.2 参数校验与自动转换
Pydantic 带来的最直接收益是参数校验。模型偶尔会输出缺参数、类型错误甚至多参数的调用指令,如果没有校验层,这些脏数据会让程序一脸懵。加了 Pydantic 之后,合法数据直接通过,非法数据会得到明确的错误原因,你可以把这个错误信息回传给模型,让它自己修正。
我随手写了一个带参数校验的工具定义。这里使用了 Pydantic 的TypeAdapter来对参数做精细校验,并演示了如何把校验结果统一成模型友好的消息格式:
from typing import Any, Callable, Dict, List from pydantic import BaseModel, Field, TypeAdapter, ValidationError class WeatherArgs(BaseModel): city: str = Field(description="城市名称", examples=["北京"]) days: int = Field(1, ge=1, le=7, description="查询天数") def get_weather(city: str, days: int = 1) -> str: """模拟多天天气查询,生产环境可替换为真实 API""" return f"{city}未来{days}天天气:晴转多云" class SchemaToolUse: """方案二:基于 JSON Schema 描述与 Pydantic 校验的实现""" def __init__(self): self.tools: Dict[str, dict] = {} def register(self, name: str, args_model: BaseModel, func: Callable): schema = args_model.model_json_schema() self.tools[name] = { "schema": schema, "function": func, "args_model": args_model, } def list_tools_schema(self) -> List[dict]: """生成可供模型读取的 tools 描述列表(OpenAI 风格)""" list_schema = [] for name, info in self.tools.items(): list_schema.append( { "type": "function", "function": { "name": name, "parameters": info["schema"], }, } ) return list_schema def execute(self, name: str, raw_args: Dict[str, Any]): """校验并执行工具,若校验失败则返回结构化错误""" if name not in self.tools: return {"success": False, "error": f"Unknown tool: {name}"} try: validated_args = TypeAdapter(self.tools[name]["args_model"]).validate_python(raw_args) except ValidationError as exc: return { "success": False, "error": f"参数校验失败: {exc.errors()}", "error_type": "validation_error", } try: result = self.tools[name]["function"](**validated_args) return {"success": True, "result": result} except Exception as exc: return {"success": False, "error": str(exc)} tool_use = SchemaToolUse() tool_use.register("get_weather", WeatherArgs, get_weather) # 模型输出的原始参数(假设缺少必填或类型错误) raw_args = {"city": "上海", "days": "3"} # 注意 days 是字符串,需要校验转换 res = tool_use.execute("get_weather", raw_args) print(res)这段代码里隐藏了一个很实用的细节:WeatherArgs在定义days字段时加了ge=1和le=7的约束,这样模型即使生成了一个很大的数字也会被拦截下来,避免向真实天气服务发起毫无意义的请求。
3.3 与 OpenAI 风格 Schema 的对接
我在list_tools_schema方法里,把工具的 Schema 转成了 OpenAI Function Calling 风格的列表。这个设计在真实项目中基本是通用的:无论你接的是 OpenAI、Anthropic 还是本地部署的模型,只要它支持 Function Calling,最终都是把这些 JSON Schema 塞进请求参数里。
实际请求伪代码大致如下:
functions_schema = tool_use.list_tools_schema() response = llm.chat( messages=[ {"role": "user", "content": "上海未来三天天气怎么样?"}, ], functions=functions_schema, # 不同厂商 API 的参数名不同 function_call="auto", ) # 模型响应中可能包含 function_call 字段 function_call_msg = response["choices"][0]["message"]["function_call"] tool_name = function_call_msg["name"] raw_args = json.loads(function_call_msg["arguments"]) exec_result = tool_use.execute(tool_name, raw_args) print(exec_result)这个版本比方案一明显靠谱得多,因为模型终于能“看见”工具长什么样了,参数的名称、类型、约束、描述全都写在 JSON Schema 里。模型可以根据这个 Schema 精准生成调用指令,命中率和稳定性都会大幅提升。
3.4 方案二的实际收益
有了 Schema 之后,我在项目中明显感受到三个变化:
第一,提示词瘦身。不需要在系统提示词里详细描述每个工具了,Schema 已经承担了这部分职责。
第二,歧义减少。Pydantic 的Field描述能让模型准确区分功能相近的工具。比如一个“获取用户订单”和一个“获取用户退款单”,名字相近但参数完全不同,Schema 描述越清楚,模型越不容易用错。
第三,错误可控。参数非法时能拿到具体的校验错误(哪个字段缺失、哪个类型不对),既可以直接转给用户看,也可以把日志丢给模型让它自己修正。
这套方案的短板在于它仍然没有“对话记忆”:每轮调用都是独立的,模型不知道自己上一轮调过什么工具,如果用户连续追问“那广州呢?”,框架就不知道这个“那”指的是什么。这为第三个方案留下了真正的用武之地。
4. 方案三:带对话记忆与动态工具选择的进阶版
4.1 真正逼近生产环境的调用闭环
方案二虽然解决了“模型能否看懂工具”的问题,但距离真正生产可用还差一个重要能力:多轮对话中的状态管理。
你想一下真实场景:用户先问“北京天气怎么样”,模型调了天气工具拿到结果,用户紧接着说“那上海呢”,模型应该能推断出也要查天气,只是城市变了。这要求框架在每一轮调用之后,把工具调用的过程、结果以及用户的追问都保存到对话上下文里,下一轮才能接得住。
所以我加了两个东西:一是动态追加的历史消息列表,二是“前一轮系统说需要调用工具,这一轮接着调用”的延展逻辑。
4.2 带记忆的调用框架实现
我先设计一个带消息历史的 ToolUse 类,核心数据结构就是self.messages。
import json from typing import Any, Callable, Dict, List, Optional from pydantic import BaseModel, Field, TypeAdapter class WeatherArgs(BaseModel): city: str = Field(description="城市名称") days: int = Field(1, ge=1, le=7) class MemoryToolUse: """方案三:带对话上下文记忆的进阶 Tool Use 实现""" def __init__(self): self.tools: Dict[str, dict] = {} self.messages: List[dict] = [] def register(self, name: str, args_model: BaseModel, func: Callable): self.tools[name] = { "schema": args_model.model_json_schema(), "function": func, } def add_message(self, role: str, content: str): self.messages.append({"role": role, "content": content}) def call_tool(self, name: str, raw_args: Optional[Dict[str, Any]] = None): if name not in self.tools: return {"success": False, "error": f"未知工具: {name}"} raw_args = raw_args or {} try: validated = TypeAdapter(self.tools[name]["args_model"]).validate_python(raw_args) except Exception as exc: return {"success": False, "error": f"参数错误: {exc}"} result = self.tools[name]["function"](**validated) return {"success": True, "result": result} def get_openai_messages(self) -> List[dict]: """返回带有完整调用历史的消息列表,供模型推理下一轮使用""" return self.messages tool_use = MemoryToolUse() tool_use.register("get_weather", WeatherArgs, get_weather)这个版本的messages是整个框架的命脉。它不只是一个数组,而是一份完整的“程序运行日志”,每一轮用户的提问、模型的回答、调用的工具、拿到的结果,全部按顺序追加进去。
4.3 带上下文的调用主流程
假设用户连续问两句话,主流程可以这么写:
def run_multi_turn(user_input: str): # 1. 把用户本轮输入追加进上下文 tool_use.add_message("user", user_input) # 2. 实际项目中这里会调用模型,结合当前 messages 决定是否调用工具 # 为了演示,我们模拟模型第一轮选择了工具,第二轮基于历史继续选择 if "天气" in user_input: predicted_call = { "name": "get_weather", "arguments": {"city": "上海", "days": 1}, } tool_use.add_message("assistant", None, function_call=predicted_call) exec_result = tool_use.call_tool(predicted_call["name"], predicted_call["arguments"]) tool_use.add_message("function", json.dumps(exec_result, ensure_ascii=False)) # 3. 返回最新上下文给模型生成最终回答 return tool_use.get_openai_messages() # 模拟第 1 轮 msg1 = run_multi_turn("上海天气怎么样?") print(msg1) # 模拟第 2 轮:用户追问“那北京呢” msg2 = run_multi_turn("北京呢?") print(msg2)注意,在add_message里我用了一个function_call参数模拟 OpenAI 的消息结构。真实接入模型时,function_call消息和function消息的格式略有差异,但核心思路一致:已经执行过的工具调用必须作为上下文回传给模型,否则模型会“失忆”。
4.4 动态工具选择与流程控制
第三个方案里我加入了两个新方法,用来模拟“动态选择工具”的能力:
def decide_tool(self, user_input: str) -> Optional[str]: """根据用户输入和记忆,决定调用哪个工具,实际中由模型完成""" if "天气" in user_input or "气温" in user_input: return "get_weather" if "计算" in user_input or "等于" in user_input: return "calculate" return None def full_turn(self, user_input: str): self.add_message("user", user_input) tool_name = self.decide_tool(user_input) if tool_name: # 实际工程中,参数需要从模型响应里解析 # 这里为了演示,使用关键词简单抽取 args = self.extract_simple_args(user_input, tool_name) result = self.call_tool(tool_name, args) self.add_message("function", json.dumps(result, ensure_ascii=False)) return self.get_openai_messages()decide_tool在真实工程里不可能是我们自己写的规则,而是模型基于历史消息和用户新输入做出的推理决策。我写成规则只是为了在没有模型环境的情况下,也能把整个“多轮 + 动态选择”的流程演示完整。
这套方案实际上就是一个小型的 Agent Runtime:有记忆、有工具注册中心、有执行器、有上下文管理器。你再往上面增加规划、反思、重试,就是一个完整的 Agent 框架了。
4.5 进阶版的实际收益与额外亮点
引入对话记忆后,框架解决了三个让我在项目中真正头痛的问题。
第一个是上下文依赖。没有记忆时,模型面对“那北边呢”这种省略主语的话,很容易一脸茫然,或者答非所问。有了历史消息和工具执行结果,模型知道上一轮查过天气、知道“北边”很可能就是“北京”,推理准确率大幅提升。
第二个是成本控制。与其把用户和模型的全部对话原封不动塞进提示词,我可以在get_openai_messages里做裁剪,只保留最近 N 轮和工具结果摘要。这样既能保留关键上下文,又能控制 token 消耗,对大模型的调用成本非常友好。
第三个是审计可追溯性。整个调用链条留在messages里,用户问过什么、模型做过哪些决策、哪个工具被调用了、返回了什么结果,一目了然。对于需要合规审计或者调试故障的线上场景,这份日志比什么都管用。
5. 三套方案的对比选型与演进路径
5.1 横向对比表
我把三套方案放在一起做了个对比,面试时我直接在白板上画了这么一张表,面试官看完频频点头。
| 维度 | 方案一:函数注册表 | 方案二:Schema 描述 | 方案三:记忆 + 动态选择 |
|---|---|---|---|
| 依赖外界能力 | 极低,纯 Python | 需要 JSON Schema 理解能力 | 需要完整对话级模型推理 |
| 是否需要模型参与参数生成 | 不需要 | 需要 | 需要 |
| 参数校验与转换 | 无 | 强校验 + 自动类型转换 | 强校验 + 类型转换 |
| 多轮上下文支持 | 无 | 无 | 完整 |
| 工具规模扩展性 | 差,靠人肉维护提示词 | 好,Schema 自动描述 | 好,可动态扩充 |
| 适合场景 | 教学 Demo、规则脚本 | 已有 Function Calling 能力的项目 | 需要连续对话的 Agent 应用 |
| 生产友好度 | 低 | 高 | 高 |
这张表本质上呈现的是一条演进路径:从“工具是代码”进化到“工具是描述”,最后再进化到“工具是对话中的一环”。
5.2 实际项目里怎么选型
我不建议任何人直接抄第三个方案进生产项目,因为真实的 Agent 框架需要考虑的远不止这三层。但你可以根据项目阶段做选择:
如果只是验证概念、做技术调研,方案一完全够用,它可以帮你把整个链路理解透彻。
如果已经确定要接大模型的 Function Calling,且你的业务场景是一问一答式,不需要记得前一轮说了什么,方案二性价比最高。
如果要做多轮对话的智能助手,并且希望模型能在多步操作中自行决定工具的调用顺序,方案三就是基础骨架,你需要在此基础上叠加模型调度。
5.3 生产环境还需要补什么
面试官最后问到我心坎里的问题:“你这个方案三离生产还差多远?”
我当时的回答是四个方向:鉴权与权限管控(每个工具要有独立的权限校验)、并发与隔离(多用户会话之间上下文绝对不能串)、可观测性(每次调用的耗时、token 数、工具成功率都要能跟踪)、以及重试降级机制(工具失败时选择备选方案或直接告知用户)。
这四个方向几乎每个都能单独展开成一篇文章,但它们都是建立在第三个方案的这个“核心闭环”之上的。一旦你把这个闭环吃透了,后面加再多层次都不会乱。
6. 手写 Tool Use 踩坑实录与独家心得
6.1 参数问题永远排在第一位
我在实际调试 Tool Use 时,90% 的报错都出在参数上。模型生成的参数键名和工具函数形参不一致、参数缺失、类型错误、多余参数,各种问题轮番上演。
方案二引入的 Pydantic 校验确实解决了一大半,但还有一个隐蔽问题:有些字段类型是Optional[str],模型可能给它传一个空字符串"",校验能通过,但业务逻辑看不懂这个空值。最终我在每个工具函数内部都加了防御式判断,宁可多写两行,也不愿线上炸了再救火。
6.2 模型输出解析必须做容错
模型输出非法 JSON 的情况比你想象中常见得多。比如它会输出带注释的 JSON、多行代码块包裹的 JSON、或者干脆是“我调用了天气工具”这种自然语言。
我的经验是:优先尝试标准json.loads,失败后用正则把最外层的{...}或[...]匹配出来再解析,再不行就把原始字符串抛给模型让它自己修正。这三层兜底在真实项目中帮我挡住了大量脏数据。
6.3 工具执行结果别忘考虑体量
有一次我在工具里执行了一段数据分析代码,返回结果是一张上万行的 DataFrame,然后我直接把整个结果回传给模型。结果模型不仅回答变慢了,还经常被无关的中间数据带跑偏。
后来我学乖了:工具返回给模型的结果必须做裁剪和摘要。对于太大、太长的结构化数据,只回传统计结论、Top N 条目或者一个可读性更强的精简版本,模型反而能给出更高质量的答案。这对 token 成本也是极大缓解。
6.4 让模型自己处理执行错误
我刚把 Tool Use 接入项目时,习惯在工具内部就把异常处理好,保证最终一定给模型一个干净结果。后来发现自己犯了一个认知错误:工具结果的error字段本身就是模型的可消费信息,它比你想象中更擅长基于错误做修正。
于是我把工具边界上的异常捕获保留下来,但把原始报错信息原封不动打包成结构化错误返回给模型。有一次,模型在收到“文件不存在”错误后,自动修改了文件路径参数重新请求,居然真的成功了。这种能自我纠错的 Agent 体验,在纯逻辑代码里是永远等不来的。
7. 手写 Tool Use 的扩展思路
三个方案写完之后,我并没有停下来。因为面试官接着问了一个开放性题目:如果让你把这个框架扩展成一个支持多步骤规划的小型 Agent,你会怎么设计?
我当时给了一个很朴素的扩展思路:在第三个方案之上,给工具增加“前置条件和后置条件”的描述。比如“查天气”这个工具,前置条件是用户提供了城市名;后置条件是把天气结果写入上下文。模型在每一步推理时,根据前一步的结果判断下一步该调用什么工具,这就是一个极简版的 ReAct 模式。
后来我在自己项目里真的试了这个设计,效果比预期好很多。因为不需要引入复杂的状态机,只需要在每个工具 Schema 里多定义两个字段,模型就能自动完成“观察结果 -> 决定行动”的循环。当然了,如果工具数量太多、步骤太长,你还是需要上规划器(Planner)或引入图数据库来管理依赖关系,但至少这套思路作为起步是完全可行的。
我个人的体会是:手写 Tool Use 最大的价值,不在于你写出的代码有多优雅,而在于你亲手踩过参数混乱、上下文丢失、错误不可控这些坑之后,才能真正看懂各种 Agent 框架的设计意图。每个框架里的 Router、Memory、ToolValidator,本质上都在解决这三个方案里暴露出来的同一个问题。
如果你也在准备 AI 应用相关面试,建议你亲手把这三版都敲一遍,再用自己的工具函数替换掉天气和计算器,跑通一个两轮以上的对话场景。这个过程中你踩到的每一个报错、想通每一个设计点,都会成为面试桌上比任何简历都更有说服力的资本。