Skip to main content

BLOGE 0.9.8-RC1 在线版 · 事实校验 2026-09-15 · English

第 14 章 —— 多轮 Session

承诺: 读完本章后,你将知道什么时候应该从一次性 graph 升级到 session(多轮交互会话),如何用 ONCE / ROUND 两类 phase 建模多轮交互,以及如何让整段交互在重启、超时和恢复之后仍然保持清晰。


学习目标

  1. 区分“只挂起一次的 graph”和“跨越多次信号、多段 phase 边界的 session”。
  2. session 建模成一组有序的 phase,并理解 ONCEROUND 的职责边界。
  3. 使用 ctx.round.inputyield_onuntilthen 来表达真实的多轮交互。
  4. 配置空闲超时、历史保留、所有权和恢复逻辑,而不丢失运行时心智模型。
  5. 知道什么时候 session 是正确抽象,什么时候 第 12 章第 13 章 里的普通 graph 仍然更简单。

前置条件

源示例

文件展示内容
ch13/customer-service-session.bloge典型 session DSL:问候 → 多轮分诊 → 处理 → 收尾
ch13/interactive-review-session.bloge更小的 collect → review 轮次 → finalize 模式,用于本章引导式重写
CustomerServiceSessionExample.javaPhaseBuilder.once(...) / PhaseBuilder.round(...) 构建同一个 session 的 Java Fluent API 版本
bloge-session-ext/README.md内存 session 运行时、手动恢复边界和嵌套状态机说明
docs/session-phase-round-specification.mdsession / phase / round 的精确 DSL 与运行时契约

为什么这很重要

第 12 章 中,你已经学会了让一张 graph 挂起,然后在稍后恢复。这已经能解决很多真实工作:等待审批、等待支付、等待外部事件。

但有些工作流不是“一个 graph + 一次等待”,而是一段真正的对话式交互

  • 先打招呼
  • 再问更多细节
  • 等用户回复
  • 不够清楚时继续追问
  • 最后转人工或结束对话

同样的行为也可以用 await、分支和上下文拼装在一张 graph 中表达。代价是对话身份、轮次边界和恢复状态仍隐含在 graph 结构里,不容易检查和解释。

session 的价值就在这里:它把“多轮交互本身”提升为一等运行时模型。你不再把所有轮次都压进一张巨大 DAG,而是明确写出:

  • 哪些步骤只执行一次
  • 哪些步骤会按轮次重复
  • 哪些信号属于同一段业务交互
  • 这段交互该如何超时、恢复和继续推进

先读对话,再读 Session 模型

客户说包裹显示已送达,但自己没有收到。助手读取事实、问一个收窄问题、收到 回答,再把工单交给人工路径。Session 要保留的是这段四轮交换,而不是一个 抽象的“支持聊天”目标。

图:四轮客服 transcript

每个对话事实只翻译一次

对话事实Session owner为什么保留
工单与客户 identitySession context每一轮都稳定
当前客户消息ctx.round.input.userMessage只属于当前 round
助手回答与 donerespond.output决定 yield 与退出
action=handoffphase transition 条件把 owner 从 triage 交给 solve
最终处理结果wrap-up output给调用方稳定的 terminal result

第 2 轮在 respond 后 yield,因此调用方能显示问题,而 Session 仍停留在 triage。第 3 轮回答成为下一次 round input。第 4 轮返回 done=trueaction=handoff,phase 才迁移到 solve;它不会因为模型文本里写了“转人工” 就暗中修改工单。

现在术语有了存在理由:phase 负责一个对话阶段,round 负责一次重复 交换,yield_on 标出外部可见暂停,until 说明重复何时结束。


心智模型

可以把 session 看作是多个 graph 执行的容器

组成部分责任
SessionGraph整段交互的不可变定义
phase交互生命周期中的一个命名步骤
ONCE phase内部 graph 执行一次,然后发生跳转
ROUND phase每次收到新的信号载荷就执行一轮,轮次之间会挂起
SessionExecutor启动、发信号、恢复调用方提供的状态,并终止内存 session
DurableSessionManager在需要持久化时协调 checkpoint store、定义查询、lease 与重启恢复

两类 phase 基本覆盖了大多数模式:

Phase 类型适合什么运行时行为
ONCE问候、加载上下文、最终收尾执行一次 graph,写入输出,解析 then
ROUND澄清直到完成、评审迭代、菜单循环等待信号,执行一轮 graph,计算 until,然后继续挂起或跳到下一个 phase

关键转变是:graph 负责一次执行内部的依赖顺序,而 session 负责多次执行之间的交互顺序

Diagram: 14-multi-turn-sessions figure 1


第一个可运行的示例

companion 项目里的 customerServiceSession 是最适合照着抄的最小真实形状:

session customerServiceSession {
idle_timeout = 5m
timeout_action = "cs_session_timeout_policy"
max_rounds = 20
max_history = 50

phase greeting {
node greet : CsSessionGreeter {
input {
sessionId = ctx.sessionId
}
}
then -> triage
}

phase triage {
max_rounds = 5
yield_on = [respond]
round {
node respond : CsSessionResponder {
input {
userMessage = ctx.round.input.userMessage
}
}
}
until respond.output.done == true
then {
respond.output.action == "handoff" -> solve
otherwise -> wrapUp
}
}
}

用自然语言读它:

  1. 先进入一次性的 greeting phase
  2. 再进入可重复的 triage phase
  3. 每次信号的 payload 都会成为新的 ctx.round.input
  4. 只要 done != true,就继续停留在当前 phase
  5. 一旦完成,就决定是转 solve 还是直接 wrapUp

这正是 session 的价值:即使交互跨越了多轮用户输入,结构依然清晰可读。


拆解分析

ONCEROUND 解决的是不同层面的问题

当一个步骤只应该给出一次答案时,使用 ONCE phase:

  • 问候用户
  • 加载当前账户或上下文
  • 生成最终摘要

当一个步骤天然可能重复时,使用 ROUND phase:

  • 不断追问,直到问题足够清楚
  • 不断评审,直到不再需要修改
  • 按菜单多轮收集输入,直到用户到达明确结果

DSL 之所以把两者区分出来,就是为了避免把“重复交互”伪装成一堆难以读懂的分支。

每一轮能看到什么

ROUND phase 中,运行时会在普通 GraphContext 之外再注入几类绑定:

  • ctx.round.input —— 当前轮次恢复时收到的 payload
  • ctx.<phaseId>.output —— 之前已经完成的 phase 输出
  • ctx.session.namespace / ctx.session.ownerId —— 如果提供了身份信息,就能拿到解析后的 session 身份

所以一轮真正会同时消费三类数据:

  • 初始请求上下文,例如 ctx.sessionId
  • 之前 phase 的累积输出,例如 ctx.collect.output.*
  • 当前用户的最新回复,即 ctx.round.input.*

Session 级控制项与默认值

属性作用域默认值你为什么要关心
idle_timeoutsession30m两次信号之间允许空闲多久
timeout_actionsession通过 TimeoutPolicyProvider 解析的策略引用
max_roundssession100所有 round phase 共用的总轮次预算
max_roundsROUND phase1当前 phase 自己的轮次上限 —— 真实对话几乎总要显式设置
max_historysession0历史保留条数;0 表示无限制,不是关闭
yield_onROUND phase终端节点每轮结束后返回哪些节点输出
on_round_failureROUND phaseterminate_session一轮执行失败时该怎么处理
max_visitsphase不限运行时通过 then 规则重新进入该 phase 的硬上限
thenphase无条件或基于规则的后续跳转

这里有两个特别容易误读的默认值:

  • max_history = 0 表示全部保留
  • 没有显式设置 max_roundsROUND phase 只会跑一轮

max_visits 防止 phase 之间的反复跳转

max_rounds 限制的是一个 ROUND phase 内部循环多少次。 max_visits 限制的是运行时通过其它地方的 then 规则进入该 phase 的次数。没有它,两个互相 route 的 phase 会无限来回跳:

phase classify : ROUND { ... then "needs-more-info" -> gatherInfo }
phase gatherInfo : ROUND { max_visits = 5 then "complete" -> classify }

一旦超过 max_visits,运行时会发出 PHASE_VISIT_LIMIT_EXCEEDED,并抛出 SessionPhaseVisitExceededException。执行器随后通过普通错误终止路径 把 session 标记为失败。可被重新进入的 phase 都应设置 max_visits

启动、发信号与恢复内存状态

Session 的生命周期 API 故意保持得很小:

SessionExecutor executor = SessionExecutor.builder(engine)
.accessGuard(new OwnerOnlySessionAccessGuard()) // 默认
.build();

SessionHandle handle = executor.start(
sessionGraph,
new GraphContext(Map.of("sessionId", "SESSION-1001")),
SessionIdentity.of("support", "user-123")
);

executor.signal(handle.sessionId(), Map.of("userMessage", "我想退款"), "user-123");

SessionExecutor 只负责当前 JVM 中活跃的内存交互。builder 可以配置 access guard、listener、timeout policy 和 snapshot callback,但不接收持久化 store。

如果应用自行保存了 session snapshot,就要先重建普通的 SessionState 和 上下文,再显式交给执行器:

SessionHandle restored = executor.restore(
sessionGraph,
restoredState,
restoredContext
);

这个方法只恢复调用方提供的状态,不会主动发现 checkpoint、抢占 lease, 也不负责服务重启后的批量恢复。后面介绍的 DurableSessionManager 才负责这些工作。

所有权是模型的一部分

session 往往会存活得足够久,以至于“谁有资格继续推动它”会成为业务问题本身。

默认情况下,运行时使用 OwnerOnlySessionAccessGuard。这意味着:

  • 谁启动了 session,后续往往也决定谁有权 signal(...)
  • 读取、继续推进、终止等行为都可以被调用方身份约束
  • 与“只会挂起一次的 graph”相比,session 明确把身份和生命周期绑在了一起

应用必须从自己的认证请求中解析 caller id,再把它传给 start(...) 和后续 操作。下面这个名字相近的 Spring 属性服务于另一类 owner:

spring:
bloge:
session:
owner-id: ${HOSTNAME} # 当前 JVM 的 durable recovery/lease owner

spring.bloge.session.owner-id 不是用户身份表达式解析器,而是执行 durable recovery claim 的 JVM 标识;省略时,durable manager 会生成随机 UUID。 认证主体到 SessionIdentity 的映射仍由应用负责。

什么时候不该使用 session

不要因为它“更高级”就默认用 session

普通 graph 仍然更适合这些情况:

  • 整个流程只有一次明显的挂起 / 恢复边界
  • 业务上并没有比 node 更重要的 phase 结构
  • 核心问题仍然是一张 DAG 加上持久执行

如果真正困难的是命名状态之间的迁移,而不是多轮对话,那么通常 第 15 章 才是更合适的抽象。


常见陷阱

❌ 忘了 ROUND phase 默认只跑一轮

下面这段看上去像是会不断循环:

phase triage {
round {
node respond : CsSessionResponder { }
}
until respond.output.done == true
}

但如果你没有显式设置 phase 级 max_rounds,它只会执行第一轮:

phase triage {
max_rounds = 5
round {
node respond : CsSessionResponder { }
}
until respond.output.done == true
}

Session 级 max_rounds = 20全局预算,并不会覆盖 ROUND phase 默认值 1


常见故障

服务重启后,新的内存执行器找不到旧 session

服务重启后,如果直接创建一个新的 SessionExecutor,再向旧 session id 发送 signal,就会失败,因为新的执行器里没有那段活跃状态:

SessionExecutor executor = SessionExecutor.builder(engine).build();
executor.signal(sessionId, payload, "user-123"); // 当前执行器里没有这个 session

如果由应用自行管理快照,就重建 SessionState,再调用 restore(sessionGraph, state, context);如果需要基于 store 的重启恢复, 就使用 DurableSessionManager,同时配置 execution store、checkpoint store 和 definitionLookup。这条边界避免内存执行器假装自己拥有分布式恢复能力。


引导式重写

打开 interactive-review-session.bloge,再回看 第 12 章 里的单次等待模式。

这次重写的关键步骤是:

  1. 把一次性初始化保留为 ONCE phase。 collect 只负责收集评审上下文。
  2. 把重复的人机交互抽成 ROUND phase。 review 每收到一次回复就执行一轮。
  3. until 直接表达退出条件。 当结果不再是 needs_revision 时,结束当前 phase。
  4. 最后用一个 ONCE phase 收尾。 finalize 只写一次最终总结。
session interactiveReview {
phase collect { ... then -> review }

phase review {
max_rounds = 3
yield_on = [reviewRound]
round {
node reviewRound : DecideReviewOperator { ... }
}
until reviewRound.output.decision != "needs_revision"
then -> finalize
}

phase finalize { ... }
}

这次重写的目标不是“多用几个运行时特性”,而是把边界从节点级等待提升到交互级结构


思维检查

  1. 什么时候应该继续使用普通 graph,而不是引入 session

    (当整个流程只有一次明确的挂起 / 恢复边界,并不需要多 phase 对话结构时。)

  2. session 级 max_rounds 与 phase 级 max_rounds 有什么区别?

    (前者是所有 round phase 共用的总预算;后者只限制当前 ROUND phase,而且默认值是 1。)

  3. ctx.round.input 里是什么?

    (当前轮次恢复时收到的 payload。)

  4. max_history = 0 代表什么?

    (历史无限制保留,而不是关闭历史。)

  5. 调用 SessionExecutor.restore(...) 时,调用方必须提供什么?

    SessionGraph、重建后的 SessionState 和对应的 GraphContext;持久化与 lease 恢复不属于纯内存执行器。)

  6. session 与“一张只有一个 await 节点的 graph”本质差异是什么?

    (session 明确拥有 phase 顺序、轮次边界、session 级超时 / 历史 / 访问控制,以及跨多次信号的恢复语义。)


实验

目标: 把一个一次性客服 graph 改造成真正的多轮 support session。

  1. 从 greet、clarify、resolve 三步交互开始。
  2. greetresolve 放进 ONCE phase。
  3. 把澄清步骤放进一个 ROUND phase。
  4. 配置:
    • session idle_timeout = 10m
    • session max_rounds = 12
    • phase max_rounds = 4
    • yield_on,让调用方在每轮结束后都能拿到最新回复
  5. 增加一个分支:当轮次输出中 action == "handoff" 时转人工。

进阶: 增加自定义 timeout_action 策略,让空闲 session 在终止前先发送提醒。


持久 Session(Durable Sessions)

上面的内容都使用纯内存 SessionExecutor。在生产环境中,跨越数小时甚至数天的 session 需要检查点持久化,才能在进程重启后存活。bloge-session-durable 模块提供的 DurableSessionManager 正是为此而生的。

Maven 依赖

<dependency>
<groupId>com.leanowtech.bloge</groupId>
<artifactId>bloge-session-durable</artifactId>
</dependency>

构建与使用

Map<String, SessionGraph> sessionRegistry = Map.of(
sessionGraph.name(), sessionGraph
);

DurableSessionManager durableSession = DurableSessionManager.builder()
.executionStore(executionStore)
.checkpointStore(checkpointStore)
.graphEngine(engine)
.definitionLookup(sessionRegistry::get)
.recoveryConfig(SessionRecoveryConfig.defaultConfig())
.build();

durableSession.startup();
// 若本地没有 active session,signal() 会先 claim durable execution、
// 恢复 checkpoint,再投递下一轮输入。
durableSession.signal(sessionId, newTurn);

DurableSessionManager 会创建带 durable snapshot callback 的 SessionExecutor,session snapshot 经 checkpoint store 写入。当 signal(...) 在本地找不到该 session 时,manager 会 claim 对应 execution、加载最近 checkpoint,再投递 signal。

内容哈希与版本检测

每个 session graph 定义都有一个 contentHash —— 对 session graph 结构计算的 SHA-256 摘要。这个哈希会存储在每一个检查点内部。当 DurableSessionManager 加载检查点时,它会把存储的哈希与当前定义的哈希进行比较。

如果两个哈希不匹配,说明 session 定义在检查点写入之后发生了变化。

SessionVersionMismatchException

当检测到哈希不匹配时,DurableSessionManager 会抛出 SessionVersionMismatchException

try {
durableSession.signal(sessionId, userTurn);
} catch (SessionVersionMismatchException e) {
// e.sessionId() — 哪个 session 失败了
// e.checkpointHash() — 检查点中的哈希
// e.currentHash() — 当前定义的哈希
}

恢复策略:

策略适用场景
快速失败(默认)开发阶段 —— 强制你立刻注意到定义变更
丢弃旧 session,重新开始旧 session 状态可丢弃时 —— 捕获异常后启动全新 session
迁移需要连续性时 —— 实现应用层迁移逻辑,把旧的 phase 状态映射到新定义

选择哪种策略取决于你的业务场景。短生命周期的客服 session 通常可以安全丢弃;长周期的入职引导流程可能需要迁移逻辑。


实验验收卡

  • 预期与观察: 四轮对话映射到 context、round input、yield 和 transition。
  • 失败与恢复: 缺失 session identity 时恢复失败;恢复同一 identity 和 checkpoint。
  • 证明边界: 证明 Session 生命周期,不证明对话内容正确。
  • 练习合同: 四轮 transcript;只改一轮输入;交付 phase 表;每轮 owner 和下一 transition 可解释即停止。

回顾

  • 普通 graph 建模的是一次执行session 建模的是多轮交互
  • ONCE phase 负责一次性工作;ROUND phase 负责按信号驱动重复推进。
  • ctx.round.input 是外部信号与当前轮次 graph 之间的桥梁。
  • session 级限制和 phase 级限制不是一回事,真实流程通常两者都要配。
  • 纯内存执行器只恢复调用方提供的状态;基于 store 的重启恢复属于 DurableSessionManager
  • 当业务需要的是“对话结构”而不是单张 DAG 时,session 才是真正合适的抽象。

下一步

第 15 章 —— 状态机 中,你将从“多轮对话”切换到“显式命名状态”。到那时,重点不再是反复跑几轮,而是描述诸如 draft -> pendingReview -> processing -> completed 这样的生命周期迁移。


参考链接

Coding Agent: Open the versioned task guide.