带着问题去理解 DeepSeek Harness

0x00 概况

Q1:DSH 默认装了哪些插件,哪些 Profile?

先说结论:DSH 的发行包会携带一批官方插件,但启动时并不会把它们不加区分地全部运行;真正启用哪些插件,由所选 Profile 的 bundle 组合决定。 另外,Profile 和 Web 界面里的 Agent Preset 是两套不同的概念。

内置 Profile

开箱即用的 Profile 模板只有两个:

Profile Bundle 组合 用途
web @deepseek-ai/dsh-base + @deepseek-ai/dsh-web-app 启动 HTTP 服务和浏览器界面,管理多个会话
headless @deepseek-ai/dsh-base + @deepseek-ai/dsh-headless 创建一个会话、执行一次任务、打印结果后退出

模板定义在 profile.ts。首次使用这两个名字时,DSH 会自动初始化对应 Profile。启动器本身没有一个隐式的默认 Profile:一般使用 dsh --profile webdsh --profile headlessdsh web 只是前者的快捷写法。

用户也可以创建其他 Profile。新 Profile 默认从 dsh-base 开始,再通过 dsh plugin --profile <name> add <package> 安装额外的 bundle 或插件,并由该 Profile 自己的 cordis.patch.yml 做最终覆盖。

dsh-base 默认挂载的插件

这里只列出最关键的 10 个;完整清单见 dsh-base/cordis.patch.yml

Web 和 Headless 各自再增加什么

Web Profile 的关键增量:

这里只列出最关键的 10 个;完整清单见 dsh-web-app/cordis.patch.yml

Headless Profile 的增量:

完整配置见 dsh-headless/cordis.patch.yml

Web 的 Agent Preset 不是 Profile

Web 随包提供四个 Preset,默认选择 standard

Preset 作用
standard 完整编码 Agent:Shell、文件、搜索、Skills、计划、目标、子代理和工作流
code 在 standard 基础上用 Code Mode SDK 组合多步工具调用
minimal 精简为持久 Shell 与字符串替换编辑器
cordis 面向 Cordis 插件实验和自定义 Agent Preset 创作

Profile 决定整个进程运行哪些宿主能力,Preset 决定某个 Web 会话里的 Agent 获得哪些工具和提示词。因此“Web Profile + standard Preset”是正常组合,不是重复配置。

想确认自己机器上的最终结果,最可靠的方法不是数 node_modules,而是查看组合后的配置树:

dsh --profile web --dump-default-config
dsh --profile headless --dump-default-config

如果还要包含用户 Profile、$DSH_HOME/cordis.patch.yml 和命令行 --patch 的覆盖,则使用 --dump-config

0x01 会话数据结构

Q2:会话和会话持久化都是插件,而且是两个插件?怎么做到的?

是。从 Profile 的挂载结果看是两个插件;从源码分层看则是“两个运行时插件 + 一个持久化接口包”。

提供的服务 作用
@deepseek-ai/dsh-session ctx.sessions 创建和持有内存中的 Session,校验并追加事件,不负责磁盘 I/O
@deepseek-ai/dsh-session-persistence 抽象的 SessionPersistence 定义 createappendloadinspectlist 等持久化接口,并提供通用协调器;它不是单独挂载的后端
@deepseek-ai/dsh-session-persistence-jsonl ctx.sessionPersistence 继承上述抽象 Service,默认把会话保存为 JSONL/Zstd

它们通过 Cordis 的服务注入和 Session 生命周期事件连接起来:

业务插件
  │ session.append(type, data)

Session / ctx.sessions                    内存真源

  ├─ session/created ──────────────────┐
  ├─ session/event ────────────────────┼─> PersistenceCoordinator
  ├─ session/flush  ───────────────────┤        │
  └─ session/disposed ─────────────────┘        ▼
                                      JSONL/Zstd backend

具体流程如下:

  1. session.append() 先校验事件、生成连续 seq、冻结数据,并同步提交到内存日志;相关实现见 Session.append()
  2. 内存提交完成后,Session 发布 session/event。JSONL 插件内部的 PersistenceCoordinator 订阅该事件,把事件复制进每个会话自己的写入队列;见 installWritePath()
  3. 队列到达批处理窗口时写盘;调用 ctx.sessions.flush(session) 则通过可等待的 session/flush 事件立即排空队列。Session Store 的 flush 实现见 SessionStore.flush()
  4. 恢复、检查和列举会话时,消费者调用 ctx.sessionPersistence;JSONL 插件把这些方法委托给协调器和自己的文件后端。它的构造与委托关系见 JsonlSessionPersistence

这里有两个容易忽略的语义:

  • session.append() 成功表示事件已经进入内存日志,不等于已经落盘;需要持久性边界的调用方必须等待 ctx.sessions.flush(session)
  • 持久化插件保存的仍是同一套 SessionEvent,并不存在另一套“持久消息”数据模型。更换后端时,只需提供同一个 SessionPersistence 能力;Agent Loop、标题和 UI 不需要知道底层是 JSONL、SQLite 还是远程存储。
Q3:新会话的 Session ID 怎么生成?

在 Web 界面新建会话时,Session ID 由 Host 生成,不是浏览器生成的

const sessionId = request.payload.sessionId
  ?? `session-${randomUUID()}`

因此常见形式是:

session-550e8400-e29b-41d4-a716-446655440000

代码位于 api-proxy.tsrandomUUID() 来自 Node.js node:crypto,生成随机 UUID;请求也允许显式传入 sessionId,此时 Host 会使用调用方提供的值,并检查是否与已有会话冲突。

不同入口并不强制使用同一种外观:

  • Headless 使用相同的 session-${randomUUID()},见 headless/src/index.ts
  • ACP 的 session/new 直接使用不带 session- 前缀的 UUID,见 acp/src/index.ts
  • Subagent Provider 也可以生成自己的 ID,或接收调用方指定的 child ID。

原因是 SessionId 本质上只是一个带 TypeScript brand 的不透明字符串,核心层不要求它必须是 UUID。SessionStore 在完全没有收到 ID 时还有一个进程内兜底:依次尝试 session-1session-2……,见 SessionStore.prepare()。这个计数器只保证当前 Store 内不重复,不适合作为跨进程的全局 ID 策略,所以正式入口通常主动传入 UUID。

插件应把 Session ID 当作不可解析的标识:可以比较、传递和作为 API 参数使用,但不要依赖 session- 前缀,也不要从中推导创建时间、入口类型或文件路径。JSONL 后端会自行把任意 Session ID 安全编码为目录名。

Q4:Sessions 列表存在哪里,通过哪个插件、哪个接口获取?

Sessions 列表没有单独存成一张表或一个 sessions.json 它是查询时把“当前进程中的热会话”和“持久化后端中的冷会话”合并出来的。

来源 插件与接口 能看到什么
内存 @deepseek-ai/dsh-sessionctx.sessions.list() 当前进程已挂载的 Session,包括尚未落盘的新会话
磁盘 @deepseek-ai/dsh-session-persistence-jsonl 提供的 ctx.sessionPersistence.list() 已经实体化为持久日志、但当前可能没有加载的历史会话
Web 汇总 @deepseek-ai/dsh-host-apiproxysession.list 热、冷会话去重合并后的 SessionSummary[]

内存侧非常直接:SessionStore 内部维护 Map<SessionId, SessionEntry>list() 返回其中所有实时 Session,见 SessionStore.list()

磁盘侧也没有维护额外索引。JSONL 后端按下面的目录结构发现会话:

$DSH_HOME/sessions/
  --<normalized-cwd>--/
    <encoded-session-id>/
      session.jsonl.zstd

ctx.sessionPersistence.list() 枚举项目目录和会话目录,只读取每个日志的第一行 Header,不解析整份事件日志;实现见 listArtifacts()。抽象接口返回 Promise<SessionHeader[]>,见 SessionPersistence.list()

Web Host 的合并过程是:

  1. 先调用 ctx.sessions.list() 得到热会话;
  2. 再调用可选的 ctx.sessionPersistence.list() 得到冷会话;
  3. 按 Session ID 排除已经在热集合中的重复项;
  4. 为每一项补充标题、统计、Workspace 等 Projection;
  5. updatedAt 倒序返回。

这段逻辑位于 listVisibleSessionSummaries()session-projection-cache 只负责缓存标题、统计等列表列,不负责记录 Session 的存在性;默认关闭的 session-query-sqlite 也不是 Sessions List 的来源。

还有一个边界:JSONL 后端采用延迟实体化。新 Session 如果从未 append、从未生成持久文件,它只会出现在当前进程的热列表中;进程退出后不会留下一个空的历史会话条目。

Q5:自己写的插件想获取 Sessions List,应该怎么做?

取决于插件运行在哪一侧。

同进程的宿主插件

如果插件与 DSH Host 运行在同一个 Cordis 进程中,注入 sessions,再可选读取 sessionPersistence。当前没有一个公开的 ctx.sessions.listAll() 自动合并冷热会话,因此可以按 Session ID 自己做一次简单合并:

import type { Context } from '@deepseek-ai/cordis'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'

export const inject = ['sessions']

export async function listSessionHeaders(
  ctx: Context,
  signal?: AbortSignal,
): Promise<SessionHeader[]> {
  const byId = new Map<SessionId, SessionHeader>()

  // 冷会话:已经持久化的 Header。
  const persistence = ctx.get('sessionPersistence')
  if (persistence !== undefined) {
    for (const header of await persistence.list(signal)) {
      byId.set(header.id, header)
    }
  }

  // 热会话优先:它代表当前进程中最新的生命周期。
  for (const session of ctx.sessions.list()) {
    byId.set(session.id, session.header)
  }

  return [...byId.values()]
}

这里仅把 sessions 声明为必需依赖;ctx.get('sessionPersistence') 允许插件在纯内存部署中继续工作。如果你的插件必须读取历史会话,则可以把 sessionPersistence 也加入 inject,让 Cordis 等待该服务可用后再激活插件。

不要直接扫描 $DSH_HOME/sessions:目录布局、ID 编码、压缩格式和未来的后端类型都属于持久化插件的实现细节。应始终通过 SessionStore.list()SessionPersistence.list() 获取数据。

浏览器插件或进程外程序

这类插件无法访问 Host 的 ctx,应调用 session.list Remote API。它返回的是 Web Host 已经完成权限过滤、冷热合并、排序并补充 Projection 后的 SessionSummary[],也就是侧栏使用的同一份结果。

需要注意:上面的同进程示例只返回 SessionHeader,其中没有最终标题、运行状态和统计。如果你需要与 Web UI 完全一致的列表,不要自行复制 Projection 和可见性逻辑,直接复用 session.list;Host 的权威聚合实现位于 listVisibleSessionSummaries()

Q6:Web 界面的 Sessions List 调的是哪个接口?

Web 侧调用的是 Remote 方法 session.list,通过 Fetch Transport 落到:

POST /api/session.list

完整调用链如下:

SessionManager.refreshList()
  → api.sessions.list({})
  → FetchApiClient.callUnary('session.list', ...)
  → POST /api/session.list
  → Fetch handler 校验 RPC envelope
  → ApiProxy.sessions.list()
  → listVisibleSessionSummaries()
  → { items: SessionSummary[] }

浏览器中的 SessionManagerrefreshList() 里发起请求,并用 single-flight 避免同一时刻重复刷新;实现见 sessions/manager.ts

Fetch Client 把方法名直接拼到 /api/ 后面,相关代码见 callUnary()sessions.list。服务端 Handler 再把 session.list 映射到 api.sessions.list,见 fetch/handler.ts

它看起来像一个 REST URL,但请求体和响应体使用的是 DSH 自己的 RPC envelope,不是简单地向该地址 POST 一个空 JSON。调用方最好复用 IApiClient,不要手写协议。

session.list 提供列表的完整基线;页面建立连接后,会话的创建、状态和 Projection 变化还会通过 /api/events.mux 的 SSE 事件增量更新。因此侧栏不是每出现一个流式事件就重新请求整个列表。

Q7:会话名字存在哪里?

会话名存成 Session Log 里的 session/title 事件,不在 SessionHeader、单独的 metadata 文件或数据库字段中。

一条标题事件大致是:

{
  type: 'session/title',
  seq: 42,
  time: 1780000000000,
  data: {
    title: '排查会话持久化性能',
    messageSeqs: [],
    source: { kind: 'user' },
  },
}

其中:

  • title 是规范化后的标题文本;
  • messageSeqs 记录自动标题参考了哪些用户消息,手动改名时为空;
  • source 区分确定性 fallback、LLM Provider 生成和用户手动改名。

标题采用 latest-wins 语义:顺序查找最后一条 session/title 事件,它就是当前标题。对应的纯 Fold 位于 foldSessionTitle()。旧标题不会被原地修改或删除,因此日志仍能说明标题何时、由谁发生了变化。

标题的产生过程通常是:

  1. 第一条有效用户消息到来后,@deepseek-ai/dsh-session-title 先生成一个确定性的短 fallback;
  2. 如果挂载了标题 Provider,Provider 完成后再 append 一条质量更高的 session/title
  3. 用户在 Web 中改名时,session.rename 调用 SessionTitleService.rename(),append 一条 source: { kind: 'user' } 的新事件,并停止后续自动改名。

手动改名实现见 SessionTitleService.rename(),Web API 入口见 ApiProxy.sessions.rename

为了高效显示列表,session-title 还向 Session Projection Registry 注册了 title Projection;Web 的 Projection Cache 可以把最新折叠结果持久化。但这两者都是可重建的加速层,权威数据仍是 session.jsonl.zstd 中最后一条 session/title 事件。该事件是 log-only,不进入 Surface,也不会被发送给 LLM。

Q8:会话很长,后面改名,拉列表时要完整扫描每个持久化文件吗?

正常情况下不需要。Sessions List 的主要成本随会话数量增长,而不是随每个会话的日志长度增长。

热会话:改名立即进入内存 Projection

用户改名时会 append session/title。Session Projection Registry 订阅 session/event,同步把这条事件 Fold 进 title Projection,并向 Web 推送变化;见 SessionProjectionRegistry.drive()

因此会话仍在当前进程中时,列表直接从内存 Projection 读取最新标题,不读取持久文件。即使会话已经有数十万条事件,最后一次改名的热路径仍然只处理这一个新增事件。

冷会话:Header 枚举 + Projection Cache

进程重启后,列表采用两层轻量读取:

  1. JSONL 后端只读取每个 session.jsonl.zstd 的第一个 Header Frame,用于发现 Session ID、cwd 和创建时间;不会解压后面的完整事件日志。
  2. 标题、统计等列表字段从持久化的 Session Projection Cache 读取。Web 默认把它存到 storage 根目录下的 session_projcache.json

列表使用的是同步、零日志 I/O 的 cachedSnapshot()。Web 配置每累计 200 个事件或最长 5 秒写一次缓存,并在 turn/end 与 Session dispose 时强制写入;配置见 web-app/cordis.patch.yml,写入时机见 SessionProjectionCache.installWritePath()

所以一次普通列表刷新大致是:

O(会话目录数量 × 读取一个 Header)
+ O(会话数量 × 查询一条 Projection Cache)

而不是:

O(所有会话的全部历史事件)

缓存缺失或落后时会怎样

Projection Cache 是加速层,不是权威数据。异常退出可能发生在 session/title 已落盘、缓存尚未刷新之间;此时冷列表可以暂时显示旧标题或没有标题,但不会为了补救而阻塞列表、扫描所有长日志。

真正打开该会话时,coldSnapshot() 会从缓存记录的 seq 水位继续 Fold 后续事件,并把新结果写回缓存;缓存无效时才从 seq 0 重建。实现见 SessionProjectionCache.coldSnapshot()。对于 JSONL 这种顺序介质,readFrom() 为了找到后缀最坏仍要解析这个被打开的会话的完整文件;但这是按会话发生的冷恢复成本,不是每次 Sessions List 对所有会话的固定成本。

另有一个很小的例外:Host 为确认“空白会话”可以探测物理文件,但默认只允许最多 1024 字节的文件进入该路径;大文件直接跳过,因此不会拖慢长会话列表。

0x02 会话持久化格式与性能

Q9:session.jsonl.zstd 是只追加,还是每加一行都会重写整个文件?

正常写入路径是只追加,不会因为新增一条事件而重新压缩、重写整个文件。 更准确地说,它是“逻辑事件只追加 + 多事件批量物理追加”。

第一次写入:原子创建文件

Session 创建时,JSONL 后端只登记 Header,并不立即产生空文件。第一次有事件需要持久化时,它会:

  1. 分别编码 Header 和第一批事件;
  2. Zstd 模式下把它们压成两个独立 Frame;
  3. 写入随机临时文件并执行 fsync
  4. POSIX 使用不会覆盖已有目标的 link() 发布,Windows 使用 write-through rename;
  5. 同步父目录,保证文件名本身在掉电后仍然存在。

这条路径见 materialize()encodeMaterialization()

后续写入:只在文件尾追加一个批次

后续事件先进入每个 Session 独立的 Write-behind Queue。默认从队列由空变为非空开始计算固定 200ms 窗口;窗口内到达的事件合成一个批次,后续事件不会不断延长这个截止时间。显式 flush 或 Session 销毁会跳过等待、立即排空。

一个批次的物理写入是:

events
  → 编码为若干 JSONL 记录
  → 压成一个新的 Zstd Frame
  → open(path, 'a')
  → writeFile(frame)
  → fsync

实现见 appendLines(),批处理控制器见 SessionWriteBehind。因此文件会形成:

[Header Frame][首批事件 Frame][追加批次 Frame][追加批次 Frame]...

一条事件不一定对应一次写入,也不一定对应一行

  • 200ms 内的多条事件通常共享一次 append 和一次 fsync
  • 默认 packChunks: true 时,至少 3 个连续、同一 Content Block 的 assistant/chunk delta 可以无损打包成一条存储记录;
  • 加载时会把打包记录还原为原始、连续 seq 的事件,所以这只是物理编码优化,不改变逻辑日志。

哪些情况会截断文件

只追加描述的是正常提交路径。有两个修复性例外:

  • 如果 write 或 fsync 失败,后端把文件截回本批次开始前的长度,再保留整批事件供重试,避免半批数据和重复 seq;
  • 崩溃恢复发现不完整尾部时,会截到最后一个可验证边界,再保留可恢复记录并追加合成的结束事件。

已经成功 flush 的旧事件不会因为正常追加而被重新编码或覆盖。因此长会话每次落盘的 CPU 和写入量主要取决于本批事件大小,而不是整份历史文件大小。

Q10:JSONL 靠换行分割,Zstandard Frame 是什么,怎么保证按行读取?

Frame 是 Zstandard 的独立压缩单元,不是 JSONL 的“压缩版行”。DSH 先按 Frame 边界解压,再在解压后的字节中找换行;它从不在压缩数据中寻找 \n

两套边界各管一件事

一个 Zstandard Frame 由 Frame Header、若干压缩 Block、可选校验和等部分组成,可以被独立解压。Zstandard 允许多个完整 Frame 直接串联,所以 DSH 才能把新的压缩批次追加到旧文件末尾,而不用重新压缩旧内容。

DSH 自己写出的文件会刻意让 Frame 落在 JSONL 记录边界上:

Frame 0 plaintext: Session Header + \n
Frame 1 plaintext: event row + \n + event row + \n ...
Frame 2 plaintext: event row + \n + event row + \n ...

首次创建时 Header 独占一个 Frame;每个追加批次先由 eventLines() 生成换行分隔的记录,并在批次末尾补一个换行,再压成一个新 Frame。对应代码见 encodeMaterialization()encodeEventBatch()。因此正常生成的文件里,一条 JSONL 记录不会横跨两个 Frame。

实际读取流程

完整加载时执行的是:

  1. scanZstdFrames() 只解析压缩容器结构,找出每个完整 Frame 的字节范围;这一步还不解压。
  2. createZstdFrameDecoder() 按文件顺序逐 Frame 解压并校验 checksum。
  3. 第一个 Frame 必须恰好是“一条以换行结束的 Header”,否则拒绝该文件。
  4. 后续明文字节交给 SessionLogScanner。Scanner 在明文 Buffer 中查找 0x0A,只对完整行做 UTF-8 解码和 JSON.parse();如果解码器的一块输出恰好截在一行中间,它会暂存片段,等下一块明文到达再拼接。

所以“能按行读”的保证来自三层:Frame 结构保证压缩边界可识别,checksum 验证完整 Frame,JSONL Scanner 负责明文记录边界。Frame 与行是相邻但相互独立的抽象。

崩溃把最后一个 Frame 写了一半怎么办

结构扫描会把此前完整的 Frame 视为已提交前缀,并记录不完整末帧的起点。加载器会尝试以 flush 模式解出末帧已经产生的明文,但 Scanner 只接纳以换行结束、seq 连续的完整记录;最后半行会被丢弃。随后修复路径把物理文件截回末帧起点,再重新追加确认恢复出的事件。

这套设计的权衡是:追加成本只与新批次有关,崩溃影响也被限制在最后一个 Frame;但 JSONL 仍是顺序介质,完整恢复长会话时依然需要依次解压和解析全部 Frame。Sessions List 之所以快,是因为它只读取那个独立的 Header Frame,而不是因为 Zstd 支持任意按行随机访问;实现见 readFirstZstdLine()

0x03 一个对话轮次

Q11:新一轮开始时,在哪里组装 Session Messages 并发给上游 LLM?

主流程在 @deepseek-ai/dsh-agent-loopReactLoopAgent 中;真正的历史消息由 Session.deriveMessages() 从当前 Surface 派生,不是临请求时重新读取并解释持久化文件。

一次用户输入触发的 Turn,大致经过下面这条链路:

followup / steer
  → Inbox
  → turn()
  → preStep()
  → 把本步输入 append 为 user/message
  → step()
  → session.deriveMessages()
  → buildRequest()
  → preparedCall.stream(request) / ctx.llm.stream(request)
  → Provider Adapter

1. 先确定本步输入和 System Prompt

turn() 先 append turn/start,随后 preStep() 从 Inbox claim 本步用户消息,同时调用 System Prompt Service 组装提示词、工具定义和运行时上下文。通过 agent/pre-step 瀑布钩子后,本步正式 append step/start,并把每条输入写成带 surfaceOp: 'append'user/message。实现集中在 turn()preStep()

2. 从 Surface 派生消息历史

进入 step() 后,Agent 先渲染 System Prompt,再调用:

this.session.deriveMessages()

它按 Surface 中保存的事件 seq 顺序,把 user/message、完整的 assistant/message、工具结果以及压缩摘要等事件投影成内部 Message[]。Chunk、Turn/Step 边界、标题等 log-only 事件不会混入模型历史;被 surface/replace 替换的节点也不会出现。

这份派生结果有增量缓存:普通追加只投影新 Surface 节点,发生 replace 才重建。具体见 Session.deriveMessages()

3. 冻结请求并交给 Adapter

buildRequest() 负责补齐其余请求字段:

  • 通过 agent/request 钩子确定 provider、model、reasoning effort、max tokens;
  • llm.prepareCall() 绑定精确的 Adapter 注册版本,并落实 Adapter 默认值;
  • 把最终 System Prompt、Tools 和调用配置记录为 request/header,把模型上下文窗口记录为 request/context
  • 组装并深度冻结统一的 GenerateOptions{ messages, system, tools, provider, model, sessionId, signal, ... }

随后 step() 调用已准备好的 preparedCall.stream(request);没有 Prepared Call 的中间件路由则走 ctx.llm.stream(request)。LLM Runtime 再把请求交给相应 Provider Adapter 做协议转换和网络调用。

因此要区分三个概念:Surface 决定发哪些历史消息,System Prompt Service 决定本步的系统提示和工具,Agent Loop 把两者与模型配置合成最终请求。 一个 Turn 如果包含工具调用,还会继续产生多个 Step;每个新 Step 都会重新在当时的 Surface 边界派生消息并发起下一次模型请求。

Q12:this.session.append('assistant/chunk', { chunk }) 写的是流式碎片,谁把它合并?落盘也全是碎片吗?

语义上的合并由 @deepseek-ai/dsh-llmBlockAssembler 完成;日志中既保留原始 assistant/chunk,也追加一条组装完成的 assistant/message。所以碎片确实会落盘,但模型历史不会靠下一次请求重新拼这些碎片。

Agent Loop 为每次模型请求新建一个 BlockAssembler。每收到一个流式 Chunk,会按同一顺序做两件事:

chunkSeqs.push(session.append('assistant/chunk', ...).seq)
assembler.push(chunk)

对应流程见 ReactLoopAgent.step()。第一步留下精确的 Provider 流,便于 UI 实时展示、诊断和回放;第二步在内存里维护各 Content Block 的组装状态。

BlockAssembler.push()index 区分 Block:

  • text-deltareasoning-delta 追加字符串;
  • tool-call-delta 累积工具名、ID 和参数 JSON 片段;
  • block-end 提供权威的完整 Block;
  • usagefinish 分别保存 token 用量、结束原因与 Provider replay metadata。

流正常结束后,Agent 从 assembler.blocks() 创建一条完整的 assistant/message,并以 surfaceOp: 'append' 放入 Surface;它的 sourceEventSeqs 正是本次请求所有 Chunk 的 seq。下一次请求直接使用这条完整消息,不再拼 Chunk。

中断路径也有明确规则:如果已经产生非空文本或推理内容,会追加 interrupted: true 的完整前缀消息;未执行的半截工具调用会丢弃。若请求报错后重试,失败尝试已经记录的 Chunk 仍留在日志中用于诊断,但因为没有对应的 Surface Message,不会进入后续模型上下文。

物理文件会不会被碎片撑得很大

逻辑上每个 Chunk 都是独立 Session Event,确保 seq、时间和原始 token 边界可恢复。物理 JSONL 默认开启 packChunks:连续至少 3 个、同一 Block 的 text/reasoning/tool-call delta 会被编码为一条 text-chunksreasoning-chunkstool-call-chunks 存储行;读取时再无损展开成原事件。

这不是把文字直接连接后丢失边界,而是把每个 delta 保存进数组,同时用 seq0time0 和时间差恢复原始事件。实现见 packChunkRuns()decodeStorageRecord()。Block 边界、usage、finish、短序列和无法完全识别的未来 Chunk 仍逐事件存储。

因此这里有两层不同的优化:BlockAssembler 生成模型可用的完整语义消息;Chunk Row Packing 只压缩日志的物理表示,并不改变事件语义。

Q13:解决碎 chunk 的关键是 Surface 吗?日志里碎的和完整的都有,下次怎么挑?

Surface 确实是“挑哪些事件组成模型历史”的关键;但它不是用来合并 Chunk 的,Chunk 在上一题的热路径中已经由 BlockAssembler 合并。下次请求根本不会再次从碎片重组消息。

可以把同一份 Session Log 看成两层:

Event Log:完整事实与执行轨迹
  ├─ turn/start、step/start、request/header ...
  ├─ assistant/chunk × N
  ├─ assistant/message(完整结果)
  └─ tool/call、tool/result ...

Surface:Event Log 上的有序索引
  └─ [user/message seq, assistant/message seq, tool/result seq, ...]

Surface 不是另一份消息副本,内存实体只是当前模型可见事件的 seq[]。核心实现见 surface.ts

准入规则是显式的,不靠猜

当前只有三类 Session Event 可以进入 Surface:

  • user/message
  • assistant/message
  • tool/result

它们在 append 时必须明确携带 surfaceOp: 'append'surfaceOp: { op: 'replace', ... }assistant/chunkturn/starttool/call、标题和请求元数据都不是 Surface-eligible,若强行携带 Surface 标记还会在写入前报错。准入与标记校验见 surfaceOpOf()

因此一段模型输出在日志里可能是:

seq 100..149  assistant/chunk      // 不在 Surface
seq 150       assistant/message    // surfaceOp: append
                                      sourceEventSeqs: [100..149]

sourceEventSeqs 说明完整消息由哪些原始事件产生,提供可追溯性;它不是“下次再合并这些 seq”的指令。对于 replace,它还必须覆盖所有被遮蔽的 Surface 节点,防止替换事件在 provenance 中漏报某个旧节点;这项结构校验并不能判断自然语言摘要是否真的保留了每项事实。校验见 assertProvenance()

下一次请求实际怎么取

Session.deriveMessages() 只遍历 surface.nodes,对每个 seq 取回对应事件,再由 deriveEventMessage() 投影成内部 Message。上例只会读取 seq 150 的完整 assistant/message;100–149 的 Chunk 虽然还在日志和持久化文件里,但从来不在待发送节点集合中。

这带来一个很清晰的分工:

  • Event Log 保证审计、流式回放和崩溃诊断所需的完整事实;
  • Surface 明确当前模型应该看到的有序历史;
  • sourceEventSeqs 记录派生关系并约束替换的完整性;
  • deriveMessages() 把当前 Surface 节点投影成请求消息。

所以不存在“扫描全部事件,再判断哪条完整、哪条是碎片”的昂贵或含糊过程;选择结果在事件写入时就由 Surface 标记确定,并由 SurfaceManager 增量维护。

0x04 上下文压缩

Q14:上下文压缩过程是怎样的?有什么选择和权衡?每次请求会发送所有历史吗?

每次普通请求发送的是“当前 Surface 的全部消息”,不是 Event Log 的全部历史。 没发生压缩时,两者在模型可见消息上大体一致;压缩后,旧事件仍完整留在日志中,但 Surface 上的旧区间已经被一条 Checkpoint Summary 替代,所以请求只带“摘要 + 保留的近期原文”。

默认的 dsh-compaction-basic 在每个 agent/pre-step 边界检查压力,也会在 Provider 明确返回 CONTEXT_WINDOW_EXCEEDED 时执行一次强制恢复。自动钩子见 BasicCompactionEngine._registerAutomaticCompaction()

正常压力压缩流程

  1. 估算当前请求大小:Token Meter 对最新 request/header 中的 System Prompt、Tools 和当前 Surface Messages 计价;若 Adapter 曾报告可复用的真实 usage,也会用它校正启发式估算。
  2. 判断阈值:默认在目标模型 Context Window 的 80% 触发。模型必须向 Adapter 提供 contextWindow,否则只记录警告并继续,不会拿一个猜测值冒险压缩。
  3. 先做无模型裁剪:如果安装了 Tool Result Pruner,先处理过大的工具结果。默认超过 8192 个 Unicode code point 时保留头部 4096、尾部 1024,中间换成固定标记;这是一次只替换该 tool/result 的 Surface rewrite。实现见 ToolResultPruner.pruneSession()。裁剪后重新计价,若已低于阈值就不调用总结模型。
  4. 选择摘要区间:从 Surface 头部开始选择旧历史,同时默认至少保留 Context Window 16% 的近期消息原文。切点会向前调整,确保不拆开 Assistant Tool Call 与对应 Tool Result。选择算法见 selectCompactableRange()
  5. 调用总结模型:复用会话最后一次请求的 System Prompt、Tools 和被选区间消息,在末尾追加结构化总结指令。默认使用显式配置的总结模型;没有配置时沿用最近路由或 Agent 模型,输出上限默认 8192 tokens。这样安排还能尽量复用 Provider 的 Prefix/KV Cache。调用见 summarizeWithLlm()
  6. 验证并提交:摘要必须是非空纯文本,且加上 Checkpoint 包装后仍比原区间更小;异步总结期间 Surface 也不能发生不允许的变化。通过后依次 append compaction/summary、一条作为 Checkpoint 的 user/messagecompaction/end,其中 Checkpoint 使用 surfaceOp: replace 覆盖旧区间。提交代码见 commitCompactionBody()

默认阈值、保留比例、8192-token 摘要上限和重试次数见 resolveConfig()。一次压缩后若估算仍高于阈值,默认还可再压一次;Provider 已确认溢出时则跳过 80% 判断,并以 retainTokens = 0 尽力产生一个可重试的缩减结果。

有哪些选择和权衡

选择 收益 代价
更低的触发阈值 更少遇到真实溢出,给输出留更多余量 更早、更频繁地产生总结调用
保留更大的近期尾部 近期细节、措辞和工具状态更准确 可释放的 Context 更少
先裁剪 Tool Result 确定性、便宜、无需 LLM 工具输出中段会退出后续模型上下文,原文仅保留在日志中
用更强的总结模型或更高输出上限 Checkpoint 可能更完整 延迟和 token 成本更高;摘要仍然有损
关闭自动压缩、手动 /compact 用户完全控制时机 更容易在请求时才发现 Context Overflow

最终要点是:压缩修改的是模型可见 Surface,不删除审计日志。 后续请求仍会发送完整的“当前 Surface”,但当前 Surface 已经不是未经处理的全部历史;被替换的旧节点只在 Event Log 中保留,不再重复占用上游上下文。

Q15:surface/replace 是替换一个范围吗?下次从头重放时遇到 replace,就去掉被替换内容?

对,但准确名称不是一种 surface/replace 事件,而是 Surface-eligible 事件上的 surfaceOp: { op: 'replace', start, end } 元数据。它把当前 Surface 中从 startend 的一段连续节点(两端都包含)替换为“这条新事件”一个节点。

这里的“范围”按当前 Surface 位置解释,不是把原始日志中 seq 数值位于 [start, end] 的所有事件删除。假设重放到某处时:

surface.nodes = [12, 20, 31, 45, 60]

随后 seq 80 的 user/message 携带:

surfaceOp: { op: 'replace', start: 20, end: 45 }

Fold 后得到:

[12, 80, 60]

seq 20、31、45 被新的 seq 80 节点遮蔽;12 和 60 保持原相对顺序。20 到 45 之间那些本来就不在 Surface 的 Chunk、Turn Marker 等日志事件从未参与这次范围计算。类型定义见 SurfaceOp

从头重放时确实是逐事件 Fold

冷恢复可以从空数组按 seq 顺序重放:

  • 遇到 surfaceOp: 'append',把该事件 seq 放到 Surface 尾部;
  • 遇到 replace,先在当时的 Surface中找到 startend 的位置,再执行 splice(startIdx, count, replacementSeq)
  • 其他事件不改变 Surface。

实现分别在 replacementRange()applySurfacePlan()。端点不存在或顺序反了时,事件在 append 或恢复时就会被拒绝。Surface Core 不替任意插件判断对话语义;Compaction 在生成 replace 前另做工具配对边界检查,避免把 Assistant Tool Call 与 Tool Result 拆开。

replace 还有一条重要的完整性约束:新节点的 sourceEventSeqs 必须包含被遮蔽范围内的每一个当前 Surface seq。因此写入者不能声称替换了整段历史,却只引用其中一部分;Compaction 还会把总结调用相关事件一起列入来源。

原内容只是从 Surface 消失,没有从日志删除

物理 session.jsonl.zstd 仍然只追加:旧消息、原始 Chunk、compaction/summary 和 replacement message 全都保留。replace 改的是重放得到的模型视图,因此:

  • 下一次 deriveMessages() 只投影 [12, 80, 60]
  • 审计、调试和人类对话轨迹仍能读取原始 append 事件;
  • 再次压缩时,seq 80 这条 Checkpoint 可以像普通 Surface 节点一样被新的 replace 覆盖。

这种设计用一条可重放的新事实表达“模型现在该看什么”,避免原地改写旧日志;代价是每次 replace 需要在当前 Surface 中定位并 splice 一段范围,而且 deriveMessages() 的缓存要在 replacement generation 变化后重建。普通尾部 append 不承担这个成本。

Q16:Surface 的内存数据实体什么时候更新?分冷链路和热链路吗?

Surface 的内存实体是每个 Session 内唯一的 SurfaceManager,核心状态是 nodes: number[]replaceGeneration 和已处理到的 seq。它采用同步、增量、惰性 Fold:事件 append 时先验证,真正把新事件应用到数组通常发生在下一次 Surface 读取或下一次 append。对调用者而言始终是最新的。

热链路:先计划,再提交,按需推进

Session.append() 的顺序是:

构造并冻结候选 Event
  → surfaceManager.validateNext(event)
  → event 进入内存 Event Log
  → 同步发布 session/event

validateNext() 会先 Fold 尚未处理的旧尾部,再校验候选事件的 Surface 类型、replace 端点和 provenance,并把转换计划暂存起来;它此时不修改已提交的 nodes。只有校验成功,事件才进入日志,所以非法 replace 不可能留下“日志写了一半、Surface 没写”的状态。append 边界见 Session.append()

新事件入 Log 后,下面任一操作都会同步 Fold 尚未处理的尾部:

  • 读取 session.surface.nodes
  • 读取 session.surface.replaceGeneration
  • 下一次 Session.append() 调用 validateNext()

SurfaceManager 会直接复用刚才暂存的 Plan,无需再次做完整校验,然后推进 _lastProcessedSeq。实现见 SurfaceManager。因此“惰性”只意味着延后到最近的同步边界,并不存在后台线程或最终一致窗口;session/event 观察者在回调中读取 Surface 时,也会立刻得到包含刚 append 事件的结果。

模型消息还有上层缓存:deriveMessages() 先读取最新 Surface。普通 append 时只投影新增节点;若 replaceGeneration 改变,则清空派生缓存,并按新的 nodes 全量重建一次。代码见 Session.deriveMessages()

冷链路:恢复完整 Log,仍走同一套 Fold

冷会话仅出现在 Sessions List 中时,不会为了列表创建 Session 或构建 Surface;列表使用 Header 和 Projection Cache。

真正打开/恢复会话时,Persistence Coordinator 才会读取并修复完整事件前缀,然后以这些 events 作为 seed 创建 Session;见 prepareCore()Session 构造器逐条 seed 调用与热 append 相同的 surfaceManager.validateNext(),再把事件压入 Log,因此冷恢复与热运行共享同一套校验和状态转换;见 Session seed 构造

这里没有单独持久化的 Surface Cache,也不会用标题/统计的 Session Projection Cache 代替它。原因是 Surface 直接决定下一次模型请求,必须由权威事件日志无歧义重建;列表 Projection 允许暂时落后,Surface 不允许。

所以可以说有冷热链路,但区别只在数据来源和成本:

路径 数据来源 成本特征
热 Session 新 append 的内存事件 平常只 Fold 新事件,普通 append 近似 O(1)
冷恢复 持久化文件中的完整有效事件前缀 首次打开需顺序读取、校验并 Fold 该会话历史
replace 后首次派生 已更新的内存 Surface nodes 做一次 splice,Message Cache 按当前 Surface 重建

语义上没有“冷 Surface”和“热 Surface”两种实现:二者最终都收敛到同一个 SurfaceManager Fold。

0x05 LLM 适配层

Q17:上游 LLM API 支持 Responses、Chat Completions、Claude 等风格,内部用哪一种?在哪里统一转换?

内部不采用 Responses、Chat Completions 或 Anthropic Messages 中的任何一种,而是定义了一套 Provider-neutral 协议。统一的是 Adapter 的输入输出契约;真正的 Wire Format 转换由各 Adapter 在边界处完成。

Harness 内部的中立结构

一次调用的统一输入是 GenerateOptions

{
  provider,
  model,
  messages: Message[],
  system?,
  tools?,
  reasoningEffort?,
  temperature?,
  maxTokens?,
  stop?,
  sessionId?,
  signal?
}

其中 Message 只有中立的 system | user | assistant role、稳定 ID、来源信息和 Content Blocks;Block 包括 textreasoningimagetool-calltool-result。例如工具结果在内部是 user-role Message 中的 tool-result Block,而不是直接照搬 OpenAI 的 role: tool 或 Anthropic 的 tool_result Wire Object。

统一输出则是 StreamChunk:Block Start/End、Text/Reasoning/Tool-call Delta、Usage 和 Finish Reason。Agent Loop、BlockAssembler、Session 和 UI 都只依赖这套协议,不需要知道上游是哪家。

Runtime 负责路由,Adapter 负责翻译

ctx.llmLlmRuntimeprovider 找到注册的 LlmAdapter,解析模型能力与默认值,并把一次调用绑定到同一个 Adapter 配置快照;然后 Adapter 接收完整的 GenerateOptions 并返回 AsyncIterable<StreamChunk>。注册与 Adapter 契约见 LlmAdapter,最终分发与错误规范化见 LlmRuntime.adapterStream()

它不是在 Runtime 中放一个巨大的 if provider === ... 转换器,而是让每个 Adapter 实现双向边界:

DeepSeek 直连 Adapter

dsh-llm-deepseek 面向 OpenAI-compatible Chat Completions

GenerateOptions / Message / ContentBlock
  → serializeRequest()
  → POST {baseURL}/chat/completions
  → SSE Wire Chunk
  → translate()
  → StreamChunk

serializeRequest() 把 System、Assistant Reasoning、Tool Call/Result、Tools 和生成参数转成 Chat Completions 字段;translate() 再把 contentreasoning_contenttool_calls、usage 和 finish_reason 还原为中立流。

pi-ai 多 Provider Adapter

dsh-llm-pi-ai 先由 toPiContext() 把 Harness Messages、Tools 和图片变成 pi-ai 的中间 Context;pi-ai 再根据模型所属 API 实现转成实际 Wire Request。返回时,toStreamChunks() 把 pi-ai 的 Text/Thinking/Tool-call Events、Usage 和 Stop Reason 统一映射回 Harness StreamChunk。

对于手工声明的兼容 Provider Profile,当前明确可选三种协议:

  • openai-completions:这里指 OpenAI Chat Completions,不是老式文本 /completions
  • openai-responses:OpenAI Responses API;
  • anthropic-messages:Anthropic/Claude Messages API。

协议表见 PROTOCOLS。pi-ai 自带 Catalog 的 Provider 在没有显式覆盖协议时会复用其原生 Provider,因此还能保留 Catalog 支持的其他 API、认证方式和兼容性细节;手工 Profile 则刻意只开放上述三种能够用现有 endpoint + key + headers 配置完整描述的协议。

为什么还要保存 Adapter-private replayState

完全中立化会丢掉某些“下一轮必须原样回传、但对 Harness 没有通用语义”的字段,例如 Responses 的 Response ID、Anthropic Thinking Signature 或 Provider 原生 Stop Reason。为此完整 Assistant Message 的 source 可以携带不透明、可 JSON 持久化的 replayState

pi-ai 在终止事件中把这些信息投影为版本化 Replay Envelope;下次同一 Adapter 处理历史时再与中立 Content Blocks 合并,恢复原生消息。相关实现见 toPiReplayState()toPiAssistant()。如果切换到由另一个 Adapter 实例负责的 Provider,Runtime 会剥离这份私有状态,只进行安全的中立转换,避免把一家的私有字段误交给另一家。

所以整个分层可以概括为:

Agent / Session
  ↕ Provider-neutral GenerateOptions + StreamChunk
LlmRuntime(注册、路由、能力、错误边界)
  ↕ Adapter contract
DeepSeek serializer/translator 或 pi-ai Context/Event converter

Chat Completions / Responses / Anthropic Messages / Catalog 原生协议