1. 为什么我最终选了子图模式来做 AI 客服
1.1 从单智能体到多智能体的真实痛点
去年下半年我接了一个 AI 客服系统的项目,客户是做 SaaS 的,每天大概有三千到五千条用户咨询,内容涵盖账单问题、功能使用、账号异常、退款流程这几大类。一开始我的思路很直接:搞一个大而全的 Agent,把所有工具都挂上去,让它自己判断该调哪个。结果上线第一周就翻车了。
问题出在几个地方。第一,工具数量一多,模型选错工具的概率直线上升,尤其是“查账单”和“查订单”这两个工具,参数结构很像,模型经常混着调。第二,System Prompt 越写越长,为了覆盖所有场景,我塞了将近两千字的指令,结果模型开始“顾此失彼”,处理退款的时候忘了要验证用户身份。第三,调试极其痛苦,一个请求进来,我根本不知道它中间走了哪条路径,日志打出来是一坨,排查一个问题要花半小时。
后来我意识到,这不是 Prompt 工程能解决的问题,这是架构问题。一个 Agent 承担了太多职责,就像一个客服同时要懂技术、懂财务、懂法务,还要记住所有流程,人都会崩溃,何况模型。于是我开始转向多智能体架构,让每个 Agent 只负责一个垂直领域,各司其职。
1.2 为什么是 LangGraph 的子图模式,而不是别的方案
多智能体的实现方式有好几种。我试过用纯 LangChain 的 AgentExecutor 做路由,也试过自己写调度逻辑,但都有各自的坑。AgentExecutor 的问题是它把“决策”和“执行”耦合在一起,你很难在中间插入人工审核或者条件分支。自己写调度的话,状态管理会变成噩梦,尤其是多个 Agent 需要共享上下文的时候。
LangGraph 的子图模式吸引我的点在于:它把每个子智能体封装成一个独立的 StateGraph,有自己的状态定义、自己的节点和边,然后通过父图来编排这些子图。这带来几个实际好处。一是隔离性,子图内部的状态变化不会污染父图,每个 Agent 只管自己那一亩三分地。二是可复用,比如“身份验证”这个子图,账单 Agent 要用,退款 Agent 也要用,写一次就能到处挂。三是可观测,每个子图的执行路径在 LangGraph 的可视化里是分开的,出问题一眼就能定位到是哪个环节。
打个比方,父图就像公司的前台,负责判断用户来找谁;子图就是各个部门的专员,前台把人领到对应部门后,专员按自己的流程办事,办完把结果交回前台。这个模型和真实客服团队的组织结构是对应的,所以落地的时候团队里没人觉得别扭。
1.3 这套系统最终长什么样
最终的系统架构是这样的:用户消息进来,先经过一个“意图识别”节点,判断属于账单、功能、账号、退款中的哪一类,然后路由到对应的子图。每个子图内部有自己的多轮对话逻辑,比如退款子图会先验证身份,再确认订单,再判断是否符合退款条件,最后生成退款单。子图执行完毕后,结果汇总回父图,由父图统一生成回复。
整个系统跑下来,意图识别准确率从原来的 78% 提升到了 94%,工具调用错误率从 12% 降到了 2% 以下。更重要的是,新增一个业务场景(比如“发票申请”)只需要写一个新的子图挂上去,不用动现有逻辑,迭代速度完全不一样了。
下面我把这套系统的设计思路、核心实现、踩过的坑,完整地拆一遍。如果你也在做多智能体或者 AI 客服,希望能帮你少走点弯路。
2. 整体架构设计与核心思路拆解
2.1 父图与子图的职责边界怎么划
设计多智能体系统,第一个要回答的问题就是:什么该放在父图,什么该放在子图。我的划分原则是——父图管“路由和汇总”,子图管“业务和状态”。
父图只做三件事:接收用户输入、判断意图、把子图的输出整合成最终回复。它不碰任何业务逻辑,不知道退款要验证什么,也不知道账单怎么查。这样做的好处是父图极其稳定,业务怎么变它都不用改。
子图则是一个完整的业务闭环。以退款子图为例,它内部有自己的状态机:等待身份验证 → 验证中 → 验证通过 → 确认订单 → 判断退款资格 → 生成退款单 → 完成。每个状态之间的流转条件都定义在子图内部,父图完全不用关心。
这里有个容易踩的坑:很多人会把“身份验证”放在父图,觉得这是通用能力。我一开始也这么干,结果发现账单子图需要验证手机号,退款子图需要验证手机号加订单号,账号子图只需要验证邮箱,验证逻辑根本不一样。放在父图反而要写一堆 if-else,不如让每个子图自己管,需要复用的部分抽成一个独立的验证子图,谁需要谁调用。
2.2 状态设计:父子图之间传什么、怎么传
LangGraph 的状态传递是这套架构里最容易出问题的地方。父图和子图各有各的 State,它们之间的数据交换必须通过明确定义的字段。
我的做法是定义一个ParentState,里面包含几个关键字段:messages(对话历史)、intent(识别出的意图)、user_id(用户标识)、sub_result(子图返回的结果)、final_response(最终回复)。子图有自己的SubState,包含messages、user_id、以及业务相关的字段比如order_id、verified、refund_amount。
关键在于:子图只能读取父图传给它的字段,不能直接访问父图的完整状态。这是隔离性的体现。具体实现上,父图在路由到子图时,会把需要的字段打包传给子图;子图执行完后,把结果写回sub_result,父图再读取这个字段生成回复。
这里有个细节值得说:messages字段我用的是 LangGraph 的add_messagesreducer,这样父子图之间的对话历史能自动合并,不用手动拼接。但要注意,如果子图内部产生了大量的中间消息(比如工具调用的详细日志),这些不应该全部回传给父图,否则父图的上下文会爆炸。我的做法是子图只把“对用户可见的消息”写回父图,中间过程留在子图内部。
2.3 路由策略:意图识别节点怎么设计
意图识别是整个系统的入口,它的准确性直接决定了后续所有环节的质量。我试过三种方案,最后选了“LLM 分类 + 规则兜底”的混合策略。
纯 LLM 分类的问题是偶尔会“幻觉”,把退款意图识别成账单意图。纯规则的问题是覆盖不全,用户说话的方式千奇百怪。混合策略是先用 LLM 做分类,输出一个置信度,如果置信度低于阈值(我设的是 0.7),就走规则匹配兜底,规则匹配还搞不定就转人工。
意图识别的 Prompt 我改了很多版,最后稳定下来的结构是:先给模型列出所有意图类别和每个类别的典型例子,然后要求它输出 JSON 格式的结果,包含intent和confidence两个字段。这里有个技巧——例子要给“边界案例”,比如“我上个月的钱怎么还没退”这种,既像账单又像退款,给模型一个明确的归类,它后面遇到类似的就不会犹豫。
路由节点本身很简单,就是一个条件边,根据intent字段决定走哪个子图。但要注意,LangGraph 的条件边需要返回一个字符串,这个字符串要和你注册的节点名完全一致,大小写都不能错,我在这上面浪费过半小时。
2.4 为什么不用 Supervisor 模式而用子图模式
多智能体有个常见的模式叫 Supervisor,就是一个“主管”Agent 来调度其他 Agent。我试过,但放弃了。原因是 Supervisor 模式下,主管 Agent 需要了解每个下属 Agent 的能力边界,这又回到了“一个 Agent 知道太多”的老问题。而且 Supervisor 的调度是动态的,每次都要调一次 LLM 来决定下一步,延迟高、成本也高。
子图模式本质上是“静态路由 + 动态执行”。路由是提前定义好的(意图识别一次搞定),执行是动态的(子图内部可以多轮对话、条件分支)。这样既保证了调度的确定性,又保留了业务处理的灵活性。对于客服这种场景,用户意图相对明确,静态路由完全够用,没必要上 Supervisor 增加复杂度。
3. 核心细节解析与实操要点
3.1 子图的独立状态定义与 reducer 选择
定义子图状态的时候,reducer 的选择很关键。LangGraph 默认的行为是“覆盖”,也就是新值直接替换旧值。但对话历史这种字段,你需要的是“追加”,所以要用add_messages。我踩过的坑是:一开始所有字段都用默认 reducer,结果子图内部多轮对话的时候,历史消息一直被覆盖,模型永远只看到最后一条,完全没法做多轮。
正确的做法是区分对待。messages用add_messages,verified这种布尔标志用默认覆盖(因为只需要最新值),collected_info这种字典用自定义的合并 reducer(新字段合并进旧字典,而不是替换)。
自定义 reducer 的写法很简单,就是一个函数,接收旧值和新值,返回合并后的值。比如:
def merge_dict(old: dict, new: dict) -> dict: return {**old, **new}然后在 State 定义里用Annotated[dict, merge_dict]标注。这个技巧在处理“逐步收集用户信息”的场景特别有用,比如退款流程里先收集订单号、再收集退款原因,用合并 reducer 就不用每次手动把旧字段带上。
3.2 父子图状态传递的三种方式与选型
父子图之间传数据,我总结下来有三种方式,各有适用场景。
第一种是通过父图状态字段传递。父图在路由前把数据写进 State,子图读取。这种方式适合传递“路由决策相关”的数据,比如user_id、intent。优点是简单直接,缺点是父子图的状态定义要耦合,子图得知道父图有哪些字段。
第二种是通过子图调用时的参数传递。LangGraph 的add_node支持给节点传额外的参数,你可以在挂载子图的时候把需要的配置传进去。这种方式适合传递“配置类”数据,比如 API key、超时时间。优点是子图不需要知道父图的状态结构,缺点是只能传静态数据,不能传运行时产生的值。
第三种是通过共享的外部存储。比如用一个 Redis 或者内存字典,父子图都通过user_id去读写。这种方式适合传递“大量数据”或者“需要跨请求持久化”的数据。优点是解耦彻底,缺点是要管理外部依赖,而且调试的时候数据流不直观。
我的选择是:路由相关的小数据用第一种,配置用第二种,会话级的持久化数据用第三种。大部分场景第一种就够了,不要过度设计。
3.3 工具调用的封装与错误处理
每个子图内部都会挂一些工具,比如查订单、查账单、提交退款。工具调用的封装有两个要点:参数校验和错误兜底。
参数校验我是在工具函数内部做的,用 Pydantic 定义参数模型,LangGraph 会自动校验。如果校验失败,会抛异常,这个异常需要在子图里捕获,然后引导用户补充信息。比如用户说“我要退款”,但没给订单号,工具调用会因为缺少order_id失败,这时候子图应该回复“请提供您的订单号”,而不是直接报错。
错误兜底是指工具执行本身可能失败,比如查订单的 API 超时了。我的做法是给每个工具包一层重试逻辑,重试两次还失败就返回一个友好的错误信息,同时记录日志。这里要注意,不要让模型看到原始的技术错误信息,否则它可能会把“Connection timeout”这种话直接说给用户听,体验很差。
还有一个细节:工具调用的结果要格式化后再喂给模型。比如查订单返回的是一个 JSON,直接丢给模型它可能理解不了,我会先转成自然语言描述,比如“订单号 12345,金额 299 元,状态已支付,下单时间 3 月 15 日”,这样模型处理起来准确得多。
3.4 多轮对话中的状态保持与超时处理
客服场景几乎都是多轮对话,用户不会一句话把需求说全。这就要求子图能记住之前的对话内容,并且在用户长时间不回复的时候能优雅地结束会话。
状态保持靠的是messages字段的累积,这个前面说过了。但有个坑是:上下文不能无限增长。用户和退款子图聊了二十轮,messages里堆了四十条消息,模型的 token 消耗会很大,而且早期的信息可能已经不重要了。我的做法是设置一个窗口,只保留最近十轮对话,更早的用摘要代替。摘要用一个轻量的 LLM 生成,成本很低。
超时处理是另一个容易被忽略的点。用户可能聊到一半就走了,这时候子图的状态还挂在内存里。我设置了一个 30 分钟的超时,超时后自动清理状态,并且如果用户再回来,重新走一遍意图识别。LangGraph 本身不提供超时机制,这个要自己在状态里加一个last_active时间戳,用一个后台任务定期扫描清理。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
先把环境搭起来。我用的是 Python 3.11,LangGraph 的版本是 0.2.x,LangChain 是 0.3.x。这两个版本搭配比较稳定,再新的版本有些 API 变了,网上的教程对不上,容易踩坑。
pip install langgraph==0.2.60 langchain==0.3.7 langchain-openai==0.2.8 pip install fastapi uvicorn pydantic redis这里解释一下为什么选这些。LangGraph 0.2.x 是子图功能比较成熟的版本,add_node挂载子图的 API 已经稳定了。LangChain 0.3.x 和它是配套的,工具调用的接口没有大改。FastAPI 用来做 HTTP 服务,Redis 用来做会话状态的持久化。
模型我用的是 OpenAI 的 GPT-4o-mini,客服场景对推理能力要求没那么高,mini 版本够用,成本还低。如果你要用别的模型,LangChain 的ChatOpenAI换成对应的类就行,接口是一样的。
4.2 定义父图状态与意图识别节点
先定义父图的状态。这里我用TypedDict来定义,字段包括messages、intent、user_id、sub_result、final_response。
from typing import TypedDict, Annotated from langgraph.graph import add_messages class ParentState(TypedDict): messages: Annotated[list, add_messages] intent: str user_id: str sub_result: dict final_response: str意图识别节点的实现,核心是一个 Prompt 加一次 LLM 调用。Prompt 里我列了四个意图类别,每个类别给了五个例子,特别强调了边界案例的归类。输出要求是 JSON,包含intent和confidence。
INTENT_PROMPT = """你是一个客服意图识别助手。请判断用户消息属于以下哪个类别: - billing: 账单查询、费用疑问、扣费异常 - feature: 功能使用、操作咨询、报错排查 - account: 账号登录、密码重置、绑定解绑 - refund: 退款申请、退款进度、退款条件 边界案例说明: - "我上个月的钱怎么还没退" → refund(涉及退款进度) - "这个月扣了我两次钱" → billing(涉及扣费异常) 请输出 JSON:{"intent": "...", "confidence": 0.0-1.0} 用户消息:{message} """节点函数里,我加了置信度判断。低于 0.7 的时候,走一个关键词匹配的兜底逻辑。关键词匹配很简单,就是维护一个词表,比如“退款”“退钱”“退费”归到 refund,“密码”“登录”“账号”归到 account。兜底还搞不定就返回unknown,路由到人工客服子图。
4.3 构建退款子图的完整状态机
退款子图是这套系统里最复杂的,我拿它当例子详细说。它的状态定义如下:
class RefundState(TypedDict): messages: Annotated[list, add_messages] user_id: str order_id: str verified: bool refund_reason: str refund_eligible: bool refund_amount: float collected_info: Annotated[dict, merge_dict]节点有六个:verify_identity、collect_order、check_eligibility、collect_reason、create_refund、generate_response。边的话,verify_identity之后根据verified决定是继续还是要求重新验证,check_eligibility之后根据refund_eligible决定是继续还是告知不符合条件。
verify_identity节点会调用一个验证工具,传入user_id和用户提供的验证信息。验证通过就把verified设为 True。这里有个细节:验证信息可能是手机号、邮箱、订单号中的任意一种,我在工具里做了兼容,只要有一种匹配就算通过。
collect_order节点负责从对话中提取订单号。我用了一个简单的正则加 LLM 提取的组合,正则先扫一遍,扫不到再让 LLM 从对话历史里找。提取到之后写入order_id。
check_eligibility节点调用退款资格查询工具,返回是否符合条件以及可退金额。这个工具内部有一堆业务规则,比如“超过 30 天的订单不可退”“已使用超过 50% 的服务不可退”,这些规则写在工具里,不暴露给模型。
4.4 父子图挂载与条件路由配置
子图构建好之后,用add_node挂到父图上。这里的关键是给子图起一个名字,这个名字就是路由时的目标节点名。
from langgraph.graph import StateGraph, END parent = StateGraph(ParentState) parent.add_node("intent_router", intent_router_node) parent.add_node("billing_agent", billing_subgraph) parent.add_node("feature_agent", feature_subgraph) parent.add_node("account_agent", account_subgraph) parent.add_node("refund_agent", refund_subgraph) parent.add_node("response_generator", response_node)条件路由用add_conditional_edges,传入一个函数,函数返回目标节点名。
def route_by_intent(state: ParentState) -> str: intent = state.get("intent", "unknown") mapping = { "billing": "billing_agent", "feature": "feature_agent", "account": "account_agent", "refund": "refund_agent", } return mapping.get(intent, "response_generator") parent.add_conditional_edges("intent_router", route_by_intent)每个子图执行完后都连到response_generator,由它统一生成最终回复。response_generator会读取sub_result,结合对话历史,生成一段自然的回复文本。
4.5 会话持久化与 FastAPI 接口封装
状态持久化我用的是 LangGraph 的 checkpointer,配合 Redis 做存储。这样即使用户中途断开,下次回来还能接着之前的对话。
from langgraph.checkpoint.redis import RedisSaver checkpointer = RedisSaver.from_conn_string("redis://localhost:6379") app_graph = parent.compile(checkpointer=checkpointer)FastAPI 的接口很简单,一个 POST 端点接收用户消息和session_id,调用app_graph.invoke,传入config={"configurable": {"thread_id": session_id}},这样同一个session_id的对话会自动关联到同一个状态。
@app.post("/chat") async def chat(req: ChatRequest): config = {"configurable": {"thread_id": req.session_id}} result = app_graph.invoke( {"messages": [HumanMessage(content=req.message)], "user_id": req.user_id}, config=config, ) return {"reply": result["final_response"]}这里有个性能上的注意点:invoke是同步的,如果并发量高,要用ainvoke异步版本,配合 FastAPI 的 async 端点。我实测下来,异步版本在 100 并发下的 P99 延迟比同步版本低 40% 左右。
5. 常见问题与排查技巧实录
5.1 子图状态不更新或更新丢失
这是最常见的问题,表现是子图内部明明修改了某个字段,但父图读到的还是旧值。原因通常是子图的输出没有正确写回父图的状态。
LangGraph 的子图在作为节点执行时,它的返回值会被合并到父图状态里。但合并的规则取决于父图该字段的 reducer。如果父图sub_result字段用的是默认 reducer(覆盖),子图返回{"sub_result": {...}}就能正常写入。但如果子图返回的字段名和父图对不上,就会被忽略。
排查方法:在子图的最后一个节点里打印一下返回值,确认字段名和父图定义的一致。我踩过的坑是子图返回了result但父图字段叫sub_result,白白调试了一小时。
5.2 意图识别准确率上不去
如果意图识别经常出错,先检查 Prompt 里的例子够不够“边界”。我一开始只给了典型例子,模型遇到模糊表达就懵。后来加了二十多个边界案例,准确率从 82% 提到了 91%。
另一个技巧是给模型“思考空间”。不要让它直接输出分类结果,而是先让它用一句话解释为什么这么分类,再输出 JSON。这个“解释”步骤能显著提升准确率,因为模型在解释的过程中会自我校验。实测下来,加了这一步,准确率又提了 3 个百分点。
如果还是不行,考虑上 few-shot 的动态检索。把历史对话里相似的案例检索出来,作为例子塞进 Prompt。这个方案成本高一些,但对长尾意图效果很好。
5.3 工具调用参数缺失或格式错误
工具调用失败,十有八九是参数问题。LangGraph 的工具调用依赖模型输出的 JSON,模型有时候会漏字段或者字段类型不对。
我的解决方案是三层防护。第一层,在工具定义里用 Pydantic 严格定义参数类型,模型输出不符合就直接报错,不会带着错误参数去执行。第二层,在子图里捕获参数错误,引导用户补充信息,而不是直接失败。第三层,给模型一个“参数检查”的提示,在调用工具前先确认所有必填参数都有了。
还有一个实用技巧:把工具的参数说明写清楚,包括格式要求。比如order_id要说明“格式为 ORD 开头的 12 位字符串”,模型看到这个说明,输出的格式就规范多了。
5.4 多轮对话中上下文丢失
上下文丢失通常是因为messages字段的 reducer 配置错了,或者消息没有正确传递。检查两个地方:一是父图和子图的messages字段是否都用了add_messages,二是子图返回时是否把新消息带上了。
另一个可能的原因是消息窗口设置得太小。我一开始设的是保留最近 5 轮,结果用户聊到第 6 轮的时候,第 1 轮提供的关键信息(比如订单号)丢了。后来改成 10 轮,并且对更早的消息做摘要,问题就解决了。
摘要的实现很简单,用一个便宜的模型(比如 GPT-4o-mini)把早期对话压缩成一段话,作为一条 SystemMessage 放在最前面。这样既保留了关键信息,又控制了 token 消耗。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 子图状态不更新 | 字段名不匹配 | 打印子图返回值 | 统一父子图字段命名 |
| 意图识别不准 | Prompt 例子不足 | 检查边界案例 | 补充边界案例,加解释步骤 |
| 工具调用失败 | 参数缺失或格式错 | 查看工具调用日志 | Pydantic 校验 + 引导补充 |
| 上下文丢失 | reducer 配置错 | 检查 messages 字段 | 用 add_messages,设窗口 |
| 响应延迟高 | 同步调用阻塞 | 看 P99 延迟 | 改异步 ainvoke |
| 会话状态不持久 | checkpointer 没配 | 检查 Redis 连接 | 配置 RedisSaver |
5.6 几个我踩过的坑和独家技巧
第一个坑是子图嵌套子图。我一开始想把“身份验证”做成一个子图,然后让退款子图去调用它。LangGraph 支持子图嵌套,但状态传递会变得很复杂,调试难度翻倍。后来我放弃了嵌套,把身份验证做成一个普通节点,在需要它的子图里直接调用函数。简单直接,效果一样。
第二个坑是条件边的返回值。LangGraph 的条件边函数必须返回一个字符串,这个字符串要和节点名完全一致。我有一次返回了"refund"但节点名是"refund_agent",结果直接报错,而且错误信息不直观,找了半天才发现。
第三个技巧是给每个子图加一个“兜底节点”。子图内部如果所有条件分支都没命中,会走到一个默认节点,这个节点负责生成一个“抱歉,我没理解”的回复,并把状态标记为需要人工介入。这样不会出现“卡死”的情况。
第四个技巧是用 LangGraph 的可视化做调试。app_graph.get_graph().draw_mermaid()能生成流程图,虽然我这里不画图,但你在本地调试的时候可以生成出来看,能直观地看到状态流转路径,排查路由问题特别快。
6. 性能优化与扩展方向
6.1 延迟优化的几个实操手段
客服系统对延迟很敏感,用户等超过 3 秒就会不耐烦。我做了几件事来压延迟。
第一,意图识别和子图执行并行化。意图识别只需要看用户最新一条消息,不需要等完整的对话历史加载完。我把这两步拆开,意图识别先跑,跑的同时加载历史,等意图出来的时候历史也加载好了。
第二,工具调用加缓存。查订单、查账单这些操作,同一个用户短时间内可能重复调用,我在工具层加了一个 5 分钟的缓存,命中率大概 30%,省了不少时间。
第三,模型选择分级。意图识别用 mini 模型,子图内的复杂推理用标准模型,生成回复用 mini 模型。这样在保证质量的前提下,整体成本降了 60%,延迟也降了。
6.2 新增业务子图的标准流程
这套架构最大的好处就是扩展方便。新增一个业务场景,比如“发票申请”,标准流程是四步。
第一步,定义子图状态。参考退款子图,把业务相关的字段列出来,比如invoice_title、tax_number、invoice_amount。
第二步,实现节点和边。发票申请的流程比退款简单,大概三个节点:收集开票信息、校验信息、生成发票申请单。
第三步,挂载到父图。在父图add_node加一行,在意图识别的 Prompt 里加一个类别,在路由映射里加一条。
第四步,测试。用 LangGraph 的可视化确认路由正确,然后用几个典型用例跑一遍,确认状态传递没问题。
整个过程熟练的话半天就能搞定,不用动任何现有代码。这就是子图模式的价值。
6.3 从单机到分布式的演进思路
现在这套系统是单机跑的,状态存在 Redis 里。如果并发量继续涨,需要考虑分布式。LangGraph 本身支持分布式部署,多个实例共享同一个 Redis checkpointer 就行。
但分布式会带来一个新问题:同一个session_id的请求可能被路由到不同实例,如果两个请求同时到达,会有状态竞争。解决方案是用 Redis 的分布式锁,按session_id加锁,保证同一会话的请求串行处理。这个锁的粒度要控制好,太粗会影响并发,太细会有竞争,按会话加锁是比较合适的。
再往后如果量特别大,可以考虑把不同子图部署成独立的服务,父图通过 HTTP 调用子图服务。这样每个子图可以独立扩缩容,退款子图压力大就多部署几个实例。不过这是后话了,大部分场景单机加 Redis 就够了。
6.4 监控与可观测性建设
上线之后,监控比开发还重要。我埋了几个关键指标:意图识别的准确率(人工抽检)、子图的平均执行时长、工具调用的成功率、会话的完成率(用户问题是否被解决)。
这些指标我用 Prometheus 收集,Grafana 展示。LangGraph 的每个节点执行都会产生 trace,我把这些 trace 接到 LangSmith 上,出问题的时候能直接看到是哪个节点、哪次调用出的错。
还有一个实用的监控是异常会话告警。如果某个会话的轮次超过 15 轮还没结束,大概率是卡住了,系统会自动告警,人工介入看一下。这个机制帮我发现了好几个 Prompt 的边界问题。
7. 一些个人体会
这套系统从设计到上线大概花了六周,其中前两周基本都在试错,试了 Supervisor 模式、试了单 Agent 大 Prompt、试了纯规则路由,最后才定下来子图模式。回过头看,子图模式最大的价值不是技术上的先进,而是它符合人类组织协作的直觉。每个 Agent 像一个专员,有自己的职责和流程,父图像前台负责调度,这种结构团队里任何人都能理解,沟通成本极低。
如果让我给正在做类似系统的朋友一个建议,那就是:先把业务流程图画出来,再决定怎么拆子图。不要一上来就想技术架构,先想清楚用户的问题有哪几类,每类的处理流程是什么,哪些步骤是共用的。画完流程图,子图的边界自然就出来了。
另外,不要追求一步到位。我第一版只做了账单和退款两个子图,跑通了之后再逐步加功能。每次加新子图都是一次验证架构的机会,如果加得很痛苦,说明架构有问题,趁早调整。如果加得很顺,说明方向对了,继续往前走。
最后分享一个小的工程习惯:给每个子图写一个独立的测试脚本,用几个典型用例跑一遍,确认状态流转正确。这个习惯帮我省了很多联调的时间,尤其是改了一个子图之后,跑一遍测试就知道有没有影响到别的子图。