上半场:上下文满了怎么办。 你在同一线程里改了三十轮,到第三十轮 agent 突然问"当前目录是什么"——用量顶到窗口边上,系统必须动手砍历史。难点是砍哪段、留哪段,以及砍完之后剩下的东西怎么摆。
下半场:会话凭什么第二天还能接着改。 列表还在、对话能续,看似同一份存储,落盘时却走两条轨:上轨按行追加,下轨只抄封面。理解了这两条轨,就知道中途拔电到底会丢什么。
两件事的共同点是一句很硬的话:原文和派生视图必须分开,顺序不能反。 先保证原文,再修镜像;允许镜像落后,禁止镜像超前。
| 能力 | 判据 | 对应源码位置 |
|---|---|---|
| 相位识别 | 拿到一次压缩,能说出它是 PreTurn / MidTurn / StandaloneTurn,以及为什么 | session/context_window.rs L74–L91、session/turn.rs L458–L483 |
| 分发路由 | 能按 TokenBudget 与提供方能力,推出走远端 v2 / 旧远端 / 本地 / 空窗 | session/turn.rs L1178–L1201 |
| 远端过滤 | 能背出丢四类、留四类,并解释为什么必须是穷尽 match | compact_remote.rs L370–L397 |
| 摘要安置 | 能说清为什么 MidTurn 的摘要必须在队尾 | compact.rs L59–L74 |
| 双轨写入 | 能解释 JSONL 先落盘、SQLite 后投影的顺序为什么不能反 | thread-store/src/local/live_writer.rs L335–L347 |
| 恢复推演 | 给一个拔电时刻,能推出恢复捡回哪一段、列表标题变不变 | rollout/src/recorder.rs L547–L559、session/rollout_reconstruction.rs L155–L188 |
| 维度 | 取值 | 源码枚举 |
|---|---|---|
时机 CompactionPhase | PreTurn(发消息前)、MidTurn(工具跑完还要续跑)、StandaloneTurn(手动) | context_window.rs L74–L91 |
实现 CompactionImplementation | 本地再采样、旧 /responses/compact、新 compaction_trigger、空窗 | turn.rs L1178–L1201 |
九宫格是乘法表,源码里没有九套并列函数。 自动路径共用 run_auto_compact,手动路径共用 CompactTask——两套入口。
三个自动时机先问同一个函数:token_limit_reached 在"缓冲后的 compact 限额"或"满窗"任一触发时为真。MidTurn 还多一道闸:后面还要继续,且模型刚请求了新窗口或 token 已到顶。只满不续 → 这一轮自然结束,下一轮用户消息到来时走预采样。
TokenBudget 开? → 空窗(远端和本地都被跳过)
↓ 关
认 compaction_trigger 且特性开 → 远端 v2
认 V2 但特性关 → 旧远端接口
Unsupported → 本地
TokenBudget 默认关,RemoteCompactionV2 默认开 → 默认 OpenAI 会话走远端 v2。
服务端带回来的 transcript 可能夹着过期 developer 指令。不滤就和本地按当前 world state 新渲染的环境叠在一起——效果等价于有人改了旧行。
滤网是完整的穷尽 match:
| 丢掉 | 留下 |
|---|---|
| developer 指令 | 真实用户消息 |
| 非用户内容的 user 包装 | 持久化 hook prompt |
| 工具调用 | assistant |
| 压缩触发项 | 压缩项 |
v2 复用同一函数 → 这张滤网是被当成合同对待的,不是临时措施。
| 相位 | 策略 | 效果 |
|---|---|---|
| PreTurn / StandaloneTurn | DoNotInject | 替换历史里没有初始上下文,清掉 reference_context_item,下一轮普通 turn 走完整重注 |
| MidTurn | BeforeLastUserMessage | 当前环境和权限插到最后一条真实用户消息之上,摘要仍在队尾 |
为什么 MidTurn 特殊:模型被训练成"摘要是历史上最后一项"。中途压完还要继续采样,上下文插到摘要后面,训练约束就破了——模型表现得像没看见刚才的压缩。
插入函数还有两层兜底:没有真实用户 → 插在摘要前;再没有 → 插在最后一条 compaction 项前。
三种实现最后都进 replace_compacted_history:history_version 只在这时加一,追加不碰版本号。Guardian 复用 transcript 时会核对 parent_history_version,版本变了对不上就不能复用旧审查前缀。
TokenBudget 跳过模型和服务端,安装一扇新窗口,摘要字段是空字符串。模型在新窗口里看不到旧对话,只看到此刻的环境和权限——与 new_context 工具的合同一致:*换窗,不摘要*。
它仍然走压缩生命周期,pre-compact hook 若停下,窗口还没换。复用同一条生命周期的收益是事件流完整(hook 和 ContextCompaction 条目看得见这件事),代价只是摘要字段为空。开关默认关——避免用户在没意识到时丢掉整段对话。
None,第一次远端失败直接返回,超时不在可重试名单里。列表要快,恢复要对,一份文件很难同时满足。
| JSONL(原文 / 上轨) | SQLite(镜像 / 下轨) | |
|---|---|---|
| 写入 | 按行追加,便宜 | 只抄封面字段 |
| 擅长 | 顺序写、命令行可读 | 按工作区/置顶/归档筛选 |
| 用于 | 恢复、fork、压缩回放 | 会话列表 |
| 失败后果 | 不能由 SQLite 顶上 | 可以下次重做 |
两件看起来相反的事故,指向同一条规则:
state_5.sqlite 被删 → 列表空一阵又长回来,对话还在(镜像可重建);
Session → LiveThread → store
↓
先:一行 JSON + 换行,write_all 后 flush ← 闸门
↓
后:materialize_to_sqlite( Paginated 模式下 flush 屏障必须先赢)
↓
投影失败 → 只 warn,下次按字节偏移续
如果先写 SQLite 再补 JSONL,进程死在两步中间 → 列表里出现点不开的会话:用户看见标题,点进去没有对应行。这种不一致比列表暂时为空更难查——你看到的是一个存在但残废的对象。
落字节那一步:一行 JSON 加换行,write_all 后再 flush。这里的 flush 是 tokio 文件缓冲,源码没有再调 sync_all——进程被立刻杀掉时,最后几行可能停在内核页缓存里。下次打开会补换行,坏掉的半行计进 parse_errors。
~/.codex/sessions/ 目录,打稳定指标 codex.sqlite.fallback.count。日志挡住历史丢失,数据库挡住列表太慢,回填挡住镜像变空,fallback 挡住镜像撒谎。任何一层都可以失败——默认让列表降级,不要让恢复降级。
压缩先改内存历史,再按 Compacted → WorldState → TurnContext 顺序落盘。WorldState 必须跟在 replacement history 后面,因为它是这份新历史的基线。
恢复时反过来:从后往前扫,碰到带 replacement_history 的 Compacted 就切断更早的后缀,并清掉更早的 TurnContext 基线;再正序重放 WorldState,full 快照重置基线,patch 往上合并。
这三类对列表几乎无用——apply_rollout_item 碰到 Compacted 和 WorldState 是空操作。镜像不是全文索引,是列表和筛选要用的字段;标题来自 UserMessage,不从模型的 ResponseItem 猜。
压缩家族
| 项目 | 不叫模型的那一刀 | 递归怎么挡 | 失败计数 |
|---|---|---|---|
| Codex | TokenBudget 空窗,仍走压缩生命周期 | CompactTask 不进 turn 循环;本地超窗靠修剪;中途靠压成功即低于限额 | 中途路径没有对等计数器。作者把赌注写在注释里:压到限额以下就不必担心死循环 |
| DSH | prune 先落地,回到阈值以下则摘要不跑 | replaceGeneration 当重试许可,只在 replace 计划提交时加一 | prune 先落地、摘要抛错,只要 generation 前进就允许从新表层再试 |
| Claude Code | 无,默认仍是再叫一次模型 | session_memory 和 compact 两种 querySource 直接返回 false(注释:fork 出来的 agent,再触发会死锁) | 连续失败 3 次电路熔断,注释记录过单会话连续失败上千次的事故 |
会话持久化
| 项目 | 单元 | 后端 | 未知形状怎么办 |
|---|---|---|---|
| Codex | rollout item | JSONL 原文 + SQLite 镜像双轨 | 尽量打开,未知形状靠 serde 失败计入 parse_errors,有 item 时仍尽量建 builder |
| DSH | 内存 SessionEvent | JSONL 与 SQLite 实现同一份 SessionPersistence seam,换后端不换日志语义;header 带 SESSION_FORMAT_VERSION = 0 | 版本不对或未知且未标 ignorable 的事件 → 直接拒绝,报 SessionFormatUnsupportedError。静默残缺比报错更难查 |
| Claude Code | 一份 JSONL | projects/<project>/<sessionId>.jsonl,同步 appendFileSync,权限 0o600,无会话镜像库 | 单文件读取有 50 MB 上限(注释写明会话 JSONL 可长到数 GB);列表走 getSessionFilesLite 读文件头尾 |
压缩侧
history_version 是不是只在整表替换时加一?追加路径有没有误碰它?存储侧
约束一:行号是一次快照。 全部对应 openai/codex 仓库 commit 4f39251a01,核对日期 2026-08-22。用于理解调用链形状,不是可引用的长期指标。
约束二:九宫格是教学编排。 源码里没有九套并列函数,实际是两套入口加一张分发表。把九宫格当成九份实现去读源码,会找不到对应物。
约束三:flush 不等于 sync。 源码只调了文件缓冲的 flush,没有 sync_all。断电而非进程被杀的场景下,最后几行的持久化保证由操作系统决定,别把它当成已落盘。
约束四:默认组合会变。 TokenBudget 默认关、RemoteCompactionV2 默认开 → 默认走远端 v2。特性开关的默认值随版本演进,判断当前行为要先读代码里的默认值,不要依赖本文。
约束五:镜像的价值边界。 SQLite 存的是列表和筛选字段,不是全文索引。任何"从镜像反推对话内容"的需求都超出了它的设计范围。
任何同时存在原文和派生视图的系统,先写死一条规则:派生可以落后,不能超前。写的时候先落原文,读的时候允许降级。这条不需要任何框架,今天就能改。
外部返回的结构化数据形状会变。穷尽匹配的好处是,形状一变编译期就逼你重新表态,而不是让未知类型静默流进下游。
加法变乘法。新加一种提供方只改分发器的一行,不动判定入口;新加一种触发方式只加一个枚举值,不改分发逻辑。
摘要在队尾不是一个审美选择,它是训练约束。MidTurn 路径上任何把上下文插到摘要之后的行为,都会让模型表现得像没看见这次压缩——这类 bug 只有在长对话里才暴露,值得专门写测试。
时机和实现拆开,九种组合共用两套入口;远端结果进门先过穷尽滤网,中途压缩的摘要必须停在队尾;空窗是压缩的一种产物,回忆不带走。JSONL 挡住历史丢失,SQLite 挡住列表太慢,回填挡住镜像变空,fallback 挡住镜像撒谎——任何一层都可以失败,默认让列表降级,不要让恢复降级。
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
本页文字稿与音频为同源二次演绎,音频由文本合成,措辞以本页为准。