mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
用户指出(2026-10-22):doc_query 的结果走 NoMemory 路径,否则会对记忆块
产生自激震荡。核实后确认**两个入口都开着**。
## 回路
doc_query
├─ ① 工具返回值含被召回事件的正文片段 → 作为 ToolResults 进 context
│ → 参与向量化/关键词/蒸馏 → 同一内容被**重新注入**记忆
├─ ② 回填事件本身(带原 Source、完整 Blocks)进 L1
│ → Prune 因相关而保留它们(挤掉真正的新事件)
│ → 下一轮 Prune 又把它们归档回 L2
└─ ⇒ 下次 doc_query 再召回同一批:块在 L1↔L2 之间反复搬迁
## ① 工具输出:doc_query 未声明 NoMemory
基础设施早就有(textForVector 里按 ToolDef.NoMemory 跳过工具输出),
但 doc_query 是**内置工具**,它的声明无处安放:
StageHost.ToolDef 只覆盖插件工具,内置工具由 executeToolCallInner 的
switch 分派,不经过注册。
修法:
- internal/sdk/tool.go:BuiltinToolDef 加 NoMemory 字段(与 ToolDef.NoMemory
同义,两条路必须能声明同一件事)
- internal/agent/core/tooldefs.go:declareNoMemoryTool + noMemoryOf,
由 init() 里**显式名单**登记 doc_query
- internal/agent/core/context.go:textForVector 同时查插件 ToolDef 与
内置声明表(原来只查前者)
★ 为什么在 init 显式登记、不靠 toolDefWith 构造时登记:
工具表是按需构造的(受 `if a.docStore != nil` 之类条件控制),
而 textForVector 可能在构造之前被调用 ⇒ 声明会"时有时无"。
实测踩到:构造前查 noMemoryOf("doc_query") 得到 false。
★ declareParallelTool 顺带改为**按字段合并**(原来整体覆盖):
同一工具可能同时声明 parallel 与 noMemory,覆盖会让后登记的那项
静默丢失 —— 声明丢一个是最难察觉的一类问题。
## ② 回填事件:加 ContextEvent.NoMemory
ContextEvent 新增 NoMemory 字段(此前只有 ToolDef/ChannelDef 有)。
restoreDocEvents 回填时置位(含存量降级路径),于是:
- textForVector 直接返回 "" ⇒ 不向量化、不参与关键词/蒸馏
- PruneWithProtected 归档前过滤掉它们 ⇒ 不再产生新的 L2 副本
语义与 ToolDef.NoMemory 一致:**原文仍在上下文可读**,只是不进计算、
不再产生新记忆。这是「回填是为了让模型读到原文」的本意。
## 判据(3 条,钉住两个入口 + 一个后果)
selfexcite_test.go:
- DocQueryDeclaresNoMemory:声明可查 + 工具输出不进 textForVector
- RestoredEventsNotReArchived:回填事件带 NoMemory 标记 + Prune 后
L2 里不出现含回填内容的新文档(真跑 Prune 验证回路)
- RestoredEventsDontCrowdOutNewOnes:回填事件不被向量化(否则会
参与相关性打分、挤掉新事件)
变异全部被抓住:
- Prune 不过滤 NoMemory → 「回填事件被 Prune 又归档回了 L2」
- restoreDocEvents 不置 NoMemory → 「没有 NoMemory 标记」+ 回路成立
- init 不登记 doc_query → 「未声明 NoMemory」
## 验证
- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- 公开 SDK(third_party/homeagent-sdk)未改动 —— 改的是 internal/sdk
151 lines
4.9 KiB
Go
151 lines
4.9 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。
|
||
//
|
||
// 变异:restoreDocEvents 不置 NoMemory ⇒ 红(Prune 会把它归档回 L2)。
|
||
func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) {
|
||
a := newToolTestAgent(t)
|
||
|
||
// 先归档一条事件到 L2
|
||
archiveEvents(t, a, []ContextEvent{{
|
||
Timestamp: time.Now().Add(-2 * time.Hour),
|
||
Source: "webui",
|
||
Input: "自激测试:这条会被归档",
|
||
Response: "然后被 doc_query 取回",
|
||
}})
|
||
|
||
// 召回
|
||
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, "自激测试") {
|
||
restored++
|
||
if !e.NoMemory {
|
||
t.Error("回填事件没有 NoMemory 标记 —— Prune 会把它再归档回 L2,形成自激回路")
|
||
}
|
||
}
|
||
}
|
||
if restored == 0 {
|
||
t.Fatal("没有回填任何事件")
|
||
}
|
||
|
||
// ③ 真实验证回路:再 Prune 一次。
|
||
//
|
||
// 关键不是"文档数不变"(filler 本身也会被归档),而是
|
||
// **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)
|
||
|
||
for _, d := range a.docStore.RecentDocs(1000) {
|
||
if !d.HasEvents() {
|
||
continue
|
||
}
|
||
for _, ev := range d.Events {
|
||
in, _, _ := ev.InOut()
|
||
if strings.Contains(in, "自激测试") {
|
||
t.Errorf("回填事件被 Prune 又归档回了 L2(doc=%s)—— 自激回路成立:\n"+
|
||
"下次 doc_query 会再召回同一批,块在 L1↔L2 之间反复搬迁", d.ID)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ④ 回填事件不得参与 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
|
||
}
|