Embedding API 的报错比聊天接口更隐蔽。聊天请求失败,用户当场就能看到;Embedding 往往跑在离线入库管道里,一次 400 或 429 可能只让某批文档缺了几行,直到几天后检索质量下降才被发现。

排查不要从“换个模型试试”开始。顺序是:先看状态码和错误类型,再看请求体大小和批量,最后检查向量本身。下面按 401、413、429、维度不匹配和空向量拆开,并给出可复用的处理代码。

说明:OpenAI、DeepSeek 等名称是其各自权利人的商标。文中接口限制以 OpenAI embeddings 接口的公开文档为例:单条输入不超过 8192 Token,单次最多 2048 条,单请求合计不超过 30 万 Token;不同厂商和网关的限制可能不同,请以实际文档为准。

一、先按错误类型分组

Embedding 报错大致可以分成七类。先归类,再决定是改代码、改批量,还是改数据。

现象 常见状态码 典型原因 先查什么
鉴权失败 401 或 403 Key 错误、请求头缺失、Key 停用、权限或区域不对 Authorization 头与 Key 来源
输入过长 400 或 413 单条超过模型上限、请求体超过网关限制 单条 Token 数与请求体字节数
批量过大 400 或 413 条数超过上限、合计 Token 超限 条数与合计 Token
限流 429 并发突增、共享 Key、重试风暴 速率、并发与 Retry-After
上游故障 5xx 服务端过载或临时故障 请求 ID 与发生时间
维度不匹配 400 或客户端异常 换模型、改维度、索引固定维度 模型名、维度与索引配置
空向量或 NaN 200 但数据异常 空文本、解析错误、归一化除零 向量长度、有限值与范数

日志至少记录时间、状态码、错误类型、模型名、批量大小、耗时和请求 ID。不要记录完整 Key,也不要把原文整段打印。

二、401 与 403:先确认请求头

401 表示身份没有通过验证,403 表示身份通过了但无权访问。两者都不要先怀疑模型能力。

常见原因:请求头没有使用 Authorization: Bearer <key>;Key 前后带空格或引号;环境变量没加载;Key 被删除或停用;Base URL 与 Key 不属于同一平台;反向代理丢弃了鉴权头。403 还要检查模型访问权限、项目权限、区域和 IP 白名单。

先用 curl 发一条只带短输入的最小请求,确认返回 200,再逐步加回批量、并发和代理。仍然 401 就换一把新 Key 对比,区分 Key 本身和网关配置。

三、413 与输入过长:先分清是谁的限制

413 通常表示请求体太大,可能是网关或反向代理的限制,不一定是模型拒绝。模型的输入上限则可能返回 400。两者要分开看。

以 OpenAI embeddings 为例:单条不超过 8192 Token,数组最多 2048 条,单请求合计不超过 30 万 Token。合计超限通常返回 400,即使每条都没超。网关还可能有请求体大小限制,在请求到达模型前就返回 413。

处理输入过长,建议按下面的顺序做:

  1. 用对应模型的 tokenizer 计算 Token,不要用字符数估算。
  2. 按标题、段落和语义边界切块,块之间保留少量重叠。
  3. 超长块截断兜底,记录原始长度和截断位置。
  4. 批量时同时限制条数和合计 Token,留出一到两成余量。
  5. 413 优先拆请求体,400 优先查单条内容和参数。

还要注意空字符串。OpenAI 的 embeddings 接口不接受空输入,清洗后变成空白的段落要在发送前过滤掉。

四、429:限流、并发与重试风暴

429 表示超过速率、并发或配额限制,不等于余额不足。常见触发:多个服务共用 Key、批量任务整点同时启动、重试没有退避、长请求占用较多配额。

先看响应头有没有 Retry-After,有就按它等待;没有就用指数退避加随机抖动,并设重试上限。重试只适合瞬时错误:401、403、400 和多数 413 重试多少次都不会成功。

工程上还可以:按项目拆 Key;给批量任务加队列和并发上限;大任务错峰;失败记录进死信队列。重试控制在三到五次,超过就告警,不要无限循环。

五、维度不匹配:最容易被忽略的错误

维度不匹配可能表现为数据库报错,比如“expected dimension 1536, got 1024”;也可能表现为 numpy 的形状错误、余弦相似度计算时的广播错误,甚至只是检索结果突然变得毫无相关性。

常见原因:换了模型但旧向量还在索引;调整了 dimensions 参数;同名模型在不同厂商或版本下维度不同;索引创建时固定了维度;有人手工截断或补零。维度相同也不代表能混用,不同模型的向量空间并不兼容。

修复不是补零,而是重建。每条向量记录模型名、维度、归一化方式、距离度量和切块版本;一个索引只放同一模型同一版本的向量;换模型时新建集合并全量重算,再切流验证。写入和查询两侧都要校验维度。

六、空向量、零向量与 NaN

接口返回 200 不代表数据可用。空向量、全零向量和 NaN 都会让相似度计算失效,而且往往不会抛错。

常见来源:清洗后只剩空白的文本;输入数组为空;响应解析错误导致 embedding 为空列表;本地模型对空输入返回零向量;归一化除以零产生 NaN;base64 没有正确解码。

入库前至少做三项校验:长度是否等于预期维度;每个元素是否为有限数;向量范数是否大于一个很小的阈值。校验失败的记录放进隔离表,带上原文 ID 和原因,修完再重跑,不要静默丢弃。

七、一份可复用的错误处理代码

下面的示例针对 OpenAI 兼容接口,覆盖限流退避、批量拆分和向量校验。实际使用时把模型名、维度和 Base URL 换成自己的配置。

import logging
import math
import random
import time

from openai import OpenAI, APIStatusError, APITimeoutError, RateLimitError

logger = logging.getLogger("embedding")
client = OpenAI(
    api_key="sk-你的密钥",
    base_url="https://api.zhongshangqihang.com/v1",
    max_retries=0,  # 关闭 SDK 自动重试,避免与自定义重试叠加
)

MODEL = "text-embedding-3-small"
EXPECTED_DIM = 1536


def vector_is_valid(vec: list[float]) -> bool:
    if len(vec) != EXPECTED_DIM:
        return False
    if not all(math.isfinite(x) for x in vec):
        return False
    norm = math.sqrt(sum(x * x for x in vec))
    return norm > 1e-6


def embed_batch(texts: list[str], attempt: int = 0) -> list[list[float] | None]:
    # 空文本不发送,用 None 占位,保持下标对齐
    pairs = [(i, t) for i, t in enumerate(texts) if t and t.strip()]
    if not pairs:
        return [None] * len(texts)
    indices = [i for i, _ in pairs]
    payload = [t for _, t in pairs]

    try:
        resp = client.embeddings.create(model=MODEL, input=payload, timeout=60)
        items = sorted(resp.data, key=lambda item: item.index)
        vectors = [item.embedding for item in items]
        if len(vectors) != len(payload):
            raise ValueError(f"返回数量不一致: {len(vectors)} != {len(payload)}")
        for index, vec in enumerate(vectors):
            if not vector_is_valid(vec):
                raise ValueError(f"向量异常: index={index}, dim={len(vec)}")
        result: list[list[float] | None] = [None] * len(texts)
        for source_index, vec in zip(indices, vectors):
            result[source_index] = vec
        return result
    except (RateLimitError, APITimeoutError) as exc:
        if attempt >= 5:
            raise
        wait = min(2 ** attempt, 30) + random.random()
        logger.warning("限流或超时,%.1f 秒后重试: %s", wait, exc)
        time.sleep(wait)
        return embed_batch(texts, attempt + 1)
    except APIStatusError as exc:
        if exc.status_code in (400, 413) and len(texts) > 1:
            mid = len(texts) // 2
            logger.warning("批量过大,拆分为 %d + %d", mid, len(texts) - mid)
            return embed_batch(texts[:mid], attempt) + embed_batch(texts[mid:], attempt)
        if exc.status_code >= 500 and attempt < 5:
            wait = min(2 ** attempt, 30) + random.random()
            logger.warning("上游错误,%.1f 秒后重试: %s", wait, exc)
            time.sleep(wait)
            return embed_batch(texts, attempt + 1)
        raise

代码有两个取舍。第一,400 也参与拆批,因为有些网关把“合计 Token 超限”报成 400;拆到单条仍失败会抛出。第二,向量校验放在写入前,坏数据不会进索引。生产环境还应记录请求 ID、重试次数和失败批量。

八、排查顺序与监控清单

遇到 Embedding 报错,按下面的顺序处理,通常比反复换模型更快:

  1. 记录状态码、错误类型、请求 ID、模型名、批量大小和耗时。
  2. 401 或 403 先查请求头、Key、环境变量和平台。
  3. 400 先查单条 Token、合计 Token、空字符串和参数格式。
  4. 413 查请求体字节数和网关限制,拆小请求后重试。
  5. 429 看 Retry-After,退避并降低并发,检查是否共享 Key。
  6. 5xx 做有限次退避重试,持续出现时附请求 ID 联系服务方。
  7. 返回 200 但结果异常时,检查维度、范数、NaN 和归一化。
  8. 把上述指标接入监控,定期用固定评测集做回归。

监控按小时统计 401、400、413、429、5xx 的数量,以及空向量和维度异常条数。错误率突增时,先看是否集中在某个项目、Key、模型或文档批次,再决定降并发、拆批量、回滚还是修数据。

常见问题

413 和 400 都表示输入太长,有什么区别?

413 多数来自网关或反向代理的请求体大小限制,请求可能还没到模型就被拒绝;400 更多来自接口参数或 Token 限制。先看响应体和请求 ID,再决定是拆请求体还是切分文本。

单条没有超过 8192 Token,为什么还是 400?

可能是单请求合计 Token 超过上限,也可能是输入数组条数超限、存在空字符串、参数格式错误或模型名不对。把批量拆小、过滤空文本,再逐项检查请求体。

429 重试几次比较合适?

常见做法是三到五次,配合指数退避和随机抖动,并优先遵守 Retry-After。超过上限就进入队列或告警,不要无限重试。限流严重时,降低并发比增加重试更有效。

维度不匹配可以靠补零解决吗?

不可以。补零会改变向量方向和相似度,检索结果不可信。正确做法是统一模型和维度,新建索引后全量重算,再切流验证。

为什么接口返回 200,向量却是空的?

可能是输入为空或清洗后只剩空白,也可能是响应解析、base64 解码或本地推理出了问题。入库前校验长度、有限值和范数,把异常记录隔离出来排查。

下一步

先在中商起航 AI 网关创建测试 Key,用一小批文本把鉴权、批量、限流和向量校验各跑一遍,再接入正式入库管道。模型可用性与价格以模型价格为准。

相关阅读: