第十三章 Transcripts:同一段历史的多种读法

第十二章结尾留下一个问题:所有子系统最终都沉淀为同一组事实,但这组事实以什么形态抵达模型和用户? 本章就来回答这个问题,讨论整套 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["终端 / 界面上实际的行"]

每一级都在丢东西、也在加东西:

  1. rollout → thread store:只追加日志被物化成 thread、turn、item 的可查询结构(第 10.4.1 节)。投影可以落后、可以重建,事实源不变。
  2. thread store → 协议对象:通过 thread/readthread/turns/listthread/items/list 按游标取出,schema 是公开稳定的;存储用 legacy 还是 paginated 模式,被挡在这层后面。
  3. 协议对象 → cell(展示单元):用户消息、assistant markdown、plan、reasoning 摘要、命令执行、文件改动、MCP 调用、review 结论……每一类 item 变成一种自己带渲染逻辑的展示单元。
  4. 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 只在缓存键变化时重算,键由四个维度组成:

  1. 终端宽度(换行结果依赖宽度);
  2. active cell 的修订号(原地变更过几次);
  3. 流是否仍在继续(影响段落间距等细节);
  4. 动画 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 设计的七条原则

  1. 一份事实,多种 transcript。 模型、rollout、live 画面、定型历史、列表预览、外接程序各有独立投影,设计目标互不相同:可直接作为模型输入、可恢复、显示流畅、可读、读取成本低、接口稳定。让一种 transcript 服务所有读者,等于让所有读者拿到错误的东西。

  2. 事实与表现严格分层。 rollout 存语义事实,协议层出稳定对象,cell 持展示源,屏幕行只是可丢弃的渲染缓存。任何一层的渲染产物都可以从上游无损重建;宽度、主题、动画等观察条件永远不进入事实层。

  3. Item 是权威数据,delta 只是流式显示用的临时数据。 流式期间允许临时单元和可变 active cell;consolidation 之后 source-backed 单元成为唯一依据。持久化、回放、对外语义只承认定型后的 item,不完整的文本永远不能成为历史依据。

  4. 一切读取都必须有界。 初始加载按 turn 页、item 页和屏幕行数三重预算停止;预览有行数、页数、字节、线程四重预算;重排有对齐宿主容量的行数上限。无界读取在十万条 item 的线程面前必然失败。

  5. 有效历史优先于低成本读取。 Rollback、revert、compaction 都会让“物理上存在”不等于“当前有效”。当低成本路径无法证明自己读到的是有效历史时,放弃结果、升级到完整 replay;宁可不显示,也不能显示错误内容。

  6. 读不等于 resume,观察不等于现状。 历史读取不创建运行时、不触发 LOOP、不产生副作用;transcript 记录的是当时观察到的事实,不随外部世界自动更新。悬空结果必须显示为未知,世界现状要靠新观察获取。

  7. 读模型是长期兼容面,也是敏感数据面。 对外 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 必须能够行动,其行动能够被约束、被恢复、被解释,并且它的历史记录能够准确反映实际发生过什么。

分类:Agent Harness标签:#agent #harness #codex