From 6bea130639f7a839355f5d3dd0059d5cdf73699e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 20:37:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20=E5=9B=9E=E5=A1=AB=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E5=BF=85=E9=A1=BB=E7=85=A7=E5=B8=B8=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E7=BA=A0=E6=AD=A3=20NoMemory=20?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E8=A2=AB=E9=94=99=E8=AF=AF=E6=89=A9=E5=A4=A7?= =?UTF-8?q?=E5=88=B0=E5=AD=98=E5=82=A8=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户指出: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 失败 --- internal/agent/core/context.go | 47 ++++++++++------------ internal/agent/core/selfexcite_test.go | 54 +++++++++++++++++++------- internal/agent/core/toolcall.go | 32 ++++++++++----- internal/agent/core/tooldefs.go | 26 ++++++++++++- 4 files changed, 108 insertions(+), 51 deletions(-) diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index 3aa32f6c..8ab48792 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -35,18 +35,19 @@ type ContextEvent struct { ToolsUsed []string `json:"tools_used,omitempty"` ToolResults []ToolResultItem `json:"tool_results,omitempty"` - // NoMemory 标记这条事件**不参与记忆计算与再归档**。 + // NoMemory 标记这条事件**不参与记忆计算**(不向量化、不提关键词、不蒸馏), + // 但**照常参与归档**(离开 L1 时会写回 L2)。 // - // ★ 为 doc_query 的回填而设(2026-10-22)。回填是把 L2 的历史事件 - // 按原时间戳放回 L1 —— 它们**已经**在记忆体系里了。若不加标记: + // ★ 与 ToolDef.NoMemory 语义一致:只影响计算层,不影响存储层。 // - // ① 计算层:它们会被重新向量化、参与 Prune 打分 ⇒ 因为与当前输入 - // 相关而倾向被保留,挤掉真正的新事件; - // ② 归档层:Prune 会把它们**再次**归档回 L2 ⇒ 下次 doc_query - // 又召回同一批 ⇒ 块在 L1↔L2 之间反复搬迁,自激震荡。 + // ★★ 为什么不能顺手把归档也关掉(2026-10-22 踩过的坑): // - // 语义与 ToolDef.NoMemory / ChannelDef.NoMemory 一致: - // **原文保留在上下文里可读**,只是不进计算、不再产生新记忆。 + // doc_query 的 Consume 已经**从 L2 删除**了原文。若回填事件在 L1 + // 被裁掉时又不归档,它就**三层都不在**了 —— 内容被永久丢弃。 + // L1 是它最后的落脚点,必须能落回去。 + // + // 所以"回填事件不再归档"是错的。它确实该重新归档(那才是迁移闭环), + // 该抑制的只是"重新计算向量/再次进蒸馏"—— 那才是自激的入口。 NoMemory bool `json:"no_memory,omitempty"` // --- 原生多模态记忆 --- // 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。 @@ -231,7 +232,9 @@ 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 回填的历史事件):不进计算层。 - // 原文仍在上下文里可读,只是不参与向量化/关键词/蒸馏。 + // + // 为什么回填事件不参与计算:它们的内容刚刚在 L2 里被向量化过一次, + // 再算一次只会让"同一内容"在向量空间里被重复加权。但它们**照常归档**。 if evt.NoMemory { return "" } @@ -538,24 +541,14 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc archived := 0 if docStore != nil && len(archive) > 0 { - // ★ 回填事件(NoMemory)**不再归档回 L2**。 + // ★ 回填事件(NoMemory)**照常归档** —— 这是数据不丢的关键。 // - // 这是自激震荡的根: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) - } + // 曾经在这里跳过它们(理由是"防自激"),那是错的: + // doc_query 的 Consume 已经把 L2 副本删掉了,L1 是唯一副本。 + // 跳过归档 = 裁掉即丢失(L1、L2、L3 三层都不在)。 + // + // 自激的入口不在"归档"这一侧,而在"重新计算向量"那一侧 —— + // 那已经由 textForVector 里 `if evt.NoMemory { return "" }` 挡住。 } if docStore != nil && len(archive) > 0 { entries := make([]document.ContextEntry, len(archive)) diff --git a/internal/agent/core/selfexcite_test.go b/internal/agent/core/selfexcite_test.go index 8cfecd81..8f4d6f35 100644 --- a/internal/agent/core/selfexcite_test.go +++ b/internal/agent/core/selfexcite_test.go @@ -52,21 +52,30 @@ func TestSelfExcite_DocQueryDeclaresNoMemory(t *testing.T) { } } -// ② 回填事件必须带 NoMemory,且不得被 Prune 再次归档回 L2。 +// ② 回填事件必须带 NoMemory(不参与计算),但**必须照常归档**(数据不丢)。 // -// 变异:restoreDocEvents 不置 NoMemory ⇒ 红(Prune 会把它归档回 L2)。 +// ★ 这里曾经写反过:要求"不得被 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: "自激测试:这条会被归档", + Input: marker, Response: "然后被 doc_query 取回", }}) - // 召回 + // 召回(Consume 会把 L2 副本删掉) out := a.executeDocTool(agentAPI.ToolCall{ Name: "doc_query", Arguments: map[string]interface{}{"query": "自激测试 归档", "top_k": float64(3)}, @@ -75,13 +84,16 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) { t.Fatalf("未召回:%s", out) } - // 回填的事件必须带 NoMemory 标记 + // ① 回填事件必须带 NoMemory(不参与计算) restored := 0 for _, e := range a.context.Recent(0) { - if e.Source == "webui" && strings.Contains(e.Input, "自激测试") { + if e.Source == "webui" && strings.Contains(e.Input, marker) { restored++ if !e.NoMemory { - t.Error("回填事件没有 NoMemory 标记 —— Prune 会把它再归档回 L2,形成自激回路") + t.Error("回填事件没有 NoMemory 标记 —— 它会再被向量化/蒸馏,同一内容重复加权") + } + if len(e.Vector) > 0 { + t.Error("回填事件被向量化了(NoMemory 未生效)") } } } @@ -89,10 +101,20 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) { t.Fatal("没有回填任何事件") } - // ③ 真实验证回路:再 Prune 一次。 - // - // 关键不是"文档数不变"(filler 本身也会被归档),而是 - // **L2 里不得出现含回填事件内容的新文档**。 + // ② 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(), @@ -103,18 +125,22 @@ func TestSelfExcite_RestoredEventsNotReArchived(t *testing.T) { } 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, "自激测试") { - t.Errorf("回填事件被 Prune 又归档回了 L2(doc=%s)—— 自激回路成立:\n"+ - "下次 doc_query 会再召回同一批,块在 L1↔L2 之间反复搬迁", d.ID) + if strings.Contains(in, marker) { + foundInL2 = true } } } + if !foundInL2 { + t.Error("回填事件被裁掉后没有归档回 L2 —— Consume 已删除原副本," + + "L1 是唯一副本,不归档就是内容永久丢失(三层都不在)") + } } // ④ 回填事件不得参与 Prune 的相关性打分(否则会挤掉真正的新事件)。 diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index edf9eff7..28943eab 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -327,9 +327,21 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnS // 工具是动态注册的,"不存在"是常态而非异常(插件未加载/已卸载/崩溃)。 // 文案必须让模型知道该做什么,而不是含糊的"执行失败"—— // 后者会让模型反复重试同一个不存在的名字。 - return toolOutcome{Text: fmt.Sprintf("工具 %s 不存在或未注册:它可能属于未加载/已崩溃的插件。"+ - "先调 get_plugin_tools(\"\") 看当前可用工具,或 output_list_channels 看通道;"+ - "确认名称无误后再调用", tc.Name)} + // ★ 提示必须**先指向 skill**(2026-10-22)。 + // + // 按需插件(auto_start=false)的工具不在初始清单里,而它的 + // skill 正是"这个插件是什么、要怎么用、需要先启动什么"的唯一 + // 说明(SDK autostart.go 的 OnDemandNeedsSkill 就是这条契约)。 + // + // 此前这里只提 get_plugin_tools / output_list_channels, + // 一个字没提 skill —— 模型调用了、失败了,提示却不告诉它去 + // 看说明书,于是只能盲目试工具名。 + return toolOutcome{Text: fmt.Sprintf("工具 %s 不存在或未注册(它可能属于未加载的插件)。按这个顺序处理:\n"+ + "1. 先看【可用技能】里有没有相关的:有就先 skill_info(\"技能名\") 读全文 —— "+ + "skill 里写着该插件的正确用法、已知坑,以及需要先启动哪个插件;\n"+ + "2. 没有相关 skill 就调 get_plugin_tools(\"\") 查当前可用的插件与工具;\n"+ + "3. 通道相关的不确定时用 output_list_channels 看有哪些通道。\n"+ + "确认之后再调用,不要反复重试同一个名字。", tc.Name)} } return toolOutcome{Text: fmt.Sprintf("工具 %s 执行失败: %v", tc.Name, err)} } @@ -1305,13 +1317,15 @@ 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 之间反复搬迁。 + // 为什么要标记:它们的内容刚在 L2 里被向量化过一次, + // 再算一次会让同一内容在向量空间里被重复加权(自激)。 + // + // ★★ 为什么仍要归档(不能顺手关掉): + // Consume 已经把 L2 副本删了,L1 是**唯一副本**。 + // 裁掉时不归档 = 三层都不在 = 内容永久丢失。 + // (这个错犯过:曾用同一个 NoMemory 同时关掉计算与归档。) NoMemory: true, ToolsUsed: append([]string(nil), ev.ToolsUsed...), ToolResults: func() []ToolResultItem { diff --git a/internal/agent/core/tooldefs.go b/internal/agent/core/tooldefs.go index 0c76b8f2..d500b7e1 100644 --- a/internal/agent/core/tooldefs.go +++ b/internal/agent/core/tooldefs.go @@ -251,7 +251,19 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string { // 主动 skill_info 拉取全文按文档执行 if a.skillIndex != nil { if idx := a.skillIndex.SkillIndex(); idx != "" { - prompt = appendPromptBlock(prompt, "【可用技能】以下是已安装的原生技能。当用户请求与某技能描述匹配时,\n先用 skill_info(\"技能名\") 拉取全文,再严格按文档步骤执行:\n"+idx) + // ★ 措辞从"用户请求匹配某技能时"改成"**调用插件工具之前**"。 + // + // 旧措辞把人带偏成"只有用户点名要某个技能才去读",而 skill 的真实 + // 用途是**工具的使用说明**:里面写着参数怎么填、有哪些已知坑 + // (例如 QQ 的"拉私聊历史只传 user_id,不要传 group_id")、 + // 以及按需插件要先 plugin_start 拉起。 + // + // 不看 skill 就直接调工具,是这个仓库反复吃过亏的一类错误。 + prompt = appendPromptBlock(prompt, "【可用技能】这些是插件配套的**使用说明书**,不是可选装饰。\n"+ + "**规则**:准备调用某个插件的工具(或工具调用失败、参数不确定)时,**先看这里有没有同名技能的**"+ + "(如 file-tools / cmd-tools / qq-tools),用 skill_info(\"技能名\") 读全文,"+ + "再严格按其中步骤与坑位说明执行。技能里通常会写明:工具该怎么调、需要先启动哪个插件、常见报错怎么读。\n"+ + "用户明确要求用某技能时同样先 skill_info 读全文,不要凭名字猜做法。\n\n"+idx) } } @@ -328,6 +340,18 @@ func (a *Agent) buildToolCatalog() string { var sb strings.Builder sb.WriteString("\n\n【可用工具能力】\n") sb.WriteString("工具按插件分组注册。需要某个插件的具体工具时,调用 get_plugin_tools(\"{插件名}\") 获取该插件的完整工具定义(名称/参数/用途)。\n") + // ★ 说明"按需插件"这回事(2026-10-22)。 + // + // 插件有两种启动形态(SDK autostart.go): + // 常驻(auto_start 缺省/true)—— 工具一开始就在清单里 + // 按需(auto_start=false) —— 初始不在清单里,要靠 plugin_start 拉起 + // + // 不说清这件事的后果:模型看到"清单里没有某能力"就断定"没有这个能力", + // 于是绕道去用别的工具硬凑,或者干脆告诉用户做不到。 + sb.WriteString("★ **工具清单里没有 ≠ 没有这个能力**:部分插件是**按需加载**的," + + "它们的工具一开始不在清单里。需要时先 get_plugin_tools(\"插件名\") 看它有哪些工具、" + + "再 plugin_start(\"插件名\") 把它拉起来,工具就会出现。对应的 skill(同类名字或同名技能)" + + "会说明这个插件是常驻还是按需、以及拉起后怎么用。\n") for _, plg := range order { sb.WriteString(fmt.Sprintf("- %s (%d 个工具)", plg, byPlugin[plg])) if d := pluginDesc[plg]; d != "" {