第十一章:Session 日志深度解析
本章目标:帮助你理解 JSONL 格式的逐行结构、事件序列号(SessionSeq)的单调递增保证、投影(Projection)的增量派生算法、以及日志重放(Replay)与 Fork 机制。阅读本章后,你应该能回答"Session 日志在磁盘上长什么样"以及"投影是如何从日志增量计算的"。
11.1 JSONL 格式
11.1.1 什么是 JSONL
JSONL(JSON Lines)是 dsh 选择的会话日志格式。每行是一个独立的 JSON 对象,对应一个 SessionEvent。
{"type":"turn/start","seq":1,"data":{"turn":1}}
{"type":"user/message","seq":2,"data":{"content":"Hello"}}
{"type":"assistant/message","seq":3,"data":{"content":"Hi there!"}}
{"type":"turn/end","seq":4,"data":{"reason":{"kind":"completed"}}}
11.1.2 为什么选择 JSONL
| 特性 | JSONL | SQLite | 纯 JSON |
|---|
| 追加写入 | ✅ O(1) | ⚠️ 需要事务 | ❌ 需要重写整个文件 |
| 流式读取 | ✅ 逐行 | ✅ 游标 | ❌ 需要完整解析 |
| 人类可读 | ✅ | ❌ | ✅ |
| 无依赖 | ✅ | ❌ 需要 sqlite3 | ✅ |
| 并发安全 | ✅ 行级 | ✅ 事务级 | ❌ |
| 压缩友好 | ✅ | ❌ | ✅ |
11.1.3 文件结构
$dsh-home/sessions/
├── <session-id>.jsonl # 会话日志
├── <session-id>.meta.json # 会话元数据
└── index.json # 会话索引
11.2 SessionEvent 详解
11.2.1 事件类型全景
11.2.2 事件类型定义
interface TurnStartEvent {
type: 'turn/start'
seq: SessionSeq
data: {
turn: number
source: string
}
}
interface UserMessageEvent {
type: 'user/message'
seq: SessionSeq
data: {
content: string
role: 'user'
}
}
interface AssistantMessageEvent {
type: 'assistant/message'
seq: SessionSeq
data: {
content: string
toolCalls?: ToolCall[]
role: 'assistant'
}
}
interface ToolCallEvent {
type: 'tool/call'
seq: SessionSeq
data: {
name: string
parameters: Record<string, unknown>
callId: string
}
}
interface ToolResultEvent {
type: 'tool/result'
seq: SessionSeq
data: {
callId: string
result: unknown
error?: string
}
}
11.3 SessionSeq:事件序列号
11.3.1 单调递增保证
SessionSeq 是一个品牌化数字类型,保证单调递增:
type SessionSeq = number & { __brand: 'SessionSeq' }
function SessionSeq(value: number): SessionSeq {
return value as SessionSeq
}
11.3.2 序列号分配
class SessionEventLog {
private nextSeq: SessionSeq = SessionSeq(1)
append(event: Omit<SessionEvent, 'seq'>): SessionEvent {
const seq = this.nextSeq++
const fullEvent = { ...event, seq } as SessionEvent
this.writeFile(JSON.stringify(fullEvent) + '\n')
return fullEvent
}
}
11.3.3 序列号的用途
排序:事件按 seq 排序
投影:投影使用 seq 判断是否已处理
Fork:Fork 时记录父会话的 seq 偏移
查询:按 seq 范围查询事件
11.4 投影(Projection)
11.4.1 概念
投影从日志增量派生状态。每次新事件到达时,投影更新其状态,而不需要重新计算整个日志。
11.4.2 ProjectionDefinition
interface ProjectionDefinition<K extends string, S> {
key: K
stateVersion: number
stateSchema: ZodType<S>
init(): S
apply(state: S, event: SessionEvent): S
}
11.4.3 投影实现示例
const turnBoundaryProjection: ProjectionDefinition<'turnBoundary', TurnBoundaryState> = {
key: 'turnBoundary',
stateVersion: 2,
stateSchema: zod.object({
openTurnStartSeq: zod.number().nullable(),
lastStepStartSeq: zod.number().nullable(),
lastStepBoundary: zod.object({
kind: zod.union([zod.literal('start'), zod.literal('end')]),
seq: zod.number()
}).nullable(),
lastTurn: zod.number()
}),
init: () => ({
openTurnStartSeq: null,
lastStepStartSeq: null,
lastStepBoundary: null,
lastTurn: 0
}),
apply: (state, event) => {
switch (event.type) {
case 'turn/start':
return {
...state,
openTurnStartSeq: event.seq,
lastTurn: event.data.turn
}
case 'turn/end':
return {
...state,
openTurnStartSeq: null
}
case 'step/start':
return {
...state,
lastStepStartSeq: event.seq,
lastStepBoundary: { kind: 'start', seq: event.seq }
}
case 'step/end':
return {
...state,
lastStepBoundary: { kind: 'end', seq: event.seq }
}
default:
return state
}
}
}
11.4.4 投影运行时
class ProjectionRuntime {
private projections = new Map<string, ProjectionInstance>()
register<K extends string, S>(definition: ProjectionDefinition<K, S>): void {
this.projections.set(definition.key, {
definition,
state: definition.init(),
lastSeq: SessionSeq(0)
})
}
apply(event: SessionEvent): void {
for (const instance of this.projections.values()) {
if (event.seq > instance.lastSeq) {
instance.state = instance.definition.apply(instance.state, event)
instance.lastSeq = event.seq
}
}
}
stateOf<K extends string>(key: K): ProjectionState<K> {
const instance = this.projections.get(key)
if (!instance) throw new Error(`Projection "${key}" not found`)
return instance.state
}
snapshot(keys: string[]): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const key of keys) {
result[key] = this.stateOf(key)
}
return result
}
}
11.5 日志重放(Replay)
11.5.1 概念
日志重放允许从现有日志重建会话状态。这是 Fork 和恢复的基础。
11.5.2 重放流程
class SessionReplay {
async replay(log: SessionEventLog, options: ReplayOptions): Promise<SessionState> {
let state = this.createInitialState()
for await (const event of log.read(options.offset)) {
state = this.applyEvent(state, event)
if (options.maxSeq && event.seq > options.maxSeq) {
break
}
}
return state
}
private applyEvent(state: SessionState, event: SessionEvent): SessionState {
switch (event.type) {
case 'user/message':
return { ...state, messages: [...state.messages, event.data] }
case 'assistant/message':
return { ...state, messages: [...state.messages, event.data] }
case 'tool/call':
return { ...state, pendingToolCalls: [...state.pendingToolCalls, event.data] }
case 'tool/result':
return {
...state,
pendingToolCalls: state.pendingToolCalls.filter(tc => tc.callId !== event.data.callId),
toolResults: [...state.toolResults, event.data]
}
default:
return state
}
}
}
11.6 Fork 机制
11.6.1 概念
Fork 允许从现有会话创建新会话,共享部分历史。
11.6.2 Fork 实现
class SessionFork {
async fork(
parentSession: Session,
options: ForkOptions
): Promise<Session> {
const forkPoint = options.forkPoint ?? parentSession.currentSeq
const history = []
for await (const event of parentSession.log.read()) {
if (event.seq > forkPoint) break
history.push(event)
}
const childSession = await this.createSession({
parentSession: parentSession.id,
isSeeded: true,
inheritedEventCount: forkPoint
})
await childSession.log.append(history)
return childSession
}
}
11.6.3 Fork 选项
interface ForkOptions {
forkPoint?: SessionSeq
copyMetadata?: boolean
meta?: Partial<SessionHeader>
}
11.7 SessionQuery 服务
11.7.1 查询接口
interface SessionQueryService {
list(options?: ListOptions): Promise<SessionHeader[]>
inspect(sessionId: SessionId): Promise<SessionSnapshot>
search(query: string): Promise<SessionSearchResult[]>
readLog(sessionId: SessionId, options?: ReadOptions): Promise<SessionEvent[]>
}
11.7.2 语义搜索
class SessionCorpus {
async semanticSearch(query: string): Promise<SearchResult[]> {
const queryEmbedding = await this.embed(query)
const results = await this.vectorStore.search(queryEmbedding, {
limit: 10,
threshold: 0.7
})
return results
}
}
11.8 配置
11.8.1 持久化配置
- id: session-persistence
config:
backend: 'jsonl'
jsonl:
dir: '${DSH_HOME}/sessions'
compress: true
maxFileSize: 10485760
cleanup:
enabled: true
maxAge: 2592000000
maxCount: 1000
11.8.2 投影配置
- id: session-projection
config:
projections:
- turnBoundary
- toolUsage
- errorSummary
cache:
enabled: true
maxSize: 1000
11.9 小结
| 概念 | 一句话解释 |
|---|
| JSONL | 每行一个 JSON 对象的日志格式 |
| SessionSeq | 单调递增的事件序列号 |
| Projection | 从日志增量派生状态 |
| Replay | 从日志重建会话状态 |
| Fork | 从现有会话创建新会话 |
| SessionQuery | 会话查询和语义搜索 |
下一步:第十二章:Typert 类型图系统——深入理解类型图生成、运行时类型注册和跨仓库类型发现。