1. 用图组织复杂流程
前面七篇文章分别介绍了情绪状态机、混合记忆检索、Prompt 分层架构和安全过滤等模块。单独实现这些模块并不困难,真正把它们接入同一条请求链路时,执行顺序和条件分支才会成为问题。
记忆检索必须先于 Prompt 组装完成,否则记忆层没有内容可以填充。情绪则出现在两个不同阶段:回复生成前要读取当前情绪,用来控制回复风格;回复生成后还要根据本轮对话更新状态。安全过滤同样需要执行两次,用户输入和 AI 输出都要检查,但两次检查处于链路的不同位置。与此同时,记忆写入不能阻塞用户回复,而安全过滤一旦拦截输出,还要中断正常流程并返回兜底内容。
如果继续使用层层嵌套的 if-else 串联这些逻辑,条件判断很快就会分散到多个位置。以后每增加一个模块,都可能同时修改正常流程、异常分支和收尾逻辑,代码会越来越难以维护。
LangGraph 的价值在于把节点、依赖关系和条件分支显式建模为一张有向图。数据从哪里进入,经过哪些处理,在哪些位置分叉或汇合,都能直接从图的拓扑结构中看出来。执行流程因此不再隐藏在函数调用和条件语句中,调试与扩展也会更加清晰。
这种建模方式并不是 LangGraph 独有。Airflow 使用 DAG 编排数据管线,Kubernetes 根据依赖关系调度 Pod,React 则用组件树组织渲染结构。当一个系统同时存在复杂依赖与条件分支时,图通常比线性代码更适合表达它的整体关系。
2. 完整管线
我们先从完整流程入手。用户发送一条消息后,系统会依次完成输入检查、查询理解、上下文读取、Prompt 组装、模型生成和输出检查,并在返回回复后启动后台任务。
整条管线包含三种执行路径。
主路径(Happy Path)从输入安全检查开始,经过查询理解,再并行执行记忆检索与情绪读取。两个分支汇合后,系统组装 Prompt、调用 LLM 生成回复、检查输出安全,最后把结果返回给用户。95% 以上的请求都会沿着这条路径处理。
拦截路径(Blocked Path)处理输入或输出安全检查失败的情况。一旦触发拦截,系统就会跳过不再需要的后续节点,直接返回兜底回复。输入侧与输出侧的拦截入口虽然不同,最终都会汇聚到同一个结束节点。
异步路径(Background Path)在主路径返回之后执行,包括情绪更新、记忆写入和亲密度计算。用户看不到这段过程,但后续对话的记忆与情绪状态都依赖它。
把这三条路径拆开,是为了区分用户可感知的延迟与系统必须完成的后台工作。用户只需要等待约 1-3 秒的主路径,耗时 500ms-2s 的异步处理则留在后台完成。
3. State 数据总线
图中的节点需要交换数据,LangGraph 为此采用了共享状态模式。所有节点读取和更新同一个 State 对象,而不是在节点之间逐层传递参数。
共享 State 首先降低了节点之间的耦合。每个节点只需要声明自己读取和写入哪些字段,不必关心这些数据由谁产生。例如,记忆检索节点把结果写入 memories,Prompt 组装节点再读取 memories。两个节点没有直接引用,即使在它们之间插入新的处理步骤,原有代码也不需要跟着修改。
这种方式也让节点更容易替换。以后如果把向量检索改成全文检索,只要新实现仍然向 memories 写入相同结构的数据,图中的其他节点就可以继续工作。传统的面向接口编程,在这里表现为面向 State 字段编程。
调试时,共享 State 同样有用。任意节点执行结束后,都可以直接查看完整的中间状态,不必沿着函数调用链逐层寻找参数,也不用猜测某个字段在哪个步骤被修改。
State 中的字段可以分成三类:
| 字段类型 | 示例 | 特征 |
|---|---|---|
| 输入字段 | messages(对话历史) | 只在入口写入,后续节点只读 |
| 中间字段 | queryPlan、memories、npcMood | 由某个节点写入,被下游节点读取 |
| 输出字段 | response、outputSafe | 由末端节点写入,供调用方读取 |
这三类字段不是 LangGraph 在语法层面强制执行的约束,而是节点之间共同遵守的架构契约。末端节点即使修改了 messages 也不会立即报错,却会打乱原本清楚的数据流。因此,实际项目通常会在文档或代码注释中注明每个字段的生产者与消费者。
4. 节点职责
管线包含 7 个核心节点,每个节点都对应前面文章中已经实现过的模块。这里不再重复模块内部的代码,而是从输入输出、延迟和失败影响三个方面观察它们在图中的职责。
| 节点 | 读取字段 | 写入字段 | 延迟 | 失败影响 |
|---|---|---|---|---|
| 输入安全检查 | messages | inputSafe | 1-50ms | 拦截,走兜底 |
| 查询理解 | messages | queryPlan | 100-300ms | 降级为全通道检索 |
| 记忆检索 | queryPlan, messages | memories | 50-150ms | 降级为空记忆 |
| 情绪读取 | — | npcMood, moodIntensity, intimacyLevel | 5-15ms | 降级为默认情绪 |
| Prompt 组装 | memories, npcMood, moodIntensity, intimacyLevel | systemPrompt | <1ms | 不可能失败 |
| LLM 生成 | systemPrompt, messages, recentContext | response | 500-2000ms | 返回通用兜底 |
| 输出安全检查 | response | outputSafe | 1-10ms | 拦截,走兜底 |
从延迟分布来看,LLM 生成占据了总耗时的 70-80%,是整条链路最明显的瓶颈。把记忆检索从 100ms 优化到 50ms 固然有收益,但对整体体验的影响有限。更值得投入的方向是流式输出,让用户尽早看到逐字生成的内容,以及根据速度和质量要求选择合适的模型。
查询理解则是链路中最容易影响后续流程的节点。它依赖 LLM 分析用户意图,并输出结构化查询计划。如果模型返回的 JSON 无法解析,记忆检索就拿不到可执行计划。因此,这里必须准备明确的降级方案:解析失败时默认激活所有检索通道。宁可多执行几次检索,也不能直接丢失记忆。
情绪读取只需要 5-15ms,因为它读取的是 KV 中的情绪快照,而不是访问关系型数据库。这正是把高频状态放入 KV 带来的架构收益。
5. Fork-Join 并行
管线里最重要的性能设计,是让记忆检索和情绪读取并行执行。
如果采用串行方式,查询理解完成后先执行 100ms 的记忆检索,再执行 10ms 的情绪读取,总延迟为 200 + 100 + 10 = 310ms。改成并行后,记忆检索与情绪读取同时开始,总延迟变为 200 + max(100, 10) = 300ms。
这次改动只节省了 10ms,但它更重要的价值在于扩展能力。假设以后新增一个耗时 50ms 的用户画像分析节点,以及一个耗时 80ms 的场景识别节点。在线性流程中,每加入一个模块都要增加对应的延迟;在并行结构中,只要新节点不依赖其他分支的输出,就可以直接加入同一组分支,总耗时仍然取决于最慢的节点。
并行结构总会同时出现分叉点和汇聚点。查询理解节点是 Fork,它的结果会同时流向记忆检索与情绪读取;Prompt 组装节点是 Join,它要等两个分支都执行完成,才能继续生成完整的系统提示词。
当一个节点存在多条入边时,LangGraph 会根据图的拓扑等待相应的上游节点完成,再触发这个节点。并行关系与等待关系都写在图中,不需要额外维护一段 Promise.all 调用。以后增减分支时,图的结构也比手写并发逻辑更容易调整。
并行并不意味着可以忽略失败。某个分支出错后,汇聚点必须知道是等待重试,还是放弃该分支继续执行。在当前设计中,记忆检索失败会把 memories 降级为空数组,Prompt 组装仍然继续,只是这次回复不再注入长期记忆。这属于优雅降级,而不是让整条请求链路中断。
6. 条件路由
条件路由负责决定一个节点执行结束后,下一步应该进入哪条边。它是图编排区别于线性管线的重要能力。
第一处路由位于输入安全检查之后。如果用户输入不安全,系统会跳过查询理解、记忆检索和 LLM 生成,直接进入拦截路径。这个短路设计不仅可以节省一次无意义的模型调用,也能避免攻击性 Prompt 进入后续上下文。
第二处路由位于输出安全检查之后。如果模型回复泄露了系统提示词,或者包含不允许返回的内容,系统会丢弃原始回复,并改用预设的兜底内容。
两个路由点都能进入兜底节点,但它们承担的职责并不相同。输入侧拦截发生在 LLM 调用之前,重点是避免不必要的成本与风险;输出侧拦截发生在模型已经生成内容之后,负责阻止有问题的结果离开系统。第六篇 Prompt 工程从提示词层面讨论过双层防护,这里则把它落实到管线结构中。
路由函数还需要遵守一个约束:它必须是纯函数。路由只能读取 State 并返回决策,不能修改 State,也不应该调用外部服务。如果一段路由逻辑需要再次调用 LLM,例如识别用户意图,那么它应该成为一个独立节点,而不是塞进路由函数。
7. 同步与异步
完整管线还需要决定哪些工作必须在返回回复之前完成,哪些可以留到返回之后处理。
| 时机 | 工作内容 | 原因 |
|---|---|---|
| 同步(返回前) | 安全检查、记忆检索、情绪读取、Prompt 组装、LLM 生成 | 直接影响本次回复的质量 |
| 异步(返回后) | 情绪更新、亲密度计算、记忆写入、对话摘要生成 | 影响的是"下一次"回复,不影响本次 |
判断边界时只需要回答一个问题:这项操作的输出是否会被当前回复使用?如果当前回复需要它,就放在同步链路;如果它只会影响之后的对话,就交给异步任务。
在 CloudFlare Workers 这类 Serverless 环境中,异步任务还有额外约束。Worker 返回 HTTP 响应后,执行上下文可能被销毁,尚未完成的任务也会被中断。此时需要使用 ctx.waitUntil(promise),告诉运行时响应已经发出,但当前进程仍要存活到这个 Promise 执行结束。
后台任务失败时,用户已经收到了回复,系统无法再补发一条“记忆写入失败”的通知。因此,异步链路需要独立的容错设计:
- 幂等性:记忆写入即使因超时而重试两次,最终结果也应该与执行一次相同
- 最终一致性:情绪状态可以出现短暂滞后,本轮对话产生的影响在下一轮生效是可以接受的
- 死信队列:连续失败的任务需要被记录下来,供运维人员之后补偿处理
8. 节点粒度
图设计得是否清楚,很大程度上取决于节点拆分得是否合适。当前方案包含 7 个核心节点,以及兜底回复、异步后处理 2 个辅助节点,这是在并行能力、可替换性和图的复杂度之间做出的选择。
节点过粗时,多个职责会被藏在同一个执行单元中。假如把记忆检索、情绪读取和 Prompt 组装合成一个节点,记忆与情绪就失去了并行执行的机会,也无法独立替换其中一个模块。这个大节点一旦失败,调试时也很难立即分辨究竟是检索超时,还是 Prompt 组装出现问题。
节点过细同样会增加理解成本。假如把输入安全检查继续拆成正则匹配、模型分类和结果合并三个节点,整张图可能从 9 个节点膨胀到 15 个以上,而且每条边都需要一次状态序列化。更重要的是,原本明确的“输入安全检查”会被拆散,读图的人必须先理解三个内部步骤,才能知道这一组节点在处理什么业务。
判断是否需要拆分,可以参考下面三条原则:
| 原则 | 说明 | 示例 |
|---|---|---|
| 独立可替换 | 节点可以独立替换实现,不影响其他节点 | 记忆检索从向量检索换成混合检索,其他节点无感知 |
| 并行机会 | 如果两个操作可以并行且延迟显著,就拆成两个节点 | 记忆检索(100ms)和情绪读取(10ms)值得并行 |
| 调试边界 | 每个节点是一个可观测单元,输入输出清晰 | 出问题时能精确定位到"查询理解返回了错误的 JSON" |
只要满足其中一条,通常就值得拆成独立节点;三条都不满足时,合并起来反而更清楚。正则匹配与模型分类虽然是不同操作,但它们共同服务于输入安全检查,调试时也总是放在一起观察,因此适合保留在同一个业务节点中。
9. 整合前文模块
这条管线并不是凭空增加的一层编排代码,它把前七篇文章中的独立能力组合成了一个完整系统。每个节点都能找到对应的实现来源:
| 管线节点 | 对应文章 | 核心机制 |
|---|---|---|
| 输入/输出安全检查 | 第六篇 Prompt 工程 | 双层防护:Prompt 层 + 代码层 |
| 查询理解 | 第七篇 混合记忆架构 | LLM 分析用户意图,生成查询计划 |
| 记忆检索 | 第四篇 向量化检索 + 第七篇 混合架构 | 四通道并行检索 → 结果融合 |
| 情绪读取 | 第五篇 情绪状态机 | KV 读取当前情绪快照 + 亲密度 |
| Prompt 组装 | 第六篇 Prompt 工程 | 四层架构:安全 → 人设 → 情绪 → 记忆 |
| LLM 生成 | 第三篇 LangChain/LangGraph | LCEL 链式调用 |
| 异步后处理 | 第二篇 内存调度 + 第五篇 情绪状态机 | 情绪更新、记忆写入、亲密度计算 |
管线的拓扑结构反映了这些业务模块之间的依赖关系。记忆检索依赖查询理解,因为系统需要先知道应该检索什么;Prompt 组装依赖记忆和情绪,因为这两部分数据需要写入提示词;LLM 生成又依赖已经组装完成的 Prompt。这些关系并不是实现方式碰巧造成的耦合,而是业务流程本身的要求。
图编排所做的事情,就是把原本隐藏在代码中的依赖关系转换为明确的图拓扑。新成员加入项目后,可以先通过这张图理解系统如何处理一条消息,再进入各节点阅读具体实现,而不必从大量代码中自行拼接完整流程。
10. 总结
LangGraph 把复杂的节点依赖和条件分支显式组织成有向图,让执行结构本身成为一份可以阅读和调试的架构说明。正常处理、输入或输出拦截、返回后的异步任务被拆成三条路径,各自承担不同职责。
节点之间通过 State 字段交换数据,不需要直接引用彼此。查询理解之后的 Fork-Join 结构让记忆检索与情绪读取并行执行,后续增加不相互依赖的模块时,也不必把它们全部串联起来。条件路由则负责输入与输出两处安全短路,并要求路由函数保持纯粹,只做决策,不执行外部操作。
同步与异步的边界取决于一项工作是否影响当前回复。后台任务虽然不会增加用户等待时间,但必须具备幂等性、最终一致性和失败补偿能力。节点粒度则以独立可替换、并行机会和调试边界作为判断依据:满足任意一条就可以拆分,三条都不满足时应优先合并。