Files
Cyrene_v2/internal/runtime/memory/tidy_budget.go
T
AskaEth 6e28b38b08
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 护栏抬高+时间预算闸+试算加性异步 与 D3 入库前审查(耦合两片一起落)
【MT-A:护栏 + 诚实截断 + 试算异步】
- maxTidyCompareItems 400(写死)→2000 并开放为 MEMORY_TIDY_COMPARE_LIMIT;
  MEMORY_TIDY_CANDIDATE_LIMIT 默认 20→200(语义不变,仍每分区)
- 依据是首跑实测:459 条的分区被 400 截断 ⇒ compared(705) < scanned(784),
  候选数(77)由上限而不是规则决定;三处上限的 WARN 都补齐了总数/上限/分区名
- 新增「上限 + 时间预算」双闸(实测 2000 条/分区 ≈234s > 3 分钟墙钟):
  整分区跳过 / 两两比对中途收手,一律如实记 (budget) + WARN
- ?async=1 ⇒ 202 {runId,status};GET …/{runId} 轮询(writeAuth 同档);
  done 的 result 与同步 data 逐字节同形;并发=复用既有 runId、TTL 30min 自完成起算、
  重启即如实 404 tidy_run_not_found;不带 async 时同步路径逐字不变

【MT-B:D3 入库前审查(design/23 §15.1)】
- 新记忆经 Store.Add(仓库里唯一 INSERT INTO memory_items)落库后,由独立记忆管理
  会话(sess-memorytidy)在有界后台队列异步判定 skip/merge/modify/create
- 软合并回写(status + merged_into/merged_from/merge_count,可 revert、绝不改 content)
- 隔离来源(群聊/陌生人)**概不审查**、召回/超时失败**零改写**(fail-safe 结构性成立)
- 写入侧仅 +62.7µs/条(非阻塞入队);默认 ENABLED=true(主人裁定:入库前先审查)

【耦合说明(重要)】两片同包且互相依赖:MT-B 的 preinsert_recall.go 用 MT-A 的
collectTidyPairs 新签名 + tidyBudget ⇒ **各自单独都 build 不过**,故一并落地。
MT-A 为让整包编译,最小幅度适配了 MT-B 的 preinsert_recall.go / preinsert_test.go
(零值预算=行为不变;符号重名改名)。

【我的验收】隔离 worktree(37 文件)build/vet/linux/darwin 全过,切片包全绿;
我亲手打红两个变异:① 去掉 (compare) 截断标记 ⇒ TestTidyCompareLimit_HitIsLoud 红;
② 中和隔离来源守卫 ⇒ TestReviewRestrictedSourceIsNeverTouched 红。
唯一红是 internal/runtime/tool 的两条超时用例(该包无人改)⇒ 主仓库 -count=8 全绿,
判定为负载敏感 flake(当时两个整树验收 + 子代理并发),已记入开发习惯与约定 §7。

.env.example 补 MEMORY_REVIEW_* 四键(MT-B 无该文件权限,由我按其代码语义补写)。
2026-10-06 08:04:12 +08:00

98 lines
4.0 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"
"time"
)
// 本文件是记忆整理**时间预算闸**(切片 MT-A):它是「上限」之外的第二道闸。
//
// 为什么需要它:抬高护栏(compare 400 → 2000)之后,一个巨型分区可以在墙钟上限
// 内烧掉几分钟。同一台开发机上实测(临时库、单分区、CompareLimit=2000):
//
// n=2000 语义比对(1024 维)≈ 23.5 秒 ← 纯 O(n²·dim) 计算
// n=2000 词项余弦回退 ≈ 40.5 秒 ← 每对都重算词项集合,CPU 更贵
//
// 而真实的语义召回是**远端调用**:真机 89.9 秒 / 744 条 ⇒ 约 117 ms/条,2000 条
// 就是 234 秒,已经越过试算的 3 分钟墙钟上限。没有这道闸,结果是「整个请求超时、
// 一条候选都拿不到」;有它,本轮在 deadline 前收尾,把**已完成分区的结果**如实
// 交出去,并把这些分区标成 (budget) 截断。
//
// 它**不改变**心跳轮:心跳跑在进程生命周期 ctx 上(没有 deadline ⇒ 没有预算),
// 只有带墙钟上限的调用方(同步 / 异步试算)才受它约束。
const (
// tidyBudgetMargin 是时间预算相对 ctx deadline 的默认收尾余量:留出投影响应、
// 写注册表的时间,使本轮被**预算**截断,而不是被硬超时打断(后者会连已算完的
// 分区一起丢掉)。
tidyBudgetMargin = 10 * time.Second
// tidyBudgetMarginDivisor 是短预算下的余量折算:总预算本身短于 tidyBudgetMargin
// 时,余量退到预算的 1/divisor。否则「调用方只给了 5 秒」会被判成「一上来就
// 预算用尽」的假截断(3 分钟的试算预算不受影响)。
tidyBudgetMarginDivisor = 10
// tidyBudgetCheckEveryPairs 是两两比对循环里检查预算的间隔(按已比较的对数)。
// 每对都问一次时钟会把 O(n²) 循环本身拖慢;按批检查足以在预算用尽后很快收手。
tidyBudgetCheckEveryPairs = 4096
)
// tidyBudget 是本轮的时间预算(由调用方的 ctx deadline 派生)。零值 = 无预算
// (ctx 没有 deadline,如心跳轮 —— 那时它的行为与从前逐字一致)。
type tidyBudget struct {
deadline time.Time
margin time.Duration
enabled bool
// now 是判定预算的时钟:生产是 time.Now,测试可注入(因此「预算用尽 ⇒ 诚实
// 截断」这条路可以确定性地验,而不必真的等 3 分钟)。零值时钟视为无预算时钟,
// 由 newTidyBudget 填上。
now func() time.Time
}
// newTidyBudget 从 ctx 的 deadline 派生本轮预算。ctx 为 nil 或无 deadline 时
// 预算关闭。
func (t *Tidier) newTidyBudget(ctx context.Context) tidyBudget {
if ctx == nil {
return tidyBudget{}
}
deadline, ok := ctx.Deadline()
if !ok {
return tidyBudget{}
}
return tidyBudget{
deadline: deadline,
margin: tidyBudgetMarginFor(deadline.Sub(t.now())),
enabled: true,
now: t.now,
}
}
// tidyBudgetMarginFor 按剩余预算算出收尾余量(见 tidyBudgetMarginDivisor)。
func tidyBudgetMarginFor(total time.Duration) time.Duration {
switch {
case total <= 0:
return 0
case total < tidyBudgetMargin:
return total / tidyBudgetMarginDivisor
default:
return tidyBudgetMargin
}
}
// exhausted 报告预算是否已用尽。它按**本轮整理器的时钟**判断(生产 = time.Now;
// 测试可注入),而不是另开一个时间源 —— 预算讲的是「还能烧多久」,与判定用的
// 时钟同源才不会互相打架。
func (b tidyBudget) exhausted() bool {
if !b.enabled || b.now == nil {
return false
}
return b.now().After(b.deadline.Add(-b.margin))
}
// budgetTruncationLabel 渲染时间预算截断的分区标签。它与 (scan)/(compare)/
// (candidates) 同一形状(进响应的 truncated 数组),只是原因不同:那些是
// **配置的配额**被打满,这个是**墙钟预算**用尽。
func budgetTruncationLabel(p tidyPartition) string {
return p.label() + "(budget)"
}