mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
Merge pull request #22 from JianFeeeee/fix/doc-query-self-excitation
fix(memory): 掐断 doc_query 的自激震荡 —— 工具输出与回填事件都不进记忆
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 {
|
||||
|
||||
150
internal/agent/core/selfexcite_test.go
Normal file
150
internal/agent/core/selfexcite_test.go
Normal file
@ -0,0 +1,150 @@
|
||||
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
|
||||
}
|
||||
@ -1054,6 +1054,7 @@ func (a *Agent) executeDocTool(tc agentAPI.ToolCall) string {
|
||||
Source: "cold_storage",
|
||||
Input: fmt.Sprintf("加载文档记忆: %s", query),
|
||||
Response: content,
|
||||
NoMemory: true, // 同上:回填不产生新记忆
|
||||
})
|
||||
n = 1
|
||||
}
|
||||
@ -1304,6 +1305,14 @@ func (a *Agent) restoreDocEvents(d *document.Doc) int {
|
||||
Source: ev.Source,
|
||||
Input: input,
|
||||
Response: response,
|
||||
// ★ 回填事件**不参与记忆计算、不再归档回 L2**。
|
||||
//
|
||||
// 它们本来就在记忆体系里(来自 L2,可能已蒸馏进 L3)。
|
||||
// 不标记的后果是自激震荡:
|
||||
// doc_query 从 L2 取出 → Prune 因相关而保留它们、
|
||||
// 把真正的新事件挤掉 → 下一轮 Prune 又归档回 L2 →
|
||||
// 再被 doc_query 召回 ⇒ 块在 L1↔L2 之间反复搬迁。
|
||||
NoMemory: true,
|
||||
ToolsUsed: append([]string(nil), ev.ToolsUsed...),
|
||||
ToolResults: func() []ToolResultItem {
|
||||
var out []ToolResultItem
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -77,6 +77,17 @@ type BuiltinToolDef struct {
|
||||
// 与 ToolDef.Timeout 同义(对外插件 / 内置插件两条路都要能声明)。
|
||||
// 留空 = 内核默认 60s。
|
||||
Timeout string
|
||||
// NoMemory 声明本工具的**输出**不参与记忆计算(向量化/关键词/蒸馏),
|
||||
// 但原文仍留在上下文的 ToolResults 里可读。
|
||||
//
|
||||
// 与 ToolDef.NoMemory 同义(对外插件经 proc 桥、内置工具经这里,
|
||||
// 两条路必须能声明同一件事 —— 否则内置工具的输出无法抑制)。
|
||||
//
|
||||
// ★ 典型用例:doc_query 的返回值里含被召回事件的正文片段。
|
||||
// 那些正文**已经在记忆体系里**(来自 L2/L3),若工具的返回文本再
|
||||
// 参与一次向量化与蒸馏,就等于把同一内容重新注入记忆 ——
|
||||
// 与「回填事件不再归档」是同一个自激回路的两个入口。
|
||||
NoMemory bool
|
||||
}
|
||||
|
||||
// ToSchema 转成下发给模型的 function schema。
|
||||
|
||||
Reference in New Issue
Block a user