前四篇里有一句话反复出现:

模型可见的东西,必须先落进日志。

这一篇就来拆这条日志本身。它是整个系统里唯一的事实来源——模型看到的历史、界面上的气泡、排错用的时间线、崩溃后的恢复、子 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 变成 done
Trajectory补上结果、耗时、错误码
状态投影运行中工具数 −1

给模型的:少而语义完整

模型历史只取三类

✅ user/message        → role=user
✅ assistant/message   → role=assistant(含文本、推理块、tool-call)
✅ tool/result         → role=user 的工具结果消息

turn/step 边界、原始 assistant/chunk、独立的 tool/call、计时、Goal 变更、UI 状态——统统不进

request/header 也不进历史,它负责的是另一件事:重建系统提示词、工具 Schema 和调用配置。

给人看的:Chat

Chat 不是把 Message[] 原样打印。客户端会把事件重新组织成气泡、卡片、命令节点:

你:检查并修复登录模块
  └─ 读取 src/auth.ts        ✓
  └─ 修改 JWT 过期判断        ✓
  └─ 运行认证测试             ✓
助手:已修复……

这是浏览器里的读模型,不会往 Session 回写"气泡事件",也不会反过来改模型历史。

给排错用的:Trajectory

Trajectory 专门捡模型历史故意丢掉的那些东西:

Turn 0
├─ Step 0
│  ├─ User:检查并修复登录模块
│  ├─ Model Request:TTFT 420ms,输入/输出 token…
│  └─ Tool read_file:参数、结果、耗时
├─ Step 1
│  ├─ Model Request
│  └─ Tool apply_patch
└─ Step 2
   └─ Assistant:最终回复

我当时问过一个问题:Trajectory 是不是第二份日志?

不是。 它只在浏览器里渲染,不修改 Session,也不进模型请求。它是同一本账的诊断视角

一句话记住三者的关系:
模型历史是给决策用的、Chat 是给人读的、Trajectory 是给查案用的。

顺带说清 Goal

Goal 也不是单独的数据库。每次变更追加一条 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 记 parentSession=AseedLength=切点长度、相同的 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

界面上也做了配合:普通侧边栏隐藏 origin=subagent 的行(不然执行细节会把列表铺满),父会话页头提供一棵可展开的 Subagent 树。Host 还能把根 Session 连同所有后代一起导出成 ZIP,后代放在 subagents/<id>/ 下。

换来的是什么?上下文、token、seq、取消、生命周期全都隔离。 尤其是多个同级子 Agent 并行时,不用抢同一条日志的 seq。


到底该 Spawn 还是 Fork

判断标准不是任务重不重要,而是这一条:

这个子任务,能不能用一条完整的 Prompt 交代清楚?
├─ 能            → Spawn
└─ 不能,且必须继承历史 → Fork
SpawnFork 子 Agent
Child Session新 ID、独立日志新 ID、独立日志
父对话 seed最后一个完成 Turn 之前的前缀
cwd与父相同与父相同
文件副本不复制不复制
适合独立审查、并行检索、边界清楚的小任务高度依赖长期讨论和历史工具结果
代价得写自足的 prompt重复上下文 token,可能继承噪声

拿修登录举个具体的例子:

  • 用 Spawn 做安全审查:"检查 src/auth.ts 的 JWT 校验,关注过期、签名算法和密钥读取,返回风险和行号。"——任务短、自足,不需要带上父级关于 UI、测试、部署的一堆讨论。省 token,噪声还少。
  • 用 Fork 试 Cookie 方案:父级已经聊过威胁模型、兼容约束、好几个工具结果和用户的明确选择,重写 prompt 太容易漏。让子 Agent 从共同背景出发更靠谱。

有一个特别常见的误用:

别为了"让子 Agent 知道代码"而 Fork。
父子共享 cwd,Spawn 出来的子 Agent 自己读文件就行了。
Fork 的价值是继承对话,不是继承文件。


常见误解

误解实际
"Session ID 就等于 Session"ID 只是身份;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:气泡与工具卡片,给人看
  ├─> Trajectory:Turn/Step/耗时,给排错用
  └─> Goal / 摘要:完整状态快照,给界面用

  ├─ 冷恢复:保住全部事实,按实际缺口补 Closer
  ├─ 普通 Fork:历史锚点 → 完整 Turn → 新的普通 Session
  ├─ Spawn:空历史 → 新的子 Agent Session
  └─ Fork 子 Agent:最后完成的 Turn → 新的子 Agent Session

所有分支日志独立;谱系可导航;cwd 可共享;文件不复制。

下一篇

《DeepSeek Harness 架构拆解(六):权限与沙箱》会讲这个系列里一直提到、但每次都绕过去的安全层:allow / deny / ask 三档策略怎么判、一次性升权到底升的是什么、文件系统围栏和 Shell 沙箱各自拦在哪一层,以及 Windows 上有哪些边界跟 Unix 不一样。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐