Skip to content

[Enhancement][OPT-19] 明确并补齐流式模型 fallback 的安全切换边界 #210

Open

Description

@Aias00

总览:#180

代码依据

问题、范围与验收

现有 ModelFallbackInterceptor 捕获 handler.call 的同步异常,类注释明确不处理订阅后 publisher 的错误。此项是已声明能力限制的补全,不把当前实现直接认定为违约。已有 ModelRetryInterceptor 在发出事件后不重试,可作为语义参考。

验收

  • 明确调用构建失败、订阅后首事件前失败、输出后失败的分类;评审是否支持首事件前切换并给出理由。
  • 若支持,只有尚无任何对外事件/可观察效果时才切换;取消、deadline、租约失效不得触发 fallback。
  • 输出后失败明确终止或采用显式新 attempt 协议,禁止直接追加另一个模型的文本导致混合/重复结果。
  • 重新校验替代模型请求能力,保留 system/messages/tools/attributes,并明确 fallback 与 retry 的组合顺序和总体调用预算。
  • 覆盖首事件前失败、首事件后失败、usage/tool/RESPONSE 事件、所有候选失败、取消及多层中间件测试。
  • 事件和失败信息能说明实际尝试的模型及结果;保持现有非流式行为或说明版本变更。

与 OPT-18 能力预检、#127 执行预算协调。若决定保留当前限制,需给出明确能力声明、调用方策略及验证依据,不能只关单忽略差异。

验证与变更约束

代码基线 Core f45b2cf。本单来自静态审查,尚未运行针对性复现或实现。先补行为验证,复用已有能力;破坏性调整逐项说明收益、兼容替代、消费者影响、迁移成本和版本。局部修复不等待 Agent/Workflow/Loop 全局重构,新增通用契约不反向依赖 Loop。

关联任务

Activity

  1. Aias00 commented on Oct 23, 2026

    @Aias00
    ContributorAuthor

    新增可执行复现:被包装的取消仍触发非流式 fallback

    基线 Core 76d44429fdaa6a69320e21a1cb77119d0889d792。使用实际编译的 ModelFallbackInterceptor,配置一个备用 ChatClient;handler 首次抛 new CompletionException(new CancellationException("stop")),若再次调用则返回普通 assistant 消息。

    结果:handler 被调用 2 次,最终返回备用模型成功消息。propagateCancellation 仅识别最外层 CancellationException/InterruptedException 与线程中断状态,没有识别 cause 链。对照同工程 ModelRetryInterceptor 已遍历 cause 链。

    本复现不依赖真实模型/网络,确认同步拦截链中被包装取消仍可触发多余调用;不是首事件前/后流式切换的复现。沿用本单既有“取消、deadline、租约失效不得触发 fallback”验收,不另建同目标 issue。实施时还需覆盖多层 CompletionException/ExecutionException、包装中断及已失效执行控制;不能凭任意错误文本判断取消。

    代码:

  2. XianReallyHot-ZZH commented on Nov 13, 2026

    @XianReallyHot-ZZH
    Contributor

    你好,我想认领这个问题 @Aias00

    复现计划

    1. 同步路径沿用您给出的可执行复现:propagateCancellation 在当前 main(efd12ae)仍只检查最外层异常类型,被包装的取消会触发多余的 fallback 调用。
    2. 流式路径构造订阅后、首个 ChatEvent 前失败的 publisher,确认当前原样透传、fallback 不介入(类注释声明的限制),作为边界重审的起点。

    修复方案(含设计选择)

    1. 取消识别对齐 ModelRetryInterceptor.cancellation(Throwable):遍历 cause 链;取消/中断在任何候选位置直接终止,不触发 fallback。
    2. 每次切换前检查请求携带的 ExecutionGuard(存在时 assertActive()),deadline/租约失效不消耗剩余候选。
    3. 流式切换采用与 retry 相同的首事件门闸:订阅后发出任何 ChatEvent 之前失败 → 切换下一备用的流(请求经 ModelRequest.builder(request).chatClient(...) 复用,system/messages/tools/attributes 保留);已发出事件后失败 → 错误原样终止,绝不拼接两个模型的输出;每次订阅独立预算(Flux.defer)。
    4. 首事件前切换默认开启,与同步路径"失败即切换"的既有默认一致;若您倾向 opt-in,我可以改为默认关闭的 builder 开关。
    5. 全候选失败的汇总异常消息补充实际尝试顺序;fallback 与 retry 的推荐组合顺序(fallback 在外层:先重试当前模型、耗尽后整体切换)写入 javadoc 并补组合测试。

    兼容边界

    测试范围

    • 同步:顶层取消、包装取消(CompletionException 链)、中断、全候选失败顺序与最终 cause;
    • 流式:首事件前切换、首事件后透传、usage/toolCall 事件视为已输出、取消流不切换、重复订阅预算隔离、fallback×retry 组合;
    • 现有三个 fallback 测试类保持通过(其中"publisher 错误原样透传"一条将按第 3 点更新预期)。

    完成后提交 PR。

  3. XianReallyHot-ZZH commented on Nov 13, 2026

    @XianReallyHot-ZZH
    Contributor

    已基于最新 main(efd12ae)实现:#368。

    按认领评论的方案落地,补充三点实现说明:

    1. 取消识别与线程纪律:cause 链遍历(带 32 层深度上限,防环链自旋),同步路径保持线程感知语义(既有 cancellationAndInterruptionNeverAttemptAnotherClient 契约不变);响应式路径只信错误信号本身,不再读写池化线程的中断标志——避免污染 Netty 事件循环线程导致后续无关请求被误判为取消。取消在首事件前后都解包为同一身份,下游按顶层类型判断取消的分支不再受包装影响。
    2. 流式门闸:发出任何 ChatEvent(textDelta/toolCall/usage/response)之前失败 → 切换下一候选;之后失败 → 原样终止。切换派发经 boundedElastic,避免不支持流式的备用客户端以 callBlocking 阻塞送达错误信号的线程。每次切换前检查 ExecutionGuard(主调用入口也补了检查,对齐 retry 的每次尝试检查),每次订阅独立切换状态。
    3. 已知共享限制(未纳入本单):切换后同步响应转 Flux.just(ChatEvent.response(...)) 不经过 StreamingModelInterceptor 策略——这与 ModelRetryInterceptor.toStreamingFlux 及 AgentLlmNode 同步分支的既有约定一致,如需收紧建议单开 issue 统一处理。

    测试 30 项(新增 17 项边界用例):包装取消 ×3、取消身份一致性、首事件前切换/后终止(primary 与 fallback 候选两层)、usage/toolCall 门闸、guard 入口/切换点、每订阅预算、多候选推进、同步构建失败分类、fallback×retry 组合顺序;模块全量 1382 项通过。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions