review round1 的 p1/p2 收口(commit 5967272 之后)。
p1:旧实现 `warmed` 只在 2xx 之后才翻 ⇒ 注释里写的「只在第一次成功响应之前」
实际是「服务坏着的全部时间」。reviewer 实测「接受连接但永不回包」时**每次**调用
都等满 30s(fail-fast 变 fail-slow):审查 20s 预算被一次吃光、索引写入拖成
8×30s、开机探针阻塞 30s。
- `warmupLeft` 改为**一次性许可**(CompareAndSwap 取走即失效,成败都算花掉)
⇒ 挂死时只有第一个请求慢一次,此后一律 DefaultRequestTimeout。
- `warmupTimeout` 30s → 8s:与常态 5s 同一量级(≤2×),且严格小于审查总预算
20s(一次冷启动不许吃光整轮);两条界都由 TestDefaultRequestTimeout 守住。
- 放宽被花掉且没成功时留一条 WARN(事后可复核「第一个请求」这个判据)。
- 更正两处自述:api.go 的「不会被 30s 拖住」与 budget.go 的不变式说明(批次形状
永远按稳态超时推导;冷启动只放宽第一个请求的截止时刻)。
- p2:删掉零调用者的 `WithRequestTimeout` —— 单次超时只允许改常量,批次上限与它
同源并由编译期/常量断言复核。
p2:试算轮在向量源不可用时 `RecallMode` 与「没有候选可比」同为空串。
- `recallLedger` 增 `unavailable` 计数 + `noteUnavailable()`(strictRecall 失败
那一笔记一笔);试算汇总行新增 `recall_unavailable_partitions`,**成败都记**。
- 不扩 `recallMode` 取值集合:失败那一路不序列化 result(响应是
500 tidy_recall_unavailable),新取值只会出现在日志里却要动一次契约,不划算。
测试:三种失败形态 × 两次调用的耗时断言(连接被拒 / 500 / 永不回包),
试算轮的「不可用」与「没候选」两态日志断言;两处变异自证(warmupTimeout 调回
30s ⇒ TestDefaultRequestTimeout 红;去掉 noteUnavailable ⇒ 试算汇总行退化成
recall_mode="" + recall_unavailable_partitions=0)。
337 lines
12 KiB
Go
337 lines
12 KiB
Go
package embedding
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log/slog"
|
||
"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 里**同一个不等式**推出 —— 单次请求的耗时上界不得超过超时的一半。
|
||
// 改这一行就等于改了批次上限,反之亦然;两侧永远是配套的。
|
||
//
|
||
// 为什么每次调用必须有自己的上限:上层「入库前审查」的单条总预算是 20s
|
||
// (memory.defaultReviewTimeout),embedding 只是它内部的第一步,后面还有 T1 灰区
|
||
// 判定(8s)。一次卡死的 HTTP 不能把整条审查的 20s 都赌掉。真慢就**快速失败**,
|
||
// 调用方按**既有语义**降级(审查降级到词项余弦并如实留痕 degraded),
|
||
// **绝不静默丢记忆** —— 本常量只砍等待时间,不改任何判定规则。
|
||
//
|
||
// 5s 保持不变的依据:本次故障(2026-10-13)的成因不是这个数太小,而是批次太大 ——
|
||
// 64 条的实测耗时 7.5~8.5s 已经越过它,客户端放弃、服务端还在算,把整台本机
|
||
// embedding 服务顶住,连带单条请求(写向量索引)也一起超时。所以修法是**把批次
|
||
// 收进预算内**,而不是把预算放大到迁就批次(后者会让真挂死的请求也拖住整轮)。
|
||
//
|
||
// 冷启动另有 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
|
||
log *slog.Logger
|
||
// 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}
|
||
}
|
||
}
|
||
|
||
// WithLogger sets the logger used for embed failures. A nil logger is ignored
|
||
// and the default discard logger is kept.
|
||
func WithLogger(l *slog.Logger) APIOption {
|
||
return func(e *APIEmbedder) {
|
||
if l != nil {
|
||
e.log = l
|
||
}
|
||
}
|
||
}
|
||
|
||
// NewAPI builds an APIEmbedder from cfg using the default HTTP client.
|
||
// 单次超时不可由选项更换:它是 budget.go 那条推演的输入,批次上限与它同源,
|
||
// 因此**只允许改常量**(改了之后批次上限自动跟着改,测试的编译期/常量断言立刻复核)。
|
||
func NewAPI(cfg config.EmbeddingConfig, opts ...APIOption) *APIEmbedder {
|
||
e := &APIEmbedder{
|
||
cfg: cfg,
|
||
client: defaultHTTPClient(),
|
||
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||
}
|
||
// 冷启动放宽是一个**一次性**许可:见 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 推出来的上限(条数 / 字符总量)
|
||
// **自己切片**,分多次请求顺序发出,因此「一次请求的耗时 < 单次超时」这条不变式
|
||
// 对任何调用方都成立 —— 调用方给多大的切片都不会把服务端顶住。中途任一段失败即
|
||
// 整体失败(返回已发请求的错误),绝不半途改用混合表示。
|
||
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 —— 服务挂死时不会每次都慢。
|
||
//
|
||
// 返回值 relaxed 表示这一次是不是拿到了放宽,供失败时留痕。
|
||
func (e *APIEmbedder) callTimeout() (timeout time.Duration, relaxed bool) {
|
||
if e.warmupLeft.CompareAndSwap(true, false) {
|
||
return warmupTimeout, true
|
||
}
|
||
return DefaultRequestTimeout, false
|
||
}
|
||
|
||
// embedChunk 发**一次** /embeddings 请求。chunk 必须已经落在 batchLimitsFor 推出来
|
||
// 的边界内(Embed 负责切),因此它的耗时上界不超过单次超时的一半。
|
||
func (e *APIEmbedder) embedChunk(ctx context.Context, texts []string) ([]Vector, error) {
|
||
// 先取超时(取走即花掉那次放宽),再建 ctx:放宽许可与这次请求一一对应。
|
||
timeout, relaxed := e.callTimeout()
|
||
callCtx, cancel := context.WithTimeout(ctx, timeout)
|
||
defer cancel()
|
||
|
||
req, err := e.newEmbeddingRequest(callCtx, texts)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
resp, err := e.client.Do(req)
|
||
if err != nil {
|
||
// 冷启动放宽被花掉且没成功时留一条 WARN:它是「本进程第一次请求」这个
|
||
// 判断唯一能被事后复核的地方(此后一切失败都按常态超时快速失败)。
|
||
// 只记两个预算与错误(错误文本里已带端点,不另记 URL,避免重复落一条
|
||
// 可能含 userinfo 的地址)。
|
||
if relaxed {
|
||
e.log.Warn("embedding: one-shot cold-start allowance spent without a successful response",
|
||
"warmup_timeout", warmupTimeout.String(),
|
||
"steady_timeout", DefaultRequestTimeout.String(),
|
||
"error", err)
|
||
}
|
||
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,
|
||
}
|
||
}
|