fix(memory): 记忆层启动接线/并发/落盘一致性整备

按设计方案整顿记忆系统,收敛一批"单测照不出、只在长跑生产里暴露"的缺陷:

- 启动接线:initMemoryStack 残留 `defer distiller.Stop()`,规则蒸馏
  10min 心跳启动即死。改为由调用点 cleanup 停机,并补 Stopped() 探针 +
  TestInitMemoryStackKeepsDistillerRunning / TestStartKeepsLoopRunningUntilStop。
- L0 相关性上下文:SetDenseSpace 注入稠密空间时回填已有事件的稠密向量,
  否则旧事件走稀疏余弦、新事件走稠密余弦,同一次 Prune 里两种尺度混排。
- 文档检索:QueryScored 访问计数从读锁内写移出(-race 竞争),更新后置脏,
  优雅关停可落盘、FindColdDocs 冷度判据跨重启不再失真。
- 蒸馏管线:只 flush 未落盘记录(persisted 标记)、蒸馏成功后从 raw 文件
  删除对应行、原子写文件,修重启重复蒸馏导致 mention_count 膨胀。
- 图库:全量 Recall 加实体上限(内部整备路径,防大图整表进内存);
  ClearSentenceID 补写锁;Commit/upsertEntity 计数语义注释澄清。
- 索引器:recalled 去重集加 FIFO 上限,防长跑进程自动注入越来越沉默。
- 文档/媒体注释修正;README 记忆层流程对齐跨模态召回。

验证:go build ./...、go vet、go test -race
./internal/memory/... ./internal/agent/core/... ./cmd/homed/... 全绿。
This commit is contained in:
JianFeeeee
2026-09-15 06:32:36 +08:00
parent 3a780384d0
commit 6afe361804
12 changed files with 413 additions and 61 deletions

View File

@ -3,6 +3,7 @@ package core
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sort"
@ -68,10 +69,38 @@ func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *Rele
// SetDenseSpace 注入稠密多模态向量空间。配置后 L0 相关性裁剪可用稠密向量
// 余弦(与媒体检索、文档检索共享同一空间),未配置时退化到稀疏词向量。
//
// 注入时**回填已有事件**的稠密向量。为什么必须回填NewRelevanceContext 先
// load()、再 SetDenseSpace载入时 c.denseSpace 还是 nil旧事件只算了稀疏
// 向量若这里只赋值不回填Prune 里旧事件因 DenseFP 为空、长度不符而全部
// 走稀疏余弦,新事件走稠密余弦 —— 同一次排序里两种尺度混排,谁留下谁归档
// 取决于事件新旧而非相关性。对齐 DocStore.BuildDenseIndex 的做法。
//
// 注意 DenseVec/DenseFP 刻意不持久化json:"-"):这是每次启动一次性重算的
// 缓存,不落盘,因此这里也不需要 Save。
func (c *RelevanceContext) SetDenseSpace(ds vector.MultimodalEmbedder) {
c.mu.Lock()
defer c.mu.Unlock()
c.denseSpace = ds
if ds == nil || !ds.Loaded() {
return
}
fp := ds.Fingerprint()
dim := ds.Dim()
filled := 0
for _, evt := range c.events {
if evt == nil {
continue
}
if evt.DenseFP == fp && len(evt.DenseVec) == dim {
continue
}
c.computeVector(evt)
filled++
}
if filled > 0 {
log.Printf("[agent] context dense backfill: %d events", filled)
}
}
func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) {

View File

@ -2,6 +2,7 @@ package core
import (
"os"
"path/filepath"
"testing"
"time"
@ -237,6 +238,41 @@ func containsStr(s, substr string) bool {
return false
}
// TestSetDenseSpaceBackfillsExistingEvents 锁死 L0 稠密回填:
//
// NewRelevanceContext 先 load()(此时 denseSpace 仍为 nil旧事件只算了稀疏
// 向量SetDenseSpace 才注入稠密空间。若不回填已有事件,它们的 DenseFP
// 为空、DenseVec 长度不符Prune 里旧事件走稀疏余弦、新事件走稠密余弦——
// 同一次排序里混排两种尺度,谁留下只取决于事件新旧。
func TestSetDenseSpaceBackfillsExistingEvents(t *testing.T) {
path := filepath.Join(t.TempDir(), "context.json")
// run1写入事件并落盘不注入稠密空间
c1 := NewRelevanceContext(path, memory.NewStaticEmbedder(""))
c1.Append(ContextEvent{Timestamp: time.Now(), Source: "user", Input: "昨天的决定"})
c1.Append(ContextEvent{Timestamp: time.Now(), Source: "agent", Response: "记为待办"})
if err := c1.Save(); err != nil {
t.Fatal(err)
}
// run2模拟重启——load() 发生在 SetDenseSpace 之前。
c2 := NewRelevanceContext(path, memory.NewStaticEmbedder(""))
if c2.Len() == 0 {
t.Fatal("重启后未读回任何事件")
}
c2.SetDenseSpace(fakeSpace{})
events := c2.Recent(c2.Len())
if len(events) == 0 {
t.Fatal("no events")
}
for i, e := range events {
if e.DenseFP != "fake-space" || len(e.DenseVec) != 2 {
t.Errorf("event %d 未回填稠密向量: fp=%q len=%d", i, e.DenseFP, len(e.DenseVec))
}
}
}
func splitLines(s string) []string {
var lines []string
start := 0