如果你写过一段时间 Agent,一定遇到过这种困惑:模型在第二轮明明“记得”上一轮的工具调用结果,但你去翻数据库,却找不到任何一张表存着“对话历史”。DeepSeek Harness 给出的答案非常反直觉——对话历史压根不单独存储,它只是从一份仅追加的事件日志里派生出来的投影。这篇文章我们就把 DeepSeek Harness(下文简称 dsh)的可观测性底层拆开看:会话(Session)如何成为唯一真源、轮次(turn)与步骤(step)如何在日志上划出执行边界、轨迹回放为什么只需要“同一组事件重新派生一遍”就能做到。这是面向进阶 Agent 开发者的深水区内容,读完你会明白为什么 dsh 敢说“回放不是重跑,而是重投影”,以及这套设计如何把可观测性从“事后打日志”变成了“架构级不变量”。本段是全文第 1/2 部分,先把日志、投影与事件域这三块地基打牢,第 2 段我们再进入回放工程与实战调试。

会话即仅追加日志:SessionEvent、单调 seq 与 epoch 毫秒 time 的三件套

先抛出一个问题:模型看到的那段对话历史,到底存在哪里的?大多数框架的答案是“存在内存里的一个 messages 数组,顺手落个盘”。dsh 的答案截然不同——它不存在专门的地方,而是从一份会话日志里派生出来的。这句话是理解整套设计的总钥匙。

在 dsh 中,一个 Session 本质上是一份由类型化 SessionEvent 组成的仅追加日志(append-only log)。请注意“仅追加”三个字的重量:日志里的每一行一旦写入,就永远不会被修改、删除或重排。你无法“更新一条历史消息”,你只能追加一条新事件去表达“这里发生了变化”。这跟 Git 的 commit 链、Kafka 的分区日志是同一个哲学——用不可变的追加操作,换取完整的可追溯性与确定性回放

日志里的每个事件都携带一个稳定的“三件套”:

  • type:事件的类型标签,比如 turn/startassistant/messagetool/result。它决定了这个事件属于哪个语义域,也是后面做可辨识联合收窄的关键。
  • seq:单调递增的序列号,取值方式非常极客——seq = log.length。也就是说,事件一旦被 append,它的 seq 就是这个事件在日志中的下标位置。第一个事件 seq 为 0,第二个为 1,以此类推,中间永远不会出现空洞。这带来一个极强的保证:seq 就是事件在历史中的绝对坐标,任何两个事件的前后关系都可以用 seq 的大小直接比较,不需要依赖时间戳。
  • time:以 epoch 毫秒为单位的时间戳。比如素材日志里的 1755000000000,这是一个 Unix 毫秒时间,用于人读、用于排序展示、用于性能分析(比如算某个 step 的耗时)。但请注意,time 不承担因果序的判定职责——因果序由 seq 保证,time 只负责“墙上时间”的参考价值。这是一个很成熟的工程取舍:时间戳可能因为时钟回拨或精度问题产生歧义,而 seq 不会。

为什么“三件套”要这样设计?因为它们分别回答三个正交的问题:type 回答“这是什么”seq 回答“它在历史中的确切位置”time 回答“它大概发生在什么时刻”。把这三个维度拆开,就避免了用单一字段承担过多职责,也让回放、调试、断点续传各自有干净的抓手。

从地位上看,这份日志是 agent 完整交互历史的唯一真源(single source of truth)。这句话不是宣传口号,而是一条会在架构层面被反复验证的约束:LLM 消息历史从日志派生而来,从不单独存储;回放就是重新从同一组事件派生历史。换句话说,日志是源,消息是视图。你在 UI 上看到的对话、在 API 上发出去的 messages 数组、在回放器里重现的每一步,本质都是同一份日志的不同投影。

还有个容易忽略的细节:日志里的每个事件,其 data 都必须能无损序列化为 JSON,并且这一约束由 Session.append 在源头强制。为什么要卡在 append 这一层?因为日志是要被持久化、被跨进程传输、被加载回放的,一旦允许塞入 Map、Date、类实例、循环引用这类东西,回放时就得面对“反序列化后语义变了”的灾难。把校验前移到写入入口,代价最小、收益最大——写入时严格,读取时才能放肆

下面这张表把三件套的职责对比列清楚,建议在实现自己的 Agent 事件系统时对照使用:

字段类型/取值核心职责是否可缺省工程坑点
type字符串字面量(可辨识联合的判别式)标识事件语义,决定投影与路由不可缺省新增 type 必须同步扩 SessionEventMap,否则收窄失效
seq整数,等于 append 前的 log.length给出历史中的绝对坐标与因果序不可缺省不要用时间戳代替 seq 排序,时钟回拨会毁掉顺序
timeepoch 毫秒整数墙上时间参考,用于展示与耗时统计不可缺省不要用它做业务因果判断,跨机器不可比
data可 JSON 无损序列化的对象承载事件载荷,投影的原料不可缺省塞 Date/Map 会在回放时失真,append 阶段应直接拒绝

把这三件套理解透,你会发现 dsh 的会话日志其实是一个自带坐标系的、时间可参考的、类型可辨认的事件流。它既不像纯文本日志那样丢失结构,也不像数据库表那样把状态可变性引入进来。这正是它敢当“唯一真源”的底气。

为什么 LLM 消息历史不单独存储:从日志派生 + 可回放 transcript 的 SDK 消费姿势

传统做法里,很多 Agent 框架会维护一个独立的 messages[] 数组,然后把这个数组和原始的运行日志各存一份。问题随之而来:两份数据会漂移。你修了一个拼接逻辑的 bug,日志里记录的是旧行为,messages 数组却用的是新逻辑;你想回溯“模型当时到底看到了什么”,结果发现日志记的是“我们以为它看到了什么”。这类“双写不一致”是可观测性最大的敌人。

dsh 的做法是从根上消灭第二个写入口:LLM 消息历史不单独存储,它由日志派生。所谓“派生(derive)”,指的是有一套明确的、确定的投影规则,把事件日志映射成 Message[]。既然规则是确定的,那么同一组事件在任何时候、任何机器上派生出的消息历史必然完全一致——这就是“可回放”的数学基础。

这里有一个非常关键的语义:回放 = 重新从同一组事件派生历史。不是重跑模型、不是重放网络请求,而是把日志重新喂给投影函数。这意味着轨迹回放是纯函数式的:输入是事件序列,输出是消息序列,没有副作用、没有随机性、不依赖外部状态。这让我们可以做到很多传统框架做不到的事:

  • 确定性调试:报错时你把那一段日志切出来,任何人重放都能看到同样的消息,不会“在我这跑不出来”。
  • 零成本分叉:想在某个 step 上试试不同的投影逻辑(比如把 tool 结果改个格式),只需对同一份日志套用新的投影器,历史数据一份都不用动。
  • 多视图共存:同一份日志可以派生出“给模型看的 messages”“给审阅者看的 transcript”“给 UI 看的 timeline”,它们互不干扰,因为都只是投影。

那么作为 SDK 使用者,如果我想拿到“可回放的 transcript 数据”,应该怎么消费?官方给的姿势很明确:需要可回放 transcript 数据的 SDK 用户应当消费 session/event 事件流。请注意它不是让你去读某个快照文件,也不是让你去调 deriveMessages 拿最终结果,而是让你订阅事件流本身。原因很简单:只有事件流保留了全部原始信息——包括那些不投影为消息的边界事件与流式分片。一旦你只拿派生后的 messages,你就丢掉了 turn/step 的边界、丢掉了 assistant/chunk 的 token 级保真、丢掉了 tool/call 的原始参数。要做完整回放,你就得持有源事件流。

下面给一个可直接粘贴运行的示例:用 Node.js 消费一份 JSONL 格式的会话日志事件流,既拿边界事件、又做派生投影。这里用 JSONL 的规范打包行布局——每一行是一个完整的事件对象。

// 文件路径:examples/consume_session_events.mjs
// 消费 session/event 事件流(JSONL 行布局),演示
// 1)如何逐事件流式处理;2)如何同时拿到派生 messages 与回放所需的边界数据。
// 运行:node examples/consume_session_events.mjs examples/session.jsonl

import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";

/**
 * 消费事件流:这是 SDK 推荐的“可回放 transcript”姿势。
 * 注意:我们保留全部事件,而不是只保留派生后的消息。
 * @param {string} path JSONL 会话日志路径
 */
async function consumeSessionStream(path) {
  const rl = createInterface({
    input: createReadStream(path, { encoding: "utf-8" }),
    crlfDelay: Infinity,
  });

  const events = [];      // 原始事件,回放与调试的原料
  const boundaries = [];  // turn/step 边界,不投影为消息但回放必需
  const messages = [];    // 派生出的模型可见历史

  for await (const line of rl) {
    if (!line.trim()) continue;
    const ev = JSON.parse(line);
    events.push(ev);

    // 边界事件:记录执行结构,但不进 messages
    if (ev.type === "turn/start" || ev.type === "turn/end") {
      boundaries.push({ kind: ev.type, turn: ev.data.turn, seq: ev.seq });
    } else if (ev.type === "step/start" || ev.type === "step/end") {
      boundaries.push({ kind: ev.type, turn: ev.data.turn, step: ev.data.step, seq: ev.seq });
    }

    // surface 事件:按投影规则产出模型可见消息
    const d = ev.data;
    if (ev.type === "user/message") {
      messages.push({ role: "user", content: d.content });
    } else if (ev.type === "assistant/message") {
      // 空内容 assistant/message 不进提供方 transcript
      const content = d.message.content;
      if (!Array.isArray(content) || content.length > 0) {
        messages.push({ role: "assistant", content });
      }
    } else if (ev.type === "tool/result") {
      messages.push({
        role: "user", // 工具结果以 user 角色回到模型
        content: [{ type: "tool-result", toolName: d.message.toolName, content: d.message.content }],
      });
    }
    // assistant/chunk、tool/call、turn/*、step/* 都不投影为消息
  }

  console.log("事件总数:", events.length);
  console.log("边界事件数:", boundaries.length);
  console.log("派生消息数:", messages.length);
  console.log("末事件 seq:", events.at(-1)?.seq, "time:", events.at(-1)?.time);
  return { events, boundaries, messages };
}

await consumeSessionStream(process.argv[2]);

这段代码里有两个值得强调的设计点。第一,events 数组是回放的根基,它保留了 seq 与 time,所以你可以对任意片段做重投影;第二,boundaries 单独收集,因为 turn/step 边界“不投影为消息但回放必需”——如果想在回放器里还原“模型是在第几个 step 收到工具结果的”,你就必须保留这些边界事件。

switch (event.type) 直接收窄:可辨识联合与 event.data 的无损 JSON 序列化约束

事件是不是“类型化”的,差别巨大。dsh 里的事件基于 type 构成真正的可辨识联合(discriminated union),这意味着在 TypeScript 里写 switch (event.type) 时,编译器能直接收窄 event.data 的类型,你不需要写任何 as 类型断言。

举个直观的例子。当你写下

switch (event.type) {
  case "step/start":
    // 此处 event.data 被收窄为 { turn: number; step: number }
    console.log(event.data.turn, event.data.step);
    break;
  case "tool/call":
    // 此处 event.data 被收窄为 { turn; step; callId; name; arguments }
    // arguments 是模型产出的原始 JSON 字符串,不是解析后的对象
    console.log(event.data.name, event.data.arguments);
    break;
  case "tool/result":
    // 此处 event.data 被收窄为 { turn; step; message; error?; meta? }
    console.log(event.data.message.toolName, event.data.error ?? "ok");
    break;
}

在 case 分支里,你直接访问 event.data.turnevent.data.arguments 都是类型安全的,IDE 会自动补全,写错字段名立刻飘红。这不是“文档约定”,而是类型系统强制的契约。为什么能做到?因为每个事件类型的 data 形状都由 SessionEventMap 精确映射,type 字段就是这个联合的判别式(discriminant)。

这里要纠正一个常见误区:“能收窄”不等于“运行时安全”。类型断言解决的是编译期问题,但日志是可以被手写、被篡改、被外部系统注入的。所以还有一个更底的防线:所有 event.data 都必须能无损序列化为 JSON,Session.append 会在源头强制这一点。所谓“无损”,指的是 JSON.parse(JSON.stringify(data)) 之后语义不变。哪些东西会破坏无损?

  • Date 对象:序列化后变成字符串,反序列化回来是 string 不是 Date,类型漂移。
  • Map / Set:JSON.stringify 直接变成 {},数据静默丢失,最危险。
  • 类实例:方法丢失,只剩可枚举自有属性,原型链断裂。
  • undefined 与函数:JSON 里不存在这些值,会被静默丢弃或变 null。
  • 循环引用:JSON.stringify 直接抛错。
  • NaN / Infinity:会被序列化成 null,产生语义污染。
  • BigInt:JSON.stringify 抛错,无法序列化。

把这条约束放在 append 入口,是典型的“在系统边界做最严格的校验”思路。写入时宁可抛错,也不要让一条无法回放的事件混进日志——因为一条坏事件会污染整条时间线的回放能力。可以用一句话总结这套组合拳:可辨识联合保证写入端类型正确,无损 JSON 约束保证存储端语义不丢,两者合起来才让读取端(派生与回放)可以放心大胆地假设“日志里的每一条都是可重建的”。

再补一个实践技巧:如果你在业务里确实需要携带复杂结构(比如一个 Date),标准做法是在写入前显式转成可 JSON 的形式,例如存 epoch 毫秒数而非 Date 对象,然后在投影阶段按需还原。这跟 dsh 用 time 存 epoch 毫秒、而非存一个时间对象,是完全一致的哲学。

「模型可见即已记录」不变量:新增一项模型可见输入 = 新增一个会话事件

这是整套设计里最硬核、也最容易被忽视的一条:模型可见即已记录。展开说就是——抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量(invariant)断言这一点

什么叫“不变量”?它是一句在任何时刻、任何状态下都必须为真的断言。这里的断言是:凡是进入模型请求的内容,都能在日志里找到对应的事件。如果某天你在代码里加了一段“悄悄塞给模型的系统提示词”,却没有为它新增一个会话事件,那么这个不变量就会被打破——运行时断言会直接报错,而不是让你在下次回放时才发现“模型当时看到的和日志里记的不一样”。

这条不变量的推论极其重要,请务必记住:新增一项模型可见输入,就需要新增一个会话事件。具体操作分两步:

  1. 扩展 SessionEventMap:为这类新输入定义一个新的事件类型及其 data 形状,让它成为可辨识联合的一员。
  2. 从日志渲染:让 deriveMessages 的投影规则知道如何处理这个新事件,这样它才会出现在派生历史里。

为什么必须两步都做?只做第一步,事件记了但不投影,模型看不到——记录与视图脱节;只做第二步,投影里凭空多出一段内容但日志里没有对应事件——回放时这段内容就丢了,违反不变量。两步合起来,才满足“日志能重建模型所见”这一定义。

这条设计带来了几个非常实际的收益:

  • 可审计:模型看到的每一个字都有出处(可追溯到具体的 seq)。出了幻觉或越权,能精确定位是哪条事件导致的输入。
  • 可回放:因为“可见即可重建”,所以给定日志就能 100% 复现模型的输入,不需要额外的旁路记录。
  • 可演进:想加新输入?先加事件类型,再改投影。改动方向被强制成“日志优先”,从架构上防止了双写不一致。

反过来说,这也给代码审查提供了一个极佳的检查清单。当你 review 一个“给模型加东西”的 PR 时,只需问三个问题:SessionEventMap 扩了吗?投影规则改了吗?不变量断言会过吗?三个都 yes,这个改动才在架构上自洽。这种把规范固化成运行时不变量的做法,是 dsh 可观测性区别于普通“打日志”框架的分水岭——它把“应该记录”从人的自觉,变成了系统的强制。

deriveMessages() 投影表逐行拆解:user/message、assistant/message、tool/result 谁进谁不进

理解了“日志是真源、消息是投影”,接下来最关键的就是搞清楚投影规则。dsh 里负责这件事的函数叫 Session.deriveMessages(),它把事件日志投影成模型看到的 Message[]。规则其实非常朴素,但每一行都有讲究。

先明确一个概念:只有 surface 事件会投影成消息。所谓 surface,指的是“构成对话表面内容”的事件。dsh 里有三类 surface 事件,它们带有一个标记字段 surfaceOp,用来说明它们如何加入派生 surface。我们来逐行拆解投影表:

事件类型投影为关键说明是否带 surfaceOp
user/message一条 user 消息携带确切 content,可选 envelope 只作为日志展示元数据
assistant/message一条 assistant 消息包含提供方、模型与可选回放状态
assistant/chunk跳过属于回放/UI 数据,组装后的消息才是权威
tool/result一条带 tool-result 块的 user 消息工具结果以 user 角色回到模型
turn/*、step/*跳过结构信息,不投影为消息

逐行细读:

  1. user/message → 一条 user 消息。它携带确切 content。这里有个细节:user/message 可能存在一个可选 envelope,但 envelope 只作为日志展示元数据,不进入模型可见内容。也就是说,展示层可以给消息加个“来源渠道”“置信度”之类的信封信息,但模型看到的还是干净的 content。这个区分很实用——它让“给审计看的元数据”和“给模型看的正文”解耦。
  2. assistant/message → 一条 assistant 消息。包含提供方(provider)、模型(model)与可选回放状态。这些附加信息让回放时能还原“这条消息是由哪个 provider 的哪个模型产生的”,是做多模型轨迹比对的宝贵材料。
  3. assistant/chunk → 跳过。为什么跳过?因为它是原始流式分片,属于回放/UI 数据,组装后的消息才是权威。这一点极其重要:模型看到的、真正权威的,是组装好的 assistant/message;chunk 只是 token 级回放保真用的原始素材。如果投影时把 chunk 也算进去,会重复。素材里的日志就体现了这个关系——assistant/message(seq 5)通过 sourceEventSeqs: [3, 4] 精确指向了组成它的两条 chunk(seq 3、4)。
  4. tool/result → 一条带 tool-result 块的 user 消息。注意角色是 user,不是 tool。这是 LLM 消息协议的一个普遍约定:工具执行结果以 user 角色回灌给模型。dsh 遵循了这一点。素材里那条 tool/result 的 message 携带 role: "tool"toolName: "bash"content: "runoob"isError: false,投影后变成一条 user 消息里的 tool-result 块。
  5. turn/*、step/* → 跳过。它们是结构信息,用来划执行边界,不投影为消息。这解释了为什么前面消费事件流时要单独收集 boundaries——它们对回放重要,但对“模型看什么”不重要。

还有一个投影规则表之外、但同样关键的细节:tool/call 也不投影为消息。你会发现素材里的 tool/call(seq 6)确实没有出现在派生历史里。为什么?因为模型发起的工具调用,其语义已经被 assistant/message 里的 tool_use 块承载了(看 seq 5 的 assistant/message,content 里已经有 type: "tool_use", id: "call_1", name: "bash")。tool/call 事件是执行侧的记账,用于把 callId 与后续 result 关联,而不是再次进入模型视图。这个区分让“模型说了要调什么”和“系统真的调了什么”成为两条可对照的线索。

再叠加一个前面埋下的规则:内容为空的 assistant/message 也会被跳过。所以完整的跳过清单是:assistant/chunk、turn/*、step/*、tool/call,加上“空内容的 assistant/message”。下面这段可直接运行的 Python 示例,把投影规则和边界打印揉在一起,方便你对着素材日志看:

# 文件路径:examples/derive_and_boundaries.py
# 解析 JSONL 会话日志:一遍打印执行边界,一遍重建模型可见历史。
# 这是 Session.deriveMessages() 的简化教学模型;真实实现是缓存的且返回冻结消息。
import json
import sys


def derive_messages(events):
    """只投影 surface 事件,模拟 deriveMessages 的投影规则。

    - user/message      -> user 消息
    - assistant/message -> assistant 消息(空内容跳过)
    - tool/result       -> 携带 tool-result 块的 user 消息
    - assistant/chunk / tool/call / turn/* / step/* 不投影
    """
    messages = []
    for ev in events:
        t = ev["type"]
        d = ev["data"]
        if t == "user/message":
            messages.append({"role": "user", "content": d["content"]})
        elif t == "assistant/message":
            content = d["message"]["content"]
            if not content:          # 空内容不进提供方 transcript
                continue
            messages.append({"role": "assistant", "content": content})
        elif t == "tool/result":
            messages.append({
                "role": "tool",
                "name": d["message"]["toolName"],
                "content": d["message"]["content"],
            })
    return messages


def main(path):
    with open(path, encoding="utf-8") as f:
        events = [json.loads(line) for line in f if line.strip()]

    # 第一遍:打印执行边界,理解 turn 与 step 的嵌套关系。
    for ev in events:
        d = ev["data"]
        t = ev["type"]
        if t == "turn/start":
            print(f"[turn/start] turn={d['turn']}")
        elif t == "turn/end":
            print(f"[turn/end] turn={d['turn']} reason={d['reason']}")
        elif t == "step/start":
            print(f"  [step/start] turn={d['turn']} step={d['step']}")
        elif t == "step/end":
            print(f"  [step/end] turn={d['turn']} step={d['step']}")
        elif t == "assistant/chunk":
            print(f"  chunk: {d['chunk']['type']}")
        elif t == "tool/call":
            print(f"  tool/call: {d['name']} args={d['arguments']}")

    # 第二遍:重建模型可见的派生历史。
    print("\n模型可见的派生消息:")
    for m in derive_messages(events):
        if m["role"] == "tool":
            print(f"  [tool] {m['name']}: {m['content']}")
        else:
            print(f"  [{m['role']}] {m['content']}")


if __name__ == "__main__":
    main(sys.argv[1])

把素材里那段日志喂给这个脚本,你会看到投影后的历史里,chunk 的 text-deltatool-call-delta 都不见了,tool/call 也不见了,只剩 user 消息、assistant 消息、tool 结果三样。这就是“组装后的消息才是权威”的直接体现。

缓存与冻结:surface 节点首次出现投影一次,重写时重建,类型上不可改历史

投影逻辑讲清了,还有个工程问题绕不开:性能与安全性。日志会越写越长,如果每次有人问“模型看到什么”,都从头把所有事件重新投影一遍,那么在一个有几千条事件的会话里,每次取历史都是 O(n)。dsh 的 deriveMessages 是怎么处理的?答案藏在两句话里:它是缓存的;它返回冻结的消息数组。

先看缓存。每个 surface 节点在首次出现时投影一次,surface 重写时重建。怎么理解?派生历史本质是由一串 surface 节点(user/message、assistant/message、tool/result 这几类)构成的有序序列。当你第一次需要派生结果时,系统遍历日志,把这些 surface 节点逐个投影成消息,并把结果缓存起来。之后再次调用 deriveMessages,直接命中缓存,无需重算。

那“surface 重写”是什么意思?既然日志是仅追加的,历史不应该被改啊?关键在于——日志不可变,但表面(surface)是可被后续事件“重写”的视图。比如某些场景下,后续事件可能表达“之前那条消息的某个属性被修正/补充”。这时缓存不再是全量重算,而是只重建受影响的部分。这就把增量更新的成本从 O(n) 降到接近 O(改动量)。这是一个典型的“不可变日志 + 可变视图 + 缓存”三层架构:底层不可变保证了真源可靠,中间层视图允许演进,缓存层保证性能。

再看“冻结”。deriveMessages 返回的是冻结的消息数组。这意味着调用者拿到手之后不能改——不能 push、不能改某个元素的字段。为什么这么做?因为“通过投影修改已记录的历史在类型上不可表达”。这句话非常精妙,值得反复咀嚼:

  • 派生的历史是日志的投影,它的权威性来自日志,而不是来自这个数组本身。
  • 如果你能改这个数组,那么“模型看到的历史”和“日志记录的历史”就会分叉,唯一真源的地位瞬间崩塌。
  • 所以设计上直接在类型层面禁止修改——数组是 frozen 的,写操作会在运行时抛错(严格模式下),类型上也不暴露可变接口。

这条约束的意义在于把“不该做的事”变成“做不到的事”。与其在文档里写十遍“请不要修改派生历史”,不如让它在类型和运行时层面直接不可能。这正是 dsh 可观测性设计的整体气质:用机制保障正确性,而非依赖人的自律。对于写 Agent 框架的人来说,这是一个可以直接借鉴的模式——不可变真源、缓存投影、冻结输出,三件套下来既快又稳。

空内容 assistant/message 的双重身份:记录 usage 提供方模型,却不进提供方 transcript

有一个边界情况特别能体现设计的细致:内容为空的 assistant/message。它既存在,又“不存在”,这种双重身份值得单独拎出来讲。

先看它“存在”的一面。assistant/message 事件会记录每次成功的提供方调用,包括返回空内容或以 max-tokens 结束的调用。也就是说,当模型因为达到 max-tokens 上限被截断、返回了一个空内容的响应时,dsh 依然会记录一条 assistant/message 事件。为什么?因为要保存 usage、提供方与模型信息。素材里的 assistant/message 就带着 usage: { inputTokens: 12, outputTokens: 4 },这些计费与配额信息必须落库,否则你没法做成本核算、没法做 token 预算。哪怕内容为空,这次调用的“账”也不能丢。

再看它“不存在”的一面:无内容的 assistant 轮次不得进入提供方 transcript。也就是说,派生消息历史时,这条空内容的 assistant/message 会被跳过,不会以一条空 assistant 消息的形式出现在下一次模型请求里。为什么?因为大多数提供方的 API 不接受、或对空内容 assistant 消息行为不一致,把它塞进去轻则浪费 token,重则报错。更重要的是语义正确性——一个被截断且无输出的回合,本来就不该被模型当成“我说过一句空话”。

这一进一出的取舍,完美演示了 dsh 的核心区分:“记录在日志里”和“进入模型视图”是两件事。日志负责完整与可审计(usage 要留),投影负责模型契约与干净(空消息要过滤)。素材里那句总结极为精准:“空内容不会进入派生历史,但该持久事件仍会保留用量。”

还有一个关联细节:这条 assistant/message 通过 sourceEventSeqs 精确列出其对应的 assistant/chunk 事件,包括显式空列表。“显式空列表”这四个字很关键——它意味着即使一个 assistant/message 没有任何 chunk 支撑(比如直接返回了组装好的消息),系统也会写一个空数组来表达“确实没有对应 chunk”,而不是省略这个字段。为什么坚持显式?因为“没有”和“字段缺失”在回放时要区分开:省略字段会让读者怀疑“是不是数据丢了”,显式空数组则明确表达“这次确实没有原始分片”。这是可观测性设计里对“未知”与“空”严格区分的又一例证。

把这块的规则汇总成一张对照清单:

场景是否写入 assistant/message 事件是否进入派生历史usage 是否保留
正常返回有内容
max-tokens 截断且无内容
提供方返回空内容
tool/result 出错不涉及(走 tool/result 的 error 字段)是(带 isError)不适用

这条“空消息双重身份”的规则,能帮你避免一个真实场景里的坑:你在做 token 成本分析时,如果只看派生后的 messages,会漏掉那些被截断的空调用;只有回到日志层,才能拿到完整的 usage 统计。再次印证那句话——要做完整统计与回放,就去消费 session/event 事件流,别只看派生结果。

事件三域选型指南:会话事件、Agent 事件、能力事件各自的持久性与拦截时机

最后一块地基,是事件域的划分。官方文档把事件分成三域,各有各的用途。选对事件域,是大多数改动的第一个决定。这句话对做扩展的开发者尤为重要——你往哪个域加事件,直接决定了它的持久性、可拦截性和作用范围。

先上对照表,这是本节最该收藏的东西:

事件域代表事件特性什么时候用
会话事件turn/start、step/start、user/message、assistant/*、tool/call、tool/result追加进日志并广播,持久事实某个事实必须在重新加载后仍然存在
Agent 事件agent/pre-step、agent/request、agent/status、agent/turn-stopping携带活跃 Agent,实时控制与状态观察或拦截进行中的工作
能力事件tools/*、fs/*、llm/stream无导入循环地向 seam 附加策略给能力 seam 挂策略与适配器

逐域拆解:

  1. 会话事件(Session 事件)。代表是 turn/start、step/start、user/message、assistant/*、tool/call、tool/result。它们的核心特征是追加进日志并广播,是持久事实。判断标准很明确:某个事实必须在重新加载后仍然存在,就应该建模成会话事件。比如“用户发了什么”“模型回了什么”“工具返回了什么”,这些都是必须落库、必须可回放的持久事实。它们承担了回放与审计的所有原料来源。
  2. Agent 事件。代表是 agent/pre-step、agent/request、agent/status、agent/turn-stopping。它们的特征是携带活跃 Agent,用于实时控制与状态。使用场景是观察或拦截进行中的工作。比如 agent/pre-step 决定模型看到什么——监听器可以改写已领取的消息,也可以直接拒绝它们。这类事件关注的是“此刻正在发生什么,我要不要介入”,而不是“历史上发生了什么”。注意素材特别提到的时序细节:首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。也就是说,哪怕你拦下了一条输入、什么都没执行,系统也会留下一条无步骤的持久轮次记录——“尝试过”本身也是必须留痕的事实
  3. 能力事件。代表是 tools/*、fs/*、llm/stream。它们的特征是无导入循环地向 seam 附加策略。使用场景是给能力 seam 挂策略与适配器。这类事件的价值在于“seam”——能力接缝。你想给工具调用加一层权限校验、给文件系统加一层沙箱、给 LLM 流式输出加一层审计,都应该通过能力事件挂载,而不是去改会话事件。它们的设计目标是避免模块间的导入循环,让策略能以插件方式attach 到能力边界上。

这里还要强调一个容易踩坑的机制差异:waterfall 与 serial 事件的区别。素材明确指出,agent/pre-step、agent/request、llm/stream 和三个 tools/* 事件是 waterfall,监听器必须调用 next() 才能委托下去;而 agent/turn-stopping 是 serial 事件,没有 next()

  • waterfall(瀑布流):多个监听器按顺序串成一条链,每个监听器处理完后调用 next() 把控制权交给下一个。这给了每个监听器“改写、短路、旁路”的权力——你不调 next() 就截断了后续处理。适合策略拦截场景(比如 pre-step 改写消息)。
  • serial(串行):监听器依次执行,但没有 next() 委托语义,不存在“截断链”的概念。适合通知型场景(比如 turn-stopping 时做状态清理)。

把“选哪个域”和“用哪种派发”组合起来,就构成了一份扩展开发的决策树:要持久留痕 → 会话事件;要实时拦截正在执行的工作 → Agent 事件(且多为 waterfall,别忘了 next());要挂载策略到能力边界 → 能力事件(同样注意 next())。踩过 waterfall 的坑的人都知道,忘记 next() 会导致后续监听器静默不执行,表现是“我的插件怎么没生效”,而日志里一点异常都没有——这是新手最常见的“幽灵 bug”。

回头串一遍流程图,理解会更完整。官方时序图把完整流程画成:turn/start → agent/pre-step → step/start → llm/stream → 工具 → step/end → turn/end。其中 turn/start、step/start、step/end、turn/end 是会话事件(持久边界),agent/pre-step 是 Agent 事件(实时拦截点),llm/stream 是能力事件(策略挂载点)。三条域在同一条时间线上各司其职,共同支撑起整个可观测性骨架。

到这里,第 1/2 段的三块地基就铺完了:会话是仅追加日志,seq 与 time 组成坐标系;消息历史是投影,不单独存储,回放即重投影;事件按 type 构成可辨识联合,data 在 append 处强制无损 JSON;「模型可见即已记录」是不可违背的运行时不变量;deriveMessages 按规则表投影、带缓存、返回冻结数组;空内容 assistant/message 存 usage 却不进 transcript;三域选型决定了你的改动能不能持久、能不能拦截。接下来的第 2 段,我们会把这些机制放到真实工程里——从一份 JSONL 日志出发,动手做轨迹回放、对比派生视图与原始事件的差异、并处理回放时最棘手的边界情况(比如 sourceEventSeqs 缺失、surface 重写导致的缓存失效)。如果你现在就想动手,不妨把上面那段 Python 脚本存下来,找一份自己的会话日志跑一遍,先感受一下“同一份日志,两种视图”到底是什么体验。

上一段我们从「模型看到的那段对话历史到底存在哪里」这个问题出发,拆开了 Session 这份仅追加事件日志作为唯一真源、deriveMessages() 如何把日志投影成模型可见的 Message[],以及事件三域的基本分工。这一部分我们继续往下钻:事件在 seam 上的传播语义、轮次与步骤的边界定义、官方时序图的完整走位,以及 turn/end、tool/call、tool/result 这些关键事件的具体字段,最后落到一份可运行的 JSONL 重放脚本与一套可观测性工程实践。

waterfall 与 serial:agent/pre-step、agent/request、llm/stream 与 tools/* 必须调 next()

要理解轮次生命周期,先得理解事件在 seam(能力接缝)上的传播方式。DeepSeek Harness 的事件并不是「广播之后就结束了」,其中一部分是 waterfall 事件——它们沿监听器链依次下传,每一个监听器都必须显式调用 next(),才能把控制权委托给下一个监听器,否则链条就地截断。根据官方文档,必须调用 next() 才能委托下去的 waterfall 事件是:

  • agent/pre-step:模型请求前的最后一道改写关口,决定模型最终看到什么。
  • agent/request:请求发出前的拦截点,用于附加策略或改写请求。
  • llm/stream:流式响应路径上的中间环节,用于观测或改写流式输出。
  • tools/\*:三个能力事件(tools 域的系列事件),用于给工具 seam 挂策略与适配器。

与之相对的是 serial 事件:它没有 next(),不构成委托链,典型代表是 agent/turn-stopping——轮次即将停止时触发,用于状态收尾与观测,而不是用来拦截或改写流程。这个区分很容易踩坑:如果你把 agent/turn-stopping 当成 waterfall 去调 next(),在运行时会直接报错;反过来,如果你在 agent/pre-step 里忘了调 next(),链路会被静默截断,后续监听器不再执行,而模型可能收到一个被提前「冻结」的请求,排查起来非常隐蔽。

事件传播语义是否需要 next()典型用途
agent/pre-stepAgent 事件waterfall必须调用改写或拒绝已领取的输入,决定模型可见内容
agent/requestAgent 事件waterfall必须调用请求发出前附加策略、观测请求形态
llm/stream能力事件waterfall必须调用流式路径观测与适配
tools/*能力事件waterfall必须调用给工具 seam 挂策略与适配器
agent/turn-stoppingAgent 事件serial没有 next()轮次停止前的状态收尾与观测

一个工程化的写法是:把「必须调 next()」这件事收进类型系统或 lint 规则里。因为 waterfall 监听器的签名里 next 是必填参数,漏调在 TypeScript 里往往能通过类型检查(你只是没用它),但在运行时就是 bug。务实做法是给所有 waterfall 监听器加一条统一约定:任何提前返回都必须对应一次显式的「短路决策」,而不是「忘记委托」。下面这段 TypeScript 展示了 waterfall 与 serial 在同一条 seam 上的注册差异,可直接作为接入模板:

// 注册一个 waterfall 监听器:必须调用 next() 才能委托下去
agent.on("agent/pre-step", async (event, next) => {
  const claimed = event.data.messages;

  // 决策一:直接拒绝本次领取,短路整条链
  if (shouldReject(claimed)) {
    return { rejected: true };
  }

  // 决策二:改写已领取的消息,再委托给下一个监听器
  const rewritten = claimed.map((m) => rewrite(m));
  return next({ ...event, data: { ...event.data, messages: rewritten } });
  // 注意:这里若忘了 return next(...),链会静默截断
});

// 注册一个 serial 监听器:没有 next(),不要试图委托
agent.on("agent/turn-stopping", (event) => {
  recordTurnStopping(event.data.turn, Date.now());
  // 这里没有 next 参数,也不应该出现 next()
});

轮次与步骤的边界定义:一次模型请求加其工具调用,轮次包围一次模型循环

把传播语义搞清楚之后,边界就顺理成章了。官方文档给的定义非常克制:

  • 一个步骤(step)是一次模型请求,加上这次请求所调用的工具。
  • 一个轮次(turn)包含零个或多个步骤:它在领取首条输入之前打开,在不再欠下任何工作时关闭。

最容易误解的一点是「轮次包围一次模型循环执行,而不是整个会话日志」。也就是说,turn 并不是「一次会话」的同义词,它只是会话日志上的一段执行边界;一次会话可以有很多个 turn,一个 turn 也可以只有零个 step(例如输入在 pre-step 阶段被拒绝)。日志把这两层边界都记下来,正是为了让回放时能精确还原「哪一次模型请求发生在哪一段上下文里」。

为什么 step 的定义要强调「加它调用的工具」?因为 step 不是「一次 LLM 调用」,而是「一次模型请求及其引发的工具往返」这个闭环。模型请求返回 tool_use,系统执行工具、把 tool/result 作为 user 角色回灌,这一整套动作共同属于同一个 step;下一次模型请求再开一个新 step。这就解释了为什么日志里 step/start 和 step/end 之间既会有 assistant/message,也会有 tool/call 与 tool/result。

示意图
会话日志上 turn 与 step 的嵌套边界:turn/start 打开轮次,其内包含零个或多个 step,每个 step 由一次模型请求及其工具调用构成,最后以 turn/end 关闭。

把定义落到日志事件上,边界就变成了一组一一对应的事件对,下表是工程中最常打交道的字段速查:

事件携带的数据说明
turn/start{ turn }在 loop 认领排队输入或运行 pre-step 之前打开轮次
turn/end{ turn, reason }以 TurnEndReason 关闭轮次(completed / aborted / blocked / error / max-tokens / interrupted)
step/start{ turn, step }打开某轮次里的一个步骤
step/end{ turn, step }关闭该步骤

注意 turn/start 只带一个 { turn },turn/end 才带 { turn, reason }。这个不对称是刻意的:轮次开始时不预设结局,只有走到关闭那一刻,系统才知道它是因为完成、被中止、被阻塞、报错、被 max-tokens 截断还是被中断而结束。这也意味着,如果你在回放时只看到 turn/start 而没有配对的 turn/end,就只能判定这次执行「没有正常收尾」。

官方时序图走一遍:turn/start → agent/pre-step → step/start → llm/stream → 工具 → step/end → turn/end

官方时序图把一次完整流程画成了这条链:

turn/start → agent/pre-step → step/start → llm/stream → 工具 → step/end → turn/end

逐段读一遍。turn/start 在 loop 认领排队输入或运行 pre-step 之前打开轮次,这是整个执行边界的起点。接着进入 agent/pre-step:它是模型请求前最后的决策点,能改写已领取消息,也能直接拒绝。决策通过后 step/start 打开该轮次里的第一个步骤,llm/stream 承载流式响应(token 级的分片就落在 assistant/chunk 里),如果模型发起了工具调用,就进入「工具」这一段——tool/call 记录请求、工具执行、tool/result 记录模型可见结果。工具往返消耗完之后,step/end 关闭当前步骤;如果模型还有后续动作,会再开下一个 step/start。当不再欠下任何工作时,turn/end 关闭轮次。

这里有一个非常关键的输入侧细节:输入通过同一个 inbox 到达驱动器。 不是每个入口单独走一条队列,而是统一进一个 inbox。其中「有些消息会立即唤醒驱动器」,例如直接的用户提示词;而「注入的上下文会留在 inbox 中,直到另一条消息将其唤醒」。这个区分对可观测性影响很大:你在日志里看到的 user/message 事件,可能来自直接提示词,也可能来自注入上下文、steering 或实时收件箱事件,它们共享同一个带标识的值类型 UserMessage。回放时如果只看 content,会误判一条消息是「用户亲口说的」还是「系统注入的」;好在每个 user/message 都带标识,配合 surfaceOp 才能还原它进入派生 surface 的方式。

把这条时序链翻译成可观测性视角,可以拆成三类观测点:

  1. 边界观测:turn/start、step/start、step/end、turn/end,用来还原执行结构与耗时。
  2. 内容观测:user/message、assistant/chunk、assistant/message、tool/call、tool/result,用来还原模型看到和产出了什么。
  3. 控制观测:agent/pre-step、agent/request、agent/status、agent/turn-stopping,用来观察或拦截进行中的工作。

这三类观测点分别对应会话事件、Agent 事件、能力事件三域:会话事件是持久事实,追加进日志并广播,适合「重新加载后仍然存在」;Agent 事件携带活跃 Agent,适合实时控制与状态;能力事件用于无导入循环地向 seam 附加策略。选对事件域,是大多数改动的第一个决定。

agent/pre-step 决定模型看到什么:改写、拒绝与「空轮次也落日志」

agent/pre-step 是整个生命周期里最值得敬畏的一环,因为它决定模型最终看到什么。监听器在这里有两类动作:改写已领取的消息,或直接拒绝它们。改写意味着你可以在模型看到之前插入、删除、替换上下文;拒绝意味着这次领取不发生,模型不会收到这批输入。

官方文档里有一条容易被忽略但极其重要的规则:首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。 换句话说,「什么都没发生」也会留下痕迹。这直接呼应了会话日志作为唯一真源的设计哲学,也是「模型可见即已记录」这条不变量的延伸:抵达模型请求的一切都必须能从日志重建,反过来说,一次被拒绝的尝试也是完整交互历史的一部分,不能凭空消失。

这条规则对排查的价值极高。想象一个场景:用户说「帮我修一下 README 的 typo」,但你的策略层在 pre-step 里因为权限或护栏把输入拒绝了。如果没有「空轮次落日志」,你会看到日志里什么都没有,误以为是驱动器没收到消息;而有了这条规则,日志里会出现一对 turn/start 与 turn/end,中间没有任何 step/start,reason 很可能落在 blocked 档位。这就是「不含步骤的持久轮次」的意义:它让「被拒绝的尝试」变成可查询的事实,而不是消失在黑洞里。

与 pre-step 配套的是前面提过的两类事件语义:agent/pre-step、agent/request、llm/stream 和三个 tools/* 事件都是 waterfall,监听器必须调用 next() 才能委托下去;agent/turn-stopping 是 serial 事件,没有 next()。所以一个健壮的 pre-step 监听器应当显式表达三种出口:委托(return next(...))、改写后委托、拒绝短路。至于改写为空与直接拒绝的区别,从可观测性角度看,两者都会产生「不含步骤的轮次」,但语义不同——一个是「策略过滤后没有内容」,一个是「明确拒绝」,需要结合 reason 与你的业务日志一起判断。

turn/end 的 TurnEndReason 全档位:completed / aborted / blocked / error / max-tokens / interrupted

turn/end 携带 { turn, reason },reason 是一个离散的 TurnEndReason。把它逐个拆开,基本就能覆盖轮次状态流转的全貌:

reason 档位含义典型成因排查提示
completed正常完成不再欠下任何工作,模型循环自然收束健康路径,可作基线对照
aborted被中止外部取消或主动停止区分「谁发起的取消」,对照调用方日志
blocked被阻塞策略或护栏拦截,常见于 pre-step 拒绝往往伴随不含步骤的空轮次
error出错执行过程中抛出异常需要结合 error 与上游工具日志定位
max-tokens被截断达到 max-tokens 上限会留下无内容的 assistant/message 以保存用量
interrupted被中断执行被外部打断与 aborted 区分:中断更偏运行时打断

与 turn/start 的 { turn } 数据形态配合起来看,轮次状态流转就很清楚了:turn/start 只声明「第 N 个轮次开始了」,不带任何预期结局;到 turn/end 才由 reason 落锤。因此用日志做状态机重建时,正确做法是维护一个 per-turn 的状态映射,遇到 turn/start 置为「进行中」,遇到 turn/end 按 reason 落终态,遇到只有 start 没有 end 的悬挂轮次则单独告警。

其中 max-tokens 档位有一个非常具体的字段级细节,素材讲得很明确:因 max-tokens 截断且无内容的步骤仍会记录一条 assistant/message 来保存用量、提供方与模型,但无内容的 assistant 轮次不得进入提供方 transcript。 也就是说,这条 assistant/message 事件是真实存在的持久事件,它的作用是留住 usage、provider、model 这些元数据;但它在投影成模型历史时会被跳过——内容为空的 assistant/message 也会被跳过。这是一个典型的「持久性与投影性分离」设计:日志负责记录一切事实,派生历史只负责给模型看它该看的东西。

tool/call 与 tool/result 的字段细节:callId、原始 JSON 字符串 arguments、sourceEventSeqs 精确回溯

工具往返是 Agent 执行里最容易出问题的环节,所以字段设计得相当克制且可回溯。先看 tool/call:它携带 { turn, step, callId, name, arguments }。这里最关键的一条是——arguments 是模型产出的原始 JSON 字符串,不是已经被解析成对象的结构。这一点非常重要:模型生成的 JSON 可能是畸形的、可能带尾随逗号、可能被流式分片切碎,保留原始字符串意味着你在排查时能看到模型「真正吐出来的东西」,而不是解析失败后的一片空白。解析是你的事,不是日志的事。

tool/call 里的 callId 是连接请求与结果的钥匙:tool/result 携带 { turn, step, message, error?, meta? },其中 message 是「一次完成的工具调用的模型可见结果」,error? 与 meta? 是可选字段,分别承载错误信息与附加元数据。callId 让「哪次调用产生了这个结果」可以被精确配对,尤其在同一个 step 里并行发起多个工具调用时,没有 callId 就无法还原对应关系。

再来看 sourceEventSeqs。assistant/message 事件通过 sourceEventSeqs 精确列出对应的 assistant/chunk 事件,包括显式空列表。这个「显式空列表」的细节值得单独强调:它意味着「这条 assistant/message 没有对应的流式分片」是一个被明确记录的事实,而不是「因为没写所以不知道」。在可观测性里,显式空值和缺失值是完全不同的语义——前者是「已知为空」,后者是「未知」。回放链路要能区分这两种情况,才能正确判断一次组装消息是否本应来自流式。

事件关键字段字段语义为什么重要
tool/callcallId本次工具调用的标识与 tool/result 精确配对,支撑并行调用还原
tool/callarguments模型产出的原始 JSON 字符串保留畸形/未解析形态,便于排错
tool/resultmessage模型可见的工具结果工具结果以 user 角色回到模型
tool/resulterror? / meta?可选错误与元数据区分失败结果与附加上下文
assistant/messagesourceEventSeqs对应的 assistant/chunk 序号列表精确回溯组装消息的来源分片,含显式空列表

还有一条投影层面的细节:tool/result 会投影成「一条带 tool-result 块的 user 消息」——工具结果是以 user 角色回到模型的。而 assistant/chunk 在投影时被跳过,因为它属于回放/UI 数据,组装后的 assistant/message 才是权威;tool/call 也不投影为消息,它属于结构化事实。理解这些「哪些事件投影、哪些不投影」,是写重放器的前提。

动手解析 JSONL 会话日志:surfaceOp 标记与不投影的边界事件

下面用一段真实形态的会话日志把前面的规则串起来。素材给了一小段 JSONL(规范打包行布局),来自一次「修复 runoob 仓库 typo」的任务。每一行是一个 SessionEvent,包含 type、seq、time 与 data:

{"type":"turn/start","seq":0,"time":1755000000000,"data":{"turn":1}}
{"type":"step/start","seq":1,"time":1755000000010,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":2,"time":1755000000020,"data":{"role":"user","content":[{"type":"text","text":"Fix the typo in the runoob README."}]},"surfaceOp":"append","sourceEventSeqs":[0]}
{"type":"assistant/chunk","seq":3,"time":1755000000030,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","text":"I'll "}}}
{"type":"assistant/chunk","seq":4,"time":1755000000040,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","name":"bash","arguments":"{\"command\":\"grep runoob README.md\"}"}}}
{"type":"assistant/message","seq":5,"time":1755000000050,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I'll search"},{"type":"tool_use","id":"call_1","name":"bash","input":{"command":"grep runoob README.md"}}]},"usage":{"inputTokens":12,"outputTokens":4}},"surfaceOp":"append","sourceEventSeqs":[3,4]}
{"type":"tool/call","seq":6,"time":1755000000060,"data":{"turn":1,"step":1,"callId":"call_1","name":"bash","arguments":"{\"command\":\"grep runoob README.md\"}"}}
{"type":"tool/result","seq":7,"time":1755000000070,"data":{"turn":1,"step":1,"message":{"role":"tool","toolName":"bash","content":"runoob","isError":false}},"surfaceOp":"append","sourceEventSeqs":[6]}
{"type":"step/end","seq":8,"time":1755000000080,"data":{"turn":1,"step":1}}
{"type":"turn/end","seq":9,"time":1755000000090,"data":{"turn":1,"reason":{"kind":"completed"}}}

这份日志里藏着几条值得逐条核对的规律。第一,seq 是单调递增的,素材明确 seq = log.length,也就是说序号本身就是日志长度,天然连续、天然唯一;time 是 epoch 毫秒。第二,user/message、assistant/message、tool/result 三种 surface 事件带有 surfaceOp 标记,说明它们如何加入派生 surface(这里都是 "append")。第三,turn/start、step/start 等边界事件不携带 surfaceOp,也不会投影成模型消息——它们是结构信息,不是内容。

再看 sourceEventSeqs 的用法:user/message(seq 2)的 sourceEventSeqs 是 [0],指向 turn/start;assistant/message(seq 5)的 sourceEventSeqs 是 [3, 4],正好对应两条 assistant/chunk;tool/result(seq 7)的 sourceEventSeqs 是 [6],指向 tool/call。这条回溯链让「组装结果」与「原始分片/调用」之间的对应关系完全可查。

接下来用 Python 重放这份日志,重建对话并标出 turn/step 边界。这是 Session.deriveMessages() 的一个简化教学模型;真实实现是缓存的——每个 surface 节点在首次出现时投影一次,surface 重写时重建——并且返回冻结的消息数组,通过投影修改已记录的历史在类型上不可表达:

# 文件路径:examples/parse_session_log.py
# 解析一份 JSONL 会话日志,重建模型可见的对话并标出 turn/step 边界。
import json
import sys

def derive_messages(events):
    messages = []
    for ev in events:
        t = ev["type"]
        d = ev["data"]
        if t == "user/message":
            messages.append({"role": "user", "content": d["content"]})
        elif t == "assistant/message":
            if d["message"].get("content"):
                messages.append({"role": "assistant", "content": d["message"]["content"]})
        elif t == "tool/result":
            messages.append({"role": "tool", "name": d["message"]["toolName"], "content": d["message"]["content"]})
        # turn/*, step/*, assistant/chunk, tool/call 不投影为消息
    return messages

def main(path):
    with open(path, encoding="utf-8") as f:
        events = [json.loads(line) for line in f if line.strip()]

    # 第一遍:打印执行边界,理解 turn 与 step 的嵌套关系。
    for ev in events:
        d = ev["data"]
        if ev["type"] == "turn/start":
            print(f"[turn/start] turn={d['turn']}")
        elif ev["type"] == "turn/end":
            print(f"[turn/end] turn={d['turn']} reason={d['reason']}")
        elif ev["type"] == "step/start":
            print(f"  [step/start] turn={d['turn']} step={d['step']}")
        elif ev["type"] == "step/end":
            print(f"  [step/end] turn={d['turn']} step={d['step']}")
        elif ev["type"] == "assistant/chunk":
            print(f"  chunk: {d['chunk']['type']}")
        elif ev["type"] == "tool/call":
            print(f"  tool/call: {d['name']} args={d['arguments']}")

    # 第二遍:重建模型可见的派生历史。
    print("\n模型可见的派生消息:")
    for m in derive_messages(events):
        if m["role"] == "tool":
            print(f"  [tool] {m['name']}: {m['content']}")
        else:
            print(f"  [{m['role']}] {m['content']}")

if __name__ == "__main__":
    main(sys.argv[1])

把它存成 examples/parse_session_log.py,再准备一份 session.jsonl,直接运行:

python examples/parse_session_log.py session.jsonl

这里有两处教学简化需要说明。第一,真实实现是缓存的,且返回冻结消息:surface 节点首次出现时投影一次,重写时重建;返回的 Message[] 在类型上不可修改,防止你通过投影去改写已记录的历史。第二,示例里我额外给 assistant/message 加了一层「内容为空则跳过」的判断,因为素材明确指出内容为空的 assistant/message 会被跳过——但那条事件本身仍然保留在日志里,用来保存 usage、provider 与 model。如果你在实现中直接无脑 append,就会把空内容灌进模型历史,这正是 max-tokens 场景下最常见的投影 bug。

还有一个工程坑值得提醒:这份日志里 tool/result 的 message.role 是 "tool",但在投影规则里,tool/result 应当投影为「一条带 tool-result 块的 user 消息」——工具结果以 user 角色回到模型。教学脚本为了打印直观保留了原始 role,真实实现必须按提供方要求的角色形态组装,否则下一轮请求会因角色不符被拒绝。这类「日志形态」与「投影形态」不一致的地方,是写重放器时最容易翻车的位置。

2026 年 9 月的最新实践:把会话日志当作可观测性数据面来消费

到 2026 年 9 月,围绕 Agent 可观测性的一个明显趋势是:不再把日志当「调试附属品」,而是当作一等公民的数据面来消费。DeepSeek Harness 的设计正好契合这个方向——session/event 事件流本身就是可回放的 transcript 数据源。官方给出的指引是:需要可回放 transcript 数据的 SDK 用户应当消费 session/event 事件流。结合素材里已有的机制,可以搭出一条完整的实践链路:

  1. 采集层:订阅 session/event 事件流,落库时保留 type、seq、time、data 的原始形态,尤其不要丢弃 sourceEventSeqs 与 surfaceOp。这两者一个负责「这条内容的来源」,一个负责「它如何进入派生 surface」。
  2. 重建层:用与 deriveMessages 一致的投影规则重建模型可见历史,保证「回放看到的就是模型当时看到的」。由于模型可见即已记录,这条重建链在原理上是完备的——凡是抵达模型请求的,都能从日志重建。
  3. 回溯层:以 callId 配对 tool/call 与 tool/result,以 sourceEventSeqs 从 assistant/message 反查到具体的 assistant/chunk。排查「模型为什么这么说」时,直接顺链下钻到 token 级分片。
  4. 告警层:对 turn/end 的 reason 分布做监控,尤其关注 error、max-tokens、blocked 与只有 turn/start 没有 turn/end 的悬挂轮次。
  5. 扩展层:新增一项模型可见输入,就必须新增一个会话事件——扩展 SessionEventMap 并从日志渲染。这条约束保证了可观测面不会因为功能迭代而漏掉新输入。

为什么「新增模型可见输入就要新增会话事件」如此关键?因为它把可观测性从「事后补埋点」变成了「结构强制」。如果某个新输入绕过了日志,那么它就无法被重建,回放就会与真实执行产生偏差,这直接违反运行时不变量。由于事件基于 type 做真正的可辨识联合,switch (event.type) 能直接收窄 event.data,无需类型断言;而所有 event.data 都必须能无损序列化为 JSON,Session.append 会在源头强制这一点。这意味着你的可观测性管线拿到的永远是结构化、可序列化的事件,不需要为「格式不统一」写一堆容错泥巴。

把这条链路做成日常工具时,我建议至少暴露三个查询能力:按 turn 查全部事件(还原一次循环)、按 callId 查工具往返(还原一次工具调用)、按 sourceEventSeqs 反查来源(还原一条消息的来龙去脉)。这三者组合起来,就是「轨迹回放」的最小可用集。

总结与最佳实践

把全文压缩成一份可执行清单:

  • 日志即真源:Session 是由类型化 SessionEvent 组成的仅追加日志,是 agent 完整交互历史的唯一真源;LLM 消息历史从日志派生,从不单独存储;回放就是重新从同一组事件派生历史。
  • 守住不变量:「模型可见即已记录」——抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言。新增模型可见输入,就扩展 SessionEventMap 并新增会话事件。
  • 选对事件域:会话事件(turn/start、step/start、user/message、assistant/*、tool/call、tool/result)是持久事实;Agent 事件(agent/pre-step、agent/request、agent/status、agent/turn-stopping)用于实时控制;能力事件(tools/*、fs/*、llm/stream)用于给 seam 挂策略与适配器。
  • 分清传播语义:agent/pre-step、agent/request、llm/stream 与三个 tools/* 是 waterfall,必须调用 next() 才能委托;agent/turn-stopping 是 serial,没有 next()。
  • 理清边界:step 是一次模型请求加它调用的工具;turn 含零个或多个 step,在领取首条输入前打开、在不再欠下任何工作时关闭,且只包围一次模型循环而非整个会话。
  • 重视空轮次:首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,日志记录这次尝试——不要把「什么都没发生」当成「没有发生」。
  • 盯紧 turn/end 的 reason:completed / aborted / blocked / error / max-tokens / interrupted 六档,配合 turn/start 的 { turn },构成完整的轮次状态流转;对悬挂轮次单独告警。
  • 保留原始形态:tool/call 的 arguments 是模型产出的原始 JSON 字符串;tool/result 携带 error?/meta?;assistant/message 用 sourceEventSeqs 精确列出对应 chunk,包括显式空列表。
  • 投影规则要严格:user/message → user 消息;assistant/message → assistant 消息(内容为空则跳过);tool/result → 带 tool-result 块的 user 消息;assistant/chunk、tool/call 与 turn/*、step/* 不投影为模型消息。
  • 回放要缓存与冻结:deriveMessages 是缓存的,surface 节点首次出现时投影一次、重写时重建;返回冻结数组,让「通过投影修改已记录历史」在类型上不可表达。
  • 消费 session/event:需要可回放 transcript 数据的 SDK 用户应当消费 session/event 事件流,至少提供按 turn、按 callId、按 sourceEventSeqs 三种查询能力。