☰
手写Tool Use调用流程:从Function Calling到ReAct与JSON Mode的三种实现
2026/10/14 7:48:13 网站建设 项目流程

面试官让我手写一个 Tool Use,我用了 3 种方案

上个月面一个 AI 应用岗位,聊完项目经历后,面试官直接在在线编辑器里甩了一道题:“现在给你一个工具函数get_weather(city, date),你手写一个 Tool Use 调用流程,让模型根据用户问题自动决定要不要调它、怎么调、调完怎么把结果还给用户。”

说实话,听到这个题我反而松了口气。Tool Use 这个词在 LLM 应用开发里已经是标配能力了,从智能客服、数据分析助手,到自动化业务流程编排,几乎每个生产级项目都绕不开它。但“手写”和“在项目里封了一层 SDK”完全是两码事,尤其你平时都是调现成框架的情况下,很容易写出一个能跑但经不起追问的版本。

这道题表面是考代码,实际考的是三件事:第一,你懂不懂“意图识别—参数提取—工具执行—结果回填”这条完整链路;第二,你清不清楚不同的实现方案各自依赖什么前提;第三,你能不能根据模型能力和工程约束做出取舍。我把这三个层面都写下来,方案也整理成可以直接抄的代码,希望给正在准备 Agent 方向面试、或者正准备从零搭一套工具调用框架的朋友一点参考。

1. 这道题到底在考什么

1.1 Tool Use 解决的核心问题

在没有 Tool Use 之前,LLM 只是一个“文字接龙机器”。你问“明天北京适合出门吗”,它能回答,但它的回答完全来自训练数据里的常识,它并不知道明天的真实天气。你让它“把 10 月对账单发到邮箱”,它能听懂,但它没有能力登录账务系统去拉数据。

Tool Use 要解决的就是这个缺口:让模型在推理过程中,识别出“我需要调用某个外部能力”,并且把用户自然语言里的关键信息转换成这个外部能力需要的结构化参数。模型本身不执行任何真实操作,它只负责“决定调什么、参数是什么”,真正的动作由你写的代码去完成。

这个分工非常重要。很多新手会误以为 Tool Use 是模型自己学会了操作工具,实际上模型只是产出了一份“调用意图声明”,执行权永远在你的程序手里。理解这一点,后面设计权限控制、错误恢复、多轮追问就有了基准。

1.2 完整的调用链路拆解

不管用哪种方案实现,Tool Use 的完整链路都逃不开下面这五个环节:

  • 工具注册:把散落的函数整理成一份模型能看懂的“能力清单”,包括名字、功能描述、参数说明。
  • 意图识别:拿到用户输入后,让模型判断是否需要调用工具,以及调用哪个。
  • 参数提取:把用户输入里的信息映射到工具参数上,比如“明天北京”映射成{"city": "北京", "date": "2025-..."}。
  • 工具执行:在你的代码环境里真实运行函数,拿到返回值。
  • 结果回填:把执行结果以模型能理解的形式塞回上下文,让模型基于真实数据生成最终回复。

面试手写的核心,就是把 2 到 5 这四步串起来。第一步虽然不起眼,但恰恰是很多人会忽略的,没有工具清单,模型根本不知道它能调什么。我见过不少候选人上来就调 API,却不说清楚 tools 参数怎么构建,这其实已经漏掉了半个考点。

2. 方案一:走官方 Function Calling 协议

2.1 工具定义与请求构建

第一种方案最直接,就是利用当前主流大模型 API 自带的 Function Calling 能力。你在请求里显式声明有哪些工具,模型在需要时返回一个结构化的tool_calls字段,而不是把工具调用混在自然语言里。

先看工具定义。假设我们有这样一个函数:

def get_weather(city: str, date: str) -> dict: # 这里假装会去查天气服务 return {"city": city, "date": date, "weather": "晴天", "temperature": 22}

要把它交给模型,得转成 API 认识的 JSON Schema 形式:

tools = [ { "type": "function", "function": { "name": "get_weather", "description": "查询某个城市在指定日期的天气情况", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称,如北京、上海" }, "date": { "type": "string", "description": "日期,格式为 YYYY-MM-DD" } }, "required": ["city", "date"] } } } ]

关键点在于 description 要写得足够清楚。这不是给人看的注释,而是给模型看的“使用说明”。模型判断该不该用这个工具,很大程度依赖你对“什么时候调用它”的描述。比如这里我写“查询天气”,模型可能犹豫;如果我补一句“当用户询问未来天气或出行建议时调用”,模型决策就会更果断。

然后发起第一轮请求:

response = client.chat.completions.create( model="model-name", messages=[{"role": "user", "content": "明天北京适合出门跑步吗"}], tools=tools, tool_choice="auto", temperature=0 )

注意这里 temperature 我直接设成 0。工具调用场景里我们希望模型尽可能稳定、少发挥,温度太高会让模型偶尔把参数名改得乱七八糟。

2.2 解析 tool_calls 并执行工具

如果模型判断需要调用工具,返回的消息里会出现tool_calls字段。我们的代码要识别它,执行工具,再把结果以role="tool"的消息回传。这是一个循环,模型输出工具调用 -> 代码执行 -> 结果回填 -> 模型再输出最终答案,直到模型不再产生新的 tool_calls。

def run_tool_call_loop(user_input: str): messages = [{"role": "user", "content": user_input}] while True: response = client.chat.completions.create( model="model-name", messages=messages, tools=tools, tool_choice="auto", temperature=0 ) msg = response.choices[0].message if not msg.tool_calls: return msg.content # 模型不再需要工具,返回最终回答 # 把模型的带工具调用消息追加进历史 messages.append(msg) for tool_call in msg.tool_calls: if tool_call.function.name == "get_weather": args = json.loads(tool_call.function.arguments) result = get_weather(**args) # 回填工具执行结果,注意要带上 tool_call_id messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": json.dumps(result, ensure_ascii=False) })

这整套逻辑的核心就一句话:每一轮必须把工具调用的请求和结果都放进 messages 历史,模型才能基于真实结果继续思考。如果把工具调用这一轮丢掉,模型就会失忆,你问它调用结果怎么样,它只能瞎编。

2.3 这个方案的优势和隐藏限制

方案一的优点非常明显:可靠性高,解析成本低,几乎不用自己做 Prompt 格式缝合,模型返回的 JSON 参数也是结构化校验过的。生产环境里只要用的是支持 Function Calling 的模型,我都优先选它。

但面试时千万别只说优点。这个方案有一个实际限制:它强依赖 API 底层的特殊训练。你如果面对的是一个纯文本模型、开源小模型,或者通过某些网关把请求转成了纯文本协议,Function Calling 就失效了。另外,不同厂商对工具调用的实现细节有差异,有的要求角色名必须是tool,有的对 tools 参数里有additionalProperties会报错,迁移代码时不注意很容易踩坑。

我在一个实际项目里就遇到过:从某家 API 换到另一家后,同样的 tools 结构对方直接返回 400,排查半天发现是多了"additionalProperties": True这个字段。所以方案一虽然好用,但你得对它的“边界”有预期。

3. 方案二:ReAct 模式 Prompt 驱动

3.1 不让 API 做特殊处理,让模型自己“想”

第二种方案是经典的 ReAct 模式。它的思路是:不依赖 API 的原生工具调用能力,而是把工具调用“翻译”成一个文本格式约定,通过 Prompt 诱导模型自己输出“我要调用哪个工具、参数是什么”,代码再用解析器从文本里提取信息去执行。

通俗点说,方案一像学生交了标准答题卡,机器扫描就能读取;方案二像是让学生按规定的段落结构写作文,批改人用自己的规则去判卷。API 哪有什么标准答题卡,但我们可以让模型按“Thought / Action / Action Input / Observation”这样的格式来思考。

先定义一个工具注册表:

TOOL_REGISTRY = { "get_weather": { "func": get_weather, "description": "查询天气,参数为 city: 城市, date: 日期" } }

再写系统 Prompt:

你是一个能调用工具的助手。当你需要查询信息时,严格按以下格式输出: Thought: 描述你的思考过程 Action: get_weather Action Input: {"city": "北京", "date": "明天"} 然后你会收到 Observation 结果,基于它继续回答。 如果不需要工具,直接输出最终答案。

注意这里的“Action Input”我要求模型输出 JSON 字符串。这是最常见也最容易被写崩的格式,因为 JSON 里会包含双引号,而模型是在生成文本,非常容易把引号转义搞乱。

3.2 正则解析与多轮循环实现

拿到模型文本后,代码要做两件事:判断有没有 Action,以及提取 Action 和 Action Input。

import re import json def parse_action(text: str): action_match = re.search(r"Action:\s*(\w+)", text) input_match = re.search(r"Action Input:\s*(\{.*?\})", text, re.DOTALL) if not action_match or not input_match: return None, None action = action_match.group(1) try: args = json.loads(input_match.group(1)) except json.JSONDecodeError: return None, None return action, args def run_react_loop(user_input: str, max_steps: int = 5): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input} ] for _ in range(max_steps): response = client.chat.completions.create( model="model-name", messages=messages, temperature=0 ) content = response.choices[0].message.content action, args = parse_action(content) if action is None: return content # 没有解析到 Action,认为模型给出了最终回答 # 在注册表里查找并执行 tool = TOOL_REGISTRY.get(action) if tool is None: observation = "错误:未找到该工具" else: try: observation = tool["func"](**args) except Exception as e: observation = f"错误:{e}" messages.append({"role": "assistant", "content": content}) messages.append({"role": "user", "content": f"Observation: {observation},请继续"}) return "超过最大轮数,已停止"

这里有个细节:回传 Observation 时我用了role="user",而不是专门的系统消息,主要因为很多模型对 system 和 user 的权重感知不同,用 user 消息让模型更重视这个观察结果。这算是 ReAct 模式下一个小 tricks。

3.3 解析器的脆弱性和应对思路

方案二的优势是通用:只要模型能遵循指令输出文本,就能跑起来,不依赖厂商的特殊 API。缺点是文本解析特别脆弱,主要体现在三处:

  • Action Input 里的 JSON 如果跨行,正则.默认不匹配换行,需要re.DOTALL。
  • 模型偶尔会在 JSON 前后加 markdown 代码块标记,比如```json,那\{.*?\}也匹配不到。
  • 模型可能把参数键名写错,比如把city改成城市,**args执行时直接 TypeError。

我在实际测试里发现,小参数量模型(比如 7B 级别)用 ReAct 时,格式遵守率可能只有六成左右,大量轮次浪费在修复格式上。因此这个方案最合适的场景是:你只能用本地纯文本模型、或者需要通过中间层网关把各类模型统一成纯文本协议时。面试时你可以主动提一句“这个方案需要加一层格式纠错器”,会显得你真正做过工程化落地。

一种实用的纠错思路是:把解析失败的原始文本直接丢回模型,让它自己观察哪一步格式错了,再输出一遍。相当于用模型的自我修正能力弥补解析器的不完美。代价是多一次请求、多几百个 token,但在纯文本模型场景下仍然划算。

4. 方案三:结构化输出 JSON Mode

4.1 强制模型输出 JSON,再做分发

第三种方案算是一种折中。它既不像 Function Calling 那样依赖专用协议,也不像 ReAct 那样从自由文本里费劲解析,而是利用模型对“JSON 格式约束”的遵循能力,让模型直接输出一个统一结构的 JSON,由你的代码执行分发。

很多主流 API 都支持response_format参数,你告诉模型“这次的输出必须是合法 JSON”,模型就会尽力把所有回复包装成 JSON 对象。

先设计一个分发契约:

# 所有工具调用的统一返回结构 """ { "action": "get_weather", "arguments": {"city": "北京", "date": "2025-06-01"}, "thought": "用户想了解天气情况,我可以调用天气工具" } """

System Prompt 可以这样写:

你是一个严格输出 JSON 的助手。当用户的问题需要调用工具时,输出如下结构: {"action": "工具名", "arguments": {参数对象}, "thought": "简短思考"} 不需要工具时,输出: {"action": "none", "arguments": {}, "answer": "最终回答"} 必须输出合法 JSON,不要掺杂其他文本。

然后请求里加上格式约束:

response = client.chat.completions.create( model="model-name", messages=messages, response_format={"type": "json_object"}, temperature=0 )

拿到响应后,先json.loads整个字符串,再校验action字段是否在注册表里:

import json def dispatch_action(response_text: str): try: data = json.loads(response_text) except json.JSONDecodeError: return {"error": "模型输出不是合法 JSON"} action = data.get("action") if action == "none": return {"answer": data.get("answer", "")} if action not in TOOL_REGISTRY: return {"error": f"未注册的工具: {action}"} tool = TOOL_REGISTRY[action]["func"] args = data.get("arguments", {}) # 对参数做一层兜底:只传注册表允许的 key,避免注入非法参数 allowed_keys = set(inspect.signature(tool).parameters.keys()) clean_args = {k: v for k, v in args.items() if k in allowed_keys} result = tool(**clean_args) return {"observation": result}

4.2 把 JSON 模式改造成可循环的 Agent

这个方案同样能循环。模型第一轮输出{"action": "get_weather", ...},代码执行完以后,把结果转成字符串,作为Observation放回 messages,再让模型决定是继续调用下一个工具还是给出最终 answer。

我在实际项目里做过类似实现,加了一个最大循环数限制,一般是 3 到 5 轮,防止模型陷入“重复调用同一个工具”的死循环:

for step in range(MAX_STEPS): response = client.chat.completions.create( model="model-name", messages=messages, response_format={"type": "json_object"}, temperature=0 ) parsed = dispatch_action(response.choices[0].message.content) if "answer" in parsed: return parsed["answer"], step + 1 observation = parsed.get("observation") or parsed.get("error") messages.append({"role": "user", "content": f"工具返回结果: {observation},请继续输出 JSON"})

4.3 方案三的定位和坑

JSON Mode 方案最大的好处是,它绕过了方案一“必须原生支持 Function Calling”的限制,又比方案二的自由文本解析稳定得多。很多开源小模型虽然 tool call 能力不行,但经过微调后输出 JSON 的能力尚可,这个方案能兼容更多模型。

不过这方案有几个很实际的坑。第一,response_format={"type": "json_object"}不是所有网关都支持,有些 API 把 json_object 和 json_schema 分成两种模式,写错参数名直接报错。第二,模型输出的大 JSON 很容易因为一个多余的逗号导致整个解析失败,只做纯json.loads不够,最好加一层“坏 JSON 修复”,例如用第三方容错解析库,或者去掉代码块围栏后再解析。第三,复杂工具参数嵌套时,模型输出 JSON 的准确性会明显下降,所以工具参数尽量扁平化,不要搞太深的嵌套结构。

我现在的习惯是:能用方案一就用方案一,拿不到原生 Tool Call 就用方案三做兜底,方案二只在纯文本小模型上没有 JSON 能力时才考虑。优先级排序很明确。

5. 三种方案横向对比与面试追问实录

5.1 一页纸看懂选型差异

对比维度方案一:Function Calling方案二:ReAct Prompt 解析方案三:JSON Mode
对模型的依赖依赖原生 tool call 训练只依赖文本遵循能力依赖 JSON 输出能力
实现成本最低,SDK 自带较高,需写解析器中等,需写分发器
参数提取可靠性最高,原生结构化最低,文本易崩中等,受 JSON 格式影响
模型兼容性差,部分模型不支持好,几乎所有模型都可以较好,多数模型能输出 JSON
多轮循环复杂度低,tool_call_id 自动配对高,Obs 需要手工拼接中,需维护 session 状态
典型场景生产级标准 API本地小模型 / 纯文本协议异构模型统一接入

选型逻辑其实很朴素:你的约束是什么,决定你选什么。如果完全掌控模型选型,且 API 稳定,方案一就是标准答案。如果模型是不可控的第三方,或者走内部网关无法保证 tool call 语义,方案三更稳。方案二通常作为“最后手段”,或者用在那种模型能力弱到连 JSON 都输出不好的边缘场景。

5.2 面试官真正追问的是这些

这道题我在面试现场被追问了好几个问题,这里整理最有价值的 5 个,基本覆盖了 Tool Use 的所有深水区。

  1. 模型一次想调用多个工具怎么办?

方案一的 API 允许一次返回多个 tool_calls,需要在循环里逐个执行,再逐个回填。方案二和三要想支持多工具并行,就得在 Prompt 里约定“一次输出多个 JSON 对象”或在 Action 里支持数组格式,解析复杂度会上升。

  1. 工具执行抛异常了,怎么处理?

核心原则是把异常转成模型能理解的文本,而不是让程序直接崩溃。把异常信息格式化成 Observation 回传,模型会基于错误信息调整参数或换工具。如果你直接raise异常,整个循环就断了。

  1. 工具数量上百了,全塞进 Prompt 不现实怎么办?

可以按命名空间分组,在意图识别阶段先分诊到某一组工具,再带着局部工具列表发起正式调用。或者按用户输入动态检索工具描述,用向量召回 Top N 个相关工具,这也是 Agent 系统里常见的双阶段检索思路。

  1. 调用危险工具时怎么控制权限?

模型只负责生成调用意图,实际执行前你的代码要做权限判断,比如是否允许该用户调这个工具、是否需要人工审批。工具权限应该放在你的执行层,而不是依赖模型自觉。

  1. 工具调用历史会不会拖垮上下文?

会。工具参数和结果都挺占 token,尤其结果是一个大 JSON 时。我的做法是截断工具结果,只保留关键摘要,比如天气查询只保留温度和天气,而不是把整个响应对象塞回去。超过轮数就强制结束,避免上下文无限膨胀。

面试官问到这里,基本能判断出来你不是只会调 SDK,而是真正理解工具调用系统的工程边界。如果你还能随口说出“我用装饰器实现过统一注册表”“我在工具执行层加过超时熔断”,效果会比只写一个if分发好很多。

6. 踩坑实录与调优心得

6.1 我实际踩过的几个坑

先整理一个避坑速查表,都是我真实遇到过、并且定位起来很耗时间的问题。

现象根本原因解决办法
模型反复调用同一个工具不停止缺少最大循环轮数限制加上 MAX_STEPS,推荐 3~5
工具结果被模型忽略Observation 没有拼进新一轮 messages确认每次循环都追加完整上下文
参数解析报错 TypeError模型把参数名改写或漏传 required 参数工具适配层做参数映射和缺省值填充
同一套 tools 在另一家 API 报 400各家对 JSON Schema 支持不一致去掉 optional 修饰字段、统一格式后重试
JSON Mode 下模型输出带代码块围栏模型对格式理解的残留行为解析前先剥离 ```json 标记
工具返回内容过大导致 token 超限未做结果裁剪返回前压缩、摘要敏感字段

最坑的一次经历是,我在某 AIOps 平台里接了几个运维工具,其中一个工具的返回结果是几百行的机器巡检日志。模型收到完整日志后,反而不知道该怎么回答用户,因为它被无关信息淹没了。后来我把工具返回改成只传“异常条数和最高风险等级”,效果立刻变好了。

这个教训可以上升到原则:工具返回值的信息密度,直接决定 Agent 的回答质量。你不要指望模型从一堆原始 JSON 里替你提取重点,你最好在工具函数里就把它提炼好。工具函数不只是“完成操作”的,它还承担了“为模型准备素材”的任务。

6.2 一个通用的工具注册骨架

无论你最后选哪种方案,工具注册这层建议抽出来统一写。我常用的实现是装饰器注册,把“工具名、描述、参数校验、权限标记、超时控制”都绑到一个函数上:

import functools import time TOOL_REGISTRY = {} def register_tool(name, description, timeout=5, need_confirm=False): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): # 统一超时保护 start = time.time() result = func(*args, **kwargs) cost = time.time() - start return {"result": result, "cost": cost} wrapper.metadata = { "name": name, "description": description, "timeout": timeout, "need_confirm": need_confirm, "func": func, } TOOL_REGISTRY[name] = wrapper return wrapper return decorator @register_tool("get_weather", "查询天气", need_confirm=False) def get_weather(city: str, date: str): return {"city": city, "date": date, "weather": "晴"}

这样设计的好处是,工具元数据和业务逻辑放在一起,注册表本身可以随时遍历生成给模型看的 tools 列表。以后不管切到哪个模型、哪种调用协议,只需要写一个“把注册表渲染成对应格式”的函数,业务代码一行都不用改。

6.3 面试手写时的加分细节

最后分享几个现场手写时很加分的小动作。先讲链路再写代码。面试官要的不只是一段能跑的代码,更想看到你有全局设计能力。动笔前先说清楚“我会按工具注册、意图识别、执行分发、结果回填四层来写”,这句话能立刻拉开你与其他候选人的差距。

写代码时注意局部变量不要污染全局。比如方案一的循环里,messages 必须在 while 外面初始化、在循环里逐轮 append,很多人写着写着就把历史记录重置了。工具名不要硬编码,尽量从注册表里取,哪怕现场只有两个工具,也体现出扩展意识。异常处理不要只print,要把错误信息回传模型,这是 Agent 鲁棒性的关键。

最后再提一个数据点。我在自己的几个模拟项目里测过这三种方案的稳定性:同一组包含 20 个工具调用测试用例,方案一的成功率大约 98%,方案三大约 91%,方案二只有 78% 左右,而且方案二的失败大多集中在参数解析环节。所以如果面试官最后问“你实际生产会选哪个”,你可以明确的回答“优先 Function Calling,它是原生语义;但我会把方案三作为 fallback 写进框架里,避免被单一厂商绑定”。这种答案既有技术判断,又有工程全局观,比单纯罗列三种方案要高一个层次。

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

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

立即咨询