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 在生成回复时有两个选择:

  1. 正常回复文本——"答案是 42"
  2. 返回一个工具调用请求——"我需要调用 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 额度耗尽。从本章开始,每一个有循环的图都必须至少有一重防护。


本章小结

  1. 条件边add_conditional_edges)让图根据 State 内容动态选择下一站——路由函数返回节点名,path_map 映射到实际节点。
  2. ReAct 模式是 AI Agent 的核心循环:思考(LLM 决定调什么工具)→ 行动(执行工具)→ 观察(把结果交回 LLM),循环直到得出最终答案。
  3. @tool 装饰器把 Python 函数变成 LLM 可理解的工具——自动提取名称、描述、参数 Schema。
  4. Annotated[list, add_messages] 是消息列表的 reducer——确保新消息追加而非覆盖旧消息。忘记它是最常见的错误之一。
  5. ToolMessage + tool_call_id 是工具调用结果的标准格式——LLM 靠 tool_call_id 配对请求和结果。
  6. 循环必须有防护:迭代计数器(应用层)+ RemainingSteps(框架层)+ recursion_limit(运行时)三层保障。
  7. 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() 的运行时配置,超过步数上限时强制抛出异常

posted @ 2026-06-16 11:39  Yobeeo  阅读(1)  评论(0)    收藏  举报