Files
AskaEth 589e26c887 fix(memory): 增量向量化 + 单分片逐条隔离,召回请求数降一个数量级(M61)
M15(2026-10-13)登记的两条放大源落在记忆层,本次一并收口:

1. 整理轮/入库审查**只嵌增量**:新加 vector.Index.Vectors(SQLite/PG 实现,
   按 id 取已存向量、维度不符当未命中),Store.embedIncremental 先复用索引里的
   向量、只嵌缺的那些,并把手算出的向量回填索引 ⇒ 同一分区第二轮起零请求。
   判据写明为「向量存在性 + 维度一致」(正文在本仓不可就地改写,故无需哈希)。
   维度与当前 embedder 不一致时禁用复用并 WARN(不混两种表示)。
2. 嵌入链对单个慢分片有韧性:embedIsolated 整批失败后逐条隔离,坏的那几条
   记缺口、其余照常返回;一条都没拿到才报错降级。请求数上界 1+B(B=8 ⇒ 9),
   服务整体挂死时一次即收手,不用「请求放大」换韧性。
3. 缺口/复用/回填条数进日志(缺口走 WARN),review 的被审条目拿不到向量时
   如实返回错误 ⇒ 走既有词项余弦降级(不把「算不出来」说成「没有重复」)。

请求数(单测内假 OpenAI 兼容服务端计数,生产 API embedder + 真 SQLite 索引):
  24 条存量:修复前每轮 3 → 第一轮 3、第二轮 0
  12 条分区整理轮:第一轮 2 → 第二轮 0
  20 条候选的入库审查:26(201÷8)→ 第一条 3、第二条 0
  一条坏正文:保住 7/8 条,代价 9 个请求;整体性故障 9 个请求后收手
2026-10-30 08:30:06 +08:00

215 lines
13 KiB
Go
Raw Permalink 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 "time"
// 本文件是 D3「入库前审查」(design/23 §15.1 D3)的**调优面**:默认值常量、
// ReviewConfig 的旋钮与它们的零值归一化。它从 preinsert.go 拆出来只为守
// AGENTS §10 的文件行数上限(≤500 行)与「模块按职责拆源文件」那条规范 ——
// 口径逐字未变。
//
// 边界:本文件只管「旋钮长什么样 + 零值取什么默认值」,不认识服务本体
// (PreInsertReviewer 的队列/工作协程在 preinsert.go),也不认识任何开关的
// 运维来源(env 与 `$DATA_DIR/config/memory-tidy.json` 都在组合根)。
// 默认值与审计口径。运算路径上不出现裸魔法数字(AGENTS §10)。
const (
// defaultReviewQueueSize 是有界审查队列长度:审查是「尽力而为」的旁路,
// 队列满时宁可少审一条(记忆早已落库),也绝不阻塞写入方。
defaultReviewQueueSize = 256
// defaultReviewScanLimit 是每次审查在同一分区内最多比对的既有条目数。
defaultReviewScanLimit = 200
// defaultReviewTimeout 是单条审查的墙钟上限,**也是全链的唯一真源**
// (切片 M16,2026-10-13):配置层 `config.MemoryReviewConfig.TimeoutSec`
// 的 env tag **刻意不带** `default:`,没配时读出 0(= 没配),
// 由本文件下方的 normalizeReviewConfig 落地成这个常量。
//
// 🔴 为什么要把真源搬到这里:原先 tag 上写着 `default:"20"`,而 20s 又等于
// 当时的本常量 ⇒ 看着「两处一致」,实际是 **env 层永远产出一个非零值**,
// 「≤0 才回落默认」这条归一化**永远是死代码** ⇒ 本常量改成 90s 后生产启动行
// 仍是 `timeout=20s`(塔在部署后读真实启动行抓到)。所以别再往 env tag 上写
// 字面默认值:那等于把「默认值」搬回配置层,本常量又一次变成装饰。
//
// 取值推导如下(2026-10-13 由 20s 抬到 90s),依据是**实测的链长**,不是感觉:
// 单条审查的召回链是「新条 1 + ScanLimit 200 = 201 条文本」,而 embedding 侧
// 一次请求只吃 DefaultBatchLimit 条(改前 4、改后 8,见 embedding/budget.go 的
// 文件头实测)⇒ 链长 = ⌈201/批次⌉ 个请求:
//
// 改前(4 条/片)51 个请求:整条链 **23.070 / 21.860 s**(空闲机器,两轮实测)
// 改后(8 条/片)26 个请求:整条链 **21.435 / 21.597 s**(同上两轮)
// 另有一次**并发整理链**时测到的同一条链 = **56.7 s**(M14 只读勘测报告)
//
// ⇒ 旧值 20s **比链长还短**:即使机器空闲,一条候选数满额(200)的审查也跑不完
// 它的 embedding,于是父 ctx 到期 ⇒ 整条审查失败、**一行留痕都不写**(7 天内
// 92.1% 的审查 `recall_mode` 为空,其中超时降级 2274 条,正是这条链的后果)。
//
// 取 90s 的推导:预算必须 > 链长 + 本层后面的 T1 灰区判定(defaultT1Timeout 8s)。
// 空闲实测 21.6s ⇒ 4.2 倍余量;并发实测 56.7s + 8s = 64.7s ⇒ 仍有 1.4 倍余量。
//
// 为什么**不再更大**(例如 180s / 300s):
// - 审查是**异步、单 worker、有界队列**(QueueSize 256,preinsert.go 的
// `go r.worker`)。worker 的最坏占用 = 一条审查的预算 ⇒ 队列 256 条的最坏
// 积压 = 256 × 90s ≈ **6.4 小时**(180s 就是 12.8 小时)。预算再大,等于把
// 「worker 被一条慢审查占住」的时间线性放大。
// - 90s 已经是 embedding 单次超时(5s)的 **18 倍**、T1 自身超时(8s)的 11 倍。
// embedding 服务**真挂死**时这条预算根本用不到 —— 第一片请求在 5s 就失败,
// 召回立刻降级到词项余弦、审查照常跑完(这一点是结构性事实,见
// preinsert_recall.go 的降级路径)⇒ 预算只对「慢但在推进」的链有意义。
// 那种形态说明 embedding 服务本身退化了,该修的是服务,不是继续等。
// - 审查超时后的失败形态目前**不写留痕**(preinsert.go 的 `ctx.Err()` 分支,
// 不属本次改动范围)⇒ 预算越长,「静默停摆」的窗口越长、面板越看不出来。
//
// 🔴 与设计稿的对齐(如实登记):design/23 §15 那一行原写 `Timeout=20s`(本次改动
// **之前**的口径),已按上面实测同步为 `Timeout=90s`。
//
// 🆕 2026-10-30(M61):链长已由**增量向量化**压到「只剩缺向量的那几条」——
// 同分区第一条审查 ⌈K/批⌉ 个请求、之后 **0 个**(候选从 `vec_items` 取,
// 见 preinsert_recall.go 的 embedReviewTexts 与 embed_incremental.go)。因此上面
// 的 90s 从「必须覆盖链长」变成「远大于链长的余量」;**不下调**是刻意的:
// 单请求在并发下仍会偶发越过 5s(embedding/budget.go 文件头 ④ 的实测),余量是
// 韧性而不是浪费 —— 慢分片现在由 embedIsolated 逐条隔离,不吃满整条预算。
defaultReviewTimeout = 90 * time.Second
// reviewAuditWho / reviewAuditVerb 是审查留痕的审计口径:它是**系统任务**,
// 不是任何角色(design §4.1 C 的同一理由)。
reviewAuditWho = "system/memoryreview"
reviewAuditVerb = "memory.review.ingest"
// defaultT1PerWindow / defaultT1Window 是 T1 **全局限速**的默认额度
// (design/23 §15.4,主人 2026-10-07 裁定「加一层保守的全局速率限流」)。
//
// 推算依据(2026-10-07):现网节奏是每天 700–1000 条新记忆(≈25–42 条/小时),
// 而 T1 只在「T0 判不出结论**且**存在灰区候选」时才发调用 —— 每条新记忆至多换
// 一次调用。取 **120 次 / 60 分钟**,即在最忙的那一小时也留 ≈3–5 倍余量:
// 正常节奏下这个闸几乎碰不到;只有「召回/规则突然把大批条目判成灰区」这类
// 异常形态才会触顶,而那正是要拦的东西。超限不是错误:跳过 T1、回落 create、
// 留痕 `t1_throttled`,记忆一条不丢、一个字不改。
defaultT1PerWindow = 120
defaultT1Window = 60 * time.Minute
// tempTtlSec 是**临时记忆**(第五态 `defer`,design/23 §18.1)的默认存活秒数:
// **7 天 = 604800 秒**。它是 design/03 §9.6 [替主人定 · Q3] 定死的代码默认值
// (设计稿点名的常量名就是 `tempTtlSec = 604800`)。
//
// 为什么「多久算临时」不进代码死值:7 天是保守默认,而它本质是**主人的口味**
// 而不是判据 ⇒ 面板可调,走 `$DATA_DIR/config/memory-tidy.json` 的
// `tempTtlSec`(与既有整理旋钮同一个文件、同一套「面板 > 代码默认」优先级,
// 见 ApplyReviewTuning)。**`.env` 不加键**(已冻结,design/13 §2.1)。
tempTtlSec = 604800
)
// 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
// DisableT1 关闭 **T1 灰区模型判定**(design/23 §15.1 D3-2:灰区调一次轻量
// 模型,只许回 merge/modify/keep + 理由、禁产新内容)。
//
// 它是**反向**字段,理由与 DisableIngestGate 逐字相同:bool 的零值必须落在
// 「默认行为」上,而 T1 的代码默认值是**开**(主人 2026-10-07:要的就是「真的
// 走记忆管理会话」,T0 仍是第一道零成本闸)。若写成正向的 EnableT1,任何没
// 显式配过的地方(含测试与组合根构造的一半配置)都会读成「关掉」,与默认相反。
// 要一键回到纯规则(零模型调用),写 `$DATA_DIR/config/memory-tidy.json` 的
// `{"t1Judge": false}`(**零新 env 键**:`.env` 已冻结,design/13 §2.1)。
DisableT1 bool
// T1Timeout 是**单次** T1 模型调用的墙钟上限。非正用代码默认值
// defaultT1Timeout;最终还会被 cfg.Timeout 收紧取小(见 t1Timeout)。
T1Timeout time.Duration
// T1PerWindow 是 T1 的**全局**窗口额度(次)—— 不是每条各自的限制,而是
// 全进程共享的一个窗口:T1Window 内至多这么多次模型调用,超限即跳过 T1 并
// 回落 `create`(reason=`t1_throttled`,design/23 §15.4)。
//
// 非正用代码默认值 defaultT1PerWindow。**刻意没有「无上限」语义**:
// design/12 §N C.5 G3 登记过那条静默失败形态(未归一的 ≤0 直达滑窗 ⇒ 被读成
// 「无限制」,没有报错、没有 WARN);要完全不花钱请用
// `$DATA_DIR/config/memory-tidy.json` 的 `{"t1Judge": false}` 关掉 T1。
//
// 额度**只被真正的模型调用消耗**(闸放行才记账,被拒的那次不占),因此
// 「窗口过了就恢复」是结构性成立的。
T1PerWindow int
// T1Window 是 T1PerWindow 的时间窗。非正用代码默认值 defaultT1Window。
T1Window time.Duration
// TempTTL 是**临时记忆**的存活时长(design/23 §18.1 第五态 `defer` /
// design/03 §9.6):`defer` 命中时,落库行的 `expires_at` = 当时 + TempTTL。
// 非正用代码默认值 **7 天**(tempTtlSec = 604800)。
//
// 🔴 **非正绝不放行**(design/03 §9.6 的明文口径):一个 ≤0 的 TTL 会让每条
// defer 记忆**落库即过期**(读侧立刻隐藏、后台清扫下一轮就删)—— 那是坏配置
// 而不是「不过期」,与既有 `panelPositiveSec` 同一口径(tidy_tuning.go)。
// 因此面板写了坏值时由 ApplyReviewTuning 记 WARN 并**不覆盖**默认值,这里
// 再兜一道(任何绕过解析器的构造路径同样拿不到 ≤0)。
TempTTL time.Duration
}
// normalizeReviewConfig 用设计稿默认值填充零值字段。
func normalizeReviewConfig(cfg ReviewConfig) ReviewConfig {
if cfg.QueueSize <= 0 {
cfg.QueueSize = defaultReviewQueueSize
}
if cfg.ScanLimit <= 0 {
cfg.ScanLimit = defaultReviewScanLimit
}
if cfg.Timeout <= 0 {
// 单条审查预算的**唯一**落地处(切片 M16):配置层的 env tag 不带默认值,
// 所以「没配」与「配了 ≤0」都走这一行。全链的生效值就是这一行的结果 ——
// 启动行 `memory ingest review started … timeout=` 打印的也正是它。
cfg.Timeout = defaultReviewTimeout
}
if cfg.Threshold <= 0 {
cfg.Threshold = defaultTidyRecallThreshold
}
if cfg.GateBudget <= 0 {
cfg.GateBudget = defaultReviewGateBudget
}
if cfg.GateScanLimit <= 0 {
cfg.GateScanLimit = defaultReviewGateScanLimit
}
if cfg.T1Timeout <= 0 {
cfg.T1Timeout = defaultT1Timeout
}
// 🔴 限流的两项必须在这里回落(design/12 §N C.5 G3 的教训):闸是照着
// 「非正 = 恒放行」实现的,未归一的 ≤0 直达它就会把「回落默认」**静默**变成
// 「无上限」。因此 NewPreInsertReviewer 保证闸在 normalize **之后**构建。
if cfg.T1PerWindow <= 0 {
cfg.T1PerWindow = defaultT1PerWindow
}
if cfg.T1Window <= 0 {
cfg.T1Window = defaultT1Window
}
// 临时记忆的 TTL 同样在这里回落(design/03 §9.6:「坏值(≤0)绝不放行」)。
// 面板侧的那道 WARN 在 ApplyReviewTuning;这里是把「未归一的 ≤0 直达
// 判据」这条静默失败形态堵死 —— 与 T1 限流两项同一教训。
if cfg.TempTTL <= 0 {
cfg.TempTTL = time.Duration(tempTtlSec) * time.Second
}
return cfg
}