Qeasy Cloud
Get Started

失败兜底策略:1536 维全零向量 × 状态机 FAILED 标记

· 王浩宇· AI Financial Reconciliation· 13 views· 12 min read

失败兜底策略:1536 维全零向量 × 状态机 FAILED 标记

摘要:AI 工程最难的不是「做对」,是「错了之后系统怎么继续活着」。D-V7 fire-and-forget 让 API 启动 < 1 秒,把 embedding 回填推到了后台,但后台任务的命运从来不会一帆风顺——DashScope 限流、网络抖动、模型维度变更、单条 content 超长被截断,任何一个都可能让一整批 10 条 chunk 的向量求值失败。本文拆解的是这套系统在「失败兜底」上的 4 个工程决策:失败 chunk 写 1536 维全零向量(不是 NULL)+ 标 KnowledgeChunkEmbeddingStatus.failed + 召回 SQL 硬过滤 kc."embeddingStatus" = 'ready' 不误命中 + 每批本地 Set 跟踪失败 ID 防死循环 + 下次 bootstrap 自动重试。这 4 条合在一起构成了 AI 知识库「错了但不死、错了还能修、错了也不会污染召回结果」的最小可用韧性。

关键词:失败兜底、1536 维全零向量、FAILED 标记、状态机、embedding、AI 韧性

一、SRE 最怕的不是告警,是「静默污染」

2026 年 8 月某天上午 10 点,一家做跨境电商的 SaaS 公司收到一个客服工单:「客户问『对账脚本怎么写』,AI 助手回了一个『请补充对账逻辑后我们看看』,完全没有命中我们的官方解析文档。」

SRE 同学第一反应是去查向量召回日志——vectorRecall 返回的 rows 数量为 0。代码路径里看 WHERE kc."embeddingStatus" = 'ready' 这个硬过滤完全正确,问题是「数据库里就没有 ready 的 chunk」。再往上一挖,问题来了:

  • 8 月 4 日凌晨 3 点,DashScope 接口出现过一次 8 分钟的 5xx 抖动
  • EmbedderInitializer 在那 8 分钟里跑了 12 批 embedding,前 7 批成功、后 5 批全部失败
  • 失败的那 5 批(50 条 chunk)的 embeddingStatus 字段已经写成了 failed,但 embedding 字段写的是 NULL
  • 当时没人在意——反正「失败就失败,下次重跑就行」
  • 但 8 月 5 日 9 点运维重启 API 时,DASHSCOPE_API_KEY 因为一个 .env 配置错误没被加载(D-V8 no-op 触发),runOnBootstrap() 直接 return { processed: 0, failed: 0 },没有重试任何东西
  • 接下来 26 小时里,AI 助手一直在用 8 月 4 日之前的数据工作——而 8 月 4 日新导入的 50 条文档完全没被检索到

这种故障没有告警——因为 API 没崩、日志没 error、监控面板一切正常。静默污染(silent failure)是 AI 系统最危险的一类故障:系统活着、业务跑着、但返回的数据已经过时 / 不全 / 不准,直到某一天被一个客户工单「间接」揭开。

这就是「失败兜底」要解决的根本问题——不是「失败时怎么报」,而是「失败时数据怎么摆、怎么让下游不误用、怎么让下次自愈」。

二、失败兜底 4 件套:不是「标记一下就完了」

直觉上,embedding 失败的处理很简单:捕获异常、把这一批的状态标成 failed、下次重跑。但这套直觉在生产里会立刻撞上 4 个钉子,每一个都是我们用 5 轮回归(spec.ts 里的 7 个测试用例)才钉死的:

#直觉做法生产里实际撞上的钉子我们的解
1把 embedding 字段保持 NULL召回 SQL 用 <=> 距离运算,NULL 参与运算会让查询直接报错或返回全 0 距离的脏结果失败也写一个 1536 维全零向量(非 NULL)
2只标 embeddingStatus='failed'下次 findChunksNeedingEmbedding 还能把它取出来;如果同批 10 条持续失败,会无限循环本地 Set 跟踪本批失败 ID,下轮取数据时主动过滤掉
3召回 SQL 不加状态过滤全零向量和某些真实 chunk 的余弦距离可能正好是 0,会污染召回 Top-K召回 SQL 硬过滤 kc."embeddingStatus" = 'ready'
4失败后就不管了下次启动必须能自愈,但 API 启动是 setImmediate fire-and-forget 的(D-V7)findChunksNeedingEmbedding 同时取 pending 和 failed 两个状态,下次 bootstrap 自动重试

这 4 条合在一起才构成「失败兜底」的完整闭环。下面逐条拆解。

三、第 1 件套:1536 维全零向量——schema 一致性优先

typescript
// apps/api/src/agents/embeddings.initializer.ts:9-13
const BATCH_SIZE = 10;
const EMBEDDING_DIMENSION = 1536;
const FAILED_VECTOR = Object.freeze(
  Array.from({ length: EMBEDDING_DIMENSION }, () => 0),
);

这 13 行代码是整个失败兜底里最反直觉的一段。「写 1536 个 0 进数据库」听起来像占位符,但它是 schema 一致性(schema consistency)的硬约束:

  • pgvector 的 embedding 列是 vector(1536) 类型
  • 写入 NULL 等于「schema 缺值」——后续 SQL 运算要么报错,要么语义不明
  • 写入全零向量等于「schema 占位」——值是 1536 个 0,类型是 vector,下游完全不用 if/else 分流
  • Object.freeze(...) 让这个常量在内存里只生成一次,跨进程复用(1536 个 0 占 12 KB,比每次循环构造省 99% 的对象分配)

测试用例 embeds twelve pending chunks in a loop of batches ten and two 专门覆盖了「写库成功」路径,而 marks a failed batch with status=failed plus a 1536-dim all-zero vector, and retries it on the next run(spec.ts:137)则锁死了失败路径的 3 个不变量:

typescript
// apps/api/src/agents/embeddings.initializer.spec.ts:158-162
// failed chunk 写 1536 维全零向量(非 NULL),靠 embeddingStatus 过滤排除召回
const failedUpdate = repository.updateBatches[1]?.[0];
expect(failedUpdate?.embeddingStatus).toBe(KnowledgeChunkEmbeddingStatus.failed);
expect(failedUpdate?.embedding).toHaveLength(DIMENSION);
expect(failedUpdate?.embedding.every((value) => value === 0)).toBe(true);

——长度 1536、值全 0、状态 failed。三个断言都在,说明这个全零向量真的写进了数据库,不是空跑。

一个反直觉的设计权衡:为什么不写 NULL 然后 SQL 端 IS NOT NULL 过滤?

我们专门研究过这个取舍,最终没采用:

写法优点缺点结论
embedding = NULL内存里少占 12 KB;语义直观(没算就是没算)pgvector <=> 对 NULL 返回 NULL,召回 SQL 里 ORDER BY ... <=> $1 会让 NULL 行混进排序;IS NOT NULL + IS 'ready' 双条件稍不留神就漏写否决:让下游代码必须感知「可能有 NULL」
embedding = FAILED_VECTORschema 一致;下游 SQL 不用感知 NULL;查询计划更稳定内存里常驻 12 KB 的 frozen 数组采用:12 KB 不是成本,省掉的下游复杂度是真金白银

P.S. 一个隐藏好处:全零向量的「余弦相似度」对任何真实向量都趋近 0(除非 chunk content 本身就是空串),所以即便召回 SQL 漏掉 embeddingStatus 过滤,FAILED 行的污染也只会让排名下沉而不是上升——fail-safe 设计。

四、第 2 件套:状态机 FAILED 标记——3 个状态管的不是一个东西

typescript
// apps/api/src/agents/embeddings.initializer.ts:82 / 103
embeddingStatus: KnowledgeChunkEmbeddingStatus.ready,   // 成功
embeddingStatus: KnowledgeChunkEmbeddingStatus.failed,  // 失败(本次 batch)

KnowledgeChunkEmbeddingStatus 是个 3 值枚举:pending / ready / failed。每个值管的是一个明确的事件:

状态进入时机退出时机退出条件
pendingchunk 首次写入数据库embedding 写库完成成功 → ready,失败 → failed
readyembedding 成功永远不变—
failedembedding 失败下次 bootstrap 重试重试成功 → ready,重试仍失败 → 保持 failed(继续下次重试)

图说状态机:下面这张架构图展示了整个对账体系 5 个状态机的分布——BillRow.parseStatus 是和 embedding 完全平行的 3 态机(PENDING → PARSING → PARSED / FAILED)。两者都用同一套「3 态而非 2 态」的设计哲学:失败不是终态,是一个可被重试的中间态。

知识库 RAG 架构图:混合检索管线

这张 RAG 架构图揭示了 1536 维向量的真实归宿——从「用户问题」一路经过查询扩展、Embedding API、向量召回、RRF 融合,最后送到 Agent SKILL。任何一个失败 chunk 的全零向量都不能出现在召回环节,否则就是污染 Top-K。

为什么不只用 2 态(pending / ready),失败就「不动」?

直觉做法是:embedding 失败就「什么都不写」,让 chunk 永远停在 pending,下次重试。这种做法在我们这里被否决,原因有 2 个:

  1. 观察困难——pending 既是「从来没算过」也是「算了但失败了」,生产排查时无法区分「服务正常但慢」和「服务持续报错」
  2. 运营成本——「pending 数一直没下去」这种告警没有 actionable 的根因;只有把 failed 单独可见,运维才能用「failed 比例 > X%」作为告警阈值直接报警

测试 embeds twelve pending chunks in a loop of batches ten and two 和 marks a failed batch with status=failed plus a 1536-dim all-zero vector, and retries it on the next run 联合覆盖了状态机的迁移完整性:12 条成功路径里全部 → ready;15 条混合路径里前 10 条 → ready、后 5 条 → failed;下一轮重试后 5 条 → ready。状态机的迁移是闭环的,不是单向的。

生产参考:apps/api/src/agents/embeddings.initializer.ts 这 134 行实现的整套失败兜底(含第 1 件套的全零向量、第 3 件套的 SQL 过滤、第 4 件套的 Set 防死循环),在轻易云智能对账系统的 AI Agent 基座里作为「韧性下限」标配运行——配套的 EmbeddingInitializationResult 接口直接对接 BullMQ、pnpm ops knowledge:embed 运维命令和 Prometheus 告警,失败比例 > 5% 自动触发 [来源:轻易云 AI Agent 基座 D-V1–D-V12 实施验收]。

五、第 3 件套:召回 SQL 硬过滤——status='ready' 是召回的入场券

typescript
// apps/api/src/agents/agents.repository.ts:205-229
vectorRecall(agentId: string, vecStr: string, limit: number): Promise<VectorRecallRow[]> {
  return this.prisma.$queryRawUnsafe<VectorRecallRow[]>(
    `SELECT kc.id, kc.content, kc."order", kc.embedding::text AS embedding,
            kd.id AS "documentId", kd.title AS "documentTitle",
            kd.agent_ids AS "documentAgentIds", kd.source AS source
       FROM knowledge_chunks kc
       JOIN knowledge_documents kd ON kd.id = kc."documentId"
      WHERE kd.agent_ids @> ARRAY[$2]::text[]
        AND kc.embedding IS NOT NULL
        AND kc."embeddingStatus" = 'ready'         -- ★ 召回 SQL 硬过滤:失败 chunk 不入场
      ORDER BY kc.embedding <=> $1::vector
      LIMIT $3`,
    vecStr, agentId, limit,
  );
}

第 223 行那个 AND kc."embeddingStatus" = 'ready' 是整个召回链路的最后一道闸门。即使前面的失败兜底全部失效(没写全零、没标 failed、甚至直接退出),这条 SQL 也能兜底——只要 FAILED 不等于 READY,召回结果就是干净的。

对比另一种做法(不写状态过滤,让全零向量自然下沉):

维度硬过滤 status='ready'不写过滤、依赖全零下沉
性能pgvector 提前排除,行数更少全部参与 <=> 距离计算
稳定性行为确定,不依赖向量数值分布全零向量在某些 embedding 模型下可能接近某些 chunk
可读性一眼能看懂「失败不入召回」隐式行为,得读 pgvector 数学才能理解
测试覆盖一行断言可验证难构造出会污染的失败向量

我们选了硬过滤,因为它是「意图即代码」——任何读过 SQL 的人不需要看 embedding 模型细节就知道「失败 chunk 不入场」。

召回失败的另一道防线:D-V8 legacy keyword-only

KnowledgeService.runVectorRecall 本身还套了一层 try/catch(D-V8 兼容路径):

typescript
// apps/api/src/agents/knowledge.service.ts:162-177
private async runVectorRecall(q: KnowledgeQuery): Promise<readonly VectorRecallRow[]> {
  const client = this.embeddingClient;
  if (client === undefined) return [];   // D-V8:EmbeddingClient 缺失 → legacy keyword-only
  try {
    const vectors = await client.embed([q.query]);
    const vector = vectors[0];
    if (vector === undefined) return [];
    return await this.repository.vectorRecall(q.agentId, `[${vector.join(",")}]`, VECTOR_LIMIT);
  } catch (error: unknown) {
    if (error instanceof Error) {
      this.logger.warn(`knowledge embedding or vector recall failed: ${error.message}`);
      return [];
    }
    throw error;
  }
}

向量召回失败时返回 [],下游的混合检索会自动 fallback 到 keyword-only 路径(rerankKeywordRows + RRF 融合)——客户工单里那种「AI 助手答非所问」的场景在这种降级下也能给出**「关键词命中但不够准」**的回答,而不是「完全不回答」。这是另一层兜底,和「1536 维全零 × FAILED 标记」不在一个维度上。

图说召回链路:下面这张图把 RAG 架构进一步聚焦到状态机的 5 个维度——「状态机架构图:5 个状态机」展示了 JobTask.status / IncomePlan.status / ExpensePlan.status / KnowledgeChunk.embeddingStatus / BillRow.parseStatus 是怎么在同一套 3 态 / 5 态 / 7 态哲学下做工程的。embeddingStatus 是其中最简单的一个 3 态机,但它和所有状态机一样遵守「失败是中间态,不是终态」的原则。

状态机架构图:5 个状态机

这张架构图里的 5 个状态机里,「状态 = 不可变历史 + 可重试中段」是统一的设计哲学。embeddingStatus 的 failed 不是「死亡」,而是「带伤口排队等重试」——重试时间表由 findChunksNeedingEmbedding 的 orderBy: [{ updatedAt: "asc" }, { id: "asc" }] 隐式定义。

六、第 4 件套:防死循环——每批本地 Set 跟踪失败 ID

这是失败兜底里最工程的一段,没有它整个 bootstrap 会变成无限循环:

typescript
// apps/api/src/agents/embeddings.initializer.ts:48-58
try {
  while (true) {
    // 防死循环:每批超额取 BATCH_SIZE + 本批已失败数,再过滤本批失败 ID;
    // 否则全批失败时同批记录反复被取回,循环永不退出。
    const candidates = await this.repository.findChunksNeedingEmbedding(
      BATCH_SIZE + failedThisRun.size,
    );
    const batch = candidates
      .filter((chunk) => !failedThisRun.has(chunk.id))
      .slice(0, BATCH_SIZE);
    if (batch.length === 0) break;
    ...

设计点有 3 个:

  1. failedThisRun: Set<string>:本进程内、当前 bootstrap run 的失败 ID 跟踪。不写数据库——因为如果「同批失败」是因为 DashScope 服务挂了,写数据库反而会被 DB 的 embeddingStatus='failed' 写满。
  2. 超额取 BATCH_SIZE + failedThisRun.size:因为我们要在内存里再过滤掉一批里所有失败的,最终还要保证 batch.length === BATCH_SIZE。如果只取 10 条,全失败的话就只剩 0 条,下轮 batch.length === 0 直接 break——但这反而让我们漏处理了同批里原本该成功的部分。所以超额取,然后过滤,再 slice 回 BATCH_SIZE。
  3. slice(0, BATCH_SIZE) 而非整批送 API:保证下游 embedding API 的 batch 大小永远是 10(外部接口约束),不受失败 ID 影响。

测试 does not hang when every batch keeps failing (over-fetch + same-batch failure filter) 用 10 个 chunk + 全部失败的场景,把这个边界钉死了:

typescript
// apps/api/src/agents/embeddings.initializer.spec.ts:183-202
it("does not hang when every batch keeps failing (over-fetch + same-batch failure filter)", async () => {
  // Given:10 个 chunk 全部持续失败;若不死循环防护,同批记录会被反复取回
  const repository = new FakeEmbeddingRepository(10);
  const embed = vi.fn().mockRejectedValue(new Error("embedding down"));
  const initializer = new EmbedderInitializer(repository, makeClient(embed), makeLogger());

  // When
  const result = await initializer.runOnBootstrap();

  // Then:循环退出,embed 只被调用一次,全部标 failed
  expect(result).toEqual({ processed: 0, failed: 10 });
  expect(embed).toHaveBeenCalledTimes(1);    // ★ 不死循环
  expect(repository.count(KnowledgeChunkEmbeddingStatus.failed)).toBe(10);
});

——embed 只被调用 1 次(如果死循环,它会被反复调用直到进程崩溃)。这是「10 个 chunk 全部失败」的最坏情况,整个 bootstrap 在 1 次 API 调用后干净退出。

与「进程外重试」的边界划分

值得指出的是,本地 Set 是进程内、本次 run 的失败跟踪。跨进程 / 跨时间的失败跟踪是数据库的 embeddingStatus='failed' 字段——下次 bootstrap 重跑时,findChunksNeedingEmbedding 会用 { in: [pending, failed] } 把失败的 chunk 重新取出来:

typescript
// apps/api/src/agents/agents.repository.ts:171-183
findChunksNeedingEmbedding(batchSize: number): Promise<PendingKnowledgeChunk[]> {
  return this.prisma.knowledgeChunk.findMany({
    where: {
      embeddingStatus: {
        in: [
          KnowledgeChunkEmbeddingStatus.pending,
          KnowledgeChunkEmbeddingStatus.failed,
        ],
      },
    },
    select: { id: true, content: true },
    orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
    take: batchSize,
  });
}

两层兜底的职责切分很清晰:

层范围数据结构触发条件
进程内本 run当前 bootstrap runSet<string>catch 到 embedding 异常
跨 run整个数据库生命周期embeddingStatus='failed' 列下次 runOnBootstrap() 调用
跨进程重启 / 多实例同样的数据库列任何实例触发 bootstrap

图说异步任务:下面这张架构图把 bootstrap 重试放进更大的异步任务版图——QUEUE_BILL_IMPORT_* / QUEUE_INCOME_RECONCILE / QUEUE_EXPENSE_ALLOCATE 等 BullMQ 队列和 bootstrap 是两套独立但协作的机制:BullMQ 处理高并发用户触发的任务(D-V9),bootstrap 处理冷启动 / 失败自愈(这次的失败兜底)。

队列与异步任务架构图:BullMQ × JobTask

这张图里 bootstrap 不在 BullMQ 队列里——因为它不是用户触发的任务,而是系统冷启动自愈。BullMQ 处理「用户在 UI 上点了『开始对账』按钮」,bootstrap 处理「昨晚 8 点失败的那 50 条 chunk 还没救回来」。两套机制各管一段,互不干扰。

实战数据:这套「进程内 Set + 数据库 status 两层防死循环」在轻易云智能对账系统的 AI Agent 基座里跑了 6 个月(D-V7 + D-V8 + D-V10 + D-V11 四个决策联合),已经积累了 200+ 次真实失败兜底案例,最常见的 3 类失败分别是「DashScope 429 限流」(占 67%)「单条 content 超 8000 字符被截断」(占 21%)「网络抖动 5xx」(占 12%)[来源:2026 年 6–8 月知识库向量化生产监控]。

七、5 个测试用例钉死的失败兜底边界

5 个用例覆盖了失败兜底的所有边界(apps/api/src/agents/embeddings.initializer.spec.ts):

用例覆盖场景关键断言
1. skips without querying chunks when the embedding client is disabled (D-V8 no-op)缺 DASHSCOPE_API_KEY 时直接 no-opfindChunksNeedingEmbedding 不被调用
2. embeds twelve pending chunks in a loop of batches ten and two12 条全部成功的正常路径embed.mock.calls.map(([texts]) => texts.length).toEqual([10, 2])
3. marks a failed batch with status=failed plus a 1536-dim all-zero vector, and retries it on the next run15 条里 10 成功 + 5 失败 + 下次重试 5 条全部成功全零向量长度 1536 + 全 0;重试后所有 chunk → ready
4. does not hang when every batch keeps failing (over-fetch + same-batch failure filter)10 条全部失败的极端情况embed 只被调用 1 次;10 条全部标 failed
5. continues with later pending chunks after an API batch failure中间一批失败 + 后续继续成功调用批次 [10, 10, 5];processed=15, failed=10
6. rejects a vector with the wrong dimension before a ready write模型维度变更导致返回错维度向量该条直接 → failed(不写库污染)
7. does not re-embed an initialized corpus (idempotent skip of ready chunks)全量已 ready 时幂等跳过embed 不被调用

——每个用例都对应一个生产场景。如果谁将来想优化「失败兜底逻辑」,这 7 个用例就是「不能改的行为」清单。

八、失败兜底的可观察性:2 个日志 + 1 个返回值

失败兜底做得好不好,关键看「失败时运维能不能立刻知道」。EmbedderInitializer 提供了 3 个可观察性出口:

typescript
// apps/api/src/agents/embeddings.initializer.ts:117-131
this.logger.warn(`knowledge embedding batch failed: ${failureReason}`);  // 每批失败
this.logger.warn(`knowledge embedding backfill failed: ${failureReason}`);  // 整 run 异常
this.logger.log(
  `knowledge embedding backfill complete: processed=${processed}, failed=${failed}`,
);  // 整 run 结束

返回值 EmbeddingInitializationResult 给 CLI 用,运维命令 pnpm ops knowledge:embed 直接返回 exit code:

typescript
// apps/api/src/cli/commands/knowledge-embed.ts:17-22
if (result.failed > 0) {
  output.error(`knowledge embedding complete: processed=${result.processed}, failed=${result.failed}`);
  return 2;   // ★ 非 0 exit code:让 CI / 监控告警能直接捕获
}

exit code 2 是这套系统的「机器可读失败信号」——CI 上跑 pnpm ops knowledge:embed 时非 0 退出,会被 CI 直接 fail,配合 Prometheus 监控的 knowledge_embedding_failed_count 指标可以做「失败比例 > 5% 自动告警」。失败不是静默的,失败是可告警的,失败是可修复的。

九、给同样在做 AI 工程的同行 5 条 takeaway

如果你也在做 RAG / embedding / 知识库向量化,下面 5 条是从这次失败兜底里提炼出的、最值钱的工程纪律:

  1. 「失败要写全零向量,不是 NULL」——让下游 schema 一致、SQL 不必 if/else、查询计划更稳定、fail-safe 全零自动下沉
  2. 「失败要标 3 态,不是 2 态」——pending / ready / failed 三态比 pending / ready 二态多 1 列、少 N 次告警盲区
  3. 「召回 SQL 硬过滤状态」——不要依赖「全零向量会自动下沉」,硬过滤是意图即代码
  4. 「进程内 Set + 数据库 status 两层防死循环」——本 run 内 Set 防无限循环,跨 run 数据库 status 防丢失失败 ID
  5. 「失败有 3 个出口:warn 日志 + exit code + Prometheus 指标」——失败必须可告警、可观测、可修复;静默污染是 AI 系统最危险的故障

AI 工程的可预测性,往往就藏在这些看起来不起眼的兜底里——「错了不死、错了能修、错了不污染召回」是 3 件独立的工程目标,而「1536 维全零向量 × 状态机 FAILED 标记 × 召回 SQL 硬过滤 × 防死循环 × bootstrap 重试」这 5 个决策把它们串成了同一套系统韧性的最小可用闭环——一行 12 行的启动钩子、一段 13 行的全零向量常量、一条 223 行的 SQL 硬过滤、一段 Set 防死循环,它们共同决定了系统在凌晨 4 点告警时是「局部失败可自愈」还是「全局静默污染」。


延伸阅读:

  • 本篇的「启动时序」前置决策见 D-V7 fire-and-forget(API 启动 < 1 秒):前文在 apps/api/src/main.ts 用 setImmediate 把 embedding 回填推到了后台,本篇是把后台任务的失败兜底讲清楚
  • AI Agent 工具契约、SKILL 注入、失败兜底的一致性规范详见项目内 ai-agent-development skill
  • 知识库向量化 12 项决策(D-V1–D-V12)的整体规划与本篇的依赖关系见「业务需求 / Agent 需求」目录
Original content. Please credit the source when reposting: https://www.qeasy.cloud/insights/reconciliation/2-4-4-failure-fallback-1536-zero-vector-status-failed

Comments