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、不动判定规则与任何阈值。
130 lines
5.9 KiB
Go
130 lines
5.9 KiB
Go
package embedding
|
||
|
||
import "time"
|
||
|
||
// 本文件是**单次 /embeddings 调用的预算推演**:批次上限与单次超时必须由**同一个
|
||
// 式子**得出。任一侧单独调整都会让「一次请求的实测耗时 < 单次超时」这条不变式
|
||
// 静默失效 —— 而失效的后果不是报错,是**服务端还在算、客户端已经放弃**:被放弃的
|
||
// 那一批仍占着本机 llama.cpp 的算力,后面每一个请求(包括写索引时的单条请求)都
|
||
// 排在它后面等到自己的超时,于是整条记忆写入路径被拖住、召回全线降级。
|
||
//
|
||
// ## 不变式
|
||
//
|
||
// requestOverhead + 一次请求的字符总量 × perCharBudget ≤ DefaultRequestTimeout / callBudgetShare
|
||
//
|
||
// 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半(另一半是这台机器的
|
||
// 抖动余量)。批次上限由解这个不等式得到(见 DefaultBatchLimit / DefaultBatchChars)。
|
||
//
|
||
// ## 实测依据(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 是**首次**调用(模型加载 / GPU 缓冲分配尚未发生)的墙钟上限。
|
||
// 它**只在拿到第一个成功响应之前**生效,之后所有调用立刻回到
|
||
// DefaultRequestTimeout —— 所以它不会让「真挂死的请求」长期拖住整轮审查,
|
||
// 又不让冷启动那一次注定失败(2026-10-13 部署后第一分钟生产日志里
|
||
// `vector embed for index failed ... embed 1 texts ... context deadline exceeded`
|
||
// 就是这一路:模型还没热,5s 不够)。
|
||
warmupTimeout = 30 * 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:])
|
||
}
|