Skip to main content

01 · ReAct:从零搭一个会用工具的 Agent

零、开始之前

这篇的目标:读完你能自己写出一个真正能干活的 Agent(能查资料、能改文件),并且说得清每一行为什么这么写

需要的前置知识:会写 Python,调用过一次大模型 API(client.messages.create 这种)。不需要懂任何 Agent 框架。

读完你会明白这几件事

  • 大模型「调用工具」到底是什么意思 —— 它其实什么都没调用
  • 一次完整的工具调用,请求和响应里到底传了什么
  • 为什么这个循环必须是循环,不能是一次调用
  • 什么时候该自己手写,什么时候该上框架

这篇在整个专题里的位置:ReAct 是另外五种范式的地基。后面讲的计划、反思、多智能体,全都是在这个循环外面套东西。所以这一篇必须先啃透。

一、先看一个真实问题

假设你直接问大模型一个问题:

「我这个项目里一共有多少个 Python 文件?」

模型会怎么回答?它只能说「抱歉,我无法访问你的文件系统」。

再问一个:

「LangGraph 现在最新版本是多少?」

模型可能会给你一个版本号,但那是它训练时看到的,大概率已经过期了,而且它不会告诉你这一点。

这两个问题暴露了大模型的两个硬边界:

这座桥就是「工具」。 而 ReAct,就是让模型学会自己走这座桥的方法。

二、最朴素的办法,以及它为什么不够

你可能已经想到一个办法:我自己去查,把结果贴给模型不就行了?

# 第 1 步:你自己在终端跑
$ find . -name "*.py" | wc -l
47

# 第 2 步:把结果贴进 prompt
prompt = "我的项目有 47 个 Python 文件,帮我分析一下规模"

这确实能用。但它有三个问题,而且一个比一个致命:

问题一:你得提前知道要查什么。 如果模型分析到一半发现「还需要看看这些文件多大」,你只能重新跑一次命令、重新组织 prompt。

问题二:多步任务会累死人。 「找出项目里最大的那个 Python 文件,看看它有什么问题」—— 这需要:先列文件 → 再看大小 → 再读内容 → 再分析。四步,你要来回粘贴四次。

问题三:模型没法根据结果调整方向。 如果 find 命令报错了(比如目录不存在),模型看不到错误,你得自己判断、自己改命令。

所以真正的需求是:让模型自己决定要查什么、自己看到结果、自己决定下一步。 你只负责在它开口要的时候,替它跑一下命令。

这就是 ReAct。

三、ReAct 到底是什么

3.1 一个生活里的类比

想象你在电话里指挥一个修水管的师傅,他在现场,你看不见。

  • 师傅说:「我先看看总阀在哪」——这是Thought(想)
  • 师傅走过去拧开柜门——这是Action(做)
  • 师傅说:「柜子里有三个阀门,都锈了」——这是Observation(看到什么)
  • 你听完说:「那你先拍张照给我」——下一轮 Thought

ReAct 就是把大模型放在「你」的位置,把你的 Python 代码放在「师傅」的位置。 模型负责想和决定,代码负责跑腿和汇报。

它的名字就是这么来的:Reasoning(推理)+ Acting(行动)。出自 Yao 等人 2022 年的论文,2023 年发表在 ICLR。

论文真正的贡献不是发明了工具调用,而是发现了一件事:让模型把「我为什么要这么做」显式写出来,再决定做什么,准确率会明显高于让它直接做。

3.2 三个词的循环

ReAct 范式中的思考-行动-观察协同循环

图 1-1 ReAct 的「思考 → 行动 → 观察」循环

图片来源:Datawhale Hello-Agents 第四章(CC BY-NC-SA 4.0)

用大白话说这张图:

阶段谁在干活干了什么
Thought大模型「我现在缺什么信息」
Action大模型决定,你的代码执行「调 bash,参数是 find . -name '*.py'
Observation你的代码把命令的输出原样告诉模型

然后回到 Thought,直到模型说「我知道答案了」。

3.3 最关键的一件事:模型什么都没执行

这是初学者最容易误解的地方,必须现在讲清楚。

当你看到「模型调用了 bash 工具」,实际发生的是:

模型输出的只是一段结构化的文本,说明它「想」调用什么。真正执行的永远是你的代码。

这意味着两件事,都很重要:

  1. 安全责任在你身上。 模型说要执行 rm -rf /,执行它的是你写的 subprocess.run。模型没有能力做任何事,除非你给它。
  2. 工具想给什么就给什么。 工具不一定是真的命令行,可以是查数据库、调内部 API、甚至假数据。模型不知道也不关心背后是什么。

四、动手:一步步搭出来

下面从一个空文件开始,五步搭出一个能改你代码的 Agent。每一步都能单独跑,建议跟着敲。

拆解用的原型是 shareAI-lab/learn-claude-code(74,605★,MIT 协议)的 s01_agent_loop/code.py,全文 141 行。选它是因为它没有任何框架,全是标准库加一个官方 SDK。

4.0 准备

pip install anthropic python-dotenv

然后建一个 .env 文件放 API key。

4.1 第一步:先让模型能说话

最原始的形态,没有工具,就是普通对话:

import os
from anthropic import Anthropic

client = Anthropic()
MODEL = "claude-sonnet-4-6"

response = client.messages.create(
model=MODEL,
max_tokens=8000,
messages=[{"role": "user", "content": "这个项目有多少个 Python 文件?"}],
)
print(response.content[0].text)

跑一下,模型会回答「我无法访问你的文件系统」。这是我们的起点:一个什么都干不了的模型。

4.2 第二步:告诉模型「你有一把工具」

现在加上工具声明。注意,这一步只是告诉模型有这么个东西,还没有任何执行逻辑:

TOOLS = [{
"name": "bash", # 工具叫什么
"description": "Run a shell command.", # 什么时候该用它
"input_schema": { # 参数长什么样
"type": "object",
"properties": {
"command": {"type": "string"}
},
"required": ["command"],
},
}]

response = client.messages.create(
model=MODEL,
max_tokens=8000,
messages=[{"role": "user", "content": "这个项目有多少个 Python 文件?"}],
tools=TOOLS, # ← 新增
)

这四个字段各是干什么的,逐个说清楚:

字段作用写不好会怎样
name模型用这个名字来指定要调哪个工具名字含糊(比如叫 run)模型会用错
description模型判断「该不该用它」的唯一依据这是最重要的一个字段,下面单独讲
input_schema参数的结构,用 JSON Schema 写少写 required,模型会漏传参数
properties每个参数的名字和类型类型写错,你的代码会拿到意外的值

关于 description,有一条实用原则:写「什么时候用它」,不要写「它是什么」。

  • ❌ 不好:"A tool for running shell commands"(在描述它是什么)
  • ✅ 好:"当你需要查看文件、搜索内容或运行命令时使用。返回 stdout 和 stderr。"(在描述何时用、能拿到什么)

现在再跑一次,你会发现返回的东西变了 —— 这就是第三步要处理的。

4.3 第三步:接住模型的「我要用工具」

加了 tools 之后,response.content 不再是单纯一段文字,而是一个块(block)列表。可能长这样:

[
TextBlock(text="我需要先看看目录里有什么"),
ToolUseBlock(
id="toolu_01abc...", # ← 这次调用的唯一编号
name="bash",
input={"command": "find . -name '*.py' | wc -l"}
)
]

所以你要做的是:把类型是 tool_use 的块挑出来

tool_calls = [block for block in response.content if block.type == "tool_use"]

if not tool_calls:
# 模型没要工具,说明它认为已经能回答了
print(response.content[0].text)
else:
for block in tool_calls:
print(f"模型想执行:{block.input['command']}")
print(f"这次调用的编号:{block.id}")

注意那个 id 现在先记住它存在,第四步会讲它为什么关键。

4.4 第四步:真的去执行,然后把结果还回去

这一步分两半。先写执行函数:

import subprocess

def run_bash(command: str) -> str:
try:
r = subprocess.run(
command, shell=True, cwd=os.getcwd(),
capture_output=True, text=True, timeout=120,
)
out = (r.stdout + r.stderr).strip() # ← 注意这里
return out[:50000] if out else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"

这几行里有三个决定,都是踩过坑才这么写的,第五节会展开讲。现在只记住一条:stdout + stderr 必须一起返回。

然后是把结果送回模型。这里的格式是有讲究的:

results = []
for block in tool_calls:
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id, # ← 用第三步那个 id,原样带回
"content": output,
})

# 关键:结果要以 user 的身份发回去
messages.append({"role": "user", "content": results})

两个初学者一定会卡住的点

① 为什么要带 tool_use_id

因为模型一轮可以同时要求调多个工具。比如它一次性说「我要读 a.py、b.py、c.py」,返回三个 tool_use 块,三个不同的 id。你把三个结果送回去时,模型靠 id 来对应「哪个结果属于哪次调用」。

漏带或者带错,Anthropic 和 OpenAI 的 API 都会直接返回 400 错误。

② 为什么工具结果的角色是 user,而不是 toolsystem

这个确实反直觉。原因是 Anthropic 的 Messages API 要求 user 和 assistant 严格交替

user      → 用户提问
assistant → 模型说"我要用工具"
user → ??? 这里必须是 user,不然协议就断了
assistant → 模型根据结果回答

所以在协议层面,工具被建模成「用户替模型跑了一趟腿」。理解了这一点,格式就不用死记了。

4.5 第五步:套上循环

前面四步只跑了一轮。但真实任务需要多轮 —— 模型看到 find 的结果后,可能还想再看看文件内容。

所以把它包成循环:

def agent_loop(messages: list):
while True:
# ① 带着完整历史去问模型
response = client.messages.create(
model=MODEL, system=SYSTEM, messages=messages,
tools=TOOLS, max_tokens=8000,
)

# ② 模型这轮的输出,原样存进历史
messages.append({"role": "assistant", "content": response.content})

# ③ 没有 tool_use 块 = 模型认为干完了,退出
tool_calls = [b for b in response.content if b.type == "tool_use"]
if not tool_calls:
return

# ④ 有就逐个执行,收集结果
results = []
for block in tool_calls:
output = run_bash(block.input["command"])
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})

# ⑤ 结果塞回历史,进入下一轮
messages.append({"role": "user", "content": results})

到这里,一个能改你文件系统的 Agent 就完整了。 核心逻辑就这 20 行。

4.6 跑一次,看清楚 messages 是怎么长大的

这是理解 ReAct 最有效的方式:盯着 messages 这个列表看

问「这个项目有多少个 Python 文件」,历史会这样增长:

关键观察:

  • 每轮都会往 messages 追加两条(模型的输出 + 工具结果),一条都不会删
  • 每次调模型都是把「完整历史」重新发一遍 —— 模型本身没有记忆,所谓的"记忆"就是这个不断变长的列表
  • 循环退出的条件是格式,不是内容 —— 不是模型说了「完成了」,而是它这轮没有产出 tool_use

第二条是最反直觉的,也是后面所有上下文问题的根源:跑到第 15 轮时,你每次都在重发前面 14 轮的全部内容。 又慢又贵,而且迟早撑爆上下文窗口。

五、把四个关键决定讲透

上面代码里有几处一笔带过的写法,现在逐个说清楚为什么。

5.1 为什么 stderr 必须回传

out = (r.stdout + r.stderr).strip()

假设模型执行 cat config.yaml,但这个文件不存在。

  • 只回传 stdout:模型收到空字符串,它会以为「文件是空的」,然后继续基于错误前提往下推理。更糟的是,它可能反复执行同一条命令,因为它不知道哪里出了问题。
  • 回传 stderr:模型收到 cat: config.yaml: No such file or directory,它立刻知道路径错了,下一轮会去 ls 找找看。

报错信息就是 ReAct 里最有价值的 Observation。 这是新手最常犯、也最难自己发现的错误 —— 因为程序不报错,只是 Agent 变笨了。

5.2 为什么要有超时

timeout=120

模型会写出永远不返回的命令:tail -f app.lognpm run devpython -m http.server。没有超时,整个 Agent 就挂在那儿。

5.3 为什么要截断输出

return out[:50000] if out else "(no output)"

一条 cat 大文件就能塞爆上下文。5 万字符大约是 1.2 万 token。

后半句 if out else "(no output)" 也不是可有可无:空字符串会让某些 API 报 400;而且「命令跑成功了但没有输出」(比如 mkdir)本身是有效信息,得让模型知道。

5.4 为什么模型的输出要原样存

messages.append({"role": "assistant", "content": response.content})

注意存的是 response.content(整个块列表),不是 response.content[0].text

如果只存文本,那些 tool_use 块就丢了。下一轮模型看不到自己刚才发起过哪些调用,会重复调用同一个工具。而且历史里会出现「有工具结果但没有对应的工具调用」,这在 API 层面是非法的。

六、从玩具到生产:工业级实现多了什么

上面那 20 行能跑,但离能上线还差得远。

对比一下:LangChain 官方的 ReAct 实现(chat_agent_executor.py)有 1015 行。多出来的 985 行不是抽象税,每一块都在防一个具体的事故

先看整体形状的变化:

下面把每一块对应到「它在防什么真实事故」。

6.1 防烧钱:给循环装保险丝

事故场景:模型陷入死循环,反复执行同一条命令。你的 while True 会一直跑下去,每一轮都是一次付费的 API 调用。半夜跑一个任务,早上起来发现账单四位数。

生产做法:维护一个「还剩多少步」的计数器,快用完时强制收尾。LangChain 的默认值是 25 步。

有个细节值得注意:它的阈值判断是「剩余步数 < 2」就停,而不是 < 1。因为「还要调工具」意味着至少还需要两步(执行工具 + 让模型看结果)。只剩一步就动手,会停在一个「调了工具但没人看结果」的破损状态上 —— 而这种状态是非法的(见 6.2)。

6.2 防历史损坏:入口校验

事故场景:任务跑到一半崩了(进程被杀、机器重启)。你从数据库里恢复上次的进度,接着跑 —— 结果 API 返回 400,报错信息完全看不懂。

原因:你恢复的那个时间点,恰好停在「模型说要调工具、但结果还没写回」的中间状态。这份历史里有一个孤儿 tool_use,没有配对的 tool_result

这是一条硬约束,值得单独记住:

消息历史里,每一个工具调用都必须有对应的工具结果,一个都不能少。

生产实现会在入口就检查这件事,发现问题立刻报错,并给一段人话解释,而不是让你去猜 API 的 400 是什么意思。

6.3 提速:并行执行工具

事故场景:模型一轮里说「我要读这 5 个文件」。你的 for 循环一个一个读,5 倍延迟。

生产做法:把这一轮的多个工具调用同时发出去。改动量很小,收益经常是数倍 —— 这是 ReAct 最容易拿到的性能提升

6.4 防上下文爆炸:留一个裁剪的钩子

事故场景:跑到第 15 轮,模型开始答非所问,或者直接报 token 超限。

原因:4.6 讲过,messages 只增不减,而且每轮全量重发。

生产做法:在「调模型之前」留一个插槽(LangChain 叫 pre_model_hook),你可以在这里做裁剪、摘要、压缩。这条线一直通到更复杂的上下文工程方案,本专题 0305 会继续讲。

6.5 防误操作:执行前让人确认

事故场景:Agent 决定执行 rm -rf build/,而你其实不想。

玩具版的做法是关键词黑名单:

dangerous = ["rm -rf /", "sudo", "shutdown"]
if any(d in command for d in dangerous):
return "Error: Dangerous command blocked"

这只是演示,不是安全措施。 rm -rf /* 就绕过去了,rm -rf $HOME 也绕过去了。

生产做法是在「执行工具」这个节点前插一个中断点,把命令展示给人看,等人点确认。真正可靠的方案还要加上沙箱隔离。

6.6 那么,我到底该不该用框架

把上面几条列成一张表,答案就清楚了:

你需要的能力20 行手写框架
能跑起来,做个 demo
防止死循环烧钱
多工具并行
上下文裁剪
崩溃后从断点续跑
危险操作人工审批

结论:自己手写完全没问题 —— 直到你需要这张表右边那列的第二项。

建议的路径是:先手写一遍(这样你知道框架在替你做什么),上线前换框架。跳过手写直接用框架的人,遇到问题会完全不知道从哪查。

一个实践提醒:如果你在网上看到 from langgraph.prebuilt import create_react_agent 这种写法,那是旧版入口,现在已经标记为弃用了。新写法是:

from langchain.agents import create_agent

七、常见故障与排查

按「你会观察到什么现象」来组织,方便对号入座。

你看到的现象大概率的原因怎么查 / 怎么修
Agent 反复执行同一条失败的命令只回传了 stdout,模型不知道自己错了打印一下你回传给模型的 content,看有没有报错信息(5.1)
跑十几轮后开始胡说八道上下文太长,早期信息被稀释打印 len(messages) 和总 token 数;加裁剪(6.4)
模型明明该用 A 工具却总用 B工具描述写得像文档,不像使用说明description,写「什么时候用」;减少工具数量
API 报 400,说 tool_use 相关有孤儿工具调用,或 tool_use_id 对不上逐条打印 messages,检查每个 tool_use 是否都有配对的 tool_result(6.2)
任务跑着跑着偏离了目标原始需求被挤到上下文中段这是 ReAct 的结构性缺陷,见下
Agent 卡住不动执行了不会返回的命令加 timeout(5.2)

最后一行「偏离目标」需要单独说:这不是调参能解决的。

ReAct 的结构里只有「下一步」,没有「全局」。任务超过十来步,最初的需求会被埋在越来越长的历史中段,模型的注意力照顾不到,就开始漂移。

这正是下一篇 Plan-and-Execute 存在的理由。

八、全局定位:ReAct 在编排体系里的位置

回到整个专题的坐标轴 —— 谁决定下一步

ReAct 处在「刚刚把方向盘交给模型」的位置:

  • 往左一步(Workflow):把方向盘拿回来,换取可预测和便宜
  • 往右一步(Plan):让模型先看全局,解决漂移
  • 再往右(Supervisor):多个 ReAct 循环并行跑

Reflection 是个可以挂在任何一个上面的附加回路。

Anthropic 对自主 Agent 的抽象

图 1-2 Anthropic 对「自主 Agent」的抽象:本质上就是这个循环

图片来源:Anthropic — Building Effective Agents

什么时候不该用 ReAct

  • 路径其实是确定的(「提取字段 → 校验 → 写库」)→ 用 Workflow,便宜十倍还可预测
  • 步数超过 10 步 → 会漂移,得叠 Plan
  • 对延迟敏感的在线接口 → 每轮都是一次完整往返,P99 控不住
  • 根本没有工具要调 → 那不叫 Agent,直接调模型就行

九、小结与检查清单

这一篇的核心三句话

  1. 模型不执行任何东西,它只输出「我想调什么」,执行的永远是你的代码
  2. 所谓记忆,就是那个不断变长的 messages 列表,每轮全量重发
  3. 循环的退出条件是「这轮没有工具调用」,是格式判断,不是语义判断

自查清单 —— 写完你的 ReAct Agent,逐条对一遍:

  • 工具执行时,stderr 有没有和 stdout 一起回传?
  • 有没有 timeout?超时后返回的是可读的错误信息吗?
  • 工具输出有没有截断上限?空输出有没有兜底?
  • 模型的输出是不是原样存进历史(而不是只存 text)?
  • 每个 tool_resulttool_use_id 是不是原样带回的?
  • 循环有没有最大轮数保险丝?
  • 工具的 description 写的是「什么时候用」还是「它是什么」?
  • 危险操作有没有比关键词黑名单更靠谱的防护?

参考资料

资料位置协议
本篇拆解的教学实现s01_agent_loop/code.py,141 行MIT
工业级实现LangGraph chat_agent_executor.py,1015 行MIT
中文教程Hello-Agents 第四章 4.2CC BY-NC-SA 4.0
论文原始实现ysymyth/ReAct(2024-02 停更,考古用)MIT

Yao S, Zhao J, Yu D, et al. ReAct: Synergizing Reasoning and Acting in Language Models. ICLR 2023.

下一篇:02 · Workflow 编排 —— 如果路径其实是确定的,把方向盘从模型手里拿回来会更划算。