mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
上一轮(memory-blockflow-repair-plan)修好了 L1↔L2 结构,但功能不可用。
用生产数据实测:三个互不相关查询分数全挤在 0.75~0.79,区分度≈0,
命中内容与查询无关。本次修四层。
## ① 向量输入:Summary + Content → 事件正文
denseFor 原用 `VectorizeDense(doc.Summary + " " + doc.Content)`,而 Summary
由 summarizeEntries → memory.ExtractKeywords → TokenizeContentWords → jieba.Tag()
生成。同一来源的摘要逐字同质:
来自 1 个来源的 1 条对话 (src0) 涉及: 用户, 提问
来自 1 个来源的 1 条对话 (src1) 涉及: 用户, 提问
真实 chineseclip 实测(4 篇主题完全不同的短正文):两两余弦 0.85~0.95。
这与设计矛盾 —— ARCHITECTURE 写的是「统一多模态空间(主)」,而 Summary
是主路径的输入,却由最末一级的词法分词产出。
改为 documentTextForVector(doc):按事件顺序拼原文(块里的正文)。
存量无 Events 的文档退回 Content。
★ 媒体不单独生成摘要:媒体块与文本块在同一事件内
(Doc.Events[i].Blocks),事件正文进入向量即覆盖其语义。
为媒体单独写描述正是 CAS 时代「描述式索引」的错误,v1.2.0 已拆除。
「按图搜图」是视觉路(RecallBlocks)的职责,已有独立通路。
## ② doc 层移除 jieba 语义生成
- summarizeEntries:jieba 词袋 → 取首条事件正文首行(真实内容,可读)
- 删除 extractTags / extractEntities(纯 jieba 词袋)
- 删除 Doc.Tags / Doc.Entities(前者唯一消费点是 doc_query 返回的
「标签: map, content」噪声行;后者零消费点)
- doc_commit 删除 tags 参数(无处可存,留着会骗模型)
★ TF-IDF 回退路径**保留**(Store.tfidfEmb/tfidfIdx/ensureTFIDF/Reindex):
它是「稠密空间没加载」时的兜底,按 ARCHITECTURE「统一多模态空间(主)
→ 词嵌入 → TF-IDF(回退)」必须保留。但它不能是主路径 —— 一旦稠密空间
可用,jieba 就不参与。
## ③ doc_query 返回真实内容
旧返回:「已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok)」
——全 jieba 词袋,模型无法判断有没有用(这是它几乎不调用 doc_query 的直接原因)。
新返回:
已召回 3 篇历史记忆,原文已按各自**原始时间戳**插回对话时序(不是当前对话):
[1] 2026-06-07 08:09 · webui · 2 条事件 · 含 1 份媒体
配色方案怎么定?
结论:紫蓝红三色带按六比三比一…
共回填 N 条事件。要引用其中的细节,直接看对话时序里对应时间点即可。
每条给出「时间 · 来源 · 事件数 · 媒体数」+ 真实正文前几行。
## ④ 提示词:agent 终于能理解三层记忆
- doc_query 描述:一句话 → 覆盖「L2 是什么 / 何时用 / 调用后原文回填到时序 /
与 memory_recall 分工 / 消费式副作用」
- doc_commit 描述:说清「适合写整段过程结论,不适合写单个事实」
- 系统提示词新增【三层记忆】段(内核运行时块):L1 对话时序 / L2 历史记忆 /
L3 图记忆各自是什么、怎么取、如何选择
- 预填充文案(crossModalMarkdown):统计描述 → 时间+来源+正文开头,
并在块尾明确引导「要完整原文用 doc_query」
## ⑤ 向量算法版本(否则修复对存量无效)
实测踩到的坑:改完 denseFor 后,1055 篇存量文档分数纹丝不动(0.78/0.79/0.76,
区分度 0.003)—— 因为 DenseFP 只记「哪个向量空间」,不记「用什么文本算的」,
指纹不变 ⇒ 全部命中跳过条件 ⇒ 永不重算。
修法:DenseFP = 空间指纹 + "|" + docVectorAlgoVersion(v2)。
算法一变,存量自动重算一次。
## 判据(每条都有变异自证)
document/retrieval_quality_test.go(6 条):
- Top1HitsCorrectDoc / DistinctQueriesHitDistinctDocs / ScoreGapIsSignificant
- VectorInputIsBodyNotSummary(正文哨兵必须进向量)
- SummaryNoiseMustNotPolluteVector(主题不同的短正文,两两余弦 <0.80)
- AlgoVersionChangeForcesRecompute(换算法必须触发存量重算)
core/recalldoc_test.go(+2):返回值必须含时间/来源/真实正文/时序说明
core/prompt_memory_test.go(5 条):工具描述五项覆盖、doc_commit 范围、
三层记忆段落、tags 参数已删、预填充文案含内容与 doc_query 引导
变异全部被抓住(截断落库 / Summary+Content / 去掉算法版本 / 旧预填充文案 /
一句话工具描述 / 删三层记忆段)。
## 验证
- go build ./... 通过;go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- go test -race ./internal/memory/document/ ./internal/agent/core/:通过
- 真实数据(chineseclip 512 维):
· 旧格式存量(1055 篇,无 Events):4/5 → 仍是旧分数,等自然冲刷
· 新格式文档(走 ContextToDoc):4/5 top1 命中正确,
区分度最高 0.18(旧实现 0.003)
357 lines
11 KiB
Go
357 lines
11 KiB
Go
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)
|
||
}
|
||
|
||
// ⑥ doc_query 的返回值必须**带真实内容**,不能只有统计描述。
|
||
//
|
||
// 断点史(生产日志实测):旧返回值是
|
||
//
|
||
// 已加载 3 篇文档记忆: #1(来自 1 个来源的 1 条对话 (agent) 涉及: ok), #2(...)
|
||
//
|
||
// 全 jieba 词袋,零信息量 ⇒ agent 无法判断这笔召回有没有用 ⇒ 几乎不再调用。
|
||
//
|
||
// 本判据要求返回值同时具备三件事:
|
||
// 1. 明确说明原文去了哪里("插回对话时序")
|
||
// 2. 每条给出时间 + 来源(可判断"这是哪次的事")
|
||
// 3. 每条给出真实正文片段(可判断"这是不是我要的")
|
||
func TestRecallByDoc_ReturnValueCarriesRealContent(t *testing.T) {
|
||
a := newToolTestAgent(t)
|
||
|
||
const sentinel = "紫蓝红三色带按六比三比一"
|
||
archiveEvents(t, a, []ContextEvent{{
|
||
Timestamp: time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC),
|
||
Source: "webui",
|
||
Input: "配色方案怎么定?",
|
||
Response: "结论:" + sentinel + ",主色紫、辅色蓝、点缀红。",
|
||
}})
|
||
|
||
out := a.executeDocTool(agentAPI.ToolCall{
|
||
Name: "doc_query",
|
||
Arguments: map[string]interface{}{"query": "配色方案 紫蓝红", "top_k": float64(3)},
|
||
})
|
||
|
||
// 1) 必须说明原文去了时序
|
||
if !strings.Contains(out, "时序") {
|
||
t.Errorf("返回值没说原文去了哪里:\n%s", out)
|
||
}
|
||
// 2) 必须有时间(年月日或月-日)
|
||
if !strings.Contains(out, "2026-06-07") && !strings.Contains(out, "06-07") {
|
||
t.Errorf("返回值没有时间信息:\n%s", out)
|
||
}
|
||
// 3) 必须有来源
|
||
if !strings.Contains(out, "webui") {
|
||
t.Errorf("返回值没有来源信息:\n%s", out)
|
||
}
|
||
// 4) ★ 必须有真实正文(否则模型无法判断相关性)
|
||
if !strings.Contains(out, "六比三比一") {
|
||
t.Errorf("返回值不含真实正文片段(模型无法判断相关性):\n%s", out)
|
||
}
|
||
// 5) 不得回退成 jieba 词袋的模板摘要
|
||
if strings.Contains(out, "涉及:") {
|
||
t.Errorf("返回值仍是 jieba 词袋模板摘要:\n%s", out)
|
||
}
|
||
}
|
||
|
||
// ⑦ 旧格式文档的召回也要给出可判断的信息(降级路径不能退化成无内容)。
|
||
func TestRecallByDoc_LegacyReturnAlsoCarriesContent(t *testing.T) {
|
||
a := newToolTestAgent(t)
|
||
if err := a.docStore.Insert(&document.Doc{
|
||
Summary: "旧格式摘要",
|
||
Content: "[10:00] legacy: 旧格式文档里的真实正文在这里",
|
||
Source: "context_archived",
|
||
Meta: map[string]string{"content_hash": "legacy-2"},
|
||
}); 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.Errorf("存量文档召回没有给出正文:\n%s", out)
|
||
}
|
||
}
|