TTFT优化:一个方案的5次推倒重来

从一张白纸开始

假设接到一个需求:把首字延迟打下来。最初脑子里跳出来的东西一定很直觉------让模型快点吐字、把路上的开销省掉、把无关的事往后挪。这些直觉都没错,但它们拼在一起,离"可靠"还有多远?

下面是一次完整的复盘:从最直接的方案开始,每部署一轮就撞上新的墙,然后回头修补,反复五次,最终收敛到一个分层的完整框架。每一步都有代码佐证,每一步都在追问同一个问题------这次真的够了吗?


V1:直觉方案------把应用层能做的一口气做了

第一版的逻辑简单粗暴:凡是在应用代码里能省的延迟,一处不留。

1. 流式返回替代整段生成

最核心的一步。不等到模型把完整回答生成完才推给前端,模型每产出一个token就立刻通过SSE事件推送。

java 复制代码
// V1: 订阅模型流式输出,逐token推给前端
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> chatStream(@RequestBody ChatRequest request) {
    long start = System.currentTimeMillis();
    
    return modelClient.chatStream(request.getPrompt())
        .doOnNext(token -> {
            // 埋点:首token到达时间
            if (!firstTokenSent.get()) {
                firstTokenSent.set(true);
                metrics.recordTTFT("first_token", System.currentTimeMillis() - start);
            }
        })
        .map(token -> ServerSentEvent.<String>builder()
            .data(token)
            .build());
}

这个埋点记录了start → 第一个token的耗时,至少知道整体表现有没有恶化。

2. OkHttp连接池,省掉重复握手

java 复制代码
// V1: 连接池配置 --- 避免每次请求重新TCP+TLS握手
OkHttpClient client = new OkHttpClient.Builder()
    .connectionPool(new ConnectionPool(
        50,                      // 最大空闲连接数
        5, TimeUnit.MINUTES      // 连接保活时间
    ))
    .connectTimeout(3, TimeUnit.SECONDS)
    .readTimeout(30, TimeUnit.SECONDS)
    .build();

3. 主链路轻量化:非核心逻辑异步解耦

java 复制代码
// V1: 日志、埋点异步化,不占主链路时间
@Bean("asyncExecutor")
public ExecutorService asyncExecutor() {
    return new ThreadPoolExecutor(
        4, 16, 60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(2000),
        new ThreadPoolExecutor.CallerRunsPolicy()
    );
}

public Mono<String> processRequest(ChatRequest req) {
    // 主链路:只做解析 → 调模型 → 返结果
    String prompt = parsePrompt(req.getMessages());
    
    // 非核心:丢给异步线程池
    CompletableFuture.runAsync(() -> {
        metrics.recordRequest(req.getUserId());
        accessLog.write(req);
    }, asyncExecutor);
    
    return modelClient.chatStream(prompt);
}

4. 参数预处理精简

java 复制代码
// V1: 前置参数解析不做重操作
private String parsePrompt(List<Message> messages) {
    // 只用StringBuilder拼接,不用ObjectMapper序列化整条链路
    return messages.stream()
        .map(Message::getContent)
        .collect(Collectors.joining("\n"));
}

V1部署上线,首轮数据回来:P50的TTFT从1200ms降到了800ms,看起来不错。

但P99仍然在2800ms------也就是说,每100个用户里就有1个要等将近3秒才看到第一个字。而且查了几天日志发现:同样的代码,不同时间段TTFT波动巨大。高峰期800ms变2000ms,低谷期又能回到600ms。

V1的问题总结:只盯着应用服务内部的几十毫秒,对服务边界外发生的事情几乎一无所知。 那个埋点告诉你"花了800ms",但没告诉你在哪花的。


V2:把链路画全------传输层和接入层欠的债

回看V1的埋点代码,犯了一个认知错误:start的计时点打在应用服务收到请求之后。用户的体感从点击发送按钮就开始了。

完整的前置链路:

复制代码
用户点击发送 → 客户端网络 → DNS解析 → TCP+TLS握手 → CDN/网关 → 应用服务

这些环节V1一行代码都没覆盖。

V2新增:DNS缓存和HTTPDNS

java 复制代码
// V2: 配置DNS缓存,减少DNS查询耗时
// JVM参数: -Dsun.net.inetaddr.ttl=60 -Dsun.net.inetaddr.negative.ttl=10

// 进阶方案:HTTPDNS绕过运营商LocalDNS
public class HttpDnsResolver {
    private final Cache<String, List<InetAddress>> dnsCache = 
        Caffeine.newBuilder()
            .maximumSize(10000)
            .expireAfterWrite(5, TimeUnit.MINUTES)
            .build();
    
    public InetAddress resolve(String host) {
        List<InetAddress> cached = dnsCache.getIfPresent(host);
        if (cached != null) return cached.get(0);
        
        // 走HTTPDNS服务获取解析结果,不依赖运营商DNS
        List<InetAddress> resolved = httpDnsClient.lookup(host);
        dnsCache.put(host, resolved);
        return resolved.get(0);
    }
}

V2新增:协议升级到HTTP/2

HTTP/1.1的队头阻塞在模型API这特别致命------当前一个慢请求占着连接,后一个请求只能干等。

java 复制代码
// V2: 强制HTTP/2,利用多路复用消除队头阻塞
OkHttpClient client = new OkHttpClient.Builder()
    .protocols(Arrays.asList(Protocol.HTTP_2, Protocol.HTTP_1_1))
    .connectionPool(new ConnectionPool(50, 5, TimeUnit.MINUTES))
    .build();

更彻底的方案是把网关层的协议也升级了:

nginx 复制代码
# V2: Nginx侧启用HTTP/2,客户端到网关先受益
server {
    listen 443 ssl http2;
    
    # 上游也可以用HTTP/2
    location /api/chat {
        grpc_pass grpc://model_backend;  # gRPC天然基于HTTP/2
    }
}

V2新增:请求内容压缩

用户的prompt可能很长(RAG场景里灌进去一整段上下文),上行传输时间不能忽略:

java 复制代码
// V2: OkHttp默认不压缩请求体,手动开启Gzip
OkHttpClient client = new OkHttpClient.Builder()
    .addInterceptor(chain -> {
        Request original = chain.request();
        if (original.body() != null && original.header("Content-Encoding") == null) {
            Request compressed = original.newBuilder()
                .header("Content-Encoding", "gzip")
                .method(original.method(), gzipCompress(original.body()))
                .build();
            return chain.proceed(compressed);
        }
        return chain.proceed(original);
    })
    .build();

V2上线后,P50又降了约60ms------这部分来自DNS缓存和协议升级的红利。但P99纹丝不动。

V2的认知升级:传输层优化有确定的收益,但天花板来得很快。P99的尾巴说明还有别的瓶颈,这些瓶颈不在传输层。


V3:RAG场景------真正的TTFT头号杀手

回头看P99的慢请求日志,发现一个规律:带了知识库检索的请求,TTFT普遍比纯对话请求高300-500ms。拆开一看:

makefile 复制代码
RAG请求TTFT拆解(典型值):
  应用前置处理      15ms
  Embedding生成     120ms   ← 每次调远端API
  向量库检索         85ms
  模型推理          350ms
  网络回传          30ms
  ─────────────────
  合计             600ms

Embedding生成和向量检索占了200+ms,占比超过30%。V1和V2的所有优化都在这200ms面前显得苍白------你没优化到瓶颈上。

V3新增:Embedding结果多级缓存

系统Prompt是固定的,它的Embedding向量每次重新算一遍毫无意义:

java 复制代码
// V3: 固定Prompt的Embedding向量缓存
public class EmbeddingCache {
    private final Cache<String, float[]> localCache = Caffeine.newBuilder()
        .maximumSize(5000)
        .expireAfterWrite(1, TimeUnit.HOURS)
        .build();
    
    private final RedisTemplate<String, float[]> redisCache;
    
    public float[] getEmbedding(String text) {
        String key = "emb:" + hash(text);
        
        // L1: 本地缓存
        float[] cached = localCache.getIfPresent(key);
        if (cached != null) return cached;
        
        // L2: Redis分布式缓存
        cached = redisCache.opsForValue().get(key);
        if (cached != null) {
            localCache.put(key, cached);
            return cached;
        }
        
        // L3: 实际调用Embedding服务
        float[] result = embeddingService.encode(text);
        redisCache.opsForValue().set(key, result, 30, TimeUnit.MINUTES);
        localCache.put(key, result);
        return result;
    }
}

V3新增:Embedding本地部署,省掉网络RTT

远端Embedding API的每次调用都包含网络往返+API网关排队。如果部署一个本地常驻的Embedding推理服务:

java 复制代码
// V3: 本地Embedding服务的gRPC调用(省去HTTP开销+TLS握手)
@GrpcClient("local-embedding-service")
private EmbeddingServiceGrpc.EmbeddingServiceBlockingStub embeddingStub;

public float[] encode(String text) {
    EmbedRequest req = EmbedRequest.newBuilder()
        .setText(text)
        .setModel("bge-small-zh")  // 轻量模型,CPU即可推理
        .build();
    return embeddingStub.encode(req).getVectorList()
        .stream().map(Double::floatValue)
        .toArray(Float[]::new);  // 转回float[]
}

V3新增:向量库连接池+索引优化

java 复制代码
// V3: Milvus连接池 + 搜索参数调优
@Bean
public MilvusServiceClient milvusClient() {
    ConnectParam param = ConnectParam.newBuilder()
        .withHost("milvus.internal")
        .withPort(19530)
        .build();
    return new MilvusServiceClient(param);
}

// 检索时控制搜索范围
public List<SearchResult> search(float[] queryVector, int topK) {
    SearchParam param = SearchParam.newBuilder()
        .withCollectionName("knowledge_base")
        .withVector(Arrays.asList(queryVector))
        .withTopK(topK)
        .withParam("{\"nprobe\": 8, \"ef\": 64}")  // nprobe控制搜索精度/性能权衡
        .build();
    return milvusClient.search(param).getResults();
}

V3上线后,RAG场景的TTFT平均降了180ms。P99从2800ms降到了1800ms------但依然不算好看。

V3的认知升级:缓存和本地化能吃掉RAG检索的大部分固定开销。但剩余的长尾来自哪里?来自"等"------排队等、抢锁等、重试等。


V4:当请求涌进来------容量、排队和降级

V3的P99分析发现了一个有意思的模式:高峰期的慢请求,绝大多数时间消耗在一个意想不到的地方------等待模型服务返回第一个token之前的那段空白。这段空白里发生了什么?队列排队。

高并发场景和低并发有本质区别:

makefile 复制代码
低并发: 请求 → 立刻处理 → 正常返回
高并发: 请求 → 进队列 → 等前面200个请求 → 轮到你 → 模型推理

你应用层优化到极致,前面排着200个请求照样要等。

V4新增:请求优先级队列

不是所有用户对延迟的容忍度都一样:

java 复制代码
// V4: 多优先级请求队列
public class PriorityAwareModelClient {
    private final Semaphore highPrioritySlots = new Semaphore(20);
    private final Semaphore normalSlots = new Semaphore(50);
    private final Semaphore lowSlots = new Semaphore(10);
    
    public Mono<String> chat(String prompt, Priority priority) {
        Semaphore slot = switch (priority) {
            case HIGH -> highPrioritySlots;
            case NORMAL -> normalSlots;
            case LOW -> lowSlots;
        };
        
        return Mono.fromCallable(() -> {
            if (!slot.tryAcquire(500, TimeUnit.MILLISECONDS)) {
                // 拿不到信号量 → 触发降级
                throw new QueueFullException("No available slot");
            }
            return true;
        }).flatMap(ok -> 
            modelClient.chatStream(prompt)
                .doFinally(s -> slot.release())
        );
    }
}

V4新增:熔断机制------模型服务不可用时快速失败

java 复制代码
// V4: Resilience4j熔断器 --- 失败不重试,直接降级避免TTFT雪崩
@Bean
public CircuitBreaker modelCircuitBreaker() {
    return CircuitBreaker.of("model-api", CircuitBreakerConfig.custom()
        .failureRateThreshold(50)            // 50%失败率触发熔断
        .waitDurationInOpenState(Duration.ofSeconds(10))
        .slidingWindowSize(20)
        .permittedNumberOfCallsInHalfOpenState(5)
        .build());
}

public Mono<String> chatWithFallback(String prompt) {
    return circuitBreaker.executePublisher(
        modelClient.chatStream(prompt)
    ).onErrorResume(ex -> {
        // 熔断打开 → 直接返回兜底回复,不要重试恶化TTFT
        if (ex instanceof CallNotPermittedException) {
            log.warn("Circuit breaker open, returning fallback");
            return Mono.just("抱歉,服务暂时繁忙,请稍后再试。");
        }
        return Mono.error(ex);
    });
}

V4新增:长连接资源管控

百万并发下,每个SSE连接都是资源消耗:

java 复制代码
// V4: SSE连接数上限控制 + 超时回收
@Bean
public ConnectionLimitFilter connectionLimitFilter() {
    return new ConnectionLimitFilter(
        50_000,          // 全局最大SSE连接数
        5, TimeUnit.MINUTES  // 空闲连接超时
    );
}

更好的做法是把长连接托管下沉到网关层:

nginx 复制代码
# V4: Nginx/APISIX承担SSE长连接,后端只做短生命周期计算
location /chat/stream {
    proxy_pass http://backend:8080;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;           # SSE必须关闭缓冲
    proxy_read_timeout 300s;       # 模型推理可能很久
    proxy_connect_timeout 3s;      # 建链超时要短
    
    # 限流:单IP最多50个并发SSE连接
    limit_conn sse_conn_per_ip 50;
}

V4上线后,高峰期P99从1800ms降到1100ms。最主要的改善来自排队控制------高优先级请求不再被淹没。

V4的认知升级:并发压力下的TTFT保障不是"做更快",而是"做隔离"。不让慢的、多的、坏的东西影响快的、少的东西。


V5:看不到的东西没法优化------可观测性的重建

到V4为止,每次优化的方向都是"分析日志 → 靠经验猜瓶颈 → 改代码 → 看效果"。这套流程的问题在于:猜错了代价很大,而且永远不知道自己还有多少没猜到的。

V1那个单点埋点start → firstToken告诉了你总耗时,但从来没告诉你钱花在哪了。就像一个只知道总消费金额却拿不到明细的人------知道自己花了很多,不知道花在什么地方。

V5新增:全链路分阶段埋点

java 复制代码
// V5: 不再只打一个点,而是在每个阶段边界埋桩
public Mono<String> chatWithFullTrace(ChatRequest req, Span parentSpan) {
    // 阶段0: 请求到达 → 开始计时
    Span gatewaySpan = tracer.startSpan("gateway_route", parentSpan);
    // ... 网关路由 ...
    gatewaySpan.end();
    
    // 阶段1: 鉴权
    Span authSpan = tracer.startSpan("auth", parentSpan);
    authService.verify(req.getToken());
    authSpan.end();
    
    // 阶段2: RAG检索(如果有)
    Span ragSpan = null;
    if (req.isRagEnabled()) {
        ragSpan = tracer.startSpan("rag_retrieval", parentSpan);
        Span embSpan = tracer.startSpan("embedding", ragSpan);
        float[] emb = embeddingService.encode(req.getQuery());
        embSpan.end();
        
        Span searchSpan = tracer.startSpan("vector_search", ragSpan);
        List<Doc> docs = vectorStore.search(emb, req.getTopK());
        searchSpan.end();
        ragSpan.end();
    }
    
    // 阶段3: 模型推理
    Span modelSpan = tracer.startSpan("model_inference", parentSpan);
    return modelClient.chatStream(prompt)
        .doOnNext(token -> {
            if (!firstTokenRecorded) {
                // 首token到达 → 记录模型推理耗时
                modelSpan.addEvent("first_token");
                modelSpan.setAttribute("ttft_ms", 
                    System.currentTimeMillis() - startTime);
            }
        })
        .doFinally(s -> modelSpan.end());
}

V5新增:TTFT分位数大盘

java 复制代码
// V5: 不只是平均延迟,P50/P95/P99分桶统计
@Component
public class TTFTMetrics {
    private final MeterRegistry registry;
    
    // 按"请求类型"分桶:纯对话 vs RAG
    private final Timer ttftPureChat = Timer.builder("ttft")
        .tag("type", "pure_chat")
        .publishPercentiles(0.5, 0.95, 0.99)  // 三个分位同时记录
        .register(registry);
    
    private final Timer ttftRag = Timer.builder("ttft")
        .tag("type", "rag")
        .publishPercentiles(0.5, 0.95, 0.99)
        .register(registry);
    
    public void record(String type, long durationMs) {
        Timer timer = switch (type) {
            case "rag" -> ttftRag;
            default -> ttftPureChat;
        };
        timer.record(durationMs, TimeUnit.MILLISECONDS);
    }
}

V5新增:分层告警------每个阶段独立阈值

java 复制代码
// V5: 各阶段独立告警,出问题直接定位是哪个环节
@Component
public class TTFTAlerting {
    
    @EventListener
    public void onSlowRequest(SlowRequestEvent event) {
        TraceSnapshot trace = event.getTrace();
        
        if (trace.getStageDuration("gateway") > 50) {
            alerting.send("网关耗时异常", trace.getRequestId(), 
                trace.getStageDuration("gateway"));
        }
        if (trace.getStageDuration("rag_retrieval") > 300) {
            alerting.send("RAG检索超时", trace.getRequestId(), 
                trace.getStageDuration("rag_retrieval"));
        }
        if (trace.getStageDuration("model_inference") > 500) {
            alerting.send("模型推理排队过长", trace.getRequestId(),
                trace.getStageDuration("model_inference"));
        }
    }
}

V5带来的不是直接的TTFT下降,而是一个更根本的能力------下次TTFT恶化时,不用猜了。Trace拆解会直接告诉你问题在哪个Span。

V5的认知升级:可观测性不是在"已经够快"之后才做的事。它是让所有后续优化不再盲人摸象的基础设施。


最终定型:六层架构全景

五个版本迭代下来,TTFT优化的版图从最初应用层的四招,扩展成了覆盖全链路的六层框架:

scss 复制代码
┌─────────────────────────────────────────────────────┐
│ 1. 客户端 & 传输层                                    │
│    HTTP/2 QUIC | DNS缓存 | Gzip压缩 | 弱网预加载       │
├─────────────────────────────────────────────────────┤
│ 2. 接入网关层 (Nginx/APISIX)                          │
│    连接复用 | TLS会话复用 | 长连接托管 | 就近接入       │
├─────────────────────────────────────────────────────┤
│ 3. 应用服务层                                         │
│    SSE流式 | 连接池 | 异步解耦 | 前置轻量 | 多级缓存   │
├─────────────────────────────────────────────────────┤
│ 4. 依赖中间件层 (RAG场景)                              │
│    Embedding本地部署 | 向量库索引优化 | 检索结果缓存    │
├─────────────────────────────────────────────────────┤
│ 5. 模型推理层                                         │
│    vLLM连续批处理 | KV Cache复用 | 常驻预热 | 多活兜底 │
├─────────────────────────────────────────────────────┤
│ 6. 可观测 & 稳定性                                    │
│    全链路Trace | 分位数大盘 | 熔断限流 | 优先级调度    │
└─────────────────────────────────────────────────────┘

每一层的出现,都对应着一次"发现漏了什么"的时刻:

版本 触发的问题 新增的层
V1 应用层能做的基本都做了 第3层(应用服务)
V2 P99不动 → DNS、队头阻塞在作祟 第1层(传输)、第2层(网关)
V3 RAG占比30%+,Embedding在远端跑 第4层(中间件)
V4 高峰期排队击穿一切 第5层(推理)、第6层(熔断降级)
V5 每次排查靠猜 → 需要先"看见" 第6层(可观测补全)

每一层都不是凭空设计的------是上一次被问题撞了之后补上的。


最后的问题

回到最开始的那个直觉方案。它错了吗?其实没一条是错的。流式返回确实用上了,连接池确实省了握手,异步解耦确实让主链路短了几十毫秒。

但它漏掉的东西,恰好是TTFT从800ms走向200ms的关键:

  • 漏了链路的前半段:用户到服务器的路上,DNS和TLS在消耗看不见的时间
  • 漏了RAG的计算开销:Embedding和向量检索,占了30%以上却被当成"别人的事"
  • 漏了并发下的排队效应:单用户测试很快,不代表百万并发下依然快
  • 漏了"先看见再行动"的底气:没有分层Trace,每次排查都是一场猜谜

好的优化方案,和"看起来对"的优化方案之间,差的就是这四件事。


写于2026年7月,某个反复推倒重来的下午。

相关推荐
神经蛙199616 小时前
🌍 别再硬编码中文了!Python Web 项目国际化(i18n)完全指南
后端·python
二月龙16 小时前
Spring 事务失效的 8 种场景,很多老手依然频繁踩雷
后端
掘金酱16 小时前
「TRAE Work 实战帮」征文启动!你沉淀的经验,值得被看见!
前端·人工智能·后端
长大198816 小时前
MyBatis 常见性能陷阱:N+1 查询、一级缓存踩坑解决方案
后端
用户18615580086016 小时前
MinIO Java 对接试用:从连接、上传到下载的完整示例
后端
爱勇宝17 小时前
DeepSeek V4-Flash 更新:代码与 Agent 能力全面增强
前端·后端·deepseek
极客悟道17 小时前
SDKMAN vs jEnv vs JetTUI,JDK 版本管理到底选哪个
后端
长大198817 小时前
Java8 新特性到底要不要吃透?工作中高频使用的 5 个功能总结
后端
二月龙17 小时前
Spring Bean 生命周期 & 循环依赖:90% 开发者只知结论不懂原理
后端