本期拆解最底层: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 会把记忆本身也记成对话。官方用三步解决:
- 原始输入还原:
before_prompt_build时缓存干净的originalUserText;记录时按时间戳定位被污染的 user 消息并替换回原文。匹配失败时由 sanitize 兜底。 - sanitizeText:移除注入标签,防止"记忆污染对话 → 对话又变成记忆"的反馈回路。
- 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 记录细节:增量捕获 · 清洗 · 双写(在新窗口打开完整图示)
源码索引
| 内容 | 文件 |
|---|---|
| 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) |