AI推理延迟优化:P99延迟与吞吐量的生产调优实录
AI推理延迟优化:P99延迟与吞吐量的生产调优实录
适读人群:AI应用已上线但延迟或性能不达标的工程师,负责AI服务SLA的架构师 阅读时长:约18分钟 文章价值:系统的AI推理延迟优化方法论,从监控指标到具体调优手段,附真实调优数据
那次让我出了一身冷汗的监控告警
凌晨一点,我收到告警:AI客服系统P99延迟超过8秒,SLA红线是5秒。
当时用户量不算大,峰值QPS也就80,按道理不该有问题。
翻监控看了半小时,发现不是服务挂了,而是某些请求特别慢——平均延迟2.3秒,但P99超过8秒。这种"大多数快,少数慢"的问题,比整体慢更难排查,因为日志里看不出来,监控平均值也掩盖了问题。
排查了两个多小时,最后定位到原因是:某些用户的对话历史特别长,导致prompt token数超过8000,推理时间急剧增加。
这篇文章,把我调优这类问题的完整思路写出来。
延迟问题的构成分析
AI推理延迟不是一个单一数字,要拆解清楚各段耗时:
关键延迟指标:
| 指标 | 说明 | 优化方向 |
|---|---|---|
| TTFT(首token时间) | 从请求到收到第一个token | 减少prompt长度,流式输出 |
| TBT(token间隔时间) | 每个token生成间隔 | 模型选择,batch策略 |
| E2E延迟 | 端到端总延迟 | 综合优化 |
| P50 | 50%请求的延迟(中位数) | 通用优化 |
| P99 | 99%请求的延迟 | 处理异常慢请求 |
监控体系搭建
要优化,先要能看见。
@Component
@Slf4j
public class LLMLatencyTracker {
private final MeterRegistry meterRegistry;
// 细粒度计时器
private final Timer promptBuildTimer;
private final Timer llmCallTimer;
private final Timer e2eTimer;
private final DistributionSummary tokenCountSummary;
public LLMLatencyTracker(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.promptBuildTimer = Timer.builder("ai.latency.prompt_build")
.description("Prompt构建耗时")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
this.llmCallTimer = Timer.builder("ai.latency.llm_call")
.description("LLM API调用耗时")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
this.e2eTimer = Timer.builder("ai.latency.e2e")
.description("端到端延迟")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
this.tokenCountSummary = DistributionSummary.builder("ai.tokens.count")
.description("Token数量分布")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
}
/**
* 带延迟追踪的AI调用
*/
public <T> T trackCall(String operationName, Supplier<T> operation) {
long start = System.currentTimeMillis();
try {
T result = operation.get();
long latency = System.currentTimeMillis() - start;
// 超过阈值打印详细日志
if (latency > 3000) {
log.warn("慢AI调用: operation={}, latency={}ms", operationName, latency);
}
return result;
} finally {
long latency = System.currentTimeMillis() - start;
e2eTimer.record(latency, TimeUnit.MILLISECONDS);
}
}
}记录详细的Span信息(配合Zipkin/Jaeger):
@Service
@Slf4j
public class TracedAiService {
private final ChatClient chatClient;
private final Tracer tracer;
public String chatWithTracing(String userId, String message) {
Span span = tracer.nextSpan()
.name("ai-chat")
.tag("user.id", userId)
.start();
try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
// 阶段1:构建Prompt
long promptStart = System.currentTimeMillis();
String prompt = buildPrompt(message);
int promptTokens = estimateTokens(prompt);
span.tag("prompt.tokens", String.valueOf(promptTokens));
span.tag("prompt.build.ms", String.valueOf(
System.currentTimeMillis() - promptStart));
// 阶段2:LLM调用
long llmStart = System.currentTimeMillis();
String response = chatClient.prompt()
.user(prompt)
.call()
.content();
long llmLatency = System.currentTimeMillis() - llmStart;
span.tag("llm.latency.ms", String.valueOf(llmLatency));
span.tag("llm.response.length", String.valueOf(response.length()));
return response;
} finally {
span.end();
}
}
}优化手段一:Prompt瘦身
Prompt越短,TTFT越低,这是延迟优化的第一道菜。
@Service
@Slf4j
public class PromptOptimizer {
/**
* Prompt Token数量监控与告警
*/
public String buildOptimizedPrompt(List<Message> history,
String systemPrompt,
String userMessage) {
// 统计各部分token数
int systemTokens = estimateTokens(systemPrompt);
int historyTokens = history.stream()
.mapToInt(m -> estimateTokens(m.getContent()))
.sum();
int userTokens = estimateTokens(userMessage);
int total = systemTokens + historyTokens + userTokens;
log.debug("Prompt组成: system={}, history={}, user={}, total={} tokens",
systemTokens, historyTokens, userTokens, total);
// 动态裁剪历史(根据剩余空间决定保留多少历史)
int maxContextTokens = 6000; // 为输出预留2000
if (total > maxContextTokens) {
log.warn("Prompt超出阈值: total={}, 开始裁剪历史", total);
history = trimHistory(history, maxContextTokens - systemTokens - userTokens);
}
return buildFinalPrompt(systemPrompt, history, userMessage);
}
/**
* 系统提示词压缩
* 去掉不必要的客套话和冗余说明
*/
public String compressSystemPrompt(String verbosePrompt) {
// 实践发现,很多系统提示词有20-30%是冗余的
// 去掉"当然可以,我很乐意帮助您..."这类无意义前缀
// 去掉重复声明的规则
return chatClient.prompt()
.user("请压缩以下系统提示词,保留所有重要规则,去除冗余表达,长度压缩30%:\n\n" + verbosePrompt)
.call()
.content();
}
private List<Message> trimHistory(List<Message> history, int maxTokens) {
List<Message> trimmed = new ArrayList<>();
int usedTokens = 0;
// 从最新的消息向前保留
for (int i = history.size() - 1; i >= 0; i--) {
int msgTokens = estimateTokens(history.get(i).getContent());
if (usedTokens + msgTokens > maxTokens) break;
trimmed.add(0, history.get(i));
usedTokens += msgTokens;
}
return trimmed;
}
}实测效果:将平均prompt从3800 token减到2200 token,P50延迟从2.1s降至1.4s,P99从8.2s降至4.8s。
优化手段二:流式响应
用户感知的延迟不是"完整响应时间",而是"看到第一个字"的时间:
@RestController
@RequestMapping("/ai")
public class StreamingChatController {
private final ChatClient chatClient;
/**
* 流式响应:用户立刻看到第一个字,不需要等全部生成完
*/
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> streamChat(
@RequestParam String message,
@RequestParam String sessionId) {
AtomicLong firstTokenTime = new AtomicLong(0);
long requestStart = System.currentTimeMillis();
return chatClient.prompt()
.user(message)
.stream()
.content()
.map(token -> {
// 记录首token时间
if (firstTokenTime.compareAndSet(0, System.currentTimeMillis())) {
long ttft = firstTokenTime.get() - requestStart;
log.info("TTFT: {}ms, sessionId={}", ttft, sessionId);
}
return ServerSentEvent.<String>builder()
.event("token")
.data(token)
.build();
})
.concatWith(Flux.just(
ServerSentEvent.<String>builder()
.event("done")
.data("[DONE]")
.build()
));
}
}前端配合流式展示(React示例思路,了解即可):
- 建立SSE连接
- 收到token就追加到显示区域
- 用户感知延迟从"等待完整响应"变为"等待第一个字",体验提升明显
实测效果:P99端到端延迟不变,但用户主观等待感从8s降至1.5s(首token时间)。
优化手段三:语义缓存
相似问题复用答案,从缓存返回近乎零延迟:
@Service
@Slf4j
public class SemanticCacheAdvisor implements CallAroundAdvisor {
private final VectorStore cacheStore;
private final float SIMILARITY_THRESHOLD = 0.93f;
@Override
public String getName() {
return "SemanticCacheAdvisor";
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE; // 第一个执行,快速返回缓存
}
@Override
public AdvisedResponse aroundCall(AdvisedRequest request,
CallAroundAdvisorChain chain) {
String userText = request.userText();
// 查语义缓存
List<Document> cached = cacheStore.similaritySearch(
SearchRequest.query(userText)
.withTopK(1)
.withSimilarityThreshold(SIMILARITY_THRESHOLD)
);
if (!cached.isEmpty()) {
String cachedAnswer = cached.get(0).getMetadata()
.getOrDefault("answer", "").toString();
if (!cachedAnswer.isBlank()) {
log.info("语义缓存命中: similarity={}",
cached.get(0).getScore());
// 构造伪响应(让上层代码不感知缓存)
return buildCachedResponse(request, cachedAnswer);
}
}
// 缓存未命中,走正常流程
AdvisedResponse response = chain.nextAroundCall(request);
// 写入缓存
if (response.response() != null) {
String answer = response.response().getResult()
.getOutput().getContent();
cacheToStore(userText, answer);
}
return response;
}
private void cacheToStore(String question, String answer) {
Document doc = new Document(question, Map.of(
"answer", answer,
"cachedAt", System.currentTimeMillis()
));
cacheStore.add(List.of(doc));
}
}实测效果:命中率稳定在32%,命中的请求延迟从平均2.1s降至30ms,整体P50延迟降低约25%。
优化手段四:模型分级路由
不是所有问题都需要最贵最慢的模型:
@Service
@Slf4j
public class ModelRouter {
private final ChatClient fastClient; // gpt-4o-mini
private final ChatClient qualityClient; // gpt-4o
public ModelRouter(ChatClient.Builder builder,
@Value("${openai.api-key}") String apiKey) {
// 配置快速模型(低延迟)
this.fastClient = builder
.defaultOptions(OpenAiChatOptions.builder()
.withModel("gpt-4o-mini")
.withMaxTokens(500)
.build())
.build();
// 配置高质量模型(高延迟)
this.qualityClient = builder
.defaultOptions(OpenAiChatOptions.builder()
.withModel("gpt-4o")
.withMaxTokens(2000)
.build())
.build();
}
/**
* 智能路由:根据问题复杂度选择模型
*/
public String routedChat(String question) {
QuestionComplexity complexity = classifyQuestion(question);
log.info("问题路由: complexity={}, question={}",
complexity, question.substring(0, Math.min(50, question.length())));
return switch (complexity) {
case SIMPLE -> fastClient.prompt().user(question).call().content();
case COMPLEX -> qualityClient.prompt().user(question).call().content();
};
}
/**
* 问题复杂度分类(这里用简单规则,生产可以用小模型分类)
*/
private QuestionComplexity classifyQuestion(String question) {
// 简单问题特征:短、包含具体查询词、无需推理
boolean isShort = question.length() < 50;
boolean isFactual = question.matches(".*?(是什么|怎么|多少|哪里|什么时候).*");
boolean needsReasoning = question.contains("为什么")
|| question.contains("分析")
|| question.contains("比较")
|| question.contains("设计");
if (!needsReasoning && (isShort || isFactual)) {
return QuestionComplexity.SIMPLE;
}
return QuestionComplexity.COMPLEX;
}
enum QuestionComplexity { SIMPLE, COMPLEX }
}实测效果:60%的请求路由到fast模型,平均延迟从2.3s降至1.1s,成本降低45%。
优化手段五:连接池与超时精细化
底层网络配置常被忽视,但影响很大:
@Configuration
public class OpenAiClientConfig {
@Bean
public RestClient.Builder restClientBuilder() {
// 自定义HTTP连接池
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(5));
factory.setReadTimeout(Duration.ofSeconds(60)); // LLM响应慢,不能太短
// 连接池配置
PoolingHttpClientConnectionManager connectionManager =
new PoolingHttpClientConnectionManager();
connectionManager.setMaxTotal(200); // 最大连接数
connectionManager.setDefaultMaxPerRoute(50); // 每个路由最大连接
HttpClient httpClient = HttpClients.custom()
.setConnectionManager(connectionManager)
.evictExpiredConnections()
.evictIdleConnections(TimeValue.of(30, TimeUnit.SECONDS))
.build();
factory.setHttpClient(httpClient);
return RestClient.builder()
.requestFactory(factory);
}
}超时设置的学问:
@Service
public class TimeoutAwareChatService {
private final ChatClient chatClient;
/**
* 不同场景设置不同超时
*/
public String chatWithTimeout(String question, ChatScenario scenario) {
Duration timeout = switch (scenario) {
case INTERACTIVE -> Duration.ofSeconds(10); // 交互式:用户在等
case BATCH -> Duration.ofMinutes(2); // 批处理:可以慢
case BACKGROUND -> Duration.ofMinutes(5); // 后台:不敏感
};
return Mono.fromCallable(() ->
chatClient.prompt().user(question).call().content()
)
.timeout(timeout)
.onErrorReturn("请求处理超时,请稍后重试")
.block();
}
enum ChatScenario { INTERACTIVE, BATCH, BACKGROUND }
}调优成果汇总
经过这几轮优化,我们系统的数据变化:
| 指标 | 优化前 | 优化后 | 改善幅度 |
|---|---|---|---|
| P50延迟 | 2.3s | 0.9s | -61% |
| P95延迟 | 5.1s | 2.8s | -45% |
| P99延迟 | 8.2s | 3.6s | -56% |
| 峰值QPS | 80 | 320 | +300% |
| Token成本 | 100% | 42% | -58% |
最大贡献按顺序:流式响应(用户体感最明显)→ 语义缓存(命中率高省成本)→ Prompt瘦身(降P99最有效)→ 模型路由(成本效益高)。
小结
AI推理延迟优化有一个框架:先监控看见问题,再针对各段耗时找对应手段。
最常犯的错误是:
- 只看平均延迟,忽视P99(用户投诉的是P99,不是平均)
- 只想着换更好的模型,忽视应用层可以做的事
- 不做Profiling就开始优化(先量化,再优化)
我那次凌晨的告警,最终通过Prompt历史裁剪 + 流式响应,把P99从8s压到4.2s,滑进了5s的SLA红线里。
没有换模型,没有加机器,代码改动200行以内。
