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="" // 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:]) }