Agent 记忆

L0 原始对话:忠实记录与增量捕获

本期拆解最底层:L0 是怎么把对话原样记下来的,以及它如何解决"重复记录"和"记录污染"两个工程问题。

L0 记录细节:增量捕获 · 清洗 · 双写 全屏查看 ↗

一、L0 的定位

官方对 L0 的描述很朴素:

L0 Conversation:原始对话与完整上下文 —— 用于核对原话、时间和来源

在四层记忆模型里,L0 是唯一不经 LLM 的层。它的核心实现文件是 l0-recorder.ts,头注释里写得很清楚:

L0 Conversation Recorder: records raw conversation messages to local JSONL files. Triggered from agent_end hook. Receives the conversation messages directly from the hook context (no file I/O needed), sanitizes them, filters out noise, and writes to ~/.openclaw/memory-tdai/conversations/YYYY-MM-DD.jsonl.

一句话:Agent 每轮对话结束,把这一轮新增的 user / assistant 原话,清洗后追加到当天的 JSONL 文件里。

二、整体流程

Agent 对话(hook 全量消息)
   │
   ▼
① 位置切片 Position Slice ── 只留"本轮新增"的消息(免疫重启漂移)
   ▼
② 提取 user/assistant ── 拼接文本片段、base64 图片替换为 [image]
   ▼
③ 时间戳游标过滤 ── 只留 timestamp > afterTimestamp(重启兜底)
   ▼
④ 还原原始用户输入 ── 用缓存的 originalUserText 替换被污染的 user 消息
   ▼
⑤ 清洗 + 过滤 ── sanitizeText / stripCodeBlocks / shouldCaptureL0
   ▼
⑥ 双写落盘 ── JSONL(每日一文件)+ l0_conversations 表

三、两个核心工程问题

问题 1:怎么保证"只记新的"?—— 双层防重

L0 每次触发时拿到的是整个会话的历史消息(hook 上下文),必须从中挑出"上一轮之后新增"的部分。官方用了双保险:

  • 第一层 · 位置切片(Position Slice):在 before_prompt_build 时缓存 originalUserMessageCount(构建 prompt 前的消息数),随后按它切分 rawMessages,只保留提示词构建之后追加的消息,即本轮新消息。这层对 Gateway 重启后的时间戳漂移免疫
  • 第二层 · 时间戳游标(Timestamp Cursor):记录上一批已捕获消息的最大时间戳 afterTimestamp,只保留 timestamp > afterTimestamp 的消息。严格大于是安全的——两轮之间至少有一次 LLM 调用(数百毫秒),不存在同毫秒碰撞。

安全阀:位置切片不可用(缓存过期 / 进程重启)且全部消息都通过时间戳过滤(超过 8 条)时,会告警提示可能的时间戳漂移。

—— 源码注释(l0-recorder.ts)

问题 2:怎么保证"不记录污染"?—— 清洗链

框架在构建 prompt 时会把 prependContext(注入的记忆)拼进用户消息,如果不处理,L0 会把记忆本身也记成对话。官方用三步解决:

  1. 原始输入还原before_prompt_build 时缓存干净的 originalUserText;记录时按时间戳定位被污染的 user 消息并替换回原文。匹配失败时由 sanitize 兜底。
  2. sanitizeText:移除注入标签,防止"记忆污染对话 → 对话又变成记忆"的反馈回路。
  3. stripCodeBlocks:assistant 回复里的围栏代码块被剥离(降低嵌入噪声);base64 图片数据替换为 [image]

此外 shouldCaptureL0 还会过滤掉框架噪音消息和 / 斜杠命令(框架指令,不是用户内容)。

设计哲学:L0 故意全量捕获(permissive),严格的过滤放在 L1 入口(strict)。证据层宁可多记,也不丢。

四、落盘格式:JSONL + SQLite 双写

JSONL 通道

{"sessionKey":"sess_001","sessionId":"sess_001","teamId":"default","userId":"default","agentId":"default","recordedAt":"2026-09-01T08:00:00.000Z","id":"msg_1785ab3f","role":"user","content":"别重构旧鉴权模块,移动端还在用","timestamp":1785657600000}
{"sessionKey":"sess_001","sessionId":"sess_001","teamId":"default","userId":"default","agentId":"default","recordedAt":"2026-09-01T08:00:01.000Z","id":"msg_1785cd21","role":"assistant","content":"明白,我会在改动前先确认影响范围","timestamp":1785657601000}

设计要点(源码注释原文):

  • Uses JSONL format (one message per line — flat, easy to grep/stream)
  • One file per day (all sessions merged into the same daily file)
  • sessionKey is stored as a field in each JSONL line, not in the filename

即:每行一条消息、每日一文件、会话标识存在行内。按日分片让文件天然可归档,行内 sessionKey 让任意会话可以流式读取。

SQLite 通道

l0_conversations 表(src/core/store/sqlite.ts)提供程序化访问与向量检索:

CREATE TABLE IF NOT EXISTS l0_conversations (
  record_id TEXT PRIMARY KEY,
  session_key TEXT NOT NULL,
  session_id TEXT DEFAULT 'default',
  team_id TEXT DEFAULT 'default',
  task_id TEXT DEFAULT '',
  user_id TEXT NOT NULL DEFAULT 'default',
  agent_id TEXT NOT NULL DEFAULT 'default',
  role TEXT NOT NULL DEFAULT '',
  message_text TEXT NOT NULL,
  recorded_at TEXT DEFAULT '',
  timestamp INTEGER DEFAULT 0
);

当配置了 embedding(维度 > 0)时,还会创建 l0_vec 虚拟表(vec0,cosine),让 L0 也能被向量检索到——这是 tdai_conversation_search(搜索原始对话)的底层支撑。

隔离字段

每条记录都带 teamId / userId / agentId 三租户隔离字段(缺省落到 default 兼容桶)。官方在 SQLite 的注释里说明这是为了 three-dim tenancy isolation。

注意:写文件失败不阻断——即使 JSONL 落盘失败,过滤后的消息仍会返回给 L1 继续处理,证据层不是唯一依赖。

五、下游消费

  • readConversationMessagesGroupedBySessionId:按 sessionId 分组读取——同一个 sessionKey 内可能存在多个会话实例(如 /reset 之后),L1 抽取会对每个组独立进行,保证每条记忆正确关联所属会话。
  • L1 抽取器以 L0 输出为输入(详见第 3 期)。
  • 用户 / Agent 可通过 tdai_conversation_search 回溯原始原文。

六、本期小结

  • L0 是唯一不经 LLM 的记忆层,职责纯粹:忠实记录。
  • 双层防重:位置切片(主,免疫重启)+ 时间戳游标(兜底)。
  • 三层清洗:原始输入还原、注入标签移除、代码块 / base64 剥离。
  • 双写落盘:JSONL(人 / 流式可读)+ SQLite(程序检索),三租户隔离。
  • 哲学:L0 宽松捕获,L1 严格把关

下一期看 L0 的数据如何变成"记忆卡片":L1 原子记忆的单次 LLM 结构化抽取与批量去重。


图示索引

源码索引

内容文件
L0 记录主流程MemoryCore/src/core/conversation/l0-recorder.ts(recordConversation)
清洗与过滤规则MemoryCore/src/utils/sanitize.ts(sanitizeText / stripCodeBlocks / shouldCaptureL0)
L0 表结构与索引MemoryCore/src/core/store/sqlite.ts(l0_conversations / l0_vec DDL)
L0 读取(分组)MemoryCore/src/core/conversation/l0-recorder.ts(readConversationMessagesGroupedBySessionId)

L0原始对话增量捕获Agent Memory

← 返回AI 笔记