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但注册的是aftersalesLangGraph 找不到对应节点可能静默跳过或者报一个很隐晦的错。排查方法把路由函数的返回值打印出来和注册的 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 还难维护。我现在的习惯是先用纸笔把业务流程画出来标清楚每一步谁负责、输入什么、输出什么画明白了再写代码。这个习惯帮我省下的调试时间比我学任何框架特性都多。