第二篇:Agent 是怎么转起来的
上一篇讲了骨架:状态、节点、边。这一篇给图装上一个会自己判断下一步的模型,涉及 test3.py、test2.py、test12.py 和 test13.py。
工具先要有说明书
from langchain_core.tools import tool
@tool
def add_tool(a: int, b: int) -> int:
"""计算两个整数之和"""
return a + b
test3.py 只做了一件事,验证工具定义能不能被模型看懂。docstring 是给模型看的说明,参数类型会被自动转成 JSON Schema。写不清楚说明,模型就调错参数,这个和提示词质量是一回事。
ReAct 是个回环
我原以为 ReAct 是某种特殊 Agent 结构。拆开看,它就是一个带条件边的循环:
START → agent ──有 tool_calls──→ tools → agent
└─没有──────────→ END
builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
agent 调模型,模型可能返回若干 tool_calls。tools_condition 看到这些调用就去 tools 节点,ToolNode 执行工具并把结果包成 ToolMessage 塞回消息列表。下一轮模型看到工具结果,自己决定继续调工具还是给答案。
test2.py 是完整版的这个循环,代码不到一百行。
并行与异常
test12.py 试了两件事。模型一次返回多个 tool_calls 时,ToolNode 会并行执行,不需要自己写并发。工具抛异常时,错误信息会作为工具结果回传给模型,模型看到参数错误会自己改一次再调。这个机制救过我几次,尤其是工具参数格式写错的时候。
四个护栏
test13.py 是我把几个工程细节塞在同一个脚本里的一次尝试:
config = {"recursion_limit": 25}
recursion_limit 是回环上唯一的刹车。模型反复调用同一个工具时,没有这个上限就会一直转。我设 25,够两三次工具往返。
RetryPolicy(max_attempts=3) 挂在节点上,处理限流和网络抖动。它会重试节点本身,不是重试整个图。
llm.with_structured_output(Schema) 强制模型按 Pydantic 模型输出,省掉解析 JSON 的防御代码。test14.py 的 supervisor 路由就是靠它做出来的。
stream_mode="messages" 配合前端做流式展示,前面提过,这里不重复。
一个副作用
test4.py 在 ReAct 循环上加了一个 MemorySaver,用来维持多轮对话。代码很简单:
graph = builder.compile(checkpointer=memory)
不加的时候,每次 invoke 都从空状态开始,用户说"再来一个",模型不知道"一个"指什么。加上之后,同一个 thread_id 的对话会接着上一轮继续。
这个 MemorySaver 只是临时方案,进程退出数据就没了。第三篇把 checkpointer 展开讲。
完整示例
这一段需要模型,把三个环境变量配好就能跑:
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://api.siliconflow.cn/v1
MODEL_NAME=deepseek-ai/DeepSeek-V4-Flash
# demo_02_agent.py
import os
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import MemorySaver
@tool
def multiply(a: int, b: int) -> int:
"""计算两个整数的乘积"""
return a * b
@tool
def divide(a: int, b: int) -> float:
"""计算 a 除以 b,b 不能为 0"""
if b == 0:
raise ValueError("除数不能为 0")
return a / b
tools = [multiply, divide]
llm = ChatOpenAI(
model=os.getenv("MODEL_NAME", "deepseek-ai/DeepSeek-V4-Flash"),
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL", "https://api.siliconflow.cn/v1"),
temperature=0,
)
llm_with_tools = llm.bind_tools(tools)
def agent_node(state: MessagesState):
return {"messages": [llm_with_tools.invoke(state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition)
builder.add_edge("tools", "agent")
graph = builder.compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "demo-2"}, "recursion_limit": 25}
graph.invoke(
{"messages": [("user", "先算 123 乘 456,再用结果除以 9")]},
config,
)
for message in graph.get_state(config).values["messages"]:
message.pretty_print()
运行 python demo_02_agent.py。输出里能看到四次消息:用户提问、模型发起 multiply 调用、ToolMessage 返回 56088、模型再发起 divide 调用,最后一轮给出答案。
改一下问题,比如"456 除以 0 等于几",会看到工具抛出的错误被包成消息,模型自己解释除数为 0。
第二篇收尾
到这里,图能跑、能调工具、能循环、能记住几轮对话。下一篇处理两个更硬的问题:快照怎么落到数据库,以及图怎么在中间停下来等人。
系列目录:
-
第一篇:一张状态图是怎么跑起来的
-
第二篇:Agent 是怎么转起来的
-
第三篇:让图记住,也让它停下来
-
第四篇:多个 Agent 怎么分工
-
第五篇:从本地脚本到 HTTP 服务