项目实战:带你手把手实现agnet的ReAct模式
前面几篇我已经把 Function Calling、ReAct、Workflow 这些概念拆开讲过了,但如果一直停留在概念层,很容易产生一种“好像懂了,但自己写不出来”的错觉。
所以这篇我不再继续抽象讨论,而是直接回到代码:用一个极简的 Python 项目,从零实现一个最小可运行的 ReAct Agent。
完整的代码在文章最后。
这个实现只保留 3 个最核心的部分:
- 一个模型节点
call_model - 一个工具节点
ToolNode - 一条
模型 -> 工具 -> 模型的循环边
它不追求“功能很多”,而是追求“结构清楚”。你看完后,应该能回答这几个问题:
- ReAct 的最小执行闭环到底长什么样?
tool_calls是怎么把流程从模型切到工具节点的?- 为什么说 ReAct 本质上就是一个
思考 -> 行动 -> 观察 -> 再思考的循环?
一、项目目录
这个最小项目只有 4 个核心文件:
ReAct/
├── graph.py
├── state.py
├── tools.py
└── main.py
它们各自的职责非常明确:
graph.py:定义 ReAct 图结构state.py:定义状态结构tools.py:定义工具main.py:提供命令行交互入口
二、先看状态:为什么只有一个 messages
在最小 ReAct 实现里,State 可以非常简单:
class InputState(TypedDict):
messages: list[AnyMessage]
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
这里最关键的是:
messages是整个 Agent 的主状态- 节点不是返回完整状态,而是返回
{"messages": [...]}这样的局部更新 add_messages会把新消息自动合并进已有历史
这也是为什么 ReAct 模板通常不需要一开始就设计很多业务字段。因为在最小实现里,消息历史本身就足够支撑模型循环推理。
三、工具:让模型具备最基本的行动能力
为了让这个 Agent 有最小“行动能力”,我只保留了 3 个工具:这就是tools.py的部分
@tool
def get_current_time() -> str:
"""获取当前本地时间。适合回答现在几点、今天几号这类问题。"""
@tool
def save_note(content: str) -> str:
"""把一条笔记追加保存到本地 notes.md 文件。"""
@tool
def read_note() -> str:
"""读取本地 notes.md 的内容。适合查看、总结或复用已保存的笔记。"""
这 3 个工具分别代表了三类典型能力:
- 查询类:
get_current_time - 写入类:
save_note - 读取类:
read_note
也就是说,哪怕是最小 ReAct Agent,也已经具备了:
- 访问外部信息
- 改变外部状态
- 再次读取外部状态
这比“只会聊天”的模型已经前进了一大步。
四、模型节点:ReAct 循环的大脑
graph.py 里的核心函数是 call_model:
def call_model(state: State) -> dict:
llm = get_llm().bind_tools(TOOLS)
response = llm.invoke(
[SystemMessage(content=SYSTEM_PROMPT), *state["messages"]]
)
return {"messages": [response]}
这里发生了 3 件关键的事:
- 加载模型
- 通过
bind_tools(TOOLS)给模型绑定工具能力 - 把系统提示词和历史消息一起发给模型
最重要的一点是:
模型节点不直接执行工具。它只负责输出下一条 AIMessage。
这条 AIMessage 可能有两种情况:
- 没有
tool_calls:说明模型决定直接回答 - 有
tool_calls:说明模型决定先调用工具
这就是 ReAct 里的“Reason”阶段。
五、路由函数:决定下一步去哪
接下来是这个很小但很关键的函数:
def route_model_output(state: State) -> str:
last_message = state["messages"][-1]
if not getattr(last_message, "tool_calls", None):
return END
return "tools"
它的作用可以压缩成一句话:
检查模型刚输出的最后一条消息里有没有
tool_calls,如果有,就去工具节点;如果没有,就结束。
这就是 ReAct 里的“Act or Finish”分叉点。
六、图结构:最小 ReAct loop 是怎么连起来的
整个图的骨架非常简单:
builder = StateGraph(State, input_schema=InputState)
builder.add_node("call_model", call_model)
builder.add_node("tools", ToolNode(TOOLS))
builder.add_edge(START, "call_model")
builder.add_conditional_edges("call_model", route_model_output)
builder.add_edge("tools", "call_model")
把它翻译成流程图,就是:
START
-> call_model
-> 如果没有 tool_calls,结束
-> 如果有 tool_calls,进入 tools
-> tools
-> call_model
-> ...
这就是一个最小可运行的 ReAct Loop。
注意这里的重点不是“循环本身”,而是:
- 模型负责决定是否行动
- 工具负责执行行动
- 工具结果回到消息历史
- 模型基于新观察继续推理
这正是 Thought -> Action -> Observation -> Thought 的代码化形式。
七、命令行入口:把循环真正跑起来
最后是 main.py:
def main():
graph = build_graph()
messages = []
while True:
user_input = input("请输入你的问题(输入 exit 退出):").strip()
if user_input.lower() == "exit":
print("已退出。")
break
if not user_input:
print("输入不能为空。")
continue
messages.append(HumanMessage(content=user_input))
result = graph.invoke({"messages": messages})
messages = result["messages"]
final_message = messages[-1]
print("\nAgent 回复:")
print(final_message.content)
这里做的事也很直接:
- 读取用户输入
- 包装成
HumanMessage - 调用
graph.invoke(...) - 用返回的新
messages覆盖本地消息历史 - 输出最后一条模型回复
这就让整个 ReAct 图从“结构定义”变成了“可交互程序”。
八、这个最小 ReAct 实现的局限
当然,这份代码只是教学版最小实现,它还有很多没有覆盖的工程能力。
比如:
- 没有 Checkpoint,进程一断就丢状态
- 没有 Interrupt,不能人工中断恢复
- 没有独立业务字段,全部状态都靠
messages - 没有完整错误处理和工具失败恢复
- 没有 observability / middleware / 权限控制
也就是说,这份实现回答的是:
ReAct 最小闭环怎么跑起来?
而不是:
Agent 系统该怎么设计?
这两者是不同层级的问题。
九、完整代码
tools.py:
from datetime import datetime
from pathlib import Path
from langchain_core.tools import tool
NOTES_PATH = Path(__file__).with_name("notes.md")
@tool
def get_current_time() -> str:
"""获取当前本地时间。适合回答现在几点、今天几号这类问题。"""
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@tool
def save_note(content: str) -> str:
"""把一条笔记追加保存到本地 notes.md 文件。"""
with NOTES_PATH.open("a", encoding="utf-8") as file:
file.write(content.strip() + "\n")
return f"笔记已保存到 {NOTES_PATH.name}"
@tool
def read_note() -> str:
"""读取本地 notes.md 的内容。适合查看、总结或复用已保存的笔记。"""
if not NOTES_PATH.exists():
return "还没有笔记文件。"
content = NOTES_PATH.read_text(encoding="utf-8").strip()
return content or "笔记文件为空。"
state.py:
from typing import Annotated
from typing_extensions import TypedDict
from langchain_core.messages import AnyMessage
from langgraph.graph.message import add_messages
class InputState(TypedDict):
messages: list[AnyMessage]
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
graph.py:
import os
from langchain_core.messages import SystemMessage
from langgraph.graph import END, START, StateGraph
from langgraph.prebuilt import ToolNode
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
from state import InputState, State
from tools import get_current_time, read_note, save_note
TOOLS = [get_current_time, save_note, read_note]
SYSTEM_PROMPT = """
你是一个最小可用的 ReAct Agent。
你的工作原则:
1. 能直接回答时就直接回答。
2. 需要外部信息或持久化操作时再调用工具。
3. 调用工具后,基于工具结果继续推理并给出最终答复。
4. 回复尽量简洁、明确。
""".strip()
def get_llm() -> ChatOpenAI:
api_key = os.getenv("DEEPSEEK_API_KEY")
base_url = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
model = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
if not api_key:
raise ValueError(
"缺少环境变量 DEEPSEEK_API_KEY。请先设置 API Key 再运行。"
)
return ChatOpenAI(
model=model,
api_key=SecretStr(api_key),
base_url=base_url,
temperature=0,
)
def call_model(state: State) -> dict:
llm = get_llm().bind_tools(TOOLS)
response = llm.invoke(
[SystemMessage(content=SYSTEM_PROMPT), *state["messages"]]
)
return {"messages": [response]}
def route_model_output(state: State) -> str:
last_message = state["messages"][-1]
if not getattr(last_message, "tool_calls", None):
return END
return "tools"
def build_graph():
builder = StateGraph(State, input_schema=InputState)
builder.add_node("call_model", call_model)
builder.add_node("tools", ToolNode(TOOLS))
builder.add_edge(START, "call_model")
builder.add_conditional_edges("call_model", route_model_output)
builder.add_edge("tools", "call_model")
return builder.compile()
tips:这里要设置你自己的api_key
main.py:
from graph import build_graph
from langchain_core.runnables import RunnableConfig
def main():
graph = build_graph()
config: RunnableConfig = {
"configurable": {
"thread_id": "react-memory-demo-1"
}
}
messages=[]
while True:
user_input = input("请输入你的问题(输入 exit 退出):").strip()
if user_input.lower() == "exit":
print("已退出。")
break
messages.append(("user", user_input))
result = graph.invoke(
{
"messages": messages
},
config=config
)
messages = result["messages"]
final_message = messages[-1]
print("\nAgent 回复:")
print(final_message.content)
print()
if __name__ == "__main__":
main()
十、总结
这个最小 ReAct 项目最想说明的一点是:
ReAct 并不神秘。它的本质,就是让模型在消息历史基础上不断判断:现在该不该行动、该调用什么工具、拿到结果后还要不要继续下一轮。
放到代码里,它最终只落成了几件事:
- 一个
messages状态 - 一个模型节点
- 一个工具节点
- 一条
模型 -> 工具 -> 模型的循环边
如果说之前《ReAct 是什么》讲的是概念层的“推理-行动循环”,那么这篇代码拆解讲的就是:
这个循环在最小实现里到底长什么样。
等把这个最小版本吃透以后,再去看更复杂的 Workflow、Checkpoint、Middleware、State 设计,就不会再觉得那些东西是凭空出现的“高级黑盒”,而会知道它们只是建立在这个最小循环之上的工程扩展。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)