☰
Function Calling与ReAct实战:用大模型构建能调用工具的AI Agent
2026/10/22 9:32:17 网站建设 项目流程

最近帮一位朋友调试一个“AI 日程助手”,他问了我一个很典型的问题:模型能直接帮我查天气、算时间、订会议室吗?我的回答是:只靠模型本身不行,模型只会生成文字,不会真的去点按钮。但如果你给模型配上“两只手”——Function Calling 和 ReAct,它不仅能查,还能在多个工具之间来回切换,像真人一样边想边做。

如果你最近在学大模型应用开发,这两个词一定绕不开。Function Calling 解决的是“模型如何把一句闲聊变成一次可执行的结构化指令”,ReAct 解决的是“模型如何在一个需要多步骤的任务里,自己决定下一步干什么”。这篇文章不是照抄官方文档,而是我自己实际做 Agent 时踩过坑之后总结的一份实操笔记。适合刚接触 AI 应用开发、想在项目里接入工具调用的读者,内容尽量保持“看完就能动手试”。

1. 先说结论:为什么“会聊天”的模型办不了实事

1.1 模型的本质是“文字接龙”,不是数据库,也不是计算器

如果你让模型回答“2 的 100 次方是多少”,它可能会凭借训练数据里的片段给你一个似乎合理的数字,然后你拿去一验证,发现错了。这不是模型笨,而是它的工作方式是预测下一个词,而不是真的执行数学运算。同样,你问它“此刻某城市的实时温度是多少”,训练数据里根本没有现在的信息,它只能编一个。

所以,想让模型具备“办实事”的能力,就需要把“获取信息”和“计算结果”这两件事从模型的语言生成里拆出去,交给真实的系统完成。系统返回什么,模型就基于什么继续回答。这就像你让一个实习生写报告,他不应该凭空捏造数据,而应该去数据库查,把查到的结果写进报告。

1.2 Function Calling 让模型“说出”它想调用的函数

Function Calling 不是一个让模型直接执行代码的机制,而是一个让模型“描述调用意图”的机制。我们在系统里预先定义好一批函数,比如get_weather(city)、add(a, b),然后把函数的名称、含义、参数规则用 JSON Schema 的形式告诉模型。当用户问“阳光城今天多少度”时,模型不再直接回答一个编造的温度,而是输出类似这样的结构化指令:

{ "name": "get_weather", "arguments": "{\"city\": \"阳光城\"}" }

然后你的代码接管这个指令,实际去天气服务商那边把温度查回来,把真实结果再交给模型,让模型组织语言回答用户。

这个设计的精髓是:模型负责“决策”,你的代码负责“执行”。各干各的,互不干扰。

1.3 ReAct 让模型学会“边想边做”

有了 Function Calling,模型可以做单次工具调用,但很多任务不是一步就结束的。例如用户问“阳光城和雨城今天温差有多大”,模型需要先查 A 城市的温度,再查 B 城市的温度,可能还需要调用一个计算器做减法,最后把结果描述出来。如果模型只能调一次函数,这个任务就卡住了。

ReAct 的全称是 Reasoning + Acting,它模仿人处理问题的过程:先思考(Thought),再行动(Action),然后观察结果(Observation),再继续思考。整个过程是一个循环:

  • Thought:用户想知道温差,我需要先拿到两个城市的温度
  • Action:调用get_weather(city=阳光城)
  • Observation:阳光城 28℃
  • Thought:还差雨城的温度
  • Action:调用get_weather(city=雨城)
  • Observation:雨城 18℃
  • Thought:温差是 10℃
  • Action:直接回答

这个过程听起来简单,但它恰好是当前所有主流 Agent 框架的底层骨架。把上面这个循环用代码写一遍,再套上 Function Calling 的结构化输出,就是一个五脏俱全的迷你智能体。

2. 原理拆解:模型、工具与推理循环是怎么协作的

2.1 Function Calling 的完整数据流

我把一次完整的 Function Calling 拆成五个环节,每一步缺一不可。

第一步,开发者定义工具。工具包括函数名、描述、参数结构。描述非常重要,模型是根据描述来决定要不要用这个工具的。第二步,把工具列表放进请求消息里发送给模型。第三步,模型判断用户问题是否需要调用工具,如果不需要就直接回答,如果需要就返回tool_calls字段。第四步,你的代码监听到tool_calls后,执行对应的函数,拿到真实结果。第五步,把结果以tool角色的消息追加进对话上下文,再发给模型,让模型基于结果生成最终回答。

这里最容易误解的点是:模型返回的arguments是一个字符串,不是直接可用的 Python 对象。你必须先json.loads解析,才能拿到真正的参数。另一个常见误解是:模型不会真的去执行函数,执行函数永远是你的业务代码。

下面用一段通用格式的代码描述这个过程(不同平台的协议大同小异,这里不依赖任何特定厂商 SDK):

import json import requests API_HOST = "https://api.example.com/v1/chat/completions" API_KEY = "在这里填你的密钥" def call_model(messages, tools): payload = { "model": "your-model-name", "messages": messages, "tools": tools, "temperature": 0, } headers = {"Content-Type": "application/json"} resp = requests.post(API_HOST, headers=headers, json=payload) return resp.json() def run_function(name, arguments): args = json.loads(arguments) if name == "get_weather": # 这里换成真实天气查询逻辑 return "28 摄氏度,晴天" if name == "calculate": # 这里换成真实计算逻辑 return str(eval(args["expression"]))

2.2 ReAct 循环里的三个关键角色

ReAct 循环看起来只是“多了一步”,但它背后涉及两个关键设计:状态维持和退出条件。

状态维持靠的是对话消息历史。每一轮工具调用的结果都被追加到messages里,模型在下一次请求时能看到完整的推理过程。这意味着消息列表就像模型的工作草稿纸,写满了它每一步的思考、动作和观察结果。如果某一步把消息历史截断了,模型就会“失忆”。

退出条件则是循环的终止逻辑。通常有两种退出:模型返回了普通的文本回答而不是工具调用,或者达到了我们设置的最大轮数。第二种情况尤其重要,没有最大轮数,模型可能会在一件简单的事情上反复调用同一个函数,白白消耗你的 API 配额。

用户问题 ↓ 模型:Thought/Action(工具调用) ↓ 执行工具 → Observation ↓ 把 Observation 加入上下文 ↓ 再次调用模型 ↓ 模型输出最终回答 → 结束

2.3 Function Calling 和 ReAct 为什么必须搭配

如果只有 Function Calling,模型能调用工具,但缺少“根据观察结果继续推理”的循环,遇到多步任务就断掉了。如果只有 ReAct,模型确实会循环思考,但它的“行动”只能靠生成一段文本动作,你还需要用正则表达式去解析,格式稍微一变就崩。两者组合后,ReAct 负责循环和推理框架,Function Calling 负责提供稳定、规范的工具调用接口。一个管大脑,一个管手脚。

可以说,Function Calling 更像是 ReAct 这个循环里“Action”环节的实现方式,而 ReAct 则是让 Function Calling 能在复杂任务里持续发挥作用的“指挥棒”。理解这点,你就理解了目前大部分 Agent 产品背后的基本逻辑。

3. 从零实现一个 ReAct + Function Calling 的迷你智能体

3.1 环境准备与整体设计

后面我会带你把上面这个循环写成能跑的代码。请确保你的环境里有 Python 3.9 以上版本和requests库。如果你有自己的模型 API,直接把地址、密钥、模型名替换成你的;如果没有,先用本地模拟的方式跑通逻辑,重点理解流程。

我们先把整个程序分成三块:工具定义、工具执行、主循环。工具定义是给模型看的函数说明书,工具执行是实际干活的 Python 函数,主循环负责调度。这样拆分的好处是,将来你要加几十个工具,只需要往注册表里加一条记录,主循环代码完全不用改。

3.2 定义两个实战工具:天气查询和计算器

先体验驱动的例子做一个有点意思的任务:用户问“阳光城今天温度多少?雨城呢?两个地方温差多少?”这就需要三个能力:查阳光城天气、查雨城天气、计算温差。为了演示,我把“计算”也做成一个工具,而不是让模型直接心算,这也是很多生产系统的常见做法。

工具定义我用标准的 JSON Schema。需要注意:想让模型准确调用,必须在描述里写清楚每个参数的含义,甚至给一个示例值。模型不是人,它不会自己去猜“city”到底该填中文名还是英文名。

TOOLS = [ { "type": "function", "function": { "name": "get_weather", "description": "查询一个城市的当前温度,返回单位为摄氏度。城市名请输入中文,例如:阳光城", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名,例如:阳光城、雨城" } }, "required": ["city"] } } }, { "type": "function", "function": { "name": "calculate", "description": "执行两个数字的加减乘除四则运算。输入格式为表达式,例如:28-18", "parameters": { "type": "object", "properties": { "expression": { "type": "string", "description": "数学表达式,例如:28-18" } }, "required": ["expression"] } } } ]

接着写工具的执行函数。这里我只是做最小实现,真实项目中每个函数都应该有完整的异常处理和参数校验。

def execute_tool(name, arguments): args = json.loads(arguments) if name == "get_weather": # 实际项目中替换成天气服务 API 调用 fake_db = {"阳光城": "28", "雨城": "18", "多云城": "22"} return f"{args['city']}当前气温 {fake_db.get(args['city'], '未知')} 摄氏度" if name == "calculate": # 注意:生产环境禁用 eval,这里仅为演示 expr = args["expression"] try: return str(eval(expr)) except Exception as e: return f"计算失败:{e}" return "未找到工具"

3.3 主循环:让模型自己决定下一步调用谁

主循环是整个系统的核心。它的逻辑并不复杂:把用户消息发给模型,如果模型返回tool_calls,就先执行工具,把结果追加回消息列表,然后继续循环;如果模型返回普通文本,就把它作为最终答案输出。

这里的代码我故意保留了几个关键字段,方便你对照模型 API 的实际返回值。不同平台的字段名会有差别,但结构基本一致。

def run_agent(user_input): messages = [{"role": "user", "content": user_input}] max_steps = 6 step = 0 while step < max_steps: response = call_model(messages, TOOLS) message = response["choices"][0]["message"] if message.get("tool_calls"): # 记录这次包含工具调用的消息,保持上下文完整 messages.append(message) for tool_call in message["tool_calls"]: function = tool_call["function"] print(f"[代理执行] 调用 {function['name']}({function['arguments']})") result = execute_tool(function["name"], function["arguments"]) # 工具结果用 tool 角色返回,并用 tool_call_id 关联 messages.append({ "role": "tool", "tool_call_id": tool_call["id"], "content": result }) step += 1 continue # 没有 tool_calls 说明模型准备直接回复了 print("[代理回答]", message["content"]) return message["content"] print("[达到最大迭代次数,强制终止]") return "处理超时,请稍后再试或简化任务" run_agent("阳光城今天多少度?雨城呢?两座城市的温差是多少?")

这段代码执行的时候,你会看到类似下面的日志:

  • 调用 get_weather(city=阳光城)
  • 调用 get_weather(city=雨城)
  • 调用 calculate(expression=28-18)
  • 最终回答:阳光城 28 度,雨城 18 度,温差 10 度。

这就是一次完整的 ReAct 循环。模型的思考过程完全依赖消息历史,所以你不需要自己维护什么状态机,只要按顺序把内容追加进messages就可以了。

3.4 关键参数和生产级优化细节

循环跑通之后,有几个参数值得你反复调。

温度(temperature):建议调成 0。工具调用需要确定性,如果温度太高,模型可能一会儿想用计算器,一会儿想用天气接口,回答不稳定。我见过因温度调到 1,模型在一个简单任务里选了完全错误的工具。

最大迭代次数:我一般初版设 5,跑通之后再按实际需要调整。迭代次数设得越多,API 费用越高,响应时间越长。一个保险做法是把每次工具调用的日志都打印出来,手动观察这个任务实际需要几步,再留 1 到 2 次余量。

上下文长度:每次工具结果都会留在消息列表里,任务越长上下文越大。对于大型结果,例如文件内容或长文本,千万不能原样塞回去,否则很快会顶到模型的上下文上限。我做了一个简单的安全函数,把超过 500 字符的内容截断到 500 字符并且提示“内容过长,已截断”。生产项目建议用摘要或检索来做。

4. 实操中的五个常见坑,以及我的排查方案

4.1 模型总是编造不存在的参数

这是我在工具接入第二天就遇到的问题。我定义了一个查询日期的工具,要求传入start_date和end_date,格式必须是YYYY-MM-DD。模型在用户只说了“看一下本周的数据”时,自己把日期填成了上周一到上周日。用户看到结果后一脸问号。

排查思路是这样的:先看日志里模型填的参数是不是从用户话语里提取的,如果不是,多半是模型“脑补”的。解决办法有两个层面。第一层是在函数描述里写清楚:“如果用户没有明确指定日期,请先向用户询问日期,不要自行推断。” 第二层是在代码里做参数校验:解析完参数后,如果发现关键字段缺失或格式不对,直接返回一个错误给模型,比如“缺少必要参数 start_date,请向用户确认”。模型看到这个错误后通常会纠正自己的行为,补一句“请您提供起始日期”。

4.2 工具返回内容太大,直接把上下文撑爆

有一次我让一个文档分析工具把整篇 PDF 的内容返回给模型,结果第一次调用后消息列表里就多了几十万字符,后续请求直接报“上下文长度超限”。这个问题不只在本地模型上有,用商业模型也一样。

我后来定了一条规矩:工具返回给模型的结果,必须控制在模型可以消化的大小内。如果是文档,先做分段摘要,只给结论;如果是数据库查询,先做聚合统计;如果必须返回原始内容,就按关键字抽取相关片段。宁可多调用几次模型做二次整理,也不要一次性塞入超长文本。你可以在工具执行函数里加一个结果后处理层,让每个工具都支持“精简模式”。

4.3 死循环:模型反复调用同一个函数

最让人崩溃的是模型在同一个函数上调了一遍又一遍,每次都拿着一样的参数,结果也一样。原因通常是模型觉得这个结果不是它想要的,但又不知道该换什么方法。例如它调用计算器计算表达式后,发现结果和预期不一致,于是再算一遍,无限循环。

我的对策是三重保险。第一,在函数返回结果里加入提示,例如“这是你已经查询过的最新结果,无需重复查询”。第二,在代码里检测重复的工具调用:如果同一个函数名和参数组合出现了两次,直接返回“该信息已获取,结果是 XXX,请不要重复调用此函数”。第三,设置最大迭代次数,运行到上限就强制终掉并给用户一个友好提示。三层叠加之后,死循环基本被消灭了。

4.4 工具并发调用与依赖关系处理

有些模型支持一次返回多个工具调用,比如同时查两个城市的天气。这就带来一个选择:要不要把所有工具并行执行?如果工具之间没有依赖关系,并行执行确实能节省时间。但如果有依赖,比如我想先查询用户身份,再根据身份查专属数据,第二工具依赖第一个工具的结果,这就不能并行。

我的处理原则是:无依赖的并发,有依赖的串行。具体到 ReAct 主循环里,如果一次返回了多个tool_calls,可以先判断依赖关系。判断方式很简单:前一个工具的结果是否是后一个工具参数的来源?如果是,就把这批拆成两次循环,第一次只执行前一个,拿到结果后再走下一轮;如果不是,可以并行执行。初学阶段最稳妥的做法是先串行执行所有工具,把结果依次追加到消息里,稳定性优先。

4.5 权限和安全:工具不是越全越好

在一次内部测试中,我给模型加了一个“删除用户数据”的工具,结果模型在用户说“帮我清理一下测试数据”时,真的给删了。虽然数据能恢复,但这事给我敲了一记警钟:模型没有“意识”,它分不清哪些操作是低风险,哪些是高风险的。

安全设计必须前置。我的建议是给工具分级:只读操作如查询天气、搜索文档,可以让模型自主调用;写操作如发送邮件、修改配置,必须经过用户确认;危险操作如删除数据、转账、下订单,应该直接禁止由模型自动执行,或者接入一个人工审批环节。实现上并不复杂,在execute_tool里加一个risk_level字段,执行前判断是否需要暂停等待用户确认。这个机制在我的所有项目里都保留了。

5. 把原型变成生产可用:工具注册表与可观测性

5.1 用一个注册表代替手工 if-else

前面代码里的execute_tool全靠一个个if判断,如果工具数量超过 10 个,这段代码就会变得又臭又长。更优雅的做法是设计一个统一的工具注册表,用装饰器把函数自动注册进去,同时根据函数签名生成 JSON Schema。

TOOL_REGISTRY = {} def register_tool(schema): def decorator(fn): TOOL_REGISTRY[fn.__name__] = (schema, fn) return fn return decorator @register_tool({ "type": "function", "function": { "name": "get_weather", "description": "查询一个城市的当前温度", "parameters": { "type": "object", "properties": { "city": {"type": "string"} }, "required": ["city"] } } }) def get_weather(city: str): return f"{city} 28 摄氏度"

这样以后新增工具时,只需要定义函数并写清楚 Schema,注册表会自动收集所有工具列表传给模型,execute_tool也能通过工具名找到对应的执行函数。我建议每个工具函数内都写清晰类型注解,因为后续你想让代码根据类型注解自动生成 Schema 时,这会是巨大的便利。

5.2 做一层工具结果缓存,省钱又提速

同一个模型应用,很可能在短时间内被问到相似问题。比如早晨多个用户问同一座城市的天气,如果每次都调用实时天气 API,费用和延迟都会白白浪费。我会在系统里加一层简单缓存,以“函数名 + 参数哈希”作为 key,缓存有效期按工具类型区分:天气可以缓存 10 分钟,计算器可以缓存永久,行情类数据可能只能缓存 60 秒。

缓存层还能解决一部分重复调用问题。当模型在循环里反复查同一个城市时,第二次直接就命中缓存,速度更快,也不会触发外部接口限流。需要注意:缓存一定要设置过期时间,我之前天真地给股票行情做了缓存,结果用户看到的是半小时前的价格,差点被投诉。

5.3 高风险操作回退到人工确认

生产环境里,模型自动决策必须谨慎。我建议在设计工具时就把“人”纳入流程,比如发送邮件、修改订单、删除文件等操作,可以让模型生成一个“指令草稿”,然后以按钮或卡片的形式推送给用户确认,用户点了“确认”之后执行函数。

实现方式并不需要改变 ReAct 主循环太复杂,只需在execute_tool返回结果之前做一次“确认标记”。例如工具返回内容为“等待用户确认:确认要删除订单 12345 吗?”,然后把这条消息作为观察结果返回给模型,让模型在下一轮向用户确认。这样做虽然增加了一轮交互,但换来了安全和信任。

5.4 可观测性:把每一步推理痕迹记下来

没有日志的 Agent 就像没有黑匣子的飞机,出了问题根本不知道模型到底怎么想的。我的经验是,上线后的第一个版本必须包含详细的日志系统,至少记录下面这些内容:

  • 每轮请求的原始消息条数和字符数
  • 模型返回的tool_calls原始 JSON
  • 工具执行的函数名、输入参数、返回结果、耗时
  • 最终回答内容和总调用步数

我会把日志打成 JSON Line 格式,方便后续用日志分析工具去统计。有了这些日志,分析“模型为什么调用这个工具”就不再是玄学,而是一个可以回放的视频。调试 Agent 时要记住,模型是一个黑盒,你唯一能控制的是输入和观察输出,日志就是你的眼睛。

6. 学习路径建议:从第一行工具代码开始

6.1 先做一个“给模型加个计算器”的小项目

很多读者一上来就照着框架写复杂 Agent,结果还没跑通就死在依赖安装和环境配置上。我的建议相反:先做你能想到的最简单工具增强。比如用本文的代码,只定义一个calculate工具,让模型帮你算“(123+456) * 789”。等这个跑通了,再去加第二个、第三个工具。

这个过程会帮你建立最核心的“上下文感”:你能亲眼看到模型返回工具调用、你的代码执行工具、结果还原回上下文、模型基于结果回答。这个基本流程理解了,其他所有 Agent 框架对你来说都只是换了一种包装。

6.2 把模型每一次输出日志当学习材料

学习 Function Calling 和 ReAct,最好的教材是自己跑出来的运行日志。我会建议你每跑一个任务,都回去看一遍消息列表:模型是怎么理解工具描述的,它生成了什么参数,如果出错错在哪一步。很多模型的行为规律,只有在这个粒度才能看清楚。

比如你会发某些模型总是忽略工具描述里的“请先询问用户”,那么你就知道要在系统提示词里反复强调。你会发模型在参数里填了奇怪的格式,那么你就会知道要在 JSON Schema 里写示例。这些都是文档里学不到的实战经验,只能靠日志积累。

6.3 先跑通再选框架,不要本末倒置

现在市面上的 Agent 开发框架有很多,每个框架都有自己的概念和抽象。如果基础不牢,直接上框架,你很容易被“工具节点”“计划器”“反思层”这些概念绕晕。我的经验是,先用原生代码把 ReAct 循环从 0 写一遍,再去看框架,你会瞬间明白框架里每个模块正在做什么。之后再决定要不要用框架、用哪个,就有底气多了。

另外,模型的选择也很关键。支持 Function Calling 的模型不一定都支持同一套参数格式,有的返回命名不同,有的不支持并行调用。我的建议是最初就用一个你最顺手、文档最全的模型把主循环调通,等理解透了再切换模型或平台。这样你学的是通用能力,而不是绑定在某一家产品的版本细节上。

我在实际项目里已经很少从零手写 ReAct 循环了,但每次遇到奇怪的调用问题,我都还会回去看最核心的消息列表和日志。因为不管框架怎么变,底层的“思考-行动-观察”循环始终没变,Function Calling 的“模型决策,代码执行”边界也始终没变。把这两点刻在脑子里,你后续学任何 Agent 技术都会顺很多。如果这篇文章帮你跑通了第一个工具,那就已经值回你花在这里的时间了。

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

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

立即咨询