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

@ -10,7 +10,17 @@ HologramAgent 的认知架构由三个核心子系统构成:事件循环(eve
**阶段管道(StageHost)** 管理两类注册:工具定义(ToolDef)与阶段处理器(StageHandler)。ToolDef 包含 `NoMemory bool` 和 `Cleaner func(string) string` 两个可选的记忆控制字段:`NoMemory=true` 时工具输出不参与向量化/jieba/蒸馏计算(原文保留);`Cleaner` 在输出进入计算层前执行过滤(如提取 JSON 的 `content` 字段)。两者均不修改原文,只影响计算层输入。`RegisterTool` 拒绝同名注册,推断工具所属插件名,并维护工具到插件的映射表 `toolPlugins`。`RegisterStage` 将处理器追加至对应阶段的处理器列表。触发阶段执行时(`RunStage`),**所有已注册处理器通过 goroutine 并行执行**,共享同一 `*StageContext` 实例(通过 `sync.RWMutex` 保护并发访问)。单个处理器的 panic 被独立恢复,不影响其他处理器。短路语义通过检查 `ctx.Response != nil` 实现——任一阶段处理器可设置此值提前终止当前链路。工具执行 `ExecuteTool` 内置 panic 恢复与栈追踪记录。`UnregisterPluginTools` 在插件热重载时移除对应工具集。
**上下文窗口(RelevanceContext)** 维护一个按时间排序的事件列表。`Append` 在录入前经 `textForVector` 聚合工具输出:按 `NoMemory` 跳过、`Cleaner` 过滤(插件为各自工具注册的清洗函数,如 QQ 外置插件剥离工具调用模板),最后经 `CleanText` 做基本空白规范化,再通过三分支向量策略(agent 事件用 Response,用户事件用 Input,cold_storage 用 Input+Response)计算嵌入向量。`Prune` 在事件数超过 `topK` 时触发,**无条件保护最近 10 条事件不被裁剪**(recency bias),对剩余候选事件计算与当前输入的 CosineSimilarity,按评分降序保留 `topK - 10` 条(下限为 0),之后按时间戳重排序。裁剪出的事件中,过滤掉 `agentcli` 和 `terminal` 来源后,其余通过 `docStore.ContextToDoc` 归档至 Document 层,保留原始时间戳。持久化采用 5 秒防抖写入磁盘 JSON 文件。
**上下文窗口(RelevanceContext)** 维护一个按时间排序的事件列表。`Append` 在录入前经 `textForVector` 聚合工具输出:按 `NoMemory` 跳过、`Cleaner` 过滤(插件为各自工具注册的清洗函数),最后经 `CleanText` 做基本空白规范化,再通过三分支向量策略(agent 事件用 Response,用户事件用 Input,cold_storage 用 Input+Response)计算嵌入向量。`Prune` 在事件数超过 `topK` 时触发,**无条件保护最近 10 条事件不被裁剪**(recency bias),对剩余候选事件计算与当前输入的 CosineSimilarity,按评分降序保留 `topK - 10` 条(下限为 0),之后按时间戳重排序。裁剪出的事件通过 `docStore.ContextToDoc` **迁移**至 Document 层:事件连同它持有的块一起下沉,保留原始时间戳与完整正文。持久化采用 5 秒防抖写入磁盘 JSON 文件。
> ★ **哪些事件不该进 L2,由插件自己声明,内核不认插件名**。
>
> 旧版这里写的是「过滤掉 `agentcli` 和 `terminal` 来源后归档」——那是在内核里
> 硬编码插件行为,已废除。正确姿势是插件在注册时就声明:
> - `ToolDef.NoMemory` / `ToolDef.Cleaner`:工具输出不进计算层
> - `ChannelDef.NoMemory` / `ChannelDef.Cleaner`:该通道的输入不进计算层
> - `InjectOptions.NoMemory`:本次注入不参与记忆
>
> 内核只消费这些**声明**(已由 `textForVector` 实现),不认识任何具体插件。
**工具定义聚合自五个来源**:IOManager 注册的插件工具;StageHost 注册的 SDK 插件工具;Indexer 提供的记忆索引工具;内置条件工具(依据 memory / knowledge / docStore / social / pluginReg / providerManager 等模块的非空状态选择性添加,包括记忆操作、知识检索、文档查询、社交网络、插件重载、子代理生成、输出通道工具、LLM 源切换等);以及按 `pendingMedia` 状态添加的媒体处理工具。`buildToolDefs()` 在每次 process 周期中重新聚合所有这些来源。
@ -83,35 +93,64 @@ eventLoop() → processTextInput()
## 三层记忆
### 记忆块是唯一载体,层是持有关系
三层不是三种数据结构,而是**同一批记忆块的三层持有关系**:
```
记忆块 MemoryBlock(union)
├─ 文本块:负载在 Text
└─ 媒体块:负载在 Data(配 MIME/宽高/尺寸)
↑ 同一个块 ID 在 context → doc → graph 全程不变
↑ 块自带 vector + fingerprint:向量只算一次,跨层复用
```
| 层 | 承载单元 | 与模型的关系 | 容量 | 流动性 |
|---|---|---|---|---|
| **L1 Context** | 事件(内联正文 + 块) | **直接投喂**(进 system prompt 时序) | 最近 n 条 / 上限 m 条 | 超限时按相似度迁出 |
| **L2 Document** | 事件(内联正文 + 块) | **不直接投喂**(需召回才进 L1) | 无上限 | 冷到阈值后蒸馏进 L3 |
| **L3 Graph** | 三元组(节点 + 关系边),节点即记忆块 | 不投喂(经召回进入 L1) | 持久 | 不流出 |
★ **L1 与 L2 结构完全同构**,唯一区别是「是否被直接投喂给模型」。
L2 不是 L1 的摘要层,也不是另一种数据形态——它就是同样的事件 + 同样的块,只是不直接进模型视野。
★ **正文(含多媒体正文)内联在块里**:文本块的 `Text` 就是正文,媒体块的 `Data` 就是字节。
不存在「块 + 外部正文」的分离形态,也不存在靠渲染字符串承载原文的路径。
事件是块的容器,不是块的替代品。
★ **任何一层都不压缩**。视图(检索摘要、给模型看的渲染)可以是压缩的,但视图不是记忆。
### 记忆流转
```
① Context (工作窗口)
① Context (工作窗口 — 直接投喂)
RelevanceContext — 内存 events[] + JSON持久化
每条事件:Timestamp / Source / Input / Response(完整) / ToolsUsed / ToolResults / Blocks
Append: 每次输入, CleanText → 三分支向量(textForVector)
agent事件→Response, 用户事件→Input, cold_storage→Input+Response
向量层级:统一多模态空间(主,带 fingerprint)→ StaticEmbedder 词嵌入 → TF-IDF(回退)
Prune: DenseCosine(仅同指纹才比较)→ 退化 StaticEmbedder CosineSimilarity;保留 topK + 最近10条
├── 保留 → timeline → 按时间排序 → system prompt
└── 低分 → Document 层归档 (原始时间戳)
└── 低分 → Document 层归档(迁移:事件连同块一起下沉,时间戳与正文不变)
Save: 5s debounce 写盘
↓ Prune 归档 ↑ LLM 主动召回
↓ Prune 归档(迁移) ↑ 召回(迁回)
② Document (文件记忆)
DocStore — JSON文件 + 稠密向量(统一多模态空间;dense_fp 须与当前空间同指纹,不符即重算;兜底: StaticEmbedder / TF-IDF InvertedIndex)
② Document (文件记忆 — 不直接投喂,结构与 L1 同构)
DocStore — 每篇文档 = 检索元数据 + 事件列表(内联正文 + 块)
写入: Prune归档 / doc_commit / Graph快照(syncGraphToDocs)
读取:
├── 自动注入: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读)
└── LLM主动: doc_query → Consume(读取并删除)
→ 逐条 context.Append{Timestamp: d.CreatedAt, Source: "cold_storage"}
→ 文档以原始时间戳写入 context 时间线, 从 docStore 删除
├── 自动预填充: Query(input, top3) → 同一向量空间下相似度摘要 → 【相关记忆文档】→ system prompt (只读,不动层)
└── LLM主动: doc_query → Consume(取走)
→ 逐条按各自原始时间戳把事件插回 L1 时序
→ 块随事件迁回(同一块 ID,向量不重算),L2 不再持有
冷化: FindColdDocs(72h, ≤2次访问) → docToTriples → Graph
↓ 冷文档蒸馏 ↑ 自动召回
③ Graph (图数据库)
SQLite — entities + relations 表
③ Graph (图数据库 — 顶层仍是三元组,节点即记忆块)
SQLite — memory_blocks(节点) + memory_block_edges(边)
三元组 = 【节点】【关系边】【节点】;putTripleBlocksTx 落为 src --edge_type--> dst
写入: memory_commit / 冷文档蒸馏 / Pipeline 规则蒸馏 / memory_merge
读取:
├── 自动召回: Indexer.BuildContext(input)
@ -174,21 +213,33 @@ eventLoop() → processTextInput()
- 向量化前统一经 `CleanText` 去模版噪声
- 三分支 `textForVector`:agent 事件用 Response、用户事件用 Input、cold_storage 用 Input+Response
- 预训练词嵌入 `StaticEmbedder` → CosineSimilarity,模型不可用时自动回退 TF-IDF
- 保护最近 10 条记录免于淘汰,超出部分按相关性排序归档到文档记忆
- 归档事件以原始时间戳写入文档记忆,后续 `doc_query` 召回时按原始时间戳插回时序
- 保护最近 10 条记录免于淘汰,超出部分按相关性排序**迁移**到文档记忆
- 迁移是事件**连同它持有的块**一起下沉,时间戳与正文不变;源事件清空块(同一块不同时留两层)
### Document 层
`internal/memory/document/document.go` — `Store`
- 消费即删模式:`doc_query` 检索到后删除
- 双路召回:与 Context 共享的 StaticEmbedder 语义向量搜索 + jieba 关键词提取(模型未加载时回退 char-bigram TF-IDF)
**与 Context 同构,只是不直接投喂给模型**:
- 每篇文档 = 检索元数据(`ID`/`Summary`/`Vector`/`DenseVec`/`DenseFP`/`AccessCount`)
+ **事件列表**(内联正文 + 块)
- 正文内联在块里:文本块的 `Text` 是正文,媒体块的 `Data` 是字节
- `Doc.Content` 只是**渲染视图**(供摘要/人工阅读/SDK 兼容),不是原文载体
- 自动预填充:与 Context 共享的向量空间做相似度检索 → 摘要进 system prompt(**只读**,不动层)
- 主动召回:`doc_query` 取走后,**逐条按各自原始时间戳**把事件插回 L1 时序,块随事件迁回
- 冷化:`FindColdDocs(72h, ≤2次访问)` → `docToTriples` → Graph
### Graph 层
`internal/memory/graph.go` — `GraphDB`
- SQLite WAL 模式,两张表(驱动:mattn/go-sqlite3,CGo)
- `Commit(triples)` — UPSERT entities + INSERT relations
- `Recall(keywords, depth)` — 关键词 LIKE 搜索 + BFS 遍历
**顶层结构仍是三元组,变的是节点的物理表示**:
- SQLite WAL 模式(驱动:mattn/go-sqlite3,CGo)
- `memory_blocks`(节点,带 vector + fingerprint + modality)+ `memory_block_edges`(边)
- 三元组 = 【节点】【关系边】【节点】:`putTripleBlocksTx` 落为 `src --edge_type--> dst`
- `entities` / `relations` 旧表是**存量过渡路径**,逐步退场(退场的是节点存储,不是三元组概念)
- `Commit(triples)` — 写入时直接块化为节点 + 边
- `Recall(keywords, depth)` — 关键词检索 + BFS 遍历
### 记忆工具(LLM 可直接调用)

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 在磁盘(两级分桶),相同字节只存一份,

View File

@ -0,0 +1,444 @@
# 三层记忆系统修复计划:块化重构后的失效面
> 状态:**已实施**(2026-10-22)。实施记录见文末 §九。
> 原计划写作于同日,下述前提与失效面均经源码与生产数据核实。
> 触发:块化统一(`docs/zh/memory-restructure-plan.md`)重构了底层数据结构,
> 但上层「层的流动」没有跟着改,导致 L1↔L2 这条链出现结构性失效。
>
> 本文只写**经源码与生产数据核实**的失效点。每条都标了证据位置,
> 不含推测、不含性能断言。
---
## 〇、设计前提(不可动摇,任何修法违反即错)
### 前提 A:记忆块是唯一的物理载体
`docs/zh/memory-restructure-plan.md` §〇 纠错表原话:
> | 分层是 L0 对话/L1 文档/L2 graph/L3 blocks | L0=context / L1=doc / L2=graph。
> **blocks 不是新的一层**,是图节点从文本升级为带向量的多模态节点 |
`MemoryBlock` 是 union:文本块负载在 `Text`,媒体块在 `Data`(配 MIME/宽高/尺寸)。
**同一个块 ID 在 context → doc → graph 全程不变。层是持有关系,不是数据种类的划分。**
### 前提 B:L1 与 L2 结构同构,唯一区别是「是否直接投喂给模型」
这是**层与层的定义**,不是实现细节:
| | L1 Context | L2 Document |
|---|---|---|
| 承载单元 | 事件(内联正文 + 块) | 事件(内联正文 + 块) |
| 结构 | 同构 | 同构 |
| 与模型的关系 | **直接投喂**(进 system prompt 时序) | **不直接投喂**(需经召回才进入 L1) |
| 容量 | 最近 n 条 / 上限 m 条 | 无上限 |
| 流动性 | 超限时按相似度迁出 | 冷到阈值后蒸馏进 L3 |
⇒ L2 **不是**「L1 的摘要层」,也**不是**「另一种数据形态」。
它就是**同样的事件 + 同样的块**,只是不直接进模型视野。
召回 = 把块(连同它所属事件)从"不投喂"的持有层**迁回**"投喂"的持有层。
### 前提 C:正文(含多媒体正文)内联在块里
文本块的 `Text` 就是正文;媒体块的 `Data` 就是字节。
**不存在"块 + 外部正文"的分离形态**,也不存在靠渲染字符串承载原文的路径。
事件是块的容器,不是块的替代品。
### 前提 D:任何一层都不压缩
原文必须完整保留。视图(检索摘要、给模型看的渲染)可以是压缩的,
但**视图不是记忆**。把视图当记忆存,等于在存储端就丢了原文。
### 前提 E:Graph 顶层仍是三元组,块只是它的节点实现
`internal/memory/sentence_block.go` — `putTripleBlocksTx`:
```go
src := MemoryBlock{ID: TripleBlockID(t.Subject), Text: t.Subject, SemanticType: t.SubjectType}
dst := MemoryBlock{ID: TripleBlockID(t.Object), Text: t.Object, SemanticType: t.ObjectType}
// 边:src --edge_type=Relation--> dst,落在 memory_block_edges
```
三元组 =【节点】【关系边】【节点】,这个**顶层结构没有变**。
变的是节点的物理表示:`entities` 表 → `memory_blocks` 表。
`entities` 退场不等于三元组退场。
### 前提 F:内核不得硬编码任何插件行为
这是**铁律**。任何依赖具体插件名(`agentcli` / `terminal` / `qq` …)的内核分支都是错的。
插件的行为差异一律由插件自己**声明**:`ToolDef.NoMemory`、`ToolDef.Cleaner`、
`ChannelDef.NoMemory`、`ChannelDef.Cleaner`、`InjectOptions`。
### 前提 G:SDK 已通过一套等价接口实现同等能力
公开 SDK(`third_party/homeagent-sdk/sdk/memory.go`)已提供与内核同构的表达:
| SDK 接口 | 对应的内核概念 |
|---|---|
| `TextEvent{Role, Content, Timestamp, Channel, Attachments}` | 事件(正文内联 + 附件) |
| `DocMemoryAPI.Insert(doc)` | 写入 L2(纯文本) |
| `DocMemoryAPI.InsertWithMedia(doc, attachments)` | 写入 L2(正文 + 媒体块) |
| `DocMemoryAPI.Query(text, topK)` | 只读召回(不删除) |
| `MediaAttachment{Digest, MIME, Data}` | 块引用:带 `Data` 为新内容,只带 `Digest` 为引用已有 |
| `TextMemoryAPI.Append(TextEvent)` | 时间序文本日志 |
⇒ **SDK 契约不需要改动**。修的是内核实现没跟上契约语义,不是契约本身。
---
## 一、失效面清单(经核实)
### 失效 1:L2 只存了事件的渲染视图,事件本体被丢弃
**位置**:`internal/memory/document/document.go` — `ContextToDoc`
`Prune` 交给 L2 的是结构完整的 `[]ContextEntry`(Timestamp/Source/Content/
Response/ToolsUsed/ToolResults/Blocks),但落库时被拍平成一个字符串:
```go
for _, e := range entries {
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
line += fmt.Sprintf(" → %s", truncate(e.Response, 100)) // 正文 → 视图
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200))
}
content := strings.Join(parts, "\n")
```
只有 `Doc.Content` 被保存。**后果**:事件的时间戳只剩 `HH:MM`,
`Response` 只剩 100 rune,工具输出只剩 200 rune,`ToolsUsed` 完全不存。
这直接违反前提 B(L1/L2 同构)与前提 C(正文内联在块里)——
L1 里是完整事件,L2 里却退化成一段文本。
**生产证据**(`/home/newqqagent/memory/documents/`,1000 篇归档文档):
| 度量 | 实测值 |
|---|---|
| response 段恰好 103 字符(=100+`...`) | 311 / 2094 段 = **15.1%** |
| 工具输出段恰好 203 字符(=200+`...`) | **7593 / 13261 段** |
⇒ 原文**在落库那一刻就不存在了**。这不是"召回时没取全",是存储端丢失。
### 失效 2:L2 里没有文本块,L1/L2 不同构
**位置**:`internal/memory/document/document.go` — `blocksFromEntries`
块的 `modality` 实测分布(同一批生产文档):
```
L2 块总数 444:image 437 / audio 7 / text 0
```
**后果**:L2 里"块"只承载媒体,文本内容全靠 `Doc.Content` 这个视图。
违反前提 B/C——L1 的事件正文 + 块,到 L2 变成了"字符串 + 仅媒体块"。
(对照:L3 的文本是有块的,`putTripleBlocksTx` 的主语块/宾语块都是 `BlockText`。)
### 失效 3:召回时新建合成事件,而非把块按原时序迁回
**位置**:`internal/agent/core/toolcall.go` — `executeDocTool` 的 `doc_query` 分支
```go
docs := a.docStore.Consume(query, topK) // 读 + 从 L2 删除
...
a.context.InsertByTimestamp(ContextEvent{
Timestamp: d.CreatedAt, // ← 归档时刻,非事件时刻
Source: "cold_storage", // ← 新造来源
Input: fmt.Sprintf("加载文档记忆: %s", query),
Response: content, // ← 渲染视图,且再截 2000
})
// ← 完全没有把 d.Blocks 挂回去
```
三处偏离设计(`ARCHITECTURE.md` L107-108、L178):
> doc_query → Consume(读取并删除)
> → **逐条** context.Append{Timestamp: **原始时间戳**, Source: 原来源}
> → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
**生产证据**(块自带 `created_at`,与归档时刻差近两小时):
```
媒体块 created_at = 2026-10-12 11:09:51 ← 事件发生时刻(captureBlockMedia 里 time.Now())
doc.created_at = 2026-10-12 13:04:54 ← 归档时刻
```
443/443 个媒体块都带有效 `created_at`。**块自带它该回到哪个时刻的坐标,现在没人用。**
### 失效 4:块在 Consume 时两层同时消失
**位置**:`internal/memory/document/document.go` — `Consume` → `removeDoc`
```go
func (s *Store) removeDoc(id string) {
delete(s.docs, id)
...
os.Remove(filepath.Join(s.dir, id+".json")) // 块的字节只在这个文件里
}
```
`doc_query` 调用 `Consume` 后,文档(含 `d.Blocks`)从内存与磁盘同时删除;
而 `toolcall.go` 不回填 `Blocks`。**后果**:这些块既不在 L2(已删)也不在 L1(没挂),
只剩局部变量引用,函数返回后即不可达。
**生产风险敞口**:L2 现有 444 个块,其中 **396 个不在 L3**(`graph.db` 里查不到),
原始字节约 **248 MB**。一旦被 `doc_query` 命中即永久丢失。
### 失效 5:时序渲染用固定 120 rune 截断正文
**位置**:`internal/agent/core/process.go` — `formatMergedTimeline`
```go
sb.WriteString(fmt.Sprintf(" → %s", truncateStr(e.Response, 120)))
```
上下文预算可以是几百 K token(`ComputeTokenBudget`),但每条正文**永远只显示前 120 字**。
原文即使用前提 D 完整保存在库里,模型也看不到——这是"不压缩"在**渲染环节**的漏口。
**生产证据**(`context.json`):2 条 `cold_storage` 事件的 response 分别 1541 / 1209 字符,
渲染出来都是 123 字符。
### 失效 6:`Prune` 的归档过滤要求本身违反前提 F
**位置**:`ARCHITECTURE.md` L13 写「过滤掉 `agentcli` 和 `terminal` 来源后归档」。
`agentcli` / `terminal` 是**插件名**。把它们写进内核归档规则 = 硬编码插件行为,违反前提 F。
**当前代码**:`Prune` 里没有任何源名过滤,该要求**未实现**;
生产库里有 325 行 `agentcli` 归档(全部是终端状态行)。
⇒ 这不是"补上过滤",而是**该要求应作废,改由插件声明驱动**(前提 F)。
---
## 二、修复顺序(按依赖)
失效 1/2 是存储形态,3/4 是召回形态,5 是渲染,6 是文档纠错。
**必须先修存储**——存储里没有事件本体,召回就没有东西可放回。
| # | 动作 | 依赖 | 涉及文件 |
|---|---|---|---|
| ① | L2 存储形态改为「事件 + 块」(与 L1 同构) | — | `memory/document/document.go` |
| ② | 文本正文以块承载(消灭 `Content` 作为唯一文本载体) | ① | 同上 + `agent/core/context.go` |
| ③ | 召回按块/事件原始时序迁回 L1 | ①② | `agent/core/toolcall.go` |
| ④ | 时序渲染改为按预算分配 | 独立 | `agent/core/process.go` |
| ⑤ | 作废失效 6 的文档要求(改由 `NoMemory` 声明驱动) | 独立 | `ARCHITECTURE.md` |
### ① 的具体形状
`Doc` 与 `RelevanceContext` 的事件列表**同构**:
```
L1: events []ContextEvent // Timestamp/Source/Input/Response/ToolsUsed/ToolResults/Blocks
L2: events []ContextEvent // 同一结构,同一字段
```
- `Doc` 保留 `ID` / `Summary` / `Vector` / `DenseVec` / `DenseFP` / `AccessCount` 等
**检索元数据**(这些是 L2 作为"可检索容器"的附加属性,L1 不需要)
- 事件列表**内联正文**(`Response` 完整,不截断)+ 持有的块
- `Doc.Content` 降级为**纯渲染视图**(供摘要/人看/兼容 SDK 的 `Doc.Content`),
不再作为原文载体;原文从事件读
> ⚠️ 注意与「已否决做法」的区别:不是新造一个异构字段把 `ContextEvent` 旁挂上去,
> 而是让 **L2 的承载结构就是 L1 的那一套**。事件列表是文档的主体,不是附件。
### ② 的具体形状
- 归档时,每条事件的正文(`Input` / `Response`)生成对应文本块(`Modality=BlockText`,
`Text` 即正文),块 `CreatedAt` = 事件时间戳
- 媒体块照旧(`Data` 内联字节)
- 事件 `Blocks` 里同时含文本块与媒体块 → L1/L2/L3 三处同构
### ③ 的具体形状
```
doc_query → 取出 L2 事件列表(迁移语义:取出后 L2 不再持有)
→ 逐条按各自 Timestamp 插回 L1 时序
→ 事件持有的块随之迁回(同一块 ID,向量不重算)
```
---
## 三、判据(先写判据再改,避免自证)
> 教训(本次已发生):先改代码再写测试,测试就只会证明"我改的是我以为的"。
> 判据必须**从设计前提推导**,且必须能在改动前**红**。
### ① 存储形态(L1/L2 同构)
- [ ] 归档后能从 L2 取回**逐条**事件的原始 `Timestamp`(完整时刻,非 `HH:MM`)、
`Source`、`Response`(完整,非截断)、`ToolsUsed`、`ToolResults`
- [ ] 变异:把 `Response` 改回 `truncate(..,100)` ⇒ 红
- [ ] 变异:丢掉 `ToolsUsed` ⇒ 红
- [ ] 变异:时间戳改回 `Format("15:04")` ⇒ 红
### ② 文本块
- [ ] 归档文档的文本块 `Text` 与事件正文逐字一致
- [ ] 文本块 `CreatedAt` == 事件 `Timestamp`
- [ ] 变异:不生成文本块(只存 `Doc.Content`)⇒ 红
### ③ 召回
- [ ] `doc_query` 后事件按**各自原始时间戳**回到 L1
- [ ] 块 `ID`/`Vector`/`CreatedAt` 全程不变(迁移而非重建)
- [ ] 块的持有层从 L2 变为 L1,**不同时留在两层**
- [ ] 变异:用 `d.CreatedAt` 当时间戳 ⇒ 红
- [ ] 变异:不回填 `Blocks` ⇒ 红
### ④ 渲染
- [ ] 预算宽裕时正文完整可见(含结尾哨兵)
- [ ] 预算紧张时仍保留开头(不能整条消失)
- [ ] 单条超长正文不得把其余事件整段挤掉
### ⑤ 文档纠错
- [ ] `ARCHITECTURE.md` 不再出现任何插件名作为内核归档规则
- [ ] 判据:`grep -rn '"agentcli"\|"terminal"' internal/agent/core internal/memory`(非测试)为空
---
## 四、禁止事项(此前已犯,写下来防复发)
| 禁止 | 原因 |
|---|---|
| 给 `Doc` 加异构字段旁挂事件快照(如 `Entries []ContextEntry`) | L2 主体就是事件列表本身,不是"Doc + 附件" |
| 改 `truncateStr` 的常数当作"修复压缩" | 视图截断不是记忆压缩问题;根因在存储形态 |
| 任何插件名字面量进内核分支 | 违反前提 F |
| 编造不存在的 API 再补注释解释它"应该"怎样 | 实现必须以真实存在的设施为准 |
| 先按自己的理解写测试再改代码 | 会把误解固化成判据 |
| 改公开 SDK 契约 | 前提 G:SDK 已等价,修内核实现 |
---
## 五、已否决的做法
### 否决 1:新增 `Doc.Entries` 存事件快照(2026-10-22,已实现又撤销)
错在**异构旁挂**:`Doc` 仍是字符串主体,事件列表被当成附件挂上去。
正确形态是 §二①:事件列表**成为 L2 的主体**,与 L1 同构。
### 否决 2:把渲染层截断常数调大(2026-10-22,已撤销)
根因在存储形态,不在渲染常数。§二④ 单独处理,且应是**按预算分配**而非改常数。
---
## 六、已拍板的决定
### 1. 事件本体在 L2 的形态(2026-10-22 拍板)
**L2 就是 doc 存储,本身与 Context 同构,区别只在于是否被直接投喂给模型。**
⇒ 不是“复用 `ContextEvent` 当旁挂字段”,也不是“把事件也变成块”这种额外抽象。
**事件列表就是 L2 文档的主体**,与 L1 事件列表同一结构。
### 2. 正文形态(2026-10-22 拍板)
**正文(包括多媒体正文)本身就内联在块里。**
⇒ 文本块的 `Text` 是正文,媒体块的 `Data` 是字节。
不存在“块 + 外部正文”的分离形态,也不靠渲染字符串承载原文。
### 3. SDK(2026-10-22 拍板)
**记忆部分 SDK 已通过一套接口实现等价功能** ⇒ **不改公开 SDK 契约**。
对照表(`third_party/homeagent-sdk/sdk/memory.go`):
| SDK 接口 | 等价的内核概念 |
|---|---|
| `TextEvent{Role, Content, Timestamp, Channel, Attachments}` | 事件(正文内联 + 附件) |
| `DocMemoryAPI.Insert(doc)` | 写入 L2(纯文本) |
| `DocMemoryAPI.InsertWithMedia(doc, attachments)` | 写入 L2(正文 + 媒体块) |
| `DocMemoryAPI.Query(text, topK)` | 只读召回(不删除) |
| `MediaAttachment{Digest, MIME, Data}` | 块引用(带 Data 为新内容,仅 Digest 为引用) |
⇒ 修的是内核实现没跟上契约语义,不是契约本身。
### 4. 存量数据(2026-10-22 拍板)
**等待系统自然冲刷**,不做迁移脚本。
约束:
- 新代码必须能**读旧格式**(缺事件列表/缺文本块时降级),不得 panic、不得静默丢内容
- 降级路径要有明确日志,便于判断存量退出进度
- 旧格式文档被召回时按旧行为处理,**不伪造成“有原始事件”**
---
## 七、待确认的细节(实现层面,不阻塞开工)
1. **文本块与事件是一对一还是一对多?**
一条事件生成一个文本块(`Input` + `Response` 合在一个 `Text` 里),还是各一块(便于分别检索)?
2. **`Doc.Content` 保留为渲染视图,还是彻底删除?**
若保留,与事件列表信息重复;若删除,SDK 的 `Doc.Content` 则由事件列表现场渲染填充。
3. **L2 事件的检索向量怎么算?**
现在 `denseFor` 用 `Summary + Content`。改为事件列表后,
是用 `Summary + 各事件正文拼接`,还是用文本块自己的向量做融合(与媒体块一致)?
---
## 八、验收口径
- `go build ./...` 通过
- 全量 `go test ./... -count=1` 0 失败(当前基线:58 包通过 / 0 失败)
- §三 每条判据有对应测试,且**变异测试**各自能被抓住
- 生产侧(可在副本库验证):`doc_query` 召回后块不消失;新旧两种格式文档都能正确召回
---
## 九、实施记录(2026-10-22)
### 已落地
| # | 动作 | 落地位置 |
|---|---|---|
| ① | L2 存储形态改为「事件 + 块」 | `document.Doc.Events []DocEvent`;`ContextToDoc` 落盘事件列表 |
| ② | 正文各自成文本块 | `docBlocksForEntry` / `entriesToEvents`;Input、Response 各一块(`BlockText`) |
| ③ | 召回按原始时序迁回 | `Agent.restoreDocEvents`;`DocEvent.InOut()` 从块读回正文 |
| ④ | 蒸馏改用原文 | `documentOriginalText`(有 Events 用块内原文,存量退回 Content) |
| ⑤ | 文档纠错 | `ARCHITECTURE.md` 删除「过滤 agentcli/terminal」,改为「由插件声明驱动」 |
### Content 的定位(按拍板)
`Doc.Content` **保留为渲染视图**,由 `contentFromEvents` 生成:
- 供摘要、人工阅读、SDK 的 `Doc.Content` 字段使用
- **不是**原文载体 —— 原文在 `Events[i].Blocks` 里文本块的 `Text`
- 视图也不主动截断(存储层不替渲染层做决定)
### 存量
按拍板**不做迁移**,等系统自然冲刷。降级路径:
- `Doc.HasEvents() == false` ⇒ 召回时整篇渲成一条 `cold_storage` 事件(旧行为)
- 不伪造成「有原始事件」;有日志可观测
### 判据与变异
新增两组判据(各条都在改动前设计、改动后可跑):
| 文件 | 判据数 | 覆盖 |
|---|---|---|
| `internal/memory/document/blockflow_test.go` | 7 | 存储形态、文本块粒度、逐条保留、视图定位、幂等、存量兼容、原文可用性 |
| `internal/agent/core/recalldoc_test.go` | 5 | 逐条回填、原始时间戳、块迁回、回填不截断、存量降级 |
**变异测试(全部被抓住)**:
| 变异 | 被哪条抓住 |
|---|---|
| 正文落库时 truncate(100) | `TestL2_ArchivePreservesFullText` / `TestL2_ContentIsRenderedViewNotSource` |
| Input/Response 合成一块 | `TestL2_InputAndResponseAreSeparateTextBlocks`(及 3 条连带) |
| 召回用 `d.CreatedAt` 当时间戳 | `TestRecallByDoc_RestoresEachEventAtOriginalTimestamp` |
| 召回不回填 Blocks | `TestRecallByDoc_MigratesBlocksBackToContext` |
| 召回再截 2000 字 | `TestRecallByDoc_TextIsNotTruncatedOnTheWayBack` |
### 未做(明确不做)
- **不改公开 SDK 契约**:`TextEvent` / `DocMemoryAPI` / `InsertWithMedia` 已等价
- **不迁移存量文档**:等自然冲刷
- **不改渲染层截断常数**(失效 5):根因已在存储形态修掉;渲染按预算分配是独立议题,
避免与本次修复混在一起、互相掩盖

View File

@ -558,12 +558,20 @@ func docToTriples(doc *document.Doc, embedder nlp.Vectorizer) []memory.Triple {
// 写入 L3 并以 document --contains--> block 边关联,
// 不经过文本描述与 NLP 提取器。
// NLP 通用提取
// NLP 通用提取。
//
// ★ 输入必须是**原文**,不能是视图。
//
// 旧代码抽的是 doc.Content —— 那在 Events 引入前是对事件的压缩渲染
// (Response 截 100 rune、工具输出截 200)。拿压缩过的文本做三元组抽取,
// 等于在丢失的信息上再丢一次:句子被截断后依存句法往往解析不出主谓宾。
//
// 现在优先用事件里的完整正文;存量文档没有 Events 才退回 Content。
e := nlp.NewExtractor(nil)
if embedder != nil {
e.SetEmbedder(embedder)
}
result := e.Extract(doc.Content)
result := e.Extract(documentOriginalText(doc))
if result != nil {
for _, nt := range result.Triples {
mt := nlp.ToMemoryTriple(nt)
@ -635,3 +643,33 @@ func (a *Agent) processConsolidation(evt *agentIO.InputEvent, input string) {
log.Printf("[agent] consolidation done (%dms, tools=%v)", time.Since(start).Milliseconds(), toolsUsed)
}
// documentOriginalText 取文档的**原文**用于抽取/蒸馏。
//
// 有 Events 时按事件顺序拼正文(原文来自文本块);存量文档退回 Content 视图。
// 这是「视图不是记忆」在蒸馏侧的落地:抽取必须基于原文。
func documentOriginalText(doc *document.Doc) string {
if doc == nil {
return ""
}
if !doc.HasEvents() {
return doc.Content
}
var b strings.Builder
for _, ev := range doc.Events {
in, resp, _ := ev.InOut()
if in != "" {
b.WriteString(in)
b.WriteString("\n")
}
if resp != "" {
b.WriteString(resp)
b.WriteString("\n")
}
for _, tr := range ev.ToolResults {
b.WriteString(tr.Output)
b.WriteString("\n")
}
}
return b.String()
}

View File

@ -301,33 +301,42 @@ func TestPruneMigratesBlocksToDocument(t *testing.T) {
}
// 块应已到达 L2,且身份不变。
//
// ★ 2026-10-22 更新:L2 与 L1 同构后,事件正文也各自成为文本块
// (Input 一块、Response 一块),所以这里不能再假设「文档只有 1 个块」。
// 要断言的是**媒体块原样迁移**,而不是块总数。
var found *document.Doc
for _, d := range docStore.RecentDocs(20) {
if len(d.Blocks) > 0 {
found = d
break
for _, b := range d.Blocks {
if b.ID == block.ID {
found = d
}
}
}
if found == nil {
t.Fatal("归档文档应持有一等记忆块")
t.Fatal("媒体块未随归档事件迁移到 L2")
}
if len(found.Blocks) != 1 {
t.Fatalf("文档应有 1 个块,实际 %d", len(found.Blocks))
var got *memory.MemoryBlock
for i := range found.Blocks {
if found.Blocks[i].ID == block.ID {
got = &found.Blocks[i]
}
}
got := found.Blocks[0]
if got.ID != block.ID || got.Modality != block.Modality || got.MIME != block.MIME ||
got.Fingerprint != block.Fingerprint || len(got.Vector) != len(block.Vector) {
t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", got, block)
t.Fatalf("块身份应原样迁移:\n got %+v\n want %+v", *got, block)
}
// 字节随块迁移(union)
if string(got.Data) != string(block.Data) {
t.Fatalf("块字节应原样迁移: %q vs %q", got.Data, block.Data)
}
// 同一块不能同时留在 L0。
// 同一块不能同时留在 L0(L1)。
for _, e := range rc.Recent(100) {
if len(e.Blocks) > 0 {
t.Fatalf("块仍留在 L0(同一块同时存在于两层): %+v", e.Blocks)
for _, b := range e.Blocks {
if b.ID == block.ID {
t.Fatalf("块仍留在 L1(同一块同时存在于两层): %+v", b)
}
}
}
}

View File

@ -0,0 +1,286 @@
package core
// L2 → L1 召回协议的判据。
//
// 依据(assets/docs/zh/ARCHITECTURE.md「记忆流转」② Document 层的读取分支):
//
// doc_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
//
// 三条不变量:
// ① 逐条:N 条事件回到时间线,不是 1 条合成事件
// ② 原始时间戳:用事件时刻,不是文档归档时刻
// ③ 块随事件迁回:Consume 已删 L2,不回填就两层都不在
import (
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
)
// archiveEvents 走生产路径(Prune)把一批事件归档进 L2。
//
// Prune 无条件保护最近 10 条事件,只在更早的部分里挑归档对象,
// 所以要填充足够多的无关事件把目标挤出保护窗口。
func archiveEvents(t *testing.T, a *Agent, target []ContextEvent) {
t.Helper()
for _, ev := range target {
a.context.Append(ev)
}
for i := 0; i < 14; i++ {
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "filler",
Input: "无关填充对话,用来把目标事件挤出保护窗口",
Response: "好的。",
})
}
if n := a.context.Prune("当前输入", 2, a.docStore); n == 0 {
t.Fatal("Prune 未归档任何事件")
}
}
func countSource(c *RelevanceContext, src string) int {
n := 0
for _, e := range c.Recent(0) {
if e.Source == src {
n++
}
}
return n
}
// ① + ② 逐条回填,且每条用自己的原始时间戳。
//
// 变异:改用 d.CreatedAt 当时间戳 ⇒ 红;改回单条合成事件 ⇒ 红。
func TestRecallByDoc_RestoresEachEventAtOriginalTimestamp(t *testing.T) {
a := newToolTestAgent(t)
base := time.Date(2026, 3, 4, 10, 0, 0, 0, time.UTC)
var want []ContextEvent
for i := 0; i < 3; i++ {
want = append(want, ContextEvent{
Timestamp: base.Add(time.Duration(i) * 7 * time.Minute),
Source: "archive-src",
Input: "档案事件-" + string(rune('A'+i)) + " 关于紫蓝红配色方案的讨论",
Response: "该事件的完整回答,编号 " + string(rune('A'+i)),
})
}
archiveEvents(t, a, want)
if n := countSource(a.context, "archive-src"); n != 0 {
t.Fatalf("归档后 L1 仍有 %d 条 archive-src", n)
}
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "紫蓝红配色方案", "top_k": float64(5)},
})
if strings.Contains(out, "未找到") {
t.Fatalf("doc_query 未召回:%s", out)
}
seen := map[time.Time]string{}
for _, e := range a.context.Recent(0) {
if e.Source == "archive-src" {
seen[e.Timestamp] = e.Input
}
}
if len(seen) != 3 {
t.Fatalf("回填了 %d 条 archive-src 事件,期望 3"+
"(逐条回填,不是合成一条;也不是换了个时间戳)", len(seen))
}
for i := 0; i < 3; i++ {
ts := base.Add(time.Duration(i) * 7 * time.Minute)
if _, ok := seen[ts]; !ok {
t.Errorf("时间戳 %s 缺失 —— 回填必须用事件原始时刻(不是归档时刻)",
ts.Format("15:04:05"))
}
}
// 不得出现合成事件。
for _, e := range a.context.Recent(0) {
if e.Source == "cold_storage" {
t.Errorf("出现 cold_storage 合成事件(%q)—— 有 Events 时应逐条回填", e.Input)
}
}
}
// ③ 块随事件迁回 L1(迁移,不是复制也不是丢弃)。
//
// 变异:不回填 Blocks ⇒ 红(块两层都消失 = 永久丢字节)。
func TestRecallByDoc_MigratesBlocksBackToContext(t *testing.T) {
a := newToolTestAgent(t)
payload := []byte("recall-block-bytes")
b := mediaBlock("image/png", payload)
if err := a.memory.PutMemoryBlocks([]memory.MemoryBlock{b}); err != nil {
t.Fatal(err)
}
archiveEvents(t, a, []ContextEvent{{
Timestamp: time.Now().Add(-3 * time.Hour),
Source: "archive-src",
Input: "这张图请记住:紫蓝红三色带的配色参考",
Response: "记住了。",
Blocks: []memory.MemoryBlock{b},
}})
if blocksInCtx(a.context, b.ID) {
t.Fatal("归档后块仍在 L1")
}
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "紫蓝红三色带 配色参考", "top_k": float64(3)},
})
var back *memory.MemoryBlock
for _, e := range a.context.Recent(0) {
for i := range e.Blocks {
if e.Blocks[i].ID == b.ID {
back = &e.Blocks[i]
}
}
}
if back == nil {
t.Fatal("召回后块没有迁回 L1 —— Consume 已删除 L2 文档,块会两层都不在(字节永久丢失)")
}
if string(back.Data) != string(payload) {
t.Errorf("迁回的块字节被改动: %q", string(back.Data))
}
// 迁移而非复制。
for _, d := range a.docStore.RecentDocs(50) {
for _, db := range d.Blocks {
if db.ID == b.ID {
t.Error("块同时留在 L2 与 L1 —— 这是复制,违反单层不变量")
}
}
}
}
// ④ 回填的正文必须是原文,不能再被截断。
//
// 断点史:旧实现在召回时对 Content 再截 2000 字,且 Content 本身已是截断视图。
func TestRecallByDoc_TextIsNotTruncatedOnTheWayBack(t *testing.T) {
a := newToolTestAgent(t)
longResp := strings.Repeat("长正文", 2000) + "【召回哨兵R4】"
archiveEvents(t, a, []ContextEvent{{
Timestamp: time.Now().Add(-2 * time.Hour),
Source: "archive-src",
Input: "请记住这份长报告",
Response: longResp,
}})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "长报告", "top_k": float64(3)},
})
var got string
for _, e := range a.context.Recent(0) {
if e.Source == "archive-src" {
got = e.Response
}
}
if got == "" {
t.Fatal("未回填事件")
}
if got != longResp {
t.Errorf("回填正文被截断:len=%d,原长 %d", len(got), len(longResp))
}
if !strings.Contains(got, "【召回哨兵R4】") {
t.Error("回填正文丢了结尾哨兵")
}
}
// ⑤ 存量文档(无 Events)降级:整篇渲成一条事件,不丢弃。
//
// 决定:存量等自然冲刷,但不得因为格式旧就召回不到。
func TestRecallByDoc_LegacyDocDegrades(t *testing.T) {
a := newToolTestAgent(t)
if err := a.docStore.Insert(&document.Doc{
Summary: "紫蓝红三色带的旧格式文档",
Content: "[10:00] legacy: 这是 Events 引入之前归档的正文视图",
Source: "context_archived",
Meta: map[string]string{"content_hash": "legacy-hash-1"},
}); err != nil {
t.Fatal(err)
}
out := a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "紫蓝红三色带 旧格式", "top_k": float64(3)},
})
if strings.Contains(out, "未找到") {
t.Fatalf("存量文档召回失败:%s", out)
}
var found bool
for _, e := range a.context.Recent(0) {
if e.Source == "cold_storage" && strings.Contains(e.Response, "Events 引入之前") {
found = true
}
}
if !found {
t.Error("存量文档没有降级成合成事件 —— 会静默丢失整篇内容")
}
}
func blocksInCtx(c *RelevanceContext, blockID string) bool {
for _, e := range c.Recent(0) {
for _, b := range e.Blocks {
if b.ID == blockID {
return true
}
}
}
return false
}
// 压力:大批量事件回填不得退化(回填是逐条 InsertByTimestamp,本身 O(n) 插入)。
func TestRecallByDoc_StressManyEvents(t *testing.T) {
if testing.Short() {
t.Skip("压力测试")
}
a := newToolTestAgent(t)
const n = 120
base := time.Date(2026, 2, 3, 4, 0, 0, 0, time.UTC)
var evs []ContextEvent
for i := 0; i < n; i++ {
evs = append(evs, ContextEvent{
Timestamp: base.Add(time.Duration(i) * time.Second),
Source: "stress",
Input: "压力输入 " + strings.Repeat("内容", 10),
Response: "压力回答 " + strings.Repeat("正文", 20),
})
}
archiveEvents(t, a, evs)
start := time.Now()
if got := countSource(a.context, "stress"); got != 0 {
t.Fatalf("归档后仍有 %d 条", got)
}
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "压力输入 内容", "top_k": float64(5)},
})
elapsed := time.Since(start)
got := countSource(a.context, "stress")
if got == 0 {
t.Fatal("压力场景下一条也没回填")
}
// 回填 120 条不应超过 5 秒(实测远小于此;阈值防 O(n²) 退化)。
if elapsed > 5*time.Second {
t.Errorf("回填 %d 条耗时 %v,疑似超线性", got, elapsed)
}
t.Logf("回填 %d 条耗时 %v", got, elapsed)
}

View File

@ -1037,24 +1037,33 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
if len(d.Tags) > 0 {
parts = append(parts, " 标签: "+strings.Join(d.Tags, ", "))
}
content := d.Content
if len(content) > 2000 {
content = content[:2000] + "..."
// ★★ 召回 = 把事件(连同它持有的块)迁回 L1,不是造一条新事件
//
// 设计原文(ARCHITECTURE「记忆流转」② Document 层):
//
// doc_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
//
// 此前实现用 d.CreatedAt(归档时刻)造一条 cold_storage 事件、
// 正文取 d.Content(渲染视图)再截 2000 字、块完全没回填 ——
// 三处都偏离:时间错位、正文二次截断、块两层消失。
if n := a.restoreDocEvents(d); n == 0 {
// 存量文档(无 Events)降级:整篇渲成一个事件,保持既有行为。
// 不伪造成「有原始事件」——那时归档确实只有渲染视图。
content := d.Content
if labels := a.blockLabelsForDoc(d); labels != "" {
content = content + "\n关联媒体: " + labels
}
a.context.InsertByTimestamp(ContextEvent{
Timestamp: d.CreatedAt,
Source: "cold_storage",
Input: fmt.Sprintf("加载文档记忆: %s", query),
Response: content,
})
}
// 媒体块标签单独一行进冷存事件:正文可能被上面的 2000 字截断,
// 截掉之后模型就不知道这篇文档带过图。
if labels := a.blockLabelsForDoc(d); labels != "" {
content = content + "\n关联媒体: " + labels
}
a.context.InsertByTimestamp(ContextEvent{
Timestamp: d.CreatedAt,
Source: "cold_storage",
Input: fmt.Sprintf("加载文档记忆: %s", query),
Response: content,
})
refs = append(refs, fmt.Sprintf("#%d(%s)", i+1, d.Summary))
}
return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(完整内容参见对话时序中 cold_storage 事件)",
return fmt.Sprintf("已加载 %d 篇文档记忆: %s\n(正文已按原时间戳回填到对话时序,直接读时序即可)",
len(docs), strings.Join(refs, ", "))
case "doc_commit":
@ -1262,3 +1271,56 @@ func (a *Agent) currentFingerprint() string {
}
return a.multimodalSpace.Fingerprint()
}
// restoreDocEvents 把一篇 L2 文档的原始事件**逐条按原时间戳迁回 L1**。
//
// 返回回填的事件条数;0 表示这篇文档没有可回填的原始事件(存量文档)。
//
// ## 依据
//
// ARCHITECTURE「记忆流转」② Document 层的读取分支:
//
// doc_query → Consume(读取并删除)
// → 逐条 context.Append{Timestamp: 原始时间戳, Source: 原来源}
// → 文档以原始时间戳写入 context 时间线, 从 docStore 删除
//
// L1 与 L2 结构同构,召回是**迁移**:事件连同它持有的块从 L2 换到 L1,
// 块 ID 与向量全程不变(不重建、不重算)。
//
// ## 块为什么要一起回填
//
// Consume 已把文档从 L2 删掉,而块的字节只存在于那篇文档里。
// 不回填块 = 块两层都不在 = 永久丢内容(生产实测这一敞口约 248MB)。
func (a *Agent) restoreDocEvents(d *document.Doc) int {
if a == nil || a.context == nil || d == nil || !d.HasEvents() {
return 0
}
n, blocks := 0, 0
for _, ev := range d.Events {
input, response, held := ev.InOut()
a.context.InsertByTimestamp(ContextEvent{
Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置
Source: ev.Source,
Input: input,
Response: response,
ToolsUsed: append([]string(nil), ev.ToolsUsed...),
ToolResults: func() []ToolResultItem {
var out []ToolResultItem
for _, tr := range ev.ToolResults {
out = append(out, ToolResultItem{Name: tr.Name, Output: tr.Output})
}
return out
}(),
Blocks: append([]memory.MemoryBlock(nil), held...),
})
n++
blocks += len(held)
}
log.Printf("[agent] doc→context: %s 逐条回填 %d 条原始事件(%s → %s),块 %d 个迁回 L1",
d.ID, n,
d.Events[0].Timestamp.Format("01-02 15:04:05"),
d.Events[len(d.Events)-1].Timestamp.Format("01-02 15:04:05"), blocks)
return n
}

View File

@ -0,0 +1,523 @@
package document
// L1 ↔ L2 同构协议的判据。
//
// 依据(不是我的推断):
//
// assets/docs/zh/ARCHITECTURE.md「三层记忆」
// · L1 与 L2 结构完全同构,唯一区别是「是否被直接投喂给模型」
// · 正文(含多媒体正文)内联在块里:文本块的 Text 是正文,媒体块的 Data 是字节
// · 任何一层都不压缩(视图不是记忆)
//
// 每条判据都对应一个真实发生过的断点,且做过变异测试(见各条注释)。
import (
"encoding/json"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
func newDocTestStore(t *testing.T) *Store {
t.Helper()
s := NewStore(t.TempDir(), memory.TokenizeWords)
if err := s.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(s.Stop)
return s
}
// ① 正文完整性:归档后能逐条取回**完整**正文。
//
// 断点史:原实现 ContextToDoc 把每条事件拍平成一行,
// truncate(Response,100) / truncate(工具输出,200) —— 原文在落库那一刻就不见了。
// 生产实测:1000 篇归档文档中 15.1% 的 response 段正好 103 字符。
//
// 变异:把 docBlocksForEntry 里文本块的 Text 改成截断版 ⇒ 本判据必须红。
func TestL2_ArchivePreservesFullText(t *testing.T) {
s := newDocTestStore(t)
longResp := strings.Repeat("这是一段用于验证原文完整性的长正文", 200) + "【哨兵ZQ7】"
longInput := strings.Repeat("这是用户的原始输入内容", 100) + "【输入哨兵K9】"
ts := time.Date(2026, 3, 4, 10, 11, 12, 345678901, time.UTC)
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: ts,
Source: "webui",
Content: longInput,
Response: longResp,
ToolsUsed: []string{"report_fetch"},
ToolResults: []ToolResultItem{
{Name: "report_fetch", Output: strings.Repeat("工具输出", 500) + "【工具哨兵M3】"},
},
}}, nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
var d *Doc
for _, cand := range s.RecentDocs(10) {
d = cand
}
if d == nil {
t.Fatal("没有归档出文档")
}
if !d.HasEvents() {
t.Fatal("文档没有 Events —— L2 必须与 L1 同构,事件列表是主体")
}
ev := d.Events[0]
in, resp, _ := ev.InOut()
if in != longInput {
t.Errorf("Input 不完整:len=%d,原长 %d", len(in), len(longInput))
}
if resp != longResp {
t.Errorf("Response 不完整:len=%d,原长 %d\n"+
"L2 是 L1 的下沉层,不是摘要层 —— 正文不能在落库时被截断", len(resp), len(longResp))
}
if !strings.Contains(resp, "【哨兵ZQ7】") {
t.Error("Response 丢了结尾哨兵")
}
if !strings.Contains(in, "【输入哨兵K9】") {
t.Error("Input 丢了结尾哨兵")
}
// 工具输出是事件结构的一部分,必须原样保留。
if len(ev.ToolResults) != 1 || !strings.Contains(ev.ToolResults[0].Output, "【工具哨兵M3】") {
t.Errorf("工具输出不完整或丢失: %+v", ev.ToolResults)
}
// ToolsUsed 以前完全不存。
if len(ev.ToolsUsed) != 1 || ev.ToolsUsed[0] != "report_fetch" {
t.Errorf("ToolsUsed 丢失: %+v", ev.ToolsUsed)
}
// 原始时刻(纳秒精度)必须保留,不能只剩 HH:MM。
if !ev.Timestamp.Equal(ts) {
t.Errorf("时间戳不精确:got %s,want %s", ev.Timestamp, ts)
}
}
// ② 正文各自成块:Input 一块、Response 一块,都是 BlockText。
//
// 拍板(2026-10-22):正文各自一块,不合成一个块 —— 两段内容语义常不同,
// 分别成块才能各自被检索。
//
// 变异:把两块合并成一块 ⇒ 本判据必须红。
func TestL2_InputAndResponseAreSeparateTextBlocks(t *testing.T) {
s := newDocTestStore(t)
media := memory.MemoryBlock{
ID: memory.MediaBlockID("image/png", []byte("x")), Modality: memory.BlockImage,
Data: []byte("x"), MIME: "image/png", Size: 1,
}
_, err := s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: time.Now(),
Source: "qq",
Content: "用户问:紫蓝红三色带怎么配",
Response: "回答:紫蓝红按 6:3:1 配",
Blocks: []memory.MemoryBlock{media},
}}, nil, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
var texts []memory.MemoryBlock
for _, b := range d.Events[0].Blocks {
if b.Modality == memory.BlockText {
texts = append(texts, b)
}
}
if len(texts) != 2 {
t.Fatalf("一条事件应产出 2 个文本块(Input/Response 各一),实际 %d", len(texts))
}
if texts[0].Text != "用户问:紫蓝红三色带怎么配" {
t.Errorf("第一个文本块应是 Input 正文,实际 %q", texts[0].Text)
}
if texts[1].Text != "回答:紫蓝红按 6:3:1 配" {
t.Errorf("第二个文本块应是 Response 正文,实际 %q", texts[1].Text)
}
// 文本块的 CreatedAt 必须是事件时刻(回填靠它定位)。
if !texts[0].CreatedAt.Equal(d.Events[0].Timestamp) {
t.Error("文本块 CreatedAt 应等于事件时间戳")
}
// 媒体块仍在,且不被文本块顶掉。
var hasMedia bool
for _, b := range d.Events[0].Blocks {
if b.ID == media.ID {
hasMedia = true
}
}
if !hasMedia {
t.Error("媒体块在归档后丢失")
}
}
// ③ 逐条保留:一次归档 N 条事件,L2 里就是 N 条,不合并、不丢。
//
// 变异:只保留第一条 ⇒ 红。
func TestL2_KeepsEveryEventIndividually(t *testing.T) {
s := newDocTestStore(t)
base := time.Date(2026, 5, 6, 8, 0, 0, 0, time.UTC)
var entries []ContextEntry
for i := 0; i < 5; i++ {
entries = append(entries, ContextEntry{
Timestamp: base.Add(time.Duration(i) * 13 * time.Minute),
Source: "cli",
Content: "第" + string(rune('0'+i)) + "条输入",
Response: "第" + string(rune('0'+i)) + "条回答",
})
}
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
if len(d.Events) != 5 {
t.Fatalf("Events = %d,期望 5(逐条保留,不合并)", len(d.Events))
}
for i, ev := range d.Events {
wantTS := base.Add(time.Duration(i) * 13 * time.Minute)
if !ev.Timestamp.Equal(wantTS) {
t.Errorf("第 %d 条时间戳错位: %s vs %s", i, ev.Timestamp, wantTS)
}
in, resp, _ := ev.InOut()
if in != "第"+string(rune('0'+i))+"条输入" || resp != "第"+string(rune('0'+i))+"条回答" {
t.Errorf("第 %d 条正文错位: input=%q response=%q", i, in, resp)
}
}
}
// ④ Content 是视图而不是事实源:它由事件渲染,且不截断。
//
// 拍板(2026-10-22):Content 保留为视图。
// 判据要点:如果 Content 里还能看到完整正文,说明视图没有偷偷丢信息;
// 同时原文的权威来源是块,Content 改坏了也不影响 InOut()。
func TestL2_ContentIsRenderedViewNotSource(t *testing.T) {
s := newDocTestStore(t)
long := strings.Repeat("视图不该丢信息", 300) + "【视图哨兵】"
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: time.Now(), Source: "webui",
Content: "输入", Response: long,
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
if !strings.Contains(d.Content, "【视图哨兵】") {
t.Error("Content 视图里丢了正文尾部 —— 视图也不该在存储层主动截断")
}
// 权威来源是块:把 Content 清空,InOut() 仍要能读回完整正文。
d.Content = ""
_, resp, _ := d.Events[0].InOut()
if !strings.Contains(resp, "【视图哨兵】") {
t.Error("正文权威来源应是块,不是 Content")
}
}
// ⑤ 幂等:同一批事件重复归档,块 ID 不变、事件不翻倍。
//
// 为何重要:Prune 可能因重试/重复触发而对同一批事件归档两次;
// 块 ID 由内容派生,必须收敛而不是长出两套。
func TestL2_RepeatedArchiveIsIdempotent(t *testing.T) {
s := newDocTestStore(t)
ts := time.Date(2026, 7, 8, 9, 10, 11, 123456789, time.UTC)
entries := []ContextEntry{{
Timestamp: ts, Source: "qq", Content: "同一条输入", Response: "同一条回答",
}}
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
first := s.RecentDocs(1)[0]
id1 := first.Events[0].Blocks[0].ID
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
// 内容哈希相同 ⇒ 命中已有文档(不新建),事件列表被重写为同一份。
if n := len(s.Stats()); n == 0 {
t.Fatal("stats 异常")
}
if got := len(s.RecentDocs(10)); got != 1 {
t.Fatalf("重复归档产生了 %d 篇文档,期望 1(同内容应命中已有文档)", got)
}
d := s.RecentDocs(1)[0]
if len(d.Events) != 1 {
t.Fatalf("重复归档后 Events=%d,期望 1(不应翻倍)", len(d.Events))
}
if d.Events[0].Blocks[0].ID != id1 {
t.Errorf("同一事件重复归档得到不同块 ID:%s → %s(块 ID 必须内容稳定)",
id1, d.Events[0].Blocks[0].ID)
}
}
// ⑥ 存量兼容:没有 Events 的旧文档必须能被读,且不伪造成"有原始事件"。
//
// 决定(2026-10-22):存量等系统自然冲刷,不迁移。
// 但新代码必须能读旧格式 —— 否则升级瞬间老文档全部不可召回。
func TestL2_LegacyDocWithoutEventsStillLoads(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir, memory.TokenizeWords)
// 手工写一份旧格式 JSON(无 events 字段)。
legacy := map[string]interface{}{
"id": "doc_legacy_1",
"summary": "旧格式文档",
"content": "[10:00] old: 这是旧格式的渲染视图",
"source": "context_archived",
"created_at": "2026-01-02T03:04:05Z",
"updated_at": "2026-01-02T03:04:05Z",
"access_count": 1,
"last_access": "2026-01-02T03:04:05Z",
}
raw, _ := json.Marshal(legacy)
if err := writeFile(dir+"/doc_legacy_1.json", raw); err != nil {
t.Fatal(err)
}
if err := s.Start(); err != nil {
t.Fatalf("读取旧格式失败(不得 panic):%v", err)
}
got := s.Get("doc_legacy_1")
if got == nil {
t.Fatal("旧格式文档未被加载")
}
if got.HasEvents() {
t.Error("旧格式文档不该被伪造成有 Events")
}
if got.Content == "" {
t.Error("旧格式文档的 Content 视图丢失")
}
}
func writeFile(path string, data []byte) error { return os.WriteFile(path, data, 0o644) }
// ⑦ 蒸馏/抽取必须基于原文,不能基于视图。
//
// 断点史:docToTriples 曾抽取 doc.Content —— 而 Content 在 Events 引入前
// 是压缩渲染(Response 截 100 rune)。在被截断的句子上做依存句法分析,
// 往往解析不出主谓宾,三元组抽取直接失败(生产日志里大量
// 「三元组 N 条全被实体名校验拒绝」即与此相关)。
//
// 本判据守的是 document 包这一侧的能力:
// 只要给出原文,就能完整取回 —— 不被视图的形变影响。
func TestL2_OriginalTextAvailableIndependentOfView(t *testing.T) {
s := newDocTestStore(t)
full := strings.Repeat("原文必须完整用于抽取与蒸馏", 500) + "【原文哨兵D2】"
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: time.Now(), Source: "webui",
Content: "输入", Response: full,
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
// 刻意把视图弄坏:原文的权威来源必须是块,不是 Content。
d.Content = "视图被破坏了"
var b strings.Builder
for _, ev := range d.Events {
_, resp, _ := ev.InOut()
b.WriteString(resp)
}
if got := b.String(); got != full {
t.Errorf("事件正文取不回原文:len=%d,原长 %d", len(got), len(full))
}
if !strings.Contains(b.String(), "【原文哨兵D2】") {
t.Error("原文尾部丢失")
}
}
// ──────────────────────────────────────────────
// 压力测试:新形态(事件列表 + 正文成块)的规模与并发
// ──────────────────────────────────────────────
// 大规模归档:500 条事件 → 1000 个文本块,必须能落盘再完整读回。
//
// 关注点:新形态把每条事件变成 2 个块,块数翻倍。
// 若某处仍按"一篇文档一个块"或"块数=事件数"假设,规模上来就会暴露。
func TestL2_StressLargeArchive(t *testing.T) {
if testing.Short() {
t.Skip("压力测试")
}
const n = 500
s := newDocTestStore(t)
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
entries := make([]ContextEntry, n)
for i := 0; i < n; i++ {
entries[i] = ContextEntry{
Timestamp: base.Add(time.Duration(i) * time.Second),
Source: "stress",
Content: strings.Repeat("输入", 20) + "|" + string(rune('A'+i%26)),
Response: strings.Repeat("回答", 20) + "|" + string(rune('a'+i%26)),
}
}
if _, err := s.ContextToDoc("context_archived", entries, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
if len(d.Events) != n {
t.Fatalf("Events = %d,期望 %d", len(d.Events), n)
}
var textBlocks int
for _, ev := range d.Events {
for _, b := range ev.Blocks {
if b.Modality == memory.BlockText {
textBlocks++
}
}
}
if textBlocks != 2*n {
t.Fatalf("文本块 = %d,期望 %d(每条事件 Input/Response 各一块)", textBlocks, 2*n)
}
// 落盘再重新加载:验证序列化能承载这个规模、且逐条完整。
s.Stop()
s2 := NewStore(s.dir, memory.TokenizeWords)
if err := s2.Start(); err != nil {
t.Fatalf("重新加载失败: %v", err)
}
defer s2.Stop()
d2 := s2.Get(d.ID)
if d2 == nil {
t.Fatal("重新加载后文档丢失")
}
if len(d2.Events) != n {
t.Fatalf("重新加载后 Events = %d,期望 %d", len(d2.Events), n)
}
// 抽查首尾,确认正文没被合并或错位。
for _, i := range []int{0, n / 2, n - 1} {
in, resp, _ := d2.Events[i].InOut()
wantIn := strings.Repeat("输入", 20) + "|" + string(rune('A'+i%26))
wantResp := strings.Repeat("回答", 20) + "|" + string(rune('a'+i%26))
if in != wantIn || resp != wantResp {
t.Errorf("第 %d 条正文错位: in=%q resp=%q", i, in, resp)
}
if !d2.Events[i].Timestamp.Equal(base.Add(time.Duration(i) * time.Second)) {
t.Errorf("第 %d 条时间戳错位", i)
}
}
}
// 并发召回压力:Consume 持有写锁,而 QueryScored 走读锁 + markAccess。
// 新形态下 Consume 要回填更多数据,不能引入竞态或丢文档。
func TestL2_StressConcurrentQueryAndConsume(t *testing.T) {
if testing.Short() {
t.Skip("压力测试")
}
s := newDocTestStore(t)
const docs = 60
for i := 0; i < docs; i++ {
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "stress",
Content: "并发查询目标 " + string(rune('A'+i%26)) + " 编号" + string(rune('0'+i%10)),
Response: "回答 " + string(rune('A'+i%26)),
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
}
var wg sync.WaitGroup
for w := 0; w < 8; w++ {
wg.Add(1)
go func(w int) {
defer wg.Done()
for i := 0; i < 40; i++ {
switch w % 3 {
case 0:
s.QueryScored("并发查询目标", 5)
case 1:
s.Consume("并发查询目标", 3)
case 2:
s.FindColdDocs(time.Hour, 10)
}
}
}(w)
}
wg.Wait()
// 只要不 panic / 不 -race 报错即通过;文档数只会因 Consume 减少。
if got := len(s.RecentDocs(1000)); got > docs {
t.Errorf("文档数异常增长: %d > %d", got, docs)
}
}
// 大正文:单条事件 1MB 正文,必须完整往返(不截断、不 OOM)。
func TestL2_StressLargePayloadRoundTrip(t *testing.T) {
if testing.Short() {
t.Skip("压力测试")
}
s := newDocTestStore(t)
huge := strings.Repeat("大正文压测", 250000) + "【巨尾哨兵P8】" // ~1.25MB
if _, err := s.ContextToDoc("context_archived", []ContextEntry{{
Timestamp: time.Now(), Source: "stress",
Content: "输入", Response: huge,
}}, nil, nil, nil, nil); err != nil {
t.Fatal(err)
}
d := s.RecentDocs(1)[0]
_, resp, _ := d.Events[0].InOut()
if len(resp) != len(huge) {
t.Fatalf("大正文往返长度不符: %d vs %d", len(resp), len(huge))
}
if !strings.Contains(resp, "【巨尾哨兵P8】") {
t.Error("大正文尾部丢失")
}
}
// ⑧ simpleHash 必须 O(n) 且定长。
//
// 断点史(2026-10-22 压力测试实测):
//
// h += fmt.Sprintf("%x", c) // 每 rune 一次 Sprintf + 字符串拼接 ⇒ O(n²)
//
// 1.25MB 正文归档耗时 **270 秒**;且哈希串长度是内容的 2.2 倍
// (生产实测 content 52072 字符 → content_hash 114491 字符)。
//
// 本判据同时钉两点:长度定长(不随内容膨胀)、时间线性(不是 O(n²))。
func TestL2_HashIsLinearAndFixedLength(t *testing.T) {
small := "小内容"
large := strings.Repeat("大内容", 200000)
hs, hl := simpleHash(small), simpleHash(large)
if len(hs) != len(hl) {
t.Errorf("哈希长度随内容变化: %d vs %d(必须定长)", len(hs), len(hl))
}
if len(hl) > 64 {
t.Errorf("哈希本身过长: %d 字符(会成为文档体积负担)", len(hl))
}
// 线性性:64 倍输入不应产生平方级增长。留足余量避免机器抖动误判。
t0 := time.Now()
for i := 0; i < 32; i++ {
simpleHash(large)
}
big := time.Since(t0)
t1 := time.Now()
for i := 0; i < 32; i++ {
simpleHash(large[:len(large)/64])
}
sm := time.Since(t1)
// O(n²) 下这个比值应接近 64²=4096;线性下约 64。
// 取 300 作为阈值:远离线性(64),远低于平方(4096)。
if sm > 0 && big > 300*sm {
t.Errorf("哈希耗时随输入超线性增长:大 %.1fms / 小 %.1fms = %.0f 倍"+
"(输入差 64 倍,线性应约 64 倍,O(n²) 会到 4096 倍)",
float64(big.Microseconds())/1000, float64(sm.Microseconds())/1000,
float64(big)/float64(sm))
}
}

View File

@ -1,6 +1,8 @@
package document
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log"
@ -8,6 +10,7 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
@ -22,24 +25,74 @@ import (
type ChannelCleaner func(source string) func(string) string
// Doc — 记忆文档:由上下文提炼而来
//
// ## L1/L2 同构
//
// 文档的主体是 **Events**(事件列表),与 RelevanceContext 的事件列表同一结构。
// 区别不在形态,而在是否被直接投喂给模型:L1 进 system prompt 时序,L2 要经召回才进去。
//
// Content 只是**渲染视图**(供摘要、人工阅读、SDK 的 Doc.Content 字段),
// 不是原文载体。原文在 Events 里,正文内联在文本块里。
type Doc struct {
ID string `json:"id"`
Summary string `json:"summary"`
Content string `json:"content"`
Tags []string `json:"tags"`
Entities []string `json:"entities"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Source string `json:"source"`
Meta map[string]string `json:"meta,omitempty"`
AccessCount int `json:"access_count"`
LastAccess time.Time `json:"last_access"`
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径)
DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算
ID string `json:"id"`
Summary string `json:"summary"`
Content string `json:"content"` // 渲染视图,不是原文
Events []DocEvent `json:"events,omitempty"`
Tags []string `json:"tags"`
Entities []string `json:"entities"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Source string `json:"source"`
Meta map[string]string `json:"meta,omitempty"`
AccessCount int `json:"access_count"`
LastAccess time.Time `json:"last_access"`
// Blocks 是本文档持有的**全部**块(事件里的文本块 + 媒体块去重后的并集)。
//
// ★ 为什么还留一个扁平列表而不是只看 Events:
// · 向量检索/跨模态融合要能不遍历事件就拿到所有块;
// · L2→L3 蒸馏按块迁移;
// · 存量文档(无 Events)只有这一个字段有块。
//
// ★ 它是**视图**,不是事实源:块的真实归属在 Events[i].Blocks 里。
// 两者由 ContextToDoc 同一处产出,不得分别修改。
Blocks []memory.MemoryBlock `json:"blocks,omitempty"`
Vector tfidf.Vector `json:"vector,omitempty"` // TF-IDF 稀疏向量(fallback 时持久化)
DenseVec []float64 `json:"dense_vec,omitempty"` // 多模态稠密向量(主路径)
DenseFP string `json:"dense_fp,omitempty"` // DenseVec 所属统一空间指纹,变化时触发重算
}
// DocEvent 是一条被 L2 持有的**原始事件**,与 L1 的 ContextEvent 同构。
//
// ★ 为何正文不在字段里、而在块里
//
// 正文(含多媒体正文)内联在块里:文本块的 Text 是正文,媒体块的 Data 是字节。
// 事件只是块的容器。因此本结构**不重复存一份正文字符串** ——
// 那会出现「块里的正文」与「字段里的正文」两份真相。
//
// 读原文 = 读 Blocks 里文本块的 Text。
type DocEvent struct {
Timestamp time.Time `json:"ts"`
Source string `json:"source"`
ToolsUsed []string `json:"tools_used,omitempty"`
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
// Blocks 是本事件持有的块,**正文就在里面**。
//
// 顺序约定(复原时按此顺序回填 ContextEvent):
// Input 文本块 → Response 文本块 → 媒体块…
// 由 docBlocksForEntry 保证;回填由 blocksToInOut 对称解读。
Blocks []memory.MemoryBlock `json:"blocks,omitempty"`
}
// HasEvents 报告这篇文档是否保存了可逐条回填的原始事件。
//
// 存量文档(Events 引入之前归档的)没有它 —— 调用方必须据此降级到
// 「把 Content 塞进一条事件」,而不是假装能回填。
func (d *Doc) HasEvents() bool { return d != nil && len(d.Events) > 0 }
// Store — 文档记忆存储。
// 主路径:denseSpace(稠密多模态向量,与媒体共享空间)。
// Fallback:tfidf(TF-IDF 倒排索引,仅稠密空间不可用时加载)。
@ -234,18 +287,15 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
cleanFn = func(text string) string { return text }
}
var parts []string
for _, e := range entries {
line := fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04"), e.Source, e.Content)
if e.Response != "" {
line += fmt.Sprintf(" → %s", truncate(e.Response, 100))
}
for _, tr := range e.ToolResults {
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, truncate(tr.Output, 200))
}
parts = append(parts, line)
}
content := strings.Join(parts, "\n")
// ★★ 原文落位:正文进块,事件列表成为文档主体(L1/L2 同构)
//
// 此前这里把每条事件拍平成一行字符串,并对正文 truncate(..,100)、
// 对工具输出 truncate(..,200) —— 原文在落库那一刻就不存在了。
// 现在事件逐条保留,正文各自成为文本块(内联在块的 Text 里)。
//
// Content 只是渲染视图(见 contentFromEvents),不是原文载体。
events := docEventsFromEntries(entries)
content := contentFromEvents(events)
contentHash := simpleHash(content)
summary := summarizeEntries(entries, cleanFn, toolCleanFn, channelCleaner)
tags := extractTags(entries, cleanFn, toolCleanFn, channelCleaner)
@ -259,11 +309,12 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
d.UpdatedAt = time.Now()
d.LastAccess = time.Now()
d.Content = content
d.Events = events
d.Source = source
d.Summary = summary
d.Tags = tags
d.Entities = entities
d.Blocks = blocksFromEntries(entries)
d.Blocks = flattenEventBlocks(events)
d.DenseVec = s.denseFor(d)
if s.denseSpace != nil {
d.DenseFP = s.denseSpace.Fingerprint()
@ -282,7 +333,8 @@ func (s *Store) ContextToDoc(source string, entries []ContextEntry, _ interface{
ID: id, Summary: summary, Content: content, Tags: tags,
Entities: entities, CreatedAt: time.Now(), UpdatedAt: time.Now(),
LastAccess: time.Now(), AccessCount: 1, Source: source, Meta: meta,
Blocks: blocksFromEntries(entries),
Events: events,
Blocks: flattenEventBlocks(events),
}
s.docs[id] = doc
doc.DenseVec = s.denseFor(doc)
@ -594,6 +646,7 @@ type ContextEntry struct {
Source string
Content string
Response string
ToolsUsed []string
ToolResults []ToolResultItem
Blocks []memory.MemoryBlock // 一等记忆块随事件一起迁移到文档
}
@ -738,17 +791,212 @@ func extractEntities(entries []ContextEntry, cleanText func(string) string, tool
return entities
}
func truncate(s string, max int) string {
if len([]rune(s)) <= max {
return s
}
return string([]rune(s)[:max]) + "..."
// simpleHash 是用于「内容是否同一份」比对的快速摘要。
//
// ★ 旧实现是 O(n²) 且哈希串长度正比于内容长度:
//
// h := fmt.Sprintf("%x", len(s))
// for _, c := range s { h += fmt.Sprintf("%x", c) } // 每 rune 一次 Sprintf + 字符串拼接
//
// 两个后果都是实测出来的:
//
// 1. **性能**:1.25MB 正文归档耗时 270 秒(压力测试实测)。
// 字符串 `+=` 每轮重新分配并拷贝整个累积串 ⇒ 总拷贝量 O(n²)。
// 2. **体积**:哈希串本身是内容的 2.2 倍
// (生产实测 content 52072 字符 → content_hash 114491 字符)。
// 它存在每篇文档的 meta 里,等于把正文又存了一遍还更胖。
//
// 换成 sha256 前 16 字节:定长 32 hex、O(n) 一次遍历。
// 用途不变(仅在同一批归档里去重),不需要密码学强度之外的任何性质。
func simpleHash(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:16])
}
func simpleHash(s string) string {
h := fmt.Sprintf("%x", len(s))
for _, c := range s {
h += fmt.Sprintf("%x", c)
}
return h
// ──────────────────────────────────────────────
// L1 ↔ L2 的块级互转
//
// 设计前提:正文(含多媒体正文)内联在块里,事件只是块的容器。
// 因此归档 = 把事件正文落成文本块 + 保留事件原有的媒体块;
// 召回 = 从块读回正文,按原时间戳插回 L1。
// ──────────────────────────────────────────────
// entryTextBlockID 由「事件身份 + 正文角色」派生文本块 ID。
//
// 为何不能像原句块那样只用内容派生:
//
// SentenceBlockID(sha256(text)) 会让**两句话恰好相同**的正文合并成一个块。
// 对话里「好的。」「收到」这类短答应大量重复,合并后
// 「哪一条事件说过这句话」就不可知了 —— 而 L2 必须能按事件逐条回填。
//
// 所以 ID 掺入事件身份(时间戳纳秒 + 来源 + 角色),保证一事一块,
// 同时仍是**内容稳定**的:同一条事件重复归档得到同一个块 ID(幂等)。
func entryTextBlockID(evtTS time.Time, source, role, text string) string {
h := sha256.New()
h.Write([]byte(strconv.FormatInt(evtTS.UnixNano(), 10)))
h.Write([]byte{0})
h.Write([]byte(source))
h.Write([]byte{0})
h.Write([]byte(role))
h.Write([]byte{0})
h.Write([]byte(text))
return "blk_txt_" + hex.EncodeToString(h.Sum(nil)[:12])
}
// TextBlockRole 标记文本块在一对事件里的角色。
//
// 正文各自一块(不是 Input+Response 合成一块),为的是两段内容能
// **分别按自己的向量被检索**:用户的提问与模型的回答语义常常不同。
const (
TextBlockRoleInput = "input"
TextBlockRoleResponse = "response"
)
// docBlocksForEntry 把一条上下文事件落成「文本块 + 媒体块」的块列表。
//
// 顺序约定(回填时按此对称解读):
//
// Input 文本块(若有)→ Response 文本块(若有)→ 媒体块…
//
// 媒体块原样保留(迁移语义:同一块 ID、同一向量,只换持有层)。
func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock {
var out []memory.MemoryBlock
now := e.Timestamp
if now.IsZero() {
now = time.Now()
}
// 文本块:正文内联在 Text 里,不带向量。
//
// 为什么不在这里算向量:L2 的检索向量由 Doc.denseFor 用整篇文档文本算一次,
// 单条事件的文本块不参与 L2 召回(它是回填用的载体)。
// 这与「向量只算一次」一致 —— 不在迁移路径上重算。
if t := strings.TrimSpace(e.Content); t != "" {
out = append(out, memory.MemoryBlock{
ID: entryTextBlockID(now, e.Source, TextBlockRoleInput, t),
Modality: memory.BlockText,
Text: t,
Source: e.Source,
CreatedAt: now,
UpdatedAt: now,
})
}
if t := strings.TrimSpace(e.Response); t != "" {
out = append(out, memory.MemoryBlock{
ID: entryTextBlockID(now, e.Source, TextBlockRoleResponse, t),
Modality: memory.BlockText,
Text: t,
Source: e.Source,
CreatedAt: now,
UpdatedAt: now,
})
}
out = append(out, e.Blocks...)
return out
}
// blocksToEntry 是 docBlocksForEntry 的逆操作:从块列表读回事件的
// Input / Response 正文与它持有的块。
//
// 识别规则:文本块按 ID 前缀里的角色区分(Input 在前、Response 在后),
// 其余块(媒体/未知来源的文本块)原样作为「事件持有的块」返回。
func blocksToEntry(ev *DocEvent) (input, response string, held []memory.MemoryBlock) {
if ev == nil {
return "", "", nil
}
for i := range ev.Blocks {
b := ev.Blocks[i]
if b.Modality != memory.BlockText {
held = append(held, b)
continue
}
switch {
case input == "" && isEntryTextBlock(b, ev, TextBlockRoleInput):
input = b.Text
case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse):
response = b.Text
default:
// 不是本事件派生出的文本块(例如蒸馏来的原句块):当作持有块。
held = append(held, b)
}
}
return input, response, held
}
// isEntryTextBlock 判断文本块是否为本事件按该角色派生出来的那一块。
func isEntryTextBlock(b memory.MemoryBlock, ev *DocEvent, role string) bool {
return b.ID == entryTextBlockID(ev.Timestamp, ev.Source, role, b.Text)
}
// docEventsFromEntries 把 L1 交来的一批事件转成 L2 的事件列表。
func docEventsFromEntries(entries []ContextEntry) []DocEvent {
if len(entries) == 0 {
return nil
}
out := make([]DocEvent, 0, len(entries))
for _, e := range entries {
ev := DocEvent{
Timestamp: e.Timestamp,
Source: e.Source,
ToolsUsed: append([]string(nil), e.ToolsUsed...),
Blocks: docBlocksForEntry(e),
}
for _, tr := range e.ToolResults {
ev.ToolResults = append(ev.ToolResults, ToolResultItem{Name: tr.Name, Output: tr.Output})
}
out = append(out, ev)
}
return out
}
// flattenEventBlocks 取事件列表里全部块(去重后的并集),供 Doc.Blocks 使用。
//
// 它是视图:真实归属在 Events[i].Blocks 里。之所以仍要扁平化,
// 是因为向量融合、L3 蒸馏、存量兼容三条路都按「文档持有哪些块」工作。
func flattenEventBlocks(events []DocEvent) []memory.MemoryBlock {
seen := make(map[string]bool)
var out []memory.MemoryBlock
for _, ev := range events {
for i := range ev.Blocks {
b := ev.Blocks[i]
if b.ID == "" || seen[b.ID] {
continue
}
seen[b.ID] = true
out = append(out, b)
}
}
return out
}
// contentFromEvents 用事件正文渲染出 Doc.Content。
//
// ★ 这是**视图**,不是原文:它为了让摘要/人工阅读/SDK 的 Doc.Content 有东西可看,
// 也为了让存量逻辑(依赖 Content 做摘要与 TF-IDF)继续工作。
// 原文永远以 Events[i].Blocks 里文本块的 Text 为准。
//
// 为什么视图里也**不截断**:视图可以比原文简洁,但不该主动丢掉信息——
// 真正决定给模型看多少的是渲染层(按 token 预算),不是存储层。
func contentFromEvents(events []DocEvent) string {
var parts []string
for _, ev := range events {
input, response, _ := blocksToEntry(&ev)
line := fmt.Sprintf("[%s] %s: %s", ev.Timestamp.Format("15:04"), ev.Source, input)
if response != "" {
line += " → " + response
}
for _, tr := range ev.ToolResults {
line += fmt.Sprintf("\n [工具] %s: %s", tr.Name, tr.Output)
}
parts = append(parts, line)
}
return strings.Join(parts, "\n")
}
// InOut 从一条 L2 事件读回它的 Input 正文、Response 正文与持有的块。
//
// 这是 blocksToEntry 的导出形态:L1(agent/core)需要它来回填 ContextEvent,
// 而正文住在块里,所以必须由本包解读块的角色与归属。
func (ev *DocEvent) InOut() (input, response string, held []memory.MemoryBlock) {
return blocksToEntry(ev)
}