LESSON 12 · 进入运行

SessionEvent:Agent 的记忆从哪里来?

对话、工具结果、界面恢复和持久化,为什么都能来自同一条事件流?

普通聊天应用常用一个 messages 数组保存对话。Agent 系统却多了工具调用、流式输出、Turn、Step、审批和恢复。如果仍然只存“用户问了什么、助手答了什么”,很多重要事实都会丢失。

1

一段回复不是完整历史

假设 Agent 最终回答:“测试已经通过。”

仅保存这句话,你无法知道:

  • 它运行了哪条测试命令;
  • 第一次测试是否失败;
  • 修改了哪些文件;
  • 模型看到了什么工具结果;
  • 中途是否发生重试;
  • 浏览器怎样还原实时过程。

对人类读者,最终答案可能足够;对需要恢复和审计的系统,远远不够。

因此 DSH 把 Session 设计成一条按顺序追加的 SessionEvent 日志。

2

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 对日志的理解。

3

先看一条 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 并不神秘。它就是一条带有“类型、顺序、时间和数据”的事实记录。不同事件的外层结构相似,主要区别在于 typedata

4

一次读取文件会产生哪些事件

继续上面的例子。模型需要先读取文件,才能回答用户。因此一个 Turn 会包含两个 Step:第一个 Step 调用 read,第二个 Step 根据文件内容给出答案。

下面按发生顺序列出主干事件。真实流式响应可能包含多条 assistant/chunk,这里将连续 chunk 合并成一行展示:

顺序SessionEvent它记录的事实
1turn/start第 1 个 Turn 开始
2step/startTurn 1 的 Step 1 开始
3user/message用户要求读取 README.md
4request/header本次模型请求使用的模型、系统提示词和工具列表
5request/context当前 Provider、模型和上下文窗口信息
6assistant/chunk模型流式输出了部分 Tool Call
7assistant/message模型完整表达了“调用 read”这个决定
8tool/callHarness 正式开始执行 read
9tool/resultread 返回 README.md 的内容
10step/endStep 1 结束
11step/startStep 2 开始
12assistant/chunk模型开始流式生成最终回答
13assistant/message完整回答组装完成
14step/endStep 2 结束
15turn/end没有后续工作,Turn 结束

这条时间线说明:SessionEvent 记录的不是一句“读完了”,而是 Agent 怎样一步步走到最终回答。

其中 assistant/message 里的 Tool Call 表示模型的意图,紧随其后的 tool/call 表示 Harness 已经开始执行。二者看起来相近,但含义不同:模型提出调用,不等于工具一定已经运行。

5

再看 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.kindtool。这是 DSH 统一模型消息格式中的表示方式,意思是“这段内容不是模型自己说的,而是外部工具交给模型的新信息”;UI 会根据 source 和内容类型把它画成工具卡片,而不是普通用户气泡。

这里的 callId 把结果与模型提出的 Tool Call 对应起来,sourceEventSeqs: [7] 又把结果事件与前面的 tool/call 事实连接起来。如果工具执行失败,仍然会产生 tool/result,只是 isError 会变成 true,并附带错误信息。这样历史不会只留下一个没有结局的调用。

6

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/startstep/startrequest/headerassistant/chunk 没有出现在这里,因为它们不是模型对话消息。模型提出工具调用的 assistant/message 和工具返回的 tool/result 则会进入历史,让模型知道“我请求了什么,以及系统实际返回了什么”。

再看一个不同类型的事件:

{
  "type": "todo/write",
  "data": {
    "todos": [
      { "content": "读取 README", "status": "completed" },
      { "content": "整理项目简介", "status": "in_progress" }
    ]
  }
}

它会让 WebUI 恢复待办清单,却不会进入 deriveMessages()。这正好说明,同一条 SessionEvent 日志里既有模型需要的消息,也有只供 UI、恢复或统计使用的事实。

7

同一条日志可以投影给不同使用方

SessionEvent 不只服务模型。

同一条事件流还可以被投影成:

  • 模型上下文:deriveMessages();
  • 浏览器界面:聊天节点、工具卡片和流式文字;
  • 持久化数据:写入文件或数据库;
  • 恢复与 Fork:从某个边界重新建立 Session;
  • 遥测:统计模型耗时、工具调用和错误;
  • Transcript:生成便于人类阅读的记录。

可以把 SessionEvent 想成中心档案库。模型、UI、持久化和恢复流程不是各自保存一份真相,而是从同一条事件日志读出各自需要的视图。

流程图 · 手机端可横向滑动
正在绘制流程图…

插件系统、事件流与 SessionEvent 的整体位置

图 12-1:SessionEvent 是多个功能共同依赖的事实来源,而不是只给聊天界面使用。

这是一种“写一次事实,多种方式读取”的设计。UI 不必自己维护另一份权威状态,模型历史也不必再从界面反推。

例如用户刷新页面后,前端不需要问“刚才那个 read 工具是不是运行过”。它重新读取同一条日志即可恢复:

日志中的事件WebUI 恢复出的内容
user/message用户消息气泡
assistant/message 中的 Tool Call模型发起工具调用的展示
tool/calltool/resultread 工具卡片、参数和结果
最后一条 assistant/messageAgent 的最终回答
todo/write最近一次任务清单状态

模型上下文和页面状态来自同一批事件,因此不会出现“模型记得工具结果,但刷新页面后工具卡片消失”这类两套状态相互打架的问题。

8

Session 事件与实时事件不要混在一起

DSH 里还有 agent/、tools/ 等实时事件。它们与 SessionEvent 都叫“事件”,用途却不同。

事件类型是否持久化适合做什么
SessionEvent保存必须跨重启存在的事实
Agent Event通常否观察或介入正在运行的 Agent
Capability Event通常否给权限、适配器和策略接入某项能力

审批插件可以在 tools/pre-execute 阶段实时拦截工具;真正发生的 tool/call 和 tool/result 则写入 SessionEvent。

一个负责“现在要不要放行”,一个负责“刚才究竟发生了什么”。

9

为什么流式 chunk 也值得保存

模型回复通常是一小块一小块到达。只保存最后拼好的文本虽然省事,却会失去精确重放和 UI 过程信息。

DSH 保存 assistant/chunk,再形成 assistant/message。这样界面恢复时不仅知道最终内容,也能保持与真实运行更接近的展示语义。

当然,事件日志不是把所有内存变量都无脑写进去。它只保存会影响模型、恢复、界面和审计的稳定事实。

10

SessionEvent 是 Agent 的“可重建记忆”

人类记忆会模糊,而 DSH 的 Session 记忆更像项目档案:

工作过程 → 追加事件 → 持久化
                 ↓
      模型 / UI / 恢复 / 遥测

Agent Loop 负责产生工作过程,SessionEvent 负责让过程可以重建。

下一课,我们把前十二课全部合在一起,设计一个自己的业务 Agent:哪些能力写成工具插件,哪些放进 Preset,什么时候又需要 Bundle 与 Profile?

下一课 · 13

从插件到 Preset:定制一个业务 Agent

怎样把专属工具、提示词和权限装配成一个真正可用的业务 Agent?

0 人点赞,0 人看过