这是《DeepSeek Harness 权威指南 》系列的第 4 篇。 本文源码基线为 deepseek-harness @ 47f9438(v0.1.0-rc.5)。源码和中文文档结论在首次出现处说明来源;本机 demo 只说明该脚本、命令和环境下的行为。

就会话历史而言,模型收到的 Message[] 是事件日志的投影。 dsh 不把运行过程直接当作消息数组保存:Session.deriveMessages() 从可见事件节点生成模型历史。本文用一段作者本机示例展示 10 条日志如何投影为 3 条消息;该示例解释 API 形状,不把 scratch 脚本当作 47f9438 已提交的可复现工件。

系列第 3 篇讲了插件树的挂载与卸载,本篇进入运行时的心脏:会话日志怎么记录一次 agent 交互、事件三域怎么分工、turn/step 轮次怎么在事件上流转

一、会话日志:append-only 事件源

dsh 不用"消息数组"存对话,用事件日志。两者差别不是形式,是信息量:消息数组只保留结论(最终消息),事件日志保留全过程——流式 token 块、轮次边界、工具调用参数、请求头、待办快照,一样不落。

SessionEventMap 是这份日志的词汇表,插件可通过 TypeScript 的 declaration merging 扩展它。它的文件头直接定义了这份契约(packages/core/session/src/types.ts:230-236):

/**
 * The merge-extensible, append-only source of truth for an agent interaction.
 * Message history is derived from this log. Every event is lossless JSON and
 * sequence numbers stay contiguous, including raw chunks, so persistence can
 * store the canonical log verbatim.
 */
export interface SessionEventMap {

中文意思是:它是一次 agent 交互中可通过类型合并扩展、只能追加的事实源;消息历史由该日志推导;每个事件必须能无损表示为 JSON,序号连续(包括原始 chunk),因而持久层可以原样保存规范日志。核心事件分三类:

轮次边界——turn/startturn/end(带结束原因)、step/startstep/end。这些事件标记一次交互的结构,不投影为消息。

消息事件——user/message(用户输入或注入的上下文)、assistant/chunk(流式块,token 级重放保真)、assistant/message(组装后的助手消息,携带 token 用量)、tool/call(模型请求工具的原始参数,未解析的 JSON 字符串)、tool/result(工具结果,含可选的错误身份和工具私有 meta)。前三类中的消息类事件投影为模型历史。

仅日志记录——request/header(每次请求的完整请求头)、request/context(路由元数据)、todo/write(待办全量快照)、session/end-seed(种子边界标记)。

这份契约的行动含义是:会话事件的数据必须 JSON 无损,序号必须连续;不能把“中间删一条事件”当作正常压缩手段。后文的 compaction 只替换模型可见 surface,而不是把既有日志记录挖掉。

二、模型上下文 = 日志投影

日志是事实源,但模型不直接读整份日志。每个事件能否变成模型消息,由下面这段真实源码决定(packages/core/session/src/surface.ts:83-113):

export function deriveEventMessage(event: SessionEvent): Message | null {
  // Intentionally non-exhaustive: only message-producing events derive
  // history; turn/step boundaries, chunks, usage, and errors are trace/replay
  // data.
  switch (event.type) {
    // Ordinary prompts and injected context project in user role: the event's
    // model-facing content stays verbatim. Do NOT re-add per-type framing
    // (e.g. `<context>`) here: framing is caller-owned — a producer bakes it
    // into `content`, as agent-instructions does with `<system-reminder>` — or,
    // if reintroduced, must be driven by the event `meta` map and a dedicated
    // renderer, keeping this projection a verbatim pass-through. See the
    // deferred design note in
    // ../../../../.agents/notes/implemented/simplification/2026-07-20-unwrap-injected-content-envelopes.md
    case 'user/message': {
      return event.data
    }
    case 'assistant/message': {
      // Skip an empty-content assistant/message: it exists only to host a
      // max-tokens step's usage and must not inject a content-less assistant
      // turn into the provider transcript.
      if (event.data.message.content.length === 0) return null
      return event.data.message
    }
    case 'tool/result': {
      return event.data.message
    }
    default:
      // A non-surface event (boundary, chunk, log-only record) projects to
      // no message. Merge-extensible union: no assertNever here.
      return null
  }
}

中文按分支读即可:user/message 原样进入用户角色;有内容的 assistant/message 进入助手角色,只有 token 记账的空助手消息被跳过;tool/result 进入工具结果消息;轮次边界、chunk 与仅日志记录全部返回 null。代码中的 trace/replay data 是“用于追踪和重放的数据”,不是模型历史。

Session.deriveMessages() 再把当前可见节点逐个送进这个函数(packages/core/session/src/index.ts:726-746):

  deriveMessages(): Message[] {
    const surface = this.surface
    const nodes = surface.nodes
    const generation = surface.replaceGeneration
    if (generation !== this.derivedGeneration) {
      this.derived = []
      this.derivedNodes = 0
      this.derivedGeneration = generation
    }
    for (const seq of nodes.slice(this.derivedNodes)) {
      // Surface sequences are built from this.log — seq is always a valid
      // index by construction. The non-null assertion expresses that invariant.
      // oxlint-disable-next-line typescript/no-non-null-assertion
      const msg = this.deriveEventMessage(this.log[seq]!)
      // A surface node is one of the five message-producing types, but an
      // empty-content assistant/message (a max-tokens step that hosts only
      // usage) derives to null and must not enter the transcript.
      if (msg) this.derived.push(msg)
    }
    this.derivedNodes = nodes.length
    return [...this.derived]
  }

它只处理新增的可见节点;遇到 surface 替换代际变化时清空缓存重建。返回的是一个新数组,但其中消息对象与日志中的冻结数据共享。这个实现能证明会话历史的投影规则和增量缓存;不能据此断言所有 UI、存储或重放调用者都直接调用同一个函数。

下面是一段作者本机示例:它使用 @deepseek-ai/dsh-session API 模拟一轮写入。脚本不属于 47f9438 的已提交源码,因此输出用于说明 API 形状,不作为可独立复现的基线证据。

/**
 * A3 会话与事件系统实证:追加日志 -> 模型历史投影
 * 运行:node --import tsx packages/core/session/scratch/event-log-demo.ts
 */
import { Context } from '@deepseek-ai/cordis'
import {
  SessionId, SessionStore,
  type Session, type SessionEvent,
} from '@deepseek-ai/dsh-session'
import {
  MessageId, createAssistantMessage, createToolResultMessage, createUserMessage,
} from '@deepseek-ai/dsh-llm'

async function main() {
  const app = new Context()
  await app.plugin(SessionStore)
  const session: Session = app.sessions.create(SessionId('demo'), {})

  // ---- 一轮真实 turn 的事件流(与 agent-loop 驱动器写入顺序一致)----
  session.append('turn/start', { turn: 1 })
  session.append('user/message', createUserMessage({
    content: [{ type: 'text', text: '读一下 package.json 的 name 字段' }],
    source: { kind: 'user' },
  }), { surfaceOp: 'append' })
  session.append('step/start', { turn: 1, step: 1 })
  // 流式 chunk:token 级重放保真,但不投影为消息
  session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '我来' } })
  session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '看看' } })
  session.append('tool/call', { turn: 1, step: 1, callId: MessageId('c1'), name: 'fs_read', arguments: '{"path":"package.json"}' })
  session.append('tool/result', {
    turn: 1, step: 1,
    message: createToolResultMessage({
      callId: MessageId('c1'),
      content: [{ type: 'text', text: '{"name":"dsh"}' }],
      isError: false,
    }),
  }, { surfaceOp: 'append' })
  session.append('assistant/message', {
    turn: 1, step: 1,
    message: createAssistantMessage({
      content: [{ type: 'text', text: 'name 字段是 dsh。' }],
    }),
  }, { surfaceOp: 'append' })
  session.append('step/end', { turn: 1, step: 1 })
  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })

  // ---- 日志全览 ----
  console.log('=== 会话日志(append-only,seq 连续)===')
  for (const event of session.log) {
    const brief = summarize(event)
    console.log(`seq=${String(event.seq).padStart(2)} type=${event.type.padEnd(18)} ${brief}`)
  }

  // ---- 投影 ----
  console.log('\n=== deriveMessages():模型历史投影 ===')
  const messages = session.deriveMessages()
  for (const m of messages) {
    const text = m.content.map(blockText).join('')
    console.log(`role=${m.role.padEnd(9)} ${text}`)
  }
  console.log(`\n日志 ${session.log.length} 条事件 -> 投影 ${messages.length} 条消息`)

  // ---- 追加一条新输入,增量投影 ----
  console.log('\n=== 追加第二条用户消息后重新投影 ===')
  session.append('user/message', createUserMessage({
    content: [{ type: 'text', text: '再读一下 version 字段' }],
    source: { kind: 'user' },
  }), { surfaceOp: 'append' })
  const messages2 = session.deriveMessages()
  for (const m of messages2) {
    const text = m.content.map(blockText).join('')
    console.log(`role=${m.role.padEnd(9)} ${text}`)
  }
}

function blockText(b: any): string {
  if (b.type === 'tool-result') return b.content?.map(blockText).join('') ?? ''
  return b.text ?? b.callId ?? ''
}

function summarize(event: SessionEvent): string {
  switch (event.type) {
    case 'user/message': return (event.data as any).content?.map(blockText).join('') ?? ''
    case 'assistant/message': return (event.data as any).message?.content?.map(blockText).join('') ?? ''
    case 'tool/call': return `${event.data.name}(${(event.data as any).arguments})`
    case 'tool/result': return `→ ${(event.data as any).message?.content?.map(blockText).join('') ?? ''}`
    case 'assistant/chunk': return (event.data as any).chunk?.text ?? ''
    case 'turn/start': case 'turn/end': case 'step/start': case 'step/end':
      return `turn=${event.data.turn}${'step' in event.data ? ` step=${(event.data as any).step}` : ''}`
    default: return ''
  }
}

main().catch((err) => { console.error(err); process.exit(1) })

作者本机示例输出:

=== 会话日志(append-only,seq 连续)===
seq= 0 type=turn/start         turn=1
seq= 1 type=user/message       读一下 package.json 的 name 字段
seq= 2 type=step/start         turn=1 step=1
seq= 3 type=assistant/chunk    我来
seq= 4 type=assistant/chunk    看看
seq= 5 type=tool/call          fs_read({"path":"package.json"})
seq= 6 type=tool/result        → {"name":"dsh"}
seq= 7 type=assistant/message  name 字段是 dsh。
seq= 8 type=step/end           turn=1 step=1
seq= 9 type=turn/end           turn=1

=== deriveMessages():模型历史投影 ===
role=user      读一下 package.json 的 name 字段
role=user      {"name":"dsh"}
role=assistant name 字段是 dsh。

日志 10 条事件 -> 投影 3 条消息

=== 追加第二条用户消息后重新投影 ===
role=user      读一下 package.json 的 name 字段
role=user      {"name":"dsh"}
role=assistant name 字段是 dsh。
role=user      再读一下 version 字段

输出中的 tool/result 投影为 user 角色,不需要借用外部协议“惯例”解释;dsh 自己的消息类型就是这样定义的(packages/llm/llm/src/message.ts:151-156):

/** A tool-result specialization whose model-facing block retains call correlation. */
export interface ToolResultMessage extends Message {
  readonly role: 'user'
  readonly content: [ToolResultBlock]
  readonly source: ToolMessageSource
}

中文意思是:工具结果消息使用 user 角色,其内容是带调用关联信息的 ToolResultBlockassistant/message 则按投影函数进入 assistant 角色。10 条日志投影出 3 条消息这个数字只描述上述作者 demo;普遍规则由前面的投影源码给出。

这条示例输出最直观地说明了投影筛选:chunk、边界和 tool/call 仍在日志里,但不会出现在由 deriveMessages() 生成的模型历史中。不要把这段本机输出扩大成对所有 provider 或所有会话入口的外部协议结论。

三、事件三域:日志是事实源,事件是控制流

会话日志是持久事实,而运行时事件承载控制流。本文按用途把事件整理为三组;这是一种分析分组。仓库中文架构文档对三类事件的原文是:

  • 会话事件是追加到日志并通过 session/event 广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。
  • Agent 事件agent/*)携带活跃 Agent:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。
  • 能力事件无需导入循环即可向某个 seam(fs/*tools/*telemetry/*)附加策略和适配器。

dsh 事件三域:会话事件持久可重放,Agent/能力事件是实时控制流

图:事件三域。中央是会话事件域(append-only 日志,持久化),左侧 Agent 事件域(agent/ 驱动轮次),右侧能力事件域(llm/tools/* 附加策略)。*

会话事件域session/event 广播,每次 append 都发出。这是唯一持久的事件域——存储后端(JSONL 等)订阅它落盘。事件携带 sessionevent 两个参数,监听者只读。

Agent 事件域agent/* 事件携带活跃 Agent 实例,是驱动轮次的实时控制流。四个核心事件全是类型化的(packages/core/agent/src/runtime-types.ts):

事件模式载荷用途
agent/pre-stepwaterfallmessages, turn, step, signal改写进入步骤的消息、或拒绝进入(返回 typed decision)
agent/requestwaterfallagent, turn, step, signal模型选择、请求头构造
agent/request-errorwaterfallfailure, retryPolicy, signal失败处置、重试决策
agent/turn-stoppingserialturn, signal轮次收尾策略(无 next,不可委托)

Agent 流程中的某些事件也会被显式追加到 SessionEventMap,例如 inbox 的变更记录。本文把这类事件按它的持久化用途归入会话日志讨论,不把“事件名称含 agent”误写成两个独立事件域同时拥有同一条记录。

能力事件域llm/streamtools/pre-executetools/executetools/post-executetools/result,向能力 seam 附加策略(门禁、超时、重试、结果变换),不直接产生日志。

三个组的分工可以这样读:会话事件记录“发生了什么”并可持久化;Agent 与能力事件帮助系统决定“正在怎么跑”。waterfall / serial 的委托与执行语义由各事件定义决定,不能只根据名称猜测。

四、turn/step 轮次:事件在驱动器里流转

轮次是 dsh 运行时的主节奏:一次用户输入开启一个 turn,turn 由 0 到 N 个 step 组成;每个 step 是一次模型请求加上它调用的工具执行。 工具结果返回模型后,模型可能再请求工具——同一个 turn 里继续下一个 step。

驱动器(core/agent-loop 的默认实现)通过 turn() 推进轮次。下面是实际写入主要会话事件的完整函数源码(packages/core/agent-loop/src/agent.ts:245-330):

  /** Open one turn before claiming its first proposed step. */
  private async turn(): Promise<boolean> {
    if (this.phase.kind !== 'running') {
      this.throwError(new Error(`agent "${this.id}": turn without driver reservation`))
    }
    const phase = this.phase
    const { signal } = phase.abort
    signal.throwIfAborted()
    const turn = phase.turn + 1
    try {
      this.session.append('turn/start', { turn })
    } catch (error: unknown) {
      this.throwError(error)
    }
    phase.turn = turn
    let turnEnds: TurnEndReason | null = null
    let target: InboxTarget = 'next-turn'
    try {
      while (true) {
        signal.throwIfAborted()
        const step = phase.step + 1
        const decision = await this.preStep(target, { turn, step })
        if (decision.kind === 'reject') {
          turnEnds = { kind: 'blocked' }
          return false
        }
        if (turnEnds && decision.messages.length === 0) break
        // A removed waking message or an enter decision rewritten to empty
        // still owns the initial turn boundary, but it spends no model call.
        if (phase.step === 0 && decision.messages.length === 0) {
          turnEnds = { kind: 'completed' }
          return false
        }
        signal.throwIfAborted()
        this.session.append('step/start', { turn, step })
        phase.step = step
        try {
          for (const message of decision.messages) {
            this.session.append('user/message', message, { surfaceOp: 'append' })
          }
          // max-tokens is sticky: once any step hits the ceiling, later steps
          // that complete normally must not downgrade the turn outcome.
          const stepEnd = await this.step(decision.assembly)
          // max-tokens stays sticky: a later completed step must not
          // downgrade the turn outcome.
          if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd
        } finally {
          this.session.append('step/end', { turn, step })
        }
        signal.throwIfAborted()
        if (turnEnds && this.inbox.nextStep.length === 0) {
          await this.dispatch.serial('agent/turn-stopping', { turn, signal })
          signal.throwIfAborted()
        }
        if (turnEnds && this.inbox.nextStep.length === 0) break
        target = 'next-step'
      }
    } catch (error: unknown) {
      if (signal.aborted) {
        turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
        throw error
      }
      // Every failure is structured: an `LlmError` keeps its facts, anything
      // else flattens to `errorChain` text under the `UNKNOWN` code.
      turnEnds = {
        kind: 'error',
        error: error instanceof LlmError
          ? error.failure
          : { message: errorChain(error), code: 'UNKNOWN' },
      }
      this.throwError(error)
    } finally {
      try {
        // oxlint-disable-next-line typescript/no-non-null-assertion -- every exit assigns a turn ending
        this.session.append('turn/end', { turn, reason: turnEnds! })
      } catch (error: unknown) {
        this.throwError(error)
      }
    }
    if (!this.inbox.hasPending) return false
    phase.abort = new AbortController()
    // A fresh controller makes a latch set on the old one stale: the live driver claims the queue itself.
    phase.wakeRequested = false
    phase.step = 0
    return true
  }

代码的中文要点是:turn/start 在领取输入和执行 preStep 前写入;只有进入 step 后才写 step/startuser/message;无论 this.step() 成功或失败,finally 都会写 step/end;当没有下一步输入时才分发 agent/turn-stopping。英文注释中的 sticky 指“max-tokens 状态一旦发生,之后成功的 step 不会把轮次结局降级”。

turn/end 位于同一个函数的 finally,所以正常退出、拒绝或异常路径都会尝试记录轮次结局。

因此图里的事件顺序应该理解为该驱动器版本的执行路径;工具调用和工具结果的持久化还由独立的 tool-calls.ts 追加,不能归因给 agent.ts 一处。实际写入代码是(packages/core/agent-loop/src/tool-calls.ts:261-289):

/** Append a started call and return the event seq that its result must cite. */
function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): number {
  const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments })
  return event.seq
}

/** Append a model-ordered result linked to its call event. */
function appendToolResult(
  session: Session,
  turn: number,
  step: number,
  block: ToolCallBlock,
  result: ToolExecutionResult,
  callSeq: number,
): void {
  const message = createToolResultMessage({
    callId: block.id,
    content: result.content,
    isError: result.isError,
  })
  session.append('tool/result', {
    turn, step,
    message,
    ...result.error?.info ? { error: result.error.info } : {},
    // The tool's private presentation payload (e.g. a result-time diff),
    // persisted so a UI bridge reproduces the card on replay.
    ...result.meta !== undefined ? { meta: result.meta } : {},
  }, { surfaceOp: 'append', sourceEventSeqs: [callSeq] })
}

中文意思是:tool/call 先写入原始参数并返回自己的事件序号;tool/result 使用同一 callId 创建模型可见结果,并通过 sourceEventSeqs 关联回调用事件。英文 private presentation payload 指工具私有的展示数据,例如结果时的 diff;它被持久化是为了 UI 重放时能恢复同一张结果卡。

turn/step 时序:一轮轮次的真实事件顺序

图:turn() 负责追加 turn/step 边界与用户消息;工具调用和结果由 tool-calls.ts 的独立函数写入。图展示的是 47f9438 默认驱动器的一条执行路径。

流程的四个关键点:

第一,turn/start 先于任何输入。 驱动器在领取输入前就 append('turn/start')——这就是为什么"首次领取被拒/改写为空仍会关闭不含 step 的持久轮次":turn 可能开而不进 step,但边界必须记录。

第二,用户消息经 inbox 领取后落日志。 inbox.claim() 从持久队列取出输入,user/message 事件带 surfaceOp: 'append' 写入。agent.inject() 注入的上下文(文件变更通知、子目录 AGENTS.md、技能内容)也走同一个 user/message 事件,靠 source 字段区分来源——模型看到的是同一形态的输入。

第三,step 的边界是模型请求和工具执行。 step/start → 模型请求(agent/request waterfall → 适配器流式返回 assistant/chunk*)→ 若模型请求工具:tool/call(原始参数 JSON 落日志)→ 工具经 tools/* 事件执行 → tool/result 落日志 → step/endtool/result 是模型上下文的组成部分(投影),tool/call 不是——日志保留调用参数,投影只给结果。

第四,轮次收尾可被拦截。 驱动器在决定结束 turn 时发 agent/turn-stopping(serial),监听者可以触发 followup() 继续下一轮——这是 /loop、动态工作流这些功能的挂点。之后 turn/end 带结束原因落日志(completed / blocked / max-tokens / canceled 等)。

五、fork / resume / compaction:日志的可重放性

append-only 日志可以支持重放,但三种操作的规则并不靠“日志永远一致”这种口号。中文会话文档的关键原文是:

Session.deriveMessages() 将事件日志投影为模型看到的 Message[]。它是缓存的(每个 surface 节点在首次出现时投影一次;surface 重写触发重建)且冻结的(每次调用返回一个新数组,引用共享的深冻结消息,因此通过投影修改已记录的历史在类型上不可表达)。

fork(source, boundary?, childSessionId?) 选取到 boundary seq(含)为止的源事件,要求所选前缀结束时没有开放轮次,然后创建一个活跃的子会话,包含深克隆的种子事件和子会话元数据。

因此:

  • resume:重新打开的会话把已存储日志作为构造 seed;session/end-seed 是持久历史中识别 seed 边界的记录,而不是“这之前永远不能动”的业务标记。
  • fork:从稳定、没有开放轮次的日志前缀创建子会话;显式 boundary 允许从较早的稳定位置分支,API 会拒绝在开放轮次中间静默截断。
  • compaction:中文压缩文档明确规定 compaction/* 都只写日志、绝不进入 surface;摘要本身放在一条带 surfaceOp: { op: 'replace', start, end }user/message 上。这是摘要压缩执行时唯一的 surface 变化,不是删除已有日志事件。

所以 compaction 改变的是模型可见的 surface 选择,原始事件仍保存为审计和重放依据。格式目前仍是未发布的开发者预览格式,文档明确说它不提供兼容性承诺;升级时必须验证持久数据和事件词汇。

六、代价与边界

事件溯源不是免费午餐。三个代价:

第一,日志是写放大。 每条 chunk 都落日志(demo 里两个 2 字 chunk 各占一条事件),长会话的日志体积远大于消息数组。dsh 用 chunk 打包存储(packChunkRuns)缓解,但"全量保留"的成本是设计选择,不是 bug。

第二,投影规则的边界要守。 在当前 Session.deriveMessages() 路径中,新的模型可见输入必须由会话事件的 surface 规则产生;插件不能靠“悄悄改数组”插入模型历史。这个边界让已记录事件和派生消息可以对照审计,但它只描述当前会话子系统的契约。

第三,事件语义仍会演进。 47f9438 仍处于开发者预览;会话文档明确说明该格式尚未发布、不提供兼容性承诺。不要把现有事件类型或投影细节视为稳定 seam;升级前需要对持久日志、事件词汇和派生历史做回归验证。

七、决策表

设计问题方案 A(消息数组)方案 B(事件日志)淘汰 A 的原因
对话怎么存消息数组 + 元数据append-only 事件日志数组丢失流式块/调用参数/边界,无法重放
模型上下文怎么来直接读数组deriveMessages() 投影投影让"日志"与"模型所见"可分离校验
UI 怎么渲染读同一份数组订阅 session/event 流实时流式 + 持久重放共用同一事实源
断点续传存快照日志种子重建快照可能过期,日志永远一致
新输入怎么进直接追加数组新增 SessionEvent 类型不变量约束保证审计完整性
上下文压缩截断数组surface replace 投影截断破坏日志连续性,replace 保留事实

八、系列路线

下一篇进入工具系统:作用域化工具注册表、pre/execute/post-execute 执行流水线、审批与权限——LLM 的信息通道宽度如何决定工具设计上限。

下一篇:工具系统:LLM 的信息通道


FAQ

Q:dsh 的会话日志和普通消息数组有什么区别? 会话日志是 append-only 事件日志(SessionEventMap),除了消息还记录流式 chunk、轮次边界、工具调用参数等全部事件,seq 连续、JSON 无损;模型上下文只是对日志的投影(deriveMessages),日志本身才是唯一事实源。

Q:哪些会话事件会投影成模型消息? 只有三类:user/message、assistant/message、tool/result。turn/step 边界、assistant/chunk 流式块、tool/call 调用记录只进日志不投影。

Q:dsh 的事件三域是什么? 会话事件(session/event,追加日志、持久)、Agent 事件(agent/*,携带活跃 Agent 的实时控制流)、能力事件(向 llm/tools 等 seam 附加策略)。三域在 Cordis 事件总线上汇合。

Q:turn 和 step 是什么关系? 一次用户输入开启一个 turn,turn 由 0 到 N 个 step 组成;每个 step 是一次模型请求加上它调用的工具执行。工具结果返回模型后可能触发下一个 step。

Q:dsh 的 fork 和 resume 是怎么实现的? 会话日志是可重放的:resume 用存储的完整日志作为种子重建会话,fork 用父会话的日志前缀创建新会话。日志里的 session/end-seed 事件标记继承历史的边界。

Q:为什么新增模型可见输入必须新增 SessionEvent? 这是运行时不变量:模型上下文完全由日志投影而来,任何模型可见的输入(用户消息、注入上下文、工具结果)都必须先落日志。反过来,不落日志的东西模型永远看不到。


互动模块

① 站队:agent 框架的对话存储,事件溯源(dsh 的日志即事实源)和"消息数组 + 快照"(多数框架的做法),哪个会笑到最后?A. 事件溯源是终局 B. 快照简单够用 C. 混合:日志 + 定期压缩

② 征集:你在生产里调试过 agent 的"模型上下文神秘变化"吗——某句话突然出现在上下文里、或某段历史消失?最后是怎么定位的?评论区分享,我会在工具系统篇里结合真实案例展开。

③ 转发:如果你身边有人正要给 dsh 写日志/UI/存储插件,把这篇转给他——事件三域图值得收藏。