mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27: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:
@ -387,6 +387,18 @@ type toolDefOptions struct {
|
||||
// 而 output_send__* 是**投递到子 pipeline** 的长任务,天然可能超过 60s
|
||||
// (实测两次 60s 超时)。留空 = 用内核默认 60s,行为不变。
|
||||
timeout string
|
||||
|
||||
// noMemory 声明该工具的**输出**不参与记忆计算(向量化/关键词/蒸馏),
|
||||
// 原文仍留在上下文可读。语义与 ToolDef.NoMemory 一致。
|
||||
//
|
||||
// ★ 注意:doc_query 的 noMemory **不写在这里**,而是由 init() 里的
|
||||
// 显式名单登记。原因:工具表是按需构造的(受 `if a.docStore != nil`
|
||||
// 之类条件控制),而 textForVector 可能在构造之前被调用 ——
|
||||
// 靠构造时登记会让声明"时有时无"。声明是静态事实,init 期定死。
|
||||
//
|
||||
// 本字段保留给"确实想跟着工具构造走"的场景(工具不存在就不需要
|
||||
// 抑制它的输出),与 init 名单二选一即可。
|
||||
noMemory bool
|
||||
}
|
||||
|
||||
// parallelOpts 是"可并发"的声明项。
|
||||
@ -422,6 +434,17 @@ func toolDefWith(name, description string, properties map[string]interface{}, re
|
||||
if opts.timeout != "" {
|
||||
registerBuiltinToolTimeout(name, opts.timeout)
|
||||
}
|
||||
// 声明项也要落在**包级聚合表**上,供 textForVector 查询。
|
||||
//
|
||||
// ⚠️ 不在 declare*Tool 里做、而在这里做,是因为有先例的教训:
|
||||
// 让 toolDefWith 自己登记 parallel 曾导致"聚合表是空的"——
|
||||
// 那些工具都在 `if a.knowledge != nil` 之类的条件分支里。
|
||||
// 但 noMemory 的情况不同:它必须在**工具真被构造出来时**才成立
|
||||
// (工具不在表里,也就没有输出要抑制)。两者语义一致:
|
||||
// 登记的是"这个工具的声明",构造时登记最准确。
|
||||
if opts.noMemory {
|
||||
declareNoMemoryTool(name)
|
||||
}
|
||||
return def.ToSchema()
|
||||
}
|
||||
|
||||
@ -451,6 +474,22 @@ func init() {
|
||||
} {
|
||||
declareParallelTool(name)
|
||||
}
|
||||
|
||||
// 输出不进记忆计算的内置工具。
|
||||
//
|
||||
// ★ 必须在这里显式登记,不能只靠 toolDefWith 构造时登记:
|
||||
// 工具表是**按需构造**的(buildToolDefs 每轮重建,且受
|
||||
// `if a.docStore != nil` 之类的条件分支控制),而 textForVector
|
||||
// 在工具被构造之前也可能被调用 ⇒ 声明会"时有时无"。
|
||||
// 声明是静态事实,就该在 init 期定死(与上面 parallel 同一理由)。
|
||||
//
|
||||
// 目前只有 doc_query:它的返回值含被召回事件的正文片段,
|
||||
// 而那些正文已在记忆体系里 —— 再参与向量化/蒸馏就是重复注入。
|
||||
for _, name := range []string{
|
||||
"doc_query",
|
||||
} {
|
||||
declareNoMemoryTool(name)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) buildToolDefs() []interface{} {
|
||||
@ -808,9 +847,35 @@ var builtinDefs = &builtinDefRegistry{byName: map[string]sdk.BuiltinToolDef{}}
|
||||
// 那些工具都在 `if a.knowledge != nil` 之类的条件分支里,测试环境根本不
|
||||
// 走进去 ⇒ 声明静默丢失,而工具表里它们明明带着 parallelOpts()。
|
||||
// 症状是"判据全绿但并发能力为零"—— 判据查的是同一张空表,自证。
|
||||
// declareNoMemoryTool 在**工具定义处**登记"输出不进记忆计算"声明。
|
||||
//
|
||||
// 与 declareParallelTool 同构:声明是静态的,在 init 期聚合一次,
|
||||
// 运行期只查表。落在工具自己的结构体上,内核里不出现任何工具名硬编码。
|
||||
func declareNoMemoryTool(name string) {
|
||||
builtinDefs.mu.Lock()
|
||||
d := builtinDefs.byName[name]
|
||||
d.Name = name
|
||||
d.NoMemory = true
|
||||
builtinDefs.byName[name] = d
|
||||
builtinDefs.mu.Unlock()
|
||||
}
|
||||
|
||||
// noMemoryOf 查询内置工具的输出是否不进记忆计算。
|
||||
// 未登记 ⇒ 参与记忆(保守:与 SDK 零值语义一致)。
|
||||
func noMemoryOf(name string) bool {
|
||||
builtinDefs.mu.RLock()
|
||||
defer builtinDefs.mu.RUnlock()
|
||||
return builtinDefs.byName[name].NoMemory
|
||||
}
|
||||
|
||||
func declareParallelTool(name string) {
|
||||
builtinDefs.mu.Lock()
|
||||
builtinDefs.byName[name] = sdk.BuiltinToolDef{Name: name, ParallelSafe: true}
|
||||
// ★ 按字段合并而非整体覆盖:同一工具可能同时声明 noMemory 与 parallel,
|
||||
// 覆盖会让后登记的那项静默丢失(声明丢一个是最难察觉的一类问题)。
|
||||
d := builtinDefs.byName[name]
|
||||
d.Name = name
|
||||
d.ParallelSafe = true
|
||||
builtinDefs.byName[name] = d
|
||||
builtinDefs.mu.Unlock()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user