AI 全栈学习之旅 -Week 8:从单 Agent 到多 Agent 协作:LangGraph 实战与记忆持久化

一、引言

在过去的一个月里,我从零开始搭建了一个基于 LangGraph 的多 Agent 协作系统。Week 7 我们完成了单个 Agent 的流式推送与部署,而 Week 8 则是从"单兵作战"进化到"团队协作"的关键一周。

本周目标:

  • 掌握 LangGraph 的基本图编排能力
  • 实现 Supervisor 模式的多 Agent 调度
  • 引入长期记忆(SQLite 持久化)
  • 解决 DeepSeek 模型与 LangChain 的兼容性问题
  • 实现并行多 Agent 执行(Map-Reduce)

如果你也有前端背景,你会发现很多概念似曾相识:

  • StateGraph ≈ Redux 的 reducer + 中间件
  • Send API ≈ Promise.all
  • thread_id ≈ 用户 session ID

二、Day 1:初识 LangGraph ------ 状态机思维

2.1 核心概念

LangGraph 本质上是一个有向图状态机。每个节点(Node)是一个函数,接收当前状态,返回状态更新;边(Edge)定义了执行顺序;条件边(Conditional Edge)根据状态值决定下一步走向。

核心概念(全栈类比版)

LangGraph 术语 前端类比 说明
State(状态) Pinia/Vuex 的 store 一个全局的 TypedDict,所有节点都能读写
Node(节点) 一个 API 函数或组件 接收 State,处理后返回要更新的部分
Edge(边) 路由守卫 / 条件判断 决定下一步走到哪个节点
Conditional Edge if-else 逻辑 根据 State 的值动态选择下一个节点
Compile(编译) 构建最终的执行图 类似 webpack 打包,生成可调用的 app

2.2 代码示例(week8_day1.py

python 复制代码
python
from typing import Literal, TypedDict

from langgraph.graph import END, StateGraph


class MyState(TypedDict):
    """共享状态,类似全局 state。"""

    input_text: str
    step_count: int
    final_output: str


def node_process_input(state: MyState) -> dict:
    """节点1:处理输入,将文本转为大写。"""
    print(f"[Node: process_input] 收到: {state['input_text']}")
    return {
        "input_text": state["input_text"].upper(),
        "step_count": state["step_count"] + 1,
    }


def node_check_length(state: MyState) -> dict:
    """节点2:判断文本长度,决定下一步。"""
    length = len(state["input_text"])
    print(f"[Node: check_length] 文本长度: {length}")
    return {
        "step_count": state["step_count"] + 1,
        "final_output": f"文本长度是 {length}",
    }


def router_after_process(state: MyState) -> Literal["check_length", "end"]:
    """如果 step_count 小于 3,继续;否则结束。"""
    if state["step_count"] < 3:
        return "check_length"
    return "end"


builder = StateGraph(MyState)

builder.add_node("process_input", node_process_input)
builder.add_node("check_length", node_check_length)

builder.set_entry_point("process_input")

builder.add_conditional_edges(
    "process_input",
    router_after_process,
    {
        "check_length": "check_length",
        "end": END,
    },
)

builder.add_edge("check_length", "process_input")

app = builder.compile()

initial_state: MyState = {
    "input_text": "Hello LangGraph!",
    "step_count": 0,
    "final_output": "",
}

print("=== 开始运行 LangGraph ===")
result = app.invoke(initial_state)
print("\n=== 最终结果 ===")
print(result)

2.3 踩坑记录

问题add_conditional_edges 的路由映射字典键必须为字符串,不能是列表。

解决 :即使需要并行分发,映射字典中也只能写单个节点名,真正的并行逻辑通过 Send API 实现(Day 7 会讲)。


三、Day 2:ReAct Agent ------ 让 Agent 学会调用工具

3.1 什么是 ReAct?

ReAct = Reasoning + Acting。Agent 先思考(Reasoning),然后决定调用工具(Acting),拿到工具结果后再思考,如此循环直到完成任务。

3.2 代码实现(week8_day2.py

python 复制代码
# 1. 定义工具
@tool
def calculator(expression: str) -> str:
    """评估一个数学表达式。只允许数字和 +, -, *, /, (, )."""
    try:
        # 简单的安全限制,生产环境请用 numexpr 或 sympy
        allowed = set("0123456789+-*/(). ")
        if any(char not in allowed for char in expression):
            return "错误:包含非法字符"
        return str(eval(expression))
    except Exception as exc:
        return f"计算错误: {exc}"


tools = [calculator]


# 2. 定义状态
class AgentState(TypedDict):
    # add_messages 是 LangGraph 的 reducer,会自动把新消息追加到列表中
    messages: Annotated[list, add_messages]


# 3. 定义节点
def agent_node(state: AgentState) -> AgentState:
    """Agent 节点:调用绑定了工具的 LLM。"""
    llm_with_tools = get_llm().bind_tools(tools)
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}


def should_continue(state: AgentState) -> str:
    """路由函数:判断 LLM 是否要求调用工具。"""
    last_message = state["messages"][-1]
    if hasattr(last_message, "tool_calls") and last_message.tool_calls:
        return "tools"
    return END


# 4. 构建图
builder = StateGraph(AgentState)

builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(tools))

builder.add_edge(START, "agent")

builder.add_conditional_edges(
    "agent",
    should_continue,
    {"tools": "tools", END: END},
)

builder.add_edge("tools", "agent")

app = builder.compile()

3.3 代码逻辑与语法解释(前端老手版)

  1. @tool 装饰器 : 把普通 Python 函数变成 Agent 能用的工具。函数的 Docstring​ 极其重要!LLM 全靠读这段描述来决定什么时候调用它。

  2. AgentStateadd_messages :类似前端状态管理。我们定义了一个 messages 数组。add_messages 相当于 Redux 里的 Reducer ,确保每次节点返回新消息时,是追加(Append) 而不是覆盖。

  3. bind_tools:告诉 LLM:"你现在手里有这些工具"。LLM 会在需要时返回特定的 JSON 格式(工具调用请求)而不是直接回答。

  4. ToolNode : LangGraph 提供的预置组件 。它相当于一个自动化的 API 网关,接收 LLM 的工具调用请求,执行对应的 Python 函数,并把结果包装成 ToolMessage 返回给 LLM。

  5. 条件路由 should_continue : 检查最后一条消息有没有 tool_calls。有就去 tools 节点,没有就 END。这形成了 ReAct 循环

  6. 执行流START -> agent (LLM思考,决定调计算器) -> tools (执行计算) -> agent (LLM拿到结果,组织语言回答) -> END


四、Day 3:Supervisor 多 Agent 架构初探

4.1 架构设计

引入一个主管(Supervisor) ​ 节点,它不直接执行任务,而是分析用户意图,将任务分派给不同的专业 Agent(计算专员、搜索专员)。

4.2 代码骨架(week8_day3.py

python 复制代码
# ==================== 1. 定义 LLM ====================
llm = ChatOpenAI(
    model=os.getenv("MODEL_NAME", "deepseek-chat"),
    api_key=os.getenv("DEEPSEEK_API_KEY"),
    base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
    temperature=0,
)

# ==================== 2. 定义工具 ====================
@tool
def calculator(expression: str) -> str:
    """评估一个数学表达式。只允许数字和 +, -, *, /, (, )."""
    try:
        allowed = set("0123456789+-*/(). ")
        if any(char not in allowed for char in expression):
            return "错误:包含非法字符"
        return str(eval(expression))
    except Exception as exc:
        return f"计算错误: {exc}"

@tool
def search_web(query: str) -> str:
    """模拟搜索网页,返回假数据。"""
    return f"搜索结果:关于'{query}'的最新信息是:(假数据) 今天气温 25 度。"

# ==================== 2. 绑定工具给子 Agent ====================
calc_tools = [calculator]
search_tools = [search_web]

# ==================== 3. 定义状态(必须放在节点函数之前) ====================
class AgentState(TypedDict):
    messages: Annotated[list, add_messages]
    next: Annotated[str, lambda x, y: y]   # 记录下一步去向

# ==================== 4. 定义节点函数 ====================
def supervisor_node(state: AgentState) -> dict:
    """主管节点:决定下一个步骤"""
    last_message = state["messages"][-1]
    
    # 如果最后一条消息是工具返回的结果,说明子 agent 已经干完活,主管直接总结
    if last_message.type == "tool":
        response = llm.invoke(state["messages"] + [HumanMessage(content="请根据工具结果总结回答用户。")])
        return {"messages": [response]}
        
    # 否则,主管思考该派发给谁
    response = llm.invoke(
        state["messages"] + 
        [HumanMessage(content="你是主管。请决定下一步:如果需要计算选 'calc',需要搜索选 'search',如果可以直接回答或已完成任务选 'end'。")]
    )
    
    # 简单解析主管的回复来决定路由
    content = response.content.lower()
    if "calc" in content:
        return {"next": "calc"}
    elif "search" in content:
        return {"next": "search"}
    else:
        return {"next": "end"}

def calc_agent_node(state: AgentState) -> dict:
    """计算专员:只处理计算任务"""
    llm_with_tools = llm.bind_tools(calc_tools)
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}

def search_agent_node(state: AgentState) -> dict:
    """搜索专员:只处理搜索任务"""
    llm_with_tools = llm.bind_tools(search_tools)
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}

# ==================== 5. 构建图 ====================
builder = StateGraph(AgentState)

builder.add_node("supervisor", supervisor_node)
builder.add_node("calc", calc_agent_node)
builder.add_node("search", search_agent_node)
builder.add_node("calc_tools", ToolNode(calc_tools))
builder.add_node("search_tools", ToolNode(search_tools))

builder.add_edge(START, "supervisor")

builder.add_conditional_edges(
    "supervisor",
    lambda state: state["next"],
    {"calc": "calc", "search": "search", "end": END}
)

builder.add_edge("calc", "calc_tools")
builder.add_edge("search", "search_tools")
builder.add_edge("calc_tools", "supervisor")
builder.add_edge("search_tools", "supervisor")

app = builder.compile()

核心逻辑解释(前端老手版)

  1. 状态扩展 (next)

    • 我们在 AgentState 里加了一个 next 字段。
    • Annotated[str, lambda x, y: y] 相当于状态覆盖器(类似前端 setState 直接替换值),主管节点通过返回 {"next": "calc"} 来告诉图下一步该干嘛。
  2. Supervisor 节点

    • 它是智能路由器。如果看到是工具返回的消息,就负责总结;如果是用户提问,就分析意图(计算还是搜索)。
    • 注:为了简化演示,这里用文本匹配 calc/search 来路由。生产环境强烈建议给主管模型绑定 function calling,强制它输出结构化 JSON 路由指令。
  3. 子 Agent 与独立工具节点

    • 计算专员和搜索专员各自绑定了自己的工具。
    • 流程变成:Supervisor -> 子Agent -> 专属ToolNode -> 回到 Supervisor
  4. 图的拓扑结构

    • 这是一个星型结构。所有子任务执行完毕后,都必须回到中心节点(Supervisor)进行汇总和结束判定。

五、Day 4:生产级改造 ------ 结构化路由与熔断机制

5.1 核心改进

  1. 使用 with_structured_output:强制 LLM 输出固定格式的 JSON,避免自然语言歧义。
  2. 引入迭代计数器:超过 3 次循环强制结束,防止死循环。
  3. 自定义 ToolNode :给工具结果加上 __agent_result__ 前缀,防止模型误读。

5.2 关键代码(week8_day4.py

python 复制代码
# ==================== 1. 定义工具 ====================
@tool
def calculator(a: int, b: int) -> int:
    """计算两个整数的乘法。"""
    return a * b

@tool
def search_web(query: str) -> str:
    """搜索实时信息(如天气)。"""
    # 模拟假数据
    return f"(假数据) 今天气温 25 度。"

calc_tools = [calculator]
search_tools = [search_web]

# ==================== 2. 定义状态 ====================
class AgentState(TypedDict):
    messages: Annotated[list, operator.add]
    next: str 
    iterations: int # 熔断计数器

# ==================== 3. 定义节点 ====================
def supervisor_node(state: AgentState) -> dict:
    """主管节点:决定下一步(宽松匹配版)"""
    # 计数器兜底防死循环
    if state.get("iterations", 0) > 3:
        return {"next": "end", "messages": [AIMessage(content="达到最大迭代次数,强制结束")]}
        
    last_message = state["messages"][-1]
    
    # 如果是工具返回的结果,直接总结
    if isinstance(last_message, ToolMessage):
        response = llm.invoke(state["messages"] + [HumanMessage(content="请根据工具结果总结回答用户。")])
        return {"messages": [response], "next": "end"}
    
    # 不使用工具调用,直接让模型思考并输出文本
    prompt = HumanMessage(content="""你是主管。分析用户需求:
            - 如果需要计算数学题,你的回复必须包含关键字 calc。
            - 如果需要搜索信息(如天气),你的回复必须包含关键字 search。
            - 如果可以直接回答或任务已完成,回复 end。

            用户当前输入和历史记录如上,请做出决定。""")
    
    response = llm.invoke(state["messages"] + [prompt])
    content = response.content.lower() # 转小写匹配
    
    # 宽松匹配逻辑
    if "calc" in content:
        next_value = "calc"
    elif "search" in content:
        next_value = "search"
    else:
        next_value = "end"
        
    return {
        "next": next_value, 
        "messages": [response], 
        "iterations": state.get("iterations", 0) + 1
    }
def calc_agent_node(state: AgentState) -> dict:
    """计算专员:绑定计算工具"""
    llm_with_tools = llm.bind_tools(calc_tools)
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}

def search_agent_node(state: AgentState) -> dict:
    """搜索专员:绑定搜索工具"""
    llm_with_tools = llm.bind_tools(search_tools)
    response = llm_with_tools.invoke(state["messages"])
    return {"messages": [response]}

# 自定义工具执行节点:确保正确返回 ToolMessage 并打上防污染标记
def custom_tool_node(state: AgentState) -> dict:
    """显式执行工具并封装为 ToolMessage"""
    last_message = state["messages"][-1]
    if not hasattr(last_message, "tool_calls") or not last_message.tool_calls:
        return {}
        
    tool_messages = []
    for tc in last_message.tool_calls:
        # 简单执行工具
        if tc["name"] == "calculator":
            res = calculator.invoke(tc["args"])
        elif tc["name"] == "search_web":
            res = search_web.invoke(tc["args"])
        else:
            res = "未知工具"
            
        # 封装为标准 ToolMessage,并加入特征标记防止模型误判为新的指令
        tool_messages.append(
            ToolMessage(
                content=f"__agent_result__ {res}", 
                tool_call_id=tc["id"]
            )
        )
    return {"messages": tool_messages}

# ==================== 4. 构建图 ====================
builder = StateGraph(AgentState)

builder.add_node("supervisor", supervisor_node)
builder.add_node("calc", calc_agent_node)
builder.add_node("search", search_agent_node)
builder.add_node("tools", custom_tool_node) # 使用自定义工具节点

builder.add_edge(START, "supervisor")

# 主管路由
builder.add_conditional_edges(
    "supervisor",
    lambda state: state["next"],
    {"calc": "calc", "search": "search", "end": END}
)

# 子Agent调用工具判断
def check_tool_calls(state: AgentState) -> str:
    last_message = state["messages"][-1]
    if hasattr(last_message, "tool_calls") and last_message.tool_calls:
        return "tools"
    return END

builder.add_conditional_edges("calc", check_tool_calls, {"tools": "tools", END: END})
builder.add_conditional_edges("search", check_tool_calls, {"tools": "tools", END: END})

# 工具执行完强制回到主管复盘
builder.add_edge("tools", "supervisor")

app = builder.compile()

5.3 踩坑:DeepSeek 不支持 with_structured_output 的默认方法

LangChain 默认使用 JSON schema 模式,但 DeepSeek 只支持 Function Calling。解决方法是指定 method="function_calling"


六、Day 5:长期记忆 ------ 让 Agent 记住你是谁

6.1 为什么需要记忆?

没有记忆的 Agent 每次对话都是"失忆症患者"。我们需要把对话历史持久化到数据库,并在下次对话时恢复上下文。

6.2 两种记忆后端

后端 特点 适用场景
MemorySaver 内存存储,速度快 开发调试、单次会话
SqliteSaver 磁盘持久化,跨会话 生产环境、多用户

6.3 代码实现(week8_day5.py

less 复制代码
db_path = os.path.abspath("chat_history.db")
conn = sqlite3.connect(db_path, check_same_thread=False)  # [5,7](@ref)
memory = SqliteSaver(conn)

app = builder.compile(checkpointer=memory)

6.4 踩坑:导入路径变更

LangGraph 版本更新后,SqliteSaver 的导入路径从 langgraph.checkpoint.sqlite 变成了 langgraph.checkpoint.sqlite(注意大小写)。需要安装 langgraph-checkpoint-sqlite 包。


七、Day 6:多 Agent 协作稳定版 ------ 结构化路由 + 防死循环

7.1 最终架构

scss 复制代码
纯文本
纯文本
用户输入 → Supervisor (结构化路由) → math_agent / search_agent → 工具执行 → 回到 Supervisor
                                                                    ↓
                                                              FINISH (结束)

7.2 关键改进点

  • Pydantic RouteLiteral["math_agent", "search_agent", "FINISH"]
  • Worker 职责隔离 :每个 Worker 只返回带前缀的纯文本结论(如 [数学专家]: 408
  • Supervisor 只看最近 5 条消息:防止历史过长导致决策混乱
  • 递归限制recursion_limit: 10 作为最后防线

7.3 代码亮点(week8_day6.py

python 复制代码
def supervisor_node(state: AgentState):
    """主管:看上下文,决定派发任务或结束"""
    # 安全机制:超过 5 次迭代强制结束
    if state.get("iterations", 0) >= 5:
        return {"next": "FINISH", "iterations": state.get("iterations", 0) + 1}
    
    system_prompt = """你是主管。下面有专家:
- math_agent: 仅处理数学计算。
- search_agent: 仅处理实时搜索(如天气)。
以最近一条用户消息为当前任务,不要重新派发历史任务。
如果当前任务已有专家给出答案,或属于闲聊及回顾历史,输出 FINISH。"""
    
    llm = get_llm()
    # DeepSeek 不支持默认的 json_schema,改用工具调用并保留 Route 校验。
    supervisor_llm = llm.with_structured_output(Route, method="function_calling")
    decision = supervisor_llm.invoke(
        [SystemMessage(content=system_prompt)] + state["messages"]
    )
    
    update = {
        "next": decision.next, 
        "iterations": state.get("iterations", 0) + 1
    }
    if decision.next == "FINISH" and isinstance(state["messages"][-1], HumanMessage):
        response = llm.invoke(
            [SystemMessage(content="请根据对话历史回答用户当前的问题。")] + state["messages"]
        )
        update["messages"] = [response]
    return update

八、Day 7:并行执行 ------ Map-Reduce 模式

8.1 为什么需要并行?

用户可能同时提出多个独立任务:"帮我算一下 12×34,同时查一下上海天气"。串行执行会浪费一半时间。

8.2 核心 API:Send

Send 允许在条件路由中返回多个目标,LangGraph 会并发执行这些节点。

python 复制代码
python
python
from langgraph.types import Send

def parallel_router(state: AgentState):
    """并行路由:如果决策是 both,同时发送给两个专家"""
    if state["next"] == "both":
        # 使用 Send 实现动态分发(并发执行)
        return [Send("math_agent", state), Send("search_agent", state)]
    return state["next"]

8.3 汇总节点(Synthesizer)

所有并行分支完成后,触发一个汇总节点,将结果合并成最终回复。

sql 复制代码
python
python
builder.add_edge(["math_agent", "search_agent"], "synthesizer")
builder.add_edge("synthesizer", END)

8.4 踩坑:路由映射字典不能包含列表

add_conditional_edges 的第三个参数(映射字典)的键必须为字符串,值可以是节点名或 END。并行逻辑完全由 Send 控制,映射字典中只需为 "both" 随便指定一个占位节点(如 "math_agent")。


九、总结与展望

9.1 本周收获

天数 核心技能 关键踩坑
Day 1 LangGraph 基本图编排 路由映射键不可为列表
Day 2 ReAct Agent + ToolNode 网络超时(需加 retry)
Day 3 Supervisor 多 Agent 文本匹配不稳定
Day 4 结构化路由 + 熔断 DeepSeek 不支持默认 json_schema
Day 5 SQLite 持久化记忆 导入路径变更
Day 6 稳定版多 Agent 协作 Worker 上下文污染
Day 7 并行执行 (Map-Reduce) Send 与映射字典的配合

9.2 下一步计划

  • Swarm 模式:Agent 之间直接传递上下文,无需主管持续监控。
  • 接入真实 RAG 知识库:让 Agent 能检索私有文档。
  • 流式输出:将 Day 7 的并行结果也改为 SSE 流式推送。

9.3 给前端同学的忠告

  1. 状态管理思维迁移:LangGraph 的 StateGraph 和 Redux 极其相似,理解 reducer 和 action 就能快速上手。
  2. 异步并发Send API 就是后端的 Promise.all,只是写法不同。
  3. 错误处理:Agent 系统比前端更脆弱,一定要加熔断、重试、日志。

十、附录:完整代码仓库

源码:github.com/qishuixian/...

相关推荐
用户921080262861 小时前
Promise 和 async/await:从用途到执行机制
前端
长江后浪博客1 小时前
Python + YOLOv8 疲劳驾驶 AI 视觉检测入门:从模型训练到 ONNX 实时摄像头检测完整实战
人工智能·python·yolo·疲劳驾驶检测·onnx·yolov8
yyt3630458411 小时前
KLineChartQuant:GLSL 的精度问题及 RTC 解法
前端·vue.js·react.js·交互·图形渲染·webgl·数据可视化
hhzz1 小时前
【OpenCV 入门到精通 01】认识 OpenCV 与计算机视觉:从零建立全局认知
人工智能·python·opencv·计算机视觉·开源
HarmonLTS1 小时前
智盾 WAF v8.2 Ultra|下一代 Web 应用防火墙
前端
এ慕ོ冬℘゜1 小时前
前端实战:基于jQuery递归实现通用树形组织菜单(可直接复用)
前端·javascript·jquery
计算机魔术师2 小时前
英伟达据报洽谈向 Mira Murati 的 Thinking Machines Lab 投资 25 亿美元
前端
变与不变8062 小时前
js函数与封装详细解答
前端·javascript·vue.js
一马平川的大草原2 小时前
如何实现SVG转Mermaid图
python·svg·格式转换·mermaid