review round1 的 p1/p2 收口(commit 5967272 之后)。
p1:旧实现 `warmed` 只在 2xx 之后才翻 ⇒ 注释里写的「只在第一次成功响应之前」
实际是「服务坏着的全部时间」。reviewer 实测「接受连接但永不回包」时**每次**调用
都等满 30s(fail-fast 变 fail-slow):审查 20s 预算被一次吃光、索引写入拖成
8×30s、开机探针阻塞 30s。
- `warmupLeft` 改为**一次性许可**(CompareAndSwap 取走即失效,成败都算花掉)
⇒ 挂死时只有第一个请求慢一次,此后一律 DefaultRequestTimeout。
- `warmupTimeout` 30s → 8s:与常态 5s 同一量级(≤2×),且严格小于审查总预算
20s(一次冷启动不许吃光整轮);两条界都由 TestDefaultRequestTimeout 守住。
- 放宽被花掉且没成功时留一条 WARN(事后可复核「第一个请求」这个判据)。
- 更正两处自述:api.go 的「不会被 30s 拖住」与 budget.go 的不变式说明(批次形状
永远按稳态超时推导;冷启动只放宽第一个请求的截止时刻)。
- p2:删掉零调用者的 `WithRequestTimeout` —— 单次超时只允许改常量,批次上限与它
同源并由编译期/常量断言复核。
p2:试算轮在向量源不可用时 `RecallMode` 与「没有候选可比」同为空串。
- `recallLedger` 增 `unavailable` 计数 + `noteUnavailable()`(strictRecall 失败
那一笔记一笔);试算汇总行新增 `recall_unavailable_partitions`,**成败都记**。
- 不扩 `recallMode` 取值集合:失败那一路不序列化 result(响应是
500 tidy_recall_unavailable),新取值只会出现在日志里却要动一次契约,不划算。
测试:三种失败形态 × 两次调用的耗时断言(连接被拒 / 500 / 永不回包),
试算轮的「不可用」与「没候选」两态日志断言;两处变异自证(warmupTimeout 调回
30s ⇒ TestDefaultRequestTimeout 红;去掉 noteUnavailable ⇒ 试算汇总行退化成
recall_mode="" + recall_unavailable_partitions=0)。
146 lines
7.2 KiB
Go
146 lines
7.2 KiB
Go
package embedding
|
||
|
||
import "time"
|
||
|
||
// 本文件是**单次 /embeddings 调用的预算推演**:批次上限与单次超时必须由**同一个
|
||
// 式子**得出。任一侧单独调整都会让「一次请求的实测耗时 < 单次超时」这条不变式
|
||
// 静默失效 —— 而失效的后果不是报错,是**服务端还在算、客户端已经放弃**:被放弃的
|
||
// 那一批仍占着本机 llama.cpp 的算力,后面每一个请求(包括写索引时的单条请求)都
|
||
// 排在它后面等到自己的超时,于是整条记忆写入路径被拖住、召回全线降级。
|
||
//
|
||
// ## 不变式
|
||
//
|
||
// requestOverhead + 一次请求的字符总量 × perCharBudget ≤ DefaultRequestTimeout / callBudgetShare
|
||
//
|
||
// 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半(另一半是这台机器的
|
||
// 抖动余量)。批次上限由解这个不等式得到(见 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 次取最坏)
|
||
//
|
||
// n=1 短 0.066~0.185s 长 0.740s
|
||
// n=8 短 0.86~1.11s
|
||
// n=12 短 1.27s 长 5.47s
|
||
// n=16 短 2.20~3.31s
|
||
// n=64 短 7.49~8.49s(改前的批次上限)
|
||
//
|
||
// 拟合:单次请求耗时 ≈ 固定开销 + 字符总量 × 每字符成本;固定开销实测约 50ms,
|
||
// 每字符成本实测 2.7~3.9ms。**同一批 n≥32 起出现过数倍放大**(8.97s / 26.6s),
|
||
// 因此大批次的耗时不可预测 —— 这也是「余量取一半」而不是「卡着边界」的依据。
|
||
//
|
||
// 复跑命令(读活库正文、只连本机 embedding 服务、不写任何库):
|
||
//
|
||
// python - <<'PY'
|
||
// import json,sqlite3,time,urllib.request
|
||
// URL="http://127.0.0.1:11434/v1/embeddings"; MODEL="<model id>"
|
||
// t=[r[0] for r in sqlite3.connect("data/cyrene.db").execute(
|
||
// "select content from memory_items order by id limit 64")]
|
||
// for b in (t[:1],t[:8],t[:12],t[:16],t[:64]):
|
||
// body=json.dumps({"model":MODEL,"input":b}).encode()
|
||
// r=urllib.request.Request(URL,data=body,headers={"Content-Type":"application/json"})
|
||
// s=time.perf_counter(); urllib.request.urlopen(r,timeout=300).read()
|
||
// print(len(b), round(time.perf_counter()-s,3))
|
||
// PY
|
||
const (
|
||
// requestOverhead 是单次 HTTP 调用的固定开销上界(实测 ~50ms,向上取整)。
|
||
requestOverhead = 100 * time.Millisecond
|
||
|
||
// perCharBudget 是**每字符**的墙钟预算上界(实测 2.7~3.9 ms/字符,取 5ms)。
|
||
perCharBudget = 5 * time.Millisecond
|
||
|
||
// perItemBudget 是**每条文本**的墙钟预算上界:按活库里最长的正文(168 汉字,
|
||
// 最坏摊销 0.456 s/条)向上取整到 500ms。
|
||
perItemBudget = 500 * time.Millisecond
|
||
|
||
// callBudgetShare 表示一次请求最多用掉超时的 1/2:另一半是抖动余量
|
||
// (见上面 n≥32 的数倍放大)。调大它 = 余量变小,必须连带复核实测。
|
||
callBudgetShare = 2
|
||
|
||
// DefaultBatchLimit 是单次请求的**条数**上限,由上面三个常量推出来(唯一推演):
|
||
// (5s/2 − 100ms) / 500ms = 4。
|
||
DefaultBatchLimit = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perItemBudget)
|
||
|
||
// DefaultBatchChars 是单次请求的**字符总量**上限,同一个不等式换成每字符预算:
|
||
// (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」:
|
||
// 4 条 × 168 汉字 = 672 字符已越过它,会被再切一刀。
|
||
DefaultBatchChars = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perCharBudget)
|
||
|
||
// 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 不够;而稳态实测单条只要 0.03~0.19s(见上面的曲线),
|
||
// 所以放宽只需要覆盖「进程的第一个请求」这一刻。
|
||
warmupTimeout = 8 * time.Second
|
||
)
|
||
|
||
// callBudget 返回在 timeout 这个单次超时下,一次请求允许用掉的墙钟预算。
|
||
func callBudget(timeout time.Duration) time.Duration {
|
||
if timeout <= 0 {
|
||
timeout = DefaultRequestTimeout
|
||
}
|
||
return timeout/callBudgetShare - requestOverhead
|
||
}
|
||
|
||
// batchLimitsFor 由**同一个超时**推出一对批次上限(条数 / 字符总量)。
|
||
// 它保证:按 (条数 ≤ items 且 字符总量 ≤ chars) 切出来的任何一段,其耗时上界
|
||
// 都不超过该超时的一半。两者都至少为 1 —— 单条文本本身越界时独占一段,
|
||
// 正文**绝不截断**(截断会让向量描述的不是这条记忆)。
|
||
func batchLimitsFor(timeout time.Duration) (items, chars int) {
|
||
budget := callBudget(timeout)
|
||
items = int(budget / perItemBudget)
|
||
if items < 1 {
|
||
items = 1
|
||
}
|
||
chars = int(budget / perCharBudget)
|
||
if chars < 1 {
|
||
chars = 1
|
||
}
|
||
return items, chars
|
||
}
|
||
|
||
// chunkTexts 按「条数 ≤ maxItems 且 字符总量 ≤ maxChars」把 texts 切成若干段。
|
||
// 单条文本的 rune 数超过 maxChars 时它独占一段(见 batchLimitsFor 的口径)。
|
||
// 空输入返回 nil。
|
||
func chunkTexts(texts []string, maxItems, maxChars int) [][]string {
|
||
if len(texts) == 0 {
|
||
return nil
|
||
}
|
||
if maxItems < 1 {
|
||
maxItems = 1
|
||
}
|
||
if maxChars < 1 {
|
||
maxChars = 1
|
||
}
|
||
var out [][]string
|
||
start := 0
|
||
chars := 0
|
||
for i, text := range texts {
|
||
n := len([]rune(text))
|
||
// 段内已有内容,再加这一条就越界 ⇒ 先收口,本段从这一条重新开始。
|
||
if i > start && (i-start >= maxItems || chars+n > maxChars) {
|
||
out = append(out, texts[start:i])
|
||
start, chars = i, 0
|
||
}
|
||
chars += n
|
||
}
|
||
return append(out, texts[start:])
|
||
}
|