fix(memory,loop,capacity): 打通记忆链三条空壳(P0-3 / P0-4 / P0-17,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- P0-3 持久向量索引**不再空壳**:Store.Add 落库后真做 Embed+Add(失败只记日志、绝不让记忆写入失败);
  NewRetriever 拿到 WithVectorIndex 时**自动接上写入路径**(无需改组合根);索引无命中 → WARN + **回退内存余弦**;
  新增每进程一次的**回填**(索引确实为空时把已落库条目向量化,分批 64、上限 512)
- P0-4 工作记忆**按会话接线**:回合落定时写入(owner 会话 + 带会话 id 的窗口,LRU 上限 64),
  组装上下文时按会话读取 ⇒ 前导块真的出现;原单窗口 API 语义不变
- P0-17 L4 学习回环**真回写**:新增 capacity.Judge(只跑判定、不施人格覆盖/不发事件/不改状态);
  loop 在回合落定"只写 owner 会话 + 跳过子代理分支",写 ScopeUser(L4 的读域)+ 结构化溯源
- 顺带红线加固:意图记录是主人**原话**的机器可读形态 ⇒ **绝不注入群聊/陌生人块**(design/03 §5)
- 复核:三条各自的包 build/vet/test 绿;「真生效」断言含生产构造路径(vector.New + 真实 SQLite 后端 +
  n-gram embedder)、跑完一轮窗口非空、L4 回写后能唤醒一个自身无强信号的表达;
  **5 条变异反证**(其中两条专门打「受限会话零写入」与「群聊不泄露主人原话」)均已还原
- 未做(登记):Index.Search 无 ID 过滤(客户端按候选集过滤,极端情况走回退);每条记忆一次 Embed
This commit is contained in:
AskaEth committed 2026-10-04 14:20:22 +08:00
1 parent 983bc49218
commit fbad02899a
10 files changed
+1020 -81

No files matched your search

+98 -32
View File
@@ -3,11 +3,13 @@ package memory
import (
"context"
"fmt"
"math"
"log/slog"
"sort"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
@@ -58,7 +60,7 @@ type Result struct {
// 加共享的用户/项目作用域(design §3/§4,见 visibleCandidates)——
// 按相关性(关键词,以及配置了 embedder 或向量索引时的
// 语义向量)加权衰减(decay_weight × 时间衰减)排序后返回
// 前 K 条。它是无状态的、并发安全;排名在
// 前 K 条。它并发安全(索引装配状态与一次性回填有锁保护);排名在
// 每次调用时从存储重算,因此结果始终反映持久化的
// 状态。
type Retriever struct {
@@ -69,6 +71,14 @@ type Retriever struct {
keywordWeight float64
semanticWeight float64
recencyHalfLife time.Duration
log *slog.Logger
// seedMu 保护一次性索引回填状态(seeded 与 fallbackWarned):
// 回填每进程最多一次,而语义回退的告警也只报一次,避免每个
// Retrieve 都写日志。
seedMu sync.Mutex
seeded bool
fallbackWarned bool
}
// RetrieverOption 定制由 NewRetriever 构建的 Retriever。
@@ -78,19 +88,36 @@ type RetrieverOption func(*Retriever)
// 为 nil(默认)时,检索退化为仅关键词匹配
// 加衰减。若同时经 WithVectorIndex 接入了向量索引,则
// 索引优先:它是持久化语义路径(design §3
// 可选增强),此时 WithEmbedder 改为即时计算相似度。
// 可选增强)。索引没有作答时(索引空、无候选命中或索引报错)
// 语义分回退到此内存路径,而绝不因「接了索引」就交出一片
// 空语义分。
func WithEmbedder(e embedding.Embedder) RetrieverOption {
return func(r *Retriever) {
r.embedder = e
}
}
// WithRetrieverLogger 设置检索器的 logger,使语义路径的降级
// (索引报错、回退内存余弦)可被发现(AGENTS §10 日志必须标来源)。
// 未设置时默认用该 store 的 logger(来源 "memory"),因此没有
// 额外接线的调用方也不会静默降级。
func WithRetrieverLogger(log *slog.Logger) RetrieverOption {
return func(r *Retriever) {
r.log = logger.WithSource(log, "memory/retriever")
}
}
// WithVectorIndex 把持久化的 vector.Index 接入检索。设置后,
// 语义相关性由 idx.Search 在已存向量上作答,而非
// 内存内向量化,因此配置的 pgvector/SQLite 向量存储被
// 真正启用(design §3「真正启用向量检索」)。为 nil(默认)时,
// 除非设置了 WithEmbedder,否则语义路径跳过。调用方负责
// 让索引与已存条目保持同步(写入时 embed + add)。
// 除非设置了 WithEmbedder,否则语义路径跳过。
//
// 它同时把索引接到该 store 的**写入路径**(见 Store.indexItem),使
// 「写入时 embed + add」不再是调用方可能漏做的一步:条目写进
// memory_items 的同时也写进索引。索引中只有全局向量,检索侧
// 只认本次候选集内的命中,并对空索引做一次性回填(见
// Retriever.ensureIndexSeeded)。
func WithVectorIndex(idx vector.Index) RetrieverOption {
return func(r *Retriever) {
r.vindex = idx
@@ -138,19 +165,39 @@ func WithRecencyHalfLife(days float64) RetrieverOption {
// NewRetriever 在 store 之上构建 Retriever。nil store 非法,会在
// 调用时 panic;调用方应传入非 nil 的 store。
//
// 接入向量索引(WithVectorIndex)时,它随即将该索引接到 store 的
// 写入路径,使此后每条落库的记忆都被向量化并写入索引 —— 索引与
// 条目因此不会只同步一半(审计 P0-3:装配了索引却没有任何
// Index.Add 调用,语义检索恒空)。logger 未显式给出时沿用 store 的
// logger,使降级可见。
func NewRetriever(store *Store, opts ...RetrieverOption) *Retriever {
r := &Retriever{
store: store,
keywordWeight: defaultKeywordWeight,
semanticWeight: defaultSemanticWeight,
recencyHalfLife: time.Duration(defaultRecencyHalfLifeDays * float64(hoursPerDay*time.Hour)),
log: retrieverLog(store),
}
for _, opt := range opts {
opt(r)
}
if store != nil {
store.attachVectorIndex(r.vindex)
}
return r
}
// retrieverLog 返回检索器默认使用的 logger:store 自带的那一个
// (NewStore 已把它标为来源 "memory",且绝不为 nil),store 缺失时
// 用一个不回显的丢弃 logger,避免检索路径出现无来源裸日志。
func retrieverLog(store *Store) *slog.Logger {
if store == nil {
return logger.WithSource(discardLogger(nil), "memory")
}
return store.log
}
// Retrieve 为 personaID 返回最匹配 query 的最多 topK 条,按
// 相关性(关键词 + 可选语义)加权衰减(decay_weight ×
// 时间衰减)排序。候选集是 design/03 §3/§4 的可见域并集:该角色自己的条目
@@ -281,14 +328,24 @@ func (r *Retriever) keywordScores(items []MemoryItem, query string) map[string]f
// semanticScores 计算每个条目对 query 的语义相关性。接入了向量
// 索引(WithVectorIndex)时,它经 idx.Search 从持久化索引
// 作答 —— 真正的向量路径(design §3)。否则,在配置了 embedder
// 时,它在内存内对 query 与候选做向量化并使用夹取的余弦
// 相似度。两者都没有时返回空 map,因此语义相关性
// 作答 —— 真正的向量路径(design §3)。索引没有给出任何候选命中时
// (索引空、索引报错或 query 为空),它**回退**到内存余弦:配置了
// 增强却仍拿到一片 0 分,等于增强既无效又遮住了可用的路径
// (审计 P0-3;design/03 §3「真正启用向量检索」、design/04 §7)。
// 两条路径都没有时返回空 map,因此语义相关性
// 不贡献任何分数。
func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
scores := make(map[string]float64, len(items))
if r.vindex != nil {
return r.vectorIndexScores(ctx, items, query)
// 持久索引为空时先做一次性回填(每进程最多一次),使既有
// 条目也进入语义路径,而不是只有新写的条目可被检索。
r.ensureIndexSeeded(ctx)
if indexed := r.vectorIndexScores(ctx, items, query); len(indexed) > 0 {
return indexed
}
if r.embedder != nil {
r.warnSemanticFallback()
}
}
if r.embedder == nil {
return scores
@@ -300,10 +357,12 @@ func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, quer
}
vectors, err := r.embedder.Embed(ctx, texts)
if err != nil {
r.log.Warn("memory: semantic embed of candidates failed, semantic scores skipped", "error", err)
return scores
}
queryVectors, err := r.embedder.Embed(ctx, []string{query})
if err != nil || len(queryVectors) == 0 {
r.log.Warn("memory: semantic embed of query failed, semantic scores skipped", "error", err)
return scores
}
qvec := queryVectors[0]
@@ -321,9 +380,13 @@ func (r *Retriever) semanticScores(ctx context.Context, items []MemoryItem, quer
// vectorIndexScores 从持久化向量索引作答语义相关性:
// 它用索引对 query 做向量化,检索最相似的若干 id,并
// 返回它们的余弦相似度,以最高命中为基准归一化到 [0,1]。由于
// idx.Search 在全部已存 id 上排名,不在索引中的条目没有
// 语义得分。query 为空或索引报错时退化为空 map。
// 返回它们的余弦相似度,以候选命中的最高分为基准归一化到 [0,1]。
//
// 索引是**全局**的(承载所有角色/作用域的向量),而本次检索的候选
// 只是其中一个子集:不属于候选的命中不参与排名,也不得充当
// 归一化基准(也不得因占满 topK 名额而把候选挤出命中)。因此
// 这里只保留候选集内的命中。query 为空、索引报错或候选内
// 无命中时退化为空 map —— 调用方据此回退内存余弦。
func (r *Retriever) vectorIndexScores(ctx context.Context, items []MemoryItem, query string) map[string]float64 {
scores := make(map[string]float64, len(items))
if strings.TrimSpace(query) == "" {
@@ -331,14 +394,35 @@ func (r *Retriever) vectorIndexScores(ctx context.Context, items []MemoryItem, q
}
qvecs, err := r.vindex.Embed(ctx, []string{query})
if err != nil || len(qvecs) == 0 {
r.log.Warn("memory: vector index query embed failed, falling back to in-memory semantic",
"error", err)
return scores
}
hits, err := r.vindex.Search(ctx, qvecs[0], len(items))
if err != nil || len(hits) == 0 {
if err != nil {
r.log.Warn("memory: vector index search failed, falling back to in-memory semantic",
"error", err)
return scores
}
max := hits[0].Score
candidates := make(map[string]struct{}, len(items))
for _, it := range items {
candidates[it.ID] = struct{}{}
}
matched := make([]vector.Hit, 0, len(hits))
max := 0.0
for _, h := range hits {
if _, ok := candidates[h.ID]; !ok {
continue
}
matched = append(matched, h)
if h.Score > max {
max = h.Score
}
}
if len(matched) == 0 {
return scores
}
for _, h := range matched {
if max > 0 {
scores[h.ID] = h.Score / max
} else {
@@ -371,24 +455,6 @@ func keywordText(it MemoryItem) string {
return it.Content + " " + strings.Join(it.Tags, " ")
}
// cosineSim 计算两个向量的余弦相似度,由调用方夹到 [0,1]。
// 长度为零或不一致的向量得 0。
func cosineSim(a, b embedding.Vector) float64 {
if len(a) == 0 || len(b) == 0 || len(a) != len(b) {
return 0
}
var dot, na, nb float64
for i := range a {
dot += float64(a[i]) * float64(b[i])
na += float64(a[i]) * float64(a[i])
nb += float64(b[i]) * float64(b[i])
}
if na == 0 || nb == 0 {
return 0
}
return dot / (math.Sqrt(na) * math.Sqrt(nb))
}
// Inject 为 personaID 检索最多 topK 条相关记忆,并把它们格式化为
// 紧凑的上下文块,可供注入提示词。没有检索到任何内容时
// 返回空字符串,因此没有相关记忆时调用方可以忽略