Files
AskaEth bdf3f3edaf docs(embedding,memory): 复核 p2 的三处措辞改正(纯注释,零行为改动)
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 中无非注释行(纯文档改动)。
2026-10-13 22:12:11 +08:00

238 lines
14 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 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:])
}