Java AI 请求为什么越重试越慢:总截止、幂等与 SSE
直接结论:在请求入口确定总截止时间,所有阶段和重试共享剩余预算。连接超时、单次模型超时、SSE 首包与空闲超时各管不同问题,任何一个都不能独自代表用户的总等待时间。
模型的单次超时只覆盖调用链中的一段;排队、检索、SDK 重试和网关重试依次叠加,就可能让页面等待远超这一个参数。因此先限制整次请求的等待,再决定是否值得用余额换一次重试。
本文核心示例以 Java 21 执行;Spring 适配说明引用 Framework 6.2.x 的 MVC/WebClient API。Spring AI 官方入门文档 将 2.0.x 对应到 Spring Boot 4.0.x/4.1.x,不能把这里的 Framework 6.2.x 文档当作该组合的版本适配证明。使用 Spring AI 2.0.x 时,应按项目 BOM 实际解析出的 Framework 和客户端版本查配置;本文未验证这套 Spring 集成。
1. 先约定总预算,再给阶段设上限
假设练习接口总预算十秒,预留五百毫秒用于整理错误与响应。排队和检索用掉多少,模型就少多少;模型阶段上限六秒,但实际允许值为 min(阶段上限, 剩余时间 - 收尾预留)。这些数字是教学配置,不是测出的服务能力。
同进程用 System.nanoTime 的差值计时,避免系统时钟校准影响耗时。计时从入口开始,不能等连接拿到才开始。跨进程不能直接发送本机纳秒值。gRPC 的截止传播机制 会把截止换成已扣除耗时的剩余时长,避免直接比较不同机器的时钟。自定义 HTTP 头不会自动获得这些能力:发送前重算余额,接收时取上游余额与本地限制的较小值,再用本地单调时钟扣除后续耗时。接收端仅凭一个剩余时长无法精确知道在途耗时;需要额外的传输安全余量,并由上游自己的总截止约束等待。若选择绝对截止时间,则还需明确时钟同步与偏差处理规则,不能声称仅靠透传即可严格保证端到端截止。
2. 让每次调用重新取剩余时间
下载 TimeoutBudgetDemo.java。其 Budget.stage 在发起阶段前检查余额,retry 同时检查退避、最小尝试窗口和收尾预留。生产构造方式与核心计算为:
Budget budget = new Budget(Duration.ofSeconds(10), System::nanoTime);
Duration allowed = budget.stage(Duration.ofSeconds(6), Duration.ofMillis(500));上面两行使用下载文件中的嵌套类;不要单独当作完整程序编译。阶段超时必须传入实际客户端,并由请求级定时任务处理“阶段运行中总截止到达”的情况。仅在调用前检查余额,不能中止已卡住的操作。
JDK HttpClient 的连接超时 约束建立新连接;HttpRequest.timeout 用于单次请求。Spring 的 WebClient 配置 还区分连接和响应配置。连接池等待、正文消费、流结束都要按所用客户端核验,不能把某个参数名当成整条链路保证。
3. 重试之前做三个判断
- 故障可重试吗? 只对策略允许的暂时故障有限重试,认证失败、参数错误不重试。把 SDK、业务、网关的重试次数合并核算,约定“最多两次尝试”包含第一次。
- 操作允许重复吗? 超时可能发生在对方执行成功之后。写工单等操作需要服务端持久化幂等键、请求摘要与执行状态,并能查询结果;本机 Map 或每次新建请求号不能保证幂等。纯生成虽未写业务库,也可能重复计费或调用工具。
- 余额和输出允许吗? 退避、下一次最小窗口与收尾预留之和必须小于剩余预算;一旦已向用户输出内容,默认禁止透明重试。否则同一答案会混入两次生成结果。
本机运行方式:
javac -encoding UTF-8 --release 21 TimeoutBudgetDemo.java
java -cp . TimeoutBudgetDemo实际 stdout:
modelCapMs=6000
remainingMs=3500
retryWithBackoff=false
shortRetry=true
writeRetry=false
afterSseRetry=false
atDeadline=TimeoutException这里用可控逻辑时钟,五百毫秒排队加六秒模型阶段后只剩三千五百毫秒,带一秒退避和三秒尝试的重试被拒绝。shortRetry=true 仅说明更短的教学窗口通过策略计算,不代表已经发出网络请求,更不代表重试会成功。
4. SSE 需要额外约定终止方式
分别约定首个业务事件期限、相邻业务事件的空闲期限和流总时长。心跳可以保持链路活跃,但不应重置“模型一直没产出内容”的计时,更不能延长总截止。代理缓冲与空闲超时需一起检查。
| 期限 | 从何时计时 | 何时重置 | 主要识别的失败 |
|---|---|---|---|
| 首个业务事件 | 发起模型流调用 | 首个业务事件到达后停止该计时器 | 连接已通但模型迟迟不输出 |
| 业务事件空闲 | 首个业务事件到达 | 每个后续业务事件到达 | 输出到一半停住;心跳仍在也会触发 |
| 请求总截止 | 请求入口 | 不重置 | 排队、生成或重试累计耗尽预算 |
这里的业务事件应按接口契约识别,例如包含答案增量的事件;SSE 注释心跳不算答案进展。首包与空闲期限属于应用策略,要分别维护计时器,并始终受剩余总预算约束。ResponseBodyEmitter API 中的超时针对 Servlet 异步请求,SseEmitter 继承这一机制;它不会自动识别业务事件,也不能替代上述首包和空闲检查。把心跳当成进展会掩盖模型停滞,而只配异步请求超时又无法区分“从未输出”和“输出中断”。
Spring MVC 异步与 SSE 及 SseEmitter API 提供超时和完成回调。应用在超时、错误、完成时释放定时器并取消上游;若响应已经提交,不能再改 HTTP 状态码,连接仍可写时发送约定的错误事件后结束,断连时只做服务端清理。重连续传必须另有事件 ID 和持久化游标契约。
CompletableFuture.orTimeout 让结果异常完成,不等于底层任务已经停止;其 cancel API 也不以线程中断控制处理。取消应沿订阅、HTTP 调用和任务句柄传播,已被对方执行的操作仍须靠幂等与状态查询收尾。
本次只验证预算计算,未运行 Spring 服务、慢网络、取消或 SSE 断连。接入后建议分别注入排队拥塞、响应首包慢、输出中断、写操作超时,记录实际终止与副作用。复现证据见 实测说明,主资料入口为 Java AI 资料,RAG 场景配合 验收手册。
