阶段:G1-核心心智模型 | 预计耗时:90 分钟(本周压轴)| 前置:G1-02 State 与 Reducer 代码:要你自己敲成
langgraph-learn/examples/g1_handmade_react.py(不用 create_agent) 目标:G1-01 说过"create_agent 内部是循环图"——这次亲手把它写出来
学习目标
- 掌握
StateGraph五个动词:add_node / add_edge / add_conditional_edges / compile / invoke - 亲手搭 agent ⇄ tools 循环,理解条件边 router 是 Agent 的"大脑开关"
- 知道循环上限
recursion_limit防什么
核心知识
1. 最小 StateGraph 全景(五个动词一张图)
from langgraph.graph import StateGraph, START, END
builder = StateGraph(State) # 1. 拿一张空图
builder.add_node("agent", agent_node) # 2. 摆节点
builder.add_node("tools", tool_node)
builder.add_edge(START, "agent") # 3. 定入口
builder.add_conditional_edges("agent", router, ["tools", END]) # 4. 条件边
builder.add_edge("tools", "agent") # 工具完回来接着想
graph = builder.compile() # 5. 编译成可执行图
graph.invoke({"messages": [...]}) # 开跑
2. 条件边 = Agent 的"继续还是停"开关
import json
def router(state: State) -> str:
last = state["messages"][-1]
if getattr(last, "tool_calls", None): # 模型要求调工具?
return "tools"
return "__end__" # 说人话了 → 结束
Agent 的本质就这一段:每轮问"上一条 AI 消息里有没有 tool_calls"——有就执行工具再回炉,没有就交作业。
3. 工具执行节点:按模型点的菜做菜
模型在 tool_calls 里说"我要 add(3,5)",tools 节点负责执行并包成 ToolMessage 塞回 messages——模型下一轮才看得到结果。(骨架用 llm.bind_tools(tools) 绑定;执行循环可先用最朴素的 if/elif 手工分发,G4 再上 ToolNode。)
实验任务
任务 1:敲出 handmade ReAct(50 分钟,卡住 20 分钟才准看文档)
# g1_handmade_react.py —— 不用 create_agent 的 ReAct 循环
import os
from typing import Annotated, TypedDict
from langchain_openai import ChatOpenAI
from langchain_core.messages import AnyMessage, ToolMessage, HumanMessage
from langchain_core.tools import tool
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
@tool
def add(a: int, b: int) -> int:
"""两数相加"""
return a + b
@tool
def multiply(a: int, b: int) -> int:
"""两数相乘"""
return a * b
TOOLS = {t.name: t for t in [add, multiply]}
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages]
llm = ChatOpenAI(model=os.environ.get("LG_MODEL", "Qwen/Qwen3.5-4B"),
api_key=os.environ["SILICONFLOW_API_KEY"],
base_url="https://api.siliconflow.cn/v1", temperature=0).bind_tools(list(TOOLS.values()))
def agent_node(state: State) -> dict:
return {"messages": [llm.invoke(state["messages"])]}
def tool_node(state: State) -> dict:
last = state["messages"][-1]
out = []
for call in last.tool_calls: # 可能点多个菜
result = TOOLS[call["name"]].invoke(call["args"])
out.append(ToolMessage(content=str(result), tool_call_id=call["id"]))
return {"messages": out}
def router(state: State) -> str:
return "tools" if state["messages"][-1].tool_calls else END
builder = StateGraph(State)
builder.add_node("agent", agent_node); builder.add_node("tools", tool_node)
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", router)
builder.add_edge("tools", "agent")
graph = builder.compile()
result = graph.invoke({"messages": [HumanMessage(content="请计算 (3+5)×12")]})
for m in result["messages"]:
print(f"[{m.__class__.__name__}] {m.content or '(工具调用)'}")
任务 2:与 create_agent 对账(20 分钟)
同一个问题分别跑 01_tool_agent.py 和本脚本,对比消息轨迹条数与内容——不一致的地方就是你手写版和框架版的差异点,逐条写进笔记。
任务 3:破坏实验(15 分钟)
把 router 永远返回 "tools"(不返回 END)再跑——观察 GraphRecursionError。这就是 recursion_limit(默认 25)防的事:Agent 死循环烧钱烧到天亮。
验收标准
G1 毕业检查点(对应路线图检查点 1):能说清"为什么 Agent 是图不是链";能解释 Reducer 为什么必须存在。
衔接下一阶段
循环会搭了 → G2-01 Command 与动态跳转:比条件边更灵活的跳转方式。