面试现场碰到“手写一个 Tool Use”,大多数人的第一反应是背某个模型的 Function Calling 参数。可真当你在白板上从零开始搭调用循环时,才会发现这东西考的不是“记没记住 API”,而是你是否真的理解“模型怎么把手伸进外部世界”。
Tool Use 在现在的 Agent 开发里几乎是地基一样的存在,查天气、查数据库、操作接口、调内部服务,全靠它。面试官让我现场写一套完整可跑的实现,我给出了三种思路。这篇就把三种方案的完整代码和背后的思考讲清楚,正在准备大模型应用岗位面试的朋友可以直接抄,做 Agent 工程的也能拿去做方案选型参考。
1. 面试官到底在考什么:Tool Use 的真实模样
1.1 Tool Use 的本质:模型不会动手,只会写“任务单”
所有工具调用的起点都是同一个事实:大语言模型本身只是一个文本生成器。你问它“蓝港市现在多少度”,它没见过温度计,也接不了气象接口,它唯一能做的是基于训练数据里的知识给你一个模模糊糊的回答。
Tool Use 要解决的正是这个问题:让模型在需要外部信息时,不是自己编答案,而是明确告诉我们“我需要调用某个工具,传入这些参数”。真正去执行工具、拿到结果、再把结果喂回给模型继续推理的,是我们的程序。
我习惯把模型比喻成一个只会写纸条的实习生:它不会修电脑、不会查数据库,但它会写一张任务单,上面写清楚要找谁、要干什么。Tool Use 协议就是那张任务单的格式约定,而我们程序是那个真正跑腿办事的人。
所以手写 Tool Use,核心不是写一个函数,而是实现下面这条完整链路:
- 把工具清单(名字、功能描述、参数结构)交给模型;
- 模型在需要时,按我们约定的格式输出“要调用哪个工具、传什么参数”;
- 程序解析这段输出,找到真实函数并执行;
- 把执行结果作为一条新消息回填进对话窗;
- 模型看到结果后继续生成,直到完成最终回复。
1.2 三种方案的内在逻辑
面试官让我写“一个”Tool Use,但我一口气写了三种,因为不同场景对可靠性、兼容性、灵活性的要求完全不同:
- 方案一,结构化 JSON 协议:把工具声明成 JSON Schema,模型直接输出结构化的工具调用对象,解析最稳、错误率最低;
- 方案二,ReAct 文本协议:用提示词约束模型按特定文本格式输出动作和参数,再用正则解析,兼容性最广,不依赖结构化输出能力;
- 方案三,代码生成与沙箱执行:让模型直接生成 Python 代码,在受限沙箱里执行,自由度最高,但风险也最大。
三种方案在生产环境里都真实存在。下面我按稳健程度从高到低逐一拆解。
2. 方案一:结构化 JSON 协议,最稳定也最好讲清楚
2.1 工具声明:把“有什么工具”翻译成模型能懂的 Schema
方案一是当前主流大模型平台原生支持的做法,核心思想是把每个工具描述为一份结构化声明,模型在解码时直接生成符合该结构的 JSON。
先定义一个工具注册表:
TOOLS = [ { "type": "function", "function": { "name": "get_weather", "description": "查询某个城市当前天气情况,输入参数为城市名称", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如蓝港市" }, "unit": { "type": "string", "enum": ["celsius", "fahrenheit"], "description": "温度单位,默认 celsius" } }, "required": ["city"] } } }, { "type": "function", "function": { "name": "get_current_time", "description": "获取当前系统时间", "parameters": { "type": "object", "properties": {} } } } ]每个工具要有三个关键信息:name是程序里的唯一标识;description是给模型看的说明书,描述质量直接决定模型会不会在合适场景正确调用;parameters是参数约束,类型、枚举、是否必填都写清楚,这是后续校验和执行的依据。
注意get_current_time这种无参工具,参数对象也要显式写成空结构{"type": "object", "properties": {}},很多平台在参数缺失时会直接报错。
2.2 推理与执行循环:解析、执行、回填
完整的调用循环是这个样子:
import json # 工具名到真实函数的映射表 def get_weather(city: str, unit: str = "celsius"): # 这里对接真实天气 API,演示直接返回 mock 数据 return {"city": city, "temperature": 22, "unit": unit, "condition": "晴"} def get_current_time(): import datetime return {"time": datetime.datetime.now().isoformat()} HANDLERS = { "get_weather": get_weather, "get_current_time": get_current_time, } def run_tool_call(tool_name: str, args: dict): if tool_name not in HANDLERS: raise ValueError(f"未知工具: {tool_name}") print(f"执行工具 {tool_name}, 参数: {args}") return HANDLERS[tool_name](**args) def chat_with_tools(messages, model_respond, max_turns=5): for _ in range(max_turns): response = model_respond(messages) tool_calls = response.get("tool_calls") if not tool_calls: # 模型没有请求调用工具,说明它已经给出最终答复 return response["content"] for call in tool_calls: messages.append({ "role": "assistant", "content": None, "tool_calls": [call] }) result = run_tool_call( call["function"]["name"], json.loads(call["function"]["arguments"]) ) messages.append({ "role": "tool", "tool_call_id": call["id"], "content": json.dumps(result, ensure_ascii=False) }) raise RuntimeError("调用轮数超过上限")这里的model_respond是一个抽象出来的模型调用函数,真实项目里替换成具体平台的 SDK 调用即可。整个循环最关键的动作是:工具执行结果不是替换掉原来的消息,而是追加一条role="tool"的新消息,并且通过tool_call_id准确对应到哪一次调用。
多工具并行时,这个对应关系尤其重要。run_tool_call里的动图是给调试用的,实际生产环境建议接日志框架。
2.3 这个方案为什么是默认首选
我在面试现场首选方案一,并且给的理由非常简单:格式可靠性是它最大的价值。结构化输出是模型在训练阶段专门对齐过的能力,模型以 JSON 形式返回工具调用的成功率远高于普通文本,你的解析程序几乎不需要处理乱七八糟的边界情况。
实现上它也是三种里最省心的,解析只做三件事:取tool_calls字段、取function.name、取function.arguments再json.loads,没有玄学正则,出错面很小。
另外两个实践细节值得注意:一是参数要校验,模型偶尔会传多字段或漏字段,用声明时的 Schema 做一次校验,比盲目相信模型稳健得多;二是空参数别落下,上面代码里parameters为空对象时,json.loads之后的参数可能是空 dict,HANDLERS[tool_name](**args)仍然正常。总之,只要目标模型平台支持结构化输出,方案一永远是性价比最高的起点。
3. 方案二:ReAct 文本协议,不挑模型也能跑
3.1 先理解 ReAct 的输出约定
方案二回到了纯文本约束的路线,不依赖平台的结构化能力。它的思想是让模型在回答中“自言自语”式地输出思考和行动步骤,我们按约定的文本格式提取行动指令。
提示词里我一般这么写:
你是一个可以通过调用工具来解决问题的助手。当你需要获取信息或执行操作时,请严格按以下格式输出: Thought: 你对当前任务的一句话分析 Action: 工具名称 Action Input: {"参数名": "参数值"} 如果不需要调用工具,则直接回复最终答案,不要输出 Action。模型输出一个示例:
Thought: 用户想知道蓝港市当前的天气,我需要调用天气查询工具。 Action: get_weather Action Input: {"city": "蓝港市", "unit": "celsius"}注意:这条约定里多了一个Thought字段。它不只是增加可解释性,更重要的是让模型在回答前先“想清楚”再生成动作,能明显减少乱调用工具的情况,这也是这类文本协议在实践中比单纯两个字段更稳的原因。
3.2 正则解析与循环控制:完整实现代码
因为模型可能在任何位置插入解释性文字、换行甚至 Markdown 代码块,直接json.loads整段文本大概率失败。所以方案二的核心工作是用正则把关键字段提取出来:
import re import json ACTION_PATTERN = re.compile( r"Action:\s*(\w+)\s*Action Input:\s*(\{.*?\})", re.S ) def parse_action(text: str): match = ACTION_PATTERN.search(text) if not match: return None tool_name, args_json = match.group(1), match.group(2) try: args = json.loads(args_json) except json.JSONDecodeError: # 模型输出的 JSON 偶尔会被截断或混入多余内容 args = extract_json_with_fallback(args_json) return {"name": tool_name, "arguments": args}re.S让.可以匹配换行,保证 Action 和 Action Input 跨行时也能匹配到。难点在args_json的提取:模型经常把 JSON 写成Action Input: {"city": "蓝港市", "unit": "celsius"}然后又画蛇添足加一段解释,所以这里要做一次容错清理:
def extract_json_with_fallback(raw: str): # 去掉可能存在的 markdown 代码块围栏 raw = raw.strip().strip("`") raw = raw.replace("```json", "").replace("```", "").strip() start, end = raw.find("{"), raw.rfind("}") return json.loads(raw[start : end + 1])完整调用循环和方案一类似,区别只在模型输出的是纯文本,我们用parse_action判断是否发生了工具调用:
def chat_with_react(messages, model_respond, max_turns=5): for _ in range(max_turns): text = model_respond(messages) action = parse_action(text) if not action: return text messages.append({"role": "assistant", "content": text}) result = HANDLERS[action["name"]](**action["arguments"]) messages.append({ "role": "user", "content": f"工具执行结果: {json.dumps(result, ensure_ascii=False)}" }) raise RuntimeError("调用轮数超过上限")这里回填执行结果的role用user而不是tool,需要特别说明:因为文本协议没有tool_call_id这种机制,把结果作为用户消息回填,模型才能把它当作“外部世界的客观反馈”来继续推理,这是 ReAct 模式在工程上的通用做法。
3.3 适合它的场景
方案一在主流大模型平台上很稳,但你手里不一定有它。真实项目中至少有三种情况会想用方案二:
- 模型没有结构化输出能力:一些开源模型或国内小模型不支持专属工具字段,却对齐过 ReAct 格式;
- 需要跨平台无缝切换:屏蔽不同平台 API 的差异,只要文本输入输出,一个模型换成另一个,代码不用动;
- 追求调试可解释性:Thought/Action 全沉淀在对话流里,复盘整个决策链非常直观,出了问题翻日志就是现场。
方案二最大的代价是格式漂移风险:模型可能输出成Action: get_weather\nwith params: {...}这类变体,正则匹配不到,或者 JSON 里混入注释。所以解析器必须写得足够宽容,同时在提示词里重申格式约束也很重要。总体来说,兼容性是它的王牌。
4. 方案三:代码生成与沙箱执行,把工具调用交还原生语言
4.1 思路转变:让模型直接写 Python
前两种方案的共同点是“模型只负责表达意图,我们负责翻译执行”。方案三来了一个更激进的转变:让模型直接生成一段 Python 代码,程序把代码丢进沙箱执行。
这样做的直接好处是模型不再受限于我们预设的工具 JSON 声明,它可以用变量、条件判断、循环、中间计算来组合工具,比如“先查出未来五天的天气,再筛选出温度最高的那天”这类复杂任务,模型不需要分五轮调用,而是一次性生成完整计算逻辑的代码。
为了不让模型随意 import 网络库或读系统文件,我给它在沙箱里暴露一个受限的api对象,工具的真实能力通过这个对象暴露:
class ToolAPI: def __init__(self, handlers): self._handlers = handlers def call_tool(self, name: str, **kwargs): if name not in self._handlers: return {"error": f"未知工具: {name}"} return self._handlers[name](**kwargs)提示词里要求模型只通过api.call_tool("工具名", 参数=值)来调用外部能力,限定访问方式,沙箱的风险边界就清晰多了。
4.2 执行与捕获:核心实现
import io import contextlib def execute_generated_code(code: str, api: ToolAPI, timeout: int = 5): # 用超时保护和标准输出捕获包裹执行过程 allowed_builtins = { "print": print, "len": len, "range": range, "str": str, "int": int, "float": float, "list": list, "dict": dict, "float": float, "max": max, "min": min, "abs": abs, "round": round, # 故意不放 open、import 等敏感内置 } env = { "api": api, "__builtins__": allowed_builtins, } buf = io.StringIO() try: with contextlib.redirect_stdout(buf): exec(compile(code, "<generated>", "exec"), env, env) return buf.getvalue() except Exception as e: return f"执行出错: {type(e).__name__}: {e}"演示代码重点展示了三个安全设计:
__builtins__被替换成白名单字典,open、__import__、eval等危险入口全部不放行;- 用
contextlib.redirect_stdout捕获模型代码的 print 输出,避免污染进程的标准流; compile时使用<generated>作为代码标识,报错时能清楚分辨是哪段生成代码出的问题。
超时保护在真实沙箱里非常重要,因为大模型生成一段while True是稀松平常的事。上面的timeout参数只是函数预留,生产环境必须在更底层限制,比如用子进程包裹再proc.wait(timeout),或者直接跑在无网络、无文件的容器里。
4.3 什么地方会用到这种方案
代码生成方案的危险性和灵活度一样高,把它作为面试的“锦上添花”方案,重点在于展示你对风险边界的理解。现实项目里,它主要用于一些高危但高价值的场景:
- 复杂数据分析:模型生成 Pandas 代码执行统计和图表生成,要用的工具逻辑根本不是几个 JSON 工具描述能覆盖的;
- 多步骤编排:查询、对比、条件分支,在一次生成中完成,减少模型在多轮调用中的状态丢失;
- 原型验证:快速让模型“试写”一段逻辑,人工确认再入库。
我还会单独提一句:真实环境里,代码生成方案必须配一个独立的受限执行环境,不能直接在业务进程里 exec。没有容器和严格进程隔离的方案,只停留在演示阶段,别上生产。
5. 三种方案横向对比与选型建议
5.1 关键指标对比
面试官特别喜欢问“三种方案你怎么选”,一张对比表能迅速讲清权衡:
| 对比维度 | 方案一:结构化 JSON | 方案二:ReAct 文本 | 方案三:代码生成 |
|---|---|---|---|
| 输出可靠性 | 高,模型专门对齐过 | 中,依赖提示词和正则兜底 | 中低,代码语义错误难穷尽 |
| 解析复杂度 | 低,直接取字段 | 高,正则和容错处理 | 低,编译执行即可 |
| 模型兼容性 | 需平台支持结构化输出 | 仅需文本能力,最广 | 需代码能力较强 |
| 复杂任务表达能力 | 弱,多轮循环 | 中 | 强,天然支持分支和组合 |
| 安全风险 | 低,参数白名单 | 低,同上 | 高,必须沙箱 |
| 调试可解释性 | 中 | 最高,有 Thought 记录 | 低,黑盒执行 |
这张表的核心信息是:没有任何一种方案在所有维度全胜。结构化 JSON 可靠但表达能力弱;ReAct 兼容但格式漂移烦人;代码生成强但风险高。它们的取舍不是“谁更好”,而是“当前场景更在乎哪个维度”。
另外一个常被忽略的关键区别是调用心智:方案一和二是“模型描述动作、框架执行”,方案三是“模型编写逻辑、沙箱执行”。前者把逻辑拆碎到多轮消息里,后者把逻辑压缩到一次代码执行中,语义表达效率完全不同。
5.2 我给项目/面试的建议
如果是面试现场,完整表述我的思考路径是:
第一优先写方案一,因为它是最标准的工程实践,先说主体逻辑让人理解调用循环的闭环;然后补充方案二,说明它解决“模型不支持结构化输出”的兼容问题,展示你的正则和容错能力;最后提方案三,点到为止,重点讲清楚沙箱的必要性和内置函数白名单、进程隔离的思路,面试官对“你知道安全边界”的印象分会非常显著。
真实项目的选型我通常按这个顺序判断:
- 主流大模型 + 常规工具数量 → 方案一。稳定省心,团队接手成本最低;
- 开源模型 / 多模型切换 → 方案二。文本协议是唯一的公共接口;
- 复杂逻辑任务重建模成工具描述很别扭 → 方案三。但先确认有可用的沙箱基础;
- 高频低风险调用 → 方案一;低频高灵活度调用 → 方案三,两者可以并存。
6. 实战中踩过的坑与排查技巧
6.1 模型就是不按格式输出怎么办
方案二最容易踩这个坑。模型偶尔在Action前加一句“让我看看”,或把参数写成{city: 蓝港市}(没有引号),正则就失效了。我排查这个问题的顺序是:
先做宽容化处理:解析失败时,不直接抛错,尝试剔除多余字符、补全引号,或者干脆把整段输出发给模型让其“只输出更正后的 JSON”。再检查提示词是否被污染:多轮对话里,用户输入中的“Action: xxx”字样容易干扰模型对格式的理解,最好把格式示范放在系统提示词的固定位置。所有失败分支都要日志化:把原始文本和正则匹配结果打到日志里,积累几个案例后,大概率会发现是某个模板模式导致的漂移,针对性改提示词比盲目改正则有效得多。
还有就是重试输出要带反馈:告诉模型“你上次的输出无法解析,请重新只输出 Action 和 Action Input”,把这条消息追加到对话里再请求一次。模型对“错误反馈”的纠正能力通常比你想的强。
6.2 多轮调用中的循环失控
如果让模型自由调用工具,它可能永远不主动停止,或者陷入“查了天气又查湿度、查了湿度又要紫外线”的循环。我在生产代码里一定会加三个防护:
# 1. 轮数保护 if turn >= max_turns: raise RuntimeError("工具调用超过最大轮数") # 2. 相同参数去重:完全相同的调用直接返回历史结果 signature = (tool_name, json.dumps(args, sort_keys=True)) if signature in seen_calls: return {"error": "检测到重复调用,请基于已有结果继续"} seen_calls.add(signature) # 3. 最终答复兜底:轮数耗尽后,追加一条强制提示 messages.append({ "role": "user", "content": "你已经尝试多次但未得出结论,请直接基于你已有的信息回答用户,不要再调用工具。" })这三个防护分别对应“耗死系统”“重复无效请求”和“卡在循环里没完没了”三种失控场景,缺一不可。
6.3 安全与稳定性检查清单
最后我把做工具调用方案时常用的检查点列成清单,无论最终选了哪种方案,发布前都过一遍:
| 检查项 | 建议 | 风险等级 |
|---|---|---|
| 工具参数校验 | 用声明 Schema 或pydantic严格校验后再执行 | 高 |
| 敏感信息 | 工具返回内容禁止夹带内部 token、密钥 | 高 |
| 执行超时 | 每个工具调用要有独立超时控制 | 高 |
| 代码沙箱 | 生成代码执行必须放子进程或容器 | 高 |
| 循环轮数 | 必须有限制,常见 3~8 轮 | 中 |
| 错误传播 | 工具异常要转成模型可读的文本,而不是直接崩溃 | 中 |
| 审计日志 | 每次调用记录模型原始输出、解析结果、执行结果 | 中 |
| 模型提示词版本 | 工具描述变更后要同步回归验证 | 中 |
这个清单最容易被忽略的是“工具返回内容夹带敏感信息”。真实项目中,一个数据库查询工具可能返回了包含内部字段名的原始记录,模型会把字段名原样输出给用户,这比代码漏洞更隐蔽,所以工具在返回前必须做字段脱敏。
我个人在实际写了一遍三种方案后的体会是,这类面试题真正想看到的不是多炫技的代码,而是你能不能把“模型表达意图—程序执行工具—结果回填—继续推理”这个闭环讲通透,并意识到不同方案本质是在“可靠性、兼容性、灵活度”之间做取舍。最后的落地建议是:所有代码都围绕一个极小的循环核心去写,不要一开始堆砌框架,面试或项目里能稳稳跑通一个调用闭环,比写一堆花哨的装饰有意义得多。