跳到主要内容

模型路由与高可用

RoutedChatModel 将多个可替代的 ChatModel 包装成一个普通的 ChatModel。上层的 Agent、ChatMemory、Web 接口和业务代码只调用这一个对象;节点选择、失败重试、备用节点切换及运行指标由 Router 在内部完成。

它解决的是“这次模型请求应该由哪个可用节点完成”,不是业务意图路由,也不是跨进程的服务发现。

为什么需要它

真实环境中的模型调用常见以下问题:

  • 一个模型账号或 Endpoint 被临时限速,另一个可用节点仍可完成请求。
  • 同一个模型部署在多个区域或网关,需要按并发量分流。
  • 需要按能力选择模型,例如图像问题只进入带 vision 标签的节点,普通对话优先使用 cheap 节点。
  • 主模型短时过载时,希望在不修改 Agent 代码的前提下切换到兼容的备用模型。

不需要高可用或只有单一模型节点时,直接使用原始 ChatModel 更简单。Router 不会把不兼容的模型变得兼容:同一组节点应能处理同一种 Prompt、工具调用协议、输出格式和上下文长度要求。

一次同步调用如何执行

例如节点 A 返回 429 限速、节点 B 正常时,Router 会把 A 的错误响应转换为 ModelRateLimitException,记录失败后尝试 B。对上层而言仍然只是一次 chat(...) 调用。

一个故障转移周期内,同一节点只会尝试一次:A -> B -> C。所有候选都失败后,若重试次数尚未耗尽,才开始新一轮选择,避免负载均衡器连续命中刚失败的节点。

快速开始

最简单的方式是传入多个 ChatModel。此构造方式默认使用最少活跃请求负载均衡、DefaultRetryPolicy(3) 和默认熔断器:

java
ChatModel primary = new OpenAIChatModel(primaryConfig);
ChatModel backup = new OpenAIChatModel(backupConfig);

ChatModel chatModel = new RoutedChatModel(Arrays.asList(primary, backup));

AiMessageResponse response = chatModel.chat(prompt, options);

3 表示最多三次额外重试,不包含首次请求。因此持续发生可重试故障时,单次调用最多尝试四次。

对于 Agent,不需要特殊接入:将这个 chatModel 传给 Agent 或 AgentRunner 原本使用 ChatModel 的位置即可。

自定义选择函数

如果业务规则不适合用标签表达,可以使用构建器注册选择函数。函数接收当前 Prompt、请求参数和 健康候选节点,返回筛选或重新排序后的候选列表:

java
RoutedChatModel routedModel = RoutedChatModel.builder()
    .endpoint("deepseek", deepseekModel)
    .endpoint("vision", visionModel)
    .endpoint("premium", premiumModel)
    .selector((prompt, options, candidates) -> {
        if (containsMultimodalContent(prompt)) {
            return candidates.named("vision");
        }
        if ("tenant-a".equals(options.getMetadata("tenant"))) {
            return candidates.named("premium");
        }
        return candidates.named("deepseek");
    })
    .build();

按 endpointId 选择

ChatModelCandidates.named("primary", "backup") 适合节点 ID 固定的规则;参数顺序就是候选优先级:

java
.selector((prompt, options, candidates) ->
    containsMultimodalContent(prompt)
        ? candidates.named("vision", "vision-backup")
        : candidates.named("deepseek", "deepseek-backup"))

all() 返回当前所有健康候选节点,适合不额外筛选、仅交给负载均衡器处理的场景。

使用 Stream 自定义筛选

当规则依赖 Endpoint 的标签、权重、实时指标或业务配置时,可以使用 stream() 返回标准 Java Stream:

java
.selector((prompt, options, candidates) -> {
    if ("tenant-a".equals(options.getMetadata("tenant"))) {
        return candidates.stream()
            .filter(endpoint -> endpoint.getEndpointId().startsWith("premium-"))
            .collect(Collectors.toList());
    }
    return candidates.stream()
        .filter(endpoint -> endpoint.getEndpointId().startsWith("cheap-"))
        .collect(Collectors.toList());
})

选择函数只负责候选节点的筛选和排序,重试、熔断、负载均衡、指标和流式故障切换仍由 Router 处理。为了让故障切换生效,函数应返回有序的备用候选列表,而不是只返回一个节点;例如主节点和备用节点 都符合条件时,应返回 [primary, backup]。如果函数只返回单个节点,Router 只能在该节点上重试。

选择函数会在同步和流式请求的每次候选选择前执行。Token 超限属于请求内容问题,默认不会自动重试;如果 业务需要切换到更大上下文模型,应在选择函数中根据 Prompt 预估 Token,或自定义 RetryPolicy 与路由 策略配合处理。

自定义节点与策略

当需要指定标签、权重或熔断阈值时,显式创建 ModelEndpoint

java
ModelEndpoint<ChatModel> fast = new ModelEndpoint<>(fastModel);
fast.setWeight(5);
fast.addTags(Collections.singleton("fast"));

ModelEndpoint<ChatModel> vision = new ModelEndpoint<>(visionModel);
vision.addTags(Collections.singleton("vision"));

ChatModel chatModel = new RoutedChatModel(
    Arrays.asList(fast, vision),
    new WeightedRandomLoadBalancer<>(),
    new DefaultRetryPolicy(2),
    new DefaultCircuitBreaker<>(3, 10_000)
);

为节点配置稳定标识

生产环境建议为每个逻辑节点指定稳定的 endpointId。它用于失败日志、故障聚合以及一次重试轮次内的节点去重:

java
ModelEndpoint<ChatModel> primary =
    new ModelEndpoint<>("openai-primary-cn", primaryModel);
ModelEndpoint<ChatModel> backup =
    new ModelEndpoint<>("openai-backup-cn", backupModel);

ChatModel chatModel = new RoutedChatModel(
    Arrays.asList(primary, backup),
    new LeastActiveLoadBalancer<>(),
    new DefaultRetryPolicy(2),
    new DefaultCircuitBreaker<>(3, 10_000)
);

同一个 endpointId 会被视为同一个逻辑节点,不建议把不同供应商或不同区域配置成相同 ID。 Router 会复制节点列表,调用方之后修改原始 List 不会改变运行中的路由集合;节点的健康状态和指标仍然会随请求实时变化。

对象作用
ModelEndpoint<T>保存模型实例、标签、权重、节点状态和内存运行指标。
ModelLoadBalancer<T>从当前候选节点中选择一个节点。
RetryPolicy根据已经重试次数和异常类型决定是否再尝试。
CircuitBreaker<T>根据连续失败控制节点是否继续接收请求。
RoutedChatModel对外提供标准 ChatModel 接口,并协调以上能力。

标签路由

请求可以在 ChatOptions 的 metadata 中声明必需能力:

java
ChatOptions options = new ChatOptions();
options.putMetadata("modelTags", new HashSet<>(Arrays.asList("vision")));

AiMessageResponse response = chatModel.chat(prompt, options);

modelTags 必须是 Set<String>。节点必须包含全部请求标签才能成为候选节点,例如请求 visionreasoning 时,只有同时带有这两个标签的 Endpoint 可以被选择。没有符合条件的节点会抛出 RouterException

典型用法包括:

  • vision:图像理解或图片输入请求。
  • reasoning:需要较强推理能力的复杂任务。
  • cheap:对成本敏感的批量摘要、分类或抽取任务。
  • regional-cn:只允许在特定区域或网关处理的数据。

标签只是选择约束,不会自动修改 Prompt、模型参数或工具集。

重试、切换与熔断

默认重试策略只重试通常可恢复的临时故障:

异常默认行为原因
ModelRateLimitException重试并优先尝试其他节点节点或账号可能只是暂时限速。
ModelOverloadedException重试并优先尝试其他节点服务端短时过载通常可恢复。
网络连接异常、超时重试可能是瞬时网络或网关问题。
TokenLimitExceededException不重试、不自动切换原 Prompt 超出上下文;应压缩历史或降低输出限制。
ModelQuotaExceededException不重试、不自动切换额度耗尽不是短时故障。
其他 ModelException不重试通常是鉴权、参数或请求内容问题。

限速和过载会记录为节点失败,可推动熔断;Token 超限、额度耗尽和其他请求级错误不会污染节点健康状态。默认熔断器连续失败 5 次后将节点标记为不可用,成功调用会清零连续失败计数。

节点进入 DOWN 后,恢复时间到达时会进入 HALF_OPEN,并只允许一个并发探测请求:

text
UP -> 连续临时故障 -> DOWN -> 等待 recoverMs -> HALF_OPEN
                                      |              |
                              探测失败回 DOWN    探测成功回 UP

例如 new DefaultCircuitBreaker<>(3, 10_000) 表示连续 3 次节点故障后熔断,10 秒后允许一次探测。 如果探测仍失败,节点继续保持不可用;如果成功,连续失败计数清零并恢复正常流量。

Router 重试会与具体 ChatModel 客户端的网络重试叠加。生产环境应同时检查两层配置,按最坏情况估算请求数、延迟与费用。 DefaultRetryPolicy 会读取 ModelRateLimitException 中的 retryAfterMillis,在下一次尝试前等待,最长等待 30 秒; 需要指数退避、随机抖动或更长等待时间时,实现自定义 RetryPolicy

java
RetryPolicy retryPolicy = new RetryPolicy() {
    @Override
    public boolean shouldRetry(int retryCount, Throwable error) {
        return retryCount < 2 && error instanceof ModelRateLimitException;
    }

    @Override
    public long retryDelayMillis(int retryCount, Throwable error) {
        return Math.min(100L << retryCount, 2_000L);
    }
};

等待发生在 Router 的重试边界,不会改变业务侧的 ChatModel 调用方式。线程被中断时 Router 会停止后续重试并保留中断标记。

如果所有节点都失败,抛出的 RouterException 会把最后一次失败作为 cause,并将之前节点的失败作为 suppressed exceptions 保留, 便于日志系统还原 A -> B -> C 的完整失败链路。

流式调用的故障切换

流式响应无法像同步调用一样在任意时刻切换。原因是文本一旦已经发送给浏览器,备用模型从头生成会造成重复内容;如果两个模型的输出不同,还会形成混合答案。

RoutedChatModel.chatStream(...) 的规则如下:

  1. 连接建立、首个文本分片、推理片段或 Tool Call 之前发生可重试错误:Router 自动切换备用节点。
  2. 已向业务监听器发送任意 onMessage(...) 后发生错误:不切换,继续通过原监听器触发 onError(...),然后按正常生命周期触发 onClose(...)
  3. Router 会延迟转发 onOpen(...),使“第一个节点立即失败、第二个节点成功”的场景对业务监听器只表现为一次流打开。
java
chatModel.chatStream(prompt, new StreamResponseListener() {
    @Override
    public void onMessage(StreamContext context, AiMessageResponse response) {
        // 将增量内容推送到 SSE 或 WebSocket。
    }

    @Override
    public void onError(StreamContext context, Throwable error) {
        // 已经有内容时,提示用户本次生成中断;不要在这里拼接另一模型的完整回答。
    }
}, options);

如果产品需要“中途失败后继续回答”,应由业务层显式展示中断状态并让用户重新生成,或者根据已输出内容构造新的 Prompt 发起一次新的对话。不要把这种恢复误认为 Router 的透明切换。

Embedding 路由

RoutedEmbeddingModel 复用相同的节点选择、标签、重试和熔断机制:

java
EmbeddingModel embeddingModel = new RoutedEmbeddingModel(
    Arrays.asList(embeddingA, embeddingB)
);

也可以显式指定负载均衡、重试和熔断策略:

java
EmbeddingModel embeddingModel = new RoutedEmbeddingModel(
    Arrays.asList(
        new ModelEndpoint<>(embeddingA),
        new ModelEndpoint<>(embeddingB)
    ),
    new LeastActiveLoadBalancer<>(),
    new DefaultRetryPolicy(2),
    new DefaultCircuitBreaker<>(3, 10_000)
);

Embedding Router 只选择节点,不会校验或转换向量空间。因此同一向量索引中的模型必须输出相同维度, 并使用相同或兼容的距离度量、归一化方式和语义空间。不要把不同 Embedding 模型的向量混写入同一索引, 否则检索质量会不可预测。切换供应商或模型版本时,应先重建索引或在业务侧按模型版本隔离索引。

生产检查清单

  1. 每组可切换 ChatModel 的工具能力、结构化输出、多模态能力和上下文长度满足同一业务要求。
  2. 为限速、过载、Token 超限、全部节点不可用、标签无匹配以及流式中途失败编写集成测试。
  3. 结合日志或 OpenTelemetry 观察每个 Endpoint 的延迟、失败率和活跃请求数。
  4. 为每个逻辑节点配置稳定 endpointId,便于定位哪个供应商、区域或网关发生故障。
  5. 多实例部署时,Endpoint 指标和熔断状态默认仅保存在当前 JVM;需要全局一致状态时应接入业务侧的共享健康检查或配置系统。
  6. 备用模型不是“免费保险”:确认其成本、数据区域、模型版本和合规要求。

常见问题

Router 会自动处理超长上下文吗?

不会。TokenLimitExceededException 会直接返回给上层。业务应压缩 ChatMemory、缩短 Prompt、减少工具定义或降低 maxTokens 后重新调用。

Router 会按价格自动挑选最便宜模型吗?

不会自动推断价格。可以通过标签把低成本节点隔离出来,再由业务在 ChatOptions 中显式传递 modelTags;也可以使用 RoutedChatModel.builder().selector(...),根据租户、区域、成本或实时配额 筛选/排序候选节点。选择函数只负责候选选择,重试、熔断和负载均衡仍由 Router 负责。

Agent 应该配置多个模型还是一个 Router?

只有固定的文本模型和视觉模型时,可以直接配置:

java
Agent agent = Agent.builder("assistant")
    .chatModel(textModel)
    .multimodalChatModel(visionModel)
    .build();

需要同时考虑地区、租户、成本、上下文长度或故障切换时,建议只配置一个 Router:

java
Agent agent = Agent.builder("assistant")
    .chatModel(routedChatModel)
    .build();

两种方式不会修改 ChatMemory 或 AgentTurn 的消息结构,只改变本次模型请求使用的 ChatModel。

为什么不能把同一向量索引交给不同 Embedding 模型?

因为 Router 只负责切换节点,不负责转换向量空间。即使两个模型返回的向量维度相同,距离度量、归一化方式或训练语义空间也可能不同。 模型升级或更换供应商时,应重建索引,或按模型版本拆分索引。

模型返回 null 时 Router 会怎样?

Router 将 null 视为节点调用失败,而不是成功响应,会记录失败指标并按照 RetryPolicy 决定是否切换。模型实现应尽量返回有效响应或抛出明确异常,避免隐藏真正的 Provider 错误。

为什么流式内容已经输出后没有切换备用节点?

这是为了保证输出完整性。已发送内容无法撤回,切换会导致重复或混合回答。此时应把错误呈现给用户,并由业务决定是否重新生成。

下一步