feat(memory): D3 从「异步后置」改成「同步入库闸」—— 由整理层决定入库形态
主人 2026-10-07 裁定:「把记忆整理功能接到昔涟和记忆库中间做审查,由整理功能 进行入库」。现状是**异步 + 入库后软合并** ⇒ 行已经落库才审,面板/召回先脏后清, 而写入速率实测 ~25 条/小时(≈600/天)⇒ 清理永远追不上。 落法(三条硬规格,全部落地): ① **同步闸但有界**:闸在 `store.go:247` 的 `planIngest`(INSERT **之前**), 本体 `preinsert_gate.go`。墙钟预算 **150ms**(实测最坏 p95 仅 4~7ms ⇒ 20~50 倍 余量)、候选扫描上限 **100**(比异步侧 200 更紧,因为它在写路径上)。 🔴 **零远端 embedding**:闸恒用词项余弦(`deps.Embedder` 根本不参与)—— 远端 embedding 一次 2.3~3.0s 且对同文本返回不同向量(不可复现),不许摆在同步 路径上拍板。 ② **fail-open,绝不丢记忆**:候选源报错 ⇒ `gate:candidates_unavailable`;超预算 ⇒ `gate:budget_exceeded` ⇒ **照常 insert(active) + 投既有队列 + 留痕 + WARN**。 ③ **三态落库(落 merged,而不是"不插入")**:确定性命中(字面重复 / 归一化等价 / 真包含)⇒ INSERT **直接带 `status='merged'` + `merged_into=<存活行>`**,存活行 `merged_from` 追加、`merge_count`+1 ⇒ **新行从不进 active、从不建向量索引**, 而**行与原文都在表里 ⇒ revert 无损**。若"干脆不插",那条文本从未存在过行, revert 无从下手,违反 design/23 §8 的红线。 - **不与异步 D3 重复审**:闸做决定的那条 `Async=false`(留痕由闸自己落, 审计动作名 `memory.ingest.gate`);真新/拿不准的走原有队列(留痕仍归异步层)。 - **开关**:`$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}` ⇒ 一键回到 「只插不审」(= 改动前的异步行为)。**零新 env 键**(`.env` 未碰)。 - 顺手把 `store.go` 的行映射拆到 `store_rows.go`(守 §10 的 ≤500 行)。 验收(主会话复跑 + 亲自变异):`go build ./...` + `go vet` 绿;memory 包测试绿; 我用 -overlay 摘掉闸的预算 ⇒ `TestIngestGate_Add墙钟有界` 与 `…failOpen_超预算绝不丢记忆` 变红("Add 等了 2.0s,越过闸预算 150ms")。 子代理另做 2 个变异(去掉预算 / 把 fail-open 改成 fail-closed)全红,并实测 **闸开 vs 关的 `Add` 额外墙钟:p50 ≈2.5~3.1ms、p95 ≈4~7ms**(基线批均值 35.8µs)。 ⚠️ 未部署(要一次 build + restart);⚠️ 已知边界(如实):闸只用词项余弦 ⇒ 「同义但字面差得远」拦不住(有意为之,由异步层/夜间整理兜);闸的候选是**最新 100 条**(更老的重复闸看不到);**并发同内容双写**仍可能两条都落 active(既有竞态, SELECT 与 INSERT 不在同一事务,非本次引入,第二层会清)。
This commit is contained in:
1 parent
f310173b4c
commit
90f430216a
13 files changed
+1448
-140
No files matched your search
@@ -163,19 +163,14 @@ type Store struct {
|
||||
proj *projectindex.Store
|
||||
|
||||
// reviewMu 保护 review,使入库前审查(design/23 §15.1 D3)能在装配后
|
||||
// 并发接入:接入后每条经 Add 落库的条目都会被投入审查队列,由独立的
|
||||
// 记忆管理会话判定 merge/modify/create/skip。review 为 nil 表示未启用
|
||||
// 或未接线,此时写入路径行为逐字与接入前一致。
|
||||
// 并发接入:接入后每条经 Add 落库的条目都会**先过同步入库闸**(确定性判据
|
||||
// 命中即落 merged,见 preinsert_gate.go),闸没拦下的再投入异步审查队列,
|
||||
// 由独立的记忆管理会话判定 merge/modify/create/skip。review 为 nil 表示
|
||||
// 未启用或未接线,此时写入路径行为逐字与接入前一致。
|
||||
reviewMu sync.RWMutex
|
||||
review *PreInsertReviewer
|
||||
}
|
||||
|
||||
// rowScanner 同时被 *sql.Row 与 *sql.Rows 满足,因此单个 scanItem
|
||||
// 辅助函数即可服务 Get 与 ListByPersona。
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// NewStore 在 db 上构建 Store,确保 memory_items schema 存在。
|
||||
//
|
||||
// db 必须是存活且已迁移的连接(迁移框架本身由
|
||||
@@ -220,12 +215,22 @@ func NewStore(ctx context.Context, db *sql.DB, log *slog.Logger) (*Store, error)
|
||||
// Scope/Kind/Confidence/DecayWeight 规范化到默认值。它返回已落库的
|
||||
// 条目,id 与时间戳均已填充。
|
||||
//
|
||||
// 🔴 **同步入库闸**(主人 2026-10-07 裁定):本函数是全仓**唯一**写路径,因此
|
||||
// 闸就摆在这里 —— 落库**之前**先在同分区内跑一次确定性判定(复用
|
||||
// judgeIngest → tidy_rules.go / tidy_containment.go),判出「真重复」时这一行
|
||||
// 直接落 status='merged' + merged_into=<存活行>(**照常插入、一个字不丢、可
|
||||
// revert、从不进 active 集合**),于是 active 集合当场就是干净的。闸的总墙钟
|
||||
// 预算显式且有界(见 defaultReviewGateBudget),超预算/候选源不可用一律
|
||||
// fail-open(照常插入 + 留痕,**绝不静默丢记忆**)。闸未接线或被开关关掉时,
|
||||
// 这段逻辑是零成本的 passthrough,写入路径逐字回到改动之前。
|
||||
//
|
||||
// 接入持久语义索引(见 attachVectorIndex)时,落库后同一条目也会
|
||||
// 被向量化并写入索引,使索引与 memory_items 同步 —— 语义检索路径因此
|
||||
// 真的有向量可查,而不是对一个恒空索引搜索(design/03 §3「真正启用
|
||||
// 向量检索」、design/04 §7)。索引写入失败只记日志:条目已经在
|
||||
// 关系表里,语义增强的失败绝不使记忆写入失败,检索侧也会回退到
|
||||
// 内存余弦(见 Retriever.semanticScores)。
|
||||
// 内存余弦(见 Retriever.semanticScores)。被闸判为重复的行**不建索引**:
|
||||
// 它不进召回、也不该占掉语义检索 top-K 的一个位置。
|
||||
func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
if err := item.normalize(); err != nil {
|
||||
return MemoryItem{}, err
|
||||
@@ -239,22 +244,33 @@ func (s *Store) Add(ctx context.Context, item MemoryItem) (MemoryItem, error) {
|
||||
return MemoryItem{}, fmt.Errorf("memory: encode tags: %w", err)
|
||||
}
|
||||
|
||||
plan := s.planIngest(ctx, item)
|
||||
_, err = s.db.ExecContext(ctx, `INSERT INTO memory_items
|
||||
(id, persona_id, role_id, project_id, scope, kind, content, tags, confidence,
|
||||
source_conversation, created_at, updated_at, decay_weight)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
source_conversation, created_at, updated_at, decay_weight, status, merged_into)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
item.ID, item.PersonaID, item.RoleID, item.ProjectID, string(item.Scope), string(item.Kind),
|
||||
item.Content, string(tagsJSON), item.Confidence, item.SourceConversation,
|
||||
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight)
|
||||
formatTime(item.CreatedAt), formatTime(item.UpdatedAt), item.DecayWeight,
|
||||
plan.insertStatus(), plan.absorbedID())
|
||||
if err != nil {
|
||||
return MemoryItem{}, fmt.Errorf("memory: insert item: %w", err)
|
||||
}
|
||||
s.indexItem(ctx, item)
|
||||
s.ensureProjectIndex(ctx, item)
|
||||
// 入库前审查(design/23 §15.1 D3):唯一写入路径的这一处把刚落库的条目
|
||||
// 投入审查队列。投递是非阻塞的 —— 审查跑在独立的记忆管理会话里,绝不
|
||||
// 让写入方(回合收尾)等它。未接线审查时是空操作。
|
||||
s.enqueueReview(item)
|
||||
s.finishIngest(ctx, item, plan)
|
||||
if plan.Action != ingestPlanAbsorbNew {
|
||||
s.indexItem(ctx, item)
|
||||
s.ensureProjectIndex(ctx, item)
|
||||
}
|
||||
if r := s.ingestReviewer(); r != nil {
|
||||
r.recordGate(ctx, item, plan, time.Now().UTC())
|
||||
}
|
||||
// 入库前审查(design/23 §15.1 D3)的**异步**那一层:闸没拦下的(真新 /
|
||||
// 拿不准 / 闸关闭)照旧投入审查队列,由后台用语义召回再判一次;闸挡下的
|
||||
// 那条**绝不再投**(同一件事不该有两条路径)。投递是非阻塞的。未接线审查
|
||||
// 时是空操作。
|
||||
if plan.Async {
|
||||
s.enqueueReview(item)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
@@ -324,26 +340,6 @@ func (s *Store) List(ctx context.Context) ([]MemoryItem, error) {
|
||||
return scanItems(rows, "item")
|
||||
}
|
||||
|
||||
// scanItems 把每一行读入 MemoryItem 切片,关闭 rows 并用
|
||||
// 操作名包裹 scan/遍历失败。成功时总是返回
|
||||
// 非 nil、可能为空的切片。
|
||||
func scanItems(rows *sql.Rows, op string) ([]MemoryItem, error) {
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
items := make([]MemoryItem, 0)
|
||||
for rows.Next() {
|
||||
item, err := scanItem(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: scan %s: %w", op, err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("memory: iterate %s: %w", op, err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// Delete 删除给定 id 的条目,id 为空或
|
||||
// 没有删除任何行时返回 ErrNotFound。调用方只应传入经
|
||||
// 角色作用域读取(ListByPersona/Get)得到的 id,使删除始终留在
|
||||
@@ -440,40 +436,6 @@ func applyTimestamps(item MemoryItem) (MemoryItem, error) {
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// scanItem 读取一行 memory_items。tags 以 JSON 数组字符串存储;
|
||||
// 时间戳以定宽 UTC 字符串(timeLayout)存储,因此文本
|
||||
// 比较即可按时间排序。
|
||||
func scanItem(sc rowScanner) (MemoryItem, error) {
|
||||
var (
|
||||
it MemoryItem
|
||||
scope, kind string
|
||||
tagsJSON string
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
if err := sc.Scan(&it.ID, &it.PersonaID, &it.RoleID, &it.ProjectID, &scope, &kind,
|
||||
&it.Content, &tagsJSON, &it.Confidence, &it.SourceConversation,
|
||||
&createdAt, &updatedAt, &it.DecayWeight); err != nil {
|
||||
return MemoryItem{}, err
|
||||
}
|
||||
it.Scope = Scope(scope)
|
||||
it.Kind = Kind(kind)
|
||||
if err := json.Unmarshal([]byte(tagsJSON), &it.Tags); err != nil {
|
||||
return MemoryItem{}, fmt.Errorf("memory: decode tags: %w", err)
|
||||
}
|
||||
if it.Tags == nil {
|
||||
it.Tags = []string{}
|
||||
}
|
||||
|
||||
var err error
|
||||
if it.CreatedAt, err = parseTime(createdAt); err != nil {
|
||||
return MemoryItem{}, err
|
||||
}
|
||||
if it.UpdatedAt, err = parseTime(updatedAt); err != nil {
|
||||
return MemoryItem{}, err
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// newID 返回一个 128 位密码学随机 id,十六进制编码。它避开
|
||||
// UUID 依赖,使本包保持自包含。rand.Read 只在
|
||||
// 平台熵源损坏时失败,因此错误向上传播。
|
||||
@@ -492,5 +454,3 @@ func discardLogger(log *slog.Logger) *slog.Logger {
|
||||
}
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user