AI 学习笔记(七):多 Provider 路由与降级服务层,做一套可扩展的模型调度

上一篇我们把 OpenAI、Claude、DeepSeek 的接口差异梳理清楚了。
但项目真要往线上走,问题很快就会从“能不能调通”变成:

  • 默认先走哪个 provider
  • 失败后要不要切下一个 provider
  • 同一个会话能不能中途换家
  • 429、503、529 这类错误该冷却多久
  • 每次路由决策怎样记录,排障时才能回放

所以这篇不再讲 Adapter,而是讲 多 Provider 路由与降级服务层
它的目标不是“随机换一家模型”,而是把 能力选择、会话状态、失败处理和观测 收口成一层可配置策略。

1. 路由层真正要解决的,不只是 fallback

如果你的系统里已经有统一的 generateTextgenerateObjectcallTools 这类接口,路由层至少要再解决 4 件事:

  1. 能力匹配
    不同任务走不同 provider 顺序,而不是所有请求都套同一个列表。
  2. 会话亲和性
    多轮对话、工具调用、长上下文任务,不能简单把每一轮都当成独立请求。
  3. 失败分级
    auth_errorvalidation_error 这种要立即失败;rate_limitoverloadedserver_error 才适合降级或冷却后重试。
  4. 可观测
    你需要知道“这次为什么走了 Claude 而不是 OpenAI”,“为什么 DeepSeek 被摘掉 30 秒”,“最终到底尝试了哪几家”。

一句话总结:
Adapter 解决“怎么接”,Router 解决“什么时候选谁、什么时候停”。

2. 路由前先想清楚:会话不能随便跳 Provider

这是多 provider 系统里最容易被忽略的一点。

OpenAI 的 Responses API 可以通过 previous_response_id 继续上一轮响应链路,这意味着一部分会话状态可以由平台帮你维护。
而 DeepSeek 官方文档明确说明它的多轮对话接口本质上是无状态的,需要你自己把历史消息继续带上。
Claude 这边同样需要你显式传递消息,另外它的 Prompt Caching 是否生效,也依赖你是否保持稳定的提示词前缀。

这会直接影响路由策略:

场景 更稳的做法
单次文本生成 按任务类型配置 provider 顺序,顺序降级即可
多轮对话 开启 session affinity,优先固定在同一 provider
重复长前缀任务 尽量保持统一前缀,别频繁跨 provider,避免缓存收益丢失
工具调用且有副作用 把“模型规划”与“真实执行”拆开,重试边界必须清楚

如果你的业务侧只存了 OpenAI 的 previous_response_id,然后下一轮突然切去 Claude 或 DeepSeek,会话上下文其实已经断了。
所以更稳的方式通常是:

  • 路由层只负责“选 provider”
  • 会话层维护一份规范化历史记录
  • OpenAI 可以额外利用 previous_response_id
  • Claude / DeepSeek 继续走“显式带历史”的模式

这样你在需要跨 provider 时,至少还有能力把历史物化后重新发出去,而不是完全绑死在某一家平台的状态模型里。

3. 先做一份策略配置,不要把路由写死在业务里

我更推荐把策略拆成两块:

  1. providerRegistry
    描述每家 provider 支持什么、默认冷却多久、会话模式是什么。
  2. routerPolicy
    描述每类任务优先顺序、是否需要会话亲和性、最多尝试几家。

最小示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
export const providerRegistry = {
openai: {
supports: ['generateText', 'generateObject', 'callTools', 'chatTurn'],
sessionMode: 'managed_or_external',
defaultCooldownMs: 15_000,
},
claude: {
supports: ['generateText', 'generateObject', 'callTools', 'chatTurn'],
sessionMode: 'external_history',
defaultCooldownMs: 20_000,
},
deepseek: {
supports: ['generateText', 'generateObject', 'callTools', 'chatTurn'],
sessionMode: 'external_history',
defaultCooldownMs: 15_000,
},
};

export const routerPolicy = {
taskProfiles: {
chatTurn: {
providers: ['openai', 'claude', 'deepseek'],
stickySession: true,
maxAttempts: 2,
},
generateObject: {
providers: ['openai', 'claude', 'deepseek'],
stickySession: false,
maxAttempts: 2,
},
costSensitiveBatch: {
providers: ['deepseek', 'openai', 'claude'],
stickySession: false,
maxAttempts: 2,
},
},
};

这里的 provider 顺序只是示例,不是标准答案。
真正该怎么排,取决于你项目更看重哪一项:

  • 稳定优先
  • 成本优先
  • 结构化输出优先
  • 工具调用优先
  • 国内可用性优先

重点不是“排序本身”,而是**让排序变成配置,而不是散落在业务代码里的 if/else**。

4. 先统一失败语义,再做降级

很多人做 fallback 时只写一句“失败了就换下一家”,这通常不够。

更合理的做法是先把 provider 错误收敛成统一语义,例如:

  • auth_error
  • validation_error
  • unsupported_capability
  • rate_limit
  • timeout
  • network_error
  • server_error
  • overloaded

然后再定义动作:

错误类型 建议动作
auth_error / validation_error / unsupported_capability 立即失败,不要继续切换
rate_limit 进入冷却,切下一个 provider
timeout / network_error / server_error 可切下一个 provider,必要时带短冷却
overloaded 明确冷却后跳过一段时间

为什么要做得这么细?

因为三家的“暂时失败”信号并不完全一样:

  • OpenAI 文档里有 x-request-id,也有一组 x-ratelimit-* 头,适合做请求追踪和重置时间判断。
  • Anthropic 文档里有 request-idretry-afteranthropic-ratelimit-* 头,另外 529 表示平台过载。
  • DeepSeek 的错误码文档列出了 429503,并明确建议请求过快或服务繁忙时放慢节奏,必要时临时切换其他 provider。

所以更稳的 Adapter 不只是返回文本,还应该把失败统一包装成类似下面这种结构:

1
2
3
4
5
6
7
8
9
10
11
12
{
type: 'rate_limit',
status: 429,
message: 'provider rate limited',
meta: {
requestId: '...',
headers: {
'retry-after': '10',
'x-ratelimit-reset-requests': '8',
},
},
}

这样 Router 才能根据 error.typeerror.meta.headers 决定是:

  • 立即失败
  • 冷却后跳过当前 provider
  • 切下一个 provider
  • 还是把错误原样抛给上层

5. 一个可直接复用的最小 Router 示例

下面这个版本重点展示 4 件事:

  • 按任务类型决定候选 provider
  • 支持 session affinity
  • 根据错误类型决定是否降级
  • 根据头信息设置 provider 冷却时间
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
function parseCooldownMs(headers = {}, fallbackMs = 15_000) {
const candidates = [
headers['retry-after'],
headers['x-ratelimit-reset-requests'],
headers['x-ratelimit-reset-tokens'],
headers['anthropic-ratelimit-requests-reset'],
headers['anthropic-ratelimit-tokens-reset'],
];

const values = candidates
.map((value) => {
if (!value) return 0;

const seconds = Number(value);
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1000);
}

const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? 0 : Math.max(0, timestamp - Date.now());
})
.filter(Boolean);

return values.length ? Math.max(...values) : fallbackMs;
}

function classifyFailure(error) {
const type = error?.type || 'unknown_error';
const status = error?.status || 0;

if (['auth_error', 'validation_error', 'unsupported_capability', 'tool_protocol_error'].includes(type)) {
return { action: 'fail_fast', coolDownMs: 0 };
}

if (status === 429 || status === 503 || status === 529) {
return {
action: 'fallback',
coolDownMs: parseCooldownMs(error?.meta?.headers, 20_000),
};
}

if (['rate_limit', 'timeout', 'network_error', 'server_error', 'overloaded'].includes(type) || status >= 500) {
return {
action: 'fallback',
coolDownMs: parseCooldownMs(error?.meta?.headers, 10_000),
};
}

return { action: 'fail_fast', coolDownMs: 0 };
}

export class ProviderRouter {
constructor({ adapters, registry, policy, logger = console }) {
this.adapters = adapters;
this.registry = registry;
this.policy = policy;
this.logger = logger;
this.disabledUntil = new Map();
this.sessionAffinity = new Map();
}

getCandidates(taskType, ctx = {}) {
const profile = this.policy.taskProfiles[taskType] || {};
const ordered = [...(profile.providers || [])];

if (profile.stickySession && ctx.sessionKey) {
const sticky = this.sessionAffinity.get(ctx.sessionKey);
if (sticky && ordered.includes(sticky)) {
return [sticky, ...ordered.filter((name) => name !== sticky)];
}
}

return ordered;
}

isAvailable(provider) {
return Date.now() >= (this.disabledUntil.get(provider) || 0);
}

coolDown(provider, ms) {
if (ms > 0) {
this.disabledUntil.set(provider, Date.now() + ms);
}
}

async run(taskType, payload, ctx = {}) {
const profile = this.policy.taskProfiles[taskType] || {};
const candidates = this.getCandidates(taskType, ctx).slice(0, profile.maxAttempts || 2);
const tried = [];

for (const provider of candidates) {
if (!this.isAvailable(provider)) continue;
if (!this.registry[provider]?.supports?.includes(taskType)) continue;

const startedAt = Date.now();
tried.push(provider);

try {
const result = await this.adapters[provider][taskType](payload, ctx);

if (profile.stickySession && ctx.sessionKey) {
this.sessionAffinity.set(ctx.sessionKey, provider);
}

this.logger.info('[router.success]', {
traceId: ctx.traceId,
provider,
taskType,
tried,
durationMs: Date.now() - startedAt,
requestId: result?.meta?.requestId,
});

return { ...result, provider, tried };
} catch (error) {
const { action, coolDownMs } = classifyFailure(error);
this.coolDown(provider, coolDownMs);

this.logger.warn('[router.fail]', {
traceId: ctx.traceId,
provider,
taskType,
tried,
durationMs: Date.now() - startedAt,
errorType: error?.type,
status: error?.status,
requestId: error?.meta?.requestId,
coolDownMs,
});

if (action === 'fail_fast') {
throw Object.assign(error, { provider, tried });
}
}
}

const finalError = new Error(`All providers failed for taskType=${taskType}`);
finalError.type = 'all_provider_failed';
finalError.tried = tried;
throw finalError;
}
}

这段代码刻意只做“够用的最小版本”,但已经具备几个很重要的工程特征:

  1. Router 不需要知道三家 SDK 的细节,只吃 Adapter 归一化后的结果。
  2. 会话亲和性是按任务类型开启的,不会无脑全局粘连。
  3. 429 / 503 / 529 不再只是报错,而会进入冷却窗口。
  4. 每次成功或失败都会带上 traceIdproviderrequestIdtried,后面接日志和指标会轻很多。

6. 真上项目时,别漏掉这 4 个细节

6.1 会话亲和性和“历史物化”

如果一个对话线程已经绑定到 OpenAI,并且你还利用了 previous_response_id,那就不要在下一轮无脑切去别家。
更稳的做法是:

  • 平时保存规范化历史
  • 当前 provider 正常时走它自己的最优路径
  • 必须切家时,把历史物化后重新发给新的 provider

否则你以为自己做了 fallback,实际上只是悄悄丢了上下文。

6.2 工具调用的重试边界

工具调用和纯文本生成最大的区别在于:它可能带副作用。

比如模型已经规划出“下单”“发邮件”“写数据库”这类动作时,Router 不能简单把失败重放成第二次业务执行。
更稳的做法通常是:

  • 模型规划阶段可重试
  • 真实执行阶段要带 operationId / 幂等键
  • 执行结果再回填给模型

这样降级的是“生成过程”,不是把业务动作重复做一遍。

6.3 观测维度别只记 provider

路由排障至少要有下面这些字段:

  • traceId
  • taskType
  • provider
  • model
  • routeReason
  • tried
  • requestId
  • durationMs
  • errorType

OpenAI 和 Anthropic 都提供了请求 ID;DeepSeek 这边即使没有完全一致的返回头,你也应该自己生成统一的 traceId
否则问题一上量,你只会知道“失败了”,不知道“为什么这次失败前已经绕了两家”。

6.4 熔断先做简单版就够了

很多系统一上来就想做权重路由、实时评分、成本优化。
但大多数团队更先需要的是:

  1. 顺序降级
  2. provider 冷却
  3. 会话亲和性
  4. 统一日志

先把这 4 件事做好,系统稳定性通常已经能上一个台阶。
真正跑出流量后,再考虑:

  • 动态权重
  • 区域路由
  • 成本感知调度
  • A/B 与灰度

7. 一条更稳的演进路径

如果你准备从“多 provider 接入”继续升级,推荐按这个顺序推进:

  1. 先做顺序 fallback
  2. 再做错误分类和冷却窗口
  3. 再做 session affinity
  4. 再做能力感知路由
  5. 最后再做权重、成本和实验流量

这个顺序的好处是:每一步都能独立带来稳定性收益,而且不会过早把系统做复杂。

总结

从“我能同时接 OpenAI、Claude、DeepSeek”走到“我能稳定上线”,真正关键的一步就是补上一层路由与降级服务层。

这层最重要的不是算法有多花哨,而是你有没有把下面几件事做对:

  • 任务类型和 provider 能力匹配
  • 会话不要随便跳 provider
  • 错误先归一化,再决定是否降级
  • 冷却和观测要进系统,不要靠人工猜

最值得记住的一句话是:

业务接口保持稳定,provider 可以替换;路由策略必须可配置,失败路径必须可观测。

下一篇进入 Phase 3:MCP 与 AI Agent 工作流的最小实践,把“单次模型调用”继续往“可组合工具链”推进。

参考资料

本文永久链接: https://www.mulianju.com/learning-notes/ai-learning-notes-provider-router-fallback-service-layer/