这是《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/start、turn/end(带结束原因)、step/start、step/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 角色,其内容是带调用关联信息的 ToolResultBlock。assistant/message 则按投影函数进入 assistant 角色。10 条日志投影出 3 条消息这个数字只描述上述作者 demo;普遍规则由前面的投影源码给出。
这条示例输出最直观地说明了投影筛选:chunk、边界和 tool/call 仍在日志里,但不会出现在由 deriveMessages() 生成的模型历史中。不要把这段本机输出扩大成对所有 provider 或所有会话入口的外部协议结论。
三、事件三域:日志是事实源,事件是控制流
会话日志是持久事实,而运行时事件承载控制流。本文按用途把事件整理为三组;这是一种分析分组。仓库中文架构文档对三类事件的原文是:
- 会话事件是追加到日志并通过
session/event广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。- Agent 事件(
agent/*)携带活跃Agent:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。- 能力事件无需导入循环即可向某个 seam(
fs/*、tools/*、telemetry/*)附加策略和适配器。

图:事件三域。中央是会话事件域(append-only 日志,持久化),左侧 Agent 事件域(agent/ 驱动轮次),右侧能力事件域(llm/tools/* 附加策略)。*
会话事件域:session/event 广播,每次 append 都发出。这是唯一持久的事件域——存储后端(JSONL 等)订阅它落盘。事件携带 session 与 event 两个参数,监听者只读。
Agent 事件域:agent/* 事件携带活跃 Agent 实例,是驱动轮次的实时控制流。四个核心事件全是类型化的(packages/core/agent/src/runtime-types.ts):
| 事件 | 模式 | 载荷 | 用途 |
|---|---|---|---|
agent/pre-step | waterfall | messages, turn, step, signal | 改写进入步骤的消息、或拒绝进入(返回 typed decision) |
agent/request | waterfall | agent, turn, step, signal | 模型选择、请求头构造 |
agent/request-error | waterfall | failure, retryPolicy, signal | 失败处置、重试决策 |
agent/turn-stopping | serial | turn, signal | 轮次收尾策略(无 next,不可委托) |
Agent 流程中的某些事件也会被显式追加到 SessionEventMap,例如 inbox 的变更记录。本文把这类事件按它的持久化用途归入会话日志讨论,不把“事件名称含 agent”误写成两个独立事件域同时拥有同一条记录。
能力事件域:llm/stream、tools/pre-execute、tools/execute、tools/post-execute、tools/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/start 与 user/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() 负责追加 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/end。tool/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?)选取到boundaryseq(含)为止的源事件,要求所选前缀结束时没有开放轮次,然后创建一个活跃的子会话,包含深克隆的种子事件和子会话元数据。
因此:
- 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/存储插件,把这篇转给他——事件三域图值得收藏。
