失败兜底策略:1536 维全零向量 × 状态机 FAILED 标记
失败兜底策略: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-V8no-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 一致性优先
// 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 个不变量:
// 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_VECTOR | schema 一致;下游 SQL 不用感知 NULL;查询计划更稳定 | 内存里常驻 12 KB 的 frozen 数组 | 采用:12 KB 不是成本,省掉的下游复杂度是真金白银 |
P.S. 一个隐藏好处:全零向量的「余弦相似度」对任何真实向量都趋近 0(除非 chunk content 本身就是空串),所以即便召回 SQL 漏掉 embeddingStatus 过滤,FAILED 行的污染也只会让排名下沉而不是上升——fail-safe 设计。
四、第 2 件套:状态机 FAILED 标记——3 个状态管的不是一个东西
// apps/api/src/agents/embeddings.initializer.ts:82 / 103
embeddingStatus: KnowledgeChunkEmbeddingStatus.ready, // 成功
embeddingStatus: KnowledgeChunkEmbeddingStatus.failed, // 失败(本次 batch)
KnowledgeChunkEmbeddingStatus 是个 3 值枚举:pending / ready / failed。每个值管的是一个明确的事件:
| 状态 | 进入时机 | 退出时机 | 退出条件 |
|---|---|---|---|
pending | chunk 首次写入数据库 | embedding 写库完成 | 成功 → ready,失败 → failed |
ready | embedding 成功 | 永远不变 | — |
failed | embedding 失败 | 下次 bootstrap 重试 | 重试成功 → ready,重试仍失败 → 保持 failed(继续下次重试) |
图说状态机:下面这张架构图展示了整个对账体系 5 个状态机的分布——
BillRow.parseStatus是和 embedding 完全平行的 3 态机(PENDING → PARSING → PARSED / FAILED)。两者都用同一套「3 态而非 2 态」的设计哲学:失败不是终态,是一个可被重试的中间态。

这张 RAG 架构图揭示了 1536 维向量的真实归宿——从「用户问题」一路经过查询扩展、Embedding API、向量召回、RRF 融合,最后送到 Agent SKILL。任何一个失败 chunk 的全零向量都不能出现在召回环节,否则就是污染 Top-K。
为什么不只用 2 态(pending / ready),失败就「不动」?
直觉做法是:embedding 失败就「什么都不写」,让 chunk 永远停在 pending,下次重试。这种做法在我们这里被否决,原因有 2 个:
- 观察困难——
pending既是「从来没算过」也是「算了但失败了」,生产排查时无法区分「服务正常但慢」和「服务持续报错」 - 运营成本——「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' 是召回的入场券
// 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 兼容路径):
// 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 个状态机里,「状态 = 不可变历史 + 可重试中段」是统一的设计哲学。
embeddingStatus的failed不是「死亡」,而是「带伤口排队等重试」——重试时间表由findChunksNeedingEmbedding的orderBy: [{ updatedAt: "asc" }, { id: "asc" }]隐式定义。
六、第 4 件套:防死循环——每批本地 Set 跟踪失败 ID
这是失败兜底里最工程的一段,没有它整个 bootstrap 会变成无限循环:
// 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 个:
failedThisRun: Set<string>:本进程内、当前 bootstrap run 的失败 ID 跟踪。不写数据库——因为如果「同批失败」是因为 DashScope 服务挂了,写数据库反而会被 DB 的embeddingStatus='failed'写满。- 超额取
BATCH_SIZE + failedThisRun.size:因为我们要在内存里再过滤掉一批里所有失败的,最终还要保证batch.length === BATCH_SIZE。如果只取 10 条,全失败的话就只剩 0 条,下轮batch.length === 0直接 break——但这反而让我们漏处理了同批里原本该成功的部分。所以超额取,然后过滤,再 slice 回 BATCH_SIZE。 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 + 全部失败的场景,把这个边界钉死了:
// 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 重新取出来:
// 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 run | Set<string> | catch 到 embedding 异常 |
| 跨 run | 整个数据库生命周期 | embeddingStatus='failed' 列 | 下次 runOnBootstrap() 调用 |
| 跨进程 | 重启 / 多实例 | 同样的数据库列 | 任何实例触发 bootstrap |
图说异步任务:下面这张架构图把 bootstrap 重试放进更大的异步任务版图——
QUEUE_BILL_IMPORT_*/QUEUE_INCOME_RECONCILE/QUEUE_EXPENSE_ALLOCATE等 BullMQ 队列和 bootstrap 是两套独立但协作的机制:BullMQ 处理高并发用户触发的任务(D-V9),bootstrap 处理冷启动 / 失败自愈(这次的失败兜底)。

这张图里 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-op | findChunksNeedingEmbedding 不被调用 |
2. embeds twelve pending chunks in a loop of batches ten and two | 12 条全部成功的正常路径 | 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 run | 15 条里 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 个可观察性出口:
// 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:
// 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 条是从这次失败兜底里提炼出的、最值钱的工程纪律:
- 「失败要写全零向量,不是 NULL」——让下游 schema 一致、SQL 不必 if/else、查询计划更稳定、fail-safe 全零自动下沉
- 「失败要标 3 态,不是 2 态」——
pending / ready / failed三态比pending / ready二态多 1 列、少 N 次告警盲区 - 「召回 SQL 硬过滤状态」——不要依赖「全零向量会自动下沉」,硬过滤是意图即代码
- 「进程内 Set + 数据库 status 两层防死循环」——本 run 内 Set 防无限循环,跨 run 数据库 status 防丢失失败 ID
- 「失败有 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-developmentskill - 知识库向量化 12 项决策(D-V1–D-V12)的整体规划与本篇的依赖关系见「业务需求 / Agent 需求」目录