mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 09:17:31 +00:00
fix(memory): 掐断 doc_query 的自激震荡 —— 工具输出与回填事件都不进记忆
用户指出(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
This commit is contained in:
@ -34,6 +34,20 @@ type ContextEvent struct {
|
||||
Response string `json:"response,omitempty"`
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
|
||||
// NoMemory 标记这条事件**不参与记忆计算与再归档**。
|
||||
//
|
||||
// ★ 为 doc_query 的回填而设(2026-10-22)。回填是把 L2 的历史事件
|
||||
// 按原时间戳放回 L1 —— 它们**已经**在记忆体系里了。若不加标记:
|
||||
//
|
||||
// ① 计算层:它们会被重新向量化、参与 Prune 打分 ⇒ 因为与当前输入
|
||||
// 相关而倾向被保留,挤掉真正的新事件;
|
||||
// ② 归档层:Prune 会把它们**再次**归档回 L2 ⇒ 下次 doc_query
|
||||
// 又召回同一批 ⇒ 块在 L1↔L2 之间反复搬迁,自激震荡。
|
||||
//
|
||||
// 语义与 ToolDef.NoMemory / ChannelDef.NoMemory 一致:
|
||||
// **原文保留在上下文里可读**,只是不进计算、不再产生新记忆。
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
// --- 原生多模态记忆 ---
|
||||
// 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。
|
||||
Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio)
|
||||
@ -216,6 +230,11 @@ func (c *RelevanceContext) load() {
|
||||
}
|
||||
|
||||
func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef, channelDefLookup func(name string) (sdk.ChannelDef, bool)) string {
|
||||
// 事件自带 NoMemory(doc_query 回填的历史事件):不进计算层。
|
||||
// 原文仍在上下文里可读,只是不参与向量化/关键词/蒸馏。
|
||||
if evt.NoMemory {
|
||||
return ""
|
||||
}
|
||||
var text string
|
||||
switch {
|
||||
case evt.Source == "agent" && evt.Response != "":
|
||||
@ -237,11 +256,23 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD
|
||||
}
|
||||
|
||||
// 计算层:附加工具输出,NoMemory 跳过,其余经 Cleaner 过滤
|
||||
if toolDefLookup != nil {
|
||||
{
|
||||
noMemory := make(map[string]bool)
|
||||
for _, tr := range evt.ToolResults {
|
||||
def := toolDefLookup(tr.Name)
|
||||
if def != nil && def.NoMemory {
|
||||
// ① 插件工具(含 proc 桥):走 StageHost 的 ToolDef 声明
|
||||
if toolDefLookup != nil {
|
||||
if def := toolDefLookup(tr.Name); def != nil && def.NoMemory {
|
||||
noMemory[tr.Name] = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
// ② 内置工具(core 自己的 switch 分派):走包级声明表。
|
||||
//
|
||||
// ★ doc_query 属于这一类 —— 它的返回值含被召回事件的正文片段,
|
||||
// 而那些正文已在记忆体系里(来自 L2/L3)。若返回值再参与向量化
|
||||
// 与蒸馏,就是把同一内容重新注入记忆,与「回填事件不再归档」
|
||||
// 构成同一个自激回路的两个入口。
|
||||
if noMemoryOf(tr.Name) {
|
||||
noMemory[tr.Name] = true
|
||||
}
|
||||
}
|
||||
@ -250,9 +281,10 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD
|
||||
continue
|
||||
}
|
||||
cleaned := tr.Output
|
||||
def := toolDefLookup(tr.Name)
|
||||
if def != nil && def.Cleaner != nil {
|
||||
cleaned = def.Cleaner(cleaned)
|
||||
if toolDefLookup != nil {
|
||||
if def := toolDefLookup(tr.Name); def != nil && def.Cleaner != nil {
|
||||
cleaned = def.Cleaner(cleaned)
|
||||
}
|
||||
}
|
||||
text += " " + cleaned
|
||||
}
|
||||
@ -505,6 +537,26 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc
|
||||
})
|
||||
|
||||
archived := 0
|
||||
if docStore != nil && len(archive) > 0 {
|
||||
// ★ 回填事件(NoMemory)**不再归档回 L2**。
|
||||
//
|
||||
// 这是自激震荡的根:doc_query 把事件从 L2 取到 L1,Prune 又把它们
|
||||
// 归档回 L2 —— 下一轮 doc_query 再召回同一批,块在两层间反复搬迁。
|
||||
// 它们本来就在记忆体系里(来源可能是 L2 或 L3),无需重新落库。
|
||||
filtered := archive[:0]
|
||||
skippedNoMemory := 0
|
||||
for _, s := range archive {
|
||||
if s.event != nil && s.event.NoMemory {
|
||||
skippedNoMemory++
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
archive = filtered
|
||||
if skippedNoMemory > 0 {
|
||||
log.Printf("[agent] prune: 跳过 %d 条回填事件(NoMemory,它们在记忆体系里已有归属)", skippedNoMemory)
|
||||
}
|
||||
}
|
||||
if docStore != nil && len(archive) > 0 {
|
||||
entries := make([]document.ContextEntry, len(archive))
|
||||
for i, s := range archive {
|
||||
|
||||
Reference in New Issue
Block a user