LangGraph Agent 编排

要点

  • 前面七篇文章,我们分别设计了情绪状态机、混合记忆检索、Prompt 分层架构、安全过滤等模块
  • 用户发一条消息后,系统经过以下节点处理
  • 在图编排中,节点之间如何传递数据是最基础的架构问题
  • 管线中的 7 个核心节点,每个都对应前面某篇文章的一个完整模块
  • 管线中最重要的性能设计是记忆检索和情绪读取的并行执行

内容

1. 为什么需要一张「图」

前面七篇文章,我们分别设计了情绪状态机、混合记忆检索、Prompt 分层架构、安全过滤等模块。每个模块单独看都能跑通,但真正要把它们组装成一个完整的系统时,你会发现一个问题:执行顺序和条件分支比想象中复杂得多

几个典型的依赖关系:

  • 记忆检索必须在 Prompt 组装之前完成,否则记忆层没有数据可填
  • 情绪更新既要在 LLM 回复之前做(影响回复风格),也要在回复之后做(根据本轮对话更新状态)
  • 安全过滤要同时检查用户输入和 AI 输出,但两次检查的位置完全不同
  • 记忆写入不应该阻塞用户回复,必须异步执行
  • 如果安全过滤拦截了输出,需要走一条完全不同的路径返回兜底回复

这些依赖关系如果用 if-else 串联,很快会变成一团意大利面——分支嵌套分支,每加一个新模块都要改好几处代码。

LangGraph 的核心价值就在于:把这些节点和条件分支显式地建模为一张有向图(DAG),让执行流程可视化、可调试、可扩展。图的拓扑结构本身就是架构文档——看一眼图,就知道数据从哪来、经过哪些处理、在哪里分叉、在哪里汇合。

这种思路并非 LangGraph 独有。Airflow 用 DAG 编排数据管线,Kubernetes 用 Pod 依赖图调度容器,React 用组件树管理渲染——当系统中存在复杂的依赖关系和条件分支时,图是最自然的建模方式

2. 完整管线总览

先看全貌。用户发一条消息后,系统经过以下节点处理:

Drawing canvas 整个管线分为三条路径:

主路径(Happy Path):输入安全检查 → 查询理解 → 并行执行(记忆检索 + 情绪读取)→ Prompt 组装 → LLM 生成 → 输出安全检查 → 返回用户。这是 95% 以上请求走的路径。

拦截路径(Blocked Path):输入或输出安全检查未通过 → 跳过所有后续节点 → 直接返回兜底回复。拦截路径从管线的两个位置都可以进入(输入侧和输出侧),但都汇聚到同一个终点。

异步路径(Background Path):主路径返回用户后,异步触发情绪更新 + 记忆写入 + 亲密度计算。这条路径对用户不可见,但对系统的"记忆力"至关重要。

三条路径的设计体现了一个核心架构原则:将"用户可感知的延迟"与"系统必须完成的工作"分离。用户只需要等主路径跑完(约 1-3 秒),异步路径的 500ms-2s 处理时间完全在后台消化。

3. 数据总线:State 的架构角色

在图编排中,节点之间如何传递数据是最基础的架构问题。LangGraph 采用了**共享状态(Shared State)**模式——所有节点读写同一个 State 对象,而不是节点之间直接传参。

这个设计选择有深刻的架构含义:

解耦效果。每个节点只声明"我需要读哪些字段、写哪些字段",不需要知道数据是哪个节点产生的。记忆检索节点写入 memories 字段,Prompt 组装节点读取 memories 字段——两者之间没有直接引用,中间插入一个新节点不需要改任何已有代码。

可替换性。想把向量检索换成全文检索?只要新节点也往 memories 字段写数据,图的其余部分完全不用动。这种"面向接口编程"的思想,在图编排中体现为"面向 State 字段编程"。

调试友好。任意节点执行完毕后,打印 State 就能看到完整的中间状态。不需要在函数调用链中逐层传递参数,也不需要猜测某个变量在哪个环节被修改了。

State 中的字段可以分为三类:

字段类型示例特征
输入字段messages(对话历史)只在入口写入,后续节点只读
中间字段queryPlan、memories、npcMood由某个节点写入,被下游节点读取
输出字段response、outputSafe由末端节点写入,供调用方读取

这种分类不是语法层面的约束,而是架构层面的契约。违反契约(比如末端节点修改了 messages)不会报错,但会导致数据流混乱。因此在实际项目中,通常会在文档或代码注释中明确每个字段的"生产者"和"消费者"。

4. 节点职责与依赖拓扑

管线中的 7 个核心节点,每个都对应前面某篇文章的一个完整模块。这里不重复实现细节,而是从架构视角分析每个节点的输入输出契约、延迟特征和失败影响

节点读取字段写入字段延迟失败影响
输入安全检查messagesinputSafe1-50ms拦截,走兜底
查询理解messagesqueryPlan100-300ms降级为全通道检索
记忆检索queryPlan, messagesmemories50-150ms降级为空记忆
情绪读取npcMood, moodIntensity, intimacyLevel5-15ms降级为默认情绪
Prompt 组装memories, npcMood, moodIntensity, intimacyLevelsystemPrompt<1ms不可能失败
LLM 生成systemPrompt, messages, recentContextresponse500-2000ms返回通用兜底
输出安全检查responseoutputSafe1-10ms拦截,走兜底

从这张表可以看出几个关键的架构决策:

LLM 生成是延迟瓶颈。它占总延迟的 70-80%。这意味着优化其他节点(比如把记忆检索从 100ms 压到 50ms)对整体体验的改善有限。真正有意义的优化方向是:流式输出(让用户看到逐字生成,而不是等完整回复)、模型选择(速度 vs 质量的权衡)。

查询理解是架构中最"脆弱"的节点。它用 LLM 分析用户意图,输出一个结构化的查询计划。如果 LLM 返回了格式错误的 JSON,整个检索链路就断了。因此需要一个明确的降级策略:解析失败时,默认激活所有检索通道(宁可多检索,不能不检索)。

情绪读取的延迟极低(5-15ms,纯 KV 读取),这是把情绪快照存在 KV 而非数据库的架构收益。

5. 并行与汇聚:关键的性能设计

管线中最重要的性能设计是记忆检索和情绪读取的并行执行

Drawing canvas 串行模式下,查询理解完成后依次执行记忆检索和情绪读取,总延迟 = 200 + 100 + 10 = 310ms。并行模式下,记忆检索和情绪读取同时执行,总延迟 = 200 + max(100, 10) = 300ms。看起来只省了 10ms,但这个模式的真正价值在于可扩展性

假设未来加入新模块——比如"用户画像分析"节点(50ms)、"场景识别"节点(80ms),如果是线性执行,每加一个模块就多一份延迟。而在并行模式下,只要新模块不依赖其他并行节点的输出,它就可以加入并行分支,总延迟仍然等于最慢的那个。

并行分支有一个必然的配对结构:分叉点(Fork)和汇聚点(Join)。在我们的管线中:

  • 分叉点 = 查询理解节点:它的输出同时流向记忆检索和情绪读取
  • 汇聚点 = Prompt 组装节点:它等待两个分支都完成后,才开始执行

汇聚点的等待逻辑由 LangGraph 自动处理——当一个节点有多条入边时,框架会等所有入边对应的上游节点都完成后再触发该节点。这是图编排相比手写 Promise.all 的优势:并行关系和等待关系都由图的拓扑自动决定,不需要手动管理。

但并行也有代价。并行分支中如果某个节点失败,汇聚点需要决定:是等待(希望超时后重试成功),还是放弃该分支继续执行?在我们的设计中,记忆检索失败则 memories 字段为空数组,Prompt 组装照常进行但记忆层为空——这是优雅降级而非报错中断。

6. 条件路由:图的分支逻辑

条件路由是图编排区别于线性管线的核心能力。它回答的问题是:"当前节点执行完后,下一步去哪?"

我们的管线有两个条件路由点:

路由点 1:输入安全检查之后。如果输入不安全,跳过后续所有处理,直接走拦截路径。这种"短路"设计避免了用恶意输入去调用 LLM——既省钱,又防止模型被攻击性 Prompt 污染上下文。

路由点 2:输出安全检查之后。如果 LLM 的回复泄露了系统提示词或包含违规内容,丢弃该回复,返回预设的兜底回复。

两个路由点虽然都可以导向兜底回复,但它们的架构位置完全不同:输入侧拦截是"省钱"(避免无意义的 LLM 调用),输出侧拦截是"兜底"(LLM 已经跑了,但输出有问题)。这种双层防护在第六篇(Prompt 工程)中从 Prompt 层面讨论过,现在从管线架构层面再次体现。

条件路由还隐含一个重要的架构约束:路由函数必须是纯函数。它只能读取 State,不能修改 State,不能调用外部服务。路由函数的职责是"决策"而非"执行"。如果路由逻辑本身需要调用 LLM(比如判断用户意图),那它应该被设计为一个独立节点,而非路由函数。

7. 同步与异步的边界

管线中最微妙的架构决策是:哪些工作在返回用户之前做,哪些在返回之后做?

我们的划分原则是:

时机工作内容原因
同步(返回前)安全检查、记忆检索、情绪读取、Prompt 组装、LLM 生成直接影响本次回复的质量
异步(返回后)情绪更新、亲密度计算、记忆写入、对话摘要生成影响的是"下一次"回复,不影响本次

这条边界不是随意画的。它的判断依据是:这个操作的输出是否被当前回复所需要? 如果是,放同步;如果只影响未来的回复,放异步。

异步路径在 Serverless 环境(CloudFlare Workers)中有一个特殊的技术约束:Workers 在返回 HTTP 响应后会销毁执行上下文,所有未完成的异步任务会被中断。解决方案是 ctx.waitUntil(promise)——它告诉 Workers 运行时"响应已发出,但请保持进程存活直到这个 Promise 完成"。

这引出一个架构层面的风险:如果异步任务失败了怎么办? 用户已经拿到回复走了,你无法通知用户"记忆写入失败了"。因此异步任务需要自己的容错机制:

  • 幂等性:记忆写入操作必须是幂等的。即使因为超时重试写了两次,结果也应该一样
  • 最终一致性:情绪状态可能有短暂的"滞后"(本轮对话的情绪影响在下一轮才生效),这是可接受的
  • 死信队列:连续失败的异步任务应该被记录下来,由运维人员事后补偿处理

8. 节点粒度的设计原则

最后讨论一个架构层面的核心问题:节点应该拆多细?

上面的设计是 7 个核心节点 + 2 个辅助节点(兜底回复、异步后处理)。这个粒度是经过权衡的。

太粗的问题:如果把"记忆检索 + 情绪读取 + Prompt 组装"合成一个节点,你就失去了并行执行记忆检索和情绪读取的能力,也无法单独替换其中一个模块。更严重的是,当这个"大节点"出问题时,你无法区分是记忆检索超时还是 Prompt 组装出错。

太细的问题:如果把输入安全检查拆成"正则匹配节点"+"模型分类节点"+"结果合并节点",图的节点数从 9 个膨胀到 15+,每条边都是一次状态序列化。更重要的是,过细的节点会模糊"业务语义"——看图时你不再能一眼看出"这里是安全检查",而是要先理解三个子节点之间的关系。

判断标准是三条:

原则说明示例
独立可替换节点可以独立替换实现,不影响其他节点记忆检索从向量检索换成混合检索,其他节点无感知
并行机会如果两个操作可以并行且延迟显著,就拆成两个节点记忆检索(100ms)和情绪读取(10ms)值得并行
调试边界每个节点是一个可观测单元,输入输出清晰出问题时能精确定位到"查询理解返回了错误的 JSON"

满足任意一条就值得拆,三条都不满足就应该合并。比如"正则匹配"和"模型分类"虽然是两个不同的操作,但它们共同服务于"输入安全检查"这一个业务目的,调试时也总是一起看,所以合并为一个节点。

9. 与前文的整合关系

这张管线图不是凭空设计的,它是前七篇文章的"合龙"工程。每个节点都精确对应一篇文章的核心模块:

管线节点对应文章核心机制
输入/输出安全检查第六篇 Prompt 工程双层防护:Prompt 层 + 代码层
查询理解第七篇 混合记忆架构LLM 分析用户意图,生成查询计划
记忆检索第四篇 向量化检索 + 第七篇 混合架构四通道并行检索 → 结果融合
情绪读取第五篇 情绪状态机KV 读取当前情绪快照 + 亲密度
Prompt 组装第六篇 Prompt 工程四层架构:安全 → 人设 → 情绪 → 记忆
LLM 生成第三篇 LangChain/LangGraphLCEL 链式调用
异步后处理第二篇 内存调度 + 第五篇 情绪状态机情绪更新、记忆写入、亲密度计算

这张表揭示了一个重要的架构特征:管线的拓扑结构反映了业务模块之间的依赖关系。记忆检索依赖查询理解(需要知道检索什么),Prompt 组装依赖记忆和情绪(需要填充数据),LLM 生成依赖 Prompt(需要完整的指令)。这些依赖不是技术层面的偶然耦合,而是业务逻辑的必然要求。

图编排的价值正在于此:它把这些隐式的业务依赖变成了显式的图拓扑。新成员加入团队时,看图就能理解系统的处理流程,而不需要阅读所有代码才能拼出完整图景。

10. 总结

这篇文章从架构层面分析了如何将前七篇的独立模块编排为一条完整的 LangGraph 处理管线。

关键要点:

  1. 图编排的核心价值是将复杂的节点依赖和条件分支显式建模为有向图,让架构本身成为文档
  2. 管线分三条路径:主路径(正常处理)、拦截路径(安全过滤的短路设计)、异步路径(不阻塞用户的后台处理)
  3. State 共享机制实现了节点间的完全解耦——节点只面向字段编程,不直接依赖其他节点
  4. 并行分支通过图的拓扑自动调度(Fork-Join 模式),比手写 Promise.all 更易扩展和维护
  5. 同步与异步的边界由"是否影响本次回复"决定,异步任务需要幂等性、最终一致性和失败补偿机制
  6. 节点粒度遵循三条原则:独立可替换、并行机会、调试边界。满足任一即拆,全不满足即合