Files
Cyrene_v2/internal/runtime/memory/preinsert_config.go
T
AskaEth bdf3f3edaf docs(embedding,memory): 复核 p2 的三处措辞改正(纯注释,零行为改动)
review-m15 round1 判 p2-1items(fix-then-merge),三条都属「把证据能支撑什么写准」:

1. api.go 原写「最坏也只是这一片失败」与实现语义**相反** —— Embed 的实际语义是
   「任一片失败 ⇒ 整条调用失败、其余分片已算完的工作量一并丢弃」。改为与该包
   budget.go 里已经写对的那一句对齐,并点明调用方据此降级(记忆一条不丢、判定规则
   一字不改,变的是这一条审查的召回质量)。
2. 「尖峰与批次无关」是**过度断言**:原证据只支持「至少存在一个与批次无关的外部/
   争用分量」(4 条/片也实测到 7.139s);越界的**频率**是否随批次上升,1/40 vs 2/40
   的样本不足以判定 ⇒ 改标为「归因未定」,并明说批上限取 8 的依据始终是硬证据
   (16 条/片实测越界 + 真实代码路径 3 次里 2 次越界),**不依赖**任何成因推断
   (原文与同文件「16 条越界故不取更大」的理由自相矛盾)。同时补上独立复核窗口的
   对照(review-m15:24 次单请求 0 尖峰)说明尖峰强依赖窗口负载。
3. budget.go 的不变式不再被读成延迟保证:明确定性为「**稳态、空闲机器的建模拟合**,
   用途是构造批形状」,并点名真正的兜底(90s 审查预算按整条链算 + 任一片失败即降级
   的路径 + 已登记的链路韧性遗留);结论句由「8 条/片不越界」改为「**空闲实测下**不
   越界、负载下仍偶发越界」;perItemBudget 与 callBudgetShare 注释里「另一半/余量」
   的同口径措辞一并改正(加一句:这是建模余量,不是单请求一定不越界的保证)。

另:preinsert_config.go 里「设计稿那一行待同步」已过期 —— 塔同日已把 design/23 §15
同步为 Timeout=90s,改为「已按实测同步」。

验证:go build ./... / go vet ./... 干净;go test -count=1 ./... 全绿;
go test -race -count=1 ./internal/runtime/embedding/... ./internal/runtime/memory/...
全绿;diff 中无非注释行(纯文档改动)。
2026-10-13 22:12:11 +08:00

196 lines
12 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 "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`(本次改动
// **之前**的口径),已按上面实测同步为 `Timeout=90s`。
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
}