mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
用户指出: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 失败
177 lines
5.7 KiB
Go
177 lines
5.7 KiB
Go
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
|
||
}
|