☰
LangGraph子图模式实战:构建可维护的多智能体AI客服系统
2026/10/5 12:32:14 网站建设 项目流程

1. 为什么我最终选了 LangGraph 的子图模式来做 AI 客服

1.1 从一次真实的客服系统重构说起

去年下半年我接手了一个电商客服系统的重构项目,原来的方案是一个"大而全"的 Agent,把所有工具——订单查询、退换货、物流追踪、优惠券、投诉建议——全部塞进一个工具列表里,靠一个 System Prompt 去调度。刚开始跑 demo 的时候效果还行,但一上真实流量就崩了:用户问"我上周买的鞋还没到",Agent 有时候去查订单,有时候去查物流,有时候两个都查一遍然后给出自相矛盾的回答;更麻烦的是,一旦用户话题从"查物流"切到"我要退货",整个对话历史里混杂着物流查询的中间结果,模型经常把物流单号当成退货单号。

那次踩坑让我意识到一个很朴素的问题:一个 Agent 不应该同时是客服、仓管和财务。这就像一家公司不会让同一个人既接电话又管仓库又做账,而是分成不同岗位,每个岗位有自己的职责边界、自己的工具、自己的记忆。多智能体(Multi-Agent)的思路就是这么来的——把一个大任务拆成若干专业角色,每个角色是一个独立的 Agent,彼此通过明确的协议协作。

但"多智能体"这个词听起来很美好,落地的时候会遇到一个非常现实的问题:这些 Agent 之间怎么通信?状态怎么共享?谁来当调度员?我试过几种方案,最后落在 LangGraph 的**子图模式(Subgraph)**上。这篇文章就把这套方案的完整设计、实现细节和踩过的坑讲清楚,如果你也在做类似的多智能体系统,可以直接抄作业。

1.2 子图模式到底解决了什么问题

先说清楚 LangGraph 里的"子图"是什么。在 LangGraph 中,一个StateGraph本质上是一张状态机图:节点是函数,边是流转关系,整个图共享一份 State。所谓子图,就是把一个完整的 StateGraph 当作另一个 StateGraph 的一个节点来用。听起来很简单,但它带来的架构价值非常大。

我打个比方。主图就像公司的前台和总调度,负责接待用户、判断意图、决定把请求转给哪个部门;子图就像各个部门——售后部、物流部、财务部——每个部门内部有自己的工作流程(比如售后部要先核实订单、再判断是否符合退货条件、再生成退货单),但这些内部流程对外是黑盒的,前台只需要知道"把退货请求交给售后部,售后部会返回一个处理结果"。

这种设计带来三个直接好处。第一是职责隔离:每个子图有自己的 State schema,只关心自己需要的字段,不会污染全局状态。第二是可独立测试:售后子图可以单独跑单元测试,不用把整个系统拉起来。第三是可复用:同一个"订单查询"子图,既可以被售后流程调用,也可以被物流流程调用,不用重复写。

提示:子图不是简单的函数封装。它的关键区别在于子图内部也是一个完整的状态机,可以有循环、有条件分支、有中断恢复,这是普通函数做不到的。

1.3 这套方案适合谁参考

如果你正在做下面这几类事情,这篇文章的内容应该能直接用上:一是构建有多个专业领域的对话系统(客服、医疗问诊、法律咨询);二是需要把复杂任务拆成多个步骤、每步由不同角色处理的 Agent 系统;三是已经在用 LangChain 但发现单 Agent 越来越难维护,想升级到多智能体架构的团队。

如果你只是想做一个简单的问答机器人,那单 Agent 就够了,上多智能体反而是过度设计。我见过不少项目为了"显得高级"硬上多智能体,结果调试成本翻了三倍,效果还不如一个精心调过的单 Agent。架构选择永远服务于问题复杂度,不是反过来。

2. 整体架构设计:主图调度 + 子图执行

2.1 为什么是"主图 + 子图"而不是"多 Agent 平级协作"

多智能体有两种主流拓扑:一种是平级的 Agent 之间互相通信(比如 AutoGen 那种群聊模式),另一种是分层的、有明确调度者的结构。我选后者,原因很实际。

平级协作的问题在于通信复杂度是 O(n²)。三个 Agent 两两通信是 3 条链路,五个 Agent 就是 10 条,而且每条链路上传什么、什么时候传、传完谁负责,都需要额外约定。更致命的是,平级协作容易出现"踢皮球"——A 觉得该 B 处理,B 觉得该 C 处理,最后用户等半天没人响应。我在早期原型里就遇到过这种情况,用户问一个简单的退款进度,两个 Agent 来回推了四轮才给出答案。

分层结构就清爽多了。主图里有一个路由节点(Router),它负责理解用户意图,然后决定把请求分发给哪个子图。子图处理完把结果返回给主图,主图再决定是继续追问、转给另一个子图,还是直接回复用户。整个系统只有一个"决策中心",逻辑清晰,调试的时候也容易定位问题出在哪一层。

2.2 状态设计:主图 State 和子图 State 怎么划分

这是整个架构里最容易出错的地方,我单独拎出来讲。LangGraph 的 State 是一个 TypedDict,节点函数读写它。主图和子图各自有自己的 State 定义,关键在于两者之间的字段映射。

我的划分原则是这样的:主图 State 只放跨子图共享的、全局性的信息,比如用户 ID、会话 ID、原始问题、当前意图、最终回复。子图 State 放该子图内部流转需要的临时信息,比如售后子图需要"订单详情""退货原因""退款金额"这些字段,这些字段主图根本不关心。

from typing import TypedDict, Annotated, Literal from langgraph.graph import StateGraph, END from langgraph.graph.message import add_messages # 主图 State:全局共享 class MainState(TypedDict): messages: Annotated[list, add_messages] user_id: str session_id: str intent: str final_reply: str # 售后子图 State:内部专用 class AfterSalesState(TypedDict): order_id: str return_reason: str refund_amount: float approved: bool reply: str

这里有个细节要注意:子图 State 里的字段,如果主图也需要,就必须在主图 State 里也定义一份,并且在调用子图时显式传入、返回时显式取出。LangGraph 不会自动帮你做字段透传,这一点新手特别容易踩坑——我一开始以为子图能直接读写主图 State,结果发现子图拿到的是一个全新的、独立的 State 对象。

2.3 路由策略:意图识别怎么做才稳

路由节点是整个系统的"大脑",它的准确率直接决定用户体验。我试过三种方案,最后用的是**"LLM 分类 + 规则兜底"**的混合策略。

纯 LLM 分类的问题是偶尔会"幻觉",把"我要投诉"识别成"查询订单"。纯规则的问题是覆盖不全,用户表达千变万化。混合策略是先用 LLM 做意图分类,输出一个置信度,如果置信度低于阈值(我设的是 0.7),就走规则兜底——用关键词匹配再判一次,如果还是不确定,就路由到一个"澄清子图",让系统反问用户"您是想查询订单还是申请售后?"

def route_intent(state: MainState) -> Literal["after_sales", "logistics", "clarify"]: intent = state["intent"] if intent == "after_sales": return "after_sales" elif intent == "logistics": return "logistics" else: return "clarify"

路由函数本身很简单,复杂的是意图识别的 prompt 设计。我的经验是:意图类别不要超过 6 个,超过之后 LLM 的准确率会明显下降;每个类别给 3-5 个正例和 2-3 个易混淆的反例,效果比单纯描述类别定义好得多。

3. 核心实现:把子图挂到主图上

3.1 子图的独立构建与测试

先说子图怎么建。售后子图内部有三步:核实订单、判断退货资格、生成处理结果。这三步用条件边串起来,如果订单不存在就直接结束,如果不符合退货条件就返回拒绝理由。

def build_after_sales_subgraph(): builder = StateGraph(AfterSalesState) builder.add_node("verify_order", verify_order_node) builder.add_node("check_eligibility", check_eligibility_node) builder.add_node("generate_result", generate_result_node) builder.set_entry_point("verify_order") builder.add_conditional_edges( "verify_order", lambda s: "check_eligibility" if s["order_id"] else "generate_result" ) builder.add_edge("check_eligibility", "generate_result") builder.add_edge("generate_result", END) return builder.compile()

注意builder.compile()这一步,它把图编译成一个可执行对象。子图必须先独立编译、独立测试通过,再挂到主图上。我踩过的坑是:一开始直接在主图里内联子图逻辑,结果一个字段名写错,整个系统报错,排查了半天才发现是子图内部的问题。独立测试的好处是错误边界清晰。

3.2 主图如何调用子图:两种方式的取舍

LangGraph 里把子图挂到主图有两种方式,我两种都用过,各有适用场景。

第一种是直接把编译好的子图作为节点添加:

main_builder.add_node("after_sales", after_sales_subgraph)

这种方式最简单,子图的输入输出直接和主图 State 对接。但要求子图 State 的字段必须是主图 State 的子集,否则会报字段不匹配。适合子图和主图字段高度重合的场景。

第二种是用一个包装函数调用子图:

def call_after_sales(state: MainState) -> dict: sub_input = { "order_id": extract_order_id(state["messages"]), "return_reason": state["messages"][-1].content, "refund_amount": 0.0, "approved": False, "reply": "" } sub_output = after_sales_subgraph.invoke(sub_input) return { "final_reply": sub_output["reply"], "messages": [("assistant", sub_output["reply"])] } main_builder.add_node("after_sales", call_after_sales)

这种方式灵活得多,可以在调用前后做字段转换、日志记录、异常处理。我最终选的是第二种,因为真实业务里主图和子图的字段几乎不可能完全对齐,包装函数是必要的适配层。

3.3 状态透传的坑:字段丢失与类型不匹配

状态透传是多智能体系统里最高频的 bug 来源。我整理了几种典型情况。

第一种是字段丢失。子图返回的 dict 里如果没有某个主图需要的字段,主图 State 里那个字段就会保持原值或者变成 None。解决办法是在包装函数里显式构造返回 dict,不要偷懒直接return sub_output。

第二种是类型不匹配。比如主图 State 里messages是Annotated[list, add_messages],子图返回的如果是普通 list,合并的时候会出问题。我的做法是子图内部不碰 messages,只返回业务字段,messages 的更新统一在主图的包装函数里做。

第三种是并发写入冲突。如果主图里有两个节点同时写同一个字段,LangGraph 会报错。解决办法是用 reducer 函数(比如add_messages就是一种 reducer),或者保证同一时刻只有一个节点写某个字段。

注意:调试状态透传问题时,最有效的办法是在每个节点入口和出口打印完整 State。我一般会写一个装饰器自动打日志,比断点调试快得多。

4. 实战细节:让系统真正跑起来

4.1 工具调用的组织方式

每个子图内部通常需要调用外部工具,比如售后子图要查订单数据库、物流子图要调物流 API。我的组织原则是:工具跟着子图走,不搞全局工具池。售后子图只注册售后相关的工具,物流子图只注册物流相关的工具。这样做的好处是每个子图的 LLM 看到的工具列表很短,选择准确率高,也不会出现"售后子图去调物流 API"这种荒谬情况。

工具的定义用 LangChain 的@tool装饰器就行,关键是docstring 要写清楚,因为 LLM 就是靠 docstring 判断什么时候用这个工具的。我见过太多人 docstring 写一句"查询订单"就完事,结果 LLM 根本不知道该传什么参数。正确的写法是把参数含义、返回格式、适用场景都写清楚。

from langchain_core.tools import tool @tool def query_order(order_id: str) -> dict: """根据订单号查询订单详情。 Args: order_id: 订单号,格式为 ORD 开头的 12 位字符串 Returns: 包含 status(订单状态)、amount(金额)、create_time(下单时间)的字典 订单不存在时返回 {"error": "order_not_found"} """ # 实际查询逻辑 ...

4.2 子图内部的循环与中断

有些子图需要多轮交互,比如售后子图可能需要反复和用户确认退货原因。这时候子图内部就要有循环。LangGraph 支持条件边指回之前的节点,形成循环。但循环一定要有退出条件,否则会死循环烧 token。

我的做法是给子图 State 加一个retry_count字段,每次循环加一,超过 3 次就强制走结束分支。这个数字不是拍脑袋定的,是根据业务经验——正常的退货确认最多两轮就能说清楚,超过三轮说明要么用户表达有问题,要么系统理解有问题,继续循环也没用,不如转人工。

中断(interrupt)是另一个实用特性。如果子图执行到一半需要用户输入(比如"请提供退货原因"),可以用interrupt暂停,等用户回复后再用Command(resume=...)恢复。这个特性在做需要人工确认的流程时特别有用。

4.3 日志与可观测性

多智能体系统不上日志基本没法调试。我的做法是在三个层面打日志:主图路由层记录"用户输入 → 识别意图 → 路由目标";子图入口记录"接收到的输入 State";子图出口记录"返回的输出 State"。这样任何一次请求的完整链路都能还原。

LangGraph 本身支持 callback,可以挂 LangSmith 做可视化追踪,但生产环境我一般用自建的日志系统,因为要脱敏、要控制成本。日志里千万不要记录完整的用户对话原文,只记录意图、路由、耗时、结果状态这些元信息就够了,涉及个人信息的部分要脱敏。

5. 常见问题与排查实录

5.1 子图不执行或直接跳过

最常见的现象是主图跑完了,但子图里的日志一条都没打。原因通常是路由函数返回的字符串和add_conditional_edges里注册的 key 不一致。比如路由返回"after_sales",但注册的是"aftersales",LangGraph 找不到对应节点,可能静默跳过或者报一个很隐晦的错。排查方法:把路由函数的返回值打印出来,和注册的 key 逐一比对。

5.2 状态字段在子图里读不到

如果子图里读某个字段是 None,先检查主图调用子图时有没有把这个字段传进去。我踩过的坑是主图 State 里有user_id,但包装函数构造sub_input时忘了带上,子图里自然读不到。解决办法是写一个通用的字段映射表,把主图到子图的字段对应关系集中管理,避免遗漏。

5.3 多轮对话历史丢失

多智能体系统里对话历史的管理要特别小心。如果每个子图都自己维护一份 messages,最后合并的时候会乱。我的方案是messages 只在主图维护,子图不碰 messages,只处理业务字段。子图需要历史上下文时,由包装函数从主图 messages 里提取相关片段传进去。

5.4 常见问题速查表

现象可能原因排查方向
子图不执行路由 key 不匹配打印路由返回值比对注册 key
字段读不到包装函数漏传检查 sub_input 构造逻辑
对话历史混乱多处维护 messages统一由主图维护
死循环循环无退出条件加 retry_count 上限
响应慢子图串行调用过多考虑并行化独立子图
意图识别错prompt 类别过多精简到 6 类以内

5.5 几个我踩过的独家坑

第一个坑是子图编译时机。子图必须在主图构建之前编译好,如果顺序反了会报错。我一开始把子图定义写在主图后面,调试了半小时才发现是顺序问题。

第二个坑是State 的默认值。TypedDict 不会自动给字段默认值,如果某个字段没被赋值就读取,会 KeyError。我的做法是所有字段在入口节点统一初始化,宁可多写几行代码。

第三个坑是工具调用的超时。外部 API 偶尔会卡住,如果不设超时,整个子图会挂起。我给所有工具调用都加了超时和重试,超时时间设 5 秒,重试 2 次,超过就返回降级结果。

6. 性能与扩展:从能用走向好用

6.1 并行化独立子图

如果两个子图之间没有依赖关系,可以并行执行。比如用户问"我的订单到哪了,顺便帮我看看能不能退货",物流查询和退货资格判断可以同时跑。LangGraph 支持并行节点,但要注意并行节点的 State 写入不能冲突。我的做法是让并行子图各自写不同的字段,最后用一个汇总节点合并。

6.2 子图的复用与组合

子图最大的价值之一是可复用。我后来把"订单查询"抽成一个独立子图,售后、物流、投诉三个流程都调用它。这样订单查询逻辑只维护一份,改一处全生效。组合的方式也很灵活,可以在子图里再嵌套子图,形成多层结构,但我的经验是嵌套不要超过两层,再深就难以维护了。

6.3 成本控制

多智能体系统的 token 消耗比单 Agent 高,因为每个子图都有自己的 prompt 和上下文。控制成本的手段有几个:一是子图的 LLM 用小模型,只有路由和关键决策用大模型;二是子图上下文只传必要字段,不要把整个对话历史塞进去;三是给每个子图设 token 上限,超了就降级。

我在实际项目里把售后子图的 LLM 换成了小模型,准确率只掉了 2 个百分点,但成本降了 60%。这个取舍很划算,因为售后流程本身比较标准化,不需要太强的推理能力。

6.4 后续可以怎么扩展

这套架构往上扩展的空间很大。比如加一个"人工接管"子图,当系统连续两次无法解决用户问题时自动转人工;比如加一个"质检"子图,对每次回复做合规检查;比如把子图的执行结果存下来做离线分析,持续优化路由策略。我自己下一步打算做的是给路由层加一个"用户情绪识别",情绪激动的用户直接走优先通道,这个在客服场景里价值很大。

最后分享一个我个人的体会:多智能体系统的难点从来不在"怎么把多个 Agent 拼起来",而在"怎么划分职责边界"。边界划得好,每个子图都简单清晰;边界划得烂,子图之间互相甩锅,比单 Agent 还难维护。我现在的习惯是先用纸笔把业务流程画出来,标清楚每一步谁负责、输入什么、输出什么,画明白了再写代码。这个习惯帮我省下的调试时间,比我学任何框架特性都多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询