mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-28 05:13:27 +00:00
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:
@ -59,8 +59,6 @@ type Store struct {
|
||||
denseSpace vector.MultimodalEmbedder
|
||||
}
|
||||
|
||||
const maxSummaries = 10000
|
||||
|
||||
// NewStore 创建文档存储。tokenizer 由外层注入(如 jieba),核心不直接依赖分词库。
|
||||
func NewStore(dir string, tokenizer tfidf.Tokenizer) *Store {
|
||||
return &Store{
|
||||
@ -359,44 +357,63 @@ type DocHit struct {
|
||||
}
|
||||
|
||||
func (s *Store) QueryScored(text string, topK int) []DocHit {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
|
||||
// 主路径
|
||||
var hits []DocHit
|
||||
|
||||
// 主路径:稠密检索。只读,持读锁。
|
||||
s.mu.RLock()
|
||||
if s.denseSpace != nil && s.denseSpace.Loaded() {
|
||||
if qv, err := s.denseSpace.VectorizeDense(text); err == nil {
|
||||
results := s.denseSearchScored(qv, topK)
|
||||
for i := range results {
|
||||
if d, ok := s.docs[results[i].Doc.ID]; ok {
|
||||
d.AccessCount++
|
||||
d.LastAccess = time.Now()
|
||||
results[i].Doc = d
|
||||
}
|
||||
}
|
||||
return results
|
||||
hits = s.denseSearchScored(qv, topK)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
// Fallback(需要写锁来 ensureTFIDF)
|
||||
s.mu.RUnlock()
|
||||
s.mu.Lock()
|
||||
s.ensureTFIDF()
|
||||
s.mu.Unlock()
|
||||
s.mu.RLock()
|
||||
if hits == nil {
|
||||
s.mu.Lock()
|
||||
s.ensureTFIDF()
|
||||
results := s.tfidfIdx.Search(text, topK)
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.ID]; ok {
|
||||
hits = append(hits, DocHit{Doc: d, Score: r.Score})
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
results := s.tfidfIdx.Search(text, topK)
|
||||
var out []DocHit
|
||||
for _, r := range results {
|
||||
if d, ok := s.docs[r.ID]; ok {
|
||||
// 访问计数是**写**:必须离开读锁后再取写锁更新。
|
||||
//
|
||||
// 此前在 RLock 下直接 `d.AccessCount++` / `d.LastAccess = time.Now()`,
|
||||
// 与 FindColdDocs / flush 的读并发构成数据竞争(-race 实测),且不置脏 ——
|
||||
// 计数只在内存里涨,优雅关停也不落盘,FindColdDocs 的「≤2 次访问」
|
||||
// 冷度判据跨重启失真。
|
||||
s.markAccess(hits)
|
||||
return hits
|
||||
}
|
||||
|
||||
// markAccess 记录一次检索命中:累加访问计数、刷新最后访问时间并置脏等待落盘。
|
||||
// 调用方不得持有 s.mu。
|
||||
func (s *Store) markAccess(hits []DocHit) {
|
||||
if len(hits) == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
for _, h := range hits {
|
||||
if h.Doc == nil {
|
||||
continue
|
||||
}
|
||||
if d, ok := s.docs[h.Doc.ID]; ok {
|
||||
d.AccessCount++
|
||||
d.LastAccess = time.Now()
|
||||
out = append(out, DocHit{Doc: d, Score: r.Score})
|
||||
d.LastAccess = now
|
||||
}
|
||||
}
|
||||
return out
|
||||
s.dirty = true
|
||||
}
|
||||
|
||||
func (s *Store) denseSearchScored(queryVec []float64, topK int) []DocHit {
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -652,3 +654,55 @@ func TestStopFlushesDirtyDocs(t *testing.T) {
|
||||
t.Fatalf("Stop 未落盘: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryScoredConcurrentAndDirty 锁死两点:
|
||||
// 1. QueryScored 更新访问计数必须离开读锁(旧实现在 RLock 下写,-race 会报);
|
||||
// 2. 更新后必须置脏,否则优雅关停也不落盘、FindColdDocs 的冷度判据跨重启失真。
|
||||
func TestQueryScoredConcurrentAndDirty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s := NewStore(dir, memory.TokenizeWords)
|
||||
if err := s.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Stop()
|
||||
|
||||
for i := 0; i < 8; i++ {
|
||||
// 文档必须互相区分:若 8 篇文本完全相同,查询词出现在每一篇里,
|
||||
// IDF = log(N/df) = 0,词向量全零、检索恒为空,测试就成了假阴性。
|
||||
summary, content := "无关的天气与散步记录", "今天适合出门"
|
||||
if i < 3 {
|
||||
summary, content = "并发检索目标", "并发内容"
|
||||
}
|
||||
if err := s.Insert(&Doc{ID: fmt.Sprintf("d%d", i), Summary: summary, Content: content}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// 清掉 Insert 置的脏位,验证 QueryScored 自己会置脏。
|
||||
s.mu.Lock()
|
||||
s.dirty = false
|
||||
s.mu.Unlock()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < 8; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for k := 0; k < 30; k++ {
|
||||
s.QueryScored("并发检索目标", 5)
|
||||
s.FindColdDocs(72*time.Hour, 2)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
s.mu.RLock()
|
||||
dirty := s.dirty
|
||||
access := s.docs["d0"].AccessCount
|
||||
s.mu.RUnlock()
|
||||
if access <= 1 {
|
||||
t.Errorf("AccessCount 未随检索累加: %d", access)
|
||||
}
|
||||
if !dirty {
|
||||
t.Error("QueryScored 更新访问计数后未置脏")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user