Files
AskaEth 058fa141bf docs(memory): 请求上界按条数维+字符维两维算准(review-m61 P2-1)
embedding.Embed 同时受条数界(8)与字符界(480 字)约束 ⇒ 一次 embedOnce(B 条)
会发 C 个请求(C 由正文长度决定,1..B),因此:

  正常批            C
  含一条坏正文      ≤ C + B(短正文 9;均长 75 汉字的现实批 10;病理上限 2B=16)
  服务整体挂死      首个 chunk 即失败 + 逐条 B ⇒ 1 + B = 9(与字符维无关)

修正 embed_isolation.go 文件头的上界表、approxRequests 的「两者一致」表述、
embedReuseStats.Sent 的注释,以及 embed_resilience_test.go 的上界注释与日志文案。
零行为改动:判据、实现、请求数都未动,测试全绿。
2026-10-30 09:04:14 +08:00

122 lines
5.8 KiB
Go
Raw Permalink 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"
"errors"
"fmt"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件是嵌入链对**单个慢分片**的韧性(design/23 §15.3 登记的遗留,2026-10-13):
// `embedding.Embed` 的语义是「任一分片失败 ⇒ 整条调用失败」,于是一条慢分片会把
// 其余已经算好的分片一起丢掉 —— 审查因此整条降级到词项余弦(92% 的
// `recall_mode` 为空的一半成因),整理轮同理。
//
// 修法**不在** embedding 包里改语义(那是四个调用方共享的口径,改它等于改所有
// 人的预算推演),而是在调用方这一层做**逐条隔离**:整批先发一次;失败则把这一批
// 拆成单条逐条重试 —— 好的照常拿到向量,坏的那几条记缺口,**整条调用不失败**。
//
// ## 为什么是「逐条」而不是递归二分
//
// 二分定位一条坏文本在短正文下要 1+2+2+2 = 7 个请求(比逐条的 9 少 2 个),但它
// 需要一套失败预算的簿记(哪一层还允许拆、拆到多深收手),而那套簿记本身就是
// 「韧性变成放大」的风险点:预算给紧了会把好正文一起丢掉,给松了会在服务挂死时
// 把请求数放大成 2^深度。逐条隔离的代价**恒定且可算**:`embedOnce` 调用 ≤ 1+B
// (B = 批上限),定位精度与二分相同(精确到单条),没有任何深度参数。
//
// ## 三条必须同时成立的约束
//
// 1. **定位**:只有整批失败才逐条重试,因此正常路径(绝大多数批)**零额外请求**;
// 2. **止损**:一条都没拿到 ⇒ 如实返回错误(调用方降级到词项余弦);有拿到就
// 只损失坏的那几条 —— 绝不把「算不出来」说成「没有相似」;
// 3. **预算**:父 ctx 已到期时不再逐条重试(预算花完了,重试只会把 worker 拖住)。
//
// ## HTTP 请求数的上界(必须按**两维**切片算,B = 批上限 = 8)
//
// `embedding.Embed` 不是「一批一个请求」:它按 budget.go 的**条数界**
// (DefaultBatchLimit=8)**与字符界**(DefaultBatchChars=480)同时切片
// (chunkTexts),因此一次 `embedOnce(B 条)` 会发出 C 个请求,C 由正文长度决定:
// 全批 ≤480 字 ⇒ C=1;均长 75 汉字(budget.go 的实测口径)时 8×75=600 > 480 ⇒ C=2;
// 每条自己就超过 480 字 ⇒ 每条独占一片 ⇒ C=B(病理上限)。
// 单条重试**永不切分**(它自己就是一片)⇒ 每条恰好 1 个请求。于是:
//
// 正常批 C(短正文 C=1;长正文 2 起)
// 含一条坏正文 首次失败发生在第 1~C 个 chunk 之一,随后逐条 B 次
// ⇒ ≤ C + B(短正文 9;现实长正文 10;病理上限 2B=16)
// 服务整体挂死 首个 chunk 即失败 + 逐条 B 次 ⇒ **1 + B = 9**(与字符维无关)
//
// 口径与 budget.go 同源:那里的「建模上界 ≠ 延迟保证」是同一类提醒 —— 这里的
// 「≤1+B」是**embedOnce 调用次数**的上界,HTTP 请求数必须再按字符维的 chunk 数算
// (正常批 C、含坏正文 ≤C+B);只有「服务整体挂死 = 1+B」这一条与字符维无关。
// ErrEmbedUnavailable 在「一条向量都没拿到」时返回:调用方据此走既有的词项余弦
// 降级(记忆一条不丢、判定规则一字不改),而不是把「算不出来」当成「没有相似」。
var ErrEmbedUnavailable = errors.New("memory: embedder returned no vector for any input")
// ErrEmbedCountMismatch 在 embedder 返回的向量条数与入参条数不一致时返回:
// 错位会让相似度与条目错配,因此按失败处理(随后由逐条隔离定位是哪一条出的问题)。
var ErrEmbedCountMismatch = errors.New("memory: embedder returned a mismatched vector count")
// embedIsolated 嵌入 texts 并返回与之对齐的向量:失败的位置为 nil,其余照常。
//
// 返回错误的**唯一**情形是「一个向量都没拿到」(或父 ctx 已到期);此时调用方按
// 既有语义降级到词项余弦。
func embedIsolated(ctx context.Context, emb embedding.Embedder, texts []string) ([]embedding.Vector, error) {
if len(texts) == 0 {
return nil, nil
}
vecs, err := embedOnce(ctx, emb, texts)
if err == nil {
return vecs, nil
}
if len(texts) == 1 {
// 单条失败:这条慢/坏分片只损失它自己。
return []embedding.Vector{nil}, nil
}
if cerr := ctx.Err(); cerr != nil {
return nil, fmt.Errorf("memory: embed %d texts: %w (%w)", len(texts), err, cerr)
}
out := make([]embedding.Vector, len(texts))
for i, text := range texts {
if ctx.Err() != nil {
break
}
one, oneErr := embedOnce(ctx, emb, []string{text})
if oneErr != nil {
// 这一条就是坏的那个分片:留下缺口,别让同一批里的其它条目连坐。
continue
}
out[i] = one[0]
}
if nonEmptyVectors(out) == 0 {
// 逐条重试也没拿到任何向量 ⇒ 故障是整体性的(服务挂死 / 地址错),
// 如实报错让调用方降级,而不是交回一批「没有向量」的空结果。
return nil, err
}
return out, nil
}
// embedOnce 发一次 Embed,并要求向量条数与入参一致。它是隔离的「一次尝试」。
func embedOnce(ctx context.Context, emb embedding.Embedder, texts []string) ([]embedding.Vector, error) {
vecs, err := emb.Embed(ctx, texts)
if err != nil {
return nil, err
}
if len(vecs) != len(texts) {
return nil, fmt.Errorf("%w (got %d, want %d)", ErrEmbedCountMismatch, len(vecs), len(texts))
}
return vecs, nil
}
// nonEmptyVectors 数一个对齐切片里真正拿到向量的条数。
func nonEmptyVectors(vecs []embedding.Vector) int {
n := 0
for _, v := range vecs {
if len(v) > 0 {
n++
}
}
return n
}