上个月公司做大模型能力聚合网关业务线产品经理提了一个挺现实的诉求前端对话框必须做到极致的“打字机秒出”。在他们眼里用户不管你后面跑的是千亿参数还是量化版本如果点了发送按钮超过两秒屏幕还没动静就会被判定为系统卡死。但当时我们后端接入了四五个不同供应商的 API既有公有云的主流商用模型也有本地机房私有化部署的开源大模型。每个厂商的 API 稳定性和推理速度天差地别同一款模型在早高峰和半夜的响应时间也能差出两三倍。为了做动态路由和智能降级我们必须在生产环境中持续对各厂商模型进行并行测速。做大模型测速最关键的指标不是整句话吐完的总耗时Total Latency而是 TTFTTime to First Token首字延迟。只要首个 Token 快速蹦出来用户的心理等待阈值就会大幅放宽。今天聊聊怎么用 Spring AI 配合 Project Reactor 的Flux优雅地实现多厂商模型的并行测速与指标聚合。为什么传统阻塞方式测不准首字延迟很多做传统 Java Web 开发的朋友刚转到大模型对接时习惯性地拿RestTemplate或者普通的 HTTP Client 测接口。发送请求等整个响应 JSON 包收齐算个耗时。但大模型推理本质上是自回归生成首字延迟与后续生成速度是两个完全解耦的维度TTFT 核心影响因素Prompt 编码Prefill 阶段、网络握手耗时、服务方排队时间、KV Cache 命中率。生成速度影响因素Decode 阶段显卡带宽、Batch Size 大小、生成 Token 总量。如果用全量响应来评估体验会出现严重误判。比如 A 模型首字只需 280ms但生成 1000 字花了 5 秒B 模型首字卡了 1.8 秒但后续吐字飞快2 秒吐完。在客户端交互上A 模型的实际体验远优于 B 模型。因此我们必须使用 SSEServer-Sent Events流式响应并在接收到第一个数据 Chunk 的瞬间打上高精度时间戳。在 Spring 技术栈里Spring AI 提供的流式客户端天然返回 Project Reactor 的FluxChatResponse这正是做异步并行统计的利器。响应式测速流水线设计我们的目标是针对同一段测试 Prompt同时向供应商 A、供应商 B、供应商 C 并行发起调用利用响应式操作符精准拦截首字时间戳、末字时间戳以及计算每秒生成 Token 速率Tokens Per Second, TPS最终汇总成一份对比数据。整个流程如果用线程池加CountDownLatch来做代码会显得非常冗长并且线程开销和上下文切换也会影响计时精度。而借助Flux的操作符编排几行声明式代码就能完成并行触发、分流拦截和超时兜底。来看核心的测速监控结构体定义public record ModelSpeedMetric( String providerName, String modelName, long promptTokens, long completionTokens, long dnsAndConnectMs, // 网络建连耗时 long ttftMs, // 首字延迟从发起请求到第一个有效Token long totalDurationMs, // 总耗时 double tokensPerSecond// 吐字速率 ) {}接下来是核心测速服务类的实现。我们利用Flux.defer保证每次订阅时生成独立的上下文通过局部状态追踪首字到达时刻Service public class ModelBenchmarkService { private final MapString, ChatModel chatModels; public ModelBenchmarkService(MapString, ChatModel chatModels) { this.chatModels chatModels; } public MonoModelSpeedMetric measureSingleModel(String providerKey, String promptText) { ChatModel chatModel chatModels.get(providerKey); if (chatModel null) { return Mono.error(new IllegalArgumentException(未找到对应的模型实例: providerKey)); } return Mono.defer(() - { long requestStartTime System.nanoTime(); AtomicLong firstTokenTime new AtomicLong(0); AtomicLong totalTokens new AtomicLong(0); Prompt prompt new Prompt(new UserMessage(promptText)); return chatModel.stream(prompt) .doOnNext(response - { // 记录首字延迟仅在第一次收到非空文本块时打点 String content response.getResult().getOutput().getText(); if (content ! null !content.isEmpty()) { firstTokenTime.compareAndSet(0, System.nanoTime()); totalTokens.incrementAndGet(); } }) .doOnError(ex - { // 记录特定厂商异常日志便于排查推理引擎报错 }) .then(Mono.fromCallable(() - { long endTime System.nanoTime(); long startMs requestStartTime; long firstMs firstTokenTime.get(); long ttft (firstMs 0) ? (firstMs - startMs) / 1_000_000 : -1; long totalMs (endTime - startMs) / 1_000_000; long tokens totalTokens.get(); double tps (totalMs ttft ttft 0) ? (tokens * 1000.0 / (totalMs - ttft)) : 0.0; return new ModelSpeedMetric( providerKey, chatModel.getClass().getSimpleName(), 0, // 提示词Token预估或从Metadata读取 tokens, 0, ttft, totalMs, Math.round(tps * 100.0) / 100.0 ); })) .timeout(Duration.ofSeconds(15)) .onErrorResume(ex - Mono.just(new ModelSpeedMetric( providerKey, TIMEOUT_OR_FAILED, 0, 0, 0, -1, -1, 0.0 ))); }); } public FluxModelSpeedMetric benchmarkAllProviders(ListString providers, String testPrompt) { // 利用 Flux.merge 并行分发所有厂商的测速任务 return Flux.fromIterable(providers) .flatMap(provider - measureSingleModel(provider, testPrompt) .subscribeOn(Schedulers.boundedElastic())); } }生产落地的避坑经验上面的逻辑跑在本地单元测试里看起来非常顺利但一旦搬到生产环境做定期的自动化探测有几个非常隐蔽的坑必须提前避开。1. HTTP 客户端长连接与冷启动握手偏差测速最大的误差来源往往不是大模型本身而是 TCP 与 TLS 握手。如果是第一次请求某个厂商的域名DNS 解析需要 2050msTLS 1.3 握手需要 12 个 RTT跨洋网络可能达到 200~300ms。这部分网络耗时会直接混入 TTFT 中导致探测结果剧烈抖动。在配置 Spring AI 底层的WebClient时务必开启 HTTP 连接池保持长连接Keep-AliveConnectionProvider provider ConnectionProvider.builder(custom-ai-pool) .maxConnections(50) .maxIdleTime(Duration.ofSeconds(60)) .maxLifeTime(Duration.ofMinutes(5)) .pendingAcquireTimeout(Duration.ofSeconds(5)) .evictInBackground(Duration.ofSeconds(30)) .build(); HttpClient httpClient HttpClient.create(provider) .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000) .responseTimeout(Duration.ofSeconds(30));在正式做对比之前先发送一个探测心跳例如 Prompt 为 hi做连接预热把连接建立耗时和推理准备阶段隔离开。2. 空包与元数据 Chunk 的过滤不同厂商的流式输出格式规范不完全统一。有的厂商在第一个 SSE 包里只返回role: assistantcontent字段是空字符串有的厂商会在首包里塞入当前的计费元数据。如果在doOnNext中仅仅判断response ! null就打上时间戳你会发现某些模型的 TTFT 只有惊人的 40ms。但仔细看网络报文那只是一个空的初始化包真正的文字内容直到 600ms 后才到达。因此代码中的判断条件必须严格校验content ! null !content.trim().isEmpty()确保打点对应的是人类可见的第一个有效文字。3. 反应式背压与缓冲导致的滞后在 Reactive Streams 体系中下游的处理速度如果慢于上游的推送速度或者中间经过了某些带有缓冲性质的操作符如buffer、window会导致打点时间被人为延后。在测速链路中doOnNext应当直接挂在流的最前端即直接紧随chatModel.stream()之后切忌在中间插入复杂的日志序列化或数据库写入逻辑。所有重型的持久化操作都放到流结束之后的then或专门的异步事件队列里异步处理。最终指标的应用场景拿到准确的 TTFT 和 TPS 数据后不要只把它当成监控看板上的几个折线图。我们在网关层基于这些指标落地了两项实用策略会话级动态路由对即时性要求高的场景如客服即时问答、代码自动补全加权路由算法优先倾斜到最近 5 分钟 TTFT 中位数最低的厂商节点。静默双发Hedge Request对重要 VIP 客户的提问同时向两个不同厂商发起请求。只要任一通道在 600ms 内未产生第一个 Token立即唤醒备用通道。谁先返回首字就消费谁并取消另一个未完成的流。把大模型当成传统的黑盒三方接口来管理是行不通的。利用好响应式编程与精细化时间切片才能把控住 AI 时代的系统体验底线。