Files
HomeAgent/internal/agent/core/selfexcite_test.go
JianFeeeee 6bea130639 fix(memory): 回填事件必须照常归档 —— 纠正 NoMemory 语义被错误扩大到存储层
用户指出:doc 召回部分需要回填记忆,因为 doc 召回后原文在 doc 层已经删除了。

## 我上一轮的错误(PR #22)

把「工具输出不进记忆」与「回填事件不归档」塞进了同一个 NoMemory 标记:

    doc_query → Consume(L2 副本删除)→ 回填 L1(标记 NoMemory)
              → L1 裁掉它 → Prune 跳过归档(错误实现)
              → 三层都不在 ⇒ 内容永久丢失

Consume 已经把 L2 副本删掉,**L1 是唯一副本**。裁掉时不归档 = 丢数据。
这不是自激防护,这是把数据丢掉当成防护。

## 正确区分(两件事不能共用一个标记)

| 对象 | 进计算层 | 进归档 | 理由 |
|---|---|---|---|
| doc_query 的**工具返回值**(正文摘录) | ❌ | ❌ | 它是事件的摘录副本,再向量化 = 重复注入 ⇒ 自激 |
| **回填的事件本身**(原文) | ❌ | ✅ **必须** | L2 副本已删,L1 是唯一副本;不归档即丢失 |

## 修法

ContextEvent.NoMemory 语义定为「**不参与计算,照常归档**」,
与 ToolDef.NoMemory 一致:只影响计算层,不影响存储层。

自激的入口在"重新计算向量"那一侧(textForVector 返回空),
不在"归档"这一侧 —— 删掉 Prune 里的跳过逻辑。

## 判据(改写了那条断言错误行为的判据)

TestSelfExcite_RestoredEventsNotReArchived 现在断言三件事:
1. 回填事件带 NoMemory 标记且未被向量化(不参与计算)
2. 前提成立:L2 里确实已无该事件(Consume 删掉了)
3. ★ 被裁掉后必须能落回 L2(否则内容永久丢失)

变异验证:
- Prune 重新跳过 NoMemory ⇒ 红,报「三层都不在,内容永久丢失」
- restoreDocEvents 不置 NoMemory ⇒ 红(会重复加权)

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
2026-10-22 20:37:34 +08:00

177 lines
5.7 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
// doc_query 的**自激震荡**判据。
//
// ## 回路长什么样(用户 2026-10-22 指出)
//
// doc_query
// ├─ ① 工具返回值含被召回事件的正文片段 → 作为 ToolResults 进 context
// │ → 参与向量化/关键词/蒸馏 → 同一内容被**重新注入**记忆
// ├─ ② 回填事件本身(带原 Source、完整 Blocks)进 L1
// │ → Prune 因相关而保留它们(挤掉新事件)
// │ → 下一轮 Prune 又把它们归档回 L2
// └─ ⇒ 下次 doc_query 再召回同一批:块在 L1↔L2 之间反复搬迁
//
// 两个入口都必须关掉:
// ① ToolDef.NoMemory —— doc_query 声明,工具输出不进计算层
// ② ContextEvent.NoMemory —— restoreDocEvents 置位,回填事件不再归档
import (
"strings"
"testing"
"time"
agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api"
"github.com/JianFeeeee/HologramAgent/internal/memory"
)
// ① doc_query 必须声明 NoMemory(工具输出不进记忆计算)。
//
// 变异:去掉 toolDefOptions{noMemory:true} ⇒ 红。
func TestSelfExcite_DocQueryDeclaresNoMemory(t *testing.T) {
// 工具定义层面:声明必须可查(不依赖工具表是否已构造)
if !noMemoryOf("doc_query") {
t.Error("doc_query 未声明 NoMemory —— 它的返回值含被召回事件的正文," +
"再参与向量化/蒸馏就是把同一内容重新注入记忆")
}
// 计算层层面:带 doc_query 输出的工具事件,其向量化文本不得含该输出
const sentinel = "这是被召回事件的正文片段哨兵"
evt := ContextEvent{
Timestamp: time.Now(),
Source: "agent",
Input: "查一下历史",
ToolResults: []ToolResultItem{
{Name: "doc_query", Output: "已召回:" + sentinel},
},
}
// stageHost 在测试环境为 nil:传 nil lookup,只走内置声明表那条路
text := textForVector(&evt, nil, nil)
if strings.Contains(text, sentinel) {
t.Errorf("doc_query 的输出进入了计算层(会被重新向量化/蒸馏):\n%s", text)
}
}
// ② 回填事件必须带 NoMemory(不参与计算),但**必须照常归档**(数据不丢)。
//
// ★ 这里曾经写反过:要求"不得被 Prune 再归档回 L2"。
//
// doc_query 的 Consume 已经把 L2 副本删掉,L1 是唯一副本 ——
// 不归档就等于三层都不在,内容永久丢失。
//
// 变异:
//
// · restoreDocEvents 不置 NoMemory ⇒ 它会参与计算(重复加权)
// · Prune 跳过 NoMemory 事件 ⇒ 内容丢失(本判据要抓这个)
func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) {
a := newToolTestAgent(t)
const marker = "自激测试:这条会被归档"
// 先归档一条事件到 L2
archiveEvents(t, a, []ContextEvent{{
Timestamp: time.Now().Add(-2 * time.Hour),
Source: "webui",
Input: marker,
Response: "然后被 doc_query 取回",
}})
// 召回(Consume 会把 L2 副本删掉)
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)
}
// ① 回填事件必须带 NoMemory(不参与计算)
restored := 0
for _, e := range a.context.Recent(0) {
if e.Source == "webui" && strings.Contains(e.Input, marker) {
restored++
if !e.NoMemory {
t.Error("回填事件没有 NoMemory 标记 —— 它会再被向量化/蒸馏,同一内容重复加权")
}
if len(e.Vector) > 0 {
t.Error("回填事件被向量化了(NoMemory 未生效)")
}
}
}
if restored == 0 {
t.Fatal("没有回填任何事件")
}
// ② L1 是唯一副本(L2 已被 Consume 删除)——确认这一点
for _, d := range a.docStore.RecentDocs(1000) {
if !d.HasEvents() {
continue
}
for _, ev := range d.Events {
in, _, _ := ev.InOut()
if strings.Contains(in, marker) {
t.Fatalf("L2 里仍有该事件(%s)—— 前提不成立,本判据失去意义", d.ID)
}
}
}
// ③ ★ 关键:裁掉它时**必须能落回 L2**,否则内容永久丢失
for i := 0; i < 14; i++ {
a.context.Append(ContextEvent{
Timestamp: time.Now(),
Source: "filler",
Input: "无关填充,用来触发 Prune",
Response: "好。",
})
}
a.context.Prune("当前输入", 2, a.docStore)
var foundInL2 bool
for _, d := range a.docStore.RecentDocs(1000) {
if !d.HasEvents() {
continue
}
for _, ev := range d.Events {
in, _, _ := ev.InOut()
if strings.Contains(in, marker) {
foundInL2 = true
}
}
}
if !foundInL2 {
t.Error("回填事件被裁掉后没有归档回 L2 —— Consume 已删除原副本," +
"L1 是唯一副本,不归档就是内容永久丢失(三层都不在)")
}
}
// ④ 回填事件不得参与 Prune 的相关性打分(否则会挤掉真正的新事件)。
func TestSelfExcite_RestoredEventsDontCrowdOutNewOnes(t *testing.T) {
a := newToolTestAgent(t)
archiveEvents(t, a, []ContextEvent{{
Timestamp: time.Now().Add(-3 * time.Hour),
Source: "webui",
Input: "拥挤测试的原始事件",
Response: "响应",
}})
a.executeDocTool(agentAPI.ToolCall{
Name: "doc_query",
Arguments: map[string]interface{}{"query": "拥挤测试", "top_k": float64(3)},
})
// 回填事件的向量必须为空(NoMemory → textForVector 返回 "")
for _, e := range a.context.Recent(0) {
if e.NoMemory && len(e.Vector) > 0 {
t.Errorf("回填事件仍被向量化(vector len=%d)—— 它会参与相关性打分并挤掉新事件",
len(e.Vector))
}
}
// 块仍在 L1(迁移不是丢弃)
var blocks int
for _, e := range a.context.Recent(0) {
blocks += len(e.Blocks)
}
_ = blocks
_ = memory.BlockText
}