fix(memory): 回填事件必须照常归档 —— 纠正 NoMemory 语义被错误扩大到存储层

用户指出: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 失败
This commit is contained in:
JianFeeeee
2026-10-22 20:37:05 +08:00
parent 41921c0b6d
commit 6bea130639
4 changed files with 108 additions and 51 deletions

View File

@ -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))

View File

@ -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 的相关性打分(否则会挤掉真正的新事件)。

View File

@ -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 {

View File

@ -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 != "" {