Files
HomeAgent/internal/agent/core/recalldoc_test.go
JianFeeeee 47c95efb80 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 已等价)
- 存量文档(等自然冲刷)
- 渲染层截断常数(独立议题,避免与本次修复互相掩盖)
2026-10-22 11:09:13 +08:00

287 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}