1. Transcript 的定位
1.1 不是日志,是会话记录系统
很多系统把"日志"当作事后追溯的工具——打印到 stdout、写进文件,出了问题时 grep 一下。Transcript 的定位完全不是这样。它是一个四层架构的会话记录系统,是 kimi-code 运行时最活跃的数据管道:
- 记录 Agent 的每一次操作:不只是文本输出,而是 Turn → Step → Frame 的完整树状结构,包含工具调用参数、LLM 耗时、token 用量等结构化数据
- 为 UI 提供数据:CLI、Web Dashboard、VS Code 扩展等不同界面通过同一套 Transcript API 获取渲染数据
- 支持会话重放和恢复:重启后从
wire.jsonl重建完整的会话状态,包括任务列表、待办事项、Goal 进度、交互审批记录等
1.2 纯 TypeScript 实现,浏览器安全
packages/transcript 是一个纯协议/数据包——它只定义数据结构、操作词汇和收敛规则,不导入任何引擎代码。这意味着同一个包可以在浏览器端(Web Dashboard)和服务器端(kap-server)安全加载,不会因为一个 import 就拖入整个 Node.js runtime。所有引擎相关的 payload(如 toolCallFrame.input、toolCallFrame.output)都是 unknown 类型——数据对 Transcript 层是完全透明的,只在 View 层(L4)由具体 UI 框架解释。
Transcript 包不导入引擎,不导入 Node.js,不导入任何 UI 框架。它的唯一外部依赖是 zod(用于跨进程的 schema 校验)。
2. 四层架构总览
Transcript 采用严格的分层设计,每一层有明确的职责边界:
┌─────────────────────────────────────────────────────────────────────┐
│ Transcript 四层架构 │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ L4 Views — 渲染器注册表 │ │
│ │ ViewRegistry<C> registerTool / registerInput / registerMarker │ │
│ │ 框架无关:CLI ←→ Web ←→ VS Code 各自注册自己的组件 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ L3 Subscriptions — 按粒度订阅 │ │
│ │ off / turn / block / delta Per-agent grade map │ │
│ │ WS 实时推送 + REST 补全 → 双通道同步 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ L2 Ops — 传输词汇表(幂等操作) │ │
│ │ turn.upsert / step.upsert / frame.upsert / append │ │
│ │ task.upsert / interaction.upsert / attachment.upsert / ... │ │
│ │ 除 append 外全幂等 → replay 安全 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ ▲ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ L1 Store — AgentTranscript(copy-on-write 状态机) │ │
│ │ AgentState { items, tasks, interactions, attachments, ... } │ │
│ │ apply() 是唯一的收敛路径,前端/后端同一份代码 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 数据流向:Engine Events → Ops → apply() → onChange → L3 → WS/REST │
│ 客户端: WS/REST → Ops → apply() → onChange → L4 → UI 渲染 │
└─────────────────────────────────────────────────────────────────────┘
这个四层架构的核心原则:每一层只关心自己的抽象层级。L1 关心"当前状态是什么";L2 关心"数据如何传输";L3 关心"谁需要什么";L4 关心"数据如何渲染"。修改一层不影响其他层。
3. L1 Store — AgentTranscript
3.1 按智能体粒度的状态存储
在 kimi-code 中,一个 Session 可以包含多个 Agent(主 Agent + 子 Agent + Swarm 成员)。每个 Agent 有自己独立的 AgentTranscript 实例,由 TranscriptStore(会话级根节点)管理:
class TranscriptStore {
#agents = new Map<AgentId, AgentTranscript>();
ensureAgent(agentId, descriptor?): AgentTranscript; // 懒创建
removeAgent(agentId): boolean; // 移除子 Agent
agents(): readonly AgentDescriptor[]; // 获取全会话 Agent 清单
}
AgentTranscript 本身是一个copy-on-write 状态机,核心结构:
interface AgentState {
items: TranscriptItem[]; // 有序时间线:Turn | Marker | TaskRef
tasks: Map<TaskId, TranscriptTask>; // 后台执行实体(shell/subagent/tool)
interactions: Map<InteractionId, ...>; // 审批/提问实体
attachments: Map<AttachmentId, ...>; // 附件元数据
todos: Map<TodoId, TranscriptTodo>; // 待办事项的最新状态
prompts: Map<PromptId, TranscriptPrompt>; // 提示队列
meta: TranscriptMeta; // Goal/Plan/Swarm/Agent 状态
pendingInteractions: Set<InteractionId>; // 待处理的交互请求
hasMoreOlder: boolean; // 窗口化时标记更早的 Turn 存在
}
3.2 唯一收敛路径:apply()
apply() 是 AgentTranscript 的唯一写入入口——无论数据来自服务端引擎事件还是客户端 WebSocket 推送,都走同一条路径:
class AgentTranscript {
apply(ops: readonly TranscriptOperation[]): AppliedOps {
const accepted: TranscriptOperation[] = [];
let gap: AppliedOps['gap'];
let state = this.#state;
for (const op of ops) {
const result = applyOperation(state, op); // 纯函数 reducer
if (result.gap) { gap = ...; continue; } // append offset 不连续
if (!result.changed) continue; // 幂等跳过
state = result.state;
accepted.push(op);
}
this.#state = state;
if (accepted.length > 0) {
const event = { agentId: this.agentId, ops: accepted };
for (const listener of this.#listeners) listener(event); // 广播
}
return { accepted, gap };
}
}
关键设计决策:
- snapshot() 是零拷贝的:
getItems()返回的数组不会在后续 apply 时被修改——每次状态变化都创建新的引用 - 服务端和客户端使用同一份
AgentTranscript代码:没有"服务端状态"和"客户端投影"的区别,双方持有的是完全相同的 state machine - Lazy skeleton on step/frame upsert:如果 step 的 turn 还不存在,
applyStepUpsert会自动创建一个骨架 Turn(状态为running,origin 为other),保证结构完整性
4. L2 Ops — 幂等操作词汇表
4.1 操作分类
L2 定义了 14 种操作(TranscriptOperation 的联合类型),分为三类:
// 1. 结构操作(全幂等——重复执行结果一致)
ResetOp { op: 'reset', snapshot: AgentTranscriptSnapshot }
TurnUpsertOp { op: 'turn.upsert', turn: TurnHeader }
StepUpsertOp { op: 'step.upsert', turnId, step: StepHeader }
FrameUpsertOp { op: 'frame.upsert', turnId, stepId, frame }
MarkerUpsertOp { op: 'marker.upsert', item: TranscriptMarker }
TaskRefUpsertOp { op: 'taskref.upsert', item: TranscriptTaskRef }
TaskUpsertOp { op: 'task.upsert', task }
InteractionUpsertOp { op: 'interaction.upsert', interaction }
AttachmentUpsertOp { op: 'attachment.upsert', attachment }
TodoUpsertOp { op: 'todo.upsert', todo }
PromptUpsertOp { op: 'prompt.upsert', prompt }
MetaMergeOp { op: 'meta.merge', meta }
ItemsRemoveOp { op: 'items.remove', ids }
// 2. 追加操作(唯一的非幂等操作)
AppendOp { op: 'append', target, offset: number, text: string }
4.2 幂等性设计
除 append 外,所有操作都是 state-style upsert/merge——相同输入多次执行结果一致:
- 重复发送:如果
turn.upsert的字段与当前状态完全相同(通过turnEquals()逐字段比较),返回{ changed: false },不触发 onChange - 乱序到达:由于每个 ops 都携带完整的实体数据(而非 delta),任意顺序 apply 最终收敛到相同状态
- 重放安全:客户端断线重连后,服务端重放所有 ops,客户端直接 apply——已处理的自动跳过,未处理的逐个应用
4.3 append 的特殊性
append 是唯一的非幂等操作,用于流式传输文本:
// LLM streaming: 文本逐 chunk 到达
{ op: 'append', target: { type: 'frame', turnId: 't0', stepId: 't0.1', frameId: 't0.1.f1' },
offset: 0, text: '我将' }
{ op: 'append', target: { ... }, offset: 2, text: '帮您分析' }
{ op: 'append', target: { ... }, offset: 6, text: '这个问题' }
// appendAtOffset 校验 offset:
// offset < local.length → 检查重叠区域是否匹配(不匹配 → gap,分流恢复)
// offset == local.length → 新 chunk,追加
// offset > local.length → gap:上游比本地快,需要 re-snapshot
append 的 gap 检测是双通道同步的关键——当客户端通过 WebSocket 接收流式 ops 时,如果某个 chunk 丢失(offset 跳跃),apply() 返回 gap 信号,触发 since_seq catch-up 请求补全缺失的批量操作。
4.4 操作如何保证数据一致性
L2 的一致性由三条规则保证:
- 单一通道序列:每个 agent 的 ops 从服务端通过单个有序通道发出(按 batch seq 递增),客户端严格按服务端顺序 apply
- flush 重发完整状态:当 frame/step/turn 完成时,服务端发送对应的
frame.upsert/step.upsert/turn.upsert(携带完整数据),即使客户端之前丢失了部分 ops,flush upsert 也会将其"拉回"正确状态 - 降级不丢失状态:当订阅粒度从
delta降到block时,客户端丢弃流水中的 append ops,下一个 flush upsert 将 TextFrame 的完整文本一次性送达——不会丢失内容,只是收到的时间点更晚
5. L3 Subscriptions — 按粒度订阅
5.1 四级粒度定义
订阅粒度(TranscriptGrade)是一个字符串字面量联合类型,附在每个连接上:
type TranscriptGrade = 'off' | 'turn' | 'block' | 'delta';
// 粒度排序(GS 连接/客户端来决定用户需要什么粒度)
const GRADE_RANK = { off: 0, turn: 1, block: 2, delta: 3 };
| 粒度 | 包含的 ops | 适用场景 |
|---|---|---|
off | 无 | Agent 不可见,完全关闭传输 |
turn | turn.upsert, marker.upsert, taskref.upsert, 全局实体和 meta | Agent 选择器、任务面板、"Turn 完成"通知 |
block | turn 级 + step.upsert, frame.upsert(每帧完整状态) | 浏览历史会话、翻页查看已有结果 |
delta | 全部 ops,包括 append chunks | 实时流式渲染、打字机效果 |
5.2 粒度过滤的实现
filterOpsForGrade() 在 L3 层根据当前粒度过滤 ops:
function admits(grade: TranscriptGrade, op: TranscriptOperation): boolean {
switch (op.op) {
case 'append': // 只有 delta 级别才通过
return GRADE_RANK[grade] >= GRADE_RANK.delta;
case 'step.upsert': // block 及以上通过
case 'frame.upsert':
return GRADE_RANK[grade] >= GRADE_RANK.block;
default: // turn headers, markers, tasks, meta 等
return true; // turn 级别就全通过
}
}
5.3 为什么需要不同粒度?
这是 UI 渲染的性能优化关键:
- Agent 列表不需要步骤详情:浏览器打开 Web Dashboard 时,左侧的 Agent 列表只需要知道每个 Agent 有几轮 Turn 及其状态——以
turn粒度订阅足矣 - 翻阅历史不需要流式增量:用户翻看几轮之前的对话时,以
block粒度获取即可——每个 frame 一次性拿到完整文本,无需处理 chunk 拼接 - 当前活跃 Turn 需要实时渲染:正在运行的 Agent,用户需要看到逐字输出的打字机效果——必须
delta粒度
粒度切换也考虑了安全性:
// 降级:客户端升级到更高粒度时,服务端重发 reset snapshot
function needsResetOnTransition(prev: TranscriptGrade, next: TranscriptGrade): boolean {
return GRADE_RANK[next] > GRADE_RANK[prev];
}
5.4 Per-Session + Per-Agent 订阅映射
订阅配置是一个 Record<agentId|'*', grade> 映射——'*' 作为默认值,具体 agent 覆盖:
// 示例:主 Agent 实时流式,子 Agent 只看标题
const spec = {
'*': 'turn', // 默认不看详情
'main': 'delta', // 主 Agent 实时流式
'sub-1': 'block', // 子 Agent 1 看块级内容
};
6. L4 Views — 渲染器注册表
6.1 框架无关的渲染器注册
ViewRegistry 是一个泛型类,抽象了"工具帧如何渲染"这一跨 UI 的问题:
class ViewRegistry<C = unknown> {
#toolRenderers = new Map<string, C>(); // key: view ?? name(小写)
#inputRenderers = new Map<string, C>(); // key: origin.kind
#markerRenderers = new Map<string, C>(); // key: marker key
registerTool(key: string, renderer: C): this; // 注册工具渲染器
registerInput(originKind: string, renderer: C): this; // 注册输入渲染器
registerMarker(marker: string, renderer: C): this; // 注册标记渲染器
resolveTool(frame: ToolCallFrame): C | undefined; // frame.view ?? frame.name
resolveInput(origin: TurnOrigin): C | undefined; // origin.kind
resolveMarker(marker: string): C | undefined; // marker.marker
}
6.2 不同 UI 如何渲染相同数据
同一份 ToolCallFrame,不同 UI 注册不同的渲染器组件:
- CLI:
C是 Ink React 组件,以终端友好的格式展示工具调用(颜色高亮、折叠/展开) - Web Dashboard:
C是 Vue/React 组件,展示富交互的卡片(展开详情、复制参数、查看输出) - VS Code 扩展:
C是 VS Code Webview 组件,嵌入编辑器上下文中
关键是,数据模型完全不变——所有 UI 只是注册了不同的"解释器",读取相同的 Transcript 数据结构。
6.3 可扩展的视图插件机制
注册表的设计天然支持扩展:
// 注册内置工具渲染器
registry.registerTool('read', ReadRenderer);
registry.registerTool('bash', BashRenderer);
registry.registerTool('agent', AgentRenderer);
// 第三方 MCP 工具渲染器
registry.registerTool('mcp__my_service', MyServiceRenderer);
// 自定义 Turn 输入渲染器
registry.registerInput('cron', CronInputRenderer);
registry.registerInput('task', TaskInputRenderer);
如果没有找到匹配的渲染器,resolveTool() 返回 fallbackTool(构造函数中的默认渲染器),保证任何工具调用都不会"白屏"。
7. 核心数据模型
7.1 Turn — 一轮对话
interface TranscriptTurn {
kind: 'turn';
turnId: TurnId; // e.g. "t0", "t1"
ordinal: number; // 单调递增序号(分页游标锚点)
state: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
origin: TurnOrigin; // 触发来源
prompt?: string; // 原始输入文本
attachmentIds?: AttachmentId[]; // 附件引用
steps: TranscriptStep[]; // LLM 调用列表
startedAt?: string;
endedAt?: string;
usage?: TranscriptUsage; // 整轮 token 汇总
durationMs?: number;
error?: string; // 终端错误信息
}
type TurnOrigin =
| { kind: 'user'; payload?: unknown }
| { kind: 'cron'; taskId?: TaskId; payload?: unknown }
| { kind: 'task'; taskId: TaskId; payload?: unknown }
| { kind: 'hook'; payload?: unknown }
| { kind: 'compaction'; payload?: unknown }
| { kind: 'side'; payload?: unknown }
| { kind: 'other'; payload?: unknown };
TurnOrigin 记录"这轮对话是谁触发的":用户输入(user)、定时任务(cron)、后台任务通知(task)、Hook 回调(hook)、上下文压缩注入(compaction)、旁路消息(side)、未知来源(other)。每种 origin 都可以携带自己的 payload,供 L4 的渲染器解释。
7.2 Step — 每次 LLM 调用
interface TranscriptStep {
kind: 'step';
stepId: StepId; // e.g. "t0.1", "t0.2"
turnId: TurnId;
ordinal: number;
state: 'running' | 'completed' | 'interrupted' | 'failed';
frames: TranscriptFrame[]; // 该步骤产出的帧
startedAt?: string;
endedAt?: string;
usage?: StepUsage; // 该次 LLM 调用的 token 用量
finishReason?: string; // LLM 完成原因
timing?: StepTiming; // 延迟分解
retry?: StepRetry; // 重试状态
endReason?: string; // 中断原因
endMessage?: string;
}
interface StepUsage {
inputOther: number;
output: number;
inputCacheRead: number;
inputCacheCreation: number;
}
interface StepTiming {
llmFirstTokenLatencyMs?: number;
llmStreamDurationMs?: number;
llmRequestBuildMs?: number;
llmServerFirstTokenMs?: number;
llmServerDecodeMs?: number;
llmClientConsumeMs?: number;
}
一个 Turn 可能包含多个 Step——例如 Agent 在第一轮 LLM 调用后被工具结果中断,然后进行第二轮调用(tool results → next LLM call)。每次 LLM 调用就是一个 Step。
7.3 Frame — 工具调用/结果/进度
type TranscriptFrame = TextFrame | ThinkingFrame | ToolCallFrame | NoticeFrame;
interface TextFrame {
kind: 'text';
frameId: FrameId;
role: 'assistant' | 'user';
text: string; // L1 始终持有完整文本
attachmentIds?: AttachmentId[];
taskId?: TaskId;
}
interface ThinkingFrame {
kind: 'thinking';
frameId: FrameId;
text: string;
}
interface ToolCallFrame {
kind: 'tool';
frameId: FrameId;
toolCallId: string;
name: string; // 引擎工具名:Read, Bash, Agent, ...
view?: string; // 可选的视图提示
state: 'running' | 'done' | 'error';
input?: unknown; // 工具输入(不透明)
output?: unknown; // 工具输出(不透明)
display?: unknown; // 展示数据(不透明)
error?: string;
inputText?: string; // 原始输入文本
progress?: ToolFrameProgress; // 最新进度
taskId?: TaskId; // 关联的后台执行实体
approvalId?: InteractionId; // 关联的审批交互
todoId?: TodoId; // 关联的待办事项
agentRefs?: AgentRef[]; // 生成的子 Agent 引用
}
Frame 是渲染的基本单位——UI 看到的最小的"有意义的展示单元"就是 Frame。
7.4 Interaction — 用户交互
interface TranscriptInteraction {
interactionId: InteractionId;
interactionKind: 'approval' | 'question';
toolCallId?: string; // 锚定到具体工具调用
state: 'pending' | 'approved' | 'rejected' | 'cancelled' | 'answered' | 'dismissed';
request?: unknown; // 审批请求/问题内容(不透明)
response?: unknown; // 审批结果/答案(不透明)
}
Interaction 是全局实体——不在 Step 的 Frame 序列中,而是在 interactions Map 中独立存储。这使得它可以跨 Step 存活:一个审批请求可能在当前 Step 完成后才被用户响应,但 Interaction 实体不随 Step 分页而丢失。
7.5 Attachment / Todo / Meta
// 附件(只存元数据,不传字节)
interface TranscriptAttachment {
attachmentId: AttachmentId;
mediaType: string; // e.g. 'image/png'
name?: string;
size?: number;
source?: { kind: 'url', url: string }
| { kind: 'file', fileId: string };
placeholder?: string;
}
// 待办事项(全局最新状态,TodoList 工具调用每次更新)
interface TranscriptTodo {
todoId: TodoId;
items: readonly TodoItem[]; // [{ title, status }]
updatedAt?: string;
}
// 元数据(Goal / Plan / Swarm / Agent 状态)
interface TranscriptMeta {
goal?: GoalMeta; // 目标进度
modes?: ModesMeta; // Plan/Swarm 模式标志
activity?: 'idle' | 'turn' | 'disposing' | 'unknown';
agent?: AgentStatusMeta; // 模型/权限/阶段/token 用量
}
8. wire.jsonl 持久化
8.1 单一真相源
服务端在 <sessionDir>/agents/<agentId>/wire.jsonl 中持久化会话数据。这是一个 append-only JSONL 文件:每行一个 JSON 事件,按时间顺序追加,永不修改已写入的行。
8.2 为什么选择 JSONL?
- 追加即写:不需要重写整个文件,O(1) 写入
- 故障恢复友好:即使进程崩溃,最后一行可能不完整,前面的行完全有效——不会损坏整个文件
- 可读写:调试时可以
tail -f wire.jsonl或jq逐行分析(而二进制格式需要专门的工具) - 流式解析:从文件读取时不需要将整个文件加载到内存,可以按行流式解析
8.3 与 Transcript Store 的关系
wire.jsonl 是持久化备份,不是运行时数据源。运行时数据流为:
Engine Events
→ 产生 Ops
→ apply() 写入 AgentTranscript(内存)
→ 序列化 ops 写入 wire.jsonl(磁盘追加)
→ 通过 WS/REST 推送给客户端
wire.jsonl 的主要用途是会话重启时重建——服务端启动后读取 JSONL 文件,重建内存中的 AgentTranscript 状态,然后继续处理新的引擎事件。
9. 历史重建
9.1 重建流程概览
从 wire.jsonl 重建完整会话状态是一个两阶段流水线:
wire.jsonl
│
├─ context.* 消息(LLM 上下文记录)
│ ↓ groupMessagesIntoSnapshot()
│ 按消息分组为 Turn 树,重建 attachment 实体
│
├─ 非 context.* 记录(goal/plan/swarm/task/interaction/todo)
│ ↓ foldWireRecordFacts()
│ 将 task/interaction/todo/meta 折叠到 Turn 树的基底上
│
▼
AgentTranscriptSnapshot (items + tasks + interactions + ...)
9.2 groupTurns:上下文消息分组为 Turn 树
groupMessagesIntoSnapshot() 接收引擎的扁平上下文消息列表,重建层级结构:
- 角色识别:
role: 'user'作为 Turn 边界,role: 'assistant'作为当前 Turn 的新 Step,role: 'tool'更新对应 tool frame 的结果 - 隐藏起源过滤:系统注入消息(
injection、system_trigger)默认折叠,不产生 UI 可见的 Turn;但goal_continuation和subagent这类真实打开引擎 Turn 的系统触发器会保留 Turn 边界(保证 0-based ordinal 与引擎对齐) - 标记转换:
skill_activation和plugin_command的触发消息转为 timeline marker 而非独立 Turn;compaction_summary转为 compaction marker
重建的限制(故意接受的不完整性):
- context messages 中不携带 step usage / finishReason / timing 这些在线指标——只在会话运行时通过引擎事件获得
- base64 图片数据被丢弃,不传输给客户端
- turn durationMs / error 等数据只存在于在线路径
9.3 foldFacts:元数据折叠
foldWireRecordFacts() 处理非 context.* 的 wire record:
- task.started / task.terminated → 构建
TranscriptTask实体 - interaction.request / interaction.resolved → 构建
TranscriptInteraction(pending 未解决的设 cancelled,崩溃安全) - tools.update_store → 构建
TranscriptTodo(最新状态,不保留历史版本) - goal.create / goal.update / goal.clear → 构建
GoalMeta - plan_mode.enter / plan_mode.exit / plan.revision → 构建 Plan 模式标志
- swarm_mode.enter / swarm_mode.exit → 构建 Swarm 模式标志
9.4 Session 恢复流程
完整的恢复流程:
- 服务端启动,读取
wire.jsonl(逐行解析 JSON) - 分离 context messages 和 fact records
groupMessagesIntoSnapshot()→ 基底 Turn 树foldWireRecordFacts()→ 完整AgentTranscriptSnapshot- 通过
resetop apply 到AgentTranscript - 引擎从最后一个 Turn 之后继续处理新事件
10. 操作批处理序列合约
10.1 单调递增的 seq 值
每个 agent 的 op batch 有一个单调递增的 seq 值:
- scope: per (session, agent),从 1 开始计数
- 每个 batch(不是单个 op)分配一个 seq,因此 batch 之间的 seq 是连续的
transcript.reset和 REST transcript 响应携带 watermark:seq表示"此快照包含 seq <= N 的所有 batch"
10.2 since_seq catch-up 机制
// 客户端持有 watermark N,请求 seq > N 的所有 batch
GET /v1/sessions/{session_id}/transcript/ops?since_seq=N
// 服务端响应:
{
batches: [ { seq: N+1, ops: [...] }, { seq: N+2, ops: [...] }, ... ],
latest_seq: M,
complete: true // 服务端日记覆盖到了 N → 可以增量
}
// 或
{
batches: [],
latest_seq: M,
complete: false // 服务端日记已过期 → 客户端必须回退到全量刷新
}
10.3 WebSocket 实时推送 + REST 补全
双通道同步策略:
- WebSocket 通道:推送实时 ops(
transcript.ops事件),包含seq号。所有 transcript 事件标记volatile: true——不会被 WS 的持久化日志记录,可靠性来自 Transcript 层自身的 op-batch sequence - REST 通道:当客户端检测到 seq 跳跃(丢失了一些 batch)、append gap(offset 不连续),或服务端返回
complete: false(日记已轮转),客户端回退到 REST 获取完整快照
10.4 增量 vs 全量同步策略
新客户端连接:
→ WS subscribe(携带 transcript_since 游标)
→ 如果服务端日记覆盖 to since_seq → 增量 replay(transcript.ops batches)
→ 否则 → 全量 baseline(transcript.reset snapshot)
运行时 append gap:
→ 客户端校验 offset → gap 检测
→ since_seq catch-up → 获取缺失的 batch
→ 如果 catch-up 失败(complete: false)→ 全量刷新
重新订阅升级粒度:
→ needsResetOnTransition 判断
→ 升级时(turn→block, block→delta)→ 服务端发送 reset snapshot
→ 降级时 → 客户端只需丢弃多余的 ops,下一个 flush upsert 自动重新同步
总结
Transcript 不是简单的"日志系统",而是一个精心设计的四层会话记录架构。回顾其核心设计:
- L1 Store:每个 Agent 持有独立的 copy-on-write 状态机,
apply()作为唯一写入路径保证客户端和服务端使用完全相同的收敛逻辑 - L2 Ops:14 种操作中 13 种是幂等的 state-style upsert——重复发送、乱序到达、断线重放都不会产生不一致;唯一的
append操作通过 offset 校验检测流中断 - L3 Subscriptions:四级粒度(off/turn/block/delta)让不同 UI 组件按需消费,避免不必要的网络传输和内存开销
- L4 Views:框架无关的渲染器注册表,让 CLI、Web、VS Code 各自注册自己的组件,共享同一份数据模型
数据持久化和恢复也是设计重点:
wire.jsonl作为 append-only 单一真相源,崩溃时只损毁最后一行groupTurns+foldFacts两阶段恢复流程可以从 JSONL 完整重建会话状态- seq-based 的 catch-up 机制让 WebSocket + REST 双通道同步既实时又可靠
Transcript 的设计展示了架构分层的真正价值:每一层只解决自己层级的问题,通过清晰的接口与上下层交互。L1 不知道数据会怎么传输(那是 L2 的事),L2 不知道谁会订阅(那是 L3 的事),L3 不知道数据怎么渲染(那是 L4 的事),L4 甚至不知道"组件"是什么——它只知道有一个泛型参数 C。
在下一篇中,我们将探讨 Protocol & RPC——kimi-code 的进程间通信协议层,了解 WebSocket 事件系统、REST API 设计和双向流管理。