学 AI 产品 · 专业 AI 产品经理播客第 3 章 · T3 解剖 OpenAI Codex:把安全观写进类型系统 · EP 06
第 3 章 · EP 06

满窗之后,砍哪一段留哪一段 · JSONL 是真相,SQLite 是镜像

时长 16:20音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 上下文满了之后,砍谁留谁1:36
1:36一 · 三十轮之后,它忘了自己在哪1:43
3:20二 · 时机和实现拆开,九种组合两套入口2:07
5:28三 · 远端回来的东西,进门先过滤1:40
7:08四 · 摘要放哪,看相位1:56
9:05五 · 空窗也是压缩,只是不叫模型2:00
11:06六 · 会话列表和恢复,为什么不读同一份盘1:30
12:36七 · 镜像可以落后,不能超前2:35
15:12收尾 · 今天可以带走的五条1:07
解读全文

ep17 · 满窗之后,砍哪一段留哪一段 · JSONL 是真相,SQLite 是镜像

  • 模块:T3 解剖 OpenAI Codex:把安全观写进类型系统
  • 集页:https://xueai-podcast.pages.dev/t/codex06/
  • 来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
  • 说明:本页为音频的文字稿与延展解读,音频为二次演绎配音版,内容以源课程素材为准。

本集解决什么工程问题

上半场:上下文满了怎么办。 你在同一线程里改了三十轮,到第三十轮 agent 突然问"当前目录是什么"——用量顶到窗口边上,系统必须动手砍历史。难点是砍哪段、留哪段,以及砍完之后剩下的东西怎么摆。

下半场:会话凭什么第二天还能接着改。 列表还在、对话能续,看似同一份存储,落盘时却走两条轨:上轨按行追加,下轨只抄封面。理解了这两条轨,就知道中途拔电到底会丢什么。

两件事的共同点是一句很硬的话:原文和派生视图必须分开,顺序不能反。 先保证原文,再修镜像;允许镜像落后,禁止镜像超前。


能力地图

能力判据对应源码位置
相位识别拿到一次压缩,能说出它是 PreTurn / MidTurn / StandaloneTurn,以及为什么session/context_window.rs L74–L91、session/turn.rs L458–L483
分发路由能按 TokenBudget 与提供方能力,推出走远端 v2 / 旧远端 / 本地 / 空窗session/turn.rs L1178–L1201
远端过滤能背出丢四类、留四类,并解释为什么必须是穷尽 matchcompact_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

上半场 · 上下文压缩

一 · 时机和实现拆开

维度取值源码枚举
时机 CompactionPhasePreTurn(发消息前)、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 / StandaloneTurnDoNotInject替换历史里没有初始上下文,清掉 reference_context_item,下一轮普通 turn 走完整重注
MidTurnBeforeLastUserMessage当前环境和权限插到最后一条真实用户消息之上,摘要仍在队尾

为什么 MidTurn 特殊:模型被训练成"摘要是历史上最后一项"。中途压完还要继续采样,上下文插到摘要后面,训练约束就破了——模型表现得像没看见刚才的压缩。

插入函数还有两层兜底:没有真实用户 → 插在摘要前;再没有 → 插在最后一条 compaction 项前。

三种实现最后都进 replace_compacted_history:history_version 只在这时加一,追加不碰版本号。Guardian 复用 transcript 时会核对 parent_history_version,版本变了对不上就不能复用旧审查前缀。

四 · 空窗也是压缩

TokenBudget 跳过模型和服务端,安装一扇新窗口,摘要字段是空字符串。模型在新窗口里看不到旧对话,只看到此刻的环境和权限——与 new_context 工具的合同一致:*换窗,不摘要*。

它仍然走压缩生命周期,pre-compact hook 若停下,窗口还没换。复用同一条生命周期的收益是事件流完整(hook 和 ContextCompaction 条目看得见这件事),代价只是摘要字段为空。开关默认关——避免用户在没意识到时丢掉整段对话。

失败处理的三个"宁可"

  • 本地路径自己碰上满窗 → 不递归调用自动压缩,删最老的一条再打一次;只剩一条还超 → 标满窗并返回错误。
  • 远端失败 → 不改走本地:普通满窗的预压缩和中途压缩都把 fallback 传成 None,第一次远端失败直接返回,超时不在可重试名单里。
  • 共同点:宁可失败,也不在没人注意的地方悄悄改变产物形态。

下半场 · 会话存储双轨

一 · 追加日志当原文,派生表当封面

列表要快,恢复要对,一份文件很难同时满足。

JSONL(原文 / 上轨)SQLite(镜像 / 下轨)
写入按行追加,便宜只抄封面字段
擅长顺序写、命令行可读按工作区/置顶/归档筛选
用于恢复、fork、压缩回放会话列表
失败后果不能由 SQLite 顶上可以下次重做

两件看起来相反的事故,指向同一条规则:

  • state_5.sqlite 被删 → 列表空一阵又长回来,对话还在(镜像可重建);
  • 手改库里的标题和 cwd → 刷新后有时跟着变,有时变回去(镜像可被原文覆盖);
  • 反过来不成立:原文丢了,镜像救不回来。

二 · 写入顺序:镜像可以落后,不能超前


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。

三 · 读路径分离与 fallback

  • 恢复 / fork / 压缩回放 → 只读 JSONL,从文件逐行 decode,不从 threads 表拼历史。
  • 列表 → 优先 SQLite;库不存在、打开失败、回填未完成 → 退回扫 ~/.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 猜。


横向对比

压缩家族

项目不叫模型的那一刀递归怎么挡失败计数
CodexTokenBudget 空窗,仍走压缩生命周期CompactTask 不进 turn 循环;本地超窗靠修剪;中途靠压成功即低于限额中途路径没有对等计数器。作者把赌注写在注释里:压到限额以下就不必担心死循环
DSHprune 先落地,回到阈值以下则摘要不跑replaceGeneration 当重试许可,只在 replace 计划提交时加一prune 先落地、摘要抛错,只要 generation 前进就允许从新表层再试
Claude Code无,默认仍是再叫一次模型session_memory 和 compact 两种 querySource 直接返回 false(注释:fork 出来的 agent,再触发会死锁)连续失败 3 次电路熔断,注释记录过单会话连续失败上千次的事故

会话持久化

项目单元后端未知形状怎么办
Codexrollout itemJSONL 原文 + SQLite 镜像双轨尽量打开,未知形状靠 serde 失败计入 parse_errors,有 item 时仍尽量建 builder
DSH内存 SessionEventJSONL 与 SQLite 实现同一份 SessionPersistence seam,换后端不换日志语义;header 带 SESSION_FORMAT_VERSION = 0版本不对或未知且未标 ignorable 的事件 → 直接拒绝,报 SessionFormatUnsupportedError。静默残缺比报错更难查
Claude Code一份 JSONLprojects/<project>/<sessionId>.jsonl,同步 appendFileSync,权限 0o600,无会话镜像库单文件读取有 50 MB 上限(注释写明会话 JSONL 可长到数 GB);列表走 getSessionFilesLite 读文件头尾

审查清单

压缩侧

  1. 新增一种压缩触发方式时,你改的是判定入口还是只加了一个相位枚举值?
  2. 远端返回的结构化历史,进门有没有穷尽过滤?新增一种 item 类型时,编译期会不会逼你表态?
  3. MidTurn 压缩之后,摘要是不是仍在队尾?有没有测试覆盖"上下文插到摘要之后"这个错误?
  4. history_version 是不是只在整表替换时加一?追加路径有没有误碰它?
  5. 远端失败时,系统会不会悄悄改走本地?fallback 参数传的是什么?

存储侧

  1. 写入顺序是不是先原文后镜像?有没有任何路径反过来?
  2. 镜像投影失败是 warn 还是 error?会不会阻断写入?
  3. 恢复路径有没有可能读到派生表?列表路径有没有 fallback?
  4. 压缩产生的三类条目有没有落进原文,而不只是活在内存里?

约束说明

约束一:行号是一次快照。 全部对应 openai/codex 仓库 commit 4f39251a01,核对日期 2026-08-22。用于理解调用链形状,不是可引用的长期指标。

约束二:九宫格是教学编排。 源码里没有九套并列函数,实际是两套入口加一张分发表。把九宫格当成九份实现去读源码,会找不到对应物。

约束三:flush 不等于 sync。 源码只调了文件缓冲的 flush,没有 sync_all。断电而非进程被杀的场景下,最后几行的持久化保证由操作系统决定,别把它当成已落盘。

约束四:默认组合会变。 TokenBudget 默认关、RemoteCompactionV2 默认开 → 默认走远端 v2。特性开关的默认值随版本演进,判断当前行为要先读代码里的默认值,不要依赖本文。

约束五:镜像的价值边界。 SQLite 存的是列表和筛选字段,不是全文索引。任何"从镜像反推对话内容"的需求都超出了它的设计范围。


实践提示

提示一 · 先定顺序,再谈优化

任何同时存在原文和派生视图的系统,先写死一条规则:派生可以落后,不能超前。写的时候先落原文,读的时候允许降级。这条不需要任何框架,今天就能改。

提示二 · 外部数据用穷尽 match,不用白名单加兜底

外部返回的结构化数据形状会变。穷尽匹配的好处是,形状一变编译期就逼你重新表态,而不是让未知类型静默流进下游。

提示三 · 把时机和实现拆成两个维度

加法变乘法。新加一种提供方只改分发器的一行,不动判定入口;新加一种触发方式只加一个枚举值,不改分发逻辑。

提示四 · 给"摘要位置"写一条测试

摘要在队尾不是一个审美选择,它是训练约束。MidTurn 路径上任何把上下文插到摘要之后的行为,都会让模型表现得像没看见这次压缩——这类 bug 只有在长对话里才暴露,值得专门写测试。


一句话 Takeaway

时机和实现拆开,九种组合共用两套入口;远端结果进门先过穷尽滤网,中途压缩的摘要必须停在队尾;空窗是压缩的一种产物,回忆不带走。JSONL 挡住历史丢失,SQLite 挡住列表太慢,回填挡住镜像变空,fallback 挡住镜像撒谎——任何一层都可以失败,默认让列表降级,不要让恢复降级。


来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)

本页文字稿与音频为同源二次演绎,音频由文本合成,措辞以本页为准。