☰
手写Tool Use的三种方案:从Prompt到Function Calling再到ReAct
2026/10/16 4:42:17 网站建设 项目流程

面试官让我手写一个 Tool Use,我用了 3 种方案。说实话,刚听到这道题的时候我愣了一下——手写 Tool Use?现在各种框架现成的 Agent 都一堆了,还有必要从零写吗?但接下来的十分钟让我明白,这道题根本不是考你会不会调 API,而是考你有没有真正吃透 Tool Use 这条链路里的每一个环节。

本文就把我在面试现场写出来的 3 种方案完整复盘出来。从最朴素的 Prompt 解析,到原生 Function Calling,再到带循环的 ReAct Agent,每种方案的思路、代码、优缺点、踩坑点都会讲到。不管你是准备面试,还是想在项目里从零搭建一个工具调用模块,这篇都能当一份可直接参考的实操笔记用。

1. 手写 Tool Use 之前,先想清楚这 3 个问题

1.1 Tool Use 到底解决了什么问题

先搞清楚概念,Tool Use 翻译过来就是“工具使用”。它解决的核心问题是:大模型本身不联网、不会算精确数学、也读不了你本地数据库,只能根据训练数据“猜答案”。但现实中的任务,比如查天气、订机票、算一笔复杂账目、调一下内部系统的接口,全都需要实时数据和真实操作。

Tool Use 做的事情,就是给模型装上一个“控制手柄”——模型负责理解用户意图、决定要调哪个工具、给出调用参数,外部系统负责真正执行,再把结果还给模型,由模型组织成最终回答。一句话总结:模型只负责决策,执行权交给代码。

这个概念在面试里被翻来覆去地问,核心原因就是它几乎是所有 Agent 应用的地基。你平时看到的各种“智能助手”“自动化工作流”,底层跑的都是同一条链路:意图识别、工具选择、参数抽取、执行结果回填。

1.2 手写 Tool Use 的难点在哪里

面试官让我“手写”的时候,我脑子里过了一遍这条链路,发现真正的难点不在“调用工具”这个动作上,而在三个地方:

第一,工具怎么描述给模型。模型看到的只有文字和结构化数据,你得让它能理解“这个工具是干嘛的、参数有哪些、参数是什么格式”。描述不合适,模型就会瞎填参数。

第二,模型输出怎么变成可执行的正确动作。模型吐出来的是一段文本,你需要从里面准确提取“要调哪个工具、传什么参数”,还要处理格式错误、参数缺失、JSON 解析失败这些幺蛾子。

第三,调用完之后,结果怎么“回灌”。工具执行完的结果要放回对话上下文里,让模型能基于真实结果继续推理,而不是自说自话。

想明白这三点之后,方案基本就是水到渠成的事。我写的三种方案,其实就是对这三个难点三种不同层级的处理方式。

1.3 三种方案的选型思路

我在现场是这么考虑的:

如果完全不依赖模型的特殊能力,只用通用的文本生成接口,那就走方案一,靠“系统提示词 + 强制 JSON 输出”来硬掰。

如果模型本身支持结构化的工具调用协议,那就走方案二,把工具以 JSON Schema 形式传给模型,让模型返回标准化的工具调用指令。这是我平时项目里最常用的方式。

如果还要更进一步,让模型不仅能调一次工具,还能根据结果调整计划、连续调用多个工具,那就走方案三,在方案二的基础上加一个 ReAct 循环,把单次 Tool Use 升级成一个迷你 Agent。

三个方案是递进的,不冲突。下面逐个拆。

2. 方案一:Prompt + 强制 JSON 输出(纯工程方案)

2.1 核心思路与实现原理

方案一的思想非常简单粗暴:模型只会输出文本,那我就在系统提示词里把所有工具都描述清楚,然后要求它“当需要调用工具时,只输出一个 JSON”,接着我在外部对这段 JSON 做解析和执行。

这个方案的技术含量主要在提示词设计和容错处理上。但不要小看它,在模型不支持 Function Calling、或者你用的是某些开源小模型时,它是唯一能用的方案。

它背后的原理是:大模型在训练时见过大量 JSON 格式数据,只要你明确要求并给出示例,模型是能稳定输出 JSON 的。加上现代模型的指令遵循能力越来越强,这种方案的可用性其实比很多人想象中高。

2.2 代码实现:工具定义、系统提示词与解析器

我现场写的代码是这个样子,依然是 Python 伪代码风格,核心逻辑可以直接平移到任何框架:

import json TOOL_REGISTRY = { "get_weather": { "description": "查询指定城市的实时天气,参数 city 为城市名,例如 北京", "handler": lambda **kw: query_weather(kw.get("city", "")), }, "calc": { "description": "计算数学表达式,参数 expr 为字符串表达式,例如 (1+2)*3", "handler": lambda **kw: safe_calc(kw.get("expr", "")), }, } def build_system_prompt(): tool_lines = [] for name, tool in TOOL_REGISTRY.items(): tool_lines.append(f"- {name}: {tool['description']}") tool_desc = "\n".join(tool_lines) return f""" 你是一个会调用工具的助手。当任务需要工具时,严格输出如下 JSON,不要输出任何其他内容: {{"tool": "工具名", "args": {{"参数名": "参数值"}}}} 如果不需要工具,直接回复用户的自然语言。 可用的工具及参数说明: {tool_desc} """ def handle_tool_use(messages): # call_llm 是你在用的大模型接口封装,这里省略具体实现 resp = call_llm([{"role": "system", "content": build_system_prompt()}] + messages) text = resp["content"].strip() if text.startswith("{"): try: call = json.loads(text) except json.JSONDecodeError: return "模型输出不是合法 JSON,请重新生成" tool = TOOL_REGISTRY.get(call.get("tool")) if not tool: return f"未找到工具 {call.get('tool')}" result = tool["handler"](**call.get("args", {})) return result return text

注意几个细节。系统提示词里我特意把 JSON 的格式用{{"tool": ...}}包起来,因为 Python 的 f-string 会把花括号当占位符,这里必须转义,否则直接报错。然后我在解析前先判断text.startswith("{"),为的是区分两种情况:模型在正常回答,还是模型在尝试调工具。

解析 JSON 之后,我用TOOL_REGISTRY做了一个简单的分发器,拿到工具名就去注册表里查 handler,再调用。这样后续加新工具,只需要在注册表里加一条记录,不用改主流程。

2.3 实测效果与局限

我测试过这个方案,在指令遵循能力强的模型上,单轮工具调用的成功率能做到 90% 以上。但问题也很明显:

一是模型偶尔会在 JSON 前后夹带解释文字,比如输出“好的,我来查询:{...}”,解析就会挂。我当时是在解析前做了一步“提取第一个{到最后一个}的子串”来兜底,但这个兜底在参数内部出现花括号时会出错。

二是复杂参数场景完全扛不住。如果你要调一个十几个字段的工具,靠一句提示词让模型准确填全,几乎不可能。

三是这个方案没法处理“模型胡说八道”。模型可能选了一个名字相似的错误工具,或者编了一个根本不存在的参数。代码层面只能报“未找到工具”,没法引导模型自我修正。

所以在实际生产里,我把这个方案定位成“兜底方案”和“教学方案”。适合的场景是:工具数量少、参数简单、模型不支持 Function Calling。如果你要做一个严谨的生产系统,尽量用下一个方案。

3. 方案二:Function Calling 结构化调用(原生 API 方案)

3.1 思路:把工具描述交给模型,让模型决定怎么调

方案二的核心思路,是把“工具描述”这件事从业务代码里抽出来,交给模型的原生能力去处理。

业界主流的做法是:你把每个工具写成一个 JSON Schema(包括函数名、描述、参数结构、哪些字段必填),调用模型 API 时通过tools参数传进去。模型在内部会对齐这些结构,当判断当前任务需要某个工具时,不会输出自由文本,而是直接返回一个结构化字段tool_calls,里面明确告诉你调哪个函数、参数是什么、参数都已经按照 schema 填好。

与方案一最大的区别是:方案一依赖“模型主动输出 JSON 文本”,方案二依赖“模型在概率层直接生成结构化的调用指令”。后者在可靠性上完全不是一个量级,因为整个tool_calls结构是模型服务端在解码时就约束好的,从机制上极大降低了乱解析的风险。

3.2 代码实现:工具注册与 schema 转换

核心代码分两步。第一步是定义工具 schema:

import json TOOL_SCHEMAS = [ { "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的实时天气", "parameters": { "type": "object", "properties": { "city": {"type": "string", "description": "城市名,如 北京"} }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calc", "description": "计算数学表达式", "parameters": { "type": "object", "properties": { "expr": {"type": "string", "description": "数学表达式,如 (1+2)*3"} }, "required": ["expr"] } } } ]

第二步是调用主循环。这里我把“解析工具调用→执行→结果回填→再次请求模型”的过程完整写出来:

def run_with_function_calling(messages): resp = call_llm(messages, tools=TOOL_SCHEMAS, tool_choice="auto") msg = resp["message"] if msg.get("tool_calls"): # 把模型请求工具的中间消息加回对话,保持上下文完整 messages.append(msg) for tc in msg["tool_calls"]: fn = tc["function"] # 取出参数 JSON 并执行 args = json.loads(fn["arguments"]) result = dispatch_tool(fn["name"], args) # 按协议把执行结果以 role=tool 的消息回填 messages.append({ "role": "tool", "tool_call_id": tc["id"], "content": json.dumps(result, ensure_ascii=False) }) # 模型拿到所有工具结果后,再走一轮生成 return run_with_function_calling(messages) return msg["content"]

这段代码的细节里藏着真正重要的事。

第一个细节:模型返回的tool_calls是一个列表,不是单个对象。这在并行调用多个工具时非常有用,如果你的需求是“对比两个城市的天气”,模型可能会同时发起两次get_weather调用,代码里用 for 循环分发就是为这个准备的。

第二个细节:我先把messages.append(msg)把含tool_calls的模型消息放回上下文,再用 role=tool 的消息去回填结果。这是协议明确要求的——如果你不把 assistant 那条带tool_calls的消息放回去,只塞一堆 tool 消息进来,模型会不知道这些结果分别对应哪次请求,后面生成就会乱。

第三个细节:工具执行失败时,不要把异常直接抛出来中断整个流程。我当时的处理是 catch 住异常,把错误信息序列化成字符串,塞回content让模型看到。这样模型会意识到“刚才的工具调用失败了”,自己调整参数重新调一次。这个思路会一直沿用到方案三。

3.3 为什么这个方案是当前生产环境的主流

我在实际业务里长期用方案二,最大的感受是省心。

稳定性方面,因为工具调用结果走的是结构化字段,不会再出现从一段自由文本里暴力解析 JSON 的噩梦。调试方面,你只需要在dispatch_tool那一层加一个日志,打印入参和出参,整个链路就是透明的。扩展方面,每加一个工具只需要在TOOL_SCHEMAS加一个 schema 定义,参数校验这些事模型已经帮你做了一大半。

当然它也有前提条件:你用的模型服务必须支持 Function Calling 协议。目前主流的商业模型基本都原生支持,很多开源模型也在逐步跟进。如果遇到不支持的小模型,就只能退回到方案一。

还有一个被低估的好处:方案二的说明文字与参数约束是分离的。你可以在 schema 里写细致的参数描述指导模型,比如“city 参数必须是中文城市名,不能是拼音”,模型会严格按照这个约束来生成参数。这在方案一是很难做到的。

4. 方案三:ReAct 循环,把 Tool Use 升级成一个迷你 Agent

4.1 思路:思考、行动、观察循环

写完方案二之后,面试官果然追加了问题:“如果任务是多步的呢?模型需要先查天气,再根据天气计算穿衣指数,再调另一个工具查交通。你怎么做?”

这就引出了方案三:ReAct(Reasoning and Acting,推理与行动)循环。核心思想是让模型在一个循环里反复经历“思考 → 行动 → 观察”的过程。

拆解一下:

  • Thought(思考):模型描述当前状态,分析前一步结果,决定下一步做什么。
  • Action(行动):模型选择工具并给出参数。
  • Observation(观察):外部系统执行工具,把真实结果返回给模型。

模型看到 Observation 之后,继续生成 Thought,接着下一个 Action……直到模型认为自己已经拿到足够信息,输出最终答案,循环终止。

本质上,方案三就是在方案二的基础上加了一个“外层 while 循环”,并且通过上下文管理让模型可以连续多轮使用工具。这个能力在复杂任务里是刚需,比如:先查用户订单,发现缺货,再查替代商品,再算价格差异,最后通知用户。

4.2 代码实现:一个带最大步数的 Agent 主循环

我现场写的是一个简化但结构完整的版本,基于 ReAct 文本协议 + 工具分发:

REACT_SYSTEM_PROMPT = """ 你是 Agent,按以下流程循环工作: Thought: 分析当前目标与已有信息,说清楚打算做什么 Action: 选择要调用的工具及参数,输出 JSON:{"tool": "...", "args": {...}} Observation: 等待工具执行结果,并据此继续思考 当任务完成时,直接输出 Final Answer: 最终结果。 """ def extract_action(text): # 从模型输出中提取 Action 后的 JSON start = text.find("{") end = text.rfind("}") if start == -1 or end == -1 or end <= start: return None try: return json.loads(text[start:end+1]) except json.JSONDecodeError: return None def agent_loop(user_query, max_steps=5): messages = [ {"role": "system", "content": REACT_SYSTEM_PROMPT}, {"role": "user", "content": user_query} ] for step in range(max_steps): resp = call_llm(messages) content = resp["content"] if "Final Answer:" in content: return content.split("Final Answer:", 1)[-1].strip() action = extract_action(content) if action is None: # 模型没有输出可识别的 Action,让它纠正 messages.append({"role": "assistant", "content": content}) messages.append({"role": "user", "content": "你没有输出合法的 Action JSON,请重新输出。"}) continue result = dispatch_tool(action["tool"], action.get("args", {})) # 把模型的推理过程与观察结果一起放回上下文 messages.append({"role": "assistant", "content": content}) messages.append({"role": "user", "content": f"Observation: {json.dumps(result, ensure_ascii=False)}"}) return "任务未在指定步数内完成,请简化目标或补充信息。"

在这个版本的实现里,工具的执行结果是通过Observation: ...加回对话上下文的。这样模型读到下一轮时,就能结合前面的 Thought 和最后的 Observation 继续推理,形成完整闭环。

我在extract_action里做了一个防御性设计:如果模型输出的 JSON 不合法,就追加一条用户消息,明确告诉它“没有输出合法 JSON,请重新输出”,把错误变成了引导信号,而不是直接崩掉。这个设计在后面生产环境里救了我很多次。

4.3 循环控制与失败兜底怎么做

方案三看起来很美,但真正要做扎实,魔鬼全在循环控制里。我从实际调试中提炼出几个核心注意点:

第一个是最大步数限制。没有步数限制的 Agent 就是一个吞金黑洞。模型可能会在某个复杂任务里反复尝试,一回合烧掉几千 token。我上面的代码设置了max_steps=5,实测下来 5 步已经能覆盖大多数日常多步任务,复杂场景可以调到 8 到 10,但一定要做上限。

第二个是上下文膨胀控制。每次循环都会塞入 assistant 消息和 Observation 消息,轮数一多,上下文长度迅速膨胀,token 费用成倍增长,而且模型可能被过长的历史干扰。我的做法是只保留最近 N 轮 Observation,或者把工具返回结果截断到前若干字符。核心原则是:模型决策所必需的信息必须保留,但冗长的工具输出要裁剪。

第三个是失败重试策略。工具执行本身可能会失败,比如接口超时、参数非法、权限不足。我建议把异常信息结构化成{"error": "...", "code": ...}回传给模型,让模型自己判断是换参数重试还是换别的工具。这样 Agent 看起来才像真正“会思考”,而不是一遇到错误就原地爆炸。

第四个是终止条件判断。除了“Final Answer”这个显式终止符,还要处理模型反复输出同样 Action 的死循环。我在代码里加了一个简单的“连续两次 Action 相同就强制终止”的检查,实际项目里可以换成更复杂的重复检测逻辑。

5. 三方案对比与面试回答思路

5.1 综合对比表格

面试官几乎肯定会让你横向比较,我把三种方案的关键维度整理成了表格:

对比维度方案一 Prompt+JSON方案二 Function Calling方案三 ReAct 循环
实现成本最低,只需改提示词中等,需写 schema 与分发较高,需处理循环控制
调用稳定性依赖模型指令遵循结构化返回,稳定性高依赖循环与容错设计
支持多步任务基本不支持可通过多次调用来拼原生支持,自动规划
调试难度难,JSON 解析与格式问题多容易,链路清晰难,步骤多且状态复杂
对模型要求任意文本模型需支持 Function Calling需支持且上下文要够长
生产推荐度低,仅兜底或测试用高,日常任务首选中,需额外加防护与成本控制
适用场景工具少、参数简单大多数生产系统复杂多步任务与 Agent 编排

这张表里的核心结论是:三者在面试中不是“谁替代谁”的关系,而是“复杂度逐层递进”的关系。从方案一到方案三,你付出的工程代价变大,但模型的能力边界也在同步扩展。

5.2 面试官说“再优化一下”时的加分点

写到这里,面试官一般会继续追问。我把现场被追问的高频问题及我认为的加分回答整理一下:

第一,“工具调用参数怎么校验?”加分回答:不只依赖模型生成的参数,在dispatch_tool里还要做一层运行时校验,比如参数类型、必填项、枚举范围。模型可能按照 schema 生成参数,但 schema 校验和运行时校验是两个层面的事情,生产环境两层都要有。

第二,“如果多个工具返回结果冲突怎么办?”加分回答:把多工具结果都交给模型,让模型基于附加事实做一致性判断,同时可以在工具消息里标注数据来源时间和置信度,帮助模型决策。

第三,“这个循环会不会很费 token?”加分回答:会,所以需要三步优化。一是限制最大步数;二是裁剪 Observation 内容;三是做子任务拆解,不必要的信息不要全部灌给模型。

第四,“自定义工具和内置工具有什么区别?”加分回答:内置工具属于基础能力,自定义工具属于领域能力。前者做通用动作,后者才真正体现业务价值。自定义工具要额外关注权限安全和参数脱敏。

5.3 生产落地时的工程注意点

这三个方案在面试白板上写写没问题,但真要在生产环境跑起来,还有一堆与代码无关的事要处理。从我自己踩坑的经验来看,至少要注意这四件事:

第一,工具调用要有日志审计。谁在什么时间调了什么工具、传了什么参数、拿到了什么结果,全都要有记录。因为模型可能被诱导去调一个高权限工具,没有审计日志,出事你无从追溯。

第二,权限控制不能都交给模型。工具注册表里要明确标注每个工具需要的权限级别,执行前在代码层校验,不能让“模型输出一个 tool_calls”就直接执行管理员操作。

第三,超时与并发要提前设计。工具执行可能很慢,AI 应用需要给用户“打字机式”的反馈,不能让用户干等。我的做法是异步执行工具,同时把中间状态流式返回给前端。

第四,prompt 层面的防护不能省略。模型可能被用户的恶意输入诱导,让它调用某个工具去做不在预期范围内的事情。所以在工具描述里要加“仅当用户请求与工具职责匹配时才可调用”这类约束,同时在系统提示词里禁止工具越权。

6. 常见问题与排查实录

6.1 解析 JSON 失败怎么办

这是方案一遇到最多的坑。模型输出的 JSON 可能前面带解释、后面带标点、内部混进去多余逗号。我总结了一套分级处理策略:首先尝试json.loads直接解析;失败则截取第一个“{”到最后一个“}”;再失败尝试补全缺失的引号或括号;还不行就提示模型重新输出。

最关键的一条经验是:千万不要让解析失败把整个流程中断。最好在解析函数里返回一个明确的错误状态,走重试分支。方案二因为走的是结构化字段,基本不会遇到这种问题,这也是我推荐生产环境上方案二的重要原因。

6.2 模型不听指令怎么办

“我明明在系统提示词里写了必须输出 JSON,它还给我抒情。”这种问题本质是模型指令遵循能力不足,或者你的提示词不够强。解决办法是加 few-shot 示例。在系统提示词里放两三个“用户输入 → 工具调用 JSON”的示例,模型会模仿模式输出,成功率提升非常明显。

另一个技巧是把“必须”改成“只允许”。我写提示词时习惯用“你只允许输出 ……”这种句式,配合示例,比“你可以……”有效得多。对于 Function Calling 方案,模型不太存在这个问题,但如果模型生成的参数明显不符合 schema,请检查你的 schema 描述是否写得足够具体。

6.3 工具执行报错会不会中断循环

最错误的写法是在dispatch_tool里直接抛异常。你在面试时如果说“工具报错就抛异常让上层的调用方处理”,面试官大概率会觉得你没理解 Agent 的容错机制。

正确做法是把异常 catch 住,把错误信息反向传回给模型,让模型决定下一步。比如工具query_weather因为城市名参数非法而报错,模型看到{"error": "未找到城市 北京x"}后,会自己修正成正确城市,或者向用户说明需要补充信息。这个“错误即观察”的设计是方案三的灵魂。

6.4 多轮对话中工具上下文怎么管理

随着对话轮数增加,上下文里会堆积大量工具返回内容。我在实际项目中遇到的最大教训是:工具返回的长文本列表(比如商品列表、订单明细)会严重挤占模型上下文窗口,导致后面的推理质量断崖式下降。

我的解决方案有三个层次。第一,在工具返回前做内容裁剪,只返回关键字段和摘要。第二,设置上下文滚动窗口,只保留最近 3 到 5 轮的工具观察结果。第三,把最终用户需要展示的完整结果放在一个独立的返回通道里,不混进模型上下文。这三层组合,基本能在成本和效果之间找到一个稳妥的平衡点。

现在回看整道题,我越来越觉得面试官出“手写 Tool Use”这道题,要考察的其实不是某个具体 API,而是你在一个开放问题前能不能拆清边界:哪些交给自己实现,哪些交给模型原生能力,哪些需要额外加防护。三种方案写下来,我自己对这些边界也清晰了很多。

最后分享一个我个人的体会:面试里写代码,最忌一上来就选看起来最复杂的方案。我现场是先快速确认模型能力边界,然后从方案一演进出方案二,再补充方案三去覆盖复杂场景,这样整个过程显得非常有逻辑。如果你在准备类似的面试,建议也按照这个思路把三种方案都在本地跑通一遍,尤其是把错误重试和循环控制的代码多写几遍,真到面试时候会从容很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询