最近被问得最多的问题,差不多可以浓缩成一句:"光有一个大模型(LLM)的 API,凭什么能做出一个会查资料、会算账、能自己安排执行步骤的 AI Agent?" 如果只是把问题直接丢给模型,它当然做不到——模型没有手、没有脚,也够不到那些没被训练过的实时数据。让这一切成立的核心,是我今天要讲的 ReAct 框架:让模型反复执行 Thought → Action → Observation 这个循环,再配合 Function Call 接口,把"Action"真正落地成一次可执行的函数调用。这篇文章不聊空泛的概念,我会带着你从零写一个基于 LLM + Function Call 的 Agent,从工具注册到循环控制,再到实测里最容易踩的坑,按照真实项目推进的顺序一步步来。
这套内容适合谁?如果你已经会调大模型 API,但还没想清楚"怎么让模型主动决定调用什么工具",或者你正准备在团队里搭一个能多步执行任务的 Agent,这篇文章正好对口。我会尽量让每一段都有代码、有理由、有避坑说明,哪怕你之前完全没写过 Agent,照着抄也能跑通一个最小可用版本。
1. ReAct 与 Function Call 的互补逻辑:模型如何从"回答者"变成"执行者"
1.1 大模型再聪明,也够不到它没拿过的数据
先说清楚一个很多人忽略的事实:LLM 本质是一个"根据上下文续写文本"的系统,它擅长的是知识记忆、语义理解、文字生成,但它有三个天然短板:
- 知识截止:它只记得训练时见过的内容,之后发生的事一概不知。
- 数据隔离:你的私有数据、后台数据库、实时行情、设备状态,它都拿不到。
- 动作缺失:它只能输出文字,不能真正调用一个接口、写入一条记录、触发一个流程。
所以过去很多 Demo 都停在"问答"这一层,用户问一句,模型答一句,答不上来就编。Agent 的破局思路,其实是给模型装上"外部器官":工具。模型负责思考"现在该做什么、该用哪个工具、参数怎么填",系统负责"真的把这个工具跑起来",再把结果喂回给模型,让它继续下一步。
这套"思考 + 行动 + 观察反馈"的循环,2022 年的一篇论文把它总结成了 ReAct——Reasoning and Acting。但论文提出的时候,模型的输出还是纯文本,工具调用要靠我们自己写正则去解析;真正让 ReAct 变得工程可用的,是后来大模型 API 普遍支持的 Function Call 协议。把这两个东西放一起看,你才会明白现在各种 AI Agent 的底层到底在干什么。
1.2 ReAct 三件套:Thought、Action、Observation
ReAct 的循环其实特别像人解决问题的过程:先想一下需要什么信息,然后去查、去算、去试,看到结果后判断够不够,不够就再来一轮。落实到 Agent 上就是三个固定动作:
- Thought:模型用自然语言描述"我现在遇到了什么问题,下一步需要获取什么信息"。
- Action:选择一个工具,给出调用参数,由系统执行。
- Observation:系统把工具执行的结果返回给模型,作为下一轮推测的依据。
我举个非常生活化的例子。假设我让 Agent 判断"明天出门要不要带伞",并且给它挂了一个天气查询工具。整个循环会长这样:
- Thought:用户想知道明天是否需要带伞,这需要明天的降雨信息。
- Action:调用
get_weather(city="用户所在城市", date="明天")。 - Observation:明天中雨,降水量 8mm。
- Thought:明天下雨,出门应该带伞。
- Final Answer:建议带伞。
注意,这里和平时我们说的思维链(Chain-of-Thought)不一样。思维链是让模型"闭着眼睛推理",答案完全来自模型内部知识;ReAct 是每走一步都睁开眼睛看外部反馈,拿到 Observation 之后再做下一步判断。也正是这种"循环 + 反馈",让 Agent 能处理那些模型靠记忆根本无法回答的问题,比如查实时数据、算复杂算式、操作外部系统。
1.3 为什么 Function Call 比"用嘴巴喊工具名"更可靠
ReAct 论文最初的实现方式,是让模型输出这样的文本:
Thought: 我需要知道今天的天气。 Action: Search[明天 xx 市天气] Observation: 明天下雨然后我们在代码里用正则匹配"Action:"这一行,提取工具名和参数,再去转发给对应的函数。这套方案今天仍然有意义,尤其是当你用的模型不支持 Function Call 时,它几乎是唯一的选择。但它的缺点实在太明显:
- 格式不稳定:模型偶尔会多打一个空格、少个括号,写成
Search ["xx"],正则直接匹配不到。 - 工具名幻觉:模型可能自创一个工具名,比如
SearchWeather,而我们注册的是get_weather。 - 解析成本高:用户问题、Thought、Action 全混在一起,解析逻辑会越写越复杂。
- Token 浪费:Action 那行算输出 token,解析失误还会多消耗几轮重试。
主流大模型 API 后来提供的 Function Call 协议,就是用结构化字段代替文本约定。模型该调用工具时,API 直接返回一个tool_calls数组,里面明确给出工具名、参数 JSON 字符串,不再需要我猜它要调用什么。
| 对比维度 | 文本式 ReAct | Function Call 式 Agent |
|---|---|---|
| 模型输出形式 | 自然语言混动作 | 结构化函数调用字段 |
| 工具参数解析 | 正则 + 规则,脆弱 | 协议直接给出 JSON |
| 错误典型 | 工具名拼写错误、括号不匹配 | 参数值幻觉、工具目标偏差 |
| 对模型要求 | 任意文本模型都能跑 | 需要模型支持 function call 协议 |
| 工程维护成本 | 解析规则越来越多 | 干净,依赖 SDK 协议一致 |
我的建议是:如果模型支持 Function Call,优先走 Function Call;如果只有纯文本模型可用,再退回文本式 ReAct 作为兜底方案。后者虽然糙,但关键时刻真的能救命——我在后面第 5 章会给出一个简单解析的兜底写法。
2. 先搭工具层:注册中心与 JSON Schema 的契约设计
2.1 最小 Agent 的四个组件
在写循环之前,先把整个 Agent 的边界画清楚。一个最小可用的 ReAct Agent,通常只需要四个组件:
- 模型客户端:负责把 messages 和 tools 发给大模型,拿回回复。
- 工具注册中心:维护所有可用工具的函数实现和 JSON Schema 描述,供模型感知、供系统调用。
- 循环控制器:执行"调模型 → 判断是否有工具调用 → 执行工具 → 回填 Observation → 再来一轮"。
- 上下文管理:维护 messages 列表,包含系统提示、用户问题、中间产生的 assistant 消息和 tool 消息。
很多新手一上来就去套重量级框架,结果被框架的抽象层层裹住,出了问题根本无从下手。我建议第一版自己写,几十行代码就能跑通,你对每一步都有完全的控制权,后面再考虑要不要换框架。
组件划分很重要的一点是:模型只负责"决定",系统只负责"执行"。模型永远不应该直接执行代码,系统也永远不应该替模型做判断。这个边界越清晰,Agent 越不容易失控。
2.2 工具描述就是给模型的说明书
Function Call 能否成功,一半取决于工具描述写得够不够好。模型的工具选择逻辑,本质上是在读你的 JSON Schema 说明,然后决定"该用哪个、参数填什么"。所以工具描述里每一项都别糊弄。
以get_today这个工具为例,我会这样定义它的 schema:
{ "type": "function", "function": { "name": "get_today", "description": "返回今天的日期,输出格式为 YYYY-MM-DD,例如 2026-03-01。如果用户问到'今天'、'现在'等时间概念,请调用这个工具。", "parameters": { "type": "object", "properties": {}, "required": [] } } }这里有几个新手很容易踩的细节:
- description 要写清楚"什么时候用"。最好带触发条件,比如"当用户提到今天时调用",模型命中率会高很多。
- parameters 的每个字段都要写类型,最好给出格式示例。模型填参数时会把 description 当成提示词来读,示例越具体,参数越容易填对。
- required 别漏。漏了
required,模型可能不填关键参数,你执行工具时就只能收到一个缺胳膊少腿的参数对象。
一句话总结:给模型写工具说明,就像给一个完全不懂业务的实习生写交接文档,越具体、越有例子,他越不会做错。
2.3 用注册器把函数变成协议
有了 schema,还要有配套的函数实现。为了不让"函数列表"和"函数实现"两处维护、两处漂移,我习惯用一个注册器把两者绑定在一起,用装饰器的方式声明工具:
import datetime import json import re class ToolRegistry: def __init__(self): self._funcs = {} self._schemas = [] def register(self, name, description, parameters): def decorator(func): self._funcs[name] = func self._schemas.append({ "type": "function", "function": { "name": name, "description": description, "parameters": parameters, }, }) return func return decorator def invoke(self, name, arguments: dict): if name not in self._funcs: raise ValueError(f"未注册的工具: {name}") return self._funcs[name](**arguments) @property def schemas(self): return self._schemas注册中心的核心价值是:你在定义一个业务函数的同时,就把"给模型看的说明书"和"给系统执行的真实函数"放在了一起,新增一个工具只要加一个装饰器,不用再去改循环逻辑。后面组 Agent 的时候,tools.schemas直接喂给大模型,tools.invoke直接执行模型提出的调用建议,两边统一。
3. 核心循环落地:Thought、Action、Observation 的逐行实现
3.1 识别模型返回中的 tool_calls
工具层就绪后,最关键的就是循环控制器。每轮循环我只需要做三件事:发请求、看返回、决定下一步。
以大模型 API 常见的返回结构为例,一个消息对象通常包含content和tool_calls两个字段。tool_calls为空,说明模型决定直接回答问题;tool_calls非空,说明模型想要调用工具。核心判断逻辑长这样:
response = llm_chat(messages, tools=tools.schemas) msg = response.message if not msg.tool_calls: # 模型没有调用工具的意图,这就是最终回答 return msg.content # 否则:模型想要调用一个或多个工具 for tool_call in msg.tool_calls: print("工具名:", tool_call.function.name) print("参数:", tool_call.function.arguments)这里不要犯一个低级错误:不要把模型的content当最终答复直接返回。在 Function Call 流程里,模型常常会在content里写"我需要先获取今天的日期",同时在tool_calls里附上真正的工具调用请求。只有tool_calls为空时,content才意味着"我已经有答案了"。
3.2 执行工具并回填 Observation
判断出模型要调用工具后,系统这边要执行真实函数,并把结果写回对话里,这一步就是 ReAct 里的 Observation。回填时有一个硬性要求:每条工具结果都要和对应的tool_call_id绑定,并且严格按照"assistant 请求调用 → tool 返回结果 → 再调模型"的顺序排列。顺序错了、ID 对不上,模型就会失去上下文关联,表现会突然变得非常弱智。
一个完整的回填逻辑如下:
def run_agent(user_question, max_steps=8): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_question}, ] for step in range(max_steps): response = llm_chat(messages, tools=tools.schemas) msg = response.message # 保留 assistant 消息(含 tool_calls) messages.append({ "role": "assistant", "content": msg.content or "", "tool_calls": msg.tool_calls, }) if not msg.tool_calls: return msg.content # 逐个执行工具调用 for tool_call in msg.tool_calls: name = tool_call.function.name try: args = json.loads(tool_call.function.arguments or "{}") result = tools.invoke(name, args) observation = json.dumps(result, ensure_ascii=False) except Exception as e: observation = f"工具调用异常: {type(e).__name__}: {e}" messages.append({ "role": "tool", "tool_call_id": tool_call.id, "content": observation, }) return "超出最大工具调用轮数,未能得到最终答案。"这段代码就是整个 Agent 的心脏。它把模型、工具、上下文串成了一个闭环:模型提出工具调用 → 我们执行 → Observation 写回 → 模型看到结果继续推理。每一步都在扩大上下文,直到模型认为自己拥有足够信息,不再发起工具调用。
3.3 什么时候该停:终止条件与轮数上限
Agent 的循环不能无限跑下去,否则一个 bug 就能烧掉大量的 token。终止条件至少要设计两个:
- 正常终止:模型没有返回
tool_calls,说明它认为可以回答用户了。 - 强制终止:达到
max_steps,强行停止,返回兜底文案。
max_steps建议根据任务复杂度来定,普通工具调用任务 5~8 轮足够。不要天真地以为模型会自动收敛,实际运行时你一定会遇到它反复调用同一个工具、或者一直在"再查一下"的尴尬场景。轮数上限是最后一道保险,更精细的防死循环手段我放在第 5 章讲。
4. 完整可跑示例:让 Agent 自己安排"查日期→算间隔→算存款"
4.1 准备三个实用工具
理论讲完,来一个能直接跑的完整示例。我准备三个工具:查今天日期、算两个日期之间的天数、做四则运算。这三个工具组合起来,正好能考一考 Agent 的多步规划能力。
@tools.register( "get_today", "返回今天的日期,输出格式为 YYYY-MM-DD,例如 2026-03-01。当用户提到今天、现在、当前日期时调用。", {"type": "object", "properties": {}, "required": []}, ) def get_today(): return datetime.date.today().isoformat() @tools.register( "days_between", "计算两个日期之间相差的天数,输入格式为 YYYY-MM-DD,返回整数天数。", { "type": "object", "properties": { "date1": {"type": "string", "description": "起始日期,格式 YYYY-MM-DD"}, "date2": {"type": "string", "description": "结束日期,格式 YYYY-MM-DD"}, }, "required": ["date1", "date2"], }, ) def days_between(date1, date2): d1 = datetime.date.fromisoformat(date1) d2 = datetime.date.fromisoformat(date2) return abs((d2 - d1).days) @tools.register( "calculator", "执行一个四则运算表达式并返回数值,只支持数字、加减乘除、括号和空格。例如 '30 * 5' 或 '(100 + 20) / 3'。", { "type": "object", "properties": { "expression": {"type": "string", "description": "数学表达式字符串"}, }, "required": ["expression"], }, ) def calculator(expression): # 先过滤掉不合法字符,降低直接求值的风险 safe = re.sub(r"[^\d+\-*/(). ]", "", expression) if safe != expression: raise ValueError("表达式包含非法字符") # 演示用 eval,生产环境请改用 ast 解析求值 return eval(safe)这里必须多说一句:eval在真实项目里是危险的,因为它直接执行任意代码。我在这里只是为了演示 ReAct 流程的简洁性,你自己落地产出时,请务必用ast模块解析表达式树,或者只允许白名单运算符。工具的安全边界,怎么强调都不过分。
4.2 组装完整 Agent 代码
有了注册中心和工具类之后,主角就是前面那段run_agent。把它整合起来,系统提示词也值得认真写,我通常会这样设定:
SYSTEM_PROMPT = """你是一个通过工具完成任务的助手。 - 当需要外部信息、日期计算或精确运算时,先调用对应工具,而不是凭记忆回答。 - 每次看到工具返回的 observation 后,判断信息是否足够;不够继续调用工具,足够则用自然语言回答。 - 不要编造工具返回结果,所有结论必须来自 observation 或用户提供的信息。 - 如果工具连续失败,停止尝试,并如实向用户说明遇到的问题。"""整体拼起来就是一个可运行的脚本:定义llm_chat(接你当前用的 SDK)、注册工具、写系统提示词,然后调用run_agent("今天是几号?到 2027-01-01 还有多少天?如果这些天每天存 5 元,一共能存多少?")。
4.3 跑一个三连调用的任务,看循环怎样收敛
我实际跑这个问题的过程,基本长这样:
- 第 1 轮:模型在 content 里写"我需要先获取今天的日期",同时发起
get_today({})。系统执行后把 Observation("2026-03-01")写回。 - 第 2 轮:模型看到日期是 2026-03-01,判断还需要算日期差,于是调用
days_between({"date1": "2026-03-01", "date2": "2027-01-01"}),Observation 返回306。 - 第 3 轮:模型拿到 306 天,意识到还要算总金额,调用
calculator({"expression": "306 * 5"}),Observation 返回1530。 - 第 4 轮:模型不再发起工具调用,直接给出最终答案:今天是 2026-03-01;到 2027-01-01 还有 306 天;每天存 5 元,一共能存 1530 元。
整个过程没有任何人告诉模型"要先查日期再算天数",它就是靠系统提示词和工具描述,自己规划出了这三步。这就是 ReAct 框架最让人兴奋的地方:你在给模型规则,而不是给模型步骤,模型自己完成步骤编排。
当然,实际输出不会每次都一模一样。有时模型会在一步里同时发起两个工具调用(并行调用),有时它会调整中间步骤的顺序。只要最终推理逻辑正确,都属于正常波动。
5. 实测中躲不开的四个坑:上下文膨胀、死循环、异常与模型乱答
5.1 上下文膨胀:一次搜索就把窗口塞满
第一个坑是上下文爆炸。ReAct 每执行一轮工具调用,就要在 messages 里追加一条 assistant 消息和一条 tool 消息。如果工具的 Observation 特别大——比如搜索接口返回整整一屏内容、数据库查询返回几百行记录——那么跑上三四轮,上下文窗口就快满了。后果是:费用越来越高、响应越来越慢、模型在超长上下文里反而更容易答非所问。
我常用的三个手段是:
- 工具输出截断:写一个
compact_observation,把超长内容截到几百字并加省略标识。
def compact_observation(text, max_len=500): if len(text) <= max_len: return text return text[:max_len] + f"...[已截断,共 {len(text)} 字符]"- 限制轮数:
max_steps本来就起到了"限制上下文无限增长"的作用。 - 历史压缩:如果任务确实很长,可以把早期轮次的 Thought 和 Observation 压缩成一行摘要再放回 messages,把原始长内容删掉。
尤其要注意第 3 点。我在项目里就见过一个 Agent,因为搜索工具返回的网页正文太长,导致第三轮就开始变笨,后面所有问题都答得像在复读。发现问题后我把每条 Observation 都截断到 300 字,同一套逻辑立刻就正常了。
5.2 死循环与重复动作:加最后一道闸
第二个坑是模型钻牛角尖。它可能连续五轮都在调用同一个工具,参数几乎没变,每次拿到 Observation 后又觉得"还不够"。这不是模型坏了,而是它在信息不足时缺少一个"止损"信号。
max_steps是兜底,但更好用的是重复动作检测。我通常在循环里维护一个最近的 tool call 列表,发现连续三轮调用同一个工具时,主动往 messages 里注入一条干预消息:
if step >= 3 and len(set(recent_calls[-3:])) == 1: messages.append({ "role": "user", "content": "你已经连续三次调用同一个工具,请不要再重复调用,直接基于现有信息回答用户。", })这一步效果非常明显。模型看到干预消息后,通常会停止重复搜索,转而尝试总结或给出一个诚实的"能力不足"答复。注意,干预消息用user角色比system角色更接近"用户不耐烦"的语义,模型往往会更认真地对待。
5.3 工具异常处理:把错误信息变成给模型的方向标
第三个坑是工具执行异常。模型是可能填错参数的,比如calculator收到"表达式: 306*5"这种带中文冒号的字符串,或者天数计算收到一个格式错误的日期。如果你的工具直接抛异常,整个 Agent 就会崩溃。
正确做法是把异常捕获后放进 Observation。我在前面代码里已经写了:
observation = f"工具调用异常: {type(e).__name__}: {e}"不要小看这行字符串,它是模型下一轮自我纠错的关键线索。比如 Observation 写着"ValueError: 表达式包含非法字符",模型会意识到"哦,我给的参数格式不对",然后重新生成一个格式规范参数的调用。这比直接让它面对一个空白失败要聪明得多。
还有一点要养成习惯:能返回错误对象就别直接抛异常。有些函数的设计是"正常结果返回数值,参数错误返回带 error 字段的字典",这两种方式都能让模型读到错误,但是后者不会污染调用栈,整体更可控。
5.4 模型选型与提示词细节
第四个坑藏在最不起眼的地方:选型与提示词。
首先,能选支持 Function Call 的模型就选支持 Function Call 的,别用纯文本模型硬扛。模型的工具调用能力质量差异极大,有的模型能准确理解 JSON Schema 并给出规范参数,有的模型则频繁填出莫名其妙的参数类型。在项目初期,我会用一组固定测试问题来横向对比模型的工具调用成功率,而不是只看它在开放对话里"显得多聪明"。
其次,调低采样参数。工具调用需要稳定,我把 temperature 设在 0 到 0.1,top_p 也一并调低,换来的是更少的随机性。让模型做创意写作时可以放飞,让模型控制工具调用时必须保守。
最后,提示词里一定要写"不要编造工具返回结果"。这个要求听着傻,实际非常管用。没有这句约束,模型有时会跳过工具直接编一个答案,尤其当问题稍微复杂、工具调用链较长的时候。把这条写进系统提示词,能显著减少"假 Observation"的现象。
6. 从 Demo 迈向可用:记忆、并行调用与运行日志的扩展思路
6.1 多轮会话与短期记忆
Demo 只处理单轮问题,但真实产品里用户会连续追问。多轮会话的核心,是把前面几轮对话的助手最终回答保留在 messages 里,而不是每次重新从空上下文开始。
要注意的是:不要在用户的新问题里夹带上一轮的工具调用记录。上一轮的工具 Observation 只对那一轮的问题有效,下一轮用户换了个话题,旧 Observation 反而会干扰模型。通常我会把"上一轮的最终回答"保留成普通 assistant 消息,把中间的过程消息全部清理掉,或者用一个短期记忆模块对历史做摘要。
6.2 并行工具调用
现在很多主流 API 允许模型在一次返回里发起多个 tool_calls。比如模型同时发现"我需要知道今天的日期"和"我需要查某个价格",它会一次性返回两个调用请求。
这种请求我建议并行执行,能显著降低整体耗时。用 Python 的ThreadPoolExecutor就可以,注意两点:一是回填 Observation 时顺序要和 tool_calls 顺序保持一致,避免模型混淆;二是如果多个工具之间有依赖关系(比如后一个工具需要前一个工具的输出),那就别并行,老老实实按顺序来。
from concurrent.futures import ThreadPoolExecutor def execute_tool_calls(tool_calls): with ThreadPoolExecutor(max_workers=4) as pool: futures = {tc.id: pool.submit(execute_one, tc) for tc in tool_calls} return {tc_id: fut.result() for tc_id, fut in futures.items()}并行不是越快越好,像"写入数据库""发送通知"这类有副作用的工具,并行执行可能带来重复操作风险,需要单独做好幂等。
6.3 运行日志与可观测性
最后一点,也是我自己把 Agent 接进业务环境时最看重的一点:可观测性。ReAct Agent 是一个多步决策系统,中间哪一步错了、哪一步 token 烧多了、哪一步模型产生了幻觉,光看最终答案根本看不出来。所以我在循环里一定会记录每一步的 Thought、Action、Observation、耗时和 token 消耗,丢到一个结构化日志里。
run_log = [] for step in range(max_steps): ... run_log.append({ "step": step, "thought": msg.content, "tool_calls": msg.tool_calls, "observation": observation, "latency_ms": latency_ms, "tokens": usage_tokens, })调试时我会直接看这个日志,一眼就知道模型在第几步开始跑偏。就连我后面调优系统提示词,也是靠翻这些日志找规律。个人经验是:先把日志做好,再谈优化效果,这一步绝对不能省。Agent 一旦变复杂,盲调就像在黑屋子里找开关,日志就是唯一的手电筒。