DeepSeek Harness 架构拆解
2026/9/16 21:50:50 网站建设 项目流程

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

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

这一篇就来拆这条日志本身。它是整个系统里唯一的事实来源——模型看到的历史、界面上的气泡、排错用的时间线、崩溃后的恢复、子 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/messagetool/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 不一样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询