# Agent Note: 为会话持久化写入批处理设定上界

Status: implemented

[English](2026-08-08-bounded-session-persistence-write-batching.md) | 中文

## 问题

流式响应可能会在短时间内发出大量 `assistant/chunk` 事件。此前，只要空闲队列收到一个事件，持久化协调器就会立即调度一次后端追加。该追加仍在进行时到达的事件会共用一个后续批次，但如果后端速度很快，仍可能产生大量小规模的持久化追加。每次 JSONL 追加都会创建并同步一个 Zstandard 帧或原始格式后缀，而每次 SQLite 追加都会打开并提交一个事务，同时递增会话修订版本。

丢弃分片事件或用组装后的消息替代它们可以减少逻辑存储量，但也会改变事件日志、回放、序列号、时间戳，以及助手消息引用的分片 seq。写放大问题不要求采取这项语义变化更大的方案。

### 量化基线

仓库 fixture（测试前置数据）让逻辑数据量有了具体依据。对当前 [`goal-multi-turn-actions`](../../../../apps/web/tests/snapshots/goal-multi-turn-actions/session.jsonl) 中的打包行进行解码，可得到 2,098 个事件，其中 2,017 个是分片（96.1%）。这些分片解包后的 JSONL 行共 332,647 字节，占全部事件 379,225 字节的 87.7%；分片打包则把仓库中的已提交文件缩小到 89,176 字节和 182 个存储行，其中包括 23 个打包分片行。[`permission-policy-context`](../../../../apps/web/tests/snapshots/permission-policy-context/session.jsonl) 可得到 813 个事件，其中 746 个是分片（91.8%）；这些分片解包后的 JSONL 行共 118,935 字节，占全部事件 184,821 字节的 64.4%。其打包文件为 84,917 字节，共 123 个存储行，其中包括 14 个打包行。这些是纳入版本控制的确定性 fixture，不代表生产工作负载分布；但它们说明了删除分片为何会降低逻辑数据量，也说明现有打包行布局已经消除了大量 JSON 包装开销。

SQLite 每个逻辑事件存储一行，因此同样的逻辑日志会分别保留 2,098 和 813 个事件行；批处理不会改变这些数量。JSONL 每个持久化追加批次会写入一个 Zstandard 帧并执行一次 fsync，SQLite 每个批次会执行一次事务并递增一次会话修订版本。运行时文件不记录原有追加边界，因此不能把 fixture 的存储行数当作 fsync 或事务次数。

调度上界是确定的。当写入端会立即完成每次操作时，原来的即时控制器可能对每个在前一次追加完成后到达的事件分别发起一次追加。一个控制器测试以 10 ms 的间隔接纳 20 个事件：200 ms 固定窗口会把全部 20 个事件交给一次追加。对于这种到达节奏，追加次数从 20 次降至 1 次，但这不是普遍比例。稀疏事件、强制 flush、较慢的前序写入和不同到达速率都会产生不同的批次大小。

## 决策

第一方 JSONL 与 SQLite 插件公开 `writeBatchMaxDelayMs`，其值必须是一个不超过 Node 计时器上限的正整数，默认值为 `200`。每个插件都会在加载时解析该值，再传给 `PersistenceCoordinator`；批处理行为仍只由协调器负责。

每个活跃的 Session 都有一个包私有 `SessionWriteBehind`。当其待处理队列从空变为非空时，控制器会启动一个固定窗口。后续事件加入该批次但不会重置截止时间：这属于有界合并，而不是防抖。截止时间到达后，控制器会把完整的待处理前缀交给现有的按 id 串行化机制，并沿 `appendBatch` 路径写入。同一 Session 同时最多有一个活跃写入。该写入期间接纳的事件会形成新的待处理前缀，并拥有自己的固定截止时间；如果该截止时间在活跃写入完成前到期，新前缀会在前一次写入完成后立即开始写入。

`writeBatchMaxDelayMs` 只限制控制器为批处理而主动等待的时间。事件循环调度、初始化、此前的串行化操作和后端 I/O 都可能延后持久化完成时间，因此该选项并不对 fsync 完成时间或崩溃数据丢失提供硬性 SLA。

`session/flush` 会取消剩余等待，并充当共享的完全停稳屏障。它会在完成前等待活跃写入尝试，并排空屏障运行期间接纳的每个事件。会话退役与后端 dispose（资源释放）共用该屏障，因此生命周期 teardown 绝不会等待批处理计时器。检查点策略仍会在模型请求与顶层工具副作用之前设置强制屏障。

每个事件仍会按原有顺序和形态持久化。控制器会在接纳时复制每个事件；任何 `assistant/chunk`、`seq`、`time`、surface 元数据或存储记录都不会被删除或重写。因此，JSONL 可以在一个追加帧中编码更多事件，SQLite 可以在一个事务中插入更多事件行，而无需改变任一种磁盘格式或 schema 版本。

后台追加失败后，控制器会把完整批次恢复到所有较新的待处理事件之前，报告一次该失败，并暂停自动重试。随后新接纳的第一个事件会开启新的固定窗口；显式 flush、退役或 dispose 会立即重试，如果故障再次发生，则会向调用方暴露该故障。这可以避免计时器驱动的失败循环，同时保留现有可恢复的 flush 边界。

本决策仅取代[将实时持久化归并到单个刷新控制器](../simplification/2026-07-23-collapse-persistence-flush-state.md)中的即时调度节奏。对于每个活跃 Session 使用一个控制器、保留失败批次、按 id 串行化、退役和完全停稳的 dispose，原 Agent Note 仍是权威记录。后端钩子边界仍由[共享持久化协调器](2026-06-18-shared-persistence-write-coordinator.md)定义。

## 备选方案

**不持久化流式分片事件。** 这里不采纳：这会改变事件日志作为真源的地位及恢复语义，而不只是改变物理写入节奏。在无信息损失的替代方案独立定义回放、fork、引用源事件的关联、序列和崩溃行为之前，现有的[拒绝仅保留组装消息的决策](../../rejected/simplification/2026-06-20-assembled-assistant-messages-only.md)仍是防护规则。[打包行决策](2026-07-26-packed-chunk-rows-by-default.md)仍是配套的 JSONL 存储体积优化。

**仅在语义检查点写入。** 不采纳：此方案会最大化批处理，却让普通的崩溃丢失窗口取决于另行挂载的策略。有界后台写入会在检查点之间持久化进度，而强制 flush 继续提供更强的顺序约定。

**按最新事件重置防抖窗口。** 不采纳：持续不断的流式响应可能无限期推迟首次写入。由第一个待处理事件启动的固定窗口，为主动合并等待提供了真正的上界。

**分别在 JSONL 与 SQLite 中实现计时器。** 不采纳：调度、失败保留、flush 竞态和 teardown 都是后端无关的生命周期问题。重复实现这些机制会重新引入 `PersistenceCoordinator` 已消除的实现漂移。

## 验证

控制器测试使用假时钟证明固定且不会重置的 200 ms 窗口、即时且可共享的 flush 屏障、屏障运行期间接纳的事件、在活跃写入之后已超过窗口时限的尾部批次、有序保留失败批次、暂停自动重试，以及显式 flush 会重试与其重叠发生的后台失败。协调器测试会在 Session 通知、退役、冲突回收和 teardown 路径中验证该控制器。JSONL 与 SQLite 测试套件继续覆盖存储格式、事务、恢复和共享持久化约定。

## 后果

高频事件突发通常会减少持久化追加操作，同时保持逻辑事件数量完全不变。减少幅度取决于事件到达速率和后端延迟：位于同一 200 ms 窗口内的突发事件会成为一个批次，而强制 flush 与稀疏事件仍可能产生小批次。

本决策不会限制因后端缓慢而积压的待处理事件数量或字节数，也不会减少 SQLite 行数或解码后的逻辑日志。若要建立经过验证的内存上界或逻辑保留策略，就必须为其另行定义失败与回放约定，而不是再引入一条隐式计时器规则。

接纳后的事件在配置窗口内可能只存在于内存中，此后在等待调度或后端工作完成期间也可能如此。部署可以选择较小的值以缩短普通丢失窗口，也可以选择较大的值以加强批处理。显式持久性边界保持不变，并会绕过等待。

新的 deep module 统一负责计时器、活跃写入、待处理前缀、重试暂停和屏障。`PersistenceCoordinator` 继续负责初始化和按标识串行化；后端仍只负责持久存储原语。`SESSION_FORMAT_VERSION` 与 SQLite `SCHEMA_VERSION` 均不变。
