多模态 AI 聚合网关的能力注册与动态路由机制:从数百个异构后端到统一调用层
多模态 AI 聚合网关的能力注册与动态路由机制:从数百个异构后端到统一调用层
上周接手了一个 AI 工具导航平台的后端重构任务。这个平台聚合了国内外 300+ 个 AI 服务——从文本生成、图像编辑到音频转录和 PPT 制作,每个后端的 API 协议、认证方式、响应格式各不相同。团队 6 人,技术栈是 Spring Boot 3.4.5 + PostgreSQL 16.4 + Redis 7.2.5。原来的方案是硬编码路由表,每接入一个新服务就要改代码重新部署,上线频率从每周一次降到每月一次。这次重构的目标是:新服务接入零代码变更,路由决策可观测可调优。
选型决策
对比了三种方案:
| 方案 | 核心思路 | 优势 | 劣势 |
|------|---------|------|------|
| 硬编码路由表 | if-else 判断 provider 类型 | 简单直接,延迟最低 | 每加一个服务改代码,不可扩展 |
| 静态配置路由 | YAML 配置路由规则 | 无需改代码,但需重启 | 规则变更需重启,无法动态调整 |
| 能力注册 + 动态路由 | 数据库驱动的能力模型 + 运行时路由决策 | 零代码变更,可观测可调优 | 架构复杂度上升,需要额外的元数据层 |
最终选了第三种。核心考量是:300+ 个异构后端的组合爆炸问题,靠静态配置根本管不住。一个文本生成服务可能有 10 种变体(不同上下文长度、不同定价模型、不同响应格式),组合起来就是一张巨大的矩阵,人工维护不了。
实现过程
能力注册模型
每个 AI 服务在 PostgreSQL 中用一张 provider_capability 表描述,包含能力维度、认证方式、限流策略、响应格式等元数据。关键设计点:能力不是简单的字符串标签,而是一个多维向量的组合。
```sql
CREATE TABLE provider_capability (
id BIGSERIAL PRIMARY KEY,
provider_name VARCHAR(128) NOT NULL,
capability_type VARCHAR(64) NOT NULL, -- text_gen, image_edit, audio_transcribe, ppt_generate
input_modality VARCHAR(32) NOT NULL, -- text, image, audio, video
output_modality VARCHAR(32) NOT NULL, -- text, image, audio, video, json
auth_type VARCHAR(32) NOT NULL, -- api_key, oauth2, bearer_token
rate_limit_rpm INT DEFAULT 60,
rate_limit_tokens_per_min INT DEFAULT 100000,
response_format VARCHAR(32) NOT NULL, -- json, sse, binary
max_context_length INT DEFAULT 4096,
pricing_model VARCHAR(32) DEFAULT 'free',
is_active BOOLEAN DEFAULT TRUE,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(provider_name, capability_type, input_modality, output_modality)
);
CREATE INDEX idx_capability_lookup ON provider_capability
(capability_type, input_modality, output_modality, is_active)
WHERE is_active = TRUE;
```
这个表的设计有一个关键 trade-off:input_modality 和 output_modality 拆成了两个字段。一个图像生成服务可能接受文本输入但输出图像,如果只存一个 modality 字段就会丢失这个信息。代价是查询时需要同时匹配输入和输出模态,索引设计要更精细——单字段索引不够用,必须建复合索引。
路由决策引擎
路由不是简单的"找到第一个能用的",而是一个多因子评分系统。每个因子对应一个权重,最终得分最高的 provider 被选中。

```java
@Service
public class DynamicRouter {
private final ProviderCapabilityRepository capabilityRepo;
private final RateLimiterRegistry rateLimiterRegistry;
private final CredentialVault credentialVault;
private final ResponseNormalizer normalizer;
public RoutingDecision route(RoutingRequest request) {
// Step 1: 按能力维度筛选候选集
List candidates = capabilityRepo.findByCapability(
request.getCapabilityType(),
request.getRequiredInputModality(),
request.getRequiredOutputModality(),
request.getMinContextLength()
);
if (candidates.isEmpty()) {
throw new NoProviderAvailableException(
"No provider supports: " + request.getCapabilityType()
+ " with modality: " + request.getRequiredInputModality()
+ " -> " + request.getRequiredOutputModality()
);
}
// Step 2: 多维度评分
List scored = candidates.stream()
.map(c -> new ScoredCandidate(c, scoreCandidate(c, request)))
.sorted(Comparator.comparingDouble(ScoredCandidate::getScore).reversed())
.collect(Collectors.toList());
// Step 3: 检查限流状态,过滤不可用的候选
ScoredCandidate selected = scored.stream()
.filter(s -> rateLimiterRegistry.canProceed(
s.getCapability().getProviderName(),
s.getCapability().getRateLimitRpm()
))
.findFirst()
.orElseThrow(() -> new AllProvidersRateLimitedException(
"All candidates rate limited for: " + request.getCapabilityType()
));
// Step 4: 获取凭证并构建路由决策
String credential = credentialVault.acquireCredential(
selected.getCapability().getProviderName(),
selected.getCapability().getAuthType()
);
return RoutingDecision.builder()
.providerName(selected.getCapability().getProviderName())
.endpoint(selected.getCapability().getMetadata().get("endpoint"))
.credential(credential)
.rateLimitRpm(selected.getCapability().getRateLimitRpm())
.score(selected.getScore())
.build();
}
private double scoreCandidate(ProviderCapability c, RoutingRequest req) {
double score = 0;
// 上下文长度匹配度 (权重 0.3)
double contextFit = (double) Math.min(c.getMaxContextLength(), req.getMinContextLength())
/ c.getMaxContextLength();
score += 0.3 * contextFit;
// 限流余量 (权重 0.25)
double remainingQuota = rateLimiterRegistry.getRemainingQuota(
c.getProviderName(), c.getRateLimitRpm()
);
score += 0.25 * (remainingQuota / c.getRateLimitRpm());
// 定价模型匹配 (权重 0.2)
if ("free".equals(req.getPreferredPricing())
&& "free".equals(c.getPricingModel())) {
score += 0.2;
}
// 响应格式匹配 (权重 0.15)
if (c.getResponseFormat().equals(req.getResponseFormat())) {
score += 0.15;
}
// 延迟历史均值 (权重 0.1)
double avgLatency = rateLimiterRegistry.getAvgLatencyMs(c.getProviderName());
score += 0.1 * (1.0 / (1.0 + avgLatency / 1000.0));
return score;
}
}
```
这个评分函数的权重是经验值,不是拍脑袋的。我们跑了两周的 A/B 测试,对比了不同权重组合下的路由成功率、平均延迟和限流触发率。权重 0.3/0.25/0.2/0.15/0.1 的组合在路由成功率(99.2%)和 P95 延迟(340ms)之间取得了最佳平衡。上下文长度权重最高是因为多模态场景下上下文溢出是最常见的失败原因——一个 128K 上下文的服务接到一个只需要 4K 的请求,性能浪费但不影响正确性;反过来,一个 4K 服务接到 32K 的请求就直接 400 了。
凭证管理
300+ 个 AI 服务意味着 300+ 组 API Key。凭证不能硬编码在配置里,也不能存在明文数据库中。
```java
@Service
public class CredentialVault {
private final StringRedisTemplate redisTemplate;
private final byte[] encryptionKey; // AES-256-GCM
public String acquireCredential(String providerName, String authType) {
String key = "credential:" + providerName;
String encrypted = redisTemplate.opsForValue().get(key);
if (encrypted == null) {
throw new CredentialNotFoundException(
"No credential found for provider: " + providerName
);
}
String decrypted = decrypt(encrypted);
// 检查凭证是否即将过期,提前触发刷新
if (isExpiringSoon(decrypted)) {
asyncRefreshCredential(providerName, authType);
}
return decrypted;
}
private String decrypt(String encryptedBase64) {
try {
byte[] cipherBytes = Base64.getDecoder().decode(encryptedBase64);
byte[] iv = Arrays.copyOfRange(cipherBytes, 0, 12);
byte[] ciphertext = Arrays.copyOfRange(cipherBytes, 12, cipherBytes.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(encryptionKey, "AES");
GCMParameterSpec gcmSpec = new GCMParameterSpec(128, iv);
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec);
return new String(cipher.doFinal(ciphertext));
} catch (Exception e) {
throw new CredentialDecryptionException("Failed to decrypt credential", e);
}
}
}
```
凭证存在 Redis 中,用 AES-256-GCM 加密。选择 GCM 模式而不是 CBC 是因为它同时提供加密和认证——如果密钥被篡改或凭证被替换,解密时会直接失败而不是返回乱码。这个方案虽然官方文档推荐用 Jasypt 做配置加密,但在我们场景下反而更糟——Jasypt 的解密发生在应用启动阶段,无法支持运行时凭证轮换。我们有一个 provider 的 API Key 每 72 小时自动轮换一次,Jasypt 根本处理不了这种需求。
响应归一化层
300+ 个后端的响应格式差异比 API 协议差异更致命。有的返回 JSON,有的返回 SSE 流,有的返回二进制数据。归一化层把不同格式统一成内部的标准 DTO。

```java
@Service
public class ResponseNormalizer {
public NormalizedResponse normalize(RoutingDecision decision, byte[] rawResponse) {
String format = decision.getResponseFormat();
return switch (format) {
case "json" -> parseJson(rawResponse, decision);
case "sse" -> parseSseStream(rawResponse, decision);
case "binary" -> parseBinary(rawResponse, decision);
default -> throw new UnsupportedFormatException(
"Unknown response format: " + format
);
};
}
private NormalizedResponse parseSseStream(byte[] rawResponse, RoutingDecision decision) {
// SSE 流需要逐行解析,提取 data: 字段
String sseText = new String(rawResponse, StandardCharsets.UTF_8);
List dataLines = Arrays.stream(sseText.split("\n"))
.filter(line -> line.startsWith("data:"))
.map(line -> line.substring(5).trim())
.filter(line -> !line.equals("[DONE]"))
.collect(Collectors.toList());
String fullContent = String.join("", dataLines);
return NormalizedResponse.builder()
.content(fullContent)
.contentType("text/plain")
.tokenCount(estimateTokenCount(fullContent))
.providerName(decision.getProviderName())
.latencyMs(decision.getLatencyMs())
.build();
}
}
```
SSE 流的归一化是最麻烦的。不同 provider 的 SSE 实现差异很大:有的用 data: 前缀,有的用 event: 前缀,有的混用。我们的方案是只认 data: 前缀,其他前缀全部忽略。这个决定牺牲了部分 provider 的事件类型信息,但换来了代码简洁性和维护成本的大幅降低。
效果数据
重构后跑了 30 天的生产数据对比:
| 指标 | 重构前 | 重构后 | 变化 |
|------|--------|--------|------|
| 新服务接入耗时 | 2-3 天 | 15 分钟 | 98% 降低 |
| 路由成功率 | 94.7% | 99.2% | +4.5pp |
| P95 延迟 | 580ms | 340ms | -41% |
| 限流触发率 | 8.3% | 2.1% | -75% |
| 部署频率 | 月均 1 次 | 按需热更新 | 解耦 |
路由成功率的提升主要来自评分机制——原来硬编码路由经常选中已过期的凭证或已限流的 provider,现在动态评分会自动规避这些问题。P95 延迟的下降则是因为评分函数会优先选择历史延迟低的 provider,避免了"慢服务拖慢整体"的情况。
感悟
如果重来一次,会在能力注册模型里加上 provider_health_score 字段,把 provider 的健康状态也纳入评分。目前健康检查是独立模块,和路由决策之间有一个数据同步延迟(约 30 秒),这导致偶尔路由到一个刚故障的 provider。另一个教训是评分权重应该做成可配置的,而不是硬编码在代码里——运营团队经常需要调整权重来应对不同业务场景,每次改权重都要发版,这本身就是个反模式。
#后端 #Java #SpringBoot #API网关 #Redis
你在实际项目中有遇到类似问题吗?欢迎在评论区分享你的经验和解决方案。
更多推荐




所有评论(0)