Skip to content

核心二:会话事件日志

对应官方:core/session 子系统 ——「会话日志是模型所见上下文的唯一来源」

核心思想:模型可见的,必须已入日志

Agent 和普通聊天最大的区别是过程复杂:模型会多次请求、调用工具、拿到结果再继续。如果我们只保存「最终对话」,就无法回答这些问题:

  • 模型当时看到了什么?(审计)
  • 上一步工具返回了什么?(回放)
  • 中断了怎么续跑?(恢复)
  • 界面怎么渲染流式输出?(UI)

官方给出的答案是:一切以追加式事件日志为准。任何要进入模型请求的内容,必须先写成一条会话事件;模型的历史消息(deriveMessages())是从日志投影出来的,而不是另外维护一份。

text
user/message ──► assistant/message(tool_calls) ──► tool/result ──► assistant/message
      └──────────────── 日志(ground truth) ────────────────┘

                              ▼ deriveMessages()
                   模型实际看到的 messages 数组

事件类型

事件类型持久化作用
turn/start / turn/end一轮对话的边界
step/start / step/end一个 step 的边界(一次模型请求+工具)
user/message用户输入,进入模型上下文
assistant/chunk流式增量(回放/UI 保真用)
assistant/message完整 assistant 消息(含 tool_calls),进入模型上下文
tool/result工具结果,进入模型上下文

为什么要存 assistant/chunk?

assistant/message 是最终结果,chunk 是过程。日志里保留 chunk,界面就能精确重放「当时是怎么一个字一个字打出来的」。官方称之为「保留回放与 UI 保真」。

实现

追加式日志

ts
/**
 * 会话事件日志:append-only SessionEvent + 消息投影。
 *
 * 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
 * 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
 */

import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

export interface SessionEvent {
  type: string;
  seq: number;
  ts: number;
  [key: string]: unknown;
}

export interface ToolCall {
  id: string;
  type: "function";
  function: { name: string; arguments: string };
}

export interface ChatMessage {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  tool_calls?: ToolCall[];
  tool_call_id?: string;
}

const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);

export class Session {
  id: string;
  events: SessionEvent[] = [];

  constructor(id: string = randomUUID().slice(0, 12)) {
    this.id = id;
  }

  /** 追加一条会话事件,自动带上序号与时间戳 */
  append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
    const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
    this.events.push(event);
    return event;
  }

  /** 从日志投影模型可见的历史消息(官方 deriveMessages) */
  deriveMessages(): ChatMessage[] {
    const messages: ChatMessage[] = [];
    for (const ev of this.events) {
      if (!MESSAGE_TYPES.has(ev.type)) continue;
      if (ev.type === "user/message") {
        messages.push({ role: "user", content: ev.content as string });
      } else if (ev.type === "assistant/message") {
        const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
        if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
        messages.push(msg);
      } else if (ev.type === "tool/result") {
        messages.push({
          role: "tool",
          tool_call_id: ev.tool_call_id as string,
          content: ev.content as string,
        });
      }
    }
    return messages;
  }

  // ---------- 持久化 ----------

  save(path: string): void {
    mkdirSync(dirname(path), { recursive: true });
    const lines = [
      JSON.stringify({ type: "session/meta", id: this.id }),
      ...this.events.map((ev) => JSON.stringify(ev)),
    ];
    writeFileSync(path, lines.join("\n") + "\n", "utf8");
  }

  static load(path: string): Session {
    const session = new Session();
    const text = readFileSync(path, "utf8");
    for (const line of text.split("\n")) {
      if (!line.trim()) continue;
      const ev = JSON.parse(line);
      if (ev.type === "session/meta") {
        session.id = ev.id as string;
        continue;
      }
      session.events.push(ev);
    }
    return session;
  }
}

/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
  private map = new Map<string, Session>();

  create(): Session {
    const s = new Session();
    this.map.set(s.id, s);
    return s;
  }

  get(id: string): Session | undefined {
    return this.map.get(id);
  }

  all(): Session[] {
    return [...this.map.values()];
  }
}

要点:

  • append() 自动加 seq(序号)和 ts(时间戳),事件不可修改 —— 这就是「追加式」
  • deriveMessages() 只关心三种消息事件,把日志投影成模型能直接消费的数组

消息投影

ts
/**
 * 会话事件日志:append-only SessionEvent + 消息投影。
 *
 * 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
 * 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
 */

import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

export interface SessionEvent {
  type: string;
  seq: number;
  ts: number;
  [key: string]: unknown;
}

export interface ToolCall {
  id: string;
  type: "function";
  function: { name: string; arguments: string };
}

export interface ChatMessage {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  tool_calls?: ToolCall[];
  tool_call_id?: string;
}

const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);

export class Session {
  id: string;
  events: SessionEvent[] = [];

  constructor(id: string = randomUUID().slice(0, 12)) {
    this.id = id;
  }

  /** 追加一条会话事件,自动带上序号与时间戳 */
  append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
    const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
    this.events.push(event);
    return event;
  }

  /** 从日志投影模型可见的历史消息(官方 deriveMessages) */
  deriveMessages(): ChatMessage[] {
    const messages: ChatMessage[] = [];
    for (const ev of this.events) {
      if (!MESSAGE_TYPES.has(ev.type)) continue;
      if (ev.type === "user/message") {
        messages.push({ role: "user", content: ev.content as string });
      } else if (ev.type === "assistant/message") {
        const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
        if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
        messages.push(msg);
      } else if (ev.type === "tool/result") {
        messages.push({
          role: "tool",
          tool_call_id: ev.tool_call_id as string,
          content: ev.content as string,
        });
      }
    }
    return messages;
  }

  // ---------- 持久化 ----------

  save(path: string): void {
    mkdirSync(dirname(path), { recursive: true });
    const lines = [
      JSON.stringify({ type: "session/meta", id: this.id }),
      ...this.events.map((ev) => JSON.stringify(ev)),
    ];
    writeFileSync(path, lines.join("\n") + "\n", "utf8");
  }

  static load(path: string): Session {
    const session = new Session();
    const text = readFileSync(path, "utf8");
    for (const line of text.split("\n")) {
      if (!line.trim()) continue;
      const ev = JSON.parse(line);
      if (ev.type === "session/meta") {
        session.id = ev.id as string;
        continue;
      }
      session.events.push(ev);
    }
    return session;
  }
}

/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
  private map = new Map<string, Session>();

  create(): Session {
    const s = new Session();
    this.map.set(s.id, s);
    return s;
  }

  get(id: string): Session | undefined {
    return this.map.get(id);
  }

  all(): Session[] {
    return [...this.map.values()];
  }
}

注意 assistant/message 的投影:有 tool_calls 就带上;tool/result 通过 tool_call_id 和 assistant 的工具调用配对 —— 这是 OpenAI 兼容协议的硬性要求,顺序不能乱:

ts
// 模型看到的(必须严格遵守的顺序)
[
  { role: "user", content: "1+1 等于多少?" },
  { role: "assistant", content: "", tool_calls: [{ id: "call_1", function: { name: "run_bash", arguments: "..." } }] },
  { role: "tool", tool_call_id: "call_1", content: "2" },
  { role: "assistant", content: "答案是 2" },
]

持久化:JSONL

ts
/**
 * 会话事件日志:append-only SessionEvent + 消息投影。
 *
 * 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
 * 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
 */

import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

export interface SessionEvent {
  type: string;
  seq: number;
  ts: number;
  [key: string]: unknown;
}

export interface ToolCall {
  id: string;
  type: "function";
  function: { name: string; arguments: string };
}

export interface ChatMessage {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  tool_calls?: ToolCall[];
  tool_call_id?: string;
}

const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);

export class Session {
  id: string;
  events: SessionEvent[] = [];

  constructor(id: string = randomUUID().slice(0, 12)) {
    this.id = id;
  }

  /** 追加一条会话事件,自动带上序号与时间戳 */
  append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
    const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
    this.events.push(event);
    return event;
  }

  /** 从日志投影模型可见的历史消息(官方 deriveMessages) */
  deriveMessages(): ChatMessage[] {
    const messages: ChatMessage[] = [];
    for (const ev of this.events) {
      if (!MESSAGE_TYPES.has(ev.type)) continue;
      if (ev.type === "user/message") {
        messages.push({ role: "user", content: ev.content as string });
      } else if (ev.type === "assistant/message") {
        const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
        if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
        messages.push(msg);
      } else if (ev.type === "tool/result") {
        messages.push({
          role: "tool",
          tool_call_id: ev.tool_call_id as string,
          content: ev.content as string,
        });
      }
    }
    return messages;
  }

  // ---------- 持久化 ----------

  save(path: string): void {
    mkdirSync(dirname(path), { recursive: true });
    const lines = [
      JSON.stringify({ type: "session/meta", id: this.id }),
      ...this.events.map((ev) => JSON.stringify(ev)),
    ];
    writeFileSync(path, lines.join("\n") + "\n", "utf8");
  }

  static load(path: string): Session {
    const session = new Session();
    const text = readFileSync(path, "utf8");
    for (const line of text.split("\n")) {
      if (!line.trim()) continue;
      const ev = JSON.parse(line);
      if (ev.type === "session/meta") {
        session.id = ev.id as string;
        continue;
      }
      session.events.push(ev);
    }
    return session;
  }
}

/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
  private map = new Map<string, Session>();

  create(): Session {
    const s = new Session();
    this.map.set(s.id, s);
    return s;
  }

  get(id: string): Session | undefined {
    return this.map.get(id);
  }

  all(): Session[] {
    return [...this.map.values()];
  }
}

每条事件一行 JSON,首行存会话元信息(id)。追加式日志天然适合 JSONL:恢复会话只需逐行读入,写回只需追加。

会话注册表

ts
/**
 * 会话事件日志:append-only SessionEvent + 消息投影。
 *
 * 官方概念:会话日志是模型所见上下文的唯一来源,deriveMessages() 从日志
 * 投影出模型历史消息。规则:「模型可见的,必须已入日志。」
 */

import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

export interface SessionEvent {
  type: string;
  seq: number;
  ts: number;
  [key: string]: unknown;
}

export interface ToolCall {
  id: string;
  type: "function";
  function: { name: string; arguments: string };
}

export interface ChatMessage {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  tool_calls?: ToolCall[];
  tool_call_id?: string;
}

const MESSAGE_TYPES = new Set(["user/message", "assistant/message", "tool/result"]);

export class Session {
  id: string;
  events: SessionEvent[] = [];

  constructor(id: string = randomUUID().slice(0, 12)) {
    this.id = id;
  }

  /** 追加一条会话事件,自动带上序号与时间戳 */
  append(type: string, payload: Record<string, unknown> = {}): SessionEvent {
    const event: SessionEvent = { type, seq: this.events.length, ts: Date.now() / 1000, ...payload };
    this.events.push(event);
    return event;
  }

  /** 从日志投影模型可见的历史消息(官方 deriveMessages) */
  deriveMessages(): ChatMessage[] {
    const messages: ChatMessage[] = [];
    for (const ev of this.events) {
      if (!MESSAGE_TYPES.has(ev.type)) continue;
      if (ev.type === "user/message") {
        messages.push({ role: "user", content: ev.content as string });
      } else if (ev.type === "assistant/message") {
        const msg: ChatMessage = { role: "assistant", content: (ev.content as string) ?? "" };
        if (ev.tool_calls) msg.tool_calls = ev.tool_calls as ToolCall[];
        messages.push(msg);
      } else if (ev.type === "tool/result") {
        messages.push({
          role: "tool",
          tool_call_id: ev.tool_call_id as string,
          content: ev.content as string,
        });
      }
    }
    return messages;
  }

  // ---------- 持久化 ----------

  save(path: string): void {
    mkdirSync(dirname(path), { recursive: true });
    const lines = [
      JSON.stringify({ type: "session/meta", id: this.id }),
      ...this.events.map((ev) => JSON.stringify(ev)),
    ];
    writeFileSync(path, lines.join("\n") + "\n", "utf8");
  }

  static load(path: string): Session {
    const session = new Session();
    const text = readFileSync(path, "utf8");
    for (const line of text.split("\n")) {
      if (!line.trim()) continue;
      const ev = JSON.parse(line);
      if (ev.type === "session/meta") {
        session.id = ev.id as string;
        continue;
      }
      session.events.push(ev);
    }
    return session;
  }
}

/** 会话注册表(对应官方 ctx.sessions):按 id 存取会话 */
export class Sessions {
  private map = new Map<string, Session>();

  create(): Session {
    const s = new Session();
    this.map.set(s.id, s);
    return s;
  }

  get(id: string): Session | undefined {
    return this.map.get(id);
  }

  all(): Session[] {
    return [...this.map.values()];
  }
}

对应官方 ctx.sessions,管理多个会话(Web UI 多会话、断点续跑都靠它)。

一次真实运行的日志

这是 pnpm run demo(脚本化演示)产出的真实会话日志:

text
turn/start           {"agent":"5a1b896f-beb"}
user/message         {"content":"1+1 等于多少?用工具算一下"}
step/start           {"step":0}
assistant/chunk      {"delta":{"content":"让我先算"}}
assistant/chunk      {"delta":{"content":"一下。"}}
assistant/message    {"content":"让我先算一下。","tool_calls":[{"id":"call_1",...}]}
tool/result          {"tool_call_id":"call_1","content":"2"}
step/end             {"step":0,"tool_calls":1}
step/start           {"step":1}
assistant/chunk      {"delta":{"content":"计算完成"}}
assistant/chunk      {"delta":{"content":":1+1"}}
assistant/chunk      {"delta":{"content":"=2。"}}
assistant/message    {"content":"计算完成:1+1=2。","tool_calls":[]}
step/end             {"step":1,"tool_calls":0}
turn/end             {}

tool/result 里的 "2" 不是编的 —— 那是 run_bash 真实执行 echo 1+1 | bc 的结果。

一条工程铁律

任何新进入模型上下文的输入(比如后面章节的「技能注入」),都必须先入日志、再进请求。如果绕过日志直接拼进 messages,回放和审计就失真了。官方用运行时断言强制这条规则,我们靠纪律。

测试验证

ts
it("deriveMessages 正确投影模型历史消息", () => {
  // ...构建日志...
  expect(messages).toEqual([...]); // 顺序、字段逐一断言

it("非消息事件(step/start 等)不进入模型上下文", () => {
  // turn/start、step/start、assistant/chunk 都被过滤
  expect(roles).toEqual(["user", "assistant"]);
});

本章回顾

  • 日志是唯一事实来源,上下文从日志投影
  • 消息事件 user/message / assistant/message / tool/result 进入模型,其余事件只服务回放与审计
  • JSONL 持久化让会话可恢复、可续跑

下一步:LLM 适配器缝隙 →

基于 MIT 许可的 deepseek-ai/deepseek-harness 设计理念 · 本教程为独立教学项目,与 DeepSeek 官方无隶属关系