fix(memory): embedding 批上限与超时同源 —— 不再「客户端先放弃、服务端继续算」

M9(EMBRECALL)。故障机制(M6 + 本次复测):客户端 5s 单次超时先放弃,服务端仍在
算那一批 ⇒ 本机 llama.cpp 被自己顶住 ⇒ 后续每个请求(含写向量索引的单条请求)
排队到自己的超时 ⇒ 召回全线降级。

- embedding/budget.go(新):把「一次请求的耗时上界 ≤ 单次超时的一半」写成不变式,
  批上限(条数 4 / 字符 480)与 5s 超时由**同一个不等式**解出;实测依据与复跑命令
  写进注释(空闲态摊销曲线 1→0.19s / 8→1.1s / 12→1.3s / 64→8.5s,约 50ms 固定 +
  2.7~3.9ms/字符,n≥32 起有数倍放大 ⇒ 余量取一半)。
- embedding/api.go:Embed 按同一对上限**自己切片**(不变式与调用方无关);冷启动
  首次调用单独给 warmupTimeout,拿到第一个成功响应后立刻回常态超时。
- memory/tidy_config.go:tidyEmbedBatch = embedding.DefaultBatchLimit(同源),
  并加编译期断言 —— 调回 64 会直接编译失败(constant -60 overflows uint)。
- memory/tidy.go | tidy_recall.go | tidy_dryrun.go:recall_mode 改报**实际发生**
  的来源(""=本轮没候选可比 / embedding / lexical / mixed),新增
  recall_degraded_partitions;试算轮 RecallMode 同口径(M6 medium finding)。
- 测试:embedding 侧用「实测曲线 + 会睡够时间的假客户端」钉住两态
  (批内 < 超时 ⇒ 不降级;旧配对 64 条 ⇒ 必然超时),并断言 abandoned == 0;
  memory 侧钉住降级/健康/没候选三态与试算轮的如实上报。

不改 preinsert*、不改 .env、不动判定规则与任何阈值。
This commit is contained in:
AskaEth committed 2026-10-13 20:33:26 +08:00
1 parent 68ba85389b
commit 59672720bd
9 files changed
+836 -45

No files matched your search

+23 -6
View File
@@ -119,12 +119,17 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
return stats, err
}
stats.RunID = runID
// ledger 是**本轮实际发生**的召回来源记账口(不是配置里那一种):生产轮可以
// 多分区混合(有的走语义、有的降级),因此汇总行只能由实测记录推出。
ledger := &recallLedger{}
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
t.persistCandidates(ctx, runID, cands, stats)
},
recall: t.fallbackRecall,
recall: func(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
return t.fallbackRecall(ctx, p, items, stats, budget, ledger)
},
})
// 轮末自动落地(2026-10-07 主人口径):规则层判为高置信的待定候选直接落地,
@@ -150,7 +155,7 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
failures = append(failures, err)
}
t.logRun(stats, run)
t.logRun(stats, run, ledger)
t.auditAutoApply(auto)
switch {
@@ -165,7 +170,14 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
// logRun 输出本轮的可观测口径:扫描 N / 候选 M / 建议 K / 否决原因分布。
// 它带 source(memory/tidy),因此这条汇总永远可回溯到整理子系统。
func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
//
// ledger 给出的是**本轮实际发生**的召回来源(不是配置态)。这一条是 M6 2026-10-13
// 的 medium finding 的落点:旧实现按「有没有配 embedder」写 recall_mode,全量降级
// 在汇总行里被写成 embedding。现在:
// - recall_mode = "":没有任何分区真正算过相似度(没有候选可比);
// - recall_mode = lexical ∧ recall_degraded_partitions > 0:语义不可用、降级了;
// - recall_mode = mixed:多分区里一部分走了语义、一部分降级 —— 不再被抹平。
func (t *Tidier) logRun(stats TidyStats, run TidyRun, ledger *recallLedger) {
fields := []any{
"run_id", run.ID,
"rule_version", run.RuleVersion,
@@ -181,7 +193,8 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
"kept", stats.Kept,
"errors", run.Errors,
"duration_ms", run.FinishedAt.Sub(run.StartedAt).Milliseconds(),
"recall_mode", tidyRecallMode(t.deps.Embedder != nil),
"recall_mode", ledger.label(),
"recall_degraded_partitions", ledger.degradedPartitions(),
"auto_apply", t.cfg.autoApplyEnabled(),
"auto_applied", stats.AutoApplied,
"auto_merge", stats.AutoMerged,
@@ -232,11 +245,15 @@ func (t *Tidier) scanPartitions(ctx context.Context, partitions []tidyPartition,
// fallbackRecall 是**生产轮**的召回:相似度来源不可用时降级到零依赖的词项
// 余弦(离线/无 embedding 配置时可跑,宁可比语义路径更保守),并记一条 WARN。
// 两个来源**不可混用阈值**:阈值只有一个(design §13 开放点 2 的 0.80)。
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget)
//
// 降级这一笔**必须记进 ledger**:汇总行/面板上的召回来源只能由实测记录推出,
// 否则「全量降级」会被报成「向量正常」(M6 2026-10-13 的 medium finding)。
func (t *Tidier) fallbackRecall(ctx context.Context, p tidyPartition, items []MemoryItem, stats *TidyStats, budget tidyBudget, ledger *recallLedger) ([]tidyPair, error) {
pairs, err := t.recallPairs(ctx, p, items, stats, budget, ledger)
if err == nil {
return pairs, nil
}
ledger.noteLexical(true)
t.log.Warn("memory tidy embedding recall unavailable, falling back to lexical",
"partition", p.label(), "error", err)
return t.collectPairs(p, items, t.cfg.RecallThreshold,