☰
LangGraph 1.0实战:构建带审批与记忆的智能邮件处理系统
2026/10/20 19:40:18 网站建设 项目流程

周五下午三点,我的邮箱里躺着四十七封未读邮件。其中三封是客户催报价,两封是合作方发来的合同修订意见,一封是差点被当成垃圾邮件的面试通知,剩下的全是各种订阅和通知。这种场面你应该不陌生——真正的难题从来不是收邮件,而是把邮件里的事按轻重缓急处理掉。所以当我第一次看到LangGraph 1.0的完整图编排能力时,脑子里第一个跳出来的想法就是:这东西可以拿来做一个真正能干活儿的智能邮件处理系统。

这不是又一个"调用大模型读邮件提取摘要"的玩具Demo,而是一整套带状态流转、分支决策、上下文记忆、关键节点人工审批的完整流程。核心用到三个LangGraph 1.0的特性:条件边(conditional edges)、记忆(memory)和人在回路(human-in-the-loop)。我在这套系统的设计和落地过程中踩了不少坑,也把所有能用的代码和思路沉淀了下来,这篇就当是一份实战复盘。

1. 邮件处理这个场景,为什么非要用图编排

很多同学一听到"邮件处理"就觉得没必要上LangGraph这种重武器,直接用Prompt让大模型读完邮件干三件事——分类、提取、回复——不就完了?这个想法对单个邮件成立,但对真实邮件流水线来说,会在三个地方迅速崩掉。

1.1 邮件处理不是"一次问答",而是一条流水线

一封邮件进来,正确的处理顺序是这样的:先判断这封邮件是什么性质(客户询价、售后退款、合作邀约、垃圾广告),再判断它的紧急程度(下班前必须答复还是本周内答复都行),接着决定要不要生成草稿、生成什么风格的草稿、需不需要让审批人过一眼,最后才能进入发送。这个过程有明确的先后依赖,不是一句对话能覆盖的。

如果只用单次LLM调用,你要么把所有步骤揉进一个超大Prompt里让模型一次性"脑补"完毕,要么你在应用层写一堆if / else胶水代码来手动串流程。前者的问题是模型输出不可控,分类和生成草稿混在一起,出了错难以定位;后者的问题是流程逻辑散落在业务代码里,每一个环节的上下文传递都靠手工,改一个分支就要重启一遍逻辑链。

LangGraph 1.0把它做成了图:节点是动作,边是流转条件,状态对象在整张图的节点之间来回传递。我只需要定义好每个节点做什么,再把节点之间"什么时候走哪条路"交给条件边去判断,整条流水线就活了。

1.2 单次LLM调用带来的三个退化:无状态、无分支、无暂停

无状态是这个场景最突出的问题。邮件处理过程里,一个问题可能需要来回讨论好几轮:第一封邮件里客户说了方案A,回复之后客户又追问方案A的具体参数,再回复之后客户说算了还是用方案B。如果对话没有记忆机制,每一封邮件都是孤立的,新邮件进来时根本不知道历史上下文,只能"失忆式"地重新理解一遍,轻则重复提问,重则答非所问。

无分支的问题同样明显。真实邮件不可能永远走一条直路。垃圾邮件和正常邮件路径就完全不同;正常邮件里询价、投诉、合同、发票的处理动作也各不相同。没有图级别的路由机制,业务逻辑只能全部堆在代码里,新来的同学看一眼就得头皮发麻。

无暂停是我觉得最隐蔽也最致命的问题。很多邮件是不能自动发出去的——涉及钱、涉及合同、涉及对外承诺,草稿生成得再好,也得有人拍板。单次调用模型吭哧吭哧写完一封邮件,直接就发,没有"中间停车等人确认"这个环节,商业上风险极大。所以必须要有人的审批位,而且这个审批位该在第几步插入,必须是流程层面的设计,不能靠代码硬hack。

1.3 LangGraph 1.0 在这里补了什么

有了上面的痛点,LangGraph 1.0的价值就很清晰了:它提供了状态驱动的图执行模型,节点就是业务动作,边的定义方式既支持固定跳转也支持条件跳转,checkpoint机制让任意时刻的状态可以持久化、恢复、回溯,interrupt()原语让流程可以在指定节点停下来等待人类输入。这样一来,一条邮件流水线就变成了一个看得见、摸得着、可以随时叫停的流程图,而不是一段黑盒的LLM调用来回串。

2. 条件边、记忆、人在回路到底怎么配合

标题里强调的三个核心技巧,刚开始会让人以为它们是三个独立的功能点,实际上三者在邮件系统里是咬合着使用的。我先一个个拆开说,再讲组合逻辑。

2.1 条件边:不是if-else,而是把路由决策交给状态

条件边的本质是:一个节点执行完后,不固定地走向下一个节点,而是根据当前状态的某个字段,动态决定接下来进哪个分支。

在邮件系统里,最典型的位置是第一步——分类节点跑完后,根据category字段的值决定走向:

  • 分类结果是询价/投诉/合同,走"生成草稿"节点;
  • 分类结果是垃圾广告/订阅通知,直接归档进对应文件夹,不生成草稿;
  • 分类结果是需要老板拍板的高风险邮件,走"人工审批"节点。

写起来就是一个Python函数加上配置:

def route_by_category(state: MailState) -> str: category = state["category"] if category in ("inquiry", "complaint", "contract"): return "draft_reply" elif category in ("spam", "newsletter"): return "archive" elif category == "risk_approval": return "human_approval" return END

然后在构建图的时候,把函数注册进去:

builder.add_conditional_edges("classify_mail", route_by_category, { "draft_reply": "draft_reply", "archive": "archive", "human_approval": "human_approval", })

这个设计的好处是路由逻辑本身也可以被测试、被替换、被其他节点复用。它不是藏在业务代码深处的一堆条件判断,而是图结构里的一段独立声明。

2.2 记忆:跨对话的邮件上下文管理

LangGraph 1.0的记忆机制,靠的是checkpointer。每次执行到节点之间,整张图的状态会被写入checkpoint存储,下一次新消息进来时,可以从同一条线程(thread)恢复之前的全部状态。

用起来比我想象中简单得多。先定义好状态结构,再把一个MemorySaver实例作为checkpointer传给编译后的图:

from langgraph.checkpoint.memory import MemorySaver checkpointer = MemorySaver() app = builder.compile(checkpointer=checkpointer)

然后在每次调用时传入一个固定的thread_id:

config = {"configurable": {"thread_id": "email-thread-customer-123"}} result = app.invoke({...输入参数...}, config=config)

同一个thread_id下,系统会默认把历史状态拼进当前上下文。这样客户A从"初次询价"到"追问问参数"到"改方案B",整条链路的对话历史、分类结果、上一版草稿、审批状态,全都不会丢。我会在对大模型的调用中,把历史状态作为系统提示的一部分拼进去,让模型明确知道这是第几轮交流、之前聊到哪儿了。

2.3 人在回路:让机器决定何时交给人类,而不是人类决定何时介入

人在回路,也就是interrupt(),是用来做"流程暂停等待人工输入"的。在LangGraph 1.0里,你只要在某个节点内调用interrupt(),流程就会在这里停下来,返回给前端的消息里会带着当前的挂起状态。等人类在审批界面点完确认或退回,再用同样的thread_id调用invoke或resume,图会从暂停的地方继续执行。

第一版我犯过一个错误:老想着"先让模型判断需不需要人工,如果需要就触发人审,不需要就直接走自动发送"。这个思路本身没错,但实现方式很别扭。我一开始试图在普通节点里返回一个标记表示"要不要人审",然后在下游加条件边判断。问题是,普通节点背后的LLM调用一旦跑完,生成的草稿已经留在状态里了,如果没有停下的动作,流程会一路冲到发送节点。

更好更稳的做法,是永远给系统一个"人审站点"式的节点,凡是可能涉及自动发送的路径,都统一经过这个节点。这个节点内部用interrupt()暂停,前端审批通过才继续往下走,审批退回则走向重新生成的节点或者直接结束。这样判断逻辑就非常清晰:机器负责干活儿,干完活儿老老实实停下来等人类点头。

2.4 三个机制是怎么在一条邮件流里咬合的

我用一个实际场景串一遍。某电商团队接入这套系统后,一封来自老客户的售后投诉邮件进来。

第一步,classify_mail节点跑完LLM,状态里的category变成"complaint",priority变成"high"。条件边不是无脑走"生成草稿",而是多判断了一层:高优先级投诉的草稿生成后,必须经过人工审批节点。

第二步,流程走到draft_reply节点,生成一封语气诚恳的道歉+补偿方案草稿。草稿写完后存进状态字段draft,此刻还没有发出。

第三步,流程走到human_approval节点,interrupt()触发,系统在审批队列里挂一条待办:标题是"客诉邮件待批——补偿方案需要确认"。审批人员点开一看,觉得补偿方案写得太轻了,这会直接导致客户流失,于是退回修改,并且追加了一句审批意见:"补偿金额翻倍,语气再软一些。"

第四步,系统拿到审批意见,走revise_draft分支,根据追加意见把草稿改了一版。这一版经过审批直接通过,节点进入"发送"——整个流程到这儿才真正结束。

全程里,条件边管的是分支方向,记忆管的是让系统知道这是同一个客户在第N轮沟通,人在回路管的是"草稿可以AI写,但拍板必须人来做"。三者缺一个,这套流程都会很别扭。

3. 三步搭建效果可用的邮件处理系统

理论讲多了容易飘,我直接把这套系统的搭建步骤按照我自己最后落地的版本,拆成三步给你看。每一步都会给关键代码和"为什么要这么写"的解释。

3.1 第一步:定义状态结构和两个核心节点

LangGraph 1.0的整个执行逻辑都围绕State(状态)展开。状态结构定义得好不好,直接决定以后改起来顺不顺手。我的建议是:先别急着把所有字段都塞进去,只用当前流程真正需要的最小集合,多个流程共享的字段要提前想清楚命名空间。

from typing_extensions import TypedDict class MailState(TypedDict): # 原始邮件 mail_from: str subject: str body: str # 第一步分类结果 category: str priority: str # 草稿和审批 draft: str need_approval: bool approval_status: str approval_comment: str # 归档动作是纯标记 archive_to: str

这里有两个关键点。第一,priority和category必须分开,因为邮件系统里"紧急"和"属性"是两个正交维度,某个投诉可能是低优先级,某个询价可能是高优先级。第二,approval_comment看起来只是一条小字段,但它是连接人类审批和后续修订的重要通道,少了它人在回路就成了一句空话。

第一个节点是分类与优先级判断节点,用模型输出结构化结果。这里我强烈建议用langchain的WithStructuredOutput或者手写的JSON Schema解析,而不是让模型输出一段自由文本再自己正则提取。

def classify_mail(state: MailState) -> dict: messages = [ {"role": "system", "content": "你是邮件分类助手。" "分类只能是 inquiry/投诉/合同/风险审批/垃圾广告/订阅通知。" "优先级只能是 high/medium/low。"}, {"role": "user", "content": f"发件人: {state['mail_from']}\n" f"主题: {state['subject']}\n" f"正文: {state['body']}"} ] response = llm.invoke(messages) parsed = parse_category_and_priority(response.content) return { "category": parsed["category"], "priority": parsed["priority"], "need_approval": parsed["category"] == "风险审批" or parsed["priority"] == "high", }

第二个节点是"生成草稿"节点,后面条件边的路由会决定走到这里还是走到直接归档。这个节点要读当前状态里的邮件内容、分类结果和记忆里的历史交互,一块拼进Prompt里,保证生成的草稿不是凭空生成,而是扣着上下文来的。

def draft_reply(state: MailState) -> dict: history = get_history_from_checkpointer(state["thread_id"]) prompt = f"""基于以下邮件生成一封回复草稿。 历史沟通记录:{history} 原则:语气专业、回应具体,不要承诺无法兑现的条款。""" draft = llm.invoke(prompt + state["body"]) return {"draft": draft.content}

到这一步,整张图已经有一半骨架了,接下来补条件边和审批节点。

3.2 第二步:用条件边串起路由与分支

到了这张图真正的分岔口。节点定义完后,把它们按顺序接起来,再在某一个节点出口上接条件边。下面是我最终落地的构图代码:

from langgraph.graph import StateGraph, END builder = StateGraph(MailState) # 注册节点 builder.add_node("classify_mail", classify_mail) builder.add_node("draft_reply", draft_reply) builder.add_node("human_approval", human_approval) builder.add_node("revise_draft", revise_draft) builder.add_node("send_mail", send_mail) builder.add_node("archive_mail", archive_mail) # 入口 builder.set_entry_point("classify_mail") # 分类出口:走草稿 / 走归档 / 走人工审批 builder.add_conditional_edges( "classify_mail", route_by_category, { "draft_reply": "draft_reply", "archive_mail": "archive_mail", "human_approval": "human_approval", } ) # 草稿生成后,无论什么类别都先经过人工审批 builder.add_edge("draft_reply", "human_approval") # 人工审批出口:通过则发送,不通过则回到修订节点 builder.add_conditional_edges( "human_approval", route_after_approval, { "approved": "send_mail", "rejected": "revise_draft", } ) # 修订完重新提交审批 builder.add_edge("revise_draft", "human_approval") # 归档和发送都是终点 builder.add_edge("archive_mail", END) builder.add_edge("send_mail", END)

路由函数里有一个容易被新手忽略的地方:条件边的返回值必须是add_conditional_edges第三个参数这个字典里出现的key,否则运行时会直接报错。我在调试初期经常从函数里返回了未注册的字符串,结果LangGraph抛出一个"Invalid condition"之类的异常,找半天才发现是自己路由函数里的拼写问题和配置文件里的key对不上。

route_by_category按照上一节的写法不变。route_after_approval则是看审批状态:

def route_after_approval(state: MailState) -> str: if state["approval_status"] == "approved": return "approved" return "rejected"

这套编码方式的神奇之处在于,一旦把图编译出来,你可以打印app.get_graph(),直接看到一张PND风格的流程图,每个节点、每条边、每个条件分支都一目了然。这在给团队里非技术角色演示流程时也很有说服力,不用画PPT,直接把图拉出来即可。

3.3 第三步:接入记忆和interrupt,让系统具备"停下来等人"的能力

这一步是整套系统能不能真正上线干活的分水岭。先说记忆。

我用MemorySaver作为例子,因为在本地开发和测试时它最省事。如果是部署到生产环境,建议换成PostgresSaver或者RedisSaver这类真正的持久化存储,因为服务一重启,MemorySaver里的所有状态就没了,邮件系统可经不起这样丢上下文。生产环境我最终选了PostgresSaver,把checkpoint落到数据库里,不管服务重启还是扩容,同一个thread_id都能从中断的地方接着跑,这个收益在后面会用得越来越多。

from langgraph.checkpoint.postgres import PostgresSaver pg_saver = PostgresSaver.from_conn_string("postgresql://user:pass@localhost/langgraph") app = builder.compile(checkpointer=pg_saver)

然后是在审批节点里用interrupt()暂停流程。在LangGraph 1.0里,interrupt应该被当作一个"停车标志"而不是"特殊函数",好用得飞起:

def human_approval(state: MailState) -> dict: decision = interrupt({ "mail_summary": state["subject"], "draft": state["draft"], "reason": "草稿涉及合同条款,需要人工审批", }) return { "approval_status": decision["status"], # e.g. "approved" "approval_comment": decision.get("comment", ""), }

注意interrupt()的返回值来自外部,也就是来自人类在审批界面上的操作结果。这个返回值在流程恢复后会被注入到当前状态里,节点拿到它之后再决定下一步怎么走。整个机制配合记忆非常顺滑:审批人看到的是触发暂停时的草稿和历史对话快照,确认后流程原地复活,且复活时已经带着审批结论。

到这里,一套能跑的智能邮件处理系统就已经成型了。你可以模拟跑一下走查:

config = {"configurable": {"thread_id": "email-thread-001"}} result = app.invoke({"mail_from": "customer@example.com", "subject": "Re: project quote", "body": "你们上次给的报价可以再降一点吗?"}, config=config)

这是一封普通询价邮件,不涉及合同条款,也没触发高优先级,所以一路走下去,生成草稿并通过审批,最终返回发送完成。如果这是一封风险审批邮件,结果里就会包含一套待审批挂起信息,流程不会走到发送节点。

4. 真实调试中我踩过的几个坑

这套系统第一版上线时远没这么顺,真正跑起来之后我每天都在Debug五天。我把最有代表性的几个坑和解决思路放在这里,希望你别再趟一遍。

4.1 记忆失效:thread_id命名不规范导致会话串场

最让我抓狂的问题是记忆"时灵时不灵"。后来核对的结论是:我把thread_id按客户邮箱直接拼接,比如email-thread-customer@example.com,但某些转发邮件的发件人被系统改写成了另一个内部地址,导致同一个客户在两条线程里各跑各的,前面的沟通记录全部对不上。

解决思路是:thread_id的生成规则必须稳定且业务可归因,我最后统一用customer_id + 咨询单号作为thread_id,再通过一张映射表把邮箱和客户ID对应起来,不管客户用哪个邮箱发过来,都能正确落回同一条线程。

4.2 条件边返回了未注册的分支名

这个前面提到过,运行时直接报错还算幸运,怕的是不报错却走错分支。有一次我把某条分支的key写成"archive"但注册时写的是"archive_mail",结果运行时怎么都走不对路子。排查过程让人头大,因为报错信息并不直观。

建议强烈输出图诊断信息,每次编译后用这一句话检查:

print(app.get_graph().to_json())

打印出来的结构会把所有节点和边的映射关系列在一起,对照一眼就知道分支是接到哪里了。这种问题肉眼直接就能发现。

4.3 interrupt()后流程恢复方式错误,导致审批结果没生效

interrupt()挂起后,流程恢复有两种调用方式:一种是直接用app.invoke()传入原始输入,另一种是用app.resume()传入审批结果。开始我用的是invoke,把审批结果拼在原始输入里重跑了一遍图。结果图从头开始执行,节点跑了一遍,interrupt()又被触发,审批界面上就一直悬挂着待办,像见鬼了一样。

正确做法是用resume:

app.resume(config, { "status": "approved", "comment": "同意草稿,但补充违约金条款", })

恢复后,interrupt()会返回这个值给节点,流程直接从挂起点继续往下走。这一点是LangGraph 1.0的人机交互和普通多轮对话最大的区别,值得反复确认三五次。

4.4 把模型回复直接塞进状态,导致字段类型错乱

有一个典型的低级错误是节点返回值的字段名和状态结构里定义的不一致。比如我在状态里定义了draft: str,但某个节点一时顺手返回了{"draft": {"content": "xxxx"}},结果下游节点读state["draft"]时拿到一个字典,直接报AttributeError: 'dict' object has no attribute 'format'。

LangGraph节点返回的东西会和当前状态自动合并,而不是整体替换。所以节点返回的字符串类型必须和状态定义吻合。写每个节点函数之前,最好先把返回字典的写法写出来贴在注释里,就一行,但能帮大忙:

# return {"draft": "str内容"}

4.5 邮件的上下文膨胀和截断问题

日志攒得多了之后,记忆里的内容越来越长,LLM调用动不动就把几千字的全文一股脑喂进去,费用和响应时间双双起飞。

处理方法是压缩记忆,不是无脑截断,而是用一个summarize_context节点,把"历史沟通记录"里超过一定长度的部分先用一轮LLM生成摘要,再把摘要加上最近两轮完整消息喂给下一次调用。在邮件场景里,旧邮件的重要性远不如"客户最新表达的核心诉求"高,摘要换上下文,是性价比最高的做法。

5. 把系统再往前推一步的扩展思路

做完这套系统,我估摸着你可能会跟我一样,先松口气然后盯着它发呆:下一步还能做什么?我根据自己的实操体验,给你列几个接近"立刻能用"的扩展方向。

第一个方向是给系统加定时轮询。配合邮件服务商提供的API,每小时拉一次新邮件,扔进LangGraph的入口节点,再把处理结果写回原来的邮件线程。这个扩展不涉及核心逻辑修改,只需要多一个调度组件。

第二个方向是接入多邮箱账号。每套账号对应不同的thread_id前缀和不同的流程配置表。比如销售邮箱的邮件要严格走审批节点,客服邮箱的投诉可以自动回复一层安抚话术后再询问客户是否满意,产品反馈邮箱的邮件则自动归档到产品需求池。配置表化之后,新增邮箱基本不用改代码。

第三个方向是把附件也纳入状态。合同扫描件、报价单Excel、发票PDF,这些附件是邮件系统里占比极高的资产。用解析组件把附件内容提取成文本存进状态字段,分类节点和草稿节点就能基于附件内容做更准确的判断,而不是只看邮件正文。

第四个方向是给审批环节做一个多级审批流。当前系统里审批节点是单一站点,但实际上高风险邮件可能需要"业务负责人先过一遍,财务再确认一遍"这样两级审批。用LangGraph 1.0的图特性,也就是两个interrupt()串联在一条链路上,第一站通过后会挂起在第二站,等财务也点头才进入发送节点。这个扩展几乎没有额外的学习成本。

我在实际使用里最大的感受是:这套系统最大的价值不是"节省了回邮件时间",而是让邮件的处理过程变得可追溯、可干预、可复盘。每一个节点都有记录,每一条审批都有留痕,每一封自动发出的邮件都能解释为什么发出、基于什么判断发出。条件边让决策路径清晰可见,记忆让对话连续不失忆,人在回路让人在关键节点始终保留最终决定权,这三件套放在任何一个内容生产、客户服务或合同处理的工作流中都是通用的。如果这篇笔记能帮你把LangGraph 1.0从"看过文档"推到"跑通业务",那后面那些新的想法自然就被推着往前走了。

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

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

立即咨询