review-m15 round1 判 p2-1items(fix-then-merge),三条都属「把证据能支撑什么写准」: 1. api.go 原写「最坏也只是这一片失败」与实现语义**相反** —— Embed 的实际语义是 「任一片失败 ⇒ 整条调用失败、其余分片已算完的工作量一并丢弃」。改为与该包 budget.go 里已经写对的那一句对齐,并点明调用方据此降级(记忆一条不丢、判定规则 一字不改,变的是这一条审查的召回质量)。 2. 「尖峰与批次无关」是**过度断言**:原证据只支持「至少存在一个与批次无关的外部/ 争用分量」(4 条/片也实测到 7.139s);越界的**频率**是否随批次上升,1/40 vs 2/40 的样本不足以判定 ⇒ 改标为「归因未定」,并明说批上限取 8 的依据始终是硬证据 (16 条/片实测越界 + 真实代码路径 3 次里 2 次越界),**不依赖**任何成因推断 (原文与同文件「16 条越界故不取更大」的理由自相矛盾)。同时补上独立复核窗口的 对照(review-m15:24 次单请求 0 尖峰)说明尖峰强依赖窗口负载。 3. budget.go 的不变式不再被读成延迟保证:明确定性为「**稳态、空闲机器的建模拟合**, 用途是构造批形状」,并点名真正的兜底(90s 审查预算按整条链算 + 任一片失败即降级 的路径 + 已登记的链路韧性遗留);结论句由「8 条/片不越界」改为「**空闲实测下**不 越界、负载下仍偶发越界」;perItemBudget 与 callBudgetShare 注释里「另一半/余量」 的同口径措辞一并改正(加一句:这是建模余量,不是单请求一定不越界的保证)。 另:preinsert_config.go 里「设计稿那一行待同步」已过期 —— 塔同日已把 design/23 §15 同步为 Timeout=90s,改为「已按实测同步」。 验证:go build ./... / go vet ./... 干净;go test -count=1 ./... 全绿; go test -race -count=1 ./internal/runtime/embedding/... ./internal/runtime/memory/... 全绿;diff 中无非注释行(纯文档改动)。
238 lines
14 KiB
Go
238 lines
14 KiB
Go
package embedding
|
||
|
||
import "time"
|
||
|
||
// 本文件是**单次 /embeddings 调用的预算推演**:批次上限与单次超时必须由**同一个
|
||
// 式子**得出。任一侧单独调整都会让「一次请求的实测耗时 < 单次超时」这条不变式
|
||
// 静默失效 —— 而失效的后果不是报错,是**服务端还在算、客户端已经放弃**:被放弃的
|
||
// 那一批仍占着本机 llama.cpp 的算力,后面每一个请求(包括写索引时的单条请求)都
|
||
// 排在它后面等到自己的超时,于是整条记忆写入路径被拖住、召回全线降级。
|
||
//
|
||
// ## 不变式
|
||
//
|
||
// requestOverhead + 一次请求的字符总量 × perCharBudget ≤ DefaultRequestTimeout / callBudgetShare
|
||
//
|
||
// 左半是**推演出来的耗时上界**,右半是该超时下允许用掉的一半。⚠️ 这条不等式的**地位**
|
||
// 必须读准:它是**稳态、空闲机器上的建模拟合**,用途是**构造批形状**(批次上限由解它
|
||
// 得到,见 DefaultBatchLimit / DefaultBatchChars),**不是**「单次请求 < 超时」的延迟
|
||
// 保证 —— 文件头 ④ 的实测尖峰(4 条 7.139s / 8 条 9.711s)整段越过 5s,2.5s 的
|
||
// 「另一半」装不下它。真正的兜底是三件东西、缺一不可:**调用方预算**
|
||
// (memory.defaultReviewTimeout = 90s,按整条链而不是单片算)、**降级路径**
|
||
// (任一片失败 ⇒ 调用方回落词项余弦 + 留痕 degraded,记忆不丢、规则不改),以及已登记
|
||
// 的**链路韧性遗留**(一片失败丢整条 ⇒ 修法是重试/分片级容错,不在这里)。
|
||
//
|
||
// ⚠️ 这条不等式**只按稳态超时(DefaultRequestTimeout)推导,且只约束批次形状**;
|
||
// 冷启动那次放宽(warmupTimeout)**不改批形状**,只放宽进程第一个请求的**截止
|
||
// 时刻**,而且只发生一次(见 warmupTimeout 的说明)。批形状与超时仍然同源:
|
||
// 无论第一个请求的截止多宽,它带的条数/字符数永远按稳态超时算出来。
|
||
//
|
||
// ## 实测依据(2026-10-13,本机 = AMD RX 6600 XT + llama.cpp(PID 318144,起于
|
||
// 10-07,**排除冷启动**)+ Qwen3-Embedding-0.6B Q8_0,端点 127.0.0.1:11434,
|
||
// **服务器空闲**,正文取自活库 data/cyrene.db 的 cyrene/user 分区:本次样本
|
||
// 201 条、均长 34.7 汉字、最长 75 汉字)
|
||
//
|
||
// ① 单次请求的**形状曲线**(隔离测量 = 一条请求发完算完,同档重复 2~3 次):
|
||
//
|
||
// n=1 15 字 0.069 / 0.076 / 0.072 s
|
||
// n=4 78 字 0.243 / 0.224 / 0.221 s
|
||
// n=8 184 字 0.550 / 0.570 / 0.569 s
|
||
// n=12 331 字 0.996 / 1.017 s
|
||
// n=16 464 字 1.385 / 1.394 / 1.353 s
|
||
// n=32 991 字 2.884 s
|
||
// n=64 1997 字 6.872 s
|
||
//
|
||
// 拟合:耗时 ≈ 固定开销 + 条数 × 每条成本;固定开销实测约 50ms、每条成本
|
||
// 70~110ms。64 条 6.9s **已越过单次超时 5s**(旧批次上限正落在这里)。
|
||
//
|
||
// ② **真实链路的墙钟与请求数**:把一条入库审查的召回链(201 条 = 新条 1 +
|
||
// ScanLimit 200,preinsert_recall.go 的 embedReviewTexts)按批次形状整条发出:
|
||
//
|
||
// 形状 请求数 墙钟(s) 单请求均值(s) 单请求最大(s)
|
||
// 4 条/片(改前) 51 23.070 / 21.860 0.452 / 0.429 1.449 / 1.052
|
||
// 8 条/片(本次) 26 21.435 / 21.597 0.824 / 0.831 1.782 / 1.732
|
||
// 16 条/片 / 480 字 16 21.477 → 49.433 1.342 → 3.089 2.119 → **11.043**
|
||
// 16 条/片 / 不限字 13 21.012 / 21.904 1.616 / 1.685 2.313 / 2.582
|
||
//
|
||
// 三条结论 —— 本次修复的判断全部落在这里:
|
||
//
|
||
// - **链路墙钟由「条数 × 每条成本」主导,几乎与批次形状无关**:同样 201 条,
|
||
// 4 条/片与 8 条/片的墙钟都是 ~21s。抬高批次上限买到的是**请求数减半**
|
||
// (51 → 26)与其带来的服务端排队压力下降,**不是**链路变快。要让链路落回
|
||
// 调用方预算,只有把预算抬到链长之上(审查侧 defaultReviewTimeout)。
|
||
// - **8 条/片在空闲实测下不越界**:三轮最大单请求 1.782s,远在 5s 单次超时之内。
|
||
// ⚠️ 但这**不是**延迟保证 —— 负载下仍偶发越界(见 ④),尖峰与批次的关系未定。
|
||
// - **16 条/片会越界**:同一批正文、同一次会话里复现出单请求 **11.043s**
|
||
// (那一轮整条链 49.4s);③ 里真实代码路径 3 次有 2 次越过 5s。批上限取 8 而不是
|
||
// 12/16,依据就是这两条**实测**(隔离测量里 16 条三次稳定在 1.35~1.39s ⇒ 这个形态
|
||
// 方差极大,不能只看隔离值),**不依赖**任何关于尖峰成因的推断。
|
||
//
|
||
// ③ **真实代码路径**(不是直连端点:跑 APIEmbedder.Embed(201 条) 本身,用注入的
|
||
// RoundTripper 数真实 HTTP 请求数):
|
||
//
|
||
// 8 条/片 26 个请求 / 201 个向量,墙钟 21.489s、28.220s(两轮,均未触发单请求超时)
|
||
// 16 条/片 直接 embedChunk:1.818s、**5.016s 超时**、**5.001s 超时**(3 次里 2 次越界)
|
||
//
|
||
// ④ **必须登记的残余(实测,不是推测)**:尖峰会越过 5s 单次超时 —— 交错对比
|
||
// 40 次 4 条 + 40 次 8 条(同一批正文):
|
||
//
|
||
// 4 条/片 p50 0.456s p90 1.008s 最大 **7.139s**(>5s 计 1/40)
|
||
// 8 条/片 p50 0.848s p90 1.861s 最大 **9.711s**(>5s 计 2/40)
|
||
//
|
||
// 该窗口机器上确有别的负载(`tasklist` 可见多个 go/link 进程 = 塔里其它 worker 在
|
||
// 编译;同窗后端也在跑群聊回合);同一夜 review-m15 的独立窗口(24 次单请求:n=4
|
||
// max 0.236s、n=8 max 1.097s)**一次尖峰都没有** ⇒ 尖峰强依赖窗口负载。因此这里只
|
||
// 断言证据支持得住的那一层:**至少存在一个与批次无关的外部/争用分量**(4 条/片也能
|
||
// 到 7.139s ⇒ 尖峰**不是**本次放大批次的后果);**越界的频率是否随批次上升,1/40 与
|
||
// 2/40 的样本不足以判定,归因未定**(方向反而更像「批次越大越容易越界」,但那需要带
|
||
// 在飞计数的观测才能定论)。
|
||
//
|
||
// 另一件确定的事:因为「一个分片失败 ⇒ 整条 Embed 失败」,一次尖峰就丢掉其余分片
|
||
// 已算完的工作量 —— 链路对单点慢分片的**韧性**是遗留问题,**调常量解决不了**
|
||
//(修法是重试/分片级容错,不在本切片内)。
|
||
//
|
||
// 复跑命令(读活库正文、只连本机 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("file:data/cyrene.db?mode=ro",uri=True).execute(
|
||
// "select content from memory_items where status='active' and persona_id='cyrene'"
|
||
// " and scope='user' order by created_at desc, rowid desc limit 201")]
|
||
// for b in (t[:1],t[:4],t[:8],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 是**每条文本**的墙钟预算上界:它守住「条数」那一维。
|
||
//
|
||
// 2026-10-13 由 500ms 降到 300ms —— 旧值是把条数上限压到 **4** 的那个约束项,
|
||
// 而 4 条/片会把一次召回放大成 ⌈N/4⌉ 个 HTTP 请求(201 条的审查链 = 51 个请求,
|
||
// 见文件头)。依据不是「拍一个更小的数」,而是**实测摊薄**:整条链里每条的平均
|
||
// 成本 70~103ms(8 条/片轮次 0.824s ÷ 8 ≈ 103ms;64 条档最坏 6.872s ÷ 64 ≈
|
||
// 107ms),300ms 仍是实测的约 **3 倍**。按不变式反解,它把条数上限放到 8:
|
||
// (5s/2 − 100ms) / 300ms = 8 —— 恰好用满「超时的一半」(另一半按建模拟合留给现场
|
||
// 抖动;它是**建模口径**、不是延迟保证,见文件头的不变式一节)。
|
||
//
|
||
// 为什么不再大:条数上限 12 需要 perItemBudget ≤ 200ms(只剩实测的 1.9 倍),
|
||
// 16 需要 ≤ 150ms(1.4 倍)—— 而 16 条/片本次已复现出**单请求 11.043s**
|
||
// (> 单次超时 5s,见文件头)。批次越靠近那条界,单请求越可能「客户端先放弃、
|
||
// 服务端还在算」(级联堆积的起点)。取 8:**请求数减半,且建模上界对实测摊销仍留
|
||
// 约 3 倍**(这是建模余量,不是单请求一定不越界的保证 —— 见文件头 ④)。
|
||
perItemBudget = 300 * time.Millisecond
|
||
|
||
// callBudgetShare 表示一次请求最多用掉超时的 1/2。它是**建模口径**(一半给建模拟合、
|
||
// 一半留给现场抖动),**不是**延迟保证 —— 文件头 ④ 的尖峰整段越过单次超时。调大它
|
||
// = 建模上界更紧,必须连带复核实测。
|
||
callBudgetShare = 2
|
||
|
||
// DefaultBatchLimit 是单次请求的**条数**上限,由上面三个常量推出来(唯一推演):
|
||
// (5s/2 − 100ms) / 300ms = 8。
|
||
DefaultBatchLimit = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perItemBudget)
|
||
|
||
// DefaultBatchChars 是单次请求的**字符总量**上限,同一个不等式换成每字符预算:
|
||
// (5s/2 − 100ms) / 5ms = 480。它防的是「条数很少但每条很长」。
|
||
//
|
||
// 本次**不动**它(每字符成本实测 2.7~3.9ms,5ms 仍是上界),因此两道界的分工
|
||
// 是:8 条 × 均长 35 字(280 字)走**条数界**;8 条 × 长正文(如 168 字 = 1344 字)
|
||
// 被它再切一刀,落到 2 条/片。两维都必须成立,缺哪一维都会有请求越界。
|
||
DefaultBatchChars = (int(DefaultRequestTimeout/callBudgetShare) - int(requestOverhead)) / int(perCharBudget)
|
||
|
||
// maxSafeBatchLimit 是条数上限的**实测外沿**(12),它守的是与上面那条不等式
|
||
// **不同**的一层:不等式管的是**建模成本**(requestOverhead + 批次 × perItemBudget
|
||
// ≤ 超时的一半),它管的是**实测形状** —— 文件头里 16 条/片复现出 11.043s 的单
|
||
// 请求(> 5s 超时),8 条/片三轮最大 1.782s,隔离测量 12 条 0.996~1.017s 仍在界内。
|
||
// 两层必须同时成立:把批次放到 12 需要**同时**把 perItemBudget 压到 ≤ 200ms
|
||
// (只剩实测摊销的 1.9 倍,且算式上 100ms + 12×200ms = 2.5s 已顶到边界);
|
||
// 越过 12 则已进入「实测已知会出界」的区间 —— 由下面那条编译期断言拦住。
|
||
maxSafeBatchLimit = 12
|
||
|
||
// warmupTimeout 是**一次性的**冷启动放宽:本 embedder 生命周期里的**第一个**
|
||
// 请求可以拿到这个截止(模型加载 / GPU 缓冲分配还没发生过),此后**每一个**
|
||
// 请求(含刚失败的那个之后的下一个)一律回到 DefaultRequestTimeout。
|
||
//
|
||
// 🔴 它**不是**「只要还没成功过就一直放宽」—— 那个口径会在服务挂死时把
|
||
// fail-fast 变成 fail-slow(每次都等 warmupTimeout),并把审查单条预算、
|
||
// 索引写入的 5s 预算整段吃掉。一次性 + 8s 两条一起才是有界的:
|
||
// - 一次性:放宽最多花掉**一个**请求的时长,最坏情形之后立刻回到 5s;
|
||
// - 8s:与常态 5s **同一量级**(1.6×),且严格小于审查单条总预算
|
||
// (memory.defaultReviewTimeout = 90s)⇒ 一次冷启动不可能吃光整轮审查,
|
||
// 后面的 T1 灰区判定(8s)仍有预算。这条不等式由测试守住
|
||
// (api_url_test.go 的 TestDefaultRequestTimeout)。
|
||
//
|
||
// 依据:2026-10-13 部署后第一分钟的生产日志里有一条
|
||
// `vector embed for index failed ... embed 1 texts ... context deadline exceeded`
|
||
// —— 模型还没热时 5s 不够;而本次实测稳态单条只要 0.069~0.076s(见文件头的
|
||
// 形状曲线),所以放宽只需要覆盖「进程的第一个请求」这一刻。
|
||
warmupTimeout = 8 * time.Second
|
||
)
|
||
|
||
// 编译期不变式(2026-10-13):条数上限一旦越过**实测划出来的外沿**
|
||
// (maxSafeBatchLimit)—— 例如把 perItemBudget 压到 150ms(⇒ 16)、或把
|
||
// callBudgetShare / requestOverhead 往「放宽」的方向调 —— 下面这行常量表达式
|
||
// 会变成负数、uint 溢出 ⇒ **编译失败**。与 tidy_config.go 里那条「整理侧批上限
|
||
// 不许超过 embedding 推出来的值」同一手法:把实测承诺交给编译器守,而不是只写在
|
||
// 注释里。反方向的漂移(perItemBudget 调大 ⇒ 批上限变小 ⇒ 报错变多)由
|
||
// budget_test.go 的钉值断言抓。
|
||
const _ = uint(maxSafeBatchLimit - DefaultBatchLimit)
|
||
|
||
// 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:])
|
||
}
|