diff --git a/internal/runtime/embedding/api.go b/internal/runtime/embedding/api.go index 1ff6a61..41bde16 100644 --- a/internal/runtime/embedding/api.go +++ b/internal/runtime/embedding/api.go @@ -53,7 +53,10 @@ const ( // 64 条的实测耗时 7.5~8.5s 已经越过它,客户端放弃、服务端还在算,把整台本机 // embedding 服务顶住,连带单条请求(写向量索引)也一起超时。所以修法是**把批次 // 收进预算内**,而不是把预算放大到迁就批次(后者会让真挂死的请求也拖住整轮)。 -// 冷启动那一次另有 warmupTimeout(见 budget.go),它只生效到第一次成功响应为止。 +// +// 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、 +// 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成 +// 「每次调用都慢」—— 第二个请求起就回到本常量。 const DefaultRequestTimeout = 5 * time.Second // openAICompatibleRequest is the JSON body sent to an OpenAI-compatible @@ -92,12 +95,11 @@ type APIEmbedder struct { cfg config.EmbeddingConfig client *http.Client log *slog.Logger - // timeout 是**单次**请求的墙钟上限,与批次上限同源(见 budget.go)。 - // 默认 DefaultRequestTimeout;WithRequestTimeout 可换,换了批次上限也跟着换。 - timeout time.Duration - // warmed 报告是否已经拿到过成功响应。未热之前用 warmupTimeout(模型加载 / - // GPU 缓冲分配),热之后一律用 timeout。 - warmed atomic.Bool + // warmupLeft 是**一次性**冷启动放宽的开关:为真时下一个请求用 warmupTimeout, + // 取走即置假(CompareAndSwap ⇒ 并发下也只有一个请求能拿到)。 + // 它**不是**「未成功就一直放宽」—— 失败也照样花掉这一次,因此服务挂死时 + // 只会慢一次,之后每个请求都按 DefaultRequestTimeout 快速失败(见 budget.go)。 + warmupLeft atomic.Bool mu sync.RWMutex dim int @@ -135,25 +137,17 @@ func WithLogger(l *slog.Logger) APIOption { } } -// WithRequestTimeout 换掉单次请求的墙钟上限(非正数忽略,保留默认)。 -// 它同时决定批次上限 —— 两者是同一条推演(budget.go),因此这里是**唯一**的 -// 调优点,不允许调用方各自去改批次。 -func WithRequestTimeout(d time.Duration) APIOption { - return func(e *APIEmbedder) { - if d > 0 { - e.timeout = d - } - } -} - // NewAPI builds an APIEmbedder from cfg using the default HTTP client. +// 单次超时不可由选项更换:它是 budget.go 那条推演的输入,批次上限与它同源, +// 因此**只允许改常量**(改了之后批次上限自动跟着改,测试的编译期/常量断言立刻复核)。 func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder { e := &APIEmbedder{ - cfg: cfg, - client: defaultHTTPClient(), - log: slog.New(slog.NewTextHandler(io.Discard, nil)), - timeout: DefaultRequestTimeout, + cfg: cfg, + client: defaultHTTPClient(), + log: slog.New(slog.NewTextHandler(io.Discard, nil)), } + // 冷启动放宽是一个**一次性**许可:见 APIEmbedder.warmupLeft。 + e.warmupLeft.Store(true) for _, opt := range opts { opt(e) } @@ -187,10 +181,10 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro return nil, ErrNoAPIURL } - // 切片用的上限由**稳态**超时推出(e.timeout),与 requestTimeout() 无关: - // 冷启动那一次只是**截止时刻**更宽(warmupTimeout),批次形状不变 —— 否则 - // 首个请求会按 30s 的预算切出一个巨大的批,正好把冷启动的模型顶得更久。 - items, chars := batchLimitsFor(e.timeout) + // 切片用的上限由**稳态**超时推出(budget.go 的 DefaultRequestTimeout): + // 冷启动那一次只放宽**截止时刻**,批形状不变 —— 否则首个请求会按更宽的预算 + // 切出一个巨大的批,正好把冷启动的模型顶得更久。 + items, chars := batchLimitsFor(DefaultRequestTimeout) out := make([]Vector, 0, len(texts)) for _, chunk := range chunkTexts(texts, items, chars) { vectors, err := e.embedChunk(ctx, chunk) @@ -202,19 +196,25 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro return out, nil } -// requestTimeout 返回本次调用该用的单次超时:拿到第一个成功响应之前用 -// warmupTimeout(冷启动:模型加载 / GPU 缓冲分配),之后一律用配置的 timeout。 -func (e *APIEmbedder) requestTimeout() time.Duration { - if e.warmed.Load() { - return e.timeout +// callTimeout 返回本次请求该用的单次超时,并**当场花掉**一次性冷启动放宽许可 +// (成功、失败都算花掉)。因此: +// - 本进程的第一个请求最多等 warmupTimeout(冷启动), +// - 从第二个请求起一律 DefaultRequestTimeout —— 服务挂死时不会每次都慢。 +// +// 返回值 relaxed 表示这一次是不是拿到了放宽,供失败时留痕。 +func (e *APIEmbedder) callTimeout() (timeout time.Duration, relaxed bool) { + if e.warmupLeft.CompareAndSwap(true, false) { + return warmupTimeout, true } - return warmupTimeout + return DefaultRequestTimeout, false } // embedChunk 发**一次** /embeddings 请求。chunk 必须已经落在 batchLimitsFor 推出来 -// 的边界内(Embed 负责切),因此它的耗时上界不超过超时的一半。 +// 的边界内(Embed 负责切),因此它的耗时上界不超过单次超时的一半。 func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector, error) { - callCtx, cancel := context.WithTimeout(ctx, e.requestTimeout()) + // 先取超时(取走即花掉那次放宽),再建 ctx:放宽许可与这次请求一一对应。 + timeout, relaxed := e.callTimeout() + callCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() req, err := e.newEmbeddingRequest(callCtx, texts) @@ -224,6 +224,16 @@ func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector, resp, err := e.client.Do(req) if err != nil { + // 冷启动放宽被花掉且没成功时留一条 WARN:它是「本进程第一次请求」这个 + // 判断唯一能被事后复核的地方(此后一切失败都按常态超时快速失败)。 + // 只记两个预算与错误(错误文本里已带端点,不另记 URL,避免重复落一条 + // 可能含 userinfo 的地址)。 + if relaxed { + e.log.Warn("embedding: one-shot cold-start allowance spent without a successful response", + "warmup_timeout", warmupTimeout.String(), + "steady_timeout", DefaultRequestTimeout.String(), + "error", err) + } return nil, fmt.Errorf("embedding: request: %w", err) } defer func() { _ = resp.Body.Close() }() @@ -232,8 +242,6 @@ func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector, msg := readErrorBody(resp.Body) return nil, fmt.Errorf("embedding: status %d: %s", resp.StatusCode, msg) } - // 服务端确实答了 ⇒ 冷启动这一关过了,后续调用回到常态超时。 - e.warmed.Store(true) data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { diff --git a/internal/runtime/embedding/api_url_test.go b/internal/runtime/embedding/api_url_test.go index 7980b49..c7b8234 100644 --- a/internal/runtime/embedding/api_url_test.go +++ b/internal/runtime/embedding/api_url_test.go @@ -223,9 +223,10 @@ const reviewBudgetRef = 20 * time.Second // T1 灰区判定连执行机会都没有(2026-10-07 实测故障形态)。同时验证该超时确实 // 打在了发出去的请求上。 // -// ⚠️ 冷启动那一次走的是 warmupTimeout(见 budget.go),因此这里**以稳态请求** -// (第二次及以后)为准;冷启动那一路由 budget_test.go 的 -// TestAPIEmbedder_WarmupFirstCallBudget 单独钉。 +// 🔴 冷启动放宽(warmupTimeout)必须一并守住同一条界:它只发生一次,但它那一次的 +// 等待**也**不能吃光整轮审查 —— 否则「一次挂死 = 整轮审查没有预算给 T1」这个形态 +// 会从常态超时挪到放宽预算上原样复现(reviewer round1 的 p1)。因此这里对 +// warmupTimeout 做与 DefaultRequestTimeout **同一条**断言。 func TestDefaultRequestTimeout(t *testing.T) { t.Parallel() @@ -240,6 +241,19 @@ func TestDefaultRequestTimeout(t *testing.T) { t.Fatalf("DefaultRequestTimeout = %s, want 5s(值变了要连带复核审查预算口径)", DefaultRequestTimeout) } + // 冷启动放宽的两条界:仍在常态超时的**同一量级**(不得成倍放大), + // 且严格小于审查总预算(一次冷启动不许吃光整轮)。 + if warmupTimeout <= 0 { + t.Fatalf("warmupTimeout = %s, 必须为正", warmupTimeout) + } + if warmupTimeout > 2*DefaultRequestTimeout { + t.Fatalf("冷启动放宽 %s 超过常态超时 %s 的两倍 —— 它必须是同一量级的一次性放宽", + warmupTimeout, DefaultRequestTimeout) + } + if warmupTimeout >= reviewBudgetRef { + t.Fatalf("冷启动放宽 %s 必须严格小于审查总预算 %s(否则一次冷启动就吃光整轮审查)", + warmupTimeout, reviewBudgetRef) + } var slack time.Duration rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { diff --git a/internal/runtime/embedding/budget.go b/internal/runtime/embedding/budget.go index ed33f6e..815ffc8 100644 --- a/internal/runtime/embedding/budget.go +++ b/internal/runtime/embedding/budget.go @@ -15,6 +15,11 @@ import "time" // 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半(另一半是这台机器的 // 抖动余量)。批次上限由解这个不等式得到(见 DefaultBatchLimit / DefaultBatchChars)。 // +// ⚠️ 这条不等式**只按稳态超时(DefaultRequestTimeout)推导,且只约束批次形状**; +// 冷启动那次放宽(warmupTimeout)**不改批形状**,只放宽进程第一个请求的**截止 +// 时刻**,而且只发生一次(见 warmupTimeout 的说明)。批形状与超时仍然同源: +// 无论第一个请求的截止多宽,它带的条数/字符数永远按稳态超时算出来。 +// // ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp b11371 + // Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434,**服务器空闲**,正文取自活库 // data/cyrene.db:短正文约 33 汉字、长正文约 168 汉字;每档 5~6 次取最坏) @@ -66,13 +71,24 @@ const ( // 4 条 × 168 汉字 = 672 字符已越过它,会被再切一刀。 DefaultBatchChars = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perCharBudget) - // warmupTimeout 是**首次**调用(模型加载 / GPU 缓冲分配尚未发生)的墙钟上限。 - // 它**只在拿到第一个成功响应之前**生效,之后所有调用立刻回到 - // DefaultRequestTimeout —— 所以它不会让「真挂死的请求」长期拖住整轮审查, - // 又不让冷启动那一次注定失败(2026-10-13 部署后第一分钟生产日志里 + // warmupTimeout 是**一次性的**冷启动放宽:本 embedder 生命周期里的**第一个** + // 请求可以拿到这个截止(模型加载 / GPU 缓冲分配还没发生过),此后**每一个** + // 请求(含刚失败的那个之后的下一个)一律回到 DefaultRequestTimeout。 + // + // 🔴 它**不是**「只要还没成功过就一直放宽」—— 那个口径会在服务挂死时把 + // fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查的 20s 预算、 + // 索引写入的 5s 预算整段吃掉。一次性 + 8s 两条一起才是有界的: + // - 一次性:放宽最多花掉**一个**请求的时长,最坏情形之后立刻回到 5s; + // - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算 20s + // (memory.defaultReviewTimeout)⇒ 一次冷启动不可能吃光整轮审查, + // 后面的 T1 灰区判定(8s)仍有预算。这条不等式由测试守住 + // (api_url_test.go 的 TestDefaultRequestTimeout)。 + // + // 依据:2026-10-13 部署后第一分钟的生产日志里有一条 // `vector embed for index failed ... embed 1 texts ... context deadline exceeded` - // 就是这一路:模型还没热,5s 不够)。 - warmupTimeout = 30 * time.Second + // —— 模型还没热时 5s 不够;而稳态实测单条只要 0.03~0.19s(见上面的曲线), + // 所以放宽只需要覆盖「进程的第一个请求」这一刻。 + warmupTimeout = 8 * time.Second ) // callBudget 返回在 timeout 这个单次超时下,一次请求允许用掉的墙钟预算。 diff --git a/internal/runtime/embedding/budget_test.go b/internal/runtime/embedding/budget_test.go index 3120e7d..415489e 100644 --- a/internal/runtime/embedding/budget_test.go +++ b/internal/runtime/embedding/budget_test.go @@ -3,6 +3,7 @@ package embedding import ( "context" "encoding/json" + "errors" "io" "net/http" "strings" @@ -254,7 +255,7 @@ func TestAPIEmbedder_LegacyBatchOf64TimesOut(t *testing.T) { emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"}, WithRoundTripper(rt)) - // 先热一次(越过 warmupTimeout),否则第一次调用有冷启动预算、不会超时。 + // 先把一次性冷启动放宽花掉,否则第一次调用按 warmupTimeout 算、不会超时。 if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil { t.Fatalf("预热调用失败:%v", err) } @@ -269,10 +270,92 @@ func TestAPIEmbedder_LegacyBatchOf64TimesOut(t *testing.T) { } } -// TestAPIEmbedder_WarmupFirstCallBudget 钉冷启动那一路:第一次调用(模型加载 / -// GPU 缓冲分配)拿的是 warmupTimeout,**只有第一次**;一旦拿到成功响应,后续请求 -// 立刻回到 DefaultRequestTimeout(真挂死的请求不会被 30s 拖住)。 -func TestAPIEmbedder_WarmupFirstCallBudget(t *testing.T) { +// TestAPIEmbedder_WarmupIsOneShot 钉 p1 的核心:冷启动放宽**一次性**,三种失败形态 +// 下都必须是「第一次最多等 warmupTimeout,第二次起一律 DefaultRequestTimeout」。 +// +// 这是 reviewer 在 round1 打回的那条:旧实现 `warmed` 只在 2xx 之后才翻,于是 +// 「服务接受连接但永不回包」时**每一次**调用都等完整放宽预算(30s),把 fail-fast +// 变成 fail-slow —— 一次挂死就能吃光审查的 20s 预算、把索引写入拖成 8×30s。 +func TestAPIEmbedder_WarmupIsOneShot(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + rt func(t *testing.T) http.RoundTripper + }{ + { + name: "连接被拒", + rt: func(t *testing.T) http.RoundTripper { + return roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp 127.0.0.1:11434: connectex: 目标计算机积极拒绝") + }) + }, + }, + { + name: "HTTP 500", + rt: func(t *testing.T) http.RoundTripper { + return roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader("server exploded")), + Header: make(http.Header), + }, nil + }) + }, + }, + { + name: "接受连接但永不回包", + rt: func(t *testing.T) http.RoundTripper { + return roundTripFunc(func(r *http.Request) (*http.Response, error) { + <-r.Context().Done() // 只等服务端超时,永不回包 + return nil, r.Context().Err() + }) + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + emb := NewAPI(config.EmbeddingConfig{APIURL: "http://127.0.0.1:11434/v1/embeddings"}, + WithRoundTripper(tc.rt(t))) + + // 第一次:放宽预算。断言上界 = warmupTimeout(不得再宽), + // 下界只对会真正等到截止的那一种(挂死)有意义。 + start := time.Now() + _, err := emb.Embed(context.Background(), shortTexts(1)) + first := time.Since(start) + if err == nil { + t.Fatal("这一组失败形态不该成功") + } + t.Logf("首次耗时 = %s", first.Round(time.Millisecond)) + if first > warmupTimeout+time.Second { + t.Fatalf("首次耗时 %s 超过冷启动放宽 %s", first, warmupTimeout) + } + + // 第二次:**必须**回到常态超时 —— 一次挂死绝不许变成每次都慢。 + start = time.Now() + if _, err := emb.Embed(context.Background(), shortTexts(1)); err == nil { + t.Fatal("这一组失败形态不该成功") + } + second := time.Since(start) + t.Logf("第二次耗时 = %s(常态超时 %s)", second.Round(time.Millisecond), DefaultRequestTimeout) + if second > DefaultRequestTimeout+time.Second { + t.Fatalf("第二次耗时 %s 超过常态超时 %s ⇒ 放宽成了长期的", second, DefaultRequestTimeout) + } + // 放宽许可必须已经被花掉(它是一次性的,任何路径都不许给它续期)。 + if emb.warmupLeft.Load() { + t.Fatal("第二次请求之后放宽许可仍为真 —— 一次性的约束被绕过了") + } + }) + } +} + +// TestAPIEmbedder_WarmupCoversColdStartOnce 钉放宽的**正面**作用:第一次请求 +// (模型加载 / GPU 缓冲分配)可以比常态超时多等一点,只要它落在 warmupTimeout +// 之内;而这一刻过去之后,请求立刻回到常态超时(deadline 断言)。 +func TestAPIEmbedder_WarmupCoversColdStartOnce(t *testing.T) { t.Parallel() rt := &latencyTripper{firstSleep: DefaultRequestTimeout + time.Second} @@ -282,8 +365,8 @@ func TestAPIEmbedder_WarmupFirstCallBudget(t *testing.T) { if _, err := emb.Embed(context.Background(), shortTexts(1)); err != nil { t.Fatalf("冷启动第一次调用应被 warmupTimeout(%s) 兜住:%v", warmupTimeout, err) } - if !emb.warmed.Load() { - t.Fatal("拿到成功响应后 warmed 应为真") + if emb.warmupLeft.Load() { + t.Fatal("放宽许可应在第一次请求时就被花掉") } var slack time.Duration diff --git a/internal/runtime/memory/tidy_dryrun.go b/internal/runtime/memory/tidy_dryrun.go index 2b23451..14e0dc3 100644 --- a/internal/runtime/memory/tidy_dryrun.go +++ b/internal/runtime/memory/tidy_dryrun.go @@ -76,22 +76,30 @@ func (t *Tidier) DryRun(ctx context.Context) (TidyDryRun, error) { out.Stats = stats out.Candidates = collected - // 如实上报**实际发生**的来源:空串 = 没有任何分区真正算过相似度(「没候选」, - // 与「向量不可用」不是一回事)。 + // 如实上报**实际发生**的来源:空串 = 没有任何分区真正算过相似度(「没候选」)。 + // 「尝试过但向量源不可用」是另一件事,由 recall_unavailable_partitions 单独给出 + // (见 logDryRun 的说明与 recallLedger.unavailable)。 out.RecallMode = ledger.label() out.DurationMS = t.now().Sub(started).Milliseconds() + // 汇总行**成败都记**(与 RunOnce 同一口径):失败那一路的 + // `recall_unavailable_partitions` 正是「向量不可用」与「没候选」的唯一区分点, + // 只在成功时记就等于把这条口径丢掉。 + t.logDryRun(out, ledger) if len(failures) > 0 { return out, fmt.Errorf("memory/tidy: dry-run finished with errors: %w", errors.Join(failures...)) } - t.logDryRun(out) return out, nil } // strictRecall 是**试算轮**的召回:语义召回不可用时**不**降级,如实上报。 // 日志只记分区与错误,绝不带记忆正文(候选预览里已含正文)。 +// +// 失败时要往 ledger 记一笔 unavailable:否则「尝试过但向量源不可用」与「没有候选 +// 可比」在观测面上同值(reviewer round1 的 p2)。 func (t *Tidier) strictRecall(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 { + ledger.noteUnavailable() t.log.Warn("memory tidy dry-run recall unavailable", "partition", p.label(), "items", len(items), "error", err) return nil, fmt.Errorf("%w: %w", ErrTidyRecallUnavailable, err) @@ -101,7 +109,17 @@ func (t *Tidier) strictRecall(ctx context.Context, p tidyPartition, items []Memo // logDryRun 输出试算的观测口径。它带 source(memory/tidy),**不带任何记忆 // 正文** —— 只记计数、分区数与截断标签。 -func (t *Tidier) logDryRun(out TidyDryRun) { +// +// 🔴 `recall_mode` + `recall_unavailable_partitions` 一起才把三件事分开 +// (reviewer round1 的 p2:它们曾同值): +// - mode=""、unavailable=0:本轮**没有候选可比**(压根没算过相似度); +// - mode=""、unavailable>0:**尝试过召回但向量源不可用**(严格口径不降级, +// 调用方拿到的是 `ErrTidyRecallUnavailable` ⇒ HTTP 500 `tidy_recall_unavailable`); +// - mode=embedding/lexical:真的用该来源算过。 +// +// 没有为此扩 `recallMode` 的取值集合:试算失败时**不序列化** result(响应是错误 +// 体),一个新取值只会出现在这行日志里,却要动一次 API 契约与文档 —— 不划算。 +func (t *Tidier) logDryRun(out TidyDryRun, ledger *recallLedger) { t.log.Info("memory tidy dry-run finished", "rule_version", TidyRuleVersion, "partitions", out.Stats.Partitions, @@ -113,6 +131,7 @@ func (t *Tidier) logDryRun(out TidyDryRun) { "veto_reasons", tidyReasonsLabel(out.Stats.VetoReasons), "errors", out.Stats.Errors, "recall_mode", out.RecallMode, + "recall_unavailable_partitions", ledger.unavailablePartitions(), "duration_ms", out.DurationMS) } diff --git a/internal/runtime/memory/tidy_recall.go b/internal/runtime/memory/tidy_recall.go index 65973e1..aed1c2b 100644 --- a/internal/runtime/memory/tidy_recall.go +++ b/internal/runtime/memory/tidy_recall.go @@ -45,6 +45,16 @@ type recallLedger struct { // embedder」那种配置形态)。它把「没候选」(label 为空串)与「向量不可用」 // (label 为 lexical 且 degraded > 0)分开 —— 这两件事在运维上不是一回事。 degraded int + // unavailable 是**尝试过召回但向量源不可用、且本轮不降级**(试算轮的严格口径) + // 的分区数。它与 degraded 的区别是「有没有改用词项余弦」:生产轮会降级 + // (degraded),试算轮只报错。 + // + // 🔴 它存在的理由(reviewer round1 的 p2):试算轮在向量源不可用时 + // `RecallMode` 与「没有候选可比」**同为空串**,两者在那个字段上分不开。 + // 现在 provenance 由 unavailablePartitions() 单独给出,日志行能区分; + // 调用方(面板)那条路本来就有明确错误码(500 tidy_recall_unavailable), + // 因此**没有**为此扩 recallMode 的取值集合(见 logDryRun 的说明)。 + unavailable int } // noteEmbedding 记一笔「这个分区真的用了语义 embedding」。 @@ -66,10 +76,22 @@ func (l *recallLedger) noteLexical(degraded bool) { } } +// noteUnavailable 记一笔「这个分区尝试召回但向量源不可用,且本轮没有降级」 +// (试算轮的严格口径:宁可报错,也不把「没算出来」说成「算过了,没有」)。 +func (l *recallLedger) noteUnavailable() { + if l != nil { + l.unavailable++ + } +} + // label 返回本轮**实际**的召回来源: // - "":没有任何分区真正算过相似度(没有候选可比)—— 「没候选」不是「降级」; // - embedding / lexical:所有算过的分区都走同一条路; // - mixed:两种都有(多分区里有一部分降级)。 +// +// ⚠️ 「尝试过但不可用」**不改变** label:它没有产出任何相似度,报 embedding / +// lexical 都是假的;它的存在由 unavailablePartitions() 单独给出(见 recallLedger +// 的 unavailable 字段说明)。 func (l *recallLedger) label() string { if l == nil { return "" @@ -94,6 +116,14 @@ func (l *recallLedger) degradedPartitions() int { return l.degraded } +// unavailablePartitions 返回本轮「尝试召回但向量源不可用、且没有降级」的分区数。 +func (l *recallLedger) unavailablePartitions() int { + if l == nil { + return 0 + } + return l.unavailable +} + // tidyPair 是一条被召回的候选对。 type tidyPair struct { a, b MemoryItem diff --git a/internal/runtime/memory/tidy_recall_mode_test.go b/internal/runtime/memory/tidy_recall_mode_test.go index 28be31e..d955e1d 100644 --- a/internal/runtime/memory/tidy_recall_mode_test.go +++ b/internal/runtime/memory/tidy_recall_mode_test.go @@ -151,24 +151,55 @@ func TestTidyRunLog_RecallModeEmptyWhenNoPairs(t *testing.T) { } } -// TestTidyDryRun_RecallModeIsActualNotConfigured 是**试算轮**(面板那一条)的同口径 -// 回归:配了 embedder 但向量不可用 ⇒ 试算如实报错(严格模式),且返回的 RecallMode -// **不**写成 embedding。 -func TestTidyDryRun_RecallModeIsActualNotConfigured(t *testing.T) { +// TestTidyDryRun_UnavailableIsDistinctFromNoCandidates 钉 reviewer round1 p2: +// 试算轮「尝试过召回但向量源不可用」与「没有候选可比」**必须能区分**。 +// +// 两者的 `RecallMode` 都是空串(都不曾产出相似度,报 embedding/lexical 都是假的), +// 区分点在 `recall_unavailable_partitions`:不可用时为 1,没候选时为 0。调用方 +// (面板)另有一条更硬的区分 —— 失败那一路不序列化 result,直接是 +// `500 tidy_recall_unavailable`。 +func TestTidyDryRun_UnavailableIsDistinctFromNoCandidates(t *testing.T) { t.Parallel() - ctx := context.Background() - store := newTestStore(t) - seedDryRunFixture(t, store) - emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")} - tr, _ := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb) - out, err := tr.DryRun(ctx) - if !errors.Is(err, ErrTidyRecallUnavailable) { - t.Fatalf("试算轮必须如实上报不可用,err = %v", err) - } - if out.RecallMode == tidyRecallEmbedding { - t.Fatalf("向量不可用却把 RecallMode 报成 embedding(配置态口径):%q", out.RecallMode) - } + t.Run("向量源不可用", func(t *testing.T) { + t.Parallel() + store := newTestStore(t) + seedDryRunFixture(t, store) + + emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")} + tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb) + out, err := tr.DryRun(context.Background()) + if !errors.Is(err, ErrTidyRecallUnavailable) { + t.Fatalf("试算轮必须如实上报不可用,err = %v", err) + } + if out.RecallMode == tidyRecallEmbedding { + t.Fatalf("向量不可用却把 RecallMode 报成 embedding(配置态口径):%q", out.RecallMode) + } + line := lastLogLine(t, buf, "memory tidy dry-run finished") + if !strings.Contains(line, "recall_unavailable_partitions=1") { + t.Fatalf("汇总行必须写出「尝试过但不可用」的计数,实际:%s", line) + } + }) + + t.Run("没有候选可比", func(t *testing.T) { + t.Parallel() + store := newTestStore(t) + base := tidyTestTime(t) + seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9)) + + emb := failingEmbedder{err: errors.New("embedding: request: context deadline exceeded")} + tr, buf := newLogCaptureTidier(t, store, tidyTestCfg(0.80), emb) + if _, err := tr.DryRun(context.Background()); err != nil { + t.Fatalf("没有对可比时试算不该报错:%v", err) + } + line := lastLogLine(t, buf, "memory tidy dry-run finished") + if !strings.Contains(line, "recall_unavailable_partitions=0") { + t.Fatalf("没候选不是「不可用」,计数必须为 0,实际:%s", line) + } + if !strings.Contains(line, `recall_mode=""`) { + t.Fatalf("没候选时来源为空串,实际:%s", line) + } + }) } // lastLogLine 返回 buffer 里最后一条含 marker 的日志行。