三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

LangGraph实战:基于状态机与有向图构建多Agent智能编排系统

LangGraph实战:基于状态机与有向图构建多Agent智能编排系统

1. 从“单兵作战”到“团队协作”:为什么我们需要Agent化编排

如果你最近在折腾大语言模型应用,尤其是想让它干点复杂活儿,比如自动分析一份财报然后生成投资建议,或者处理一个从数据抓取到报告生成的完整流程,你大概率会遇到一个瓶颈:一个LLM(大语言模型)好像不太够用。它可能擅长总结,但不擅长精确计算;它可能理解你的指令,但无法记住长达几轮对话的复杂上下文;它更不可能自己去调用一个数据库查询接口,或者打开浏览器搜索最新信息。

这时候,“Agent”(智能体)的概念就登场了。你可以把它理解为一个配备了“大脑”(LLM)、“记忆”(向量数据库或缓存)和“手脚”(各种工具函数,如计算器、搜索API、代码执行器)的独立智能单元。一个Agent可以相对独立地完成一个特定任务,比如“查询天气”或“总结文章”。

但现实世界的复杂任务,很少是单一动作就能完成的。它们更像一个项目,需要多个角色(Agent)协同工作,有清晰的流程和状态流转。比如“市场调研”这个任务,可能需要:

  1. 一个“信息搜集员”Agent去网上爬取相关新闻和报告。
  2. 一个“数据分析师”Agent对爬取的数据进行清洗和初步分析。
  3. 一个“报告撰写员”Agent根据分析结果,生成结构化的调研报告。
  4. 还可能需要一个“项目经理”Agent来协调前三者的工作顺序,判断分析结果是否达标,决定是否需要重新搜集信息。

这个让多个Agent按照特定逻辑、有序协同工作的过程,就是“编排”(Orchestration)。而“Agent化编排”,就是将每个处理环节都抽象成具有自主决策能力的Agent,并通过一套可靠的机制将它们连接起来,形成一个能处理复杂工作流的智能系统。这不再是简单的函数调用链,而是一个动态的、可能带有分支、循环和状态管理的“智能团队”协作图。

我最初尝试用硬编码的if-else或简单工作流引擎来串联多个LLM调用,很快就陷入了状态混乱和错误处理的地狱。直到接触到以LangGraph为代表的状态机图编排思想,才真正找到了构建复杂、鲁棒AI应用的钥匙。接下来,我就结合实践,拆解Agent化编排的核心。

2. 理解编排的核心:状态机与有向图

要掌握Agent化编排,必须吃透两个底层概念:状态机(State Machine)有向图(Directed Graph)。很多教程直接上工具,但没讲明白为什么是这两个概念,导致使用时知其然不知其所以然。

2.1 状态机:为流程赋予“记忆”和“规则”

状态机不是什么新潮概念,它在软件工程中无处不在,比如一个订单的“待支付-已支付-已发货-已完成”流程。在Agent编排中,状态(State)就是当前工作流所有相关信息的快照。

一个典型的状态对象可能包括:

  • input: 用户最初的问题。
  • messages: 整个对话历史或中间消息记录。
  • intermediate_steps: 已执行过的工具调用及其结果。
  • research_findings: 专门存放研究结果的字段。
  • next_step: 决定下一步该去哪个节点的标识。

为什么必须是状态机,而不是简单变量?因为复杂工作流是“状态驱动”的。下一个动作(该调用哪个Agent或工具)不单纯由上一个动作的输出决定,而是由当前整个状态决定。例如,在审核流程中,Agent需要根据{input: “审核合同”, messages: [历史对话], intermediate_steps: [条款提取结果], review_decision: null}这个状态,来决定是调用“法律条款分析Agent”还是“风险提示Agent”。

状态机的“机”体现在转移条件上。在LangGraph中,你定义nodes(节点,即Agent或工具)和edges(边,即流转逻辑)。边可以是固定的(always_go_to),也可以是根据状态动态决定的(conditional_edges)。这正是一个状态机:在某个节点(状态),根据状态内容(条件),转移到下一个节点(新状态)。

2.2 有向图:将工作流可视化与结构化

有向图是描述状态机最直观的工具。把每个处理单元(Agent、工具、条件判断)看作一个节点(Node),把状态流转的路径看作边(Edge)

一个基础的顺序流程就是一条链:开始 -> Node A -> Node B -> 结束。 但真实场景远不止于此:

  • 分支(Branching):像if-else,根据状态决定走哪条路。比如,分析结果置信度高于90%则生成报告,否则返回重新分析。
  • 循环(Looping):当某个条件不满足时,让状态流回之前的节点。比如,让“信息搜集Agent”持续搜集,直到“信息验证Agent”认为材料充足为止。
  • 并行与聚合(Parallel & Aggregate):多个分支同时进行,最后汇总结果。这在需要多来源信息对比时非常有用。

用图来建模,最大的好处是可视化与可维护性。你可以一眼看清整个工作流的全貌、所有可能路径和潜在的死循环风险。LangGraph之所以强大,就是因为它将“图”作为一等公民,你定义的图可以直接被编译、执行和调试。

踩坑心得:早期我用代码硬编流程,改一个环节常常牵一发而动全身,调试极其痛苦。切换到图思维后,我先在白板上画出节点和边,理清所有状态流转可能性,再动手写代码,结构清晰,bug也少了很多。强烈建议在编码前先画图,即使是手绘草图。

3. LangGraph深度解析:不只是LangChain的扩展

提到Agent编排,LangGraph是无法绕开的框架。很多人以为它只是LangChain的一个模块,其实它的设计理念和适用场景有更独特的价值。

3.1 核心抽象:StateGraph与持久化

LangGraph的核心是StateGraph。你需要先定义一个State的类型(通常用TypedDict),然后创建图实例:

from typing import TypedDict, List, Annotated import operator from langgraph.graph import StateGraph, END # 1. 定义状态结构 class AgentState(TypedDict): input: str messages: Annotated[List, operator.add] # 关键:这是一个追加式列表 intermediate_steps: Annotated[List, operator.add] next: str # 2. 初始化图 graph_builder = StateGraph(AgentState)

这里有个关键细节:Annotated[List, operator.add]。这使用了LangGraph的注解归约器,它定义了当多个节点并行修改同一个字段时,如何合并。operator.add意味着列表是追加的,这对于收集messagesintermediate_steps至关重要。如果是数值,你可以用operator.add求和,或用自定义函数。这是实现正确状态管理的基石,理解不透彻会导致状态被意外覆盖。

3.2 节点(Node)与边(Edge)的实战定义

节点本质上是接收状态、返回新状态的函数。边决定了流程走向。

# 定义节点函数:研究Agent def research_agent(state: AgentState): # 1. 基于state[‘input‘]和state[‘messages‘]构造LLM调用 llm_with_tools = ... # 绑定搜索工具 # 2. 调用LLM,它可能决定调用工具 result = llm_with_tools.invoke(...) # 3. 更新状态 new_messages = ... # 将LLM返回的消息追加 new_steps = ... # 记录工具调用 return {"messages": new_messages, "intermediate_steps": new_steps, "next": "analyze"} # 将函数添加为节点 graph_builder.add_node("research", research_agent) # 定义边 graph_builder.set_entry_point("research") # 入口 graph_builder.add_edge("research", "analyze") # 固定边:研究完直接去分析 # 条件边示例 graph_builder.add_conditional_edges( "analyze", # 一个路由函数,根据状态返回下一个节点名 lambda state: "review" if needs_review(state) else "report", {"review": "review", "report": "report"} ) graph_builder.add_edge("report", END)

条件边是编排灵活性的灵魂。上面的lambda函数needs_review(state)可以检查分析结果的置信度、完整性等,实现动态路由。

3.3 CompiledGraph与长期记忆:让工作流“可暂停、可恢复”

graph_builder.compile()会得到一个CompiledGraph对象。这才是可执行的对象。它的强大之处在于支持检查点(Checkpointing)

compiled_graph = graph_builder.compile() # 运行一次 initial_state = {"input": "问题", "messages": [], "intermediate_steps": [], "next": "research"} result = compiled_graph.invoke(initial_state)

但想象一个耗时很长的流程,比如需要人工审核中断。LangGraph可以将运行状态(包括所有变量、历史)持久化到数据库(如MySQL、Postgres)。这意味着你可以给这个运行实例一个ID,暂停它,几天后再通过ID加载完全相同的状态继续执行。这是构建生产级异步、长周期AI应用的关键。

# 配置持久化存储(以内存为例,生产环境需换为数据库) from langgraph.checkpoint import MemorySaver memory = MemorySaver() compiled_graph = graph_builder.compile(checkpointer=memory) # 运行,并保存线程ID config = {"configurable": {"thread_id": "user_123_task_1"}} result = compiled_graph.invoke(initial_state, config=config) # 之后,可以通过thread_id恢复状态,继续invoke,流程会从上次中断的节点后继续

这个特性彻底改变了交互模式,使得实现“AI助理处理到一半,用户补充信息后继续”的体验成为可能。

3.4 与LangChain的关系:互补而非替代

  • LangChain:更像一个“组件库”和“标准连接器”。它提供了大量现成的Agent实现、工具封装(如GoogleSearchTool)、文档加载器以及与各种LLM API交互的链。它的AgentExecutor其实也是一个简单循环,但定制复杂流程比较笨重。
  • LangGraph:是一个“流程编排引擎”。它不关心你节点里具体用ChatGPT还是Claude,不关心你的工具是自研的还是LangChain提供的。它专注于解决“如何让多个组件(可以是LangChain的Agent,也可以是你自己的函数)按照复杂逻辑协同工作”的问题。

最佳实践是结合使用:用LangChain快速构建强大的单点Agent和工具,然后用LangGraph把这些“乐高积木”组装成精密的“自动化机器”。LangGraph的官方示例也大量使用了LangChain的组件。

4. 构建一个实战级多Agent研究系统

理论说再多不如动手。我们设计一个相对复杂的场景:自动技术调研员。用户输入一个技术概念(如“向量数据库”),系统自动进行多轮、多源研究,并生成一份结构化报告。

目标流程

  1. 查询理解与规划Agent:拆解用户问题,生成搜索查询词和报告大纲。
  2. 网络研究Agent:执行多轮网页搜索,提取关键信息。
  3. 信息验证与摘要Agent:对搜集的信息进行去重、可信度评估,并生成分点摘要。
  4. 报告合成Agent:根据大纲和摘要,生成最终Markdown报告。
  5. 质量控制节点:检查报告完整性,不达标则触发重新研究特定部分。

4.1 状态设计:定义数据总线

状态是所有节点共享和修改的“数据总线”,设计好坏直接决定系统复杂度。

from typing import TypedDict, List, Optional, Annotated import operator class ResearchState(TypedDict): """调研工作流的状态定义""" # 原始输入 original_query: str # 当前轮次的查询(可能被规划Agent修改) current_query: str # 规划Agent生成的研究大纲 research_outline: Optional[List[str]] # 累积的搜索查询词列表 search_queries: Annotated[List[str], operator.add] # 累积的网页抓取内容(原始文本) raw_contents: Annotated[List[str], operator.add] # 处理后的信息摘要 information_summaries: Annotated[List[str], operator.add] # 当前草稿报告 draft_report: Optional[str] # 最终报告 final_report: Optional[str] # 控制流:下一步做什么?(plan, search, summarize, write, review, end) next_action: str # 错误或日志信息 errors: Annotated[List[str], operator.add]

注意Annotated的使用,它确保了列表字段在并行节点下的正确合并。

4.2 实现关键节点:以规划与搜索为例

规划节点(plan_node): 这个节点需要较强的逻辑分解能力。我们使用一个提示词工程来让LLM扮演“研究项目经理”。

from langchain_core.prompts import ChatPromptTemplate from langchain_openai import ChatOpenAI plan_prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个资深技术研究员。请将用户的问题拆解为3-5个具体的搜索查询词,并拟定一份报告大纲。输出格式为JSON:{\"queries\": [\"q1\", \"q2\"], \"outline\": [\"章节1\", \"章节2\"]}"), ("human", "{query}") ]) def plan_node(state: ResearchState): llm = ChatOpenAI(model="gpt-4", temperature=0.1) chain = plan_prompt | llm # 调用LLM response = chain.invoke({"query": state["original_query"]}) # 解析JSON响应(实际生产需加try-catch) import json plan = json.loads(response.content) # 更新状态 new_state = { "research_outline": plan["outline"], "search_queries": plan["queries"], "next_action": "search" } return new_state

搜索节点(search_node): 这里需要集成搜索工具。我们可以使用LangChain的TavilySearchResults工具(一个聚合搜索API),并让LLM决定如何组合使用查询词。

from langchain_community.tools.tavily_search import TavilySearchResults from langchain.agents import create_react_agent, AgentExecutor def search_node(state: ResearchState): # 1. 准备工具 search_tool = TavilySearchResults(max_results=3) tools = [search_tool] # 2. 创建Agent(使用ReAct模式) llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0) agent = create_react_agent(llm, tools) agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=False) # 3. 构建搜索指令,可以结合之前的查询词 search_instruction = f""" 请执行以下搜索任务,全面获取信息: {chr(10).join(state['search_queries'])} 请依次进行搜索,并提取每个结果的核心内容。最终请返回一个包含所有抓取到原始文本的列表。 """ # 4. 执行Agent result = agent_executor.invoke({"input": search_instruction}) # 5. 假设Agent的返回结果中包含了原始文本列表(实际需要解析Agent的输出消息) # 这里简化处理:将Agent的思考过程作为原始内容(实际项目应做更精细的解析) raw_texts = [result["output"]] new_state = { "raw_contents": raw_texts, "next_action": "summarize" } return new_state

实操陷阱:搜索节点最容易出的问题是信息过载格式混乱。LLM驱动的搜索Agent可能会返回大量无关信息或结构化很差的内容。我的经验是:

  1. 给搜索指令加严格约束:明确要求返回“核心摘要”、“关键数据”、“发布时间”等结构化字段。
  2. 使用“网页抓取+LLM提取”组合拳:先用search_tool拿到URL,再用专门的fetch_and_extract_tool(如Playwright)抓取页面正文,最后用一个小LLM(如gpt-3.5-turbo)提取关键信息。这比让搜索Agent一次性做完所有事更可控。
  3. 设置超时和重试:网络请求不稳定,节点内必须有健壮的错误处理。

4.3 编排图构建与条件路由

将节点组装起来,并设置智能路由。

from langgraph.graph import StateGraph, END # 初始化图 workflow = StateGraph(ResearchState) # 添加节点 workflow.add_node("plan", plan_node) workflow.add_node("search", search_node) workflow.add_node("summarize", summarize_node) # 假设已实现 workflow.add_node("write", write_node) # 假设已实现 workflow.add_node("review", review_node) # 假设已实现 # 设置入口 workflow.set_entry_point("plan") # 添加固定边 workflow.add_edge("plan", "search") workflow.add_edge("search", "summarize") workflow.add_edge("summarize", "write") # 添加条件边:报告撰写后,进入审核节点 workflow.add_conditional_edges( "write", # 路由函数:根据草稿质量决定下一步 lambda state: decide_after_write(state), { "need_review": "review", "complete": END } ) # 审核节点后,可能返回重新搜索或结束 workflow.add_conditional_edges( "review", lambda state: decide_after_review(state), { "redo_search": "search", # 跳回搜索节点,注意状态会保留 "approve": END } ) # 编译图 research_graph = workflow.compile()

条件路由函数decide_after_write是业务逻辑的核心。它可以是一个简单的规则,也可以再调用一个LLM来判断:

def decide_after_write(state: ResearchState) -> str: draft = state.get("draft_report", "") # 简单规则:如果报告少于200字或缺少“结论”部分,则需要审核 if len(draft) < 200 or "## 结论" not in draft: return "need_review" else: # 也可以用LLM做质量评分 # score = quality_check_llm(draft) # return "need_review" if score < 0.8 else "complete" return "complete"

这种设计使得工作流具备了自我修正能力。审核节点(review_node)可以分析报告缺陷,并在状态中设置新的search_queries或修改research_outline,当流程跳回search节点时,这些新信息会指导下一轮更精准的研究。

4.4 运行、调试与持久化

运行这个图,并利用LangGraph的调试工具。

# 初始状态 init_state = ResearchState( original_query="什么是LangGraph?它与LangChain有什么区别?", current_query="", research_outline=None, search_queries=[], raw_contents=[], information_summaries=[], draft_report=None, final_report=None, next_action="plan", errors=[] ) # 运行图 final_state = research_graph.invoke(init_state) print(final_state["final_report"]) # 调试:可视化执行轨迹 from langgraph.graph import GraphRecorder # 可以记录每个节点的输入输出,对于排查问题非常有用

对于生产环境,务必配置持久化存储,以支持长时间运行和异步交互。

from langgraph.checkpoint import PostgresCheckpointer import psycopg2 connection = psycopg2.connect("your_db_connection_string") checkpointer = PostgresCheckpointer(connection, serde="json") compiled_graph_with_checkpoint = workflow.compile(checkpointer=checkpointer) # 异步调用示例(假设在FastAPI中) import asyncio async def run_research(task_id: str, query: str): config = {"configurable": {"thread_id": task_id}} initial_state = ResearchState(original_query=query, ...) async for event in compiled_graph_with_checkpoint.astream(initial_state, config=config): # 可以在这里将事件推送到前端(如SSE) print(event) # 事件会包含节点开始、结束、流式输出等信息

5. 高级模式与避坑指南

当系统复杂后,你会遇到一些进阶问题。

5.1 子图(Subgraph)与模块化

一个庞大的图难以维护。LangGraph支持子图,可以将一组相关节点封装成一个“超级节点”。例如,把search_nodesummarize_node打包成一个research_subgraph,主图只和这个子图交互。这极大提升了代码的模块化和复用性。

# 创建一个子图(内部有自己的节点和边) from langgraph.graph import StateGraph as SubStateGraph research_subgraph_builder = SubStateGraph(ResearchState) research_subgraph_builder.add_node("search", search_node) research_subgraph_builder.add_node("summarize", summarize_node) research_subgraph_builder.set_entry_point("search") research_subgraph_builder.add_edge("search", "summarize") research_subgraph = research_subgraph_builder.compile() # 在主图中,将子图作为一个节点添加 main_workflow.add_node("deep_research", research_subgraph)

5.2 错误处理与补偿机制

节点执行可能失败(网络超时、API限流、LLM胡言乱语)。不能因为一个节点失败就让整个工作流崩溃

  • 节点级重试:在节点函数内部用tenacity等库实现重试逻辑。
  • 图级错误处理:LangGraph允许你定义interruptstriggers,但更实用的模式是在状态中设置errors字段。每个节点捕获异常后,将错误信息追加到state[“errors“],并将next_action设置为一个专门的error_handler_node。这个错误处理节点可以分析错误类型,决定是重试、跳过还是人工介入。
  • 超时控制:使用asyncio.wait_for或类似机制为节点执行设置超时,防止无限期卡住。

5.3 性能优化与成本控制

多Agent系统容易造成LLM调用次数激增,成本飙升。

  • 缓存:对LLM请求进行缓存(如使用langchain.cache)。相同的查询规划、相似的搜索总结,都可以复用结果。
  • 流式输出:对于最终报告生成等节点,使用LLM的流式响应,并通过astream将token实时推送给用户,提升体验。
  • “短路”逻辑:在条件边中尽早判断是否满足结束条件。例如,如果第一轮搜索得到的信息已经足够生成高质量报告,就跳过后续的深化搜索节点。
  • 限制循环次数:对于可能形成循环的边(如review -> search),必须在状态中设置计数器(如retry_count),并在路由函数中检查,超过阈值则强制流向END或人工处理节点。

5.4 常见陷阱与调试技巧

  1. 状态污染:这是最常见的问题。多个节点修改了同一个非Annotated字段,导致数据被覆盖。黄金法则:除非明确要替换,否则状态中的字典字段尽量设计为不可变,列表字段务必使用Annotated进行归并。
  2. 条件循环:不小心设计出了死循环(A->B->A)。调试时,打印每个节点执行前后的状态和next_action,画出实际执行路径图。LangGraph也提供了可视化工具来帮助分析。
  3. 工具调用泛滥:Agent在单个节点内不受控地频繁调用工具。解决方案:在工具定义中设置严格的参数schema,在Agent的提示词中明确限制工具调用的次数和场景,或者使用AgentExecutormax_iterations参数。
  4. LLM输出的解析失败:期望LLM输出JSON,但它可能返回了多余的文字。必须在节点代码中做健壮的解析,使用json.loads()配合try-catch,并准备一个fallback处理逻辑。

构建Agent化编排系统,是一个在“赋予自主性”和“施加控制力”之间寻找平衡的艺术。从简单的线性链开始,逐步引入分支、循环和状态管理,持续测试和迭代,你会逐渐体会到将多个“智能体”编织成一张可靠协作网的巨大威力。这不仅仅是技术的组合,更是对复杂业务流程进行抽象和自动化思维的锤炼。

← 返回列表