第十二章结尾留下一个问题:所有子系统最终都沉淀为同一组事实,但这组事实以什么形态抵达模型和用户? 本章就来回答这个问题,讨论整套 harness 中直接面向用户的部分——transcript(对话记录/文稿)。
一个看似平凡的问题:用户向上滚动鼠标,想回顾十分钟前 agent 说过什么;打开 resume 列表,想凭几行预览认出上周的会话;外接程序想只读历史、不触发任何执行。这些需求都指向“把过去展示出来”,却没有任何一个应该直接去读第十章那份 canonical rollout——JSONL 不是给人看的,给模型看的历史也不是给人看的。
本章的核心观点是:transcript 从来不是一份文件,而是同一组事实针对不同读者的投影(projection)。 模型读一种 transcript,正在看屏幕的用户读一种,翻看历史的用户读一种,resume 列表读一种,外接程序通过协议再读一种。它们共享同一段过去,却各自拥有独立的结构、新鲜度承诺和成本预算。理解这些投影怎样分工、怎样保持一致、怎样在终端宽度变化和历史无限增长时仍然成立,就理解了 transcript 设计的全部难点。
13.1 谁在读,决定了 transcript 长什么样
先看一个具体场景。同一段会话——用户让 agent 修 bug,它推理、调工具、等审批、改文件、汇报结果——在系统里至少同时存在六种“读法”:
| 读法 | 读者 | 它需要的形态 | 新鲜度 | 完整度 |
|---|---|---|---|---|
| 模型 transcript | 下一次采样的模型 | 严格有序的 response item,可直接序列化进请求 | 实时 | 受 compaction 裁剪的“工作历史” |
| canonical rollout | 恢复器、审计者 | 带类型和时间戳的只追加日志 | 持久化屏障后 | 最完整的事实档案 |
| live transcript | 盯着屏幕的用户 | 正在滚动出现的文字、spinner、进度 | 每个 delta | 明确是临时的 |
| committed transcript | 回看的用户 | 成型的对话、命令、补丁等展示单元 | item 完成后 | 可分页、可重渲染 |
| preview transcript | resume 列表里的用户 | 最新几句话,几行以内 | 打开列表时 | 刻意截断 |
| 协议 transcript | IDE、自动化等外接程序 | 稳定 schema 的 thread / turn / item | 按需读取 | 由游标分页控制 |
这六种 transcript 可以基于同一段事实生成,但它们的设计目标完全不同:
- 模型 transcript 追求可直接作为模型输入:顺序、配对、模态都必须符合模型协议;
- rollout 追求可恢复、可审计:长期兼容,只追加;
- live transcript 追求显示流畅:允许临时形态、允许后来被替换;
- committed transcript 追求可读、可重排:终端宽度变了还能重新排版;
- preview 追求读取成本低:为一行摘要不值得扫描整份历史;
- 协议 transcript 追求稳定和无副作用:只读,不启动 LOOP。
flowchart TB
F["同一段事实:一次 turn 中发生的所有 canonical item"]
F --> M["模型 transcript<br/>下一次采样的输入"]
F --> R["rollout JSONL<br/>canonical 事实档案"]
F --> L["live transcript<br/>delta 驱动的临时画面"]
F --> C["committed transcript<br/>展示单元序列"]
F --> P["preview transcript<br/>几行摘要"]
F --> A["协议 transcript<br/>thread / turn / item"]
R --> S["thread store 投影"]
S --> A
A --> C
L -.->|"item 完成后合并"| C
这张图值得和第十章的图对照看。第十章讲的是写侧:事实怎样被筛选、落盘、设屏障。本章讲的是读侧:同一批事实怎样以不同形态到达不同读者。写侧只有一条 append-only 时间线,读侧却故意分叉成多条投影——因为“一个通用 transcript 满足所有读者”的设计,最后会让每个读者都拿到错误的东西。
[!important] Transcript 与 rollout 的关系 Rollout 是 transcript 的事实来源,但它本身不是 transcript。二者面向的用途不同:rollout 面向恢复与审计,采用只追加日志格式;transcript 面向阅读,必须转换成具体读者需要的结构。附录 B 逐行描述了 rollout 的磁盘格式,本章不再重复格式细节,只讨论它怎样被读出、转换成读者需要的形态。
13.2 模型的 transcript:以历史尾部为“当前位置”
最特殊的一种 transcript,是模型自己读的那一种。
第五章讲过 LOOP 没有程序计数器:每一轮都从“读完整段历史”开始,在“历史末尾追加新内容”结束。这句话里的“历史”,就是内存中一份严格有序的 item 序列——最旧的在最前,最新的在最后。它是 LOOP 每一步推理使用的工作数据,也是第四章讨论的上下文管理所维护的核心数据。
这份 transcript 有几个不面向人类阅读的性质。
第一,它是工作历史,不是完整档案。 Compaction 会用一份 replacement history 替换它(第 4 章、第 10 章),rollback 会从有效历史中裁掉后缀。早期事实仍然完整保留在 rollout 中,但模型 transcript 只保留“此刻模型应该知道什么”。它还维护一个历史版本号:每当历史被重写(压缩、回退),版本号递增——任何持有旧历史形态的消费者,都可以据此知道自己持有的副本已经过期。
第二,同一份历史被多种读者以写时复制方式共享。 采样请求构造、上下文差分、扩展的只读快照可能同时需要历史。序列被包在引用计数的共享容器里,只读访问不复制;只有某个读者真的要修改(比如压缩替换)时才复制出自己的副本。这是一个简单但重要的工程判断:transcript 最常见的访问是读,数据结构应该让读操作开销小,让写操作的成本在修改时明确发生。
第三,发送给模型之前还有一道“规范化”处理。 内存历史里可以带少量内部控制标记(某条 user message 是不是上下文注入、某个 item 属于哪个 turn),但对外暴露给模型或扩展的快照视图会过滤掉这些控制类内容;真正构造请求时,还要做模态剥离、工具调用与结果的配对校验。换句话说,模型 transcript 在内存里的形态,和它在请求中的形态,并不是逐字节相同的——中间始终有一层按模型协议进行的转换。
flowchart LR
H["内存工作历史<br/>有序 item + 内部控制标记"] --> V["只读快照视图<br/>过滤控制类内容"]
H --> D["上下文差分<br/>与 reference 基线比较"]
V --> N["请求规范化<br/>配对 / 剥离 / 截断"]
N --> REQ["本次采样请求"]
第四章说“模型的工作记忆变薄了,但会话的完整档案还在”。这里还有第三种东西:屏幕上的对话既不是工作记忆,也不是完整档案——它是下一节要讲的第三种 transcript。
一个常见的设计错误,是让一份数据同时承担这三个用途:拿模型历史直接渲染界面,会发现里面混着注入指令、工具配对和被压缩掉的内容;拿界面记录恢复模型,会发现 delta、动画和经过编辑的展示形态无法可靠 replay。三者必须在设计之初就明确区分。
13.3 人的 transcript:从 canonical item 到展示单元
人读到的 transcript,是一条多级投影链的末端产物。完整链路是这样的:
flowchart LR
J["rollout<br/>canonical JSONL"] --> ST["thread store<br/>可查询投影"]
ST --> API["thread/read<br/>turns/list · items/list"]
API --> TI["ThreadItem<br/>稳定的协议对象"]
TI --> CELL["cell<br/>展示单元"]
CELL --> LINE["按宽度换行后的行"]
LINE --> ROW["终端 / 界面上实际的行"]
每一级都在丢东西、也在加东西:
- rollout → thread store:只追加日志被物化成 thread、turn、item 的可查询结构(第 10.4.1 节)。投影可以落后、可以重建,事实源不变。
- thread store → 协议对象:通过
thread/read、thread/turns/list、thread/items/list按游标取出,schema 是公开稳定的;存储用 legacy 还是 paginated 模式,被挡在这层后面。 - 协议对象 → cell(展示单元):用户消息、assistant markdown、plan、reasoning 摘要、命令执行、文件改动、MCP 调用、review 结论……每一类 item 变成一种自己带渲染逻辑的展示单元。
- cell → 行 → 屏幕:按当前终端宽度做 markdown 解析、语法高亮和换行,最终显示在屏幕上。
13.3.1 Item 不等于 cell
需要特别注意的是,第三级转换不是一一对应:
- 一次工具行为在 rollout 里至少有“调用”和“结果”两条 response item,界面上却可能合并成一个命令单元,状态经历 in-progress → completed/failed/declined;
- 一条 assistant 消息既要进入模型历史(message item),也要进入 UI 历史(完成的展示 item),附录 B 解释过这两类记录为何要分别持久化;
- 反过来,并不是所有 item 都会产生可见 cell——注入的上下文、控制类 user message、计量事件都不显示;
- 一条带隐藏控制指令的 assistant markdown(例如供宿主执行的 Git 动作指令块),在渲染时会先被解析并剥离隐藏部分,用户只看得到可见正文。
因此 cell 是一种编辑过的视图,不是 item 的复制。典型的编辑决策包括:
- raw reasoning 默认隐藏,只展示 summary;用户显式切换后才展示原始内容;
- 超长工具输出截断,保留头尾和“还有 N 行”的提示(第六章的结果回灌本身也有截断策略);
- 终端不支持的模态直接省略并告警:例如音频类用户输入无法在纯文本终端呈现,系统不会显示一个不存在的内容,而是留下明确 warning;
- 被拒绝的命令、失败的调用也会显示,但带上各自的状态样式——transcript 必须同时记录“没做成”和“做成了”,否则用户无法理解决策(第八章、第九章的可观察性要求一直延伸到展示层)。
13.3.2 读不是 resume
thread/read 系列协议有一个重要语义:只读历史,不恢复执行。
- 不需要重建模型连接、工具 runtime、审批通道;
- 不触发 LOOP,不产生新的采样;
- 线程状态可以报告为
notLoaded——“我知道这份历史存在,但它当前没有运行时实体”; - 连已归档的线程也能读。
第十章把 resume 定义为“replay 后创建一套全新运行时资源”。读 transcript 则停在更早的一步:只构造展示材料。这样前端可以在 resume 列表里预览、在 IDE 侧栏里陈列历史、被自动化程序批量分析,而不必付出启动一个 agent 的代价,也不会意外触发任何副作用。这与第十章的核心原则一致:replay 不是 re-execute。
13.3.3 为什么需要专门的协议对象
理论上前端也可以直接读 rollout JSONL。Codex 没有采用这种做法(对通用前端而言),原因在第十章和附录 B 中已经说明:
- rollout 是内部持久化协议,字段会随版本演进,读者要自己处理 alias、损坏行、legacy 事件名;
- 一条 JSONL 记录服务多个 reducer(模型历史、UI、session 状态、索引),前端需要的只是其中 UI 那一支;
- legacy 模式靠一组按内容类型分散的 UI 事件重建界面,paginated 模式靠统一的 item 完成记录——不该让每个前端各自实现这两套解释器。
协议层把这些差异吸收掉:无论底层是哪种历史模式,前端拿到的都是同一套 thread / turn / item 对象。这样存储格式可以独立演进,只要读模型保持稳定,前端就不受影响。
13.4 Live transcript:流式显示是临时的,定型 cell 才是依据
现在看技术细节最多的一种 transcript:正在生成中的对话。
模型流式输出时,用户期望文字逐段实时出现、表格逐步成形、长命令显示 spinner。但第二章已经定过纪律:item 是权威数据,delta 只是用于加速显示的临时数据。 Transcript 设计必须同时满足“流式显示要流畅”和“临时数据不能作为历史依据”。
Codex 的做法是把画面分成两层:
- active cell(活动单元):位于底部、可以原地变化的内容,接收 delta,负责显示 spinner 和进度;
- transcript cells(已提交单元序列):上方已经定型的历史,每个单元都来自一个完成的 item。
13.4.1 一条消息的一生
一条普通 assistant 消息从产生到定型,经历四个阶段:
1. delta 到达
→ markdown 收集器按"换行"收束完整行(不足一行的内容先保留,不渲染结构不完整的部分)
2. 完整行进入 FIFO 提交队列
→ 队列只从头部取,严格保序
→ 策略层根据队列压力决定每帧输出多少行(自适应 drain)
3. 已输出的行以"流式消息单元"进入 scrollback
→ 用户看到文字稳定地向上滚动
→ 表格、代码围栏等需要完整结构的内容会被 hold back,
不在结构残缺时提前渲染
4. item 完成(consolidation,定型合并)
→ 尾部那一整段临时单元被替换成一个 source-backed 的 markdown 单元
→ 这个单元持有原始 markdown 源,成为今后所有重渲染的唯一依据
第四步是整个设计的关键。流式期间为了动画产生的那些临时单元,在消息完成的瞬间全部作废,由一个持有原始 markdown 的正式单元替换。定型之后,这个 source-backed 单元是 markdown 源的唯一持有者——未来窗口缩放、重新进入查看,都以它为准重新渲染,而不是去拼接流式阶段输出过的行。
sequenceDiagram
participant M as 模型流
participant C as markdown 收集器
participant Q as FIFO 行队列
participant A as active cell
participant T as transcript cells
M->>C: delta(可能只是半行)
C->>Q: 换行收束后的完整行
Q->>A: 按帧预算输出
A-->>T: 临时行向上进入 scrollback
Note over A,T: item 尚未完成,一切皆可替换
M-->>A: 流结束
A->>T: consolidation:临时段 → 一个 source-backed 单元
这和第十章的持久化屏障形成精确对应:
| 持久化侧(第 10 章) | 展示侧(本章) | |
|---|---|---|
| 易失形态 | delta、进度事件不落盘 | 临时流式单元不持有权威数据 |
| 定型时刻 | 完整 item 越过持久化屏障 | consolidation 用 source-backed 单元替换 |
| 恢复依据 | canonical rollout | transcript cells 里的原始 markdown |
| 崩溃/切换后 | 从完整 item 重建,不从不完整的 delta 猜测 | 从 cell 重新渲染,不从屏幕行回收 |
13.4.2 Transcript overlay 里的 live tail
TUI 提供一个独立于主视口的全屏 transcript 视图(Ctrl+T),用于完整回看。它在已提交单元之外,还需要显示“当前正在生成的那一段”。这段只用于渲染的 live tail 有一个实际问题:重新计算换行的开销很大,不能每一帧都重建。
解法是带缓存键的投影。live tail 只在缓存键变化时重算,键由四个维度组成:
- 终端宽度(换行结果依赖宽度);
- active cell 的修订号(原地变更过几次);
- 流是否仍在继续(影响段落间距等细节);
- 动画 tick(spinner、微光效果随时间变化)。
这四个维度精确回答了“什么情况下渲染结果可能变”。把变化条件显式化,而不是每帧全量重排,是流式 UI 在长对话里保持流畅的关键。注意第 4 维:时间相关的渲染被单独隔离,这样静态内容的缓存不会因为一个 spinner 转动而整体失效。
13.4.3 Live 层不对外提供权威数据
Live transcript 的所有产物都是临时的:
- 不会写进 rollout(落盘的是完整 item);
- 不会被 transcript overlay 之外的任何持久化路径引用;
- 在流中断、出错或被 abort 时,可能直接消失或被中断单元替代;
- 不能反过来作为历史的来源——屏幕上出现过一行字,不等于这行字属于任何 item。
这把第二章的协议纪律一直贯彻到显示层:用户可以实时看到流式内容,但只有完成的 item 才能作为历史依据。
13.5 回看长历史:有界加载与滚动分页
Live transcript 只覆盖“此刻”。用户打开一个持续了几个月、包含几十万条 item 的长线程时,系统面对一个硬性限制:不可能、也不应该一次性加载全部历史。
13.5.1 初始 hydration:只加载首屏所需
恢复或打开线程时,历史加载是分层的:
- 先读最近若干个 turn 的元信息(约五个,不含每个 turn 的 item);
- item 按页取,每页有固定上限(约一百条);
- 扫描总量设上限(约四页),防止极端线程导致启动超时或卡顿;
- 更早的内容一律等用户向上滚动时再取。
更进一步,初始加载量还与终端实际能保留多少行 scrollback 对齐:如果配置/终端探测给出了 reflow 行数上限,初始 hydration 会同时按“行数预算”和“item 预算”提前停止。加载和渲染那些屏幕本身留不住的历史,没有任何价值。
而当用户显式打开完整 transcript 视图时,加载范围才切换成 Complete:通过分页持续加载直到最早的记录,而不是一次读取全部内容。
flowchart TD
A["打开线程"] --> B["读取最近 N 个 turn 的元信息"]
B --> C["按页取最新 item(desc)"]
C --> D{"行数 / item 预算用尽?"}
D -->|"是"| E["停止:已经够首屏与重排"]
D -->|"否"| C
E --> F["用户向上翻到边界"]
F --> G["按游标加载更早一页"]
G --> H["去重后插入到历史头部"]
H --> F
13.5.2 向前分页:方向、归位与去重
“加载更早的历史”听起来简单,实现上有三个需要处理的问题。
顺序问题。 服务端为了“先拿到最新内容”,分页默认按时间倒序返回;但展示必须是正序。客户端在合并时把整页反转,逐条插入到所属 turn 的头部;找不到所属 turn 时,还要先按 turn 游标补拉更早的 turn 元信息,再把 item 放进去。最终屏幕上顺序始终稳定,无论底层取了几批、方向如何。
重复问题。 游标可能在边界处返回重叠 item;网络重试也可能让同一页到达两次。每个 item 有稳定 ID,插入前先查同 turn 内是否已存在,重复的丢弃。游标本身也有“已见过的游标集合”,防止服务端游标形成环路导致无限翻页。
并发问题。 “加载更早一页”是一个有显式状态的异步操作:进入时标记 in-flight,重复触发直接忽略;响应回来时校验“这还是我当时请求的那个游标吗”,不匹配则整页丢弃。线程在等待期间继续接收新事件,不允许把旧页面的内容错误地合并进新状态。
13.5.3 四种边界状态要诚实
向上翻页的终点不是只有“到了”和“没到”两种:
| 状态 | 含义 | 界面表达 |
|---|---|---|
| 加载中 | 一页更早历史在路上 | “loading older history…” |
| 到头 | 游标消失,没有更早 item | “start of history” |
| 不支持 | 当前 thread store 不支持 item 分页 | 协议明确返回 unsupported,而不是悄悄给空 |
| 空 | 线程存在但没有可展示内容 | 明确的空态提示,而不是空白屏幕 |
把“不支持”和“没有了”区分开尤其重要:前者是能力缺失,后者是事实终点;混用二者会让前端在老版本服务端面前错误地宣称“已经到历史起点”。
这些状态与第九章对等待状态的处理原则一致——把系统此刻确切知道什么、不知道什么展示出来,比显示一个看似完整的画面重要。
13.6 Resume 列表预览:为“六行字”做的预算设计
要求最严格的读场景可能是 resume picker:一个列表里有几十上百个历史线程,每一行只需要展示最新的几句话。用户靠这几行认出“上周那次登录修复”是哪个线程。显然不应该为此加载完整 transcript,于是 preview 有自己独立的一套预算。
行数预算: 每个线程最多展示若干条最新对话行(当前是 6 行),只取用户和 assistant 的文本,其他类型一律不要。
页数预算: paginated 模式下,第一页只取 6 条;不够再取标准页,总扫描量同样受四页上限保护。
字节预算: legacy 模式下,本地直读 rollout 时只倒序扫描文件尾部一个有界区间(约 1MB)。扫描器从文件末尾反向按行读取,超出预算就停——即使一个 turn 单条记录大到覆盖整个预算,也不会把整个文件拖进内存。
线程预算: 磁盘扫描放在阻塞线程池里执行,不卡住 UI;读不到、扫不全、格式太老,都有明确的降级路径。
flowchart TD
A["渲染 resume 列表一行"] --> B{"历史模式?"}
B -->|"paginated"| C["items/list 倒序取页<br/>6 → 100 → ...≤400 条扫描"]
B -->|"legacy + 本地文件可读"| D["反向扫描 rollout 尾部<br/>≤ 1MB"]
B -->|"legacy 其他情况"| E["服务端初始 hydration"]
D --> F{"尾部遇到 rollback marker?"}
F -->|"是"| G["放弃直读,转服务端 hydration"]
F -->|"否"| H["取最新 6 条文本行"]
C --> H
E --> H
13.6.1 为什么扫描尾部遇到 rollback 要直接放弃
这是整个 preview 设计里最能体现“诚实”原则的一处。
第十章讲过,legacy rollback 只追加一条“忽略最近 N 个 turn”的 marker,物理记录并不删除。倒序扫描文件尾部时,很容易先扫到被回退的对话,再(可能因为 1MB 预算截断而永远扫不到)那条 marker。如果直接展示扫到的内容,用户会在 resume 列表里看到模型实际上已经“忘掉”的对话,甚至据此认错线程。
系统的选择是:只要在扫描区间内遇到 rollback marker,这次直读结果全部作废,转而走服务端 hydration——后者有完整的 replay 逻辑,能算出有效历史。
宁可不给预览,也不给一个可能属于“被作废时间线”的预览。
这与第十章处理悬空工具调用的原则一致:当低成本的读取方式无法证明自己正确时,改用具备完整语义的读取方式;仍然不可行时,承认无法确定。成本优化不能以牺牲“显示的是否为有效历史”为代价。
13.6.2 预览也必须经过展示层转换
即使只是几行字,assistant 文本依然要经过和正文相同的预处理:剥离隐藏控制指令、重写内联可视化占位符、按 trim 后的非空行计数。预览和全屏 transcript 是不同的投影,但共享同一套“什么是可见文本”的定义。否则用户会遇到最糟糕的体验:列表里看到的最后一句话,点进去之后消失了。
13.7 宽度不是历史的属性:terminal reflow
前面的讨论默认了一个前提:终端宽度不变。但 cell 渲染成行时依赖终端宽度,而宽度会变——用户拖拽窗口边缘、字体缩放、从笔记本外接到显示器。
终端程序和 GUI 有一个根本差异:写进 scrollback 的行,其所有权就交给了终端模拟器。应用并不持有一棵持久 widget 树,已经输出到屏幕上的行不会自动重新换行。80 列宽时折成三行的段落,窗口拉宽到 160 列后仍然是三个断行的短行。
Codex 的做法是:把内存里的 transcript cells 当作唯一权威数据,宽度变化时清掉 Codex 拥有的 scrollback 区域,用新宽度从 cell 重新输出全部行。 这就是 reflow(重排)。
13.7.1 重排的调度细节
听起来只是“重新显示一遍”,实际有几个细节需要处理。
防抖。 拖曳窗口边缘会在几十毫秒内产生几十次 resize 事件。重排以 trailing debounce 调度(约 75ms),持续的 resize 不断推迟截止时间,最终只在拖曳停下来的最终宽度执行一次,而不会在拖动过程中的每个中间宽度上重复执行。
观察宽度与已重建宽度分离。 终端可能在重排之后才“补发”最终尺寸。因此状态里同时记录“最近观察到的宽度”和“真正按它重建过的宽度”:下一次绘制时二者不一致,就再补一次重排。不能假设“我见过这个宽度”等于“这个宽度下的历史已修复”。
只长高也要重排。 窗口变高不会改变换行,但会把内联视口上方原本被遮住的行暴露出来,这些行同样要从 cell 重新恢复。
流式期间的竞态。 如果重排发生在输出正在流式生成、临时单元还没定型时,按临时形态重排没有意义——这些单元马上就要被 consolidation 替换。因此系统记录“流式期间做过重排”,在消息定型、source-backed 单元就位后,强制再做一次最终重排。只做前者,窗口在长回答期间被拉宽过,回答结束后上半屏会永远留在旧宽度的断行里。
sequenceDiagram
participant U as 用户
participant T as 终端
participant R as reflow 状态
participant C as transcript cells
U->>T: 拖曳边缘(连续 resize)
T->>R: 观察宽度 88 → 120 → 143
R->>R: 防抖截止时间不断后移
Note over R: 停止拖曳 75ms 后
R->>C: 按 143 列从 cell 重建
C-->>T: 清区域并重新输出行
T->>R: 迟到的最终尺寸 150
R->>C: 观察≠已重建,再补一次
13.7.2 重排行数必须有上限
从 cell 完整重建看起来最稳妥,但终端本身的 scrollback 是有容量上限的:超过容量的旧行终端自己都不留。重建一万行用户根本滚不回去的内容纯属浪费,还会让交互式 resize 卡顿。
因此重排有一个按终端类型设置的行数上限(例如 VS Code 内嵌终端约 1000 行,WezTerm 约 3500 行,Windows Terminal 约 9000 行,Alacritty 约 10000 行;识别不出的终端走保守默认值),也可以由用户显式配置固定上限或关闭上限。这个数字有意与终端自身的 scrollback 保留量保持一致:重建的范围不超过宿主能保留的范围。
13.7.3 一个通用原则:语义归语义,渲染归渲染
Reflow 设计体现的原则并不只适用于终端场景:
任何依赖观察条件(宽度、主题、字号、语言、模态支持)产生的渲染产物,都不应该被当成历史本身存储;历史只存语义单元,渲染在消费时按需重做。
这和模型侧“工作历史 + 请求时规范化”、存储侧“canonical log + 可重建投影”是同一个原则在本章的第三次出现:
- 事实层保持稳定、语义化、与表现无关;
- 表现层可以随时丢弃、随时从事实层完整重建;
- 重建必须有预算,预算对齐宿主的真实容量。
如果把“80 列下折好的三行文本”当成历史存下来,历史记录就会永久依赖生成它时的终端宽度,之后在其他宽度下都无法正确显示。
13.8 对外读取协议:读接口的设计
前面讨论的是 TUI,现在把范围扩大到整个宿主生态(第十一章讲过 TUI、IDE、CLI、自动化客户端都是 host)。外接程序消费 transcript 依赖的是 app-server 的公开读取接口,它有三条互补的 RPC:
| RPC | 回答什么 | 典型用途 |
|---|---|---|
thread/read |
这个 thread 的元信息;可选带上 turns | 判断身份、状态、来源、cwd;includeTurns 时顺带取历史(legacy) |
thread/turns/list |
按游标分页列出 turn 元信息 | 建立时间线、turn 级导航 |
thread/items/list |
按游标分页列出 item,可限定单个 turn | 实际内容的增量加载、全文检索式浏览 |
这套读模型的设计要点:
游标分页,双向可用。 响应同时带“向前”和“向后”游标,客户端可以从最新一页向旧翻,也可以从任意点向新翻;页大小服务端会钳制在上下限之间,防止超大 limit 导致查询负载过高。排序键是创建时间加 ordinal 的复合键——时间可能相同,逻辑顺序必须确定(第十二章讲 rollout-trace reducer 时得出过同样结论:wall clock 不能提供可靠全序)。
只读、无副作用、可读到归档。 如 13.3.2 所述,读取不加载线程运行时;已归档线程同样在读取范围内。错误语义明确区分:线程不存在、存储不支持分页(unsupported)、参数非法。
公开 schema 与内部格式解耦。 RPC 对象走 app-server 的公开协议约定(camelCase、稳定枚举、显式可选字段),rollout 的 snake_case 内部记录(附录 B)不直接出现在线协议上。内部格式可以按持久化兼容性规则演进,公开读模型按自己的节奏版本化。
13.8.1 一种刻意不落盘的 transcript:realtime 语音转写
语音实时会话里还有一类名字就叫 transcript 的数据流:thread/realtime/transcript/delta 和 .../done,分别携带实时转写片段和一段转写的最终全文。
但协议明确规定:它们是临时传输事件,不是 ThreadItem,不会被 thread/read、resume 或 fork 返回。语音转写有自己的实时界面和生命周期;只有当转写内容作为真正的用户消息进入对话时,它才通过正常的消息通道成为历史。
这是“读者和实时性要求决定投影形态”的典型例子:同样是“逐字稿”,实时字幕与可回放对话是两种东西,混用会迫使存储承担语音识别级别的高频写入,也会让读取接口返回大量没有对话语义的内容。
13.8.2 几种 JSONL 的最终对照
附录 B 开头区分过三种 JSONL,本章再补充 TUI 本地的调试日志,一共四种:
| 数据 | 一行是什么 | 消费者 | 是 transcript 吗 |
|---|---|---|---|
| rollout JSONL | canonical 持久化记录 | replay、投影、审计 | 事实源,不是展示 |
| app-server stdio JSONL | 一条请求/响应/通知 | 各类 host | transcript 的传输通道 |
| rollout-trace | 更细的诊断证据 | 离线排障(第 12 章) | 诊断投影,显式开启 |
| TUI 本地会话日志 | 一条 inbound 事件或 outbound 操作 | 本地调试 | 调试镜像,不是历史 |
第四种需要特别说明:TUI 会把自己收到的事件和发出的操作另记一份本地日志(文件以仅 owner 可读写的权限创建),用于复现“界面为什么这样反应”。它是一份旁路镜像,写入失败只产生 warning;它不参与任何历史重建。一个系统里同时存在四种“一行一个 JSON”的文件而不混乱,关键在于明确区分每条流的读者、可靠性承诺,以及是否可以作为事实源。
13.9 两代历史,同一套阅读纪律
Codex 的线程存储经历过两代形态(附录 B 有完整字段对照),transcript 层必须同时支持二者:
| Legacy 模式 | Paginated 模式 | |
|---|---|---|
| UI 事实怎么存 | 按内容类型分散的事件(user_message、agent_message、patch 结束、MCP 结束……) | 统一的 item 完成记录,内含领域化 TurnItem |
| 分页能力 | 无结构化游标;本地可倒序扫描文件尾部 | ordinal 复合键 + 双向游标 |
| 预览路径 | 服务端 hydration 或 1MB 有界倒扫 | items/list 倒序分页 |
| 完整回看 | thread/read(includeTurns) 一次性带回 |
turns/items 两条分页链 |
| rollback 表达 | marker,读时从有效历史排除 | revert 新 rollout + 指针切换 |
客户端在每个分叉点都先判断“这个线程是哪种模式”,然后走对应路径;而在分叉之上,所有路径最终汇聚到同一套 cell 类型和渲染逻辑。存储升级在后台进行(旧线程可以被渐进迁移到 paginated),用户和上层渲染代码都不需要感知这一过程。
这里体现了读模型的另一个价值:它隔离了存储格式演进对上层的影响。 第十章说“选择 event sourcing,就等于选择长期维护 replay 语义”;transcript 投影把这类兼容性工作的大部分集中到一处——只要读模型还能把旧形态翻译成当前的 thread/item/cell,上层的交互、主题、快捷键、渲染优化就不需要为存储格式的变化而修改。
阅读旧记录时还有一条原则值得复述(附录 B 的读取规则之一):绝不能把 legacy 的分散事件和 paginated 的 item 完成记录当成两份独立内容同时渲染,否则同一条消息会在界面上出现两次。模式决定解释方式,这是投影处理的第一个分支判断,不能简单地把两套内容都显示出来。
13.10 展示层的正确性:transcript 也可能误导用户
投影意味着选择,选择就可能误导。Transcript 的失败大多不是技术崩溃,而是在没有任何报错的情况下,展示了一个不代表当前有效状态的内容。
13.10.1 三种最常见的误导情况
把临时内容当成定型内容。 流式输出中的不完整内容被截图、被日志采集、被下游程序当作完整回复引用。防线在 13.4:临时单元与 source-backed 单元身份不同,任何持久化和对外语义只承认定型后的 item。
把已作废的历史当成有效历史。 Rollback、revert、compaction 之后,物理记录还在,但模型当前使用的有效历史已经改变(第十章)。预览倒扫遇到 marker 放弃、完整回看走有效历史 replay,都是在防止这类错误。注意 compaction 不删除早期事实,但它改变的是“工作历史”——transcript 的完整历史视图仍然应该前后一致:早期探索确实存在过,而某个时刻之后的工作历史由摘要接管。
把“不知道”显示成“没发生”。 第十章那个典型场景:工具调用已发出、结果未落盘时崩溃。Transcript 上这一格应该显示什么?不是“成功”,也不是“失败”,而是未知/中断,并保留调用事实。显示“失败”会诱导重试(可能重复发邮件、重复发布);显示“成功”则隐瞒了风险。同样,被策略拒绝的命令要显示为被拒绝,审批超时要显示为超时——每一种 harness 状态都必须在 transcript 上被准确区分和显示。
13.10.2 Transcript 回答的是“记录里发生了什么”,不是“世界现在什么样”
这一点第十章已经说明,在展示层同样成立。文件后来被用户手动改过、部署被别的系统回滚、数据库状态早已变迁——transcript 不会随之更新。它是当时观察结果的记录,不是实时状态页。
Transcript 设计必须严格限定自己表达的范围:可以显示命令当时退出码为 0,但不承诺文件现在仍是那个内容;可以显示审批曾被批准,但不暗示授权仍然有效。需要了解世界现状时,正确做法是发起新的观察(新的工具调用、新的 turn),而不是把历史记录伪装成当前状态。
13.10.3 隐私:transcript 是敏感数据的集中地
一份完整 transcript 几乎包含用户工作过程的全部记录:源码片段、路径、命令、报错、内部 URL、reasoning。第十二章的隐私分层在这里直接适用,并且展示层有自己的责任:
- 本地历史和本地调试日志默认只属于当前用户(文件权限、存储位置都按此设计);
- transcript 的外发(分享、反馈、导出)是一次需要 consent 的显式动作,分享前要能检查内容;
- 默认展示摘要、隐藏 raw reasoning 等选择不仅是降噪,也是最小披露;
- 外接程序通过协议读到什么,受同一套权限边界约束——能读 transcript 不等于能读诊断 trace。
如果因为 transcript 只是对话记录就降低保护要求,是对其敏感性的低估:它往往比源代码本身更能还原一个团队的完整工作过程。
13.11 常见失败模式
| 失败模式 | 表面现象 | 根因 | 更好的做法 |
|---|---|---|---|
| 拿 rollout 直接渲染 | 界面混入注入指令、控制事件和重复内容 | 把事实源当成读模型 | 经协议对象 → cell 的投影链,存储模式差异在协议层吸收 |
| 拿模型工作历史渲染 | 压缩后历史“丢失”、注入片段可见、工具调用配对细节直接暴露 | 混淆工作记忆与人读视图 | 三种 transcript(模型/档案/界面)显式分离 |
| 从屏幕行回收历史 | resize 后错乱、崩溃后重复或丢字 | 把渲染产物当成权威数据 | cell 持有原始语义内容,行是可丢弃的缓存 |
| 一次性加载全部历史 | 长线程打开时长时间无响应、内存暴涨 | 读侧没有预算 | turn/item 双层游标分页,初始只加载首屏所需 |
| 预览扫到 rollback 仍展示 | 列表显示已被作废的对话 | 低成本读取无法证明内容有效 | 遇 marker 放弃直读,升级到 replay;不确定就不显示 |
| delta 落盘或被持久引用 | 崩溃后留下无法判定完整性的消息 | 模糊了临时与定型边界 | consolidation 屏障;只承认定型后的 source-backed 单元 |
| live tail 每帧全量重排 | 长回答时界面随输出越来越卡 | 没有缓存键,静态内容反复重算 | 按宽度/修订号/流状态/动画 tick 四维失效 |
| 流式期间 resize 后不补排 | 回答结束后上半屏永远是旧宽度 | 漏掉临时形态→定型形态的二次重排 | consolidation 后强制一次 source-backed reflow |
| 重排不设行数上限 | 拖窗口时明显卡顿 | 重建了宿主都留不住的行 | 上限对齐终端 scrollback 容量 |
| 分页不防重、不防环 | 滚动时消息重复或无限加载 | 无 item ID 去重、无游标环路保护 | 稳定 ID 插入判重 + seen-cursor 集合 |
| “不支持”被当成“到头了” | 老服务端上前端谎称历史已到底 | 混淆能力缺失与事实终点 | unsupported 作为独立状态表达 |
| 读取触发执行 | 仅预览历史却启动了 LOOP/工具连接 | 混淆 read 与 resume | 读路径不创建运行时、不采样、无副作用 |
| 把实时转写当历史存 | 存储被高频字幕淹没,读模型充满噪声 | 混淆实时字幕与语义消息 | transcript delta 只走临时通知,定型后才成为 item |
| 悬空调用显示为“失败” | 用户/模型被诱导重复执行危险操作 | 把“结果未知”讲成“动作没发生” | 明确显示未知/中断,先对账再决定(第 10 章) |
| 两代格式重复渲染 | 同一条消息在旧线程里出现两次 | legacy 事件与 paginated item 被同时计入 | 先按 history_mode 选解释器,再投影 |
| 预览与全文用两套“可见文本”定义 | 列表里的最后一句话点进去消失 | 投影之间不共享预处理 | 指令剥离、占位符重写等规则统一复用 |
| 把 transcript 当世界现状 | 依据旧记录判断文件/部署现状 | 混淆观察记录与实时状态 | 需要现状就发起新观察 |
这些失败可以归纳成一句话:如果一种 transcript 承担了面向其他读者的职责,最终都会提供错误的信息。
13.12 更深一层:读模型体现 harness 的架构水平
“写侧一条时间线,读侧一组投影”是典型的 CQRS / event sourcing 做法。但本章想强调的不是这个术语,而是一个更直接的观察:
一个系统能提供多少种互不混淆的读模型,取决于它对“谁在什么时候需要什么”理解得有多清楚。
回看全书,几乎每个模块的设计边界最终都体现在 transcript 上:
- 第一章的 thread/turn/step 层级,对应 transcript 的线程、时间线和 turn 结构;
- 第二章的 item 与 delta 之分,对应 committed cell 与 live tail 之分;
- 第三章的流式与重试,要求 live 层能承受重连而不污染定型历史;
- 第四章的工作记忆与完整档案之分,对应模型 transcript、rollout、界面 transcript 三者之分;
- 第五章“LOOP 没有 PC,历史尾部即当前位置”,定义了模型 transcript 的使用方式;
- 第六章的调用-结果配对,在界面上合并成一个带状态的命令单元;
- 第七章每个子 agent 是独立 thread,于是每个子 agent 也有自己可读的 transcript 和 lineage;
- 第八章的 allow/deny/ask,必须在 transcript 上以可区分的形态出现;
- 第九章的等待与审批,是 transcript 里一类明确的“挂起”状态而非空白;
- 第十章的 canonical log 与可重建 projection,是整条投影链的源头和容错依据;
- 第十一章的多样宿主,要求读能力以稳定 RPC 而非本地文件的方式开放;
- 第十二章的诊断 replay 与恢复 replay 之分,在 13.8.2 的四种 JSONL 对照里落到了文件层面。
设计良好的投影链还会反过来影响写侧的设计。正是因为读侧需要:
- 稳定的 item ID,写侧才不能只给自增行号;
- 严格的 ordinal 全序,写侧才要在时间戳之外补逻辑序号;
- “有效历史”的可计算性,rollback 才必须写成 marker 而不是物理删除;
- 可重建的索引,projection 才被设计成随时可以从日志再物化;
- 无副作用的读取,read 与 resume 才必须在协议层分家。
读模型不是事后补充的查询接口,它把“未来要怎样解释现在”这件事提前写进了持久化契约。 第十章结尾说持久化保存的是“未来继续决策所需的确定性”;本章要补充的是——其中很大一部分确定性,是为“未来向不同读者解释今天”而保存的。
最后,投影链的健康程度可以用一个简单问题检验:
随便抽走中间一级(屏幕行、cell 缓存、SQLite 投影、甚至 thread store),系统能不能从上一级无损重建?
- 行没了:从 cell 按新宽度重排——可以;
- live tail 缓存没了:从 active cell 重算——可以;
- cell 没了:从协议 item 重新投影——可以;
- thread store 损坏:从 rollout 重新物化(第十章)——可以;
- 只有 rollout 没了:一切都没了——它因此是唯一必须可靠持久化的一级。
每一级都可丢弃、可重建,只有最深处的事实源享受事务级承诺。这与第十二章“canonical 事实强保证、辅助 telemetry best-effort”的分层完全一致:可靠性投入应该随着“离事实源的距离”增加而递减,而不是在每一层上平均分配。
13.13 小结:transcript 设计的七条原则
-
一份事实,多种 transcript。 模型、rollout、live 画面、定型历史、列表预览、外接程序各有独立投影,设计目标互不相同:可直接作为模型输入、可恢复、显示流畅、可读、读取成本低、接口稳定。让一种 transcript 服务所有读者,等于让所有读者拿到错误的东西。
-
事实与表现严格分层。 rollout 存语义事实,协议层出稳定对象,cell 持展示源,屏幕行只是可丢弃的渲染缓存。任何一层的渲染产物都可以从上游无损重建;宽度、主题、动画等观察条件永远不进入事实层。
-
Item 是权威数据,delta 只是流式显示用的临时数据。 流式期间允许临时单元和可变 active cell;consolidation 之后 source-backed 单元成为唯一依据。持久化、回放、对外语义只承认定型后的 item,不完整的文本永远不能成为历史依据。
-
一切读取都必须有界。 初始加载按 turn 页、item 页和屏幕行数三重预算停止;预览有行数、页数、字节、线程四重预算;重排有对齐宿主容量的行数上限。无界读取在十万条 item 的线程面前必然失败。
-
有效历史优先于低成本读取。 Rollback、revert、compaction 都会让“物理上存在”不等于“当前有效”。当低成本路径无法证明自己读到的是有效历史时,放弃结果、升级到完整 replay;宁可不显示,也不能显示错误内容。
-
读不等于 resume,观察不等于现状。 历史读取不创建运行时、不触发 LOOP、不产生副作用;transcript 记录的是当时观察到的事实,不随外部世界自动更新。悬空结果必须显示为未知,世界现状要靠新观察获取。
-
读模型是长期兼容面,也是敏感数据面。 对外 RPC 的 schema 稳定性独立于内部存储,两代存储格式在读模型处汇合;与此同时,transcript 集中了代码、命令与推理内容,本地权限、最小披露和分享 consent 必须按“完整工作记录”的级别设计。
留给读者思考的几个问题:
- 如果一份 transcript 要在模型、用户界面、审计和公开分享四个场景复用,哪些字段应该共享,哪些必须分叉?试图用一份“通用对话格式”同时满足四个场景时,最先被迫降低要求的会是哪个场景?
- Live tail 的缓存键用了宽度、修订号、流状态和动画 tick 四个维度。如果引入“按语言切换排版方向”或“用户自定义消息折叠规则”,缓存键会怎样演化?哪些变化维度应该进键,哪些应该通过分层渲染隔离在外?
- 预览在遇到 rollback marker 时放弃直读。设想 marker 恰好落在 1MB 预算边界之外(扫不到它,但被它作废的内容在预算内),系统如何在不扫描整个文件的前提下发现风险?能否在线程元数据中记录一个“尾部是否包含已作废内容”的标记,这个标记又该由谁、在什么时刻维护?
- 分页协议同时返回双向游标,但当前界面主要向旧翻。实时协作、多设备同步等场景会不会需要“从任意位置开始获取之后的新内容”?那时 item 分页接口与通知流(第二章的 Event)应该怎样分工,才能避免用轮询分页去模拟订阅?
- 语音 realtime 的 transcript delta 刻意不进历史。但如果用户事后想要“会议完整转写”,正确的产品形态是另建一份投影,还是在 done 时把转写作为正式 item 保存?两种选择分别怎样影响存储、隐私和模型上下文?
- 终端重排选择“清掉自有区域后全量重建”。在 GUI/Web 宿主里,等价策略是什么?虚拟列表、窗口化渲染和服务端分页如何组合,才能在不依赖终端 scrollback 的情况下保持同样的“可丢弃、可重建”语义?
- 当所有中间层都可以重建时,备份、取证和跨境同步应该针对哪一层进行?只备份最底层 canonical log 在工程上最简单清晰,但用户对“导出一份能直接阅读的对话”的需求又该由哪一级、在什么时刻满足?
全书十三章走到这里,一个生产级 agent harness 的组成已经完整:运行时模型定义层级和生命周期,事件协议连接内核与前端,模型接入负责推理调用,上下文管理与 LOOP 驱动每一步决策,工具系统和多 agent 扩展行动能力,安全策略与 Human in the loop 约束行动边界,持久化保证跨断点可恢复,扩展机制支持 MCP、插件和 skill,可观测性让行为可以被解释,而 transcript 负责把这一切准确地呈现给不同读者。
最终的标准只有一条:
agent 必须能够行动,其行动能够被约束、被恢复、被解释,并且它的历史记录能够准确反映实际发生过什么。