Files
HomeAgent/internal/agent/core/mediaref_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

343 lines
11 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
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
"github.com/JianFeeeee/HologramAgent/internal/memory/document"
)
// 媒体记忆接线测试(块 union 化之后):验证媒体从对话进入块、挂到 L0 事件、
// 随归档转到 L2 文档的完整链路。
//
// 核心断言不是"函数被调用了",而是不变量:
// 1. 媒体进不了块时对话照常(它是记忆增强,不是对话必需品)
// 2. 块的身份是内容派生:同内容同块,跨层迁移身份不变
// 3. 图库为 nil 时全链路静默跳过,行为与本特性上线前一致
func newTestAgentWithMedia(t *testing.T) *Agent {
t.Helper()
dir := t.TempDir()
g, err := memory.NewGraphDB(filepath.Join(dir, "graph.db"))
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
t.Cleanup(func() { g.Close() })
emb := memory.NewStaticEmbedder()
a := &Agent{
memory: g,
context: NewRelevanceContext(filepath.Join(dir, "context.json"), emb),
}
return a
}
// dataURLString 造 data URL(media 包退场后由本测试自持,与 parseDataURL 对偶)。
func dataURLString(mime string, data []byte) string {
return "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(data)
}
// imageBlockURL 造一个带指定 URL 的图片块。
// 名字带 URL 后缀是为了不与 modalfallback_test.go 里固定用 testPNG 的
// imageBlock() 撞名——两者用途不同:那个验回退链,这个验入库。
func imageBlockURL(dataURL string) agentAPI.ContentBlock {
return agentAPI.ContentBlock{
Type: "image_url",
ImageURL: &agentAPI.ImageURL{URL: dataURL, Detail: "auto"},
}
}
func TestCaptureBlockMedia_StoresDataURL(t *testing.T) {
a := newTestAgentWithMedia(t)
raw := []byte{0x89, 'P', 'N', 'G', 1, 2, 3}
blocks := []agentAPI.ContentBlock{
{Type: "text", Text: "看这张图"},
imageBlockURL(dataURLString("image/png", raw)),
}
got := a.captureBlockMedia(blocks, "multimodal_see_picture")
if len(got) != 1 {
t.Fatalf("应捕获 1 个媒体块,实际 %d", len(got))
}
b := got[0]
// 字节就在块里(union),身份内容派生
if string(b.Data) != string(raw) {
t.Fatal("块字节与输入不一致")
}
if b.ID != memory.MediaBlockID("image/png", raw) {
t.Errorf("块 ID 应内容派生: %q", b.ID)
}
if b.MIME != "image/png" || b.Tool != "multimodal_see_picture" || b.Modality != memory.BlockImage {
t.Fatalf("元数据不对: %+v", b)
}
// capture 之后由 task/processInput 做 stage(见 task.go:397/1102)——
// capture 本身只产出块,不暂存。
}
func TestCaptureBlockMedia_SkipsHTTPURL(t *testing.T) {
// http(s) URL 拿不到字节就无法成块;"下载它再存"会把一次对话
// 变成一次网络请求(超时、鉴权、SSRF 全来了),不在本层解决。
a := newTestAgentWithMedia(t)
blocks := []agentAPI.ContentBlock{
imageBlockURL("https://example.com/x.png"),
}
if d := a.captureBlockMedia(blocks, "t"); len(d) != 0 {
t.Fatalf("http URL 不该被捕获,实际 %d 个", len(d))
}
}
func TestCaptureBlockMedia_NilMemoryIsNoop(t *testing.T) {
// 块 union 化后 captureBlockMedia 不再依赖外部存储(字节就在块里),
// 但 stage/drain、bindEventMedia、blockLabel 这些纯内存通路必须照常可用。
a := &Agent{}
blocks := []agentAPI.ContentBlock{imageBlockURL(dataURLString("image/png", []byte("x")))}
got := a.captureBlockMedia(blocks, "t")
if len(got) != 1 {
t.Fatalf("捕获不依赖图库,应得 1 个块,实际 %v", got)
}
a.stageMediaBlocks(got...)
if drained := a.drainMediaBlocks(); len(drained) != 1 {
t.Fatal("stage/drain 不依赖图库,应正常工作")
}
// bindEventMedia 对空块列表也必须安全
evt := &ContextEvent{}
a.bindEventMedia(evt, nil)
if len(evt.Blocks) != 0 || evt.ID != "" {
t.Fatalf("空块列表时不该改动事件: %+v", evt)
}
if s := blockLabel(nil); s != "" {
t.Fatalf("nil 块应产出空标签,得到 %q", s)
}
}
func TestCaptureBlockMedia_AudioAndVideo(t *testing.T) {
a := newTestAgentWithMedia(t)
blocks := []agentAPI.ContentBlock{
imageBlockURL(dataURLString("image/jpeg", []byte("frame"))),
{Type: "audio_url", AudioURL: &agentAPI.AudioURL{URL: dataURLString("audio/wav", []byte("sound"))}},
}
got := a.captureBlockMedia(blocks, "multimodal_see_video")
if len(got) != 2 {
t.Fatalf("应捕获 2 个,实际 %d", len(got))
}
if got[0].Modality != memory.BlockImage || got[0].MIME != "image/jpeg" {
t.Fatalf("图像块元数据不对: %+v", got[0])
}
if got[1].Modality != memory.BlockAudio || got[1].MIME != "audio/wav" {
t.Fatalf("音频块元数据不对: %+v", got[1])
}
}
func TestStageDrainMediaBlocks(t *testing.T) {
a := newTestAgentWithMedia(t)
b1 := mediaBlock("image/png", []byte("a"))
b2 := mediaBlock("image/png", []byte("b"))
b3 := mediaBlock("image/png", []byte("c"))
a.stageMediaBlocks(b1, b2)
a.stageMediaBlocks(b3)
got := a.drainMediaBlocks()
if len(got) != 3 {
t.Fatalf("应累积 3 个,实际 %d", len(got))
}
// drain 后必须清空——否则下一轮对话会把上一轮的媒体又挂一遍
if again := a.drainMediaBlocks(); again != nil {
t.Fatalf("drain 后应为空,实际 %v", again)
}
}
func TestBindEventMedia_CreatesBlocks(t *testing.T) {
a := newTestAgentWithMedia(t)
b := mediaBlock("image/png", []byte("img"))
evt := &ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "看图"}
a.bindEventMedia(evt, []memory.MemoryBlock{b})
if evt.ID == "" {
t.Fatal("应懒生成事件 ID")
}
if len(evt.Blocks) != 1 || evt.Blocks[0].ID != b.ID {
t.Fatalf("事件应持有一等记忆块: %+v", evt.Blocks)
}
if evt.Blocks[0].Modality != memory.BlockImage || evt.Blocks[0].MIME != "image/png" {
t.Fatalf("块元数据不对: %+v", evt.Blocks[0])
}
// 字节随事件里的块一起走(union)
if string(evt.Blocks[0].Data) != "img" {
t.Fatalf("块字节应随事件: %q", evt.Blocks[0].Data)
}
}
func TestBindEventMedia_LazyIDOnlyWhenNeeded(t *testing.T) {
// 绝大多数对话没有媒体,不该为它们都生成 ID 塞进 context.json
a := newTestAgentWithMedia(t)
evt := &ContextEvent{Input: "纯文本"}
a.bindEventMedia(evt, nil)
if evt.ID != "" {
t.Fatalf("无媒体时不该生成 ID,得到 %q", evt.ID)
}
}
func TestBlockLabel_NoGeneratedDescription(t *testing.T) {
// 标签只用来告诉模型「这条记忆带着哪份媒体、可用该块 ID 取回字节」。
// 它不包含任何生成的描述:描述式索引是旧机制,已彻底废弃。
a := newTestAgentWithMedia(t)
b := mediaBlock("image/png", []byte("img"))
s := blockLabel(&b)
if s == "" {
t.Fatal("应产出标签")
}
if !strings.Contains(s, "image/png") {
t.Fatalf("标签应含 MIME 标注: %q", s)
}
if !strings.Contains(s, shortID(b.ID)) {
t.Fatalf("标签应含短块 ID 供反查: %q", s)
}
_ = a
}
func TestPrune_NilDocStoreStillArchives(t *testing.T) {
// 归档链路不依赖任何外部媒体存储:块随事件走,归档时整体迁移。
dir := t.TempDir()
emb := memory.NewStaticEmbedder()
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := docStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
for i := 0; i < 15; i++ {
rc.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "qq",
Input: "内容",
})
}
if n := rc.Prune("查询", 5, docStore); n == 0 {
t.Fatal("无媒体参与时归档也应正常")
}
}
func TestContextEvent_BlocksFieldRoundTrip(t *testing.T) {
// context.json 加字段必须向后兼容:存量文件读回来 Blocks 为空、ID 为空,
// 不影响任何既有行为。
dir := t.TempDir()
path := filepath.Join(dir, "context.json")
// 写一份"存量格式"(无 id / blocks 字段)
legacy := `[{"timestamp":"2026-09-04T10:00:00Z","source":"qq","input":"老数据","response":"回复"}]`
if err := os.WriteFile(path, []byte(legacy), 0644); err != nil {
t.Fatal(err)
}
emb := memory.NewStaticEmbedder()
rc := NewRelevanceContext(path, emb)
if rc.Len() != 1 {
t.Fatalf("应读回 1 条,实际 %d", rc.Len())
}
// 新写入带记忆块的事件,再读回
a := newTestAgentWithMedia(t)
a.context = rc
b := mediaBlock("image/png", []byte("img"))
evt := ContextEvent{Timestamp: time.Now(), Source: "qq", Input: "新数据"}
a.bindEventMedia(&evt, []memory.MemoryBlock{b})
rc.Append(evt)
rc.flush()
rc2 := NewRelevanceContext(path, emb)
if rc2.Len() != 2 {
t.Fatalf("应有 2 条,实际 %d", rc2.Len())
}
var persisted int
for _, e := range rc2.Recent(10) {
persisted += len(e.Blocks)
}
if persisted != 1 {
t.Fatalf("块应随 context.json 持久化,实际 %d 个", persisted)
}
}
func TestPruneMigratesBlocksToDocument(t *testing.T) {
// 一等记忆块的 L0→L2 迁移:块随事件离开 Context、进入 Document,
// 身份(ID/模态/MIME/向量)原样保留;同一块不能同时留在两层。
// 这条路径不依赖任何引用账本。
dir := t.TempDir()
emb := memory.NewStaticEmbedder()
docStore := document.NewStore(filepath.Join(dir, "docs"), memory.TokenizeWords)
if err := docStore.Start(); err != nil {
t.Fatal(err)
}
rc := NewRelevanceContext(filepath.Join(dir, "context.json"), emb)
block := mediaBlock("image/png", []byte("migrate-me"))
block.Vector = []float64{0.1, 0.2, 0.3}
block.Fingerprint = "qwen:test"
rc.Append(ContextEvent{
Timestamp: time.Now().Add(-time.Hour),
Source: "qq", Input: "很久以前的一张图",
Blocks: []memory.MemoryBlock{block},
})
for i := 0; i < 12; i++ {
rc.Append(ContextEvent{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Source: "qq", Input: "无关内容",
})
}
if n := rc.Prune("完全不相关的查询", 5, docStore); n == 0 {
t.Fatal("应有事件被归档")
}
// 块应已到达 L2,且身份不变。
//
// ★ 2026-10-22 更新:L2 与 L1 同构后,事件正文也各自成为文本块
// (Input 一块、Response 一块),所以这里不能再假设「文档只有 1 个块」。
// 要断言的是**媒体块原样迁移**,而不是块总数。
var found *document.Doc
for _, d := range docStore.RecentDocs(20) {
for _, b := range d.Blocks {
if b.ID == block.ID {
found = d
}
}
}
if found == nil {
t.Fatal("媒体块未随归档事件迁移到 L2")
}
var got *memory.MemoryBlock
for i := range found.Blocks {
if found.Blocks[i].ID == block.ID {
got = &found.Blocks[i]
}
}
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)
}
// 字节随块迁移(union)
if string(got.Data) != string(block.Data) {
t.Fatalf("块字节应原样迁移: %q vs %q", got.Data, block.Data)
}
// 同一块不能同时留在 L0(L1)。
for _, e := range rc.Recent(100) {
for _, b := range e.Blocks {
if b.ID == block.ID {
t.Fatalf("块仍留在 L1(同一块同时存在于两层): %+v", b)
}
}
}
}