Files
Cyrene_v2/internal/runtime/memory/preinsert.go
T
AskaEth 90f430216a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
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 不在同一事务,非本次引入,第二层会清)。
2026-10-07 11:41:44 +08:00

474 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是 D3「入库前审查」(design/23-记忆整理与去重 §15.1 D3)的服务形态:
// 新记忆落库**之后**,由**独立的记忆管理会话**(复用既有的 sess-memorytidy
// 装配,见 TidySessionID)异步判定它与同分区既有记忆的关系,四态结论见
// IngestDecision。
//
// 与 design §15 D3-1/3/5 的取舍(逐条,因为设计稿这一处同时出现「写入之前」
// 与「首选异步 + 结论回写」两种表述,实现必须把口径写死):
//
// 1. **触发点在写入之后**(异步):回合收尾的写入路径上多出一次 embedding
// 网络调用就会把主人的回合拖长(§15 D3-3「不许因为审查把对话回合拖长」),
// 而 §15 D3-3 要求的正是「审查结论**回写**到 memory_items」—— 只有行
// 已存在才谈得上回写。因此本实现是「先落库、再由审查把重复条目软合并
// 回去」,在观测上与「不新建」等价(活跃行数不变),且**不阻塞任何回合**;
// 2. **失败即安全**:条目先落库 ⇒ 审查失败/超时/embedder 不可用时「记忆绝不
// 丢」是结构性成立的,不靠回滚(§15 D3-5)。失败路径**只记 WARN、绝不
// 改写任何一行**;
// 3. **零 LLM**:T0 是纯规则(design §15 D3-2 T0),T1 默认关且**本切片未
// 实现**(§15.2 开放点 1 = T1 默认关),因此「T1 关闭时零模型调用」是
// 结构性成立 —— 本服务的依赖里根本没有模型适配器。
//
// 代价(如实登记):极短窗口内库里会多一行待裁定的重复条目,随后被软合并
// (status='merged' + merged_into,可 revert,绝不物理删除,design §8)。
// IngestDecision 是入库前审查的四态结论(design/23 §15.1 D3)。
type IngestDecision string
const (
// IngestSkip 表示已有**归一化后完全等价**的条目:新行折回既有行,
// 不产生第二条活跃记忆(§15 D3-2 T0-1)。
IngestSkip IngestDecision = "skip"
// IngestMerge 表示新条目是既有条目事实集合的**子集**:并入既有,
// 既有行存活(§15 D3-2 T0-2)。
IngestMerge IngestDecision = "merge"
// IngestModify 表示既有条目是新条目事实集合的子集:**新行承载更全的
// 内容**,旧行置为被合并(§15 D3-6「改写必须是新行 + 旧行置为被合并」)。
IngestModify IngestDecision = "modify"
// IngestCreate 表示确属新条目(互不包含 / 被规则否决 / 没有召回):
// 正常新建,不做任何改写(§15 D3-2 T0-3)。
IngestCreate IngestDecision = "create"
)
// IngestRuleVersion 是入库前审查规则集的版本号,进每条审查留痕(§15 D3-8)。
// **改判定规则必须同时升它**,否则新结论与旧留痕无法区分。
const IngestRuleVersion = "d3.1"
// 默认值与审计口径。运算路径上不出现裸魔法数字(AGENTS §10)。
const (
// defaultReviewQueueSize 是有界审查队列长度:审查是「尽力而为」的旁路,
// 队列满时宁可少审一条(记忆早已落库),也绝不阻塞写入方。
defaultReviewQueueSize = 256
// defaultReviewScanLimit 是每次审查在同一分区内最多比对的既有条目数。
defaultReviewScanLimit = 200
// defaultReviewTimeout 是单条审查的墙钟上限。
defaultReviewTimeout = 20 * time.Second
// reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**,
// 不是任何角色(design §4.1 C 的同一理由)。
reviewAuditWho = "system/memoryreview"
reviewAuditVerb = "memory.review.ingest"
)
var (
// ErrReviewDisabled 在审查被配置显式关闭却又被直接调用时返回。
ErrReviewDisabled = errors.New("memory/review: disabled by configuration")
// ErrReviewRestrictedSource 在待审条目来自群聊/陌生人这类**只读**上下文时
// 返回。它不是失败:这类条目凭什么都不该被审查(既不合并也不改写,
// design §6 / §15 D3-7 + AGENTS §9 的作用域隔离红线)。
ErrReviewRestrictedSource = errors.New("memory/review: restricted source is read-only")
// ErrReviewItemNotActive 在该条目已经不在册(被前一次审查合并掉)却又被
// 要求审查时返回。它是幂等保护:再判一次会把存活行的 merge_count 重复加一。
ErrReviewItemNotActive = errors.New("memory/review: item is no longer active")
)
// ReviewConfig 携带入库前审查的调优旋钮。零值经 normalize 选取安全默认值。
type ReviewConfig struct {
// Enabled 是总开关。关闭时组合根不建、不接、不跑 —— 写入路径行为逐字
// 回到本次改动之前。
Enabled bool
// QueueSize 是有界队列长度(≤0 用默认)。
QueueSize int
// ScanLimit 是单次审查的候选上限(≤0 用默认)。
ScanLimit int
// Timeout 是单条审查的墙钟上限(≤0 用默认)。
Timeout time.Duration
// Threshold 是候选召回的余弦门,**复用记忆整理的既有阈值**
// (config.MemoryTidy.RecallThreshold,design §13 开放点 2 = 0.80):
// 入库前审查与夜间整理必须用同一把尺子,否则同一对条目会有两个结论。
// 非正用设计稿默认值。
Threshold float64
// DisableIngestGate 关闭**同步入库闸**(任务书规格 3 的开关)。
//
// 它是**反向**字段,且这是刻意的:bool 的零值必须落在「默认行为」上 ——
// 同步闸本机制的默认取向(主人 2026-10-07「把记忆整理功能接到昔涟和记忆库
// 中间做审查,由整理功能进行入库」),若写成正向的 EnableIngestGate,任何没
// 显式配过的地方(含测试与组合根构造的一半配置)都会被读成「关掉」,与默认
// 相反。要一键回到「只插不审」(= 本次改动之前的异步行为),写
// `$DATA_DIR/config/memory-tidy.json` 的 `{"ingestGate": false}`(**零新
// env 键**:`.env` 已冻结,design/13 §2.1)。
DisableIngestGate bool
// GateBudget 是同步入库闸的**总墙钟预算**(任务书规格 1:写入路径不许变慢)。
// 非正用代码默认值 defaultReviewGateBudget。超预算一律 fail-open(照常插入 +
// 留痕),绝不静默丢。
GateBudget time.Duration
// GateScanLimit 是同步入库闸的候选扫描上限。它比异步队列更紧(后者
// ScanLimit 默认 200),因为它在**写入路径**上:非正用代码默认值
// defaultReviewGateScanLimit,且最终还会被 cfg.ScanLimit 收紧取小。
GateScanLimit int
}
// ReviewDeps 是注入给入库前审查的依赖。Store 必需;其余可空并优雅降级。
// 刻意**没有**模型适配器:T0 是零 LLM 判定,T1 未实现(§15.2 开放点 1)。
type ReviewDeps struct {
// Store 是记忆存储。必需。
Store *Store
// Embedder 是候选召回的相似度来源。为 nil 时退回零依赖的字符二元组
// 余弦(与整理生产轮的回退同一口径,见 tidy_recall.go)。
Embedder embedding.Embedder
// Audit 是审计出口(可空;为 nil 时只记日志)。
Audit func(who, what, result string)
// Now 是墙钟来源,便于测试注入。
Now func() time.Time
// Log 是基础日志器,来源标记为 "memory/review"。
Log *slog.Logger
// GateCandidates 是**同步入库闸**的候选读取出口。为 nil 时用 store 的
// 生产口径(Store.listReviewCandidates,分区过滤写在 SQL 里)。测试用它
// 注入慢/坏的候选源,验证「预算有界」与「超时 fail-open」这两条。
GateCandidates func(ctx context.Context, p tidyPartition, excludeID string, limit int) ([]MemoryItem, error)
}
// IngestReview 是一条入库前审查留痕(design/23 §15 D3-8)。它**不含记忆正文**:
// 命中的既有 id 足以让 owner-only 的读侧回表取原文。
type IngestReview struct {
ID string
ItemID string
PersonaID string
Scope Scope
ProjectID string
Decision IngestDecision
Reason string
// Veto 是命中的否决规则(`merge`/`modify`/`skip` 时为空):它是结构化依据,
// 面板据此聚合「为什么这一条没被合并」,而不是去解析 reason 字符串。
Veto TidyVetoReason
TargetID string
MergedIDs []string
Score float64
RecallMode string
Degraded bool
RuleVer string
SessionID string
CreatedAt time.Time
}
// PreInsertReviewer 是入库前审查服务。用 NewPreInsertReviewer 构建,用 Start
// 拉起工作协程、Stop 停它;Review 是同步入口(测试与手动核查用)。
type PreInsertReviewer struct {
cfg ReviewConfig
deps ReviewDeps
log *slog.Logger
now func() time.Time
queue chan MemoryItem
mu sync.Mutex
running bool
cancel context.CancelFunc
wg sync.WaitGroup
statsMu sync.Mutex
stats ReviewStats
}
// NewPreInsertReviewer 由 cfg 与 deps 构建审查服务。Store 为 nil 时返回
// ErrNilStore(与整理器同一口径:没有记忆存储就没有审查)。
func NewPreInsertReviewer(cfg ReviewConfig, deps ReviewDeps) (*PreInsertReviewer, error) {
if deps.Store == nil {
return nil, ErrNilStore
}
cfg = normalizeReviewConfig(cfg)
now := deps.Now
if now == nil {
now = time.Now
}
return &PreInsertReviewer{
cfg: cfg,
deps: deps,
log: logger.WithSource(baseTidyLog(deps.Log), "memory/review"),
now: now,
queue: make(chan MemoryItem, cfg.QueueSize),
}, nil
}
// normalizeReviewConfig 用设计稿默认值填充零值字段。
func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig {
if cfg.QueueSize <= 0 {
cfg.QueueSize = defaultReviewQueueSize
}
if cfg.ScanLimit <= 0 {
cfg.ScanLimit = defaultReviewScanLimit
}
if cfg.Timeout <= 0 {
cfg.Timeout = defaultReviewTimeout
}
if cfg.Threshold <= 0 {
cfg.Threshold = defaultTidyRecallThreshold
}
if cfg.GateBudget <= 0 {
cfg.GateBudget = defaultReviewGateBudget
}
if cfg.GateScanLimit <= 0 {
cfg.GateScanLimit = defaultReviewGateScanLimit
}
return cfg
}
// gateEnabled 报告**同步入库闸**是否生效(任务书规格 3 的开关)。
// 总开关关闭或闸被显式关掉时它都为 false,此时写入路径逐字回到「只插不审」。
func (r *PreInsertReviewer) gateEnabled() bool {
return r != nil && r.cfg.Enabled && !r.cfg.DisableIngestGate
}
// gateScanLimit 返回同步闸实际使用的候选上限:配置值与其默认值的较小者 ——
// 写入路径上的扫描必须比异步队列更紧(任务书规格 1)。
func (r *PreInsertReviewer) gateScanLimit() int {
limit := r.cfg.GateScanLimit
if limit <= 0 {
limit = defaultReviewGateScanLimit
}
if r.cfg.ScanLimit > 0 && r.cfg.ScanLimit < limit {
return r.cfg.ScanLimit
}
return limit
}
// SessionID 返回审查运行的会话标识:**复用既有的 sess-memorytidy 装配**
// (design/23 §4.1 B;§15 D3 要求「走记忆管理会话」,不许造第二形态会话)。
func (r *PreInsertReviewer) SessionID() string { return TidySessionID }
// Enabled 报告审查是否被配置开启。
func (r *PreInsertReviewer) Enabled() bool { return r.cfg.Enabled }
// Start 拉起工作协程(审查队列 + 留痕保留期清扫)。幂等;配置关闭时只记一条日志
// 并立即返回(关闭是显式选择,绝不静默)。
func (r *PreInsertReviewer) Start(ctx context.Context) {
if !r.cfg.Enabled {
r.log.Info("memory ingest review disabled by configuration", "env", "MEMORY_REVIEW_ENABLED")
return
}
if ctx == nil {
ctx = context.Background()
}
r.mu.Lock()
if r.running {
r.mu.Unlock()
return
}
r.running = true
cctx, cancel := context.WithCancel(ctx)
r.cancel = cancel
// 两个协程(审查工作协程 + 留痕清扫循环)**在释放锁之前**一次性登记,
// 避免 Stop 的 wg.Wait 与随后的 Add 并发(WaitGroup 的「计数归零后不许再 Add」
// 是硬约束,不只是风格)。
r.wg.Add(2)
r.mu.Unlock()
go r.worker(cctx)
// 留痕清扫(design/23 §15 D3-8 的 memory_ingest_reviews)与审查工作协程同生共死:
// 启动期扫一次、此后按间隔定时扫(口径见 preinsert_gc.go)。它删的是**已经过期
// 的观测留痕**,与审查本身的判定/记忆行无关,因此失败只记 WARN、绝不退出。
go r.sweepLoop(cctx)
r.log.Info("memory ingest review started",
"session_id", TidySessionID,
"queue", r.cfg.QueueSize,
"scan_limit", r.cfg.ScanLimit,
"timeout", r.cfg.Timeout.String(),
"recall_threshold", r.cfg.Threshold,
"rule_version", IngestRuleVersion,
"sync_gate", r.gateEnabled(),
"gate_budget", r.cfg.GateBudget.String(),
"gate_scan_limit", r.gateScanLimit(),
"sweep_interval", DefaultIngestReviewGCInterval.String(),
"retention", DefaultIngestReviewRetention.String(),
"max_reviews", DefaultIngestReviewMaxRows)
}
// Stop 停止工作协程并等待正在审的那一条跑完。幂等;ctx 在排空前被取消时返回
// 包装后的 context 错误,而不是泄漏 goroutine。
func (r *PreInsertReviewer) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
r.mu.Lock()
if !r.running {
r.mu.Unlock()
return nil
}
r.running = false
cancel := r.cancel
r.mu.Unlock()
cancel()
done := make(chan struct{})
go func() {
r.wg.Wait()
close(done)
}()
select {
case <-done:
r.log.Info("memory ingest review stopped", "stats", reviewStatsLabel(r.Stats()))
return nil
case <-ctx.Done():
return fmt.Errorf("memory/review: stop: %w", ctx.Err())
}
}
// Enqueue 把一条刚落库的记忆投入审查队列。它是**非阻塞**的:队列满时返回
// false(该条不做审查,记忆本身早已落库,绝不因此丢记忆)并记一条 WARN。
func (r *PreInsertReviewer) Enqueue(item MemoryItem) bool {
if !r.cfg.Enabled {
return false
}
select {
case r.queue <- item:
return true
default:
r.bump(func(s *ReviewStats) { s.Dropped++ })
r.log.Warn("memory ingest review queue full, item left unreviewed",
"item_id", item.ID, "scope", string(item.Scope), "queue", r.cfg.QueueSize)
return false
}
}
// Stats 返回累计计数(快照)。
func (r *PreInsertReviewer) Stats() ReviewStats {
r.statsMu.Lock()
defer r.statsMu.Unlock()
return r.stats
}
// worker 是单读者循环:一条一条审(单写者的自然形态,design §9 的单写者口径)。
func (r *PreInsertReviewer) worker(ctx context.Context) {
defer r.wg.Done()
for {
select {
case <-ctx.Done():
return
case item := <-r.queue:
r.process(ctx, item)
}
}
}
// process 审一条:单条超时由 r.cfg.Timeout 把守。失败**绝不改写任何一行**,
// 只如实记 WARN(design §15 D3-5)。
func (r *PreInsertReviewer) process(ctx context.Context, item MemoryItem) {
rctx, cancel := context.WithTimeout(ctx, r.cfg.Timeout)
defer cancel()
if _, err := r.Review(rctx, item); err != nil {
if errors.Is(err, ErrReviewRestrictedSource) || errors.Is(err, ErrReviewItemNotActive) {
// 只读来源 / 已不在册:都不是失败,也都零写入。
r.log.Debug("memory ingest review skipped", "item_id", item.ID, "reason", err)
return
}
r.bump(func(s *ReviewStats) { s.Failed++ })
r.log.Warn("memory ingest review failed, memory left untouched",
"item_id", item.ID, "scope", string(item.Scope), "error", err)
}
}
// Review 同步审一条并返回留痕。它在以下情形**不做任何改写**并返回错误:
// 召回不可用(超时/取消/存储错误)、改写失败。调用方据此记 WARN,
// 而记忆本身早已落库,因此「不丢记忆」是结构性成立的。
func (r *PreInsertReviewer) Review(ctx context.Context, item MemoryItem) (IngestReview, error) {
if !r.cfg.Enabled {
return IngestReview{}, ErrReviewDisabled
}
if nonOwnerSource(item.SourceConversation) {
return IngestReview{}, ErrReviewRestrictedSource
}
// 幂等:已经不在册的行不再判(否则重复判会把存活行的 merge_count 数两次)。
status, err := r.deps.Store.reviewItemStatus(ctx, item.ID)
if err != nil {
return IngestReview{}, fmt.Errorf("memory/review: item status: %w", err)
}
if status != string(tidyStatusActive) {
return IngestReview{}, ErrReviewItemNotActive
}
now := r.now().UTC()
cands, degraded, err := r.recallCandidates(ctx, item)
if err != nil {
return IngestReview{}, fmt.Errorf("memory/review: recall: %w", err)
}
if err := ctx.Err(); err != nil {
// 超时/取消发生在召回之后:同样不改写(fail-safe,§15 D3-5)。
return IngestReview{}, fmt.Errorf("memory/review: %w", err)
}
verdict := judgeIngest(item, cands)
merged, err := r.applyVerdict(ctx, verdict, item, now)
if err != nil {
return IngestReview{}, fmt.Errorf("memory/review: apply: %w", err)
}
rev := r.buildRecord(item, verdict, merged, degraded, now)
if err := r.deps.Store.insertIngestReview(ctx, rev); err != nil {
// 留痕失败**不改判决定**:改写已经生效,这里只如实记 WARN,绝不回滚
// 一次已经生效的合并(回滚会把审查看成比结论更权威,那更危险)。
r.log.Warn("memory ingest review record insert failed", "item_id", item.ID, "error", err)
}
r.auditWith(reviewAuditVerb, rev)
r.recordDecision(rev)
return rev, nil
}
// applyVerdict 落定判定:create 什么都不做;其余把「被吸收的那一行」软合并到
// 存活行(design §8:软删除 + merged_into + merged_from + merge_count,
// 绝不物理删除,绝不改任何一行的 content)。
func (r *PreInsertReviewer) applyVerdict(ctx context.Context, v ingestVerdict, item MemoryItem, now time.Time) ([]string, error) {
if v.Decision == IngestCreate {
return nil, nil
}
if v.Absorbed.ID == "" || v.Existing.ID == "" {
return nil, fmt.Errorf("memory/review: %s without a matched existing item", v.Decision)
}
survivor := item
if v.Absorbed.ID == item.ID {
// merge / skip:被吸收的是新行,存活方是既有行。
survivor = v.Existing
}
if err := r.deps.Store.absorbIngestItem(ctx, v.Absorbed, survivor, now); err != nil {
return nil, err
}
return []string{v.Absorbed.ID}, nil
}
// buildRecord 组装留痕(design §15 D3-8:决定 / 依据 / 命中的既有 id /
// 是否降级 + 规则版本与会话标识)。
func (r *PreInsertReviewer) buildRecord(item MemoryItem, v ingestVerdict, merged []string, degraded bool, now time.Time) IngestReview {
rev := IngestReview{
ID: mustNewTidyID(),
ItemID: item.ID,
PersonaID: item.PersonaID,
Scope: item.Scope,
ProjectID: item.ProjectID,
Decision: v.Decision,
Reason: v.Reason,
Veto: v.Veto,
TargetID: v.Existing.ID,
MergedIDs: merged,
Score: v.Score,
RecallMode: v.Mode,
Degraded: degraded,
RuleVer: IngestRuleVersion,
SessionID: TidySessionID,
CreatedAt: now,
}
if rev.MergedIDs == nil {
rev.MergedIDs = []string{}
}
return rev
}