用 LangGraph 写 ReAct
第 7 章我们手写过一个 ReAct 智能体:循环里"模型想 → 执行工具 → 把结果喂回去",直到模型不再要求调工具。用 LangGraph 重写时,这个循环变成一张只有两个节点的图:agent 节点负责思考并决定是否调工具,tools 节点负责执行,中间用一条条件边判断"还要不要继续"。本章给出完整可跑代码与一次真实运行轨迹。
绑定工具:model.bind_tools
让模型"知道有哪些工具可用、并返回结构化调用请求",用 bind_tools 即可:
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
@tool
def add(a: int, b: int) -> int:
"""计算两个整数的和"""
return a + b
model = ChatOpenAI(
model=os.getenv("LLM_MODEL", "deepseek-chat"),
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL"),
)
model_with_tools = model.bind_tools([add])
说明:bind_tools 走的是模型原生 function calling,回复里带结构化 tool_calls 字段,框架解析可靠;另一条路是第 5~7 章的做法——不用 bind_tools,靠提示词让模型输出固定 JSON 再自行解析,灵活但对提示与容错要求高。本系列统一用 bind_tools。
图结构:agent -> tools -> agent ... 直到 END
messages 字段用 add_messages 做 Reducer,保证每轮产生的消息都追加进历史,模型始终能看到完整对话。
from typing import Annotated, TypedDict
from langchain_core.messages import HumanMessage
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
class State(TypedDict):
messages: Annotated[list, add_messages]
def agent(state):
reply = model_with_tools.invoke(state["messages"])
return {"messages": [reply]}
def should_continue(state):
last = state["messages"][-1]
# 最后一条消息还带着工具调用请求 -> 去执行工具;否则回答完毕,结束
return "tools" if getattr(last, "tool_calls", None) else END
builder = StateGraph(State)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode([add])) # 预置节点:执行工具并回填结果
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
builder.add_edge("tools", "agent") # 工具结果送回 agent,形成循环
graph = builder.compile()
ToolNode 是 langgraph.prebuilt 提供的现成工具执行节点:它读出消息里的 tool_calls,逐个调用对应函数,再把 ToolMessage 追加回 messages。工具结果回到 agent 后,模型基于新信息继续推理,可能再次要求调工具,也可能给出最终回答走向 END。
完整运行与轨迹输出
def run(question):
out = graph.invoke({"messages": [HumanMessage(question)]})
for msg in out["messages"]:
who = type(msg).__name__
content = msg.content or str(getattr(msg, "tool_calls", ""))
print(f"[{who}] {content}")
if who == "ToolMessage":
print(f" 工具返回:{msg.content}")
run("25 加 17 等于多少?请用工具计算")
运行一次的真实轨迹(模型措辞可能不同,结构一致):
[HumanMessage] 25 加 17 等于多少?请用工具计算
[AIMessage] [{'name': 'add', 'args': {'a': 25, 'b': 17}, 'id': 'call_xxx'}] <- 模型请求调工具
[ToolMessage] 42
[AIMessage] 25 加 17 等于 42。
注意中间并没有出现第三个节点:ToolMessage 回到 agent 后,条件边发现最后一条消息不再含 tool_calls,于是走向 END。如果换成"先算价格再算税费"这类问题,轨迹会变成 agent→tools→agent→tools→agent→END 的多轮循环,图本身不用改——这就是把循环交给框架的好处。
多工具与循环上限
要挂更多工具,直接扩展列表即可:
tools = [add, subtract, multiply_by] # 每个都是 @tool 函数
model_with_tools = model.bind_tools(tools)
循环由条件边天然承载,但模型可能"停不下来",生产上建议加保护:在 should_continue 里统计轮数(例如读 messages 中 AIMessage 个数),超过上限就返回 END,避免无限调用消耗额度。
防死循环:给条件边加上限
模型偶尔会"停不下来",在 should_continue 里加轮次上限更稳妥:
MAX_TOOL_ROUNDS = 5
def should_continue(state):
last = state["messages"][-1]
if not getattr(last, "tool_calls", None):
return END # 不再请求工具,收尾
# 从历史消息里数已经发生过的工具调用轮次
used = sum(1 for m in state["messages"] if getattr(m, "tool_calls", None))
return "tools" if used < MAX_TOOL_ROUNDS else END
计数直接从消息历史推导,无需新增状态字段;超过上限强制走 END,保住预算。
工具执行出错怎么办
ToolNode 不会让图崩溃:函数抛出的异常会被包装成 ToolMessage 回填给模型,模型看到报错文本后往往自己修正参数重试。工具内部也可自行兜底:
@tool
def divide(a: float, b: float) -> str:
"""两个数相除"""
if b == 0:
return "错误:除数不能为 0"
return str(a / b)
常见问题
- 模型一直不调工具?检查 bind_tools 是否生效、函数 docstring 是否写清用途;必要时在系统提示里点明"涉及计算请使用工具"。
- 想实时展示"正在调哪个工具"?把 run() 的事后打印换成第 21 章的 stream 逐节点输出,边执行边展示。
- 与第 7 章手写 ReAct 的差别?手写版要自己维护 while 循环、解析 tool_calls、拼接消息;图版交给条件边与 ToolNode,逻辑等价但更可读、可持久化、可人工打断(见第 19~20 章)。
小结:用 LangGraph 写 ReAct = 两个节点(agent/tools)+ 一条条件边(还有 tool_calls 就回 agent,否则 END);model.bind_tools 声明工具、ToolNode 负责执行,历史消息靠 add_messages 自动累积,多轮工具循环不用自己写 while。