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

+71 -23
View File
@@ -11,6 +11,7 @@ import (
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
@@ -38,23 +39,21 @@ const (
// DefaultRequestTimeout 是**单次** /embeddings 调用的墙钟上限,独立于 HTTP 客户端
// 那一层的 180s 总超时(httpRequestTimeout)。
//
// 依据(是实测数据,不是拍脑袋的调优):
// - 上层「入库前审查」的单条总预算是 20s(memory.defaultReviewTimeout),embedding
// 只是它内部的第一步。2026-10-07 实测(本机 llama.cpp + Qwen3-Embedding-0.6B,
// 服务空闲):单条文本约 60ms;**64 条一批约 2.1~2.9s**;审查最坏情况
// (ScanLimit=200 + 新条目 1)要 4 批 ⇒ 约 8~10s,已经占掉 20s 预算的一半,
// 留给后面的 T1 灰区判定(defaultT1Timeout=8s)的余量很薄。一次卡死的 HTTP
// 就能把这点余量连同整条审查一起吃掉(实测日志里
// `memory/review: context deadline exceeded` 正是这个形态);
// - 所以每次调用必须有**自己的**上限:卡住的那一批到点就失败并降级,不能把整条
// 审查的 20s 都赌在一次 HTTP 上;
// - 5s 的取值:相对实测的 64 条一批 2.9s 留约 1.7 倍余量,能吸收并发争用带来的
// 抖动,同时远小于 20s 的审查预算。若日后批次变大或服务变慢,先调这里而不是
// 调审查总预算;
// - 真慢就**快速失败**:调用方按**既有语义**降级(审查降级到词项余弦并如实留痕
// degraded),**绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则;
// - 已知代价:llama.cpp 首次加载模型可能超过 5s,那一次调用会失败并降级,模型
// 常驻后的下一次调用即恢复正常。降级本就是既有行为,因此可接受。
// 它**不是**单独取值:批次上限(DefaultBatchLimit / DefaultBatchChars)与它由
// budget.go 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。
// 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。
//
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 20s
// (memory.defaultReviewTimeout),embedding 只是它内部的第一步,后面还有 T1 灰区
// 判定(8s)。一次卡死的 HTTP 不能把整条审查的 20s 都赌掉。真慢就**快速失败**,
// 调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 degraded),
// **绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则。
//
// 5s 保持不变的依据:本次故障(2026-10-13)的成因不是这个数太小,而是批次太大 ——
// 64 条的实测耗时 7.5~8.5s 已经越过它,客户端放弃、服务端还在算,把整台本机
// embedding 服务顶住,连带单条请求(写向量索引)也一起超时。所以修法是**把批次
// 收进预算内**,而不是把预算放大到迁就批次(后者会让真挂死的请求也拖住整轮)。
// 冷启动那一次另有 warmupTimeout(见 budget.go),它只生效到第一次成功响应为止。
const DefaultRequestTimeout = 5 * time.Second
// openAICompatibleRequest is the JSON body sent to an OpenAI-compatible
@@ -93,6 +92,12 @@ 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
mu sync.RWMutex
dim int
@@ -130,12 +135,24 @@ 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.
func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder {
e := &APIEmbedder{
cfg: cfg,
client: defaultHTTPClient(),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
cfg: cfg,
client: defaultHTTPClient(),
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
timeout: DefaultRequestTimeout,
}
for _, opt := range opts {
opt(e)
@@ -155,8 +172,10 @@ func (e *APIEmbedder) Dim() int {
// returned vectors aligned by index. It returns ErrNoAPIURL for a blank URL
// and a wrapped error for any HTTP or decode failure.
//
// 单次调用带独立的、比上层审查总预算(20s)短得多的超时(DefaultRequestTimeout),
// 卡住即快速失败,把预算留给后续步骤;失败语义与降级口径不变。
// texts 长度不受调用方控制:本方法按 budget.go 推出来的上限(条数 / 字符总量)
// **自己切片**,分多次请求顺序发出,因此「一次请求的耗时 < 单次超时」这条不变式
// 对任何调用方都成立 —— 调用方给多大的切片都不会把服务端顶住。中途任一段失败即
// 整体失败(返回已发请求的错误),绝不半途改用混合表示。
func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) {
if len(texts) == 0 {
return []Vector{}, nil
@@ -168,7 +187,34 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro
return nil, ErrNoAPIURL
}
callCtx, cancel := context.WithTimeout(ctx, DefaultRequestTimeout)
// 切片用的上限由**稳态**超时推出(e.timeout),与 requestTimeout() 无关:
// 冷启动那一次只是**截止时刻**更宽(warmupTimeout),批次形状不变 —— 否则
// 首个请求会按 30s 的预算切出一个巨大的批,正好把冷启动的模型顶得更久。
items, chars := batchLimitsFor(e.timeout)
out := make([]Vector, 0, len(texts))
for _, chunk := range chunkTexts(texts, items, chars) {
vectors, err := e.embedChunk(ctx, chunk)
if err != nil {
return nil, err
}
out = append(out, vectors...)
}
return out, nil
}
// requestTimeout 返回本次调用该用的单次超时:拿到第一个成功响应之前用
// warmupTimeout(冷启动:模型加载 / GPU 缓冲分配),之后一律用配置的 timeout。
func (e *APIEmbedder) requestTimeout() time.Duration {
if e.warmed.Load() {
return e.timeout
}
return warmupTimeout
}
// embedChunk 发**一次** /embeddings 请求。chunk 必须已经落在 batchLimitsFor 推出来
// 的边界内(Embed 负责切),因此它的耗时上界不超过超时的一半。
func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector, error) {
callCtx, cancel := context.WithTimeout(ctx, e.requestTimeout())
defer cancel()
req, err := e.newEmbeddingRequest(callCtx, texts)
@@ -186,6 +232,8 @@ func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, erro
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 {