Files
Cyrene_v2/internal/runtime/embedding/api.go
T
AskaEth f3fb097630 perf(embedding,memory): 批上限 4→8 削掉请求放大,审查预算 20s→90s 覆盖实测链长
实测(2026-10-13,本机空闲,活库 cyrene/user 分区 201 条、均长 34.7 汉字):
- 一条入库审查召回链 = 51 个「4 条」请求 = 23.070 / 21.860 s;旧审查预算 20s
  比链长还短 ⇒ 每一条候选满额的审查都超时,且超时后 **一行留痕都不写**
  (7 天里 92.1% 的审查 recall_mode 为空、2274 条超时降级的根因)
- 同一条链改成「8 条」= 26 个请求 = 21.435 / 21.597 s:请求数减半,墙钟几乎不变
  —— 链路耗时由「条数 × 每条成本」主导,批次只改请求数
- 「16 条」在**真实代码路径**上 3 次里 2 次让单请求越过 5s 单次超时(1.818 /
  5.016 超时 / 5.001 超时)⇒ 批上限不越过 8;隔离测量里 12 条 0.996~1.017 s 仍在界内

改动:
- embedding: perItemBudget 500ms→300ms(实测摊销 70~107ms/条的约 3 倍)
  ⇒ DefaultBatchLimit 由 (5s/2−100ms)/300ms = **8**,同一套推演、两维都成立
  (条数 100ms+8×300ms=2.5s;字符 100ms+480×5ms=2.5s,均在超时的一半内)
- embedding: 新增 maxSafeBatchLimit=12(实测外沿)+ 编译期断言(越界即 uint 溢出、
  编译失败),与 tidy_config.go 那条同手法
- memory: defaultReviewTimeout 20s→**90s**(> 实测最坏链:空闲 21.6s⇒4.2 倍余量、
  与整理链并发 56.7s + T1 8s ⇒ 1.4 倍;不再大的理由=单 worker 有界队列 256 的
  最坏积压 256×90s≈6.4h、服务真挂死时第一片 5s 就降级用不到这条预算)

测试(不留过时断言):budget_test.go 加「钉值 8/480 + 两维不等式 + ≤12 外沿 +
16 条必须仍判越界」四层;api_url_test.go 的 reviewBudgetRef 20s→90s;
preinsert_recall_test.go 新增跨包断言「预算 > 实测最坏链 + defaultT1Timeout」。
变异自证:perItemBudget 回 500ms ⇒ 钉值断言红;reviewTimeout 回 20s ⇒ 跨包断言红;
perItemBudget 150ms(⇒16)⇒ 编译失败(constant -4 overflows uint)。三者均已逐字还原。

遗留(如实登记,未修):① 整理轮每轮重嵌整个分区(user 一轮仍 250 个请求),
解法方向是复用已入库的 vec_items 或给整理轮一个 embedding 请求预算;② 链路对单点
慢分片没有韧性 —— 一个分片失败即整条 Embed 失败、丢掉其余分片的工作量,且实测
任何批次形状都会偶发 >5s 尖峰(4 条 7.139s / 8 条 9.711s,与批次无关),方向是
重试/分片级容错,调常量解决不了。
2026-10-13 21:59:31 +08:00

329 lines
13 KiB
Go
Raw 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 (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// maxResponseBytes caps how much of an /embeddings response body is read, so an
// oversized or malformed response cannot exhaust memory. Legitimate embeddings
// for a normal batch fall far below this; the cap is a guard, not a limit.
const maxResponseBytes = 16 << 20
// maxErrorBodyBytes caps how much of a non-OK response body is read for the
// error message.
const maxErrorBodyBytes = 4096
// HTTP client tuning values, matching the recommended pool in
// DevDocs/02-development-constraints.md §2.3.
const (
httpMaxIdleConns = 100
httpMaxIdleConnsPerHost = 20
httpIdleConnTimeout = 90 * time.Second
httpTLSHandshakeTimeout = 15 * time.Second
httpRequestTimeout = 180 * time.Second
)
// DefaultRequestTimeout 是**单次** /embeddings 调用的墙钟上限,独立于 HTTP 客户端
// 那一层的 180s 总超时(httpRequestTimeout)。
//
// 它**不是**单独取值:批次上限(DefaultBatchLimit / DefaultBatchChars)与它由
// budget.go 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。
// 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。
//
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 90s
// (memory.defaultReviewTimeout),而**一条召回链是几十个请求**(201 条 ÷ 8 条/片
// = 26 个,见 budget.go 文件头的链路实测)—— 一次卡死的 HTTP 不能把整条审查的预算
// 都赌掉:把它压在 5s,最坏也只是这一片失败,调用方按**既有语义**降级(审查降级到
// 词项余弦并如实留痕 degraded),**绝不静默丢记忆**。本常量只砍等待时间,不改任何
// 判定规则。
//
// 5s 保持不变的依据:本次故障(2026-10-13)的成因**不是**这个数太小(链条是
// 「N 条 → ⌈N/批次⌉ 个请求」,链长超的是**调用方**预算),因此修法是把链条收短
// (budget.go: 批上限 4 → 8)并**单独**抬高调用方预算(memory.defaultReviewTimeout
// 20s → 90s),而不是把单次超时放大到迁就链条 —— 后者会让真挂死的请求也一起
// 拖住整轮审查。
//
// ⚠️ 已知残余(见 budget.go 文件头 ④):空闲窗口实测单请求最大 1.782s,但**任何**
// 批次形状都偶发越过本超时的尖峰(实测 4 条 7.139s、8 条 9.711s —— 明确与批次无关)。
// 越过它的后果被设计成**有界的**:那一次调用失败 ⇒ 调用方按既有语义降级
// (召回回落词项余弦 + 留痕 degraded),**不是**整轮丢失。真正要治的是链路对单点
// 慢分片的韧性(重试/分片级容错),不在这里。
//
// 冷启动另有 warmupTimeout(见 budget.go),但它**只放宽本进程的第一个请求**、
// 且一次性花掉:失败不会让它续期,因此「服务一直挂死」这件事**不会**变成
// 「每次调用都慢」—— 第二个请求起就回到本常量。
const DefaultRequestTimeout = 5 * time.Second
// openAICompatibleRequest is the JSON body sent to an OpenAI-compatible
// /embeddings endpoint.
type openAICompatibleRequest struct {
Model string `json:"model"`
Input []string `json:"input"`
}
// openAICompatibleResponse is the subset of the /embeddings JSON response the
// APIEmbedder consumes.
type openAICompatibleResponse struct {
Data []openAICompatibleDatum `json:"data"`
}
// openAICompatibleDatum is a single embedding in the response data array.
type openAICompatibleDatum struct {
Index int `json:"index"`
Embedding []float32 `json:"embedding"`
}
// ErrNoAPIURL is returned by APIEmbedder.Embed when the configured API URL is
// blank, so a mis-configured embedder fails loudly instead of issuing a request
// to an empty URL.
var ErrNoAPIURL = errors.New("embedding: api url is empty")
// APIEmbedder implements Embedder by calling an OpenAI-compatible /embeddings
// endpoint. It is the semantic-vector enhancement selected when
// config.EmbeddingConfig.APIURL is set, and it always uses the real HTTP path
// (never a stub).
//
// The vector dimension is not known until the first successful response; Dim()
// reports the last observed dimension, or 0 before any successful Embed. The
// runtime should therefore call Embed before relying on Dim().
type APIEmbedder struct {
cfg config.EmbeddingConfig
client *http.Client
// warmupLeft 是**一次性**冷启动放宽的开关:为真时下一个请求用 warmupTimeout,
// 取走即置假(CompareAndSwap ⇒ 并发下也只有一个请求能拿到)。
// 它**不是**「未成功就一直放宽」—— 失败也照样花掉这一次,因此服务挂死时
// 只会慢一次,之后每个请求都按 DefaultRequestTimeout 快速失败(见 budget.go)。
warmupLeft atomic.Bool
mu sync.RWMutex
dim int
}
// APIOption customizes an APIEmbedder built by NewAPI.
type APIOption func(*APIEmbedder)
// WithHTTPClient sets the *http.Client used for the /embeddings request. A nil
// client is ignored and the default is kept.
func WithHTTPClient(c *http.Client) APIOption {
return func(e *APIEmbedder) {
if c != nil {
e.client = c
}
}
}
// WithRoundTripper sets the transport for the /embeddings request by wrapping
// it in a fresh *http.Client. Tests use this to inject a mock RoundTripper
// without starting a network server.
func WithRoundTripper(rt http.RoundTripper) APIOption {
return func(e *APIEmbedder) {
e.client = &http.Client{Transport: rt}
}
}
// NewAPI builds an APIEmbedder from cfg using the default HTTP client.
//
// 本包**不记日志**(没有任何 logger 缝):失败一律沿错误返回,由四个调用方
// (审查召回 / 整理召回 / 向量索引写入 / 检索)各自带 source 记账 —— 它们本来
// 就会把错误原文写进各自的 WARN,因此这里再记一条只会是重复的第二份。
// 单次超时也不可由选项更换:它是 budget.go 那条推演的输入,批次上限与它同源,
// 因此**只允许改常量**(改了之后批次上限自动跟着改,测试的常量断言立刻复核)。
func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder {
e := &APIEmbedder{
cfg: cfg,
client: defaultHTTPClient(),
}
// 冷启动放宽是一个**一次性**许可:见 APIEmbedder.warmupLeft。
e.warmupLeft.Store(true)
for _, opt := range opts {
opt(e)
}
return e
}
// Dim returns the dimension observed from the last successful response, or 0
// if no response has been received yet.
func (e *APIEmbedder) Dim() int {
e.mu.RLock()
defer e.mu.RUnlock()
return e.dim
}
// Embed sends texts to the configured /embeddings endpoint and returns the
// returned vectors aligned by index. It returns ErrNoAPIURL for a blank URL
// and a wrapped error for any HTTP or decode failure.
//
// texts 长度不受调用方控制:本方法按 budget.go 推出来的上限(条数 / 字符总量)
// **自己切片**,分多次请求顺序发出,因此「一次请求的耗时 < 单次超时」这条不变式
// 对任何调用方都成立 —— 调用方给多大的切片都不会把服务端顶住。中途任一段失败即
// 整体失败(返回已发请求的错误),绝不半途改用混合表示。
//
// ⚠️ 代价在调用方那一侧:**一次 Embed(N) 是 ⌈N/条数上限⌉ 个请求**(改前 201 条 =
// 51 个;改后 8 条/片 = 26 个),整条链的墙钟实测 ~21s(见 budget.go 文件头)。
// 因此调用方的**总**预算必须按链长算,而不是按一次请求算 ——
// memory.defaultReviewTimeout = 90s 就是这条口径(20s 的旧值比链长还短,
// 让每一条候选数满额的审查都必然超时,且失败时不留痕)。
func (e *APIEmbedder) Embed(ctx context.Context, texts []string) ([]Vector, error) {
if len(texts) == 0 {
return []Vector{}, nil
}
if err := ctx.Err(); err != nil {
return nil, err
}
if strings.TrimSpace(e.cfg.APIURL) == "" {
return nil, ErrNoAPIURL
}
// 切片用的上限由**稳态**超时推出(budget.go 的 DefaultRequestTimeout):
// 冷启动那一次只放宽**截止时刻**,批形状不变 —— 否则首个请求会按更宽的预算
// 切出一个巨大的批,正好把冷启动的模型顶得更久。
items, chars := batchLimitsFor(DefaultRequestTimeout)
out := make([]Vector, 0, len(texts))
for _, chunk := range chunkTexts(texts, items, chars) {
vectors, err := e.embedChunk(ctx, chunk)
if err != nil {
return nil, err
}
out = append(out, vectors...)
}
return out, nil
}
// callTimeout 返回本次请求该用的单次超时,并**当场花掉**一次性冷启动放宽许可
// (成功、失败都算花掉)。因此:
// - 本进程的第一个请求最多等 warmupTimeout(冷启动),
// - 从第二个请求起一律 DefaultRequestTimeout —— 服务挂死时不会每次都慢。
func (e *APIEmbedder) callTimeout() time.Duration {
if e.warmupLeft.CompareAndSwap(true, false) {
return warmupTimeout
}
return DefaultRequestTimeout
}
// embedChunk 发**一次** /embeddings 请求。chunk 必须已经落在 batchLimitsFor 推出来
// 的边界内(Embed 负责切),因此它的耗时上界不超过单次超时的一半。
func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector, error) {
// 先取超时(取走即花掉那次放宽),再建 ctx:放宽许可与这次请求一一对应。
callCtx, cancel := context.WithTimeout(ctx, e.callTimeout())
defer cancel()
req, err := e.newEmbeddingRequest(callCtx, texts)
if err != nil {
return nil, err
}
resp, err := e.client.Do(req)
if err != nil {
return nil, fmt.Errorf("embedding: request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
msg := readErrorBody(resp.Body)
return nil, fmt.Errorf("embedding: status %d: %s", resp.StatusCode, msg)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return nil, fmt.Errorf("embedding: read response: %w", err)
}
var decoded openAICompatibleResponse
if err := json.Unmarshal(data, &decoded); err != nil {
return nil, fmt.Errorf("embedding: decode response: %w", err)
}
if len(decoded.Data) != len(texts) {
return nil, fmt.Errorf("embedding: got %d vectors for %d texts", len(decoded.Data), len(texts))
}
vectors := make([]Vector, len(texts))
dim := 0
for _, d := range decoded.Data {
if d.Index < 0 || d.Index >= len(vectors) {
return nil, fmt.Errorf("embedding: index %d out of range", d.Index)
}
vec := make(Vector, len(d.Embedding))
copy(vec, d.Embedding)
vectors[d.Index] = vec
if dim == 0 {
dim = len(vec)
}
}
e.mu.Lock()
e.dim = dim
e.mu.Unlock()
return vectors, nil
}
// newEmbeddingRequest 组装一次 /embeddings 调用:序列化请求体、**规范化端点主机**
// (URL 解析之后、拨号之前;只改主机,path/query 一字不动)、构造 POST 请求并打上
// 鉴权头。它只负责组装,不负责发送。
func (e *APIEmbedder) newEmbeddingRequest(ctx context.Context, texts []string) (*http.Request, error) {
payload, err := json.Marshal(openAICompatibleRequest{
Model: e.cfg.Model,
Input: texts,
})
if err != nil {
return nil, fmt.Errorf("embedding: marshal request: %w", err)
}
endpoint, err := normalizeEndpointURL(e.cfg.APIURL)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("embedding: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if e.cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+e.cfg.APIKey)
}
return req, nil
}
// readErrorBody drains a small prefix of a non-OK response body into a message,
// capped at maxErrorBodyBytes so an error response cannot exhaust memory.
func readErrorBody(r io.Reader) string {
raw, err := io.ReadAll(io.LimitReader(r, maxErrorBodyBytes))
if err != nil {
return err.Error()
}
msg := strings.TrimSpace(string(raw))
if msg == "" {
return "bad status"
}
return msg
}
// defaultHTTPClient builds the HTTP client configuration described in
// DevDocs/02-development-constraints.md §2.3: tuned idle/TLS timeouts plus a
// total request timeout. A fresh client is returned per call (never a package
// global, which the constraints forbid).
func defaultHTTPClient() *http.Client {
return &http.Client{
Transport: &http.Transport{
MaxIdleConns: httpMaxIdleConns,
MaxIdleConnsPerHost: httpMaxIdleConnsPerHost,
IdleConnTimeout: httpIdleConnTimeout,
TLSHandshakeTimeout: httpTLSHandshakeTimeout,
},
Timeout: httpRequestTimeout,
}
}