Agent 的思考循环——条件路由与工具调用 — LangGraph 实战——构建跨平台爆款图文 Agent 第2篇
第2章:Agent 的思考循环——条件路由与工具调用
本章目标
读完本章你会:
- 能定义条件边(
add_conditional_edges),让图根据状态内容动态选择下一步 - 能用
@tool装饰器定义工具,并绑定到 LLM - 能实现 ReAct 模式——Agent 在"思考→行动→观察"之间循环,直到得出最终答案
- 能接入 DeepSeek API 作为 LLM 后端
- 能给循环添加停止条件(迭代上限 +
RemainingSteps),防止无限循环烧 API 额度
知识讲解
从一个生活例子开始
你开车用导航。从家到公司,导航不是在你出发前就画死一条路线——它会根据实时情况动态调整:
出发点 → 前方畅通?→ 是 → 继续走高速 → 到达
→ 否 → 前方堵车?→ 是 → 下高速走辅路 → 到达
→ 否 → 事故封路?→ 是 → 绕行三环 → 到达
每个决策点,导航系统做的事完全一样:评估当前状态 → 选择一个行动 → 执行行动 → 观察结果 → 继续评估。 这就是条件路由。
LangGraph 里,条件边就是导航系统的"决策点"。它和固定边(add_edge)的关键区别:
| 固定边 | 条件边 | |
|---|---|---|
| 下一站怎么定 | 永远走同一个方向 | 根据 State 内容动态决定 |
| 比喻 | 传送带——固定路线 | 导航——实时决定路口方向 |
| API | add_edge("A", "B") |
add_conditional_edges("A", router_func, path_map) |
思考一下: 如果导航系统每次到达路口都要停下来问司机"走哪条路?",这对应 LangGraph 里的什么概念?(提示:第 4 章会讲到,但你可以先想想。)
工作原理
ReAct 模式:Agent 的"呼吸"
ReAct(Reasoning + Acting)是当前 AI Agent 最核心的工作模式。它的循环只有三步:
1. 思考(Reason):基于当前信息,我需要做什么?
2. 行动(Act): 调用一个工具(搜索、计算、查数据库...)
3. 观察(Observe): 工具返回了什么?这个结果是否足够回答问题?
↓ 如果不够 → 回到第 1 步
↓ 如果够了 → 输出最终答案
用图来表示就是:
START → agent_node → [条件判断]
↓
需要调工具?→ 是 → tool_node → agent_node(循环)
↓
否 → END
这就是 LangGraph 最经典的图结构——Agent ↔ Tool 循环。你会在本章的代码中亲手构建它。
工具是什么?
在 LangGraph 中,工具(Tool) 就是一个带有结构化输入输出的 Python 函数。和普通函数相比,工具多了一层"说明书"——LLM 通过这份说明书理解工具的功能、参数类型和返回值,然后决定何时调用它。
一个工具的定义包含:
- 名称:LLM 用它来引用这个工具
- 描述:LLM 判断"在什么情况下应该用这个工具"的依据
- 参数 schema:工具需要什么参数,每个参数是什么类型、什么含义
@tool 装饰器会自动从函数的 docstring 和类型注解中提取这些信息。
LLM 怎么知道调用哪个工具?
当你把工具列表传给 LLM 时,LangChain 会把工具的定义转换成 LLM 能理解的格式(function calling schema)。LLM 在生成回复时有两个选择:
- 正常回复文本——"答案是 42"
- 返回一个工具调用请求——"我需要调用
search(query='LangGraph')"
如果是第 2 种情况,LangGraph 不会把 LLM 的回复当作最终答案,而是拦截这个请求,执行对应的工具函数,把工具结果追加到消息历史中,然后再次调用 LLM。这就是 ReAct 循环的核心。
⚠️ 常见坑:工具执行后的结果必须是
ToolMessage类型,不能是普通字符串。如果 LLM 发起了工具调用但你没有返回对应的ToolMessage,下一次 LLM 调用会报INVALID_CHAT_HISTORY错误。代码实战中会演示正确写法。
条件路由函数怎么写?
条件边需要一个路由函数,它的工作是:
def router(state: State) -> str:
# 检查最后一条消息
# 如果 LLM 要调工具 → 返回 "tools"
# 如果 LLM 给出了最终答案 → 返回 "__end__"
return "tools" # 或 END
返回值必须是以下之一:
- 一个已注册的节点名(如
"tools") END(图结束)- 在
path_map中已映射的字符串
代码实战
环境准备
本章需要新增两个包。在终端运行:
pip install -U langchain-openai langchain-core
langchain-openai提供与 OpenAI 兼容 API 的接口——DeepSeek 的 API 恰好是 OpenAI 兼容的,所以我们用它。langchain-core提供@tool装饰器和消息类型。
基础版:条件边 + 模拟路由
在接入 LLM 之前,先用纯逻辑理解条件边。新建文件 chapter02_conditional_edge.py:
"""
第 2 章 基础演示 A:条件边
理解路由函数 + add_conditional_edges 的工作方式
"""
from typing import TypedDict, Literal
from langgraph.graph import StateGraph, START, END
# ============================================================
# Step 1: 定义 State
# ============================================================
class RouterState(TypedDict):
content_type: str # 内容类型:article / video / unknown
content: str # 内容本身
result: str # 处理结果
# ============================================================
# Step 2: 定义节点——每个节点处理一种内容类型
# ============================================================
def classify_content(state: RouterState) -> dict:
"""根据内容特征判断类型。实际场景中这可能是 LLM 调用。"""
content = state["content"]
if "视频" in content or "video" in content:
return {"content_type": "video"}
elif "文章" in content or "article" in content:
return {"content_type": "article"}
else:
return {"content_type": "unknown"}
def handle_article(state: RouterState) -> dict:
"""处理文章类内容——提取摘要"""
return {"result": f" 文章分析完毕:{state['content'][:30]}..."}
def handle_video(state: RouterState) -> dict:
"""处理视频类内容——生成字幕摘要"""
return {"result": f" 视频分析完毕:{state['content'][:30]}..."}
def handle_unknown(state: RouterState) -> dict:
"""处理未知类型——标记为待人工审核"""
return {"result": f"❓ 无法识别内容类型,标记为待审核"}
# ============================================================
# Step 3: 定义路由函数——这就是条件边的"决策逻辑"
# ============================================================
def content_router(state: RouterState) -> Literal["article", "video", "unknown"]:
"""根据 content_type 决定走哪个分支。
返回值必须是已注册的节点名字符串。
"""
content_type = state["content_type"]
print(f"[router] 内容类型: {content_type}")
if content_type == "article":
return "article"
elif content_type == "video":
return "video"
else:
return "unknown"
# ============================================================
# Step 4: 构建图——关键区别是 add_conditional_edges
# ============================================================
def build_graph() -> StateGraph:
builder = StateGraph(RouterState)
# 注册节点
builder.add_node("classify", classify_content)
builder.add_node("article", handle_article)
builder.add_node("video", handle_video)
builder.add_node("unknown", handle_unknown)
# 固定边:入口
builder.add_edge(START, "classify")
# 条件边:从 classify 出发,根据 router 函数动态选择
builder.add_conditional_edges(
"classify", # 从哪个节点出发
content_router, # 路由函数 (state) -> str
{ # 路由结果 → 目标节点的映射
"article": "article",
"video": "video",
"unknown": "unknown",
}
)
# 三个处理节点都通向 END
builder.add_edge("article", END)
builder.add_edge("video", END)
builder.add_edge("unknown", END)
return builder.compile()
if __name__ == "__main__":
graph = build_graph()
# 测试 1:文章类内容
print("=== 测试 1:文章内容 ===")
result = graph.invoke({"content_type": "", "content": "这是一篇关于 AI 的文章", "result": ""})
print(f"结果: {result['result']}\n")
# 测试 2:视频类内容
print("=== 测试 2:视频内容 ===")
result = graph.invoke({"content_type": "", "content": "这是一个关于机器学习的视频教程", "result": ""})
print(f"结果: {result['result']}\n")
# 测试 3:未知类型
print("=== 测试 3:未知内容 ===")
result = graph.invoke({"content_type": "", "content": "随便写点什么", "result": ""})
print(f"结果: {result['result']}")
运行后观察:同一个图,三种不同输入走了三条不同的路径。这就是条件边的威力——图的结构可以根据数据内容动态变化。
基础版:接入 DeepSeek + 工具调用 + ReAct 循环
现在你是真的接入 LLM 了。新建文件 chapter02_react_agent.py:
"""
第 2 章 基础演示 B:ReAct Agent
接入 DeepSeek API,实现思考→行动→观察循环
LocalTrend 主线项目——Agent 获得"搜索"能力
"""
import os
from typing import TypedDict, Annotated, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
# LangChain 核心:消息类型和工具定义
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage
from langchain_core.tools import tool
# DeepSeek 通过 OpenAI 兼容接口接入
from langchain_openai import ChatOpenAI
# ============================================================
# Step 0: 配置 DeepSeek API
# ============================================================
# 推荐用环境变量,避免把 key 硬编码在代码里
# 在终端运行:set DEEPSEEK_API_KEY=sk-xxxxxxxx (Windows CMD)
# 或: $env:DEEPSEEK_API_KEY="sk-xxxxxxxx" (PowerShell)
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "your-api-key-here")
llm = ChatOpenAI(
model="deepseek-chat", # DeepSeek 的模型名
api_key=DEEPSEEK_API_KEY,
base_url="https://api.deepseek.com", # DeepSeek 的 API 地址
temperature=0.7,
)
# ============================================================
# Step 1: 定义 State——重点看 messages 字段的 Annotated
# ============================================================
class LocalTrendState(TypedDict):
"""LocalTrend 项目状态。
messages 使用了 Annotated + add_messages reducer,
确保每次追加消息而非覆盖。
"""
messages: Annotated[list, add_messages]
# 第 2 章新增字段:迭代计数器,防止无限循环
iteration_count: int
# ============================================================
# Step 2: 定义工具——LocalTrend 的"手"
# ============================================================
@tool
def search_trending(query: str) -> str:
"""搜索当前热门话题/爆款内容。当你需要了解某个领域的最新趋势时调用。
参数 query: 搜索关键词,例如 '微信公众号 爆款文章 2026'
"""
# ⚠ 实际项目中这里会调用 Tavily/Serper/自己爬虫等真实搜索 API
# 本章先用模拟数据,让焦点保持在 LangGraph 图结构上
print(f" [工具调用] 搜索: {query}")
trending_results = {
"AI": "近期 AI 相关爆款主题:Agent 开发、RAG 实战、多模态应用。标题关键词:'从零'、'实战'、'2026最新'。",
"职场": "职场爆款模式:反焦虑叙事、副业案例、AI 替代焦虑。高频互动源于共鸣+解决方案组合。",
"科技": "科技类爆款:折叠屏手机评测、AI PC 体验、智能家居联动。图文结合+真实体验为流量密码。",
}
for key, value in trending_results.items():
if key in query:
return value
return f"关于'{query}'的搜索结果:该领域当前热点围绕实用教程、案例分析和行业趋势预测展开。"
@tool
def analyze_style(content: str) -> str:
"""分析一段内容的写作风格和爆款特征。当你需要总结某篇内容的成功要素时调用。
参数 content: 需要分析的内容文本
"""
print(f" [工具调用] 分析风格(内容长度: {len(content)} 字符)")
# 模拟分析结果——实际场景中这里由 LLM 做内容分析
return (
f"风格分析结果:\n"
f"- 标题模式:含有数字 + 情感词,点击率预估提升 40%\n"
f"- 开头策略:痛点场景切入,3 秒内建立共鸣\n"
f"- 结构特征:分点论述 + 案例穿插,完读率较高\n"
f"- 互动设计:文末设置开放性问题引导评论"
)
# ============================================================
# Step 3: 将工具绑定到 LLM
# ============================================================
tools = [search_trending, analyze_style]
llm_with_tools = llm.bind_tools(tools)
# ============================================================
# Step 4: 定义节点函数
# ============================================================
def agent_node(state: LocalTrendState) -> dict:
"""Agent 的"思考"环节:调用 LLM,让它决定下一步做什么。
这是 ReAct 循环的 Reasoning 步骤。
"""
messages = state["messages"]
iteration = state.get("iteration_count", 0)
print(f"\n{'='*50}")
print(f"[Agent 思考] 第 {iteration + 1} 轮")
# 调用 LLM——它可能直接回复文本,也可能返回工具调用请求
response = llm_with_tools.invoke(messages)
# 打印 LLM 的决策
if hasattr(response, "tool_calls") and response.tool_calls:
for tc in response.tool_calls:
print(f" 决定调用工具: {tc['name']}({tc.get('args', {})})")
else:
print(f" ✅ 最终回复: {response.content[:80]}...")
return {
"messages": [response],
"iteration_count": iteration + 1,
}
def tool_node(state: LocalTrendState) -> dict:
"""执行工具调用的节点。这是 ReAct 循环的 Acting 步骤。
关键逻辑:
1. 取出最后一条 AI 消息中的 tool_calls
2. 逐个执行对应的工具函数
3. 把每个工具返回值包装成 ToolMessage
"""
last_message = state["messages"][-1]
tool_calls = last_message.tool_calls
print(f" [工具执行] 共 {len(tool_calls)} 个工具待执行")
tool_messages = []
for tc in tool_calls:
tool_name = tc["name"]
tool_args = tc["args"]
# 根据工具名找到对应的工具函数并执行
tool_map = {t.name: t for t in tools}
tool_func = tool_map[tool_name]
result = tool_func.invoke(tool_args)
# ⚠ 重要:返回值必须包装成 ToolMessage,tool_call_id 是配对关键
tool_messages.append(ToolMessage(
content=result,
tool_call_id=tc["id"],
))
print(f" ✅ {tool_name} 执行完毕")
return {"messages": tool_messages}
# ============================================================
# Step 5: 路由函数——决定继续循环还是结束
# ============================================================
def should_continue(state: LocalTrendState) -> Literal["tools", "__end__"]:
"""检查最后一条 AI 消息:
- 如果有 tool_calls → 去 tool_node 执行
- 如果没有 → 结束图
"""
last_message = state["messages"][-1]
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
return "tools"
return END
# ============================================================
# Step 6: 构建 ReAct 图
# ============================================================
def build_react_graph() -> StateGraph:
builder = StateGraph(LocalTrendState)
builder.add_node("agent", agent_node)
builder.add_node("tools", tool_node)
builder.add_edge(START, "agent")
# 核心:条件边让 agent 和 tools 之间形成循环
builder.add_conditional_edges(
"agent",
should_continue,
{
"tools": "tools", # 需要调工具 → 去 tools 节点
END: END, # 不需要 → 结束
}
)
# tool_node 执行完后回到 agent_node——形成循环
builder.add_edge("tools", "agent")
return builder.compile()
# ============================================================
# Step 7: 运行
# ============================================================
if __name__ == "__main__":
graph = build_react_graph()
# 用户问题
user_input = "帮我搜索 AI 领域的爆款内容趋势,并分析一下这类内容的写作风格特点。"
print("=" * 60)
print(f" 用户: {user_input}")
print("=" * 60)
result = graph.invoke({
"messages": [HumanMessage(content=user_input)],
"iteration_count": 0,
})
# 打印所有消息,观察 ReAct 循环的全过程
print(f"\n{'='*60}")
print(" 完整消息历史:")
print("=" * 60)
for i, msg in enumerate(result["messages"]):
role = type(msg).__name__
content = str(msg.content)[:120] if hasattr(msg, 'content') and msg.content else ""
tool_info = ""
if hasattr(msg, 'tool_calls') and msg.tool_calls:
tool_info = f" [tool_calls: {[tc['name'] for tc in msg.tool_calls]}]"
print(f" [{i}] {role}: {content}{tool_info}")
逐行解析新概念
Annotated[list, add_messages]
这是本章最重要的新概念。Annotated 告诉 LangGraph:"这个字段不是简单地用新值覆盖旧值,而是用 add_messages 函数来合并。" 具体来说,add_messages 会把新消息追加到消息列表末尾,而不是替换整个列表。没有它,每次 agent_node 返回新消息时,之前的对话历史就全丢了。
⚠️ 常见坑:如果你把
messages定义成普通的list(不加Annotated),每个节点返回的{"messages": [...]}会覆盖之前的所有消息。你会发现自己只能看到最后一条消息,前面的对话凭空消失了。
@tool 装饰器
@tool 把普通 Python 函数升级为"LLM 可理解的工具"。装饰器自动从函数的:
def search_trending(query: str) -> str:→ 提取参数名和类型"""搜索当前热门话题..."""→ 提取描述(LLM 用来判断何时调用)- 参数类型注解 → 生成 JSON Schema
LLM 不需要知道函数内部怎么实现——它只需要知道"我能用这个工具做什么 + 需要传什么参数"。
llm.bind_tools(tools)
bind_tools 把工具定义注入到 LLM 的每次调用中。绑定后,LLM 的回复可能包含 tool_calls——一个列表,每个元素是 {"name": "search_trending", "args": {"query": "AI 爆款"}, "id": "call_xxx"}。如果 LLM 选择不调工具,tool_calls 就是空的。
should_continue 路由函数
这个函数检查最后一条 AI 消息是否带有 tool_calls。如果有——说明 LLM 想调工具,路由到 "tools" 节点;如果没有——说明 LLM 给出了最终文本回复,路由到 END。这就是 ReAct 循环的"呼吸节奏"。
ToolMessage(content=result, tool_call_id=tc["id"])
tool_call_id 是关键字段。每个工具调用请求都有一个唯一 ID,对应的结果必须带上相同的 ID——LLM 靠这个 ID 把"我问了什么"和"答案是什么"配对起来。如果 ID 不匹配或缺少 ToolMessage,下一次 LLM 调用会报 INVALID_CHAT_HISTORY。
扩展版:添加循环安全机制
上面的 ReAct 循环有一个隐患:如果 LLM 反复调工具、始终不给出最终答案怎么办?每次工具调用都在烧 API 额度。本章给你两种防护方案。
"""
第 2 章 扩展演示:带安全防护的 ReAct Agent
"""
import os
from typing import TypedDict, Annotated, Literal
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.managed import RemainingSteps # 内置步数追踪
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
# ...(tool 定义和 llm 配置与基础版相同,此处省略以减少重复)...
class SafeLocalTrendState(TypedDict):
"""带安全防护的 State"""
messages: Annotated[list, add_messages]
iteration_count: int
# RemainingSteps 由 LangGraph 自动注入,不需要手动设置
remaining_steps: RemainingSteps
# ...(agent_node、tool_node 的实现与基础版相同)...
def safe_should_continue(state: SafeLocalTrendState) -> Literal["tools", "__end__"]:
""" 增强版路由函数——增加两重防护"""
last_message = state["messages"][-1]
# 防护 1: 硬性迭代上限(兜底 + 清晰的报错)
MAX_ITERATIONS = 8
if state.get("iteration_count", 0) >= MAX_ITERATIONS:
print(f" ⚠️ 达到最大迭代次数 {MAX_ITERATIONS},强制结束。")
return END
# 防护 2: RemainingSteps(LangGraph 内置,更优雅的限流方式)
if state["remaining_steps"] < 2:
print(f" ⚠️ 剩余步数不足,强制结束。")
return END
# 正常的条件判断
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
return "tools"
return END
def build_safe_graph() -> StateGraph:
builder = StateGraph(SafeLocalTrendState)
builder.add_node("agent", agent_node)
builder.add_node("tools", tool_node)
builder.add_edge(START, "agent")
builder.add_conditional_edges(
"agent",
safe_should_continue,
{"tools": "tools", END: END}
)
builder.add_edge("tools", "agent")
return builder.compile()
if __name__ == "__main__":
graph = build_safe_graph()
# 设置 recursion_limit 作为第三重防护(LangGraph 运行时级别)
result = graph.invoke(
{
"messages": [HumanMessage(content="搜索 AI 和职场的爆款趋势,分析风格")],
"iteration_count": 0,
},
config={"recursion_limit": 25} # 运行时递归上限
)
print(f"\n总迭代次数: {result['iteration_count']}")
print(f"最终回复: {result['messages'][-1].content[:200]}")
三重安全防护总结:
| 防护层 | 机制 | 级别 | 何时触发 |
|---|---|---|---|
| 1 | iteration_count >= 8 |
应用层 | 你的业务逻辑判断"足够了" |
| 2 | RemainingSteps < 2 |
框架层 | LangGraph 自动注入,更平滑 |
| 3 | recursion_limit=25 |
运行时 | 图执行步数超过限制时抛异常 |
⚠️ 常见坑:初学者经常忘记加任何循环防护。一个 LLM 调错工具 → 重试 → 又调错 → 又重试... 直到 API 额度耗尽。从本章开始,每一个有循环的图都必须至少有一重防护。
本章小结
- 条件边(
add_conditional_edges)让图根据 State 内容动态选择下一站——路由函数返回节点名,path_map映射到实际节点。 - ReAct 模式是 AI Agent 的核心循环:思考(LLM 决定调什么工具)→ 行动(执行工具)→ 观察(把结果交回 LLM),循环直到得出最终答案。
@tool装饰器把 Python 函数变成 LLM 可理解的工具——自动提取名称、描述、参数 Schema。Annotated[list, add_messages]是消息列表的 reducer——确保新消息追加而非覆盖旧消息。忘记它是最常见的错误之一。ToolMessage+tool_call_id是工具调用结果的标准格式——LLM 靠tool_call_id配对请求和结果。- 循环必须有防护:迭代计数器(应用层)+
RemainingSteps(框架层)+recursion_limit(运行时)三层保障。 - DeepSeek 通过 OpenAI 兼容接口接入——
ChatOpenAI(base_url="https://api.deepseek.com")。
关键术语
| 术语 | 释义 |
|---|---|
| 条件边(Conditional Edge) | 通过路由函数动态决定下一站,而非固定的 A→B 流向 |
| ReAct | Reasoning + Acting 循环,AI Agent 最核心的工作模式 |
| Tool(工具) | LLM 可调用的函数,通过 @tool 装饰器定义,附带参数 Schema |
| bind_tools | 将工具列表注入 LLM,使其能返回工具调用请求 |
| tool_calls | LLM 回复中的工具调用请求,每个包含 name、args、id |
| ToolMessage | 工具执行结果的标准消息格式,tool_call_id 用于配对 |
| add_messages | LangGraph 内置的 message reducer,追加而非覆盖消息 |
| RemainingSteps | LangGraph 内置的步数追踪字段,用于防止无限循环 |
| recursion_limit | invoke() 的运行时配置,超过步数上限时强制抛出异常 |

浙公网安备 33010602011771号