实测(2026-10-13,本机空闲,活库 cyrene/user 分区 201 条、均长 34.7 汉字): - 一条入库审查召回链 = 51 个「4 条」请求 = 23.070 / 21.860 s;旧审查预算 20s 比链长还短 ⇒ 每一条候选满额的审查都超时,且超时后 **一行留痕都不写** (7 天里 92.1% 的审查 recall_mode 为空、2274 条超时降级的根因) - 同一条链改成「8 条」= 26 个请求 = 21.435 / 21.597 s:请求数减半,墙钟几乎不变 —— 链路耗时由「条数 × 每条成本」主导,批次只改请求数 - 「16 条」在**真实代码路径**上 3 次里 2 次让单请求越过 5s 单次超时(1.818 / 5.016 超时 / 5.001 超时)⇒ 批上限不越过 8;隔离测量里 12 条 0.996~1.017 s 仍在界内 改动: - embedding: perItemBudget 500ms→300ms(实测摊销 70~107ms/条的约 3 倍) ⇒ DefaultBatchLimit 由 (5s/2−100ms)/300ms = **8**,同一套推演、两维都成立 (条数 100ms+8×300ms=2.5s;字符 100ms+480×5ms=2.5s,均在超时的一半内) - embedding: 新增 maxSafeBatchLimit=12(实测外沿)+ 编译期断言(越界即 uint 溢出、 编译失败),与 tidy_config.go 那条同手法 - memory: defaultReviewTimeout 20s→**90s**(> 实测最坏链:空闲 21.6s⇒4.2 倍余量、 与整理链并发 56.7s + T1 8s ⇒ 1.4 倍;不再大的理由=单 worker 有界队列 256 的 最坏积压 256×90s≈6.4h、服务真挂死时第一片 5s 就降级用不到这条预算) 测试(不留过时断言):budget_test.go 加「钉值 8/480 + 两维不等式 + ≤12 外沿 + 16 条必须仍判越界」四层;api_url_test.go 的 reviewBudgetRef 20s→90s; preinsert_recall_test.go 新增跨包断言「预算 > 实测最坏链 + defaultT1Timeout」。 变异自证:perItemBudget 回 500ms ⇒ 钉值断言红;reviewTimeout 回 20s ⇒ 跨包断言红; perItemBudget 150ms(⇒16)⇒ 编译失败(constant -4 overflows uint)。三者均已逐字还原。 遗留(如实登记,未修):① 整理轮每轮重嵌整个分区(user 一轮仍 250 个请求), 解法方向是复用已入库的 vec_items 或给整理轮一个 embedding 请求预算;② 链路对单点 慢分片没有韧性 —— 一个分片失败即整条 Embed 失败、丢掉其余分片的工作量,且实测 任何批次形状都会偶发 >5s 尖峰(4 条 7.139s / 8 条 9.711s,与批次无关),方向是 重试/分片级容错,调常量解决不了。
196 lines
12 KiB
Go
196 lines
12 KiB
Go
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 是单条审查的墙钟上限。
|
||
//
|
||
// 🔴 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`,
|
||
// 是本次改动**之前**的口径;本值按上面实测抬高,设计稿那一行待同步。
|
||
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 {
|
||
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
|
||
}
|