feat(memory): L2 与 L1 同构 —— 事件列表承载原文,正文内联在块里

块化重构(memory-restructure-plan)换了底层数据结构,但 L1↔L2 的流动没跟着改,
导致这条链出现结构性失效。本次按设计原文修复。

## 断点(生产实测)

**① L2 只存渲染视图,事件本体丢失**

ContextToDoc 把每条事件拍平成一行:truncate(Response,100)、truncate(工具输出,200)。
生产库 1000 篇归档文档实测:
  - response 段恰好 103 字符(=100+"..."):311/2094 = 15.1%
  - 工具输出段恰好 203 字符:7593/13261
  - ToolsUsed 完全不存
⇒ 原文在落库那一刻就不存在了,召回再全也拿不回来。

**② L2 里没有文本块,与 L1 不同构**

L2 块分布 image 437 / audio 7 / text 0 —— 文本全靠 Content 这个视图。

**③ 召回造合成事件,时间戳用归档时刻**

doc_query 用 d.CreatedAt 造一条 cold_storage 事件、正文取 Content 再截 2000 字、
且完全不回填 Blocks。块自带 created_at(捕获时刻),实测与 doc.created_at
差近两小时,却没人用。

**④ 块在 Consume 时两层同时消失**

Consume 从内存与磁盘同时删除文档,而块的字节只存在于那个 .json 里。
生产 L2 有 444 个块,其中 396 个不在 L3(约 248MB)—— 一旦被命中即永久丢失。

## 修法(按设计,不是按我的推断)

设计前提(写进 ARCHITECTURE 与计划文档):
  · 记忆块是唯一物理载体,层是持有关系
  · L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」
  · 正文(含多媒体正文)内联在块里;事件只是块的容器
  · 任何一层都不压缩(视图不是记忆)
  · 内核不得硬编码任何插件行为

1. Doc 增加 Events []DocEvent(与 ContextEvent 同构),成为文档主体
2. 正文各自成块:Input 一块、Response 一块(BlockText,Text 即正文)
3. restoreDocEvents 逐条按原始时间戳迁回 L1,块随事件迁回
4. Content 保留为渲染视图(contentFromEvents 生成),不再是原文载体
5. 蒸馏 docToTriples 改用 documentOriginalText:从块读原文,不再用视图
6. 删掉 ARCHITECTURE 里「过滤 agentcli/terminal 来源」——那是内核硬编码
   插件名,改由插件经 NoMemory/Cleaner 声明

## 顺带修掉的真实缺陷

simpleHash 是 O(n²) 且哈希串长度正比于内容:
    for _, c := range s { h += fmt.Sprintf("%x", c) }
压力测试实测 1.25MB 正文归档耗时 **270 秒**;生产实测 content_hash
是 content 的 2.2 倍(52072 字 → 114491 字)。
换成 sha256 前 16 字节:**270s → 0.52s(520 倍)**,定长 32 hex。

## 存量

按拍板不迁移,等系统自然冲刷。降级路径:无 Events 的旧文档仍按旧行为召回
(整篇渲成一条事件),不伪造成「有原始事件」。生产副本实测 1104 篇旧文档
全部正常加载。

## 判据(16 条,全部有变异自证)

document/blockflow_test.go(11 条):正文完整性、文本块粒度、逐条保留、
视图定位、幂等、存量兼容、原文可用性、hash 线性、3 条压力
core/recalldoc_test.go(5 条):逐条回填、原始时间戳、块迁回、
回填不截断、存量降级、批量压力

变异全部被抓:
  · 落库 truncate(100)        → ArchivePreservesFullText(实测 len=303/原 10215)
  · Input/Response 合成一块    → InputAndResponseAreSeparateTextBlocks(实际 1)
  · 召回用 d.CreatedAt         → RestoresEachEventAtOriginalTimestamp(回填 1/期望 3)
  · 召回不回填 Blocks          → MigratesBlocksBackToContext
  · 召回再截 2000 字           → TextIsNotTruncatedOnTheWayBack(2003/原 18020)
  · 恢复 O(n²) hash            → HashIsLinearAndFixedLength(超时 >300s)

## 验证

- go build ./... 通过
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 生产数据副本实际启动:1104 篇文档加载正常、0 崩溃、干净退出
- 副本 e2e:新归档 2 事件 / 4 文本块、正文 3207 字完整回读、时间戳精确

## 不改

- 公开 SDK 契约(TextEvent / DocMemoryAPI / InsertWithMedia 已等价)
- 存量文档(等自然冲刷)
- 渲染层截断常数(独立议题,避免与本次修复互相掩盖)
This commit is contained in:
JianFeeeee
2026-10-22 11:08:22 +08:00
parent 9faae921ba
commit 47c95efb80
9 changed files with 1761 additions and 92 deletions

View File

@ -24,12 +24,20 @@ CLI 需要终端面板时,**订阅事件总线**(`EventTerminalOutput` + `Ev
自己攒视图,而不是去读内核里的某个状态组件。内核只提供事件总线与插件注册面——
新增一类能力不需要改内核,也不会在核心里堆出第二套业务真相。
**三层记忆架构** — 通过分级存储策略管理 Agent 长周期运行中的信息留存:
- **Context 层**:内存中预训练词嵌入评分的事件窗口(StaticEmbedder 词向量 → CosineSimilarity,TF-IDF 回退),实时维护最近上下文,低相关性事件自动下沉到下一层
- **Document 层**:JSON 文件 + TF-IDF 向量索引的临时记忆,支持显式提交和隐式归档,冷数据蒸馏到 Graph
- **Graph 层**:SQLite 图数据库,持久化实体(entities)和关系(relations),BFS 遍历召回,蒸馏管道从对话中提取三元组
**三层记忆架构** — 三层不是三种数据结构,而是**同一批记忆块的三层持有关系**:
- **Context 层(L1,直接投喂)**:内存中预训练词嵌入评分的事件窗口(StaticEmbedder 词向量 → CosineSimilarity,TF-IDF 回退),实时维护最近上下文,低相关性事件自动下沉到下一层
- **Document 层(L2,不直接投喂)**:与 L1 **结构同构**——同样的事件 + 同样的块,只是不直接进模型视野;支持显式提交和隐式归档,冷数据蒸馏到 Graph
- **Graph 层(L3,持久)**:SQLite 图数据库,**顶层仍是三元组**(节点 + 关系边),但节点从 `entities` 表升级为 `memory_blocks`;BFS 遍历召回,蒸馏管道从对话中提取三元组
三层递进:上下文 → 冷归档 → 长期图记忆,构成从短期到持久的信息衰减与整合管道。
★ **L1 与 L2 唯一区别是「是否被直接投喂给模型」**:L2 不是 L1 的摘要层,也不是
另一种数据形态。召回 = 把事件(连同它持有的块)从“不投喂”的持有层**迁回**“投喂”的持有层。
★ **正文(含多媒体正文)内联在块里**:文本块的 `Text` 是正文,媒体块的 `Data` 是字节。
不存在「块 + 外部正文」的分离形态。**任何一层都不压缩** —— 视图(检索摘要、渲染结果)
可以是压缩的,但视图不是记忆。
三层流动:上下文 → 冷归档 → 长期图记忆,构成从短期到持久的信息衰减与整合管道;
每一次都是**迁移**(块 ID 与向量不变,只换持有层),不是复制。
**媒体记忆(v1.2.0 起:一等记忆块)** — 图片/音频不是附属物,而是记忆的一等节点:
- **内容寻址存储(CAS)**:digest 寻址,元数据在 SQLite、blob 在磁盘(两级分桶),相同字节只存一份,