会话系统
一句话版:会话是事件溯源的:
Session是 append-only 的对话历史唯一真源,模型的 LLM 消息历史是从它派生的;持久化、投影、遥测都围绕同一串SessionEvent构建。不存在并行的"持久消息"类型。
这是理解'对话怎么被记录、恢复、展示'的核心。读完你能回答:一条消息到底以什么形态存进日志、surface 和原始日志是什么关系、崩溃后怎么恢复。
一、事件溯源:日志是唯一真源
核心心智:一个 Session 存的是一个不可变事件流,不是"消息列表"。
SessionEvent(append-only 事件流)
├── session-persistence 存储 / 重载 / 列出(JSONL 或 SQLite 后端)
├── session-projection 派生视图(缓存)
├── session-telemetry 遥测导出(OTel)
└── session-title 标题生成
模型看到的"LLM 消息历史" = 从这个事件流**派生**的(surface 层)
源码原话:
事件溯源模型:日志是唯一真源,因此不存在另一套并行的『持久消息』类型。
这条边界是一切的基础:你想给会话"加什么信息",要么是新事件类型(append),要么是派生视图(surface/projection),而不是另开一个消息表。
二、surface:消息的派生层
原始日志里不只有"消息",还有边界、chunk、usage、错误等生命周期事件。模型需要的是一段有序消息投影,这就是 surface(一个在原始日志之上的有序投影层)。
- surface 只投影"产消息"的事件:接受的 user 消息、assistant 消息、工具调用与结果、注入上下文等
- 原始 chunk、生命周期边界、错误等被排除在 surface 之外(但保留在日志里)
surfaceOp标记一个事件如何进入 surface;append/replace
两个读取口径,别混:
| 谁读 | 用哪个 | |
|---|---|---|
| 模型(请求上下文) | surface()/deriveMessages() | surface(看到的是替换后的面) |
| 人类转录(debug/回放) | append-origin 事件 | 原始事件(landed 替换已遮蔽旧历史) |
一条经验:想让"模型看到摘要版"就落 surface 替换;想看"到底发生过什么"就看日志原事件。
三、SessionStore:ctx.sessions
ctx.sessions 创建并持有事件溯源的 Session 实例。持久化不是它实现的:插件订阅 session/event、在 session/flush 时 flush,并可镜像 session/created/session/disposed 生命周期。
| API | 约定 |
|---|---|
create(id?, { seed?, meta? }?) | 校验并 detach 持久 seed/header,填 version/id,createdAt 默认 now,发布并绑定到调用 fiber |
flush(session) | 发布 awaited 并行持久化检查点;没发布/已 detach/stale 的对象拒绝 |
fork(source, boundary?, childSessionId?) | 解析会话,选 seed(默认当前最后事件 seq),要求前缀结束在 turn 外,创建带血缘元数据的 live 子会话 |
get(id) | 取或 undefined |
list() | 列出 |
split 生命周期(仅当 teardown 需与其他资源排序时)
多数情况用 create() 就够了;但当 teardown 必须跟其它资源排序时,用三段式:
prepare(id?, opts?) // 校验并构造,不发布
enter(session) // 碰撞检查 + 发布(不 announce),返回 entry-bound 幂等 detach
// 并发同 id 可同时 prepare,但只有一个 enter 成功;陈旧 detach 无法删替换者
announce(session) // emit 唯一创建边;重复/重入 announce 拒绝
dsh-agent-loop 就用这个 split,让最后一次 loop flush 在 session detach 之前。
四、Session 类
注意:Session 是普通类,不是 Cordis Service。live 会话经 ctx.sessions.create(),detached 回放/检查会话用 Session.create()(后者不发生命周期事件、不绑 fiber)。
关键方法
| 方法 | 约定 |
|---|---|
session.append(type, data, opts?) | 快照并冻结持久数据与 surface 元数据,校验 marker 形状、引用的 source-event seq、complete 替换覆盖、单结果 tool/result 改写;同步提交后通知 observer(独立失败隔离)。reentrant 附加会话 append 拒绝 |
session.deriveMessages() | 增量投影每个新 surface 条目,返回冻结消息数组 |
session.events | 缓存的冻结快照(append 时失效);已接受事件保持深冻结 |
session.seq / session.id | 当前序号 / 只读标识 |
session.header | detach、深冻结的创建元数据 |
session.surface | 只读 surface 视图 |
头信息与事件分离
| 内容 | 是否可回放 | |
|---|---|---|
SessionHeader | 版本、id、createdAt、可选 cwd/parentSession/seedLength/delegationDepth | 创建元数据,写入时 detach + 深冻结,运行时不可变 |
SessionEvent | 可回放的对话状态 | 可回放 |
五、请求头重建(request/header)
request/header 记录一次请求的完整规范快照(非历史请求信封),reason 是三值:initial / resume / change。
- 可选
adapterDefaultsmap:标记 effectivereasoningEffort/maxTokens是精确模型解析物化的值,让下一次请求提案能区分它们与显式会话设置:这正是 Agent 主循环 里"adapter-default 标记"的持久化落点 foldRequestHeader()选最近快照;legacy delta 事件和已移除的fallbackreason 被拒绝
user/message 存完整 UserMessage(在 inbox 路由 / step 进入前就已创建身份);它的 content 原样渲染,source 是区分"人工 prompt / 合成注入 / 进入的 goal round"的唯一通道。
六、事件信封字段(每个 SessionEvent 都可能有)
| 字段 | 含义 |
|---|---|
sourceEventSeqs?: number[] | 引用的源事件 seq(比如 assistant/message 背后的 chunk seq;压缩替换背后的被遮蔽条目)。assistant/message 上 [] = 已知空流;其他 surface 事件出现时须非空 |
surfaceOp?: SurfaceOp | 事件如何进入 surface;非 surface 事件(边界/chunk/usage/错误)不带 |
ignorable?: true | 读到不认识的类型可以安全跳过;缺失 = 必选,未知类型会拒绝会话重建 |
事件词汇与扩展
- 完整目录见
known-event-types.ts与生成的 persistence catalog SessionEventMap可声明合并:插件用declare module加自己的类型(compaction/*、hook 桥的hook/*等),合并成员进同一 catalog- 一个插件要自己的持久事实,
session.append后await ctx.sessions.flush(session),不要伪造执行 turn
七、崩溃恢复与 turn 结束原因
turn/start 只带轮次号;之后进入的 user/message 批次记输入,llm/retry 记请求恢复。turn/end 的 TurnEndReasonMap 是 kind-标签联合:
| kind | 时机 |
|---|---|
aborted | 活 turn 被打断,reason: AgentCancelCause(保留类型化取消原因)。旧格式导入成 { kind:'aborted', reason:{ kind:'legacy' } } |
error | turn 失败,{ kind:'error', error } |
interrupted | 仅崩溃恢复合成(找不到别的证据) |
崩溃恢复:冷 load 用合成事件关闭被中断的轮次:所以恢复出的会话不会有"悬空 turn"。
八、事件溯源的校验:snapshot 与不可变
持久化的值必须"可被一次接受",而不是"check 一下再读一遍":
isJsonValue(value):布尔谓词snapshotJsonValue(value):一趟迭代校验并复制;拒绝环、不支持标量、exotic prototype;接受有限 JSON 数(但-0会被重写为0);不设调用栈深度上限snapshotSessionEvent(event)/adoptSessionEvent(event):克隆 borrowed / 原地持有独占所有权的修改(request-header)
九、持久化后端:JSONL
session-persistence-jsonl 的引擎配置决定存储形状:
| 配置 | 默认 | 作用 |
|---|---|---|
root | :(必填无默认) | 会话日志根目录,通常 $DSH_HOME/sessions |
packChunks | true | 打包 chunk 行,约瘦身 60% |
compression | zstd | zstd(默认压缩)或 none(纯文本 JSONL) |
preparedSessionCacheSize | : | 冷读 LRU 缓存大小 |
writeBatchMaxDelayMs | 200 | 写合并窗口(write coordinator) |
磁盘布局:
~/.dsh/sessions/--<归一化cwd>--/<encoded-id>/session.jsonl.zstd
- 默认 zstd 压缩;要直接
head/jq就配置compression: 'none'或先zstdcat - 两级目录:
--<cwd>--(工作区)+<encoded-id>(会话) packChunks让行更少(打包 chunk),两种后端可切换
十、持久化后端:SQLite(opt-in)
session-persistence-sqlite 是第二个 SessionPersistence provider,满足与 JSONL 相同的契约(append-only、seq 连续、惰性物化、load 时关闭被中断 turn),只是落在 node:sqlite 的行上而非文件字节上。
挂载状态:opt-in——默认组合挂 JSONL(见上节),SQLite 后端需显式配置才装载。
存储模型:每个 SessionEvent 1:1 映射到 events 表一行 (session_id, seq, type, time, data, source_event_seqs, surface_op);data 是事件 JSON 文本,所以行就是事件的逐字形态(含 assistant/chunk、seq 连续)。source_event_seqs 与 surface_op 两列可空,存 surface 元数据;SessionHeader、物化 incarnation id、per-log revision 在 sessions 行里。库默认 wal 日志模式,PRAGMA application_id 标识库、PRAGMA user_version 存 layout 版本;初始化在一个事务里建全部表并盖两个 pragma。POSIX 上缺省目录 0700、库文件 0600(先建库再交给 SQLite 打开),新 sidecar 继承 owner-only 权限。
| 配置 | 默认 | 作用 |
|---|---|---|
path | 无(必填) | SQLite 库文件路径,或 :memory: 进程内库 |
journalMode | wal | journal_mode pragma |
preparedSessionCacheSize | 5 | 冷读缓存 |
writeBatchMaxDelayMs | 200 | 写合并窗口 |
关键语义:append 是一个事务(BEGIN/COMMIT),mid-batch 失败整体回滚;create 惰性物化(首个 append 才写 sessions 行,create 后从未 append 的会话不在 list() 里);load 按共享崩溃恢复契约关闭被中断 turn;locate(meta) 返回 undefined(所有会话共用一个库,没有独立 transcript 路径)。
已知限制:DatabaseSync 是同步的(每次 append 事务阻塞事件循环)、写竞争无等待/重试、只开 pristine 新库或当前 layout 版本、不删除会话。
十一、持久化时机:checkpoint-policy
session-checkpoint-policy 是零配置的语义持久化策略,决定"什么时候必须落盘"。它消费 ctx.sessions/ctx.llm/ctx.tools,并在 ctx.sessionPersistence 存在时生效(与任一持久化后端搭配)。
在三个边界做 checkpoint:
| 边界 | 保证 |
|---|---|
| model adapter 收到请求之前 | 该请求对应的 buffered 事件已 durable |
| 顶层 tool body 可能产生外部副作用之前 | 记录的调用已 durable 才进 body |
每个 agent/pre-step | 前一个 response 与有序 tool 结果在下一个请求前 durable |
持久化与 checkpoint 调度是刻意拆开的两个插件:持久化后端跑有界后台批量 append、把每次 session/flush 变成即时 quiescence 屏障;本策略只选 request / tool-dispatch / next-step 三个屏障。只挂后端不挂本策略也合法,但 crash 可能丢掉 batching 窗口内或未完成的写。checkpoint 拒绝是 fail-closed:model 与 tool 边界拒绝则不跑 adapter / tool body;step 边界拒绝让 turn 失败。并发 tool checkpoint 共享 session store 的串行 drain,不会重复 seq。
挂载状态:默认挂载(与 JSONL 后端一起)。
十二、投影持久化缓存:projection-cache
session-projection-cache 提供 ctx.sessionProjectionCache:每个已注册投影单元状态的 durable checkpoint,每会话一条,落在 domain data form(session_projcache domain;json 后端落在配置 storage root 下的 workspace.json 旁)。
一条存储行 (key → {ver, seq, val}) 是 fold 捷径,不是权威:可能 stale(seq 精确说明 stale 到哪),但绝不错。由此而来的承诺:
- 每个后台写 fail-soft:失败记 warning、保持 stale,下次写或冷读自愈;crash 只多付一段 tail replay,不会给错值
ver与 live 单元stateVersion不匹配 → 读时丢弃、不迁移,key 从日志重新 fold- whole-record write:每次写替换会话完整 checkpoint,经 lossless-JSON 边界快照;不满足 plain-JSON 的单元状态 fail loud
- record 绑定 log 生命周期(存 header 的
createdAt/cwd),每次读校验;删了重建的 id 或换掉的 store 会丢弃无关 record,不会种 phantom 值 - log 在前、cache 跟随:live checkpoint 先把 buffered events durable flush 再落 cache row;crash 只能让 cache 落后于 log,不会超前
写策略是两个强制点 + 两个配置节流:
| 触发 | 性质 |
|---|---|
turn/end | 强制(冷读想要 turn-final 值) |
| 会话 disposal(detach) | 强制(live→cold 时刻) |
writeEveryEvents 个 committed events | 配置节流(计数) |
首个 dirty event 起 writeIntervalMs | 配置节流(间隔) |
writeEveryEvents / writeIntervalMs 都必填无默认。读阶梯:零 I/O 的 cachedSnapshot(meta)(只读 identity 匹配、version 匹配的 stored record)与 coldSnapshot(id)(cache → restoreFloor → persistence readFrom → restore → fail-soft 写回)。
挂载状态:默认挂载(Web 组合里 writeEveryEvents: 200、writeIntervalMs: 5000;不挂时投影系统只跑 live-only)。
十三、会话标题:三件套
会话标题由 ctx.sessionTitle seam 提供,模型支撑的标题 provider 共享同一套实现策略(库 session-title-llm),真正可选的是两个 provider 插件:
| 包 | 形态 | cadence | 挂载 |
|---|---|---|---|
session-title-llm | 库(非 cordis 插件) | 共享实现策略,provider 调 registerSessionTitleLlmProvider() 注册 | — |
session-title-first-prompt-llm | 插件 | first-prompt:总结第一条合格用户 prompt,仅 fresh 非 fork 会话首次创建 fallback 时自动跑一次;自动失败保留 fallback,只能 ctx.sessionTitle.refresh() 重试 | 默认挂载 |
session-title-all-prompts-llm | 插件 | all-prompts:每个新 human prompt 后起新 revision(含 seeded history 与子会话 prompt);新 revision 中止并取代旧工作 | opt-in |
共享配置(除 route override 外全必填,无库级默认):
| 键 | 约定 |
|---|---|
targetWords | 非 CJK 标题目标词数 |
targetCjkCharacters | 中/日/韩标题目标字符数 |
maxInputBytes | 最终 JSON-framed 用户提示的 UTF-8 字节上限 |
maxOutputTokens | 辅助生成 token 上限 |
timeoutMs | 端到端 deadline |
provider,model | 可选显式 route;两者都填或都不填 |
route 与失败契约:不填 provider/model 对就用当前会话已记录 request/header 的精确 route;在尚无 route 时显式 refresh 需 override。JSON-framed 用户提示(含 seq 字段、包装、转义)按 maxInputBytes 先校验后 dispatch,超则拒绝而非截断;malformed/空输出、tool call、非 stop finish reason 都拒绝。dispatch 前会直接经 Session append 一条 log-only 的 session/title-llm-request 事件(含 provider id、精确 source seqs、route、system prompt、message list、output-token cap),持久化 eager 观察。
标题请求与主对话完全分离:主 agent 请求 零额外 token;标题 purpose 映射为 thinking-disabled(DeepSeek adapter),主对话保留其 configured thinking 模式。
十四、遥测:session-telemetry-otel
session-telemetry-otel 是 telemetry seam 的唯一装载入口(OpenTelemetry 后端),mode 决定它跟随会话事件、只在记录反馈时回放、还是保持本地:
| mode | 行为 |
|---|---|
FULL | 默认。每个投影 record(含 lifecycle ops)立即交给 OTel SDK |
FEEDBACK_ONLY | 每个 feedback/record 回放、投影、redact canonical log 后缀;之后 record 等下一个 feedback,没有就留本地 |
DISABLED | 不构造任何 pipeline,无 record 出进程;feedback 留在本地会话日志 |
它按原样组合 OTel JS SDK(LoggerProvider → BatchLogRecordProcessor → OTLP/HTTP log exporter),把每个 record map 到 logger.emit(),两个 instrumentation scope(ledger / ops)。Resource identity 含 service.name/service.version(来自 dsh-llm 的 APP_IDENTITY)加匿名 user.id($DSH_HOME/.anonymous-user-id,随机 UUID)。
上传授权 fail-closed:未知 mode 在读 transport config 前失败;只有 FULL 接受直接 ctx.sessionTelemetry.emit();FEEDBACK_ONLY 只把已存在 session.events[event.seq] 的精确 feedback/record 当 consent;DISABLED 即便有 exporter 配置也不构造 SDK pipeline。seam 通过 TelemetrySharingStatus.sharing(full/feedback-only/disabled)披露 mode。
出了本机的是什么:上传模式下 record 携带完整 event.data(telemetry seam 的 raw 副本)——用户/助手消息全文、工具参数与结果(命令输出、文件内容)、system prompt、tool schema、todo、compaction summary、hook stderrSummary、feedback 文本、会话 cwd。seam 无 redaction 规则;跨信任边界部署需自挂 session-telemetry/record redaction 规则。详见 数据与隐私。
挂载状态:默认挂载(mode 默认 FULL;endpoint 缺省 https://harness-telemetry.deepseeksvc.com/v1/logs,可用 DSH_TELEMETRY_OTLP_URL 覆盖,DSH_TELEMETRY_DISABLED 任意非空值关闭)。
十五、损坏与格式策略
| 场景 | 策略 |
|---|---|
| 撕裂尾部碎片 | 丢弃 |
| 已提交损坏 / 格式错误 | SessionPersistenceCorruptionError 拒绝 |
| 未知事件类型(非 ignorable) | SessionFormatUnsupportedError 拒绝 |
| 未知事件类型(ignorable) | 跳过 |
十六、验证
# 看 session 相关插件装载(挂载状态:持久化/checkpoint/投影缓存/标题/遥测)
dsh web --dump-config | grep -iE "persistence|checkpoint|projection-cache|session-title|telemetry-otel"
# 会话日志是压缩 JSONL(zstd),先解压再读,一行一事件
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | head -3
# 类型分布(看一个会话存了哪些事件)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | jq -r .type | sort | uniq -c
# 看请求头重建来源
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep "request/header" | head -1
# 看 turn 结束原因
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep "turn/end" | tail