SessionEvent:Agent 的记忆从哪里来?
对话、工具结果、界面恢复和持久化,为什么都能来自同一条事件流?
普通聊天应用常用一个 messages 数组保存对话。Agent 系统却多了工具调用、流式输出、Turn、Step、审批和恢复。如果仍然只存“用户问了什么、助手答了什么”,很多重要事实都会丢失。
一段回复不是完整历史
假设 Agent 最终回答:“测试已经通过。”
仅保存这句话,你无法知道:
- 它运行了哪条测试命令;
- 第一次测试是否失败;
- 修改了哪些文件;
- 模型看到了什么工具结果;
- 中途是否发生重试;
- 浏览器怎样还原实时过程。
对人类读者,最终答案可能足够;对需要恢复和审计的系统,远远不够。
因此 DSH 把 Session 设计成一条按顺序追加的 SessionEvent 日志。
SessionEvent 记录“发生过的事实”
典型事件包括:
- turn/start 与 turn/end;
- step/start 与 step/end;
- user/message;
- assistant/chunk 与 assistant/message;
- tool/call 与 tool/result。
这些事件按顺序形成时间线。已经发生的事实不会被悄悄改写,新的变化继续追加在后面。
持久化插件负责把事件保存到 JSONL、SQLite 等地方;Session 核心负责在内存中维护统一事件语义。即便更换持久化的 Provider,也不需要改变 Agent Loop 对日志的理解。
先看一条 SessionEvent 长什么样
假设用户输入:“请读取 README.md,并告诉我项目是做什么的。”这条消息进入 Agent Loop 后,会被记录成一条 user/message 事件。下面省略了随机生成的真实 UUID,只保留最有助于理解的字段:
{
"type": "user/message",
"seq": 2,
"time": 1720000000000,
"data": {
"id": "msg-user-1",
"role": "user",
"content": [
{
"type": "text",
"text": "请读取 README.md,并告诉我项目是做什么的。"
}
],
"source": {
"kind": "user"
}
},
"surfaceOp": "append"
}
这几个字段可以这样读:
type表示发生了什么,这里是一条用户消息;seq是它在当前 Session 中的顺序编号;time是发生时间;data保存这一类事件自己的具体内容;surfaceOp: append表示它会作为一条新消息出现在模型历史和聊天界面中。
所以 SessionEvent 并不神秘。它就是一条带有“类型、顺序、时间和数据”的事实记录。不同事件的外层结构相似,主要区别在于 type 和 data。
一次读取文件会产生哪些事件
继续上面的例子。模型需要先读取文件,才能回答用户。因此一个 Turn 会包含两个 Step:第一个 Step 调用 read,第二个 Step 根据文件内容给出答案。
下面按发生顺序列出主干事件。真实流式响应可能包含多条 assistant/chunk,这里将连续 chunk 合并成一行展示:
| 顺序 | SessionEvent | 它记录的事实 |
|---|---|---|
| 1 | turn/start | 第 1 个 Turn 开始 |
| 2 | step/start | Turn 1 的 Step 1 开始 |
| 3 | user/message | 用户要求读取 README.md |
| 4 | request/header | 本次模型请求使用的模型、系统提示词和工具列表 |
| 5 | request/context | 当前 Provider、模型和上下文窗口信息 |
| 6 | assistant/chunk | 模型流式输出了部分 Tool Call |
| 7 | assistant/message | 模型完整表达了“调用 read”这个决定 |
| 8 | tool/call | Harness 正式开始执行 read |
| 9 | tool/result | read 返回 README.md 的内容 |
| 10 | step/end | Step 1 结束 |
| 11 | step/start | Step 2 开始 |
| 12 | assistant/chunk | 模型开始流式生成最终回答 |
| 13 | assistant/message | 完整回答组装完成 |
| 14 | step/end | Step 2 结束 |
| 15 | turn/end | 没有后续工作,Turn 结束 |
这条时间线说明:SessionEvent 记录的不是一句“读完了”,而是 Agent 怎样一步步走到最终回答。
其中 assistant/message 里的 Tool Call 表示模型的意图,紧随其后的 tool/call 表示 Harness 已经开始执行。二者看起来相近,但含义不同:模型提出调用,不等于工具一定已经运行。
再看 tool/call 和 tool/result
下面两个片段继续省略 time 等与本例关系不大的字段。tool/call 会保留模型产生的工具名和原始参数字符串:
{
"type": "tool/call",
"seq": 7,
"data": {
"turn": 1,
"step": 1,
"callId": "call-read-1",
"name": "read",
"arguments": "{\"file_path\":\"README.md\"}"
}
}
工具执行成功后,再追加一条 tool/result:
{
"type": "tool/result",
"seq": 8,
"data": {
"turn": 1,
"step": 1,
"message": {
"id": "msg-tool-1",
"role": "user",
"source": {
"kind": "tool",
"callId": "call-read-1"
},
"content": [
{
"type": "tool-result",
"toolCallId": "call-read-1",
"content": [
{
"type": "text",
"text": "# DeepSeek Harness\nA plugin-based agent harness..."
}
],
"isError": false
}
]
}
},
"surfaceOp": "append",
"sourceEventSeqs": [7]
}
你可能注意到了,工具结果消息的 role 也是 user,但它的 source.kind 是 tool。这是 DSH 统一模型消息格式中的表示方式,意思是“这段内容不是模型自己说的,而是外部工具交给模型的新信息”;UI 会根据 source 和内容类型把它画成工具卡片,而不是普通用户气泡。
这里的 callId 把结果与模型提出的 Tool Call 对应起来,sourceEventSeqs: [7] 又把结果事件与前面的 tool/call 事实连接起来。如果工具执行失败,仍然会产生 tool/result,只是 isError 会变成 true,并附带错误信息。这样历史不会只留下一个没有结局的调用。
deriveMessages:从日志投影出模型历史
模型并不需要看到所有内部事件。例如 step/start 对恢复流程有用,却不是一条模型消息。
Session 的 deriveMessages() 会从完整事件日志中,推导出本次模型请求需要的消息历史。
这好比公司保存完整项目档案,但给下一次会议准备材料时,只抽取与会议相关的内容。完整档案与会议材料来自同一来源,却不是完全相同的形式。
DSH 有一条非常重要的原则:
任何会影响模型请求的内容,都必须能够从 Session 日志重建。
如果某个插件偷偷把一段只存在内存里的文字塞进模型请求,进程重启后就无法还原同一次请求,重放和调试也会失真。
仍然使用刚才的读取文件例子。完整日志包含 Turn 边界、Step 边界、流式 chunk、请求头、工具调用和工具结果;但整个 Turn 完成后,deriveMessages() 为后续模型请求投影出的历史大致只有下面四条:
user
请读取 README.md,并告诉我项目是做什么的。
assistant
Tool Call: read({ file_path: "README.md" })
tool
# DeepSeek Harness
A plugin-based agent harness...
assistant
DeepSeek Harness 是一套插件化的 Agent Harness……
turn/start、step/start、request/header 和 assistant/chunk 没有出现在这里,因为它们不是模型对话消息。模型提出工具调用的 assistant/message 和工具返回的 tool/result 则会进入历史,让模型知道“我请求了什么,以及系统实际返回了什么”。
再看一个不同类型的事件:
{
"type": "todo/write",
"data": {
"todos": [
{ "content": "读取 README", "status": "completed" },
{ "content": "整理项目简介", "status": "in_progress" }
]
}
}
它会让 WebUI 恢复待办清单,却不会进入 deriveMessages()。这正好说明,同一条 SessionEvent 日志里既有模型需要的消息,也有只供 UI、恢复或统计使用的事实。
同一条日志可以投影给不同使用方
SessionEvent 不只服务模型。
同一条事件流还可以被投影成:
- 模型上下文:deriveMessages();
- 浏览器界面:聊天节点、工具卡片和流式文字;
- 持久化数据:写入文件或数据库;
- 恢复与 Fork:从某个边界重新建立 Session;
- 遥测:统计模型耗时、工具调用和错误;
- Transcript:生成便于人类阅读的记录。
可以把 SessionEvent 想成中心档案库。模型、UI、持久化和恢复流程不是各自保存一份真相,而是从同一条事件日志读出各自需要的视图。

图 12-1:SessionEvent 是多个功能共同依赖的事实来源,而不是只给聊天界面使用。
这是一种“写一次事实,多种方式读取”的设计。UI 不必自己维护另一份权威状态,模型历史也不必再从界面反推。
例如用户刷新页面后,前端不需要问“刚才那个 read 工具是不是运行过”。它重新读取同一条日志即可恢复:
| 日志中的事件 | WebUI 恢复出的内容 |
|---|---|
user/message | 用户消息气泡 |
assistant/message 中的 Tool Call | 模型发起工具调用的展示 |
tool/call 与 tool/result | read 工具卡片、参数和结果 |
最后一条 assistant/message | Agent 的最终回答 |
todo/write | 最近一次任务清单状态 |
模型上下文和页面状态来自同一批事件,因此不会出现“模型记得工具结果,但刷新页面后工具卡片消失”这类两套状态相互打架的问题。
Session 事件与实时事件不要混在一起
DSH 里还有 agent/、tools/ 等实时事件。它们与 SessionEvent 都叫“事件”,用途却不同。
| 事件类型 | 是否持久化 | 适合做什么 |
|---|---|---|
| SessionEvent | 是 | 保存必须跨重启存在的事实 |
| Agent Event | 通常否 | 观察或介入正在运行的 Agent |
| Capability Event | 通常否 | 给权限、适配器和策略接入某项能力 |
审批插件可以在 tools/pre-execute 阶段实时拦截工具;真正发生的 tool/call 和 tool/result 则写入 SessionEvent。
一个负责“现在要不要放行”,一个负责“刚才究竟发生了什么”。
为什么流式 chunk 也值得保存
模型回复通常是一小块一小块到达。只保存最后拼好的文本虽然省事,却会失去精确重放和 UI 过程信息。
DSH 保存 assistant/chunk,再形成 assistant/message。这样界面恢复时不仅知道最终内容,也能保持与真实运行更接近的展示语义。
当然,事件日志不是把所有内存变量都无脑写进去。它只保存会影响模型、恢复、界面和审计的稳定事实。
SessionEvent 是 Agent 的“可重建记忆”
人类记忆会模糊,而 DSH 的 Session 记忆更像项目档案:
工作过程 → 追加事件 → 持久化
↓
模型 / UI / 恢复 / 遥测
Agent Loop 负责产生工作过程,SessionEvent 负责让过程可以重建。
下一课,我们把前十二课全部合在一起,设计一个自己的业务 Agent:哪些能力写成工具插件,哪些放进 Preset,什么时候又需要 Bundle 与 Profile?
从插件到 Preset:定制一个业务 Agent
怎样把专属工具、提示词和权限装配成一个真正可用的业务 Agent?