从零到一构建智能终端(七):LLM Agent 中的 ReAct 执行模型
深入解析 ReAct(Reasoning + Acting)范式在 LLM Agent 中的应用,探讨推理-行动循环的设计、工具调用编排和状态管理
引言
当我们让 LLM 执行复杂任务时,单次对话往往不够。例如:
"帮我在项目中找到所有未使用的依赖并删除它们"
这个任务需要:
- 读取
package.json - 分析代码找出 import 语句
- 对比依赖列表
- 确认后执行删除
LLM 需要多轮交互,在"思考"和"行动"之间反复切换。这就是 ReAct(Reasoning + Acting)范式。
ReAct 是什么
ReAct 由 Yao et al. 在 2022 年提出,核心思想是让 LLM 交替进行:
- Reasoning(推理):分析当前状态,决定下一步做什么
- Acting(行动):调用外部工具,获取新信息或执行操作
与传统方法的对比
Chain-of-Thought(思维链):
问题 → 思考过程 → 答案
纯推理,无法获取外部信息,无法执行操作。
单次工具调用:
问题 → 工具调用 → 答案
无推理过程,无法处理需要多步的任务。
ReAct:
问题 → 思考 → 行动 → 观察 → 思考 → 行动 → ... → 答案
推理与行动交织,能处理复杂的多步任务。
执行循环
基本结构
┌─────────────────────────────────────────┐
│ ReAct Loop │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ LLM │───▶│ 解析响应 │ │
│ │ 调用 │ │ │ │
│ └──────────┘ └────┬─────┘ │
│ ▲ │ │
│ │ ┌────▼────┐ │
│ │ │ 分支 │ │
│ │ └────┬────┘ │
│ │ │ │
│ ┌────┴─────┐ ┌────▼────┐ ┌──────┐│
│ │ 构建消息 │◀───│工具调用 │ │ 完成 ││
│ │ │ │ │ │ ││
│ └──────────┘ └─────────┘ └──────┘│
│ │
└─────────────────────────────────────────┘
每次迭代:
- 调用 LLM,传入当前上下文
- 解析 LLM 响应
- 如果 LLM 返回工具调用 → 执行工具 → 将结果加入上下文 → 继续循环
- 如果 LLM 返回最终答案 → 结束循环
状态定义
IterationOutcome:
| ToolsRequested { calls: [(tool_name, arguments)] }
| Complete { output: string }
| Error { message: string }
伪代码
pythondef react_loop(task: str, max_iterations: int = 20):
messages = [{"role": "user", "content": task}]
for i in range(max_iterations):
# 1. 调用 LLM
response = llm.chat(messages)
# 2. 解析响应
outcome = parse_response(response)
# 3. 根据结果分支
if outcome.type == "complete":
return outcome.output
if outcome.type == "error":
raise AgentError(outcome.message)
if outcome.type == "tools_requested":
# 4. 执行工具
tool_results = []
for call in outcome.calls:
result = execute_tool(call.name, call.arguments)
tool_results.append(result)
# 5. 将结果加入消息历史
messages.append({"role": "assistant", "content": response})
messages.append({
"role": "user",
"content": format_tool_results(tool_results)
})
raise MaxIterationsExceeded()
工具系统设计
工具定义
每个工具需要定义:
Tool:
name: string # 唯一标识符
description: string # LLM 理解工具用途的描述
parameters: Schema # JSON Schema 定义参数格式
execute: Function # 实际执行逻辑
示例:
json{
"name": "read_file",
"description": "读取指定路径的文件内容",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "文件的绝对路径"
}
},
"required": ["path"]
}
}
工具注册表
维护一个工具注册表,支持:
- 注册:添加新工具
- 查询:按名称获取工具
- 列举:返回所有工具的 Schema(给 LLM 看)
- 分类:按功能分组(文件操作、网络请求、代码执行等)
权限控制
不是所有工具都应该无条件执行:
| 风险等级 | 示例工具 | 执行策略 |
|---|---|---|
| 低 | read_file, search | 自动执行 |
| 中 | write_file, run_command | 需要用户确认 |
| 高 | delete_file, send_email | 强制确认 + 显示详情 |
确认机制:
1. LLM 请求执行高风险工具
2. 系统暂停执行
3. 向用户展示工具名称、参数、预期影响
4. 用户选择:允许 / 拒绝 / 修改参数
5. 根据用户选择继续或终止
消息管理
上下文累积问题
ReAct 循环中,每次迭代都会增加消息:
迭代 1: [user] + [assistant] + [tool_result]
迭代 2: + [assistant] + [tool_result]
迭代 3: + [assistant] + [tool_result]
...
问题:
- Token 限制:很快超出模型的上下文窗口
- 成本增加:每次调用都要发送完整历史
- 注意力分散:太多历史信息可能干扰 LLM
解决方案
1. 滑动窗口
只保留最近 N 条消息:
保留:[system] + [最初的 user] + [最近 N 条交互]
问题:可能丢失重要的早期上下文。
2. 摘要压缩
定期将历史消息压缩为摘要:
原始:100 条消息,50K tokens
压缩后:1 条摘要,2K tokens
压缩策略:
- 或当 token 数超过阈值时触发
- 使用 LLM 生成摘要
3. 分层记忆
短期记忆:最近 3-5 次迭代的完整内容
长期记忆:早期迭代的摘要
工作记忆:当前任务的关键信息(提取的实体、中间结果)
流式处理
为什么需要流式
ReAct 的一次迭代可能需要 10-30 秒(LLM 生成 + 工具执行)。如果等完全结束再展示,用户体验很差。
流式处理让用户实时看到:
- LLM 正在"思考"什么
- 准备调用什么工具
- 工具执行的进度和结果
事件流设计
TaskEvent:
| TaskCreated { task_id, timestamp }
| ThinkingStarted { iteration }
| ThinkingChunk { content } # LLM 输出的 token 流
| ThinkingComplete
| ToolCallStarted { tool_name, arguments }
| ToolCallProgress { progress } # 长时间工具的进度
| ToolCallComplete { result }
| IterationComplete { iteration }
| TaskComplete { output }
| TaskError { error }
前端展示
┌─────────────────────────────────────┐
│ Task: 优化项目的 TypeScript 配置 │
├─────────────────────────────────────┤
│ 🤔 思考中... │
│ "首先我需要读取当前的 tsconfig..." │
│ │
│ 🔧 read_file │
│ path: /project/tsconfig.json │
│ ✓ 完成 (234ms) │
│ │
│ 🤔 思考中... │
│ "配置中缺少 strict 模式,我建议..." │
│ │
│ 📝 write_file │
│ path: /project/tsconfig.json │
│ ⏳ 等待确认... │
│ [允许] [拒绝] [查看更改] │
└─────────────────────────────────────┘
错误处理
工具执行失败
工具可能因各种原因失败:
- 文件不存在
- 权限不足
- 网络超时
- 参数格式错误
处理策略:
1. 返回错误信息给 LLM
pythondef execute_tool_safe(name, args):
try:
return {"success": True, "result": execute_tool(name, args)}
except Exception as e:
return {"success": False, "error": str(e)}
让 LLM 决定如何处理:重试、换方法、或放弃。
2. 重试机制
对于可恢复的错误(如网络超时),自动重试:
python@retry(max_attempts=3, backoff=exponential)
def execute_tool_with_retry(name, args):
return execute_tool(name, args)
3. 优雅降级
某些工具失败时,提供替代方案:
read_file 失败 → 尝试 cat 命令
search_web 失败 → 使用缓存结果或跳过
LLM 响应异常
LLM 可能返回:
- 格式错误的 JSON
- 不存在的工具名称
- 无效的参数
处理方式:
- 解析失败:要求 LLM 重新生成(附带格式说明)
- 工具不存在:返回可用工具列表,让 LLM 重选
- 参数无效:返回具体的验证错误,让 LLM 修正
循环检测
LLM 可能陷入重复循环:
read_file → 思考 → read_file(同一文件)→ 思考 → read_file ...
检测方法:
- 记录最近 N 次工具调用
- 如果连续 3 次调用相同工具 + 相同参数 → 触发警告
- 强制 LLM 换一种方法
与模型能力的适配
不同模型的工具调用能力
| 模型 | 工具调用方式 | 特点 |
|---|---|---|
| GPT | Function Calling API | 结构化输出,可靠 |
| Claude | Tool Use | 支持复杂参数,支持并行调用 |
| 开源模型 | 提示词模拟 | 需要精心设计 prompt |
提示词工程
对于不原生支持工具调用的模型,理论上不建议用于复杂agent任务,需要在提示词中定义格式:
你可以使用以下工具:
1. read_file(path): 读取文件内容
2. write_file(path, content): 写入文件
3. search(query): 搜索代码
当你需要使用工具时,按以下格式输出:
<tool>
name: 工具名称
arguments:
参数名: 参数值
</tool>
当你完成任务时,输出:
<answer>
最终答案
</answer>
性能优化
并行工具调用
当 LLM 一次请求多个独立的工具调用时,可以并行执行:
pythonasync def execute_tools_parallel(calls):
tasks = [execute_tool_async(c.name, c.args) for c in calls]
results = await asyncio.gather(*tasks, return_exceptions=True)
return results
注意:有依赖关系的调用不能并行。
提前终止
某些情况可以提前结束循环:
- 用户取消任务
- 检测到不可恢复的错误
- 达到预算限制(token 数或 API 费用)
缓存
对于确定性工具(如 read_file),可以缓存结果:
python@cache(ttl=60) # 60 秒内相同参数返回缓存
def read_file(path):
return open(path).read()
总结
ReAct 执行模型的核心要点:
- 推理-行动循环:LLM 交替思考和执行,逐步完成复杂任务
- 工具系统:定义清晰的接口,实现权限控制
- 消息管理:处理上下文累积问题,使用摘要压缩
- 流式输出:实时展示进度,提升用户体验
- 错误处理:优雅处理失败,避免无限循环
ReAct 不是银弹——它增加了系统复杂度,也增加了 token 消耗。但对于需要多步推理、外部交互的任务,它是目前最实用的范式之一。
从工程角度看,一个好的 ReAct 实现应该:
- 可观测:能看到每一步在做什么
- 可中断:用户随时可以暂停或取消
- 可恢复:支持从中间状态继续
- 可控制:对高风险操作有确认机制