
前四篇里有一句话反复出现模型可见的东西必须先落进日志。这一篇就来拆这条日志本身。它是整个系统里唯一的事实来源——模型看到的历史、界面上的气泡、排错用的时间线、崩溃后的恢复、子 Agent 的分叉全都从它长出来。先划清一份日志的边界这是我一开始就问错的地方一份日志到底管多大范围父 Agent 和子 Agent 是不是共用一本账答案很干脆一个 SessionId 一个 Session Header 一条独立、连续、只能追加的 Event Log一个 Session 一本账不多不少。所以父子 Agent 长这样父 Session ├─ Spawn 子 Session A ← 自己一本账从 seq 0 开始 ├─ Spawn 子 Session B ← 自己一本账从 seq 0 开始 └─ Fork 子 Session C ← 自己一本账从 seq 0 开始没有一条全局大日志。它们之间的父子关系不是靠日志串起来的而是记在 Header 里的几个字段上。Header 和 Event两种不同性质的东西Header 存回放不出来的元数据创建时写定字段是什么id这本账的身份不是用户的身份cwdAgent 操作的工作目录——只是个路径不是目录副本parentSession从哪份 Session 派生来的seedLength开头多少条事件是继承来的origin是不是子 Agent 创建的delegationDepth委派递归到第几层agentPreset当时用的是哪套 Agent 组合最后一个容易被忽略但很关键工具集和系统提示词必须跟历史匹配得上。用 A 组合跑出来的历史换成 B 组合接着跑模型会看到一堆自己调用过但现在不存在的工具。Event 存发生过的事实turn/start、turn/end 轮次边界 step/start、step/end 步骤边界 user/message 用户说的 assistant/chunk 模型流式吐出的碎片 assistant/message 拼完整的那条 tool/call、tool/result 工具调用与结果 request/header 这次请求用了什么配置 approval/*、sandbox/mode 权限与沙箱 compaction/* 上下文压缩追加逻辑简单到有点朴素const event deepFreeze({ type, seq: this.log.length, // 下一条 seq 永远等于当前长度 time: Date.now(), data, }) this.log.push(event)两个约束值得记住已提交的事件不改写要修正或压缩也只能再追加一条新的。只能追加不等于模型永远看全部这是个很容易绕进去的点。上下文压缩的时候系统不会删掉旧事件而是追加一条带surfaceOp: { op: replace }的新事件——让未来的模型视图遮住那一段旧内容。原始事件还躺在日志里审计能查排错时间线也照样能还原。遮蔽 ≠ 删除。模型看不见了账本上还在。顺便澄清日志很细到底细到什么程度我一开始的理解是事无巨细这个词有点误导。它记的是对恢复、审计、模型可见性和产品行为有意义的持久事实不是 CPU 指令、不是全部变量、更不是每次 React 渲染。一份日志长什么样Session Header id: session-A cwd: C:\project seq 0 turn/start seq 1 step/start seq 2 user/message 修复登录 Bug seq 3 assistant/message tool-call: read(login.ts) seq 4 tool/call read(login.ts) seq 5 tool/result 文件内容…… seq 6 step/end seq 7 step/start seq 8 assistant/message tool-call: edit(login.ts) seq 9 tool/call edit(login.ts) seq 10 tool/result 修改成功 seq 11 step/end seq 12 step/start seq 13 assistant/message 已经修复 seq 14 step/end seq 15 turn/end注意assistant/message和tool/call挨着出现看起来像重复记了两遍。不是重复assistant/message记的是模型提出了这个调用决定模型对话历史tool/call记的是Harness 确实开始执行了用于执行追踪和崩溃分类这个区分在下面讲崩溃恢复时会派上大用场。一本账多种读法这是这套设计最漂亮的地方Projection投影不是把日志复制成好几份而是用不同规则从同一条日志算出不同的数据结构。┌─ Message[] ── 模型 Header Event Log ─┼─ Chat 节点 ── 用户 ├─ Trajectory ── 排错 └─ Goal / 摘要 ─ 界面状态同一条tool/result在四个地方是四副面孔投影它变成什么模型历史一条 user-role 的 tool-result 消息Chat工具卡片从 running 变成 doneTrajectory补上结果、耗时、错误码状态投影运行中工具数 −1给模型的少而语义完整模型历史只取三类✅ user/message → roleuser ✅ assistant/message → roleassistant含文本、推理块、tool-call ✅ tool/result → roleuser 的工具结果消息turn/step边界、原始assistant/chunk、独立的tool/call、计时、Goal 变更、UI 状态——统统不进。request/header也不进历史它负责的是另一件事重建系统提示词、工具 Schema 和调用配置。给人看的ChatChat 不是把Message[]原样打印。客户端会把事件重新组织成气泡、卡片、命令节点你检查并修复登录模块 └─ 读取 src/auth.ts ✓ └─ 修改 JWT 过期判断 ✓ └─ 运行认证测试 ✓ 助手已修复……这是浏览器里的读模型不会往 Session 回写气泡事件也不会反过来改模型历史。给排错用的TrajectoryTrajectory 专门捡模型历史故意丢掉的那些东西Turn 0 ├─ Step 0 │ ├─ User检查并修复登录模块 │ ├─ Model RequestTTFT 420ms输入/输出 token… │ └─ Tool read_file参数、结果、耗时 ├─ Step 1 │ ├─ Model Request │ └─ Tool apply_patch └─ Step 2 └─ Assistant最终回复我当时问过一个问题Trajectory 是不是第二份日志不是。它只在浏览器里渲染不修改 Session也不进模型请求。它是同一本账的诊断视角。一句话记住三者的关系模型历史是给决策用的、Chat 是给人读的、Trajectory 是给查案用的。顺带说清 GoalGoal 也不是单独的数据库。每次变更追加一条goal/change而且事件里带的是变更后的完整状态不是round 1这种裸 delta。重放时取最后一个合法快照就是当前状态。清空则写一条带 revision 的 tombstone。为什么不用 delta因为完整快照重放不依赖起点任何一条事件坏了也不会让后面全歪。断了怎么接上默认的 JSONL 后端给每个 Session 存一份逻辑上只追加的日志第一行是 Header后面是事件或无损打包的 chunk 行。物理上通常是压缩过的session.jsonl.zstd。正常恢复的流程读 Header 和 Event → 校验格式、seq 连续性、Turn/Step 边界 → 重建 Session先不发布 → 还原 cwd、preset、请求配置、各种投影 → 创建 Live Agent → 从日志末尾继续追加这里有个认知上的坑必须澄清模型并没有恢复内部脑状态这回事。下一次请求只是重新带上从日志里重建出来的历史而已。所谓接着聊本质是重新讲一遍。崩溃在半路上最考验设计的地方假设日志停在这里seq 20 turn/start seq 21 step/start seq 22 user/message 部署服务 seq 23 assistant/message tool-call: deploy() seq 24 tool/call deploy() ⚡ 进程崩了麻烦在于deploy 可能已经成功了只是结果没来得及落账。这时候有两种偷懒做法都是错的删掉这几条假装没发生过 →抹掉了真实事实当成失败直接重试 →可能部署两次正确做法是保留全部已提交事件只丢掉物理上撕裂的不完整尾巴然后追加合成 Closer把账做平。具体补什么取决于崩在哪一步崩溃时的状态补什么含义有 assistant tool-call没有tool/callTOOL_NOT_STARTED压根没开始执行需要的话可以重试有tool/call没有tool/resultTOOL_OUTCOME_UNKNOWN可能执行过了别盲目重试Step 还开着step/end—Turn 还开着turn/end { interrupted }—于是上面那个例子恢复后变成seq 42 tool/result TOOL_OUTCOME_UNKNOWN 结果未知先检查服务是否已部署不要盲目重试 seq 43 step/end 仅因为 Step 还没关 seq 44 turn/end interrupted模型读到这条就知道该怎么办只读 / 幂等的操作 → 可以安全重试 有副作用的操作 → 先去验证真实环境或者问用户两个容易记错的细节第一不是固定补三样。我最早画图时写的是崩了就补 tool/result step/end turn/end这是错的——Step 已经关了就不补 Step不能凭空造一个不存在的步骤。第二合成事件不虚构时间。它复用最后一条真实事件的时间戳而不是填Date.now()。否则日志上会出现一个崩溃三天后才发生的收尾事件。还有个区分挺讲究inspect()只在内存里合成一个平衡视图给你看不动物理文件冷load()才会真的把修复提交下去。什么时候该拒绝加载不是所有残缺都能修。这几种属于 corruption必须拒绝不能猜中间 seq 出现缺口完整帧校验失败已提交区域损坏还有一种情况会拒绝但不是损坏在线 Session 还开着 Turn 时 load。因为内存里的 Agent 可能还在跑这时候伪造一个崩溃恢复就是在乱来。三种分叉别混为一谈一、普通 Session Fork用户从某条消息另开一路父 A需求 → 决定用 JWT → JWT 实现 │ └─ Fork 子 B复制到决策点 → 改用 Cookie用户点了某条消息分叉系统不会从那条消息中间切开而是往后找到它所在 Turn 的turn/end复制这个完整前缀。为什么必须切在 Turn 边界切在半截上会得到一段提了工具调用但没有结果的历史——那是无效的对话结构。新 Session 的 Header 记parentSessionA、seedLength切点长度、相同的 cwd 和 preset但不写origin:subagent。所以它照常出现在普通侧边栏里。如果锚点所在的 Turn 还没结束系统返回fork-unavailable——不会偷偷退回到更早的 Turn 糊弄你。二、Spawn 子 Agent干净的新脑子新 SessionId parentSession 父 SessionId origin subagent seed 无 独立的任务 Prompt它继承工作区、谱系、默认模型和组合能力但看不到父对话的任何历史。源码里写得很直白readonly inheritsParentContext false三、Fork 子 Agent带着父级上下文出发跟 Spawn 的唯一区别是有 seed把父 Agent截至最后一个已完成turn/end的平衡前缀复制过来。为什么要排除父级当前这个 Turn因为创建子 Agent 这个动作本身就发生在一次 Tool Call 里——当前 Turn 必然还没有对应的 Tool Result。要是复制进来子 Agent 拿到的历史就包含正在创建我自己、但还没完成这么一段是无效的。另外还有一点容易想当然Fork 只继承历史快照不共享父级的实时作用域。一次性授权不会跟着过去子 Agent 有全新的作用域进程内委派会在创建那一刻快照父级的显式沙箱设置写进子日志如果有审批能力子 Agent 的审批策略被固定成never。⚠️ 极重要Fork 不复制项目文件这条单独拎出来因为踩了会很痛。日志复制快照 A.events[0..cut] ──copy── B.events[0..cut] A 之后各自 append B 之后各自 append 互不同步 互不覆盖 文件共享 cwd A.cwd ─────┐ ├── C:\project 同一批真实文件 B.cwd ─────┘Fork 复制的是 Session 事件前缀不是工作目录。父子指向同一个 cwd子 Agent 改了文件父 Agent 看到的就是改完的状态甚至可能写入打架。真想隔离代码得用别的手段Git Branch / Worktree 独立目录 独立容器或远程沙箱子 Agent 的结果怎么回到父级父子日志不合并。这点很坚决。前台一次性子 Agent 的路径是子 Session 保留完整中间过程每一个 Step、每一次工具调用 → 挑出最终的 Assistant 输出 → 父 Session 只记一条 subagent 工具的 tool/result父级看到的是结果不是子级的全部思考轨迹。后台的情况要再分细一点不能笼统说都直接返回模式父级立刻拿到什么最终结果怎么回来前台 one-shot最终输出就是这条 tool/result后台 one-shotjob id通过通用的任务收集或通知后台可继续child id结算后尽力投递一条通知注意尽力投递这个措辞父级要是离线了或正在拆卸这条通知可能就没了。所以详细过程和终态的权威记录永远是Child Session 本身。分开记排错会不会更麻烦我当时的疑问就是这个。答案是会多一步跳转但值得。排错路径是沿着树走父日志为什么委派、任务参数是什么、child id 多少、最终结果 → Subagent 目录树 → 子日志模型请求、工具调用、错误、Trajectory界面上也做了配合普通侧边栏隐藏originsubagent的行不然执行细节会把列表铺满父会话页头提供一棵可展开的 Subagent 树。Host 还能把根 Session 连同所有后代一起导出成 ZIP后代放在subagents/id/下。换来的是什么上下文、token、seq、取消、生命周期全都隔离。尤其是多个同级子 Agent 并行时不用抢同一条日志的 seq。到底该 Spawn 还是 Fork判断标准不是任务重不重要而是这一条这个子任务能不能用一条完整的 Prompt 交代清楚 ├─ 能 → Spawn └─ 不能且必须继承历史 → ForkSpawnFork 子 AgentChild Session新 ID、独立日志新 ID、独立日志父对话 seed无最后一个完成 Turn 之前的前缀cwd与父相同与父相同文件副本不复制不复制适合独立审查、并行检索、边界清楚的小任务高度依赖长期讨论和历史工具结果代价得写自足的 prompt重复上下文 token可能继承噪声拿修登录举个具体的例子用 Spawn 做安全审查检查src/auth.ts的 JWT 校验关注过期、签名算法和密钥读取返回风险和行号。——任务短、自足不需要带上父级关于 UI、测试、部署的一堆讨论。省 token噪声还少。用 Fork 试 Cookie 方案父级已经聊过威胁模型、兼容约束、好几个工具结果和用户的明确选择重写 prompt 太容易漏。让子 Agent 从共同背景出发更靠谱。有一个特别常见的误用别为了让子 Agent 知道代码而 Fork。父子共享 cwdSpawn 出来的子 Agent 自己读文件就行了。Fork 的价值是继承对话不是继承文件。常见误解误解实际Session ID 就等于 SessionID 只是身份Session Header Event Log投影是好几份持久化日志是从同一事件源算出的读模型缓存不是第二权威Trajectory 是另一本账浏览器里的诊断视图不改 Session、不进模型崩溃恢复固定补三样事件只给未配对的调用补 result只给开着的 Step 补 step/end工具结果未知 失败记过tool/call却没结果是 outcome unknown有副作用的必须先验证所有子 Agent 都带父历史Spawn 不带Fork 只带父级已完成 Turn 的前缀Fork 会复制项目目录只复制事件父子指向同一个 cwd父级能看到子级完整轨迹只收最终输出、通知或子级主动 report有parentSession就是子 Agent普通 Fork 也有谱系origin:subagent才是标志恢复 把模型状态原样续上是重读日志、重建历史再由新请求带过去一句话串起整章一个 Session 一份不可变 Header 一条只追加的 Event Log ├─ 模型历史三类消息喂给下一次请求 ├─ Chat气泡与工具卡片给人看 ├─ TrajectoryTurn/Step/耗时给排错用 └─ Goal / 摘要完整状态快照给界面用 ├─ 冷恢复保住全部事实按实际缺口补 Closer ├─ 普通 Fork历史锚点 → 完整 Turn → 新的普通 Session ├─ Spawn空历史 → 新的子 Agent Session └─ Fork 子 Agent最后完成的 Turn → 新的子 Agent Session 所有分支日志独立谱系可导航cwd 可共享文件不复制。下一篇《DeepSeek Harness 架构拆解六权限与沙箱》会讲这个系列里一直提到、但每次都绕过去的安全层allow / deny / ask三档策略怎么判、一次性升权到底升的是什么、文件系统围栏和 Shell 沙箱各自拦在哪一层以及 Windows 上有哪些边界跟 Unix 不一样。