☰
LangGraph实战:构建有状态、可调工具的智能客服工作流
2026/10/20 11:19:51 网站建设 项目流程

看着标题里“第2篇”这三个字,就知道这个系列不是来科普概念的,而是直接上真东西。上一篇聊了LangGraph的基础状态机玩法,这篇我用一个完整的智能客服系统案例,把状态图设计的思路、节点拆分、条件路由、工具调用以及线上排坑的过程都串一遍。如果你正准备把一个“只会照着知识库念答案”的客服机器人,升级成有状态、能调工具、会转人工的Agent工作流,那这篇正好是你需要的。我尽量不写废话,直接给可复现的方案。

凡是做过真实业务系统的都知道,客服场景听着简单,真做起来全是细节:用户一句话里可能带三个意图,订单状态要查库,退换货要走规则引擎,聊到一半用户换个话题,兜底逻辑不好好设计就成了复读机。用LangGraph这种显式的状态图来编排,正好能把复杂度摊开摆到台面上。

1. 从需求到状态图:智能客服案例的设计思路

1.1 这个案例到底想解决什么问题

很多人一提到客服机器人,第一反应就是“接个大模型,扔一堆文档,完事”。真实业务根本不是这样。我这次要模拟的场景是某电商平台的售后客服:用户会问订单到哪了,会查物流,会要求退换货,会咨询退款进度,也会问一些纯知识类的问题比如“你们的运费政策是什么”。这些需求混合在一起,单靠一段系统提示词是控制不住场面的。

最典型的翻车场景是:用户问“我前天买的那个充电宝怎么还没发货”,模型直接把“联系人工客服”作为答案甩出来,或者把知识库里跟退换货相关的文档一股脑列出来。问题的根源不是模型不够聪明,而是缺少一个有状态、带分支的流程控制层。LangGraph解决的就是这层控制逻辑:对话进行到哪一步、接下来该走哪个节点、需不需要调用外部工具、什么时候该转人工,全部由状态图说了算。

1.2 为什么状态图比链式调用更适合客服业务

上一篇文章里我提过一个观点:大模型应用的复杂度一旦超过“单轮输入输出”,就必须把控制流显式建模出来。客服业务恰恰是控制流最复杂的场景之一。用纯LangChain的LCEL链路也能串起“识别意图、检索知识、生成回答”三步,但一旦出现分支、循环、条件跳转,LCEL的线性结构就不够用了。

客服对话天然具有以下特点:

  • 多轮状态依赖,上一轮用户说了“我要退货”,这一轮说“我地址填错了”,机器人得知道用户还在退货流程里。
  • 存在多分支跳转,订单查询、售后办理、人工转接各走各的子流程。
  • 需要工具调用,查库存要调API,退款要调支付平台,光靠模型内部的记忆是完不成任务的。
  • 必须能兜底,置信度低时得澄清,澄清失败要转人工,不能死循环。

这些特点跟状态图的建模方式完全吻合。在LangGraph里,每个节点是明确的处理单元,节点之间有向边描述流转关系,条件边决定下一步走向。这种设计让整个客服流程变得可控、可观测、可测试——你可以在图上直接看到用户当前卡在哪个环路线圈里。

1.3 场景边界与功能清单

做工程最忌讳一上来就铺一个大而全的系统。我把这次案例的功能边界圈定为五条主流程:

流程触发条件核心动作兜底路径
订单状态查询用户表达了查订单/物流意图调用订单查询工具,返回实时状态无法定位订单时澄清订单号
退换货办理用户表达了售后/退款意图走规则引擎校验是否符合策略条件不满足时说明原因
知识库问答用户问政策、规则、常识检索知识库并生成回答检索不到时引导换问法
人工转接用户明确要求或澄清失败挂起对话,进入人工队列记录上下文,保留转接记录
闲聊过滤用户聊与业务无关的内容简短回应并拉回正题多次无效则触达人工

这样圈定之后,状态图的长相基本就出来了:一个入口节点负责接收消息和识别意图,接着按意图分发到不同子流程,每个子流程内部再挂必要的工具节点和条件分支,最后汇总到生成回复的出口。

2. 核心实现细节与关键节点拆解

2.1 状态Schema与数据流设计

LangGraph的核心运行机制是状态在节点之间流转。这个状态是一个TypedDict,每个字段代表客服系统里的一块数据。我的建议是字段不要贪多,够用即可,但别把对话历史直接塞进一个裸列表里。

from typing import TypedDict, Annotated, List from langgraph.graph.message import add_messages class CSEmployeeState(TypedDict): # 用户与机器人的全部对话消息,自动追加 messages: Annotated[list, add_messages] # 会话标识,多轮对话隔离用 session_id: str # 用户信息快照,避免每次重复查询 user_info: dict # 当前意图标签与置信度 intent: str confidence: float # 关键业务参数,比如订单号、退货单号 order_id: str # 工具查询结果缓存 tool_result: dict # 澄清轮次计数,防止死循环 clarify_round: int

这里有两个值得展开的细节。

第一个是add_messages这个Reducer。LangGraph的状态传递不是简单的字典覆盖,它可以给每个字段挂一个Reducer函数,多个节点向同一字段写入时,Reducer决定怎么合并。对messages字段挂add_messages,每次新节点返回的消息都会自动追加到历史列表尾部,不需要手动拼接。如果你不加这个Reducer,新消息会把旧消息整个覆盖掉,多轮对话直接断片。

第二个是clarify_round这个计数字段。它在条件路由里至关重要:每当意图识别置信度过低,系统进入澄清节点,这个字段就加一;一旦超过预设阈值比如3次,自动转人工。没有这个字段,流程图会陷入凝滞的死循环,用户被机器人反复追问,体验极其糟糕。

2.2 意图识别节点:不是所有问题都值得走检索

很多初学者喜欢把意图识别做成一个模型调用的节点,用大模型返回JSON格式的意图标签。这么做不是不行,但要控制好延迟和成本。我在实际项目里通常分两层:第一层用轻量规则或小模型做粗分类,命中不了的再走大模型精分。本案里为了演示清晰,直接在一个节点里完成。

def intent_recognition_node(state: CSEmployeeState): last_message = state["messages"][-1].content prompt = f"""你是客服意图分类器,判断用户最新一条消息属于哪类: 类别:order_query, after_sale, knowledge, human_handoff, chitchat 用户消息:{last_message} 只输出JSON,格式:{{"intent":"..." , "confidence":0.0-1.0, "params":{{}}}}""" resp = chat_model.invoke(prompt) parsed = extract_json(resp.content) return { "intent": parsed.get("intent", "knowledge"), "confidence": parsed.get("confidence", 0.5), "order_id": parsed.get("params", {}).get("order_id", ""), }

这个节点不负责生成最终回答,只负责给状态打标签。这样做的好处是后续节点不需要重新理解一遍用户意图,各司其职,整个图的可维护性会好很多。params字段里可以顺手抽出订单号、商品名这些关键业务参数,免得后面再让大模型猜一遍。这一步在电商客服场景里尤其值钱,订单号识别准了,后面查询节点直接拿参数调API,响应速度会快一大截。

2.3 答案生成节点:把上下文装进一个包裹

答案生成节点是客服回答的出口,但它不只是简单地把检索结果拼起来丢给模型。我的经验是,生成节点收到的上下文必须经过结构化整理:检索到的知识片段要按相关性排序,工具返回的订单状态要转成一段简明摘要,再加上用户历史消息浓缩出的会话背景。把这些装进一个结构化的上下文字典,再交给模型生成最终回复。

def generate_answer_node(state: CSEmployeeState): history_text = format_messages(state["messages"][-6:]) context = { "intent": state["intent"], "knowledge": state.get("tool_result", {}).get("retrieved_docs", []), "order_status": state.get("tool_result", {}).get("order_data", None), "user_info": state["user_info"], } sys_prompt = ( "你是某电商平台客服。回答忠实于提供的上下文,不要编造订单信息。" "如果上下文缺少必要信息,明确向用户索取。注意用语友好但简洁。" ) resp = chat_model.invoke([ {"role": "system", "content": sys_prompt}, {"role": "user", "content": f"上下文:{context}\n当前问题:{history_text}"} ]) return {"messages": [{"role": "assistant", "content": resp.content}]}

这里有两个设计细节要拎出来说。

第一,上下文里的order_status是可选项。如果前面的工具节点查不到订单,这个字段就是空的。生成节点必须能处理“缺字段”的情况,而不是抛出KeyError,更不能在回答里编造一个不存在的订单状态。我在提示词里明确写了一句“如果信息缺失,向用户索取”,这是对生成幻觉的有效抑制。

第二,历史消息只取最近6条。整个对话历史全部塞给模型不是不可以,但成本会随着对话轮数线性上涨,最终触发上下文超限。做一个简单的滑动窗口,既保住短期记忆,又控制住Token消耗。如果你想更精细,可以再加一个历史摘要节点,把早期对话定期压缩成一段摘要,跟最近消息拼在一起。这个扩展方案后面我会再提一句。

2.4 工具调用节点的规范与兜底

客服系统里工具调用是刚需。订单查询、物流跟踪、退款进度,这些数据大模型根本生成不了,必须走外部API。LangGraph里工具节点可以手动写,也可以用LangChain的ToolNode封装。我建议自己写一层薄薄的封装,因为外部接口的返回格式千奇百怪,统一清洗之后再放进状态,后续节点用起来才顺。

def order_query_tool(state: CSEmployeeState): order_id = state.get("order_id", "") if not order_id: return {"tool_result": {"error": "missing_order_id"}} # 实际项目中替换为真实的订单服务调用 order_data = fake_order_api(order_id) return {"tool_result": {"order_data": order_data}}

这个节点的返回是整个状态的受控部分,所以我不建议直接透传外部API的原始报文。应当在工具节点里做一次数据清洗,把订单状态、物流节点、预计送达时间这些字段抽出来,结构化地放进tool_result。后面生成答案的节点拿到的就是一份干爽的数据,而不是一大堆没用的JSON噪声。

工具节点之后通常要接一个判断节点:工具调用成功了往下走生成答案,失败了进入追问或转人工分支。这个判断在LangGraph里可以做成条件边,也可以做成普通节点返回一个路由信号。我习惯用普通节点加条件边的组合,逻辑清晰且便于测试。

3. 完整代码实战:从零搭建客服工作流

3.1 环境准备与依赖选型

开始写代码之前,先把依赖装好。这组依赖是我在多个项目里沉淀下来的组合,LangGraph负责编排,LangChain负责消息标准化和模型接口适配。

pip install langgraph langchain langchain-openai

模型接入层我用的是兼容OpenAI协议的大模型服务,国内很多厂商的API都兼容这个协议。如果你用的是国外的模型服务,直接把base_url和api_key换成你自己的配置即可。需要特别提醒的是,案例代码里的密钥不要硬编码进脚本,用环境变量方式加载,免得哪天把明文密钥提交到代码仓库里翻车。

from langchain_openai import ChatOpenAI import os chat_model = ChatOpenAI( base_url=os.getenv("LLM_BASE_URL"), api_key=os.getenv("LLM_API_KEY"), model=os.getenv("LLM_MODEL_NAME", "default-chat-model") )

3.2 图的构建与核心节点注册

核心思路是把客服流程拆成五个节点:意图识别、订单查询工具、知识检索、答案生成、澄清追问。这几个节点按条件边连接起来。先用代码把图对象建出来:

from langgraph.graph import StateGraph, START, END from langgraph.graph.graph import CompiledGraph def build_graph() -> CompiledGraph: workflow = StateGraph(CSEmployeeState) workflow.add_node("intent_recognition", intent_recognition_node) workflow.add_node("order_query", order_query_tool) workflow.add_node("knowledge_search", knowledge_search_node) workflow.add_node("answer_generation", generate_answer_node) workflow.add_node("clarification", clarify_node) workflow.add_node("human_handoff", human_handoff_node) workflow.add_edge(START, "intent_recognition") workflow.add_conditional_edges( "intent_recognition", route_by_intent, { "order_flow": "order_query", "knowledge_flow": "knowledge_search", "clarify_flow": "clarification", "handoff_flow": "human_handoff", } ) workflow.add_edge("order_query", "answer_generation") workflow.add_edge("knowledge_search", "answer_generation") workflow.add_conditional_edges( "clarification", route_clarify_to, {"retry": "intent_recognition", "handoff": "human_handoff"} ) workflow.add_edge("answer_generation", END) workflow.add_edge("human_handoff", END) return workflow.compile()

这里有个新手特别容易犯的错:add_conditional_edges的第三个参数是个字典,字典里每个key代表路由函数可能返回的字符串,value必须是图里已经注册过的节点名。如果你返回了"order_query"但图里注册的节点叫"order_tool",编译不会报错,但运行时会抛异常。图可视化调试的时候这种问题尤其隐蔽。

3.3 条件路由函数:图的神经系统

路由函数是状态图的神经系统,它的输入是整个状态字典,输出是一个节点名。我在项目里习惯把路由函数写成纯函数,不做任何外部IO,这样单元测试非常好写。

def route_by_intent(state: CSEmployeeState) -> str: intent = state["intent"] confidence = state["confidence"] if confidence < 0.6: return "clarify_flow" if intent == "order_query": return "order_flow" if intent in ("after_sale", "knowledge"): return "knowledge_flow" if intent == "human_handoff": return "handoff_flow" return "clarify_flow" def route_clarify_to(state: CSEmployeeState) -> str: return "retry" if state["clarify_round"] < 3 else "handoff"

路由逻辑的优先级值得细品。我把置信度判断放在最前面,意图标签再准、置信度不够也得先去澄清,不能带病往下走。业务意图的分流里,after_sale被合并进knowledge_flow,原因是在这个案例里售后处理的知识检索和普通知识问答走的是同一套检索通道,只不过查询条件里多一个售后关键词。这样合并可以少维护一条链路,效果不打折。如果你后续要给售后单独接一套规则引擎,再拆一条边出来即可。

3.4 流式输出与会话管理

客服场景里用户等待大模型逐字输出的体验非常煎熬。实测下来,超过3秒的无输出状态就能劝退一批用户。LangGraph天然支持流式输出,关键是要在调用时指定stream_mode="messages"。

config = {"configurable": {"thread_id": session_id}} events = graph.stream( {"messages": [{"role": "user", "content": user_text}]}, config=config, stream_mode="messages" ) for chunk, metadata in events: if chunk.content: yield chunk.content

这里有个值得注意的点:Lambda或者WebSocket网关环境下,用生成器逐个推送Token,可以让用户端看到打字机式的效果。同时,config里的thread_id是会话隔离的关键。LangGraph的默认状态存在内存里,如果不指定thread_id,每次调用都是独立会话,上一轮的对话历史根本带不过来;指定之后,同一个会话ID的多次调用会共享同一个状态链条,这才是真正意义上的多轮对话。

如果你希望把状态持久化到数据库,LangGraph提供了检查点的概念。通过MemorySaver可以把状态存在内存里,测试够用;生产环境建议用SqliteSaver或PostgresSaver,这样服务重启之后会话还能接着聊。这一项在生产部署里属于必做功课,后面再展开。

4. 真实环境下的问题排查与踩坑实录

4.1 状态数据被意外覆盖

这是我调试LangGraph项目时遇到得最多的一类问题。如果你的状态字段没有挂Reducer,而流程里有多个节点都想写入这个字段,LangGraph会采用“后写覆盖前写”的策略。放在单分支的流程里没毛病,但一旦进入循环或回退分支,就会出现严重的数据丢失。

我举一个真实案例:客服流程里,用户先查了订单状态,然后因为追问触发了澄清分支,澄清完又回到意图识别节点重新走一遍。此时如果order_id字段没有保留机制,第二次走意图识别时order_id可能会被重新初始化为空字符串,工具节点就拿不到订单号了。

解决方案有两条路。一条是给关键字段自定义Reducer,比如用operator.add合并多个来源,或者在Reducer函数里写“保留非空值”。另一条是在写回节点时做防御性判断:只有当新值非空时才覆盖旧值。两条路我都试过,从可维护性角度我推荐前者,毕竟Reducer是LangGraph的正式机制。

4.2 循环边界引发资源耗尽

智能客服系统最怕的就是对话陷入死循环。我见过一个线上故障:用户连续三次说“你是个傻子吧”,意图识别节点每次都被归为低置信度的闲聊,然后转入澄清节点。澄清节点问一句“抱歉我没有理解,您能换个说法吗”,用户再骂一句,又进澄清节点。如果没有轮次上限,这个循环会一直跑下去,把Token费用烧掉一大笔。

我在设计里加了一个clarify_round字段作为循环护栏,澄清节点每次自增,路由函数超过3次就强制转人工。这个护栏不光要加在澄清分支,知识检索失败后的重试分支也需要类似机制。凡是可能出现回环的边,都必须有退出的条件,这是状态图设计的基本修养。

4.3 流式输出与工具调用的冲突

工具调用本身是阻塞的,用户看不到中间状态,会误以为机器人挂掉了。尤其是订单查询这种动辄几百毫秒的API调用,如果前端没有任何提示,用户早就关掉页面了。

解决思路有两种。第一种是在前端加“正在查询”之类的过渡提示,这个最简单直接。第二种是用LangGraph的stream_mode="updates"流式返回节点更新信息,每经过一个节点都向客户端推送一次状态变更。你可以把工具调用节点、检索节点设计成发送一个可见的状态标记,前端收到这个标记就展示对应的动态。两种方案可以组合使用,效果最佳。

4.4 并发下的会话数据串线

LangGraph的内存状态在多用户并发时很容易互相串线。如果你用一个全局共享的图实例去服务所有用户,且没有正确设置thread_id,用户A的订单号就可能出现在用户B的回答里。这属于严重的线上事故级Bug。

正确的姿势是一个会话一个thread_id。LangGraph官方推荐的做法是把thread_id放进config里,同时确保每个HTTP请求都有独立的会话标识。如果你用的是无状态部署,建议把状态持久化到外部存储,比如用SQLite或Redis实现自定义检查点器。这样即使服务实例重启或者负载均衡把请求打到不同实例上,用户的对话状态也不会丢失。

5. 工程化落地的几个补充经验

5.1 节点设计要讲究粒度

我在第二个项目里犯过一个错误,把一个“生成回复”节点写成了两三百行的巨型函数,里面塞了意图判断、知识拼接、情感安抚、敏感词过滤,结果任何一个小改动都要回归测试整条链路。后来我把生成节点拆成了三层:格式化层、生成层、后处理层,每一层都是一个单独的LangGraph节点。虽然图变得稍微复杂了一点,但每一层都能单独测试,出问题时也能精准定位到具体节点。

节点粒度设计有一个参考标准:如果这个函数在非LangGraph环境下也能单独跑通并返回正确的state片段,那粒度就合适。我在实际开发中会用这种思路来约束自己。

5.2 可观测性:别等线上出事了再补

LangGraph本身提供了一定的运行时追踪能力,但你可以在关键路径上主动埋点。比如在意图识别节点后,把意图标签和置信度写进结构化日志;在工具调用节点后,把API响应耗时和结果摘要记录到监控系统。这么做的目的是,当用户投诉“回答不对”时,你能快速判断是意图识别错了,还是检索结果差了,还是生成端模型被误导了。

我个人的习惯是给每个节点都加一个可选的Debug开关,线上默认关闭,接到用户反馈后再按会话ID开启详细追踪。这个设计看起来不起眼,但在排障时能节省大把时间。服务无状态化部署之后,这个开关的价值尤其明显。

5.3 从Demo到生产还要补的功课

案例代码里所有工具节点都是假的API,真正接入生产环境时,你还需要补四件事。

第一件事是接口鉴权和超时控制。客服系统要调订单、支付等多个内部系统,每个接口的鉴权方式和超时容忍度都不一样,都要单独处理。

第二件事是对大模型输出的格式校验。如果模型输出不符合预期格式,要有一套重试机制或降级方案,而不是直接把异常抛给用户。

第三件事情是敏感信息过滤。客服对话里极易出现手机号、地址、身份证号这类隐私数据,必须在进入状态之前做好脱敏处理,防止这些信息落入日志系统。

第四件事是人工转接的上下文交接。转人工不能只丢给客服一句“用户想退货”,要把完整的对话摘要、订单快照、已尝试的方案一并带过去。这里可以用前面的历史摘要节点生成一段交接文本,直接放进转人工的话务单里。

6. 最后再分享一个调式小技巧

也许你已经把整条链路跑通了,但是当用户反馈某个回答不对时,怎么快速定位问题出在哪个环节?我强烈建议你把LangGraph的状态打印出来看。编译后的图在每次调用后都能拿到最终状态,但我更关心的是中间状态。

for step in graph.stream(input_state, config=config, stream_mode="updates"): for node_name, updated_state in step.items(): print(f"节点 {node_name} 更新:{updated_state}")

这段代码会打印每个节点更新后的状态字段。实际用起来会发现,大多数看似是模型回答不好的问题,根因都在前序节点就埋下了雷。比如用户在订单查询节点拿了错误订单号,后面生成节点再怎么调提示词都没用。这个技巧帮我走过了整个案例的大部分调试过程,值得你亲自试试。

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

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

立即咨询