Files
Cyrene_v2/internal/runtime/memory/tidy_config.go
T
AskaEth f3fb097630 perf(embedding,memory): 批上限 4→8 削掉请求放大,审查预算 20s→90s 覆盖实测链长
实测(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,与批次无关),方向是
重试/分片级容错,调常量解决不了。
2026-10-13 21:59:31 +08:00

343 lines
18 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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件收拢记忆整理(design/23-记忆整理与去重)全部的标识、默认值与旋钮,
// 使运行路径上不出现裸魔法数字(AGENTS §10),并让「设计稿默认值」
// 只有一处定义、可被复核。
//
// 形态(design/23 §4 方案 A/C):整理是一件**定期后台任务**(安静时段降频)——
// 跑在专属隔离会话下、零上下文启动、只产候选**不写记忆**,绝不是会话内派
// subagent。
const (
// TidySessionID 是记忆整理运行的专属隔离会话标识(design/23 §4.1 B)。
// 它与主会话 history.MainSessionID("web")不同,因此整理不改主人的心情、
// 不进主会话泳道(cmd/cyrene 的 isConversationLane 对它恒为 false),
// 默认也不进 flowPub 的图源白名单(design/23 §4.1 F 的可选小开关默认关)。
TidySessionID = "sess-memorytidy"
// TidyRuleVersion 是 T0 规则集的版本号,进候选的幂等键(design/23 §9):
// 规则不变时重复运行绝不重复产候选;升版后同一数据重新产候选,
// 因此被 reject 的对可以重评。**改判定规则必须同时升它**。
//
// ⚠️ 2026-10-07「link 不再产候选」**刻意没有**升它(不是漏了):升版的目的是
// 「让历史 reject 的对被重评」,而本次变更不改变任何一对/一条的**判定结论**
// (互不包含的对仍判 keep,只是不再落一条不可应用的候选);升版只会让活库里
// 87 条既存候选凭 idempotency_key 复制出一份重复行,与主人「该删就删」相反。
// 真正需要升版的是「判定结论会变」的改动(阈值语义、包含算法、否决规则)。
TidyRuleVersion = "t0.1"
// defaultTidyIntervalSec 是整理周期秒数,默认 86400 = 每天 1 次
// (design/23 §13 开放点 8 的建议值)。
defaultTidyIntervalSec = 86400
// defaultTidyQuietStartHour / defaultTidyQuietEndHour 复用 backthink 的
// 安静时段语义(默认 23:00-07:00,窗口跨午夜;两端相等且非 0 则关闭窗口)。
//
// 🔴 2026-10-07 起窗口不再等于「唯一可跑时段」:它是**降频档位**的判据
// (安静时段用 defaultTidyIntervalSec 之外的另一档,见 intervalAt),并且
// 仍然决定「首次/未跑过的窗口要补跑一轮」(dueNow 判据 1/3)。
defaultTidyQuietStartHour = 23
defaultTidyQuietEndHour = 7
// defaultTidyRecallThreshold 是候选召回的余弦**召回门**,0.80
// (design/23 §5.1 T0-2 与 §13 开放点 2 的建议值)。它只决定「哪些对值得
// 看一眼」:能否合并由包含判定 + 规则否决决定,绝不退化成阈值判断
// (design/23 §1.2:「必合」0.8330 与「必不合」0.7100 只差 0.12)。
defaultTidyRecallThreshold = 0.80
// defaultTidyScanLimit 是每分区每轮的扫描上限(design/23 §9)。
defaultTidyScanLimit = 5000
// maxTidyCompareItems 是每分区每轮**真正参与两两比对**的条目上限的兜底默认
// 值(可直接从 MEMORY_TIDY_COMPARE_LIMIT 调,见 TidyConfig.CompareLimit)。
// 两两相似度是 O(n²·dim),5000 条会到十亿级乘加,后台任务也不该跑成那样。
// 2026-10-06 由 400 抬到 2000:首轮真跑 459 条的分区被旧值截到 400,
// 于是 compared(705) < scanned(784) —— 截断确实如实登记了,但护栏成为结
// 果的主因本身就是缺陷。超限仍按「最新优先」截断并记一条 WARN(绝不静默),
// 见 tidy_recall.go。
maxTidyCompareItems = 2000
// defaultTidyCandidateLimit 是每分区每轮的候选上限(design/23 §9)。
// 2026-10-06 由 20 抬到 200:首轮真跑四个分区同时打满旧值 ⇒ 候选数由
// 上限而不是规则决定(77 是下限)。
defaultTidyCandidateLimit = 200
// tidyLowConfidenceThreshold 是置信度否决门(design/23 §5.2-6):
// confidence 不能当合并门([2]0.7 与 [5]0.8 是同一事实),但必须当否决门。
tidyLowConfidenceThreshold = 0.7
// tidyEmbedBatch 是单次 Embed 调用的文本条数上限,避免把整个分区的文本
// 一次性交给远端 embedding API。
//
// 🔴 它**不是**拍出来的数字,而是 internal/runtime/embedding 里**同一条推演**
// 的结果(budget.go:一次请求的耗时上界 ≤ 单次超时的一半 ⇒ 批上限 = 超时的一半
// ÷ 单条预算上界)。2026-10-13 的实测沿革:
// - 旧值 64:单请求实测 6.872s(对比超时 5s)⇒ 客户端放弃、服务端继续算 ⇒
// 本机 embedding 服务被顶住,单条请求(写向量索引)也跟着超时;
// - 先收到 4:单请求落回界内,但把**一次调用**放大成 ⌈N/4⌉ 个请求 ——
// user 分区一轮(CompareLimit 2000 条)就是 500 个请求、审查链 51 个请求;
// - 本次放到 **8**(= embedding.DefaultBatchLimit,`perItemBudget` 500ms→300ms):
// 同样的分区 500 → **250** 个请求、审查链 51 → **26** 个请求,而单请求实测最大
// 1.782s,仍在 5s 之内(16 条/片已复现出 11.043s 的越界单请求,故不取更大)。
//
// ⚠️ 但它**不缩短整理轮的墙钟**:链路耗时由「条数 × 每条成本」主导,批次只改请求
// 数(实测 201 条在 4 条/片与 8 条/片下都是 ~21s)。「整理轮每轮把整个分区重新
// 向量化」这个**放大源仍然存在**(user 分区一轮 250 个请求),本切片不动它。
//
// 批上限在 embedder 内部**还有一道强制**(Embed 自己切片),因此这里即使被改大
// 也不会把服务端顶住;这一行只是让调用方别去构造注定被切开的超大切片。
tidyEmbedBatch = embedding.DefaultBatchLimit
// minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容
// (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。
minTidyContentRunes = 3
// defaultTidyApplyBatchLimit 是「批量应用候选」的单次条数上限(切片 TIDY-1)。
// 它有上限是**刻意**的:一次点下去改几百条记忆是不可回头的观感,因此宁可
// 让主人分批点,且回执如实带上 Total/Truncated(超限绝不静默少应用)。
// 它是**代码默认值**(`.env` 已冻结、design/13 §2.1:新旋钮一律走
// $DATA_DIR/config/** 或代码默认值),不新增任何 env 键。
defaultTidyApplyBatchLimit = 100
// maxTidyApplyBatchLimit 是批量应用上限的硬顶:请求给更大的值就钳到这里
// (与既有查询面「钳位而不是报错」同一约定)。
maxTidyApplyBatchLimit = 500
// defaultTidyAutoApplyPerRound 是**轮末自动落地**的每轮条数上限(2026-10-07
// 主人口径「该删就删,怎么这个记忆整理机制这么保守呢」)。
//
// 它是**代码默认值**,可被 `$DATA_DIR/config/memory-tidy.json` 的
// autoApplyPerRound 覆盖(`.env` 已冻结、design/13 §2.1:新旋钮一律走
// $DATA_DIR/config/** 或代码默认值,不进 .env)。
//
// 上限存在的理由与 design/23 §7 原本「merge/archive 永远人工确认」的顾虑同源:
// 自动落地是**批量改记忆**,一天一轮最多 50 条,坏判据最多伤 50 条且每条都能
// revert —— 而不是一次点下去改几百条、事后无从追溯。
defaultTidyAutoApplyPerRound = 50
// maxTidyAutoApplyScan 是轮末自动档一趟**读取**待定候选的条数上限。自动档要在
// Go 侧按判据筛(判据要读候选证据 JSON,SQL 表达不了),因此它必须有个上限;
// 命中上限时自动档只处理最新的一批,直到**下一轮**再继续(与扫描上限同一口径:
// 最新优先 + 绝不静默,见 tidy_auto.go 的 ScanTruncated 与那条 WARN)。
maxTidyAutoApplyScan = 2000
// tidyAutoMergeMinSimilarity 是自动合并第二档判据的相似度下限:**事实集合等价
// (双向包含 = containBoth)且相似度 ≥ 0.95**。合同等价但相似度低的对说明
// 「关键要素相同、表述差得远」,那不是重复而是两种说法,交人工。
tidyAutoMergeMinSimilarity = 0.95
// defaultTidyRejectBatchLimit 是「按动作批量拒绝候选」的单次条数上限
// (2026-10-07:给活库里 501 条无执行语义的 link 候选一个干净去处)。
//
// 它比批量应用的上限宽得多是**刻意的**:拒绝只写候选表的状态列,**不碰
// memory_items 任何一行**,因此没有「一次改坏几百条记忆」的风险;但仍有上限,
// 且超限如实回报 total/truncated(绝不静默少做)。
defaultTidyRejectBatchLimit = 1000
// maxTidyRejectBatchLimit 是上面那个上限的硬顶(钳位而不是报错)。
maxTidyRejectBatchLimit = 10000
)
// 编译期不变式(2026-10-13):批上限一旦被调回超过 embedding 推出来的值
// (例:写回旧值 64),下面这行常量表达式会变成负数、uint 溢出 ⇒ **编译失败**。
// 这条断言把「批上限与超时同源」从注释里的承诺变成编译器守的门。
const _ = uint(embedding.DefaultBatchLimit - tidyEmbedBatch)
// TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值,
// 因此半填的 Config 仍可用。
type TidyConfig struct {
// Enabled 是总开关。关闭时组合根不建服务、不跑任何整理
// (design/23 §4 的 MEMORY_TIDY_ENABLED)。
Enabled bool
// Interval 是整理周期(**非安静时段**)。为零时由 IntervalSec(或其默认值)推导。
Interval time.Duration
// IntervalSec 是整理周期秒数(**非安静时段**),映射自
// config.MemoryTidyConfig.IntervalSec(env),并可由
// `$DATA_DIR/config/memory-tidy.json` 的 `intervalSec` 覆盖(面板优先、`.env`
// 兜底,唯一实现点是 ApplyTidyScheduleTuning)。设置了 Interval 时忽略。
IntervalSec int
// QuietIntervalSec 是**安静时段**的整理周期秒数(2026-10-07 主人「或者动态
// 分配,比如白天间隔短一点,晚上到了静默时期稍微长一点」)。
//
// 非正 ⇒ 等于 IntervalSec:**缺省 = 不因时段换档**,而不是「夜里不跑」
// (normalize 里那一条是唯一落点)。它没有 env 键(`.env` 已冻结),只由
// `$DATA_DIR/config/memory-tidy.json` 的 `quietIntervalSec` 提供。
QuietIntervalSec int
// QuietInterval 是安静时段的整理周期。为零时由 QuietIntervalSec 推导。
QuietInterval time.Duration
// QuietStartHour / QuietEndHour 以本地钟点约束低峰窗口
// (复用 backthink 语义:窗口可跨午夜,两端相等且非 0 则关闭窗口)。
//
// 🔴 2026-10-07 起窗口的**语义一字未改**(inQuietHours 逐字不动),但它的
// **作用面**收窄了一格:从「整理唯一可跑的时段」变成「整理降频的时段」
// (见 intervalAt)。理由是主人的频率口径要求白天也跑,且白天跑得比夜里勤。
QuietStartHour int
QuietEndHour int
// RecallThreshold 是候选召回的余弦门(见 defaultTidyRecallThreshold)。
// 非正用默认值。
RecallThreshold float64
// ScanLimit 是每分区每轮扫描条数上限。非正用默认值。
ScanLimit int
// CandidateLimit 是每分区每轮候选条数上限。非正用默认值。
CandidateLimit int
// CompareLimit 是每分区每轮真正参与两两比对的条目上限(性能护栏)。
// 非正用默认值(maxTidyCompareItems)。
CompareLimit int
// DisableAutoApply 关闭**轮末自动落地**(见 defaultTidyAutoApplyPerRound)。
//
// 它是**反向**字段,且这是刻意的:bool 的零值必须落在「默认行为」上 ——
// 自动落地是本机制的默认取向(2026-10-07 主人「该删就删」),若写成正向的
// AutoApply,任何没显式配过的地方(含测试与组合根构造的一半配置)都会被读成
// 「关掉」,与默认相反。要一键回到「只产建议」,写
// `$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`(不加 env 键:
// `.env` 已冻结)。
DisableAutoApply bool
// AutoApplyPerRound 是轮末自动落地的**每轮**条数上限。非正用默认值
// (defaultTidyAutoApplyPerRound);最终还会被 maxTidyApplyBatchLimit 钳位。
AutoApplyPerRound int
}
// TidyConfigFromConfig 把 config.MemoryTidyConfig(env 侧旋钮)映射成
// TidyConfig,env 未暴露的项保持零值,交给 normalize 填默认值。
//
// 它是**兜底层**:组合根随后用 ApplyTidyScheduleTuning 把
// `$DATA_DIR/config/memory-tidy.json` 的调度旋钮叠上来(面板优先、env 兜底);
// env 没有的项(如 quietIntervalSec)在这里保持零值,由 normalize 取默认。
func TidyConfigFromConfig(cfg config.MemoryTidyConfig) TidyConfig {
return TidyConfig{
Enabled: cfg.Enabled,
IntervalSec: cfg.IntervalSec,
QuietStartHour: cfg.QuietStartHour,
QuietEndHour: cfg.QuietEndHour,
RecallThreshold: cfg.RecallThreshold,
ScanLimit: cfg.ScanLimit,
CandidateLimit: cfg.CandidateLimit,
CompareLimit: cfg.CompareLimit,
}
}
// normalize 用设计稿默认值填充 TidyConfig 的零值字段。
func normalizeTidyConfig(cfg TidyConfig) TidyConfig {
if cfg.IntervalSec <= 0 {
cfg.IntervalSec = defaultTidyIntervalSec
}
if cfg.Interval <= 0 {
cfg.Interval = time.Duration(cfg.IntervalSec) * time.Second
}
if cfg.QuietIntervalSec <= 0 {
// 缺省 = 与普通间隔同档。放在 IntervalSec 归一之后,因此「面板/env 都没给
// quietIntervalSec」时它取的是**已解析的** intervalSec,而不是魔法数字。
cfg.QuietIntervalSec = cfg.IntervalSec
}
if cfg.QuietInterval <= 0 {
cfg.QuietInterval = time.Duration(cfg.QuietIntervalSec) * time.Second
}
if cfg.QuietStartHour == 0 && cfg.QuietEndHour == 0 {
cfg.QuietStartHour = defaultTidyQuietStartHour
cfg.QuietEndHour = defaultTidyQuietEndHour
}
if cfg.RecallThreshold <= 0 {
cfg.RecallThreshold = defaultTidyRecallThreshold
}
if cfg.ScanLimit <= 0 {
cfg.ScanLimit = defaultTidyScanLimit
}
if cfg.CandidateLimit <= 0 {
cfg.CandidateLimit = defaultTidyCandidateLimit
}
if cfg.CompareLimit <= 0 {
cfg.CompareLimit = maxTidyCompareItems
}
if cfg.AutoApplyPerRound <= 0 {
cfg.AutoApplyPerRound = defaultTidyAutoApplyPerRound
}
return cfg
}
// autoApplyEnabled 报告轮末自动落地是否开启。判据只有 DisableAutoApply 一个
// (见它的零值口径),因此「默认开启」是可证的一条,而不是注释里的承诺。
func (c TidyConfig) autoApplyEnabled() bool { return !c.DisableAutoApply }
// quietWindowDisabled 报告低峰窗口是否被显式关闭(两端相等且非 0)。
func (c TidyConfig) quietWindowDisabled() bool {
return c.QuietStartHour != 0 && c.QuietStartHour == c.QuietEndHour
}
// inQuietHours 报告 at 是否落在低峰窗口内。窗口可跨午夜(23:00-07:00);
// 与 backthink.inQuietHours 同语义。
func (c TidyConfig) inQuietHours(at time.Time) bool {
if c.quietWindowDisabled() {
return true
}
h := at.Hour()
if c.QuietStartHour <= c.QuietEndHour {
return h >= c.QuietStartHour && h < c.QuietEndHour
}
return h >= c.QuietStartHour || h < c.QuietEndHour
}
// intervalAt 按 **at 所在时段** 选整理周期:安静时段用 QuietInterval,其余用
// Interval(= design/12 §N A1 给后台思考心跳定下的「窗口内降频」口径,搬进整理)。
//
// 主人 2026-10-07 的原话是「**频率必须调**,可以调到 1 小时或者三小时,**或者动态
// 分配**,比如白天间隔短一点,晚上到了静默时期稍微长一点」——这个函数就是「动态
// 分配」的唯一判据。
//
// 它是**纯函数**(只读 cfg 与入参),因此心跳可以在**每一拍之前**重算它:跨过
// 安静时段边界之后,下一拍自然换档,而不是启动时定死一次(调度侧的用法见
// TidyService.nextDelay)。
//
// 两条口径:
// - 安静判定**直接复用** inQuietHours,跨午夜口径与其余各处以字节为单位一致,
// 这里不另写一份「越走越偏」的窗口算法;
// - 窗口被显式关闭(两端相等且非 0)时不换档 —— 关闭窗口的意思是「没有低峰
// 这回事」,而不是「全天都算低峰」。
func (c TidyConfig) intervalAt(at time.Time) time.Duration {
if c.quietWindowDisabled() {
return c.Interval
}
if c.QuietInterval > 0 && c.inQuietHours(at) {
return c.QuietInterval
}
return c.Interval
}
// nextQuietStart 返回 at 之后(严格晚于 at)的下一个低峰窗口起点:
// 今天/明天的 QuietStartHour 整点。窗口关闭时返回零值时间,调用方据此
// 改走纯周期路径。
func (c TidyConfig) nextQuietStart(at time.Time) time.Time {
if c.quietWindowDisabled() {
return time.Time{}
}
start := time.Date(at.Year(), at.Month(), at.Day(), c.QuietStartHour, 0, 0, 0, at.Location())
if !start.After(at) {
start = start.AddDate(0, 0, 1)
}
return start
}
// currentQuietStart 返回 at **所在**的低峰窗口起点(不晚于 at):
// 落在窗口里(含跨午夜的半夜那一段)时是今天/昨天的 QuietStartHour 整点。
// 窗口关闭时返回零值时间。
//
// 它服务**补跑判据**(切片 TIDY-1):判断「这个窗口是不是已经跑过」只需要
// 「窗口起点 ≤ 上次 run」这一个比较,不必去构造「窗口内哪天」这种概念 ——
// 跨午夜的窗口(23:00–07:00)用日期算会算错。
func (c TidyConfig) currentQuietStart(at time.Time) time.Time {
if c.quietWindowDisabled() {
return time.Time{}
}
start := time.Date(at.Year(), at.Month(), at.Day(), c.QuietStartHour, 0, 0, 0, at.Location())
if start.After(at) {
start = start.AddDate(0, 0, -1)
}
return start
}