资讯动态

LangGraph 1.0实战:构建带审批与记忆的智能邮件处理系统

发布时间:2026/10/20 19:40:31 来源:尧图企业网站定制
周五下午三点我的邮箱里躺着四十七封未读邮件。其中三封是客户催报价两封是合作方发来的合同修订意见一封是差点被当成垃圾邮件的面试通知剩下的全是各种订阅和通知。这种场面你应该不陌生——真正的难题从来不是收邮件而是把邮件里的事按轻重缓急处理掉。所以当我第一次看到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(checkpointercheckpointer)然后在每次调用时传入一个固定的thread_idconfig {configurable: {thread_id: email-thread-customer-123}} result app.invoke({...输入参数...}, configconfig)同一个thread_id下系统会默认把历史状态拼进当前上下文。这样客户A从初次询价到追问问参数到改方案B整条链路的对话历史、分类结果、上一版草稿、审批状态全都不会丢。我会在对大模型的调用中把历史状态作为系统提示的一部分拼进去让模型明确知道这是第几轮交流、之前聊到哪儿了。2.3 人在回路让机器决定何时交给人类而不是人类决定何时介入人在回路也就是interrupt()是用来做流程暂停等待人工输入的。在LangGraph 1.0里你只要在某个节点内调用interrupt()流程就会在这里停下来返回给前端的消息里会带着当前的挂起状态。等人类在审批界面点完确认或退回再用同样的thread_id调用invoke或resume图会从暂停的地方继续执行。第一版我犯过一个错误老想着先让模型判断需不需要人工如果需要就触发人审不需要就直接走自动发送。这个思路本身没错但实现方式很别扭。我一开始试图在普通节点里返回一个标记表示要不要人审然后在下游加条件边判断。问题是普通节点背后的LLM调用一旦跑完生成的草稿已经留在状态里了如果没有停下的动作流程会一路冲到发送节点。更好更稳的做法是永远给系统一个人审站点式的节点凡是可能涉及自动发送的路径都统一经过这个节点。这个节点内部用interrupt()暂停前端审批通过才继续往下走审批退回则走向重新生成的节点或者直接结束。这样判断逻辑就非常清晰机器负责干活儿干完活儿老老实实停下来等人类点头。2.4 三个机制是怎么在一条邮件流里咬合的我用一个实际场景串一遍。某电商团队接入这套系统后一封来自老客户的售后投诉邮件进来。第一步classify_mail节点跑完LLM状态里的category变成complaintpriority变成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:passlocalhost/langgraph) app builder.compile(checkpointerpg_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: customerexample.com, subject: Re: project quote, body: 你们上次给的报价可以再降一点吗}, configconfig)这是一封普通询价邮件不涉及合同条款也没触发高优先级所以一路走下去生成草稿并通过审批最终返回发送完成。如果这是一封风险审批邮件结果里就会包含一套待审批挂起信息流程不会走到发送节点。4. 真实调试中我踩过的几个坑这套系统第一版上线时远没这么顺真正跑起来之后我每天都在Debug五天。我把最有代表性的几个坑和解决思路放在这里希望你别再趟一遍。4.1 记忆失效thread_id命名不规范导致会话串场最让我抓狂的问题是记忆时灵时不灵。后来核对的结论是我把thread_id按客户邮箱直接拼接比如email-thread-customerexample.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()又被触发审批界面上就一直悬挂着待办像见鬼了一样。正确做法是用resumeapp.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从看过文档推到跑通业务那后面那些新的想法自然就被推着往前走了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑