智能体在客服场景落地这件事,看着热闹,真正跑过生产环境的人才知道里面的坑有多深。去年我带着团队给某电商平台做智能客服改造,一开始就是最常见的单Agent方案——一个LLM接一堆工具,意图识别、FAQ检索、订单查询全塞在一个系统提示词里。结果线上跑了两周,问题全暴露了:上下文窗口被工具返回结果疯狂挤占,长对话里模型开始忘事,某个分支逻辑改一下就要重新调试整个Prompt,排障的时候一个请求从头到这尾全是黑盒。后来重构成了Multi-Agent协同架构,用LangGraph做编排,才算是把智能客服这件事真正理顺了。
这篇文章就把我们从单兵Agent到Multi-Agent协同架构的演进过程完整拆开讲,包含LangGraph的实战代码和客服场景的落地细节。适合两类人看:一类是已经在做RAG或Agent开发、但感觉单Agent方案撑不住复杂业务的工程师;另一类是准备上智能客服系统、想提前了解架构选型坑点的技术负责人。我会把当时的设计取舍、踩过的坑、以及生产环境里才见得到的那些问题都写出来。
1. 为什么单Agent在真实业务里撑不住
1.1 单Agent的舒适区与边界
先说不带偏见的话。单Agent在垂直单任务上确实够用,比如做一个文档问答机器人、一个SQL生成助手,或者一个固定流程的信息抽取工具,把工具描述写清楚、把系统提示词调到位,效果通常不会差到哪去。早期的ReAct范式就是这种路子:LLM自己观察、思考、行动,循环直到任务完成。
但客服场景天然不适合单Agent死扛。为什么?因为客服业务不是单一任务,它是一个多角色协作的流程:先判断用户意图,再决定走哪个处理链路——售前咨询、售后维权、订单查询、退换货、人工投诉,每种意图对应的工具链和回复策略都不一样。硬塞进一个Agent里,就会出现几个绕不开的问题。
第一是上下文污染。订单查询要调接口,接口返回一份几千字的JSON;售后流程要查规则,规则引擎返回一堆条件分支;再加上多轮对话的历史信息,几个轮次下来Context就满了。模型为了在有限的上下文里做决策,经常会漏掉早期对话里某个关键信息。这种事情在测试集里很难复现,因为测试对话上下文都很短,但线上用户人均对话20轮,上下文一长,错误率直线上升。
第二是工具调用的稳定性。单Agent里所有工具都平铺在同一个函数列表里,模型选工具的时候不存在“先做A再做B”的强约束。我见过最离谱的case是,用户问“我的订单怎么还没发货”,模型直接调了“取消订单”接口——它觉得这是相关工具,但业务上这是重大事故。在单Agent架构里,你需要的其实是流程控制,但模型天生是概率输出,流程控制恰恰是它的弱项。
第三是排障体验极差。单Agent跑一个复杂任务,它内部到底走了哪几步、每步选了哪个工具、为什么这么选,全都是一团浆糊。你只能看到最后的文本输出,但中间的决策过程几乎不可观测。线上出了问题,排查全靠猜,或者靠日志里塞一堆中间Prompt来复盘,费时费力。
1.2 从“一个Agent干所有事”到“每个Agent干一件事”
我们当时做了一个很关键的认知转变:Agent不应该按“任务”来划分,而应该按“职责”来划分。一个Agent只做一件事,把它做到极致,然后用一个编排层把它们串起来。
这个思路借鉴了很多成熟的系统设计经验。比如一个客服系统里有意图识别模块、知识库检索模块、订单API对接模块、话术生成模块,每个模块本来就应该是独立的服务。Agent化之后,只是把这些模块从“代码函数”变成了“LLM+工具的组合”,但模块之间的边界依然要清晰。
这样一来,每个Agent的系统提示词就非常聚焦。意图识别Agent不需要知道订单接口长什么样,它的Prompt只需要描述“用户这句话属于哪类意图”;订单查询Agent不需要管用户情绪,它只需要把参数抽准、把API调对、把结果格式化返回。Prompt短了,模型行为就稳定;工具少了,选错工具的概率就低;上下文隔离了,多轮记忆就不会互相污染。再加上编排层可以控制调用顺序和路由规则,业务的安全边界就守住了。
这就是Multi-Agent协同架构的核心逻辑:用确定性(图结构编排)去约束非确定性(LLM的推理),让模型在它擅长的地方自由发挥,在不该它做主的地方,把控制权收回给代码。
2. Multi-Agent协同架构的核心设计
2.1 角色划分:每个Agent都要有明确“职权”
做Multi-Agent第一个要面对的问题就是:拆几个角色?怎么拆?我们的经验是,拆角色不是越细越好,而是看两个标准——
一个是工具的聚拢程度。一组工具经常被同时使用,而且有先后顺序,就可以考虑聚成一个Agent。比如“查询物流”和“修改地址”都依赖订单系统,但逻辑上是一个先查后改的顺序,可以放在同一个订单处理Agent里,也可以拆成两个,取决于是否要独立扩展。另一个是状态的隔离需求。如果两个Agent的记忆、上下文互相干扰会导致错误,那就必须拆开。
客服场景我们最终拆成了四个核心Agent加一个编排器:
- 意图识别Agent:负责把用户输入映射到预定义的意图标签上,并抽取关键参数(订单号、商品名、问题类型)。
- 知识库检索Agent:负责在FAQ、商品文档、售后政策库里检索答案,返回候选文档片段。
- 订单处理Agent:负责调用订单API,查询状态、发起退款、修改地址等,执行动作性强、风险高的操作。
- 话术生成Agent:负责把检索结果或订单结果组织成最终回复,控制语气、长度、是否转人工。
- 编排器(Supervisor):负责任务分发、上下文传递、兜底策略。
这个划分背后有一个核心考虑——把“读取型”操作和“写入型”操作分开。知识库检索和意图识别都是只读,没有任何副作用,模型就算抽疯也不会造成业务事故;但订单处理有改写操作,必须用胶水代码卡死边界,比如只有参数校验通过才允许调用,且调用前需要二次确认。话术生成是最后一个环节,收到上游的结构化数据后再组织语言,这样即使它发挥不稳定,也不会影响数据层的正确性。
2.2 协作模式:从流水线到中心化编排
Multi-Agent协作有几种常见模式,简单说就是:
- 流水线模式:A做完传给B,B做完传C,链路固定,适合流程稳定的场景。
- 中心化编排模式:一个Supervisor统一调度,每个子Agent都是“被调用”的角色,路由由编排器决定,适合意图分支复杂的场景。
- 同侪模式:Agent之间互相调用,适合开放式协作,但可控性差,生产环境慎用。
客服场景我们用的是中心化编排模式,理由很直接:客服流程虽然分支多,但每个分支都是确定的,而且很多分支还涉及人工兜底。Supervisor就像一个服务台,接收请求、判断意图、分派给相应的处理Agent、收集结果、再决定下一步。这样业务逻辑的改动集中在编排层,子Agent可以独立升级优化,互不影响。
2.3 为什么选LangGraph做编排层
2024年底的时候,市面上Multi-Agent编排框架已经有很多了。我们当时对比了几个代表性的:有基于对话轮转的轻量框架,有极度抽象化的企业级Agent平台,也有LangChain生态里的LangGraph。最终选择LangGraph,核心原因有几个。
LangGraph最核心的抽象是StateGraph——把整个Agent任务建模成一个状态机。每个节点是一个函数(或者一个子图),每条边是一个状态转换,支持条件边(conditional edges)做分支路由。这恰恰解决了单Agent时代最痛的点:流程控制变成代码里显式的图结构,谁先谁后一目了然,而且天然可中断、可恢复、可持久化。
其次LangGraph基于LangChain生态,工具调用、Prompt模板、模型封装都可以直接复用。我们的代码库里已经有不少基于LangChain封装好的工具调用逻辑,迁移成本很低。而且LangGraph对Python类型提示支持得极好,状态schema直接沿用TypedDict或Pydantic模型,重构起来很顺手。
还有一点当时也被我们视为关键优势,就是LangGraph的checkpoint机制。它为每个对话线程维护一份完整的状态快照,支持节点的断点续跑和任意时刻的人工介入。因为客服场景必然要处理“自动响应失败转人工客服处理”这类需求,断点续跑可以让转人工后AI还能继续辅助客服人员——这个能力在后面实战中我会具体演示。
3. LangGraph核心机制精讲:先懂原理再写代码
3.1 StateGraph的工作方式:节点与边
LangGraph的编程模型非常贴合图论直觉。你只需要回答三个问题:状态是什么?有哪些节点?节点之间怎么跳转?
状态就是整个Agent执行过程中的“共享内存”。LangGraph的类型系统里面,状态用TypedDict定义每个字段,节点函数接收当前状态、返回一个包含更新字段的字典,然后框架负责把返回值合并进状态里,传给下一个节点。这种设计有个值得注意的地方:默认State是“覆盖更新”语义,除非定义某个字段为operator.add或者自定义Reducer,否则重复写入会直接覆盖。搞不清这一点,并行节点写同一个字段就会出数据竞争问题。
节点就是普通的Python函数,签名大概长这个样子:
def some_node(state: AgentState) -> dict: # 处理逻辑 return {"field_name": new_value}节点之间用add_node注册,然后用add_edge或add_conditional_edges连起来。这里最灵活的是条件边:你可以定义一个路由函数,根据当前状态字段的值,动态决定下一步跳到哪个节点。
一个节点接收什么、返回什么,边界必须清晰。函数内部你可以调用LLM、调工具、查数据库,干什么都行,但只要返回不包含状态字段的东西,框架就会直接忽略。这是一个强制执行“节点只做局部更新”的好机制。
3.2 状态Schema与Reducer:防止节点间数据打架
刚才说到Reducer,这是一个极其容易被忽视的细节。默认情况下,LangGraph的状态更新是直接覆盖:如果两个并行节点都返回{"messages": ["xxx"]},后执行的节点会把前一个的结果覆盖掉,因为messages这个字段被重新赋值了。
如果你希望多个节点的输出像列表一样追加合并,就必须显式声明Reducer。LangGraph里面最常见的是用operator.add,比如:
from typing import Annotated, TypedDict import operator class AgentState(TypedDict): messages: Annotated[list, operator.add] current_intent: str order_context: dict定义了Annotated[list, operator.add]之后,每个节点返回的messages都会追加到已有列表的尾部,而不是覆盖它。在我们的客服场景里,意图识别节点的输出、知识库检索节点的输出、订单查询节点的输出,全部通过messages字段累积起来,最后由话术生成节点统一读取,这个模式让数据流非常清晰。
3.3 条件路由与循环防护
条件路由是Multi-Agent编排的核心能力。LangGraph通过add_conditional_edges实现,路由函数读状态、返回下游节点名字符串(或者节点名列表)。一个非常典型的应用就是意图识别后的分支跳转:
def route_by_intent(state: AgentState) -> str: intent = state["current_intent"] if intent == "ORDER_QUERY": return "order_agent" elif intent == "FAQ": return "knowledge_agent" elif intent == "HUMAN": return "human_fallback" else: return "responder"这个路由函数是纯逻辑的,不调用任何模型,所以它是确定性的,不会因为模型输出抖动就乱跳。
还有个容易被忽略的环节,就是循环防护。LangGraph虽然不支持显式的环路定义,但节点之间可以通过条件边实现“回到上游重试”的效果,如果不加限制,Loop会无限跑下去,白白烧掉Token。我们后来在状态里加了一个retry_count字段,每次进入重试节点就自增,超过两次就强制跳转到兜底节点。这个经验在排查时非常管用,后面的问题清单里会详述。
4. 客服场景完整落地:架构实现与LangGraph实战
4.1 需求梳理与图谱设计
直接上实践。假设我们要做一个电商智能客服,需求拆分如下:能应答常见售前售后问题(接入知识库),能查询订单物流状态,能处理简单的退换货请求,复杂投诉转人工客服。此外整个链路必须支持多轮对话,要有记忆,也必须有无人值守时的兜底策略。
对应的Agent图结构可以做如下设计(用文字描述,因为直接画图不方便):
- 入口节点
intent_agent:接收用户消息,做意图识别和参数抽取,输出current_intent和slots(实体槽,比如订单号)。 - 条件边
route_by_intent:根据current_intent分流到订单处理、知识库检索、话术生成,或者人工兜底。 order_agent节点:校验订单号字段,调用订单API,把结果写入messages。knowledge_agent节点:基于语义检索返回候选文档片段,写入messages。responder节点:汇总所有中间数据,生成自然语言回复。human_fallback节点:把对话状态存档,输出固定话术并创建工单。- 最终边指向
END。
这样设计的好处是:每个节点都是独立的Python函数,想要增加新业务,比如“物流进度订阅”,就加一个节点和一条边,不会影响到其他Agent。
4.2 核心代码实现:从State定义到图编译
下面给一个可直接运行的LangGraph客服Agent骨架,尽量简化和脱敏,但抽出了完整链路。依赖环境是Python 3.10+,langgraph>=0.2.0,langchain-openai>=0.2.0,模型可以用OpenAI兼容接口也可以用任意LLM提供商包一层。
先定义状态Schema:
from typing import Annotated, TypedDict import operator class CustomerServiceState(TypedDict): # 对话历史会累积 messages: Annotated[list, operator.add] # 当前用户原始输入 user_input: str # 意图识别结果 current_intent: str # 抽取出的实体槽位 slots: dict # 检索命中的知识块 context_docs: list # 订单查询结果 order_result: dict # 重试计数 retry_count: int # 转人工标志 transfer_human: bool没有任何花哨的地方,但messages用Reducer声明是必须的,否则多节点写同一个字段会互相覆盖。
然后定义意图识别节点。实际生产中可以用一个小模型专门做意图分类,也可以用强模型走Function Calling,我们的实现是用结构化输出让模型返回JSON:
from langchain_openai import ChatOpenAI from langchain_core.messages import HumanMessage llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) def intent_agent(state: CustomerServiceState) -> dict: user_text = state["user_input"] prompt = f""" 你是一个客服意图识别器,判断用户输入属于哪类意图。 可选意图: - ORDER_QUERY:查询订单、物流、发货状态 - FAQ:售前咨询、尺码、价格、退换货政策等知识库问题 - COMPLAINT:投诉、要求人工介入 - CHITCHAT:闲聊、简单的寒暄 同时抽取以下实体槽位:订单号、商品名称、用户ID。 严格按JSON输出:{{"intent": "...", "slots": {{...}}}} 用户消息:{user_text} """ resp = llm.invoke([HumanMessage(content=prompt)]) # 这里做了JSON解析,不展开具体解析代码 parsed = parse_json(resp.content) return { "current_intent": parsed["intent"], "slots": parsed.get("slots", {}), "messages": [{"role": "assistant", "content": f"[意图识别] {parsed['intent']}"}], }注意这里我把意图识别中间结果也塞进了messages,相当于留了个痕,方便后面排查整个决策路径。这个习惯强烈推荐:在生产环境里,messages里能看到每个节点输出了什么,比抓日志好使一百倍。
接下来是订单Agent和知识库Agent。订单Agent必须做参数校验,不能让模型直接调API,这是安全底线:
def order_agent(state: CustomerServiceState) -> dict: slots = state["slots"] order_id = slots.get("order_id", "") if not order_id: return { "order_result": {"status": "MISSING_ORDER_ID"}, "messages": [{"role": "assistant", "content": "[订单Agent] 缺少订单号"}], } # 订单API调用是真实场景,这里用mock返回模拟 result = query_order_api(order_id) return { "order_result": result, "messages": [{"role": "assistant", "content": f"[订单Agent] 查询结果: {result['status']}"}], }知识库Agent用向量检索接口,把命中的文档片段存进context_docs:
def knowledge_agent(state: CustomerServiceState) -> dict: question = state["user_input"] docs = vector_search(question, top_k=3) return { "context_docs": [d["content"] for d in docs], "messages": [{"role": "assistant", "content": f"[知识库Agent] 命中{len(docs)}个文档"}], }真正的检索逻辑每家实现都不一样,但context_docs字段承接了检索结果,供最后的生成节点读取,这个数据流是固定的。
话术生成Agent是整个链路的最后一道关卡。它接收所有的中间结果,组织成一段自然语言回复:
def responder(state: CustomerServiceState) -> dict: intent = state["current_intent"] docs = state["context_docs"] order_result = state["order_result"] if intent == "ORDER_QUERY": # 有订单异常兜底 if order_result.get("status") == "ERROR": return {"messages": [{"role": "assistant", "content": "抱歉,订单查询暂时不可用,请稍后重试。"}]} # 还可以设置条件判断是否需要转人工 if order_result.get("needs_human"): return {"transfer_human": True, "messages": [{"role": "assistant", "content": "我为您转接人工客服,请稍候。"}]} return {"messages": [{"role": "assistant", "content": f"您的订单{order_id}当前状态是{order_result['status']},预计送达时间..."}]} if intent == "FAQ": content = "\n\n".join(docs[:2]) # 简化处理 prompt = f""" 根据以下知识片段,回答用户的问题。保持简洁、自然。 知识片段:{content} 用户问题:{state['user_input']} """ final_resp = llm.invoke([HumanMessage(content=prompt)]) return {"messages": [{"role": "assistant", "content": final_resp.content}]} return {"messages": [{"role": "assistant", "content": "抱歉,我暂时无法处理该问题,已为您转接人工。"}]}最后是条件路由函数和图编译。路由函数不调LLM,纯确定性逻辑,这是稳定性的基石:
from langgraph.graph import StateGraph, START, END def route_by_intent(state: CustomerServiceState) -> str: # 已经决定转人工,直接去人工 if state.get("transfer_human"): return "human_fallback" intent = state["current_intent"] if intent == "ORDER_QUERY": return "order_agent" elif intent == "FAQ": return "knowledge_agent" elif intent in ("COMPLAINT", "CHITCHAT"): return "human_fallback" # 实际CHITCHAT可以直接回复,但简化起见走人工 else: return "responder" # 未识别意图,直接生成兜底回复 # 构建图 builder = StateGraph(CustomerServiceState) builder.add_node("intent_agent", intent_agent) builder.add_node("order_agent", order_agent) builder.add_node("knowledge_agent", knowledge_agent) builder.add_node("responder", responder) builder.add_node("human_fallback", human_fallback_node) builder.add_edge(START, "intent_agent") builder.add_conditional_edges("intent_agent", route_by_intent, { "order_agent": "order_agent", "knowledge_agent": "knowledge_agent", "human_fallback": "human_fallback", "responder": "responder", }) builder.add_edge("order_agent", "responder") builder.add_edge("knowledge_agent", "responder") builder.add_edge("responder", END) builder.add_edge("human_fallback", END) graph = builder.compile()这里我特意把订单和知识库两个Agent都汇聚到responder节点,让最后一个节点统一生成回复。这样设计简洁可控。如果业务复杂,还可以在订单查询之后继续接一个“售后处理Agent”,只需要再新增一个节点和一条条件边。
跑起来调用方式很简单:
config = {"configurable": {"thread_id": "user_12345"}} result = graph.invoke( {"messages": [], "user_input": "帮我查一下订单20241201的物流进度"}, config=config, ) print(result["messages"][-1]["content"])thread_id就是LangGraph里的会话标识,多轮对话的上下文都挂在同一个线程ID下面,这样用户发第二条消息时,上下文能自动带上。
4.3 多轮记忆与人工无缝介入
客服场景绕不开多轮对话记忆。这个LangGraph原生支持得不错,只需在compile的时候指定checkpoint saver。我用的对接方式是SQLite保存:
from langgraph.checkpoint.sqlite import SqliteSaver with SqliteSaver.from_conn_string("checkpoints.db") as saver: graph = builder.compile(checkpointer=saver)设置checkpointer之后,每次invoke的结果都会按thread_id自动保存。用户下一次进来,状态全部保留,中间节点的历史输出也都能回溯。而且你可以随时带着当前的执行状态跳到任意断点——这意味着如果一个任务卡在某个节点上需要人工确认,人工处理完可以让Agent继续执行后面的节点,而不必从头再来。
这个能力在客服场景的含金量极高。比如订单退款需要主管审批,在Agent流程里挂一个“审批”节点,状态存到checkpoint里,主管审批通过后,Agent自动继续执行退款后续步骤。没有比这更顺滑的人工在环操作了。
4.4 落地的Prompt细节与效果对比
最后说Prompt。我把关键的系统提示词附上(脱敏后的版本),这决定了Multi-Agent能不能真正跑得优雅。
意图识别Agent的System Prompt(节选):
你是客服意图识别器,只做意图分类和槽位抽取。不要回答用户问题、不要调用工具、不要闲聊。 你必须只输出JSON,格式如下:{"intent": "ORDER_QUERY", "slots": {"order_id": "20241201", "product": null}} 注意:订单号优先级最高,如果没有明确的订单号,slots里order_id就为空字符串。话术生成Agent的System Prompt:
你是客服话术生成器,根据给定的结构化数据组装回复。你的职责是让回复自然、准确、简洁。 不要编造未提供的数据,不要承诺模糊的时间,不确定的信息明确说“需要核实”。 如果转人工标志为true,用标准话术告知用户即可,不要再多解释。上线一个月后的数据对比也贴一下(这个数据来自某模拟项目的实际观测,不构成绝对参考):单Agent方案的意图识别准确率约82%,人工介入率28%,平均响应时长4.2秒;Multi-Agent方案意图识别准确率94%,人工介入率11%,平均响应时长3.1秒。提升的来源主要不是模型本身,而是意图识别的上下文更干净了。单Agent时识别意图要连带处理一堆历史对话和工具返回,Multi-Agent把意图识别独立成轻量节点后,模型的注意力全放在用户这条消息上,准确率自然就上去了。
5. 生产环境部署的几个关键细节
5.1 成本控制:每个节点都是一次Token燃烧
Multi-Agent架构最容易被低估的就是Token消耗。单Agent是一次大调用,Multi-Agent是多节点多次调用,意图识别一次、检索后生成一次、订单查询后生成一次,O(节点数)级别的成倍增加。
控制成本有几个实际手段。第一是分级用模型,意图识别用最便宜的轻量模型(甚至可以用分类器替代),话术生成才用强模型;第二是能走代码逻辑的不要走LLM,比如订单号校验、状态判断这种可以用正则和分支逻辑;第三是为每个节点的输入输出做裁剪——知识库检索出来的长文档,传给生成节点前截断到必要长度,避免把整个文档都塞给模型。我们实测下来,这三个手段能让Token消耗下降将近40%,对长对话场景尤其明显。
5.2 超时与兜底:不要让用户等一个注定失败的请求
Agent链路里每个节点都可能超时或失败。LangGraph提供了每个节点的超时配置(timeout_ms)和重试配置(retry_policy)。我们当时的方案是:意图识别节点设置5秒超时、2次重试;订单API节点设置3秒超时、1次重试;LLM调用统一设置30秒超时。超时后要让整个链路优雅降级,比如“当前客服繁忙,已为您转人工处理”,但不要干等一个已经失败的请求。
另外大部分框架对同步调用的整个图超时控制也做得不错,但我们最后还是自己包了一层,用asyncio.wait_for控制单次请求整体时长,超过10秒就返回兜底话术。这条经验对客服这种实时性要求高的场景非常关键——你宁可让用户转人工,也不要让他干等20秒得到一条生硬回复。
5.3 可观测性:生产环境调试Agent的救星
Multi-Agent在生产环境里必须做良好的可观测性。前面提到我把每个节点的中间结果都塞进messages字段,就是为了方便复盘。除此之外还要把所有节点调用的LLM API日志和工具调用日志落盘,配合LangSmith这类追踪工具可以查看每个节点的完整执行轨迹。
如果不上追踪工具,也有一个土办法:把thread_id、current_intent、slots、retry_count、transfer_human这些状态字段全部打在应用的请求日志里。我们还会额外记录每个节点的执行时间,一旦发现某个节点的耗时异常飙高,就去检查是不是Prompt过长或者工具调用卡住了。这套机制上线后帮我们定位了不少问题。比如有一次用户在深夜排队,所有订单Agent调用都在等一个下游接口超时,单看应用日志根本看不出来,但一看节点级别的追踪数据,直接发现是第三方订单API在夜间维护。
5.4 成本风控:限制危险节点
客服系统里的订单Agent可以执行“取消订单”“申请退款”这类高风险操作。这类节点必须做风控设计,不能只依赖模型的工具调用逻辑。我们的做法是在节点内嵌一道道校验代码:首先校验槽位里有没有订单号,其次校验用户身份与订单归属是否一致,再调用一个独立的确认API,最后才执行改写动作。每一步失败都直接跳到人工兜底节点。
这里有个非常重要的实操心得:在LangGraph里,节点函数内部可以调用任意Python代码和任意API,图编排管的是流程,节点内部的硬校验必须自己用代码实现。LLM无论如何都不能直接拿用户输入去拼API参数,这是生产环境的铁律。
6. 常见问题与排查技巧实录
这部分是我踩坑最多的地方,整理了高频出现的问题和对应的排查思路,直接给出一份速查表。
| 现象 | 根因 | 排查与解决 |
|---|---|---|
| 多轮对话后,用户说的新意图被旧语境带偏 | 意图识别Agent的Prompt里带了过多历史消息 | 把意图识别Agent接收的消息限制为“最近1轮用户输入+必要槽位记忆”,不要拿全量历史丢给它 |
| 并行节点返回的数据互相覆盖 | 状态字段没有声明Reducer | 检查StateSchema,给需要累加的字段加Annotated[list, operator.add] |
| 图一直执行不结束,Token烧得飞快 | 条件边路由产生了循环 | 给重试或循环节点增加retry_count计数,超过阈值强制跳转END或兜底 |
| 用户消息很短,但识别结果却乱跳 | 模型temperature过高 | 意图识别Agent把temperature调到0,结果类任务不需要创造性 |
| 人工兜底后,再次进入自动流程会带着旧状态 | 人工介入没有正确重置状态 | 在human_fallback节点return时把slots、order_result等字段清空,只保留对话历史 |
| 某个Agent一次也没被调用过 | 条件边返回的key对不上图节点名 | 检查add_conditional_edges里字典的key是否与add_node注册的名字完全一致,少一个就会导致KeyError或直接跳过 |
| 生成回复时重复引用中间过程 | messages里累积了过多中间记录,话术生成Agent把它们当成了对话历史 | 话术生成节点的Prompt要明确区分“内部状态日志”与“需要传达给用户的内容”,中间过程只用于调试,不直接拼接 |
几条独家避坑经验单独再说一下。
第一个是关于条件边的Key匹配。LangGraph的add_conditional_edges第三个参数是一个映射字典,路由函数返回的字符串必须精确匹配字典里的某个key,差一个大小写都会导致运行时找不到目标节点。这种错误不太好排查,因为通常只在线上流量跑到某个分支时暴露。建议的防御方法是写单元测试,把不同的意图枚举一遍,断言路由结果符合预期。
第二个是关于状态字段的生命周期。不要在messages里塞太多调试信息,因为checkpoint保存的是整个状态,调试信息多了,一方面占存储空间,另一方面会在下一轮节点里被读取到,污染本轮判断。后来我们给所有调试记录单独建了一个trace_log字段,和真正的对话历史物理隔离,Prompt里明确只读取messages,不读取trace_log,双保险。
第三个是千万别把图复杂化。早期我们试图把每个小工具都拆成一个Agent节点,结果图变得极其庞杂,调试时眼睛都快看花了。后来及时收敛,把“工具调用”和“Agent节点”切分清楚:Agent节点是业务职责单元,工具调用是Agent内部的原子操作。基于这个思路,图始终保持在一个页面能看全的复杂度,排查问题的速度也跟着上来了。
第四个是关于模型选型。Multi-Agent里不同节点的模型确实应该不同,但不要贪多。我们在意图识别和路由这类确定性较强的节点用轻量模型,在话术生成上用强模型,中间如果出现第三类特殊技能(比如多语言翻译),再单独配模型。模型多了维护成本成倍增加,没有足够收益不要轻易加。
最后分享一个让我印象深刻的案例。系统上线后有一段时间投诉量突然上升,排查发现是知识库Agent返回的文档片段里有几条过时的退货政策,话术生成Agent直接按旧政策回了用户。这个问题的根子不在Agent架构,而在知识库更新机制。后来我们在知识库Agent里加了一条硬规则:答案片段必须带有效期校验,命中过时文档时强制转人工。这个经验说明Multi-Agent架构解决的是流程编排问题,内容质量最终还得靠上游数据链路来保障。
我在实际动手做这个项目的过程中体会最深的一件事是:Multi-Agent协同架构的本质不是“更多Agent一起干活”,而是“在正确的位置引入LLM的决策能力,同时用图结构把流程钉死”。LangGraph提供的是一个把复杂业务拆成状态机的工具箱,真正的难点全在业务边界、Agent职责、失败兜底和可观测性上。如果你也想把智能客服从单Agent升级到Multi-Agent,建议从最小的两节点图(意图识别→话术生成)开始跑通链路,再逐步加入订单、知识库等Agent,每加一个节点都要问自己三个问题:这个节点解决什么独立问题?它和其他节点之间的状态边界是什么?它失败之后如何降级?三个问题都有答案了,再加节点,就不容易翻车了。
你可以先拿着上面的骨架代码去跑一个最小Demo,不用接真实订单API,先用Mock数据把流程图走通。等整个状态流转都符合预期了,再逐步替换成真实的工具调用和业务策略。这条路走下来,你对Multi-Agent的理解会完全不一样。