跳到主要内容

创建日期:2026-09-20 | 最近更新:2026-09-20 全部代码完整贴出(含注释,不做省略);运行输出为本机真实执行结果(Mock 模式,无需 API Key)。源码与日志留档在 source/agent-engineering/。 前置:Agent 工程化总览|姊妹篇:结构化输出五层防护链。

弹性容错五层链:从「一个慢下游拖垮全站」到「高可用网关」

这篇的起点是一个很容易被低估的事实:Node.js 是单线程事件循环。

一个下游超时 30 秒,所有请求都在等超时回调,事件循环被占满——进程连健康检查都答不了,然后被 k8s 杀掉,雪崩。Java 有线程池隔离,一个池满了不影响别的业务;Node 没有这层隔离。

所以熔断、限流这些东西在 Node 里不是「锦上添花的稳定性建设」,而是主防线。对 LLM 应用还多一层:熔断器 Open 状态一个请求都不发,Token 消耗为 0——省的是真金白银。

五层阶梯的目标是「一个高可用的 LLM 调用网关」:

L0 裸调用 → L1 重试 → L2 熔断 → L3 模型降级 → L4 完整弹性链

一、L0:裸调用(以及它为什么会雪崩)​

export async function bareLLMCall<T>(
fn: () => Promise<T>,
): Promise<T> {
// 直接调用,没有超时、没有重试、没有熔断、没有降级
return fn();
}

// 模拟"100个并发请求打挂下游"的坏场景
export async function demoBadScenario(
flakyFn: () => Promise<string>,
concurrency = 20,
): Promise<{ success: number; failed: number; totalTimeMs: number }> {
const start = Date.now();
let success = 0;
let failed = 0;

const promises = Array.from({ length: concurrency }, async () => {
try {
await bareLLMCall(flakyFn);
success++;
} catch {
failed++;
}
});

await Promise.all(promises);

return {
success,
failed,
totalTimeMs: Date.now() - start,
};
}

// Mock 一个不稳定的 LLM 调用(用于演示)
export function createFlakyLLMMock(options?: {
failureRate?: number;
latencyMs?: number;
}): () => Promise<string> {
const failureRate = options?.failureRate ?? 0.3;
const latencyMs = options?.latencyMs ?? 100;
let callCount = 0;

return async () => {
callCount++;
await new Promise((r) => setTimeout(r, latencyMs + Math.random() * 50));

if (Math.random() < failureRate) {
const errors = [
new Error("429 Too Many Requests"),
new Error("ETIMEDOUT"),
new Error("503 Service Unavailable"),
new Error("ECONNRESET"),
];
throw errors[Math.floor(Math.random() * errors.length)];
}

return `Response #${callCount}: 这是一个模拟的 LLM 回复`;
};
}

真实运行输出(20 并发,下游失败率 80%):

L0 无保护: 成功=1, 失败=19, 耗时=87ms

20 个请求挂 19 个。 而且注意它很快(87ms)——快是因为它在快速失败,失败本身不花钱;但生产里真实的慢下游是卡住(等 30 秒超时),那才是把事件循环占满的原因。

createFlakyLLMMock 这个工具函数很实用:它把「不稳定」做成了可配置的参数(failureRate + latencyMs),后面每一层的效果都能用它量化验证。

二、L1:指数退避重试​

import pRetry, { AbortError } from "p-retry";

export interface RetryOptions {
retries?: number;
minTimeout?: number;
maxTimeout?: number;
factor?: number;
onFailedAttempt?: (error: Error, attempt: number) => void;
shouldRetry?: (error: Error) => boolean;
}

const DEFAULT_RETRY_OPTIONS: Required<
Pick<RetryOptions, "retries" | "minTimeout" | "maxTimeout" | "factor">
> = {
retries: 3,
minTimeout: 500, // 首次重试等待 500ms
maxTimeout: 5000, // 最大等待 5s
factor: 2, // 指数退避因子
};

export async function withSimpleRetry<T>(
fn: () => Promise<T>,
options?: RetryOptions,
): Promise<T> {
const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };

return pRetry(
async () => {
try {
return await fn();
} catch (err) {
const error = err as Error;

// 如果提供了 shouldRetry,用它来判断是否重试
// 否则默认所有错误都重试(这是 L1 的问题——不区分错误类型)
if (options?.shouldRetry && !options.shouldRetry(error)) {
throw new AbortError(error); // AbortError 会终止重试
}

throw error; // 其他错误触发重试
}
},
{
retries: opts.retries,
minTimeout: opts.minTimeout,
maxTimeout: opts.maxTimeout,
factor: opts.factor,
onFailedAttempt: (error: any) => {
console.log(
`[Retry] 第 ${error.attemptNumber} 次尝试失败 (还有 ${error.retriesLeft} 次重试): ${error.message}`,
);
options?.onFailedAttempt?.(error, error.attemptNumber);
},
},
);
}

指数退避:factor = 2 意味着等待时间是 500ms → 1s → 2s → 4s(受 maxTimeout 封顶)。目的是给下游喘息时间,而不是立刻再打一遍。

L1 的病根写在注释里了:默认「所有错误都重试」,不区分类型。于是:

  • 下游真挂了(宕机 5 分钟),所有请求都在重试:100 QPS → 400 QPS(重试 3 次),越失败越重试,越重试越失败;
  • 更糟的是 4xx 这类重试一万次也不会好的错误,也在烧钱。

重试只能解决「偶发抖动」(网络抖动、瞬时限流),解决不了「系统性故障」。 那需要熔断器。

注意 AbortError 这个 API:抛出它就等于告诉 p-retry「别重试了」——这是把「错误分类」接进重试机制的钩子,L4 会重度依赖它。

三、L2:熔断器三态机​

import CircuitBreaker from "opossum";

export const DEFAULT_BREAKER_OPTIONS: CircuitBreaker.Options = {
timeout: 10000, // 10秒超时
errorThresholdPercentage: 50, // 50% 失败率触发熔断
resetTimeout: 5000, // 5秒后进入 half-open 试探
volumeThreshold: 3, // 至少 3 个请求才开始统计(避免冷启动误判)
rollingCountTimeout: 10000, // 10秒滚动窗口
rollingCountBuckets: 10, // 10个桶(每秒一个)
};

export interface BreakerWithEvents {
fire(...args: unknown[]): Promise<unknown>;
status: {
open: boolean;
halfOpen: boolean;
closed: boolean;
};
stats: {
successes?: number;
failures?: number;
fallbacks?: number;
};
on(event: string, listener: (...args: any[]) => void): void;
shutdown(): void;
}

export function createBreaker<TArgs extends unknown[], TResult>(
action: (...args: TArgs) => Promise<TResult>,
options?: Partial<CircuitBreaker.Options>,
): BreakerWithEvents {
const breaker: any = new CircuitBreaker(action as (...args: unknown[]) => Promise<unknown>, {
...DEFAULT_BREAKER_OPTIONS,
...options,
});

// 手动维护状态标志(opossum v8 类型定义不完善)
let isOpen = false;
let isHalfOpen = false;

breaker.on("open", () => {
isOpen = true;
isHalfOpen = false;
console.log("[熔断器] 状态变化: CLOSED → OPEN (故障雪崩,快速失败,不消耗 Token)");
});

breaker.on("halfOpen", () => {
isOpen = false;
isHalfOpen = true;
console.log("[熔断器] 状态变化: OPEN → HALF_OPEN (试探性恢复,放一个请求)");
});

breaker.on("close", () => {
isOpen = false;
isHalfOpen = false;
console.log("[熔断器] 状态变化: HALF_OPEN → CLOSED (恢复正常)");
});

breaker.on("fallback", () => {
console.log("[熔断器] 执行 fallback 函数");
});

breaker.on("success", () => {
// 成功时不打日志,避免刷屏
});

breaker.on("failure", (err: Error) => {
console.log(`[熔断器] 请求失败: ${err.message}`);
});

return {
fire: (...args: unknown[]) => breaker.fire(...args),
status: {
get open() { return isOpen; },
get halfOpen() { return isHalfOpen; },
get closed() { return !isOpen && !isHalfOpen; },
},
stats: breaker.stats,
on: (event: string, listener: (...args: any[]) => void) => breaker.on(event, listener),
shutdown: () => breaker.shutdown(),
};
}

// 获取熔断器状态的辅助函数
export function getBreakerState(breaker: BreakerWithEvents): "closed" | "open" | "halfOpen" {
if (breaker.status.open) {
return "open";
}
if (breaker.status.halfOpen) {
return "halfOpen";
}
return "closed";
}

export type BreakerEvent = "open" | "halfOpen" | "close" | "fallback" | "success" | "failure";

三态机:

失败率达标
┌──────────────────┐
│ ▼
CLOSED ──────────► OPEN ◄──────┐
▲ │ │
│ │ resetTimeout
│ ▼ │
│ HALF_OPEN ──────┘
│ │ │
│ 试探成功 试探失败
└────────────┘ │
▼
OPEN
  • Closed:正常放行,统计失败率;
  • Open:快速失败,一个请求都不发——这是它相对重试的根本区别;
  • HalfOpen:resetTimeout 后放一个请求试探,成功回 Closed,失败回 Open。

三个参数的工程含义(比记阈值更重要):

参数作用调错的后果
volumeThreshold: 3至少 3 个请求才统计太少 → 冷启动时两个失败就熔断(误伤)
errorThresholdPercentage: 50失败率阈值太高 → 熔断太晚;太低 → 频繁误熔断
resetTimeout: 5000多久后试探太短 → 下游还没好就反复试探;太长 → 恢复慢

封装这层包装的理由:opossum 的状态要从事件里读,且 v8 的类型定义不完善,所以这里手动维护 isOpen / isHalfOpen 标志再暴露成 status getter。把一个第三方库的「事件式状态」包装成「属性式状态」,是让上层代码变简单的高性价比做法。

四、模型路由器:两阶段路由 + 语义缓存​

这是 L3 的前置模块,也是整套设计里最省钱的一环。

export enum ModelTier {
Premium = "gpt-4o",
Standard = "gpt-4o-mini",
Economy = "deepseek-chat",
Cache = "semantic-cache",
}

export interface RouteDecision {
model: ModelTier;
reason: "cost_first" | "complexity" | "fallback" | "cache_hit" | "simple_query";
estimatedCostPer1kTokens: number;
}

// 各模型每 1k Token 的估算成本(美元,输入+输出平均)
const MODEL_COSTS: Record<ModelTier, number> = {
[ModelTier.Premium]: 0.03,
[ModelTier.Standard]: 0.0015,
[ModelTier.Economy]: 0.0007,
[ModelTier.Cache]: 0,
};

// 复杂问题关键词 - 这些需要 Premium 模型
const COMPLEX_KEYWORDS = [
"分析", "推理", "复杂", "架构", "设计", "为什么", "比较",
"评估", "优化", "debug", "重构", "原理", "本质",
];

// 简单寒暄/FAQ 关键词 - 可以直接走缓存
const SIMPLE_KEYWORDS = [
"你好", "您好", "hi", "hello", "谢谢", "再见", "拜拜",
"帮助", "help", "怎么用", "你是谁",
];

function normalizeQuery(query: string): string {
return query
.toLowerCase()
.replace(/[^\p{L}\p{N}\s]/gu, " ")
.replace(/\s+/g, " ")
.trim();
}

function cacheKey(query: string): string {
return normalizeQuery(query).slice(0, 100);
}

export interface ModelRouter {
route(query: string): RouteDecision;
getCached(key: string): string | undefined;
setCached(key: string, value: string): void;
clearCache(): void;
cacheSize(): number;
}

export function createModelRouter(): ModelRouter {
// 语义缓存简单实现 - 生产环境应该用向量相似度匹配
// 这里用归一化后的字符串前缀匹配作为教学演示
const semanticCache = new Map<string, { value: string; timestamp: number }>();
const CACHE_TTL = 30 * 60 * 1000; // 30 分钟

// 预置一些 FAQ 缓存
const presetCache: Record<string, string> = {
"你好": "你好!我是 AI 助手,有什么可以帮你的?",
"您好": "您好!我是 AI 助手,请问有什么可以帮您?",
"你是谁": "我是基于大语言模型的 AI 助手,可以回答问题、分析代码、协助开发。",
"谢谢": "不客气!如果还有其他问题随时问我。",
};

for (const [k, v] of Object.entries(presetCache)) {
semanticCache.set(cacheKey(k), { value: v, timestamp: Date.now() });
}

function isExpired(entry: { timestamp: number }): boolean {
return Date.now() - entry.timestamp > CACHE_TTL;
}

return {
route(query) {
const normalized = normalizeQuery(query);

// 第一阶段:缓存命中检查
const key = cacheKey(query);
const cached = semanticCache.get(key);
if (cached && !isExpired(cached)) {
return {
model: ModelTier.Cache,
reason: "cache_hit",
estimatedCostPer1kTokens: MODEL_COSTS[ModelTier.Cache],
};
}

// 非常短的查询(< 20 字符)- 简单问题,用 Standard
if (normalized.length < 20) {
for (const kw of SIMPLE_KEYWORDS) {
if (normalized.includes(kw)) {
return {
model: ModelTier.Standard,
reason: "simple_query",
estimatedCostPer1kTokens: MODEL_COSTS[ModelTier.Standard],
};
}
}
}

// 复杂关键词 - 需要 Premium
for (const kw of COMPLEX_KEYWORDS) {
if (normalized.includes(kw)) {
return {
model: ModelTier.Premium,
reason: "complexity",
estimatedCostPer1kTokens: MODEL_COSTS[ModelTier.Premium],
};
}
}

// 默认:成本优先,用 Standard
return {
model: ModelTier.Standard,
reason: "cost_first",
estimatedCostPer1kTokens: MODEL_COSTS[ModelTier.Standard],
};
},

getCached(key) {
const entry = semanticCache.get(cacheKey(key));
if (entry && !isExpired(entry)) {
return entry.value;
}
return undefined;
},

setCached(key, value) {
semanticCache.set(cacheKey(key), { value, timestamp: Date.now() });
},

clearCache() {
semanticCache.clear();
},

cacheSize() {
// 清理过期的
for (const [k, v] of semanticCache) {
if (isExpired(v)) {
semanticCache.delete(k);
}
}
return semanticCache.size;
},
};
}

// 模型排序(从贵到便宜,用于降级链)
export const MODEL_TIER_ORDER: ModelTier[] = [
ModelTier.Premium,
ModelTier.Standard,
ModelTier.Economy,
ModelTier.Cache,
];

export function getNextTier(current: ModelTier): ModelTier | undefined {
const idx = MODEL_TIER_ORDER.indexOf(current);
if (idx === -1 || idx === MODEL_TIER_ORDER.length - 1) {
return undefined;
}
return MODEL_TIER_ORDER[idx + 1];
}

// 静态兜底回复
export const STATIC_FALLBACK_RESPONSE =
"抱歉,系统暂时繁忙,请稍后重试。如果问题紧急,可以尝试简化您的问题描述。";

真实运行输出(按问题复杂度自动选模型):

【模型路由】按问题复杂度自动选模型
Q: 你好... → semantic-cache (degraded=false)
Q: 帮我写一个 Hello World... → gpt-4o-mini (degraded=false)
Q: 请深度分析分布式系统的 CAP 定理及其架构取舍... → gpt-4o (degraded=false)
Q: 谢谢... → semantic-cache (degraded=false)

「两阶段路由」是这层的核心思想:

阶段手段成本覆盖
第一阶段规则(关键词 + 缓存)零成本,不调模型80% 的流量
第二阶段质量校验(见 L3)一次模型调用剩下的

注意成本表的量级差:Premium 0.03 / Standard 0.0015 ≈ 20 倍。所以「把简单问题路由到便宜模型」这一件事的收益,比大多数性能优化都大。

semanticCache 用归一化字符串做 key 是教学简化——生产环境该用向量相似度匹配(这也正好接上本站 多路检索 与 Milvus 那两篇)。

五、L3:模型级联降级(一条双向链)​

import {
createModelRouter,
getNextTier,
ModelTier,
STATIC_FALLBACK_RESPONSE,
type ModelRouter,
} from "./model-router.js";

export interface ModelCallOptions {
prompt: string;
model: ModelTier;
mockMode?: boolean;
mockScenario?: "success" | "timeout" | "rate_limited" | "low_quality";
}

export interface CascadedResult {
result: string;
modelUsed: ModelTier;
degradedFrom?: ModelTier;
qualityCheckPassed: boolean;
attempts: number;
}

export interface CascadedCallOptions {
prompt: string;
router?: ModelRouter;
mockMode?: boolean;
qualityCheck?: (output: string, model: ModelTier) => boolean;
startingTier?: ModelTier;
}

// 各模型的 Mock 响应
const MOCK_RESPONSES: Record<ModelTier, { success: string; lowQuality: string }> = {
[ModelTier.Premium]: {
success: "[GPT-4o] 这是一个高质量的详细回答,包含完整的分析和代码示例。",
lowQuality: "[GPT-4o] 抱歉,我无法回答这个问题。",
},
[ModelTier.Standard]: {
success: "[GPT-4o-mini] 这是一个简洁的回答,涵盖了主要要点。",
lowQuality: "[GPT-4o-mini] 嗯...这个...让我想想...",
},
[ModelTier.Economy]: {
success: "[DeepSeek] 简要回答:答案是这样的。",
lowQuality: "[DeepSeek] 不知道",
},
[ModelTier.Cache]: {
success: "[缓存命中] 这是预先缓存的常见问题回答。",
lowQuality: "",
},
};

async function callModel(options: ModelCallOptions): Promise<string> {
const { prompt, model, mockMode, mockScenario } = options;

if (mockMode || process.env.USE_MOCK === "true" || !process.env.OPENAI_API_KEY) {
await new Promise((r) => setTimeout(r, 30 + Math.random() * 70));

if (mockScenario === "timeout") {
throw new Error("ETIMEDOUT");
}
if (mockScenario === "rate_limited") {
throw Object.assign(new Error("429 Too Many Requests"), { status: 429 });
}

const responses = MOCK_RESPONSES[model];
if (mockScenario === "low_quality") {
return responses.lowQuality;
}
return `${responses.success}\n(针对: ${prompt.slice(0, 30)}...)`;
}

// 真实 LLM 调用
const { getLLM } = await import("@offerai/shared");
const modelName = model === ModelTier.Economy ? "deepseek-chat" : model;
const llm = getLLM(modelName);
const response = await llm.invoke(prompt);
return response.content.toString();
}

// 默认质量校验:检查输出长度和基本合理性
function defaultQualityCheck(output: string, _model: ModelTier): boolean {
if (!output || output.length < 5) return false;
if (output.includes("抱歉") && output.length < 20) return false;
if (output.includes("不知道") || output.includes("无法回答")) return false;
if (output.includes("让我想想") && output.length < 30) return false;
return true;
}

export async function cascadedModelCall(
options: CascadedCallOptions,
): Promise<CascadedResult> {
const router = options.router ?? createModelRouter();
const qualityCheck = options.qualityCheck ?? defaultQualityCheck;

// 初始模型选择:路由器决策或指定
let currentModel: ModelTier = options.startingTier ?? router.route(options.prompt).model;
const startingModel = currentModel;
let attempts = 0;
let lastError: Error | null = null;

// 最多尝试 4 个模型(Premium → Standard → Economy → Cache)
const maxAttempts = MODEL_TIER_ORDER.length;

while (attempts < maxAttempts) {
attempts++;

try {
let result: string;

// 缓存级别特殊处理
if (currentModel === ModelTier.Cache) {
const cached = router.getCached(options.prompt);
if (cached) {
return {
result: cached,
modelUsed: ModelTier.Cache,
degradedFrom: attempts > 1 ? MODEL_TIER_ORDER[attempts - 2] : undefined,
qualityCheckPassed: true,
attempts,
};
}
// 缓存未命中,跳出循环到静态兜底
break;
}

// 调用模型
result = await callModel({
prompt: options.prompt,
model: currentModel,
mockMode: options.mockMode,
});

// 质量校验
if (!qualityCheck(result, currentModel)) {
console.log(
`[降级链] ${currentModel} 输出质量不达标,尝试升级/降级`,
);

// 如果当前不是 Premium,尝试升级到更好的模型
const currentIdx = MODEL_TIER_ORDER.indexOf(currentModel);
if (currentIdx > 0) {
const betterModel = MODEL_TIER_ORDER[currentIdx - 1];
console.log(`[降级链] 升级到 ${betterModel}`);
currentModel = betterModel;
continue;
}

// 已经是 Premium 了,质量还是不行,降级
const next = getNextTier(currentModel);
if (next) {
currentModel = next;
continue;
}
break;
}

// 成功!把结果写入缓存(供后续请求使用)
router.setCached(options.prompt, result);

return {
result,
modelUsed: currentModel,
degradedFrom: attempts > 1 ? startingModel : undefined,
qualityCheckPassed: true,
attempts,
};
} catch (err) {
lastError = err as Error;
console.log(
`[降级链] ${currentModel} 调用失败: ${lastError.message},尝试降级`,
);

// 失败,降级到下一个模型
const next = getNextTier(currentModel);
if (!next) {
break;
}
currentModel = next;
}
}

// 所有模型都失败了,返回静态兜底
console.log("[降级链] 所有模型均失败,返回静态兜底回复");
return {
result: STATIC_FALLBACK_RESPONSE,
modelUsed: ModelTier.Cache,
degradedFrom: startingModel,
qualityCheckPassed: false,
attempts,
};
}

// 需要导入 MODEL_TIER_ORDER(放在后面避免循环引用问题)
import { MODEL_TIER_ORDER } from "./model-router.js";

这里最该学的一点:降级链是双向的​

降级不是「失败了就用便宜模型」这么简单。 有两种触发条件,方向相反:

触发条件动作为什么
抛错(超时/限流/网络)降级到更便宜的模型贵模型限流了,便宜模型可能还活着
质量不达标(太短/拒答)升级到更强的模型便宜模型能力不够,得请更强的来
所有模型都不行静态兜底保证不抛错给用户

defaultQualityCheck 就是「质量」的可操作定义——把「答得好不好」翻译成可判定的规则:

if (!output || output.length < 5) return false; // 空/太短
if (output.includes("抱歉") && output.length < 20) return false; // 敷衍的道歉
if (output.includes("不知道") || output.includes("无法回答")) return false; // 明确拒答
if (output.includes("让我想想") && output.length < 30) return false; // 卡壳

为什么降级方向是「从贵到便宜」​

这条注释值得单独引用(面试高频):

因为大部分请求应该由合适的模型处理,只有失败时才降级。如果反过来默认用便宜的,复杂问题都要先失败一次再升级,白烧一次调用的 Token。

也就是说:默认路径要选「成功率高」的,而不是「单价低」的——因为失败的调用也是要付钱的。

六、舱壁:给每个模型独立的水密隔舱​

import Bottleneck from "bottleneck";

export interface BulkheadConfig {
name: string;
maxConcurrent: number;
minTime?: number;
reservoir?: number;
reservoirRefreshAmount?: number;
reservoirRefreshInterval?: number;
}

export interface BulkheadStats {
queued: number;
running: number;
done: number;
}

export interface Bulkhead {
name: string;
schedule<T>(fn: () => Promise<T>): Promise<T>;
stats(): BulkheadStats;
}

// 预定义教学舱壁配置
export const PRESET_BULKHEADS: Record<string, BulkheadConfig> = {
"premium-model": {
name: "premium-model",
maxConcurrent: 5,
minTime: 200,
},
"standard-model": {
name: "standard-model",
maxConcurrent: 20,
minTime: 50,
},
"economy-model": {
name: "economy-model",
maxConcurrent: 30,
minTime: 30,
},
"free-tier": {
name: "free-tier",
maxConcurrent: 2,
minTime: 1000,
reservoir: 10,
reservoirRefreshAmount: 10,
reservoirRefreshInterval: 60 * 1000,
},
};

export function createBulkhead(config: BulkheadConfig): Bulkhead {
const limiter = new Bottleneck({
maxConcurrent: config.maxConcurrent,
minTime: config.minTime ?? 0,
...(config.reservoir
? {
reservoir: config.reservoir,
reservoirRefreshAmount: config.reservoirRefreshAmount ?? config.reservoir,
reservoirRefreshInterval: config.reservoirRefreshInterval,
}
: {}),
});

limiter.on("failed", (error, jobInfo) => {
console.warn(
`[Bulkhead:${config.name}] 任务失败 (重试 ${jobInfo.retryCount}): ${(error as Error).message}`,
);
});

limiter.on("depleted", (empty) => {
console.warn(
`[Bulkhead:${config.name}] ${empty ? "队列已空" : "达到并发上限"}`,
);
});

return {
name: config.name,

async schedule<T>(fn: () => Promise<T>): Promise<T> {
return limiter.schedule(fn);
},

stats(): BulkheadStats {
const counts = limiter.counts();
return {
queued: counts.QUEUED ?? 0,
running: (counts.RUNNING ?? 0) + (counts.EXECUTING ?? 0),
done: counts.DONE ?? 0,
};
},
};
}

export interface BulkheadManager {
getOrCreate(config: BulkheadConfig): Bulkhead;
get(name: string): Bulkhead | undefined;
getPreset(name: keyof typeof PRESET_BULKHEADS): Bulkhead;
totalStats(): Record<string, BulkheadStats>;
}

export function createBulkheadManager(): BulkheadManager {
const bulkheads = new Map<string, Bulkhead>();

return {
getOrCreate(config) {
const existing = bulkheads.get(config.name);
if (existing) {
return existing;
}
const bulkhead = createBulkhead(config);
bulkheads.set(config.name, bulkhead);
return bulkhead;
},

get(name) {
return bulkheads.get(name);
},

getPreset(name) {
return this.getOrCreate(PRESET_BULKHEADS[name]);
},

totalStats() {
const result: Record<string, BulkheadStats> = {};
for (const [name, bulkhead] of bulkheads) {
result[name] = bulkhead.stats();
}
return result;
},
};
}

舱壁与熔断器的分工(别搞混):

保护谁手段
熔断器保护下游不被打挂判断健康度,坏了就快速失败
舱壁保护自己不被某个舱拖垮独立并发队列,A 满了不影响 B

Node 里的舱壁实现比 Java 轻:Java 靠独立线程池做物理隔离;Node 没有线程池,用 bottleneck 的独立 Promise 队列做逻辑隔离,效果等价但实现轻得多。free-tier 那个配置还演示了令牌桶式限流(reservoir: 10 + 每 60 秒补充 10 个),这就是「免费用户每分钟 10 次」的典型实现。

舱壁的通用原理、以及我第一次测它时踩的两个测量陷阱(用「在途耗时」测不出排队、压测端与被测服务同进程),见另一篇:舱壁模式在 Node.js 里怎么落地。

七、L4:完整弹性链​

import {
createBreaker,
DEFAULT_BREAKER_OPTIONS,
getBreakerState,
type BreakerWithEvents,
} from "./l2-circuit-breaker.js";
import {
createBulkheadManager,
type BulkheadStats,
} from "./bulkhead.js";
import {
cascadedModelCall,
type CascadedResult,
} from "./l3-model-fallback.js";
import {
createModelRouter,
ModelTier,
type ModelRouter,
} from "./model-router.js";
import { withSimpleRetry } from "./l1-retry.js";

export interface ResilientAgentOptions {
bulkheadName?: string;
breakerOptions?: Record<string, unknown>;
maxRetries?: number;
enableDegradation?: boolean;
mockMode?: boolean;
router?: ModelRouter;
}

export interface ResilientInvokeResult {
result: string;
modelUsed: ModelTier;
latencyMs: number;
degraded: boolean;
fromBulkhead: string;
circuitState: "closed" | "open" | "halfOpen";
attempts: number;
qualityCheckPassed: boolean;
}

export interface AgentResilienceStats {
circuitState: "closed" | "open" | "halfOpen";
circuitStats: {
successes: number;
failures: number;
fallbacks: number;
};
bulkheadStats: Record<string, BulkheadStats>;
modelCalls: Record<ModelTier, { success: number; failed: number }>;
totalCalls: number;
totalDegraded: number;
}

export interface ResilientAgent {
invoke(prompt: string, options?: { tier?: ModelTier }): Promise<ResilientInvokeResult>;
getStats(): AgentResilienceStats;
resetStats(): void;
shutdown(): void;
}

export function createResilientAgent(
options?: ResilientAgentOptions,
): ResilientAgent {
const bulkheadManager = createBulkheadManager();
const router = options?.router ?? createModelRouter();
const maxRetries = options?.maxRetries ?? 2;
const enableDegradation = options?.enableDegradation ?? true;
const mockMode = options?.mockMode ?? false;

// 默认舱壁:standard-model
const defaultBulkheadName = options?.bulkheadName ?? "standard-model";

// 模型调用统计
const modelCallStats: Record<ModelTier, { success: number; failed: number }> = {
[ModelTier.Premium]: { success: 0, failed: 0 },
[ModelTier.Standard]: { success: 0, failed: 0 },
[ModelTier.Economy]: { success: 0, failed: 0 },
[ModelTier.Cache]: { success: 0, failed: 0 },
};
let totalCalls = 0;
let totalDegraded = 0;

// 核心调用函数(会被熔断器和舱壁包装)
async function coreInvoke(
prompt: string,
invokeOptions?: { tier?: ModelTier },
): Promise<CascadedResult> {
if (enableDegradation) {
return cascadedModelCall({
prompt,
router,
mockMode,
startingTier: invokeOptions?.tier,
});
}

// 不启用降级,直接调用指定模型或路由选择的模型
const tier = invokeOptions?.tier ?? router.route(prompt).model;
return cascadedModelCall({
prompt,
router,
mockMode,
startingTier: tier,
});
}

// 为每个模型舱壁创建独立的熔断器
const breakers = new Map<string, BreakerWithEvents>();

function getOrCreateBreaker(name: string): BreakerWithEvents {
let breaker = breakers.get(name);
if (!breaker) {
breaker = createBreaker<[string, { tier?: ModelTier }?], CascadedResult>(
(prompt, opts) => coreInvoke(prompt as string, opts as { tier?: ModelTier }),
{
...DEFAULT_BREAKER_OPTIONS,
...(options?.breakerOptions as object),
// 教学/测试用较短的超时
timeout: mockMode ? 1000 : 10000,
resetTimeout: mockMode ? 200 : 5000,
volumeThreshold: mockMode ? 1 : 3,
},
);
breakers.set(name, breaker);
}
return breaker;
}

return {
async invoke(prompt, invokeOptions): Promise<ResilientInvokeResult> {
const start = Date.now();
totalCalls++;

// 根据目标模型选择舱壁
const targetTier = invokeOptions?.tier ?? router.route(prompt).model;
let bulkheadName = defaultBulkheadName;
if (targetTier === ModelTier.Premium) bulkheadName = "premium-model";
else if (targetTier === ModelTier.Economy) bulkheadName = "economy-model";

const bulkhead = bulkheadManager.getPreset(
bulkheadName as "premium-model" | "standard-model" | "economy-model" | "free-tier",
);
const breaker = getOrCreateBreaker(bulkheadName);

try {
// 第一层:舱壁限流
const cascadedResult = (await bulkhead.schedule(async () => {
// 第二层:熔断器 + 第三层:重试
return withSimpleRetry(
async () => {
return breaker.fire(prompt, invokeOptions) as Promise<CascadedResult>;
},
{
retries: maxRetries,
minTimeout: 200,
maxTimeout: 2000,
},
);
})) as CascadedResult;

// 统计
modelCallStats[cascadedResult.modelUsed].success++;
if (cascadedResult.degradedFrom) {
totalDegraded++;
}

return {
result: cascadedResult.result,
modelUsed: cascadedResult.modelUsed,
latencyMs: Date.now() - start,
degraded: !!cascadedResult.degradedFrom,
fromBulkhead: bulkheadName,
circuitState: getBreakerState(breaker),
attempts: cascadedResult.attempts,
qualityCheckPassed: cascadedResult.qualityCheckPassed,
};
} catch (err) {
// 所有层都失败了,熔断器应该已经打开,返回静态兜底
modelCallStats[ModelTier.Cache].failed++;
totalDegraded++;

console.log(`[ResilientAgent] 调用失败,返回静态兜底: ${(err as Error).message}`);

return {
result: "抱歉,系统繁忙,请稍后重试。",
modelUsed: ModelTier.Cache,
latencyMs: Date.now() - start,
degraded: true,
fromBulkhead: bulkheadName,
circuitState: getBreakerState(breaker),
attempts: maxRetries + 1,
qualityCheckPassed: false,
};
}
},

getStats(): AgentResilienceStats {
const defaultBreaker = getOrCreateBreaker(defaultBulkheadName);
const breakerStats = defaultBreaker.stats;

return {
circuitState: getBreakerState(defaultBreaker),
circuitStats: {
successes: breakerStats.successes ?? 0,
failures: breakerStats.failures ?? 0,
fallbacks: breakerStats.fallbacks ?? 0,
},
bulkheadStats: bulkheadManager.totalStats(),
modelCalls: modelCallStats,
totalCalls,
totalDegraded,
};
},

resetStats(): void {
totalCalls = 0;
totalDegraded = 0;
for (const tier of Object.values(ModelTier)) {
modelCallStats[tier] = { success: 0, failed: 0 };
}
},

shutdown(): void {
for (const breaker of breakers.values()) {
breaker.shutdown();
}
},
};
}

// 为 resilience 模块创建 barrel export
export {
bareLLMCall,
demoBadScenario,
createFlakyLLMMock,
} from "./l0-no-protection.js";

export { withSimpleRetry } from "./l1-retry.js";
export type { RetryOptions } from "./l1-retry.js";

export { createBreaker, DEFAULT_BREAKER_OPTIONS, getBreakerState } from "./l2-circuit-breaker.js";
export type { BreakerWithEvents, BreakerEvent } from "./l2-circuit-breaker.js";

export {
createModelRouter,
ModelTier,
MODEL_TIER_ORDER,
getNextTier,
STATIC_FALLBACK_RESPONSE,
} from "./model-router.js";
export type { ModelRouter, RouteDecision } from "./model-router.js";

export { cascadedModelCall } from "./l3-model-fallback.js";
export type { CascadedResult, CascadedCallOptions } from "./l3-model-fallback.js";

export {
createBulkhead,
createBulkheadManager,
PRESET_BULKHEADS,
} from "./bulkhead.js";
export type { Bulkhead, BulkheadConfig, BulkheadStats, BulkheadManager } from "./bulkhead.js";

包装顺序(从外到内):

bulkhead.schedule( ← 第 1 层:排队 / 限流(入口自治)
withSimpleRetry( ← 第 3 层:退避重试(处理偶发抖动)
breaker.fire( ← 第 2 层:熔断(快速失败,不烧 Token)
cascadedModelCall( ← 第 4 层:路由 + 双向降级 + 静态兜底

注意代码里 withSimpleRetry 包在 breaker.fire 外面:先熔断判断,再决定要不要重试——如果熔断器已经 Open,fire 会立刻抛错,此时重试毫无意义(但会被重试逻辑消耗掉几次尝试)。这是顺序上一个值得推敲的细节。

它还有一个「自己给自己兜底」的设计:invoke 的 catch 分支不抛错,而是返回一个静态兜底结果并把 qualityCheckPassed: false 标出来。对外永不抛异常——这是网关类组件的典型契约:调用方不需要处理异常,只需要看 qualityCheckPassed。

八、真实对比:L0 vs L4​

同一场景(20 并发、下游失败率 80%)本机真实输出:

【L0 vs L4】并发故障场景对比
L0 无保护: 成功=1, 失败=19, 耗时=87ms
L4 完整弹性链: 成功=20, 降级=0, 总耗时=1008ms
熔断器状态: closed
模型调用统计: { 'gpt-4o-mini': { success: 20, failed: 0 } }
L0L4
成功1 / 2020 / 20
失败190
耗时87ms1008ms
熔断器无closed(说明下游恢复健康了)

这 1008ms 花在哪:重试退避(minTimeout: 200)+ 熔断 resetTimeout 试探。用 0.9 秒换掉 95% 的失败,这笔账在任何业务里都是划算的。

九、一页速查​

L0 裸调用 → 1/20 成功
L1 重试 → 救偶发抖动,但制造重试风暴(重试不区分错误类型 = 烧钱)
L2 熔断 → Open 状态一个请求都不发,Token 消耗为 0;三态机:Closed/Open/HalfOpen
L3 降级 → 双向链:失败→降级(省钱),质量差→升级(保质量)
L4 完整链 → 舱壁(隔离) → 熔断(快速失败) → 重试(退避) → 降级(成本) → 兜底(体验)
对外永不抛错,用 qualityCheckPassed 表达结果质量

关联​

自测​

  1. L1 的重试为什么会导致「重试风暴」?它解决不了哪一类故障?
  2. 熔断器三个状态分别做什么?为什么 Open 状态对 LLM 应用有「省钱」的特殊意义?
  3. volumeThreshold: 3 是干什么的?调成 1 会有什么风险?
  4. 降级链为什么是双向的?质量不达标时为什么是「升级」而不是「降级」?
  5. 为什么降级方向是「从贵到便宜」而不是反过来?
  6. 舱壁和熔断器分别保护谁?
  7. L4 的 catch 分支为什么不抛错?它用什么信号告诉调用方「这次结果可能不好」?

参考​

  • 完整源码与真实运行日志(随仓库留档):source/agent-engineering/(resilience/ 目录下 L0–L4、熔断器、舱壁、模型路由器全部实现)
  • 运行环境:Node v24.14.1;依赖 p-retry、opossum、bottleneck、@langchain/core
  • 说明:输出为 Mock 模式真实运行结果;本机未运行单元测试(运行器未安装),文中不含测试结论