从一张白纸开始
假设接到一个需求:把首字延迟打下来。最初脑子里跳出来的东西一定很直觉------让模型快点吐字、把路上的开销省掉、把无关的事往后挪。这些直觉都没错,但它们拼在一起,离"可靠"还有多远?
下面是一次完整的复盘:从最直接的方案开始,每部署一轮就撞上新的墙,然后回头修补,反复五次,最终收敛到一个分层的完整框架。每一步都有代码佐证,每一步都在追问同一个问题------这次真的够了吗?
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。拆开一看:
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之前的那段空白。这段空白里发生了什么?队列排队。
高并发场景和低并发有本质区别:
低并发: 请求 → 立刻处理 → 正常返回
高并发: 请求 → 进队列 → 等前面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优化的版图从最初应用层的四招,扩展成了覆盖全链路的六层框架:
┌─────────────────────────────────────────────────────┐
│ 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月,某个反复推倒重来的下午。