第1699篇:AI服务的慢查询分析——识别和优化高延迟的LLM调用
第1699篇:AI服务的慢查询分析——识别和优化高延迟的LLM调用
慢查询这个词,大家在数据库领域很熟悉。MySQL有慢查询日志,PostgreSQL有pg_stat_statements,ES有slowlog。这些工具帮助我们快速找出哪些查询在消耗大量时间和资源。
但在AI服务里,等价的工具和方法论几乎是空白的。大家调用大模型,如果慢了,顶多在日志里记一个"调用耗时XXms",但不知道为什么慢,也不知道怎么优化。
这篇文章想把AI服务的"慢查询"分析做一个系统梳理:怎么发现慢调用,慢的原因有哪些,以及对应的优化手段。
什么是AI服务的"慢查询"
数据库慢查询的定义很清晰:执行时间超过某个阈值(比如100ms)的SQL语句。
AI服务的"慢查询"定义更复杂,因为正常的大模型调用本身就需要几秒。我建议从两个维度来定义:
绝对慢:调用时间超过SLA要求。比如你承诺用户5秒内给出第一个Token,如果超过5秒,就是绝对慢。
相对慢:同类调用中明显比平均水平慢。比如同样类型的对话请求,P50是2秒,P99是15秒,那P99的那些就是相对慢查询——虽然15秒在大模型领域不算离谱,但它比正常情况慢了7倍,值得找原因。
构建AI慢查询日志系统
第一步:记录每次大模型调用的关键指标。
@Aspect
@Component
@Slf4j
public class LLMCallAuditAspect {
private final MeterRegistry meterRegistry;
private final SlowCallRepository slowCallRepo;
// 定义"慢"的阈值(来自配置,可动态调整)
@Value("${ai.slow-call.first-token-threshold-ms:5000}")
private long firstTokenThresholdMs;
@Value("${ai.slow-call.total-threshold-ms:30000}")
private long totalThresholdMs;
@Around("execution(* org.springframework.ai.chat.ChatClient.call(..))")
public Object auditLLMCall(ProceedingJoinPoint joinPoint) throws Throwable {
LLMCallAuditRecord record = new LLMCallAuditRecord();
record.setCallId(UUID.randomUUID().toString());
record.setStartTime(Instant.now());
// 尝试获取请求信息
Object[] args = joinPoint.getArgs();
if (args.length > 0 && args[0] instanceof Prompt prompt) {
record.setPromptTokenEstimate(estimateTokens(prompt));
record.setPromptLength(getPromptTextLength(prompt));
record.setMessages(prompt.getInstructions().size());
}
Object result = null;
try {
result = joinPoint.proceed();
record.setSuccess(true);
if (result instanceof ChatResponse chatResponse) {
Usage usage = chatResponse.getMetadata().getUsage();
if (usage != null) {
record.setPromptTokens(usage.getPromptTokens());
record.setCompletionTokens(usage.getCompletionTokens());
}
}
return result;
} catch (Exception e) {
record.setSuccess(false);
record.setErrorType(e.getClass().getSimpleName());
record.setErrorMessage(e.getMessage());
throw e;
} finally {
record.setEndTime(Instant.now());
record.setDurationMs(Duration.between(record.getStartTime(), record.getEndTime()).toMillis());
// 异步记录(不影响主流程)
recordAsync(record);
}
}
private void recordAsync(LLMCallAuditRecord record) {
// 记录到指标
Timer.builder("ai.llm.call.duration")
.tag("success", String.valueOf(record.isSuccess()))
.tag("error.type", record.getErrorType() != null ? record.getErrorType() : "none")
.publishPercentiles(0.5, 0.90, 0.95, 0.99)
.register(meterRegistry)
.record(record.getDurationMs(), TimeUnit.MILLISECONDS);
// 如果是慢调用,持久化到数据库供分析
if (record.getDurationMs() > totalThresholdMs || !record.isSuccess()) {
slowCallRepo.saveAsync(record);
log.warn("AI慢调用记录: callId={}, duration={}ms, promptTokens={}, completionTokens={}, success={}",
record.getCallId(), record.getDurationMs(),
record.getPromptTokens(), record.getCompletionTokens(), record.isSuccess());
}
}
}大模型调用慢的七个原因
找到慢调用之后,要分析为什么慢。我总结了七个主要原因:
原因一:Prompt太长
这是最常见的原因,也是最容易被忽视的。
大模型处理时间和输入Token数量正相关。一个包含大量历史对话和RAG检索结果的Prompt,可能有10000+ Token,处理时间是简单Prompt的3-5倍。
诊断方式:关联慢调用记录和Prompt长度,看是否有相关性。
// 慢调用分析:按Prompt长度分组统计
@Query("""
SELECT
CASE
WHEN prompt_length < 1000 THEN '0-1K'
WHEN prompt_length < 3000 THEN '1K-3K'
WHEN prompt_length < 6000 THEN '3K-6K'
ELSE '6K+'
END as length_group,
COUNT(*) as call_count,
AVG(duration_ms) as avg_duration,
PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY duration_ms) as p99_duration
FROM llm_call_audit
WHERE start_time > NOW() - INTERVAL '24 hours'
GROUP BY length_group
ORDER BY length_group
""")
List<PromptLengthStats> analyzeByPromptLength();优化手段:
public class PromptOptimizer {
// 1. 对话历史截断:只保留最近N轮
public List<Message> truncateHistory(List<Message> history, int maxRounds) {
if (history.size() <= maxRounds * 2) return history;
// 保留系统消息(如果有)和最近N轮
List<Message> systemMessages = history.stream()
.filter(m -> MessageType.SYSTEM == m.getMessageType())
.collect(Collectors.toList());
List<Message> recent = history.subList(
Math.max(0, history.size() - maxRounds * 2),
history.size()
);
List<Message> result = new ArrayList<>(systemMessages);
result.addAll(recent);
return result;
}
// 2. RAG结果截断:每条检索结果限制长度
public List<String> truncateContextDocs(List<String> docs, int maxLengthPerDoc, int maxTotalLength) {
int totalLength = 0;
List<String> truncated = new ArrayList<>();
for (String doc : docs) {
if (totalLength >= maxTotalLength) break;
String truncatedDoc = doc.length() > maxLengthPerDoc
? doc.substring(0, maxLengthPerDoc) + "..."
: doc;
truncated.add(truncatedDoc);
totalLength += truncatedDoc.length();
}
return truncated;
}
// 3. 动态调整:根据历史平均延迟,自动控制Prompt长度
public int calculateMaxPromptTokens(String model, Duration targetTTFT) {
// 根据模型的处理速度估算最大Token数
// 这需要根据实际测量来校准
long targetMs = targetTTFT.toMillis();
// 经验值:每1000个Prompt Token增加约500ms TTFT(高负载时更多)
return (int)(targetMs / 0.5); // 粗略估算
}
}原因二:API限速(Rate Limiting)
大模型API都有速率限制(RPM/TPM),当你触发限速,请求会被排队或拒绝,等待时间可能很长。
诊断方式:检查HTTP响应头里的限速信息:
public class RateLimitDetector implements Interceptor {
@Override
public Response intercept(Chain chain) throws IOException {
long startTime = System.currentTimeMillis();
Response response = chain.proceed(chain.request());
long duration = System.currentTimeMillis() - startTime;
// OpenAI的限速响应头
String remainingRequests = response.header("x-ratelimit-remaining-requests");
String remainingTokens = response.header("x-ratelimit-remaining-tokens");
String resetRequests = response.header("x-ratelimit-reset-requests");
if (response.code() == 429) {
// 被限速
String retryAfter = response.header("Retry-After");
log.warn("触发API限速: retryAfter={}", retryAfter);
meterRegistry.counter("ai.rate.limit.hit").increment();
}
// 记录剩余配额(接近0时预警)
if (remainingRequests != null && Integer.parseInt(remainingRequests) < 10) {
log.warn("API请求配额即将耗尽: remaining={}", remainingRequests);
}
return response;
}
}优化手段:
@Service
public class RateLimitAwareClient {
// 令牌桶限流:主动控制发送速率,不超过API限制
private final RateLimiter requestLimiter;
private final RateLimiter tokenLimiter;
public RateLimitAwareClient() {
// 假设API限制:60 RPM,90000 TPM
this.requestLimiter = RateLimiter.create(1.0); // 每秒1个请求(保守)
this.tokenLimiter = RateLimiter.create(1500.0); // 每秒1500个Token
}
public String call(String prompt) {
int estimatedTokens = estimateTokens(prompt);
// 先等请求令牌
requestLimiter.acquire();
// 再等Token令牌(根据Prompt长度)
tokenLimiter.acquire(estimatedTokens);
return rawClient.call(prompt);
}
}原因三:模型服务器过载
大模型API的服务器在高峰期也会过载,响应时间随之增加。这是外部因素,你控制不了,但可以检测并做降级。
诊断方式:对比不同时间段的延迟,看是否有规律性的高峰。
// 记录每小时的平均延迟,发现规律
@Scheduled(cron = "0 0 * * * *") // 每小时
public void analyzeHourlyPattern() {
List<HourlyStats> stats = slowCallRepo.getHourlyStats(LocalDate.now());
// 找出延迟最高的时段
stats.stream()
.max(Comparator.comparing(HourlyStats::getP99Duration))
.ifPresent(peak ->
log.info("今日延迟峰值时段: hour={}, p99={}ms",
peak.getHour(), peak.getP99Duration()));
}优化手段:在已知高峰时段,提前缓存常见请求的结果;或者把批量任务调度到非高峰时段。
原因四:网络问题
应用服务器到大模型API之间的网络RTT也是延迟的一部分。如果你在国内服务器调用OpenAI,光网络延迟就有150-300ms,一次请求来回可能高达几十次交互(SSL握手+HTTP请求+流式接收)。
优化手段:在离大模型API近的地方部署应用(比如在美国VPC),或者使用API Gateway做中转。
另外,HTTP/2相比HTTP/1.1能复用连接,减少连接建立的开销:
// 优先使用HTTP/2
OkHttpClient client = new OkHttpClient.Builder()
.protocols(List.of(Protocol.HTTP_2, Protocol.HTTP_1_1))
.build();原因五:Embedding计算慢
RAG场景里,Embedding计算是高频操作。如果用远程API做Embedding,网络延迟会累加。
诊断方式:单独统计Embedding调用的延迟,与大模型调用的延迟分开看。
@Component
public class EmbeddingLatencyTracker {
private final Timer localEmbeddingTimer;
private final Timer apiEmbeddingTimer;
public float[] embedWithTracking(String text, EmbeddingMode mode) {
return switch (mode) {
case LOCAL -> localEmbeddingTimer.record(() -> localModel.embed(text));
case API -> apiEmbeddingTimer.record(() -> apiModel.embed(text));
};
}
}优化手段:对于高频Embedding操作,换用本地小模型(如text2vec-base-chinese)。虽然质量略低,但延迟可以从100ms降到5ms以内。
原因六:向量检索慢
向量数据库的检索性能受向量数量、索引类型、资源配置影响。
诊断方式:
@Around("execution(* *.*.vectorStore.similaritySearch(..))")
public Object auditVectorSearch(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
long duration = System.currentTimeMillis() - start;
if (duration > 100) {
log.warn("向量检索慢查询: duration={}ms, args={}",
duration, joinPoint.getArgs());
}
meterRegistry.timer("ai.vector.search.duration")
.record(duration, TimeUnit.MILLISECONDS);
return result;
}优化手段:
- 检查向量数据库的索引类型(HNSW通常比暴力检索快10-100x)
- 减少topK(从10减到5)
- 增加预过滤(先用元数据过滤,减少向量比较量)
- 对热点查询结果做缓存
原因七:冷启动问题
AI服务的冷启动延迟往往很大:大模型客户端初始化、连接池建立、第一次JIT编译都会导致前几个请求特别慢。
诊断方式:比较服务启动后前5分钟和稳定运行后的延迟分布。
优化手段:
@Component
@Slf4j
public class AIServiceWarmup implements ApplicationRunner {
private final ChatClient chatClient;
private final EmbeddingModel embeddingModel;
private final VectorStore vectorStore;
@Override
public void run(ApplicationArguments args) {
log.info("开始AI服务预热...");
long startTime = System.currentTimeMillis();
try {
// 1. 预热大模型客户端(建立连接、触发JIT)
for (int i = 0; i < 3; i++) {
chatClient.call(new Prompt("你好"));
}
// 2. 预热Embedding模型
for (int i = 0; i < 5; i++) {
embeddingModel.embed(List.of("预热文本" + i));
}
// 3. 预热向量检索
vectorStore.similaritySearch(SearchRequest.query("预热查询").withTopK(1));
long duration = System.currentTimeMillis() - startTime;
log.info("AI服务预热完成, 耗时={}ms", duration);
} catch (Exception e) {
// 预热失败不影响服务启动
log.warn("AI服务预热失败(不影响正常使用)", e);
}
}
}慢查询的系统化分析流程
把上面的诊断方法组合成一个完整的分析流程:
慢查询优化的优先级排序
不是所有慢调用都需要优化,要按影响力排序:
@Service
public class SlowCallPrioritizer {
public List<OptimizationCandidate> analyze(List<SlowCallRecord> records) {
// 计算每个"慢查询模式"的优化价值
return records.stream()
.collect(Collectors.groupingBy(this::getCallPattern))
.entrySet().stream()
.map(entry -> {
String pattern = entry.getKey();
List<SlowCallRecord> group = entry.getValue();
// 影响分 = 调用次数 × 平均超时时间
double impactScore = group.size() *
group.stream().mapToLong(SlowCallRecord::getDurationMs).average().orElse(0);
return new OptimizationCandidate(
pattern,
group.size(),
group.stream().mapToLong(SlowCallRecord::getDurationMs)
.average().orElse(0),
impactScore
);
})
.sorted(Comparator.comparing(OptimizationCandidate::getImpactScore).reversed())
.collect(Collectors.toList());
}
private String getCallPattern(SlowCallRecord record) {
// 根据功能模块和Prompt长度区间分组
String lengthGroup = switch ((int)(record.getPromptTokens() / 1000)) {
case 0 -> "0-1K";
case 1, 2 -> "1K-3K";
case 3, 4, 5 -> "3K-6K";
default -> "6K+";
};
return record.getFeature() + "|" + lengthGroup;
}
}一个优化案例:从30秒优化到8秒
最后分享一个真实的优化经历。
我们有一个"智能文档问答"功能,用户上传文档后可以提问。问题是:对于长文档,响应时间经常超过30秒,用户等到绝望。
通过慢查询分析,发现问题链:
- 用户上传200页PDF → 切分成400个chunks
- 每次提问,用用户问题做向量检索,topK=20(取了太多)
- 20个chunks的全文拼接成Context,约8000 Token
- 再加上历史对话,Prompt总长约12000 Token
- GPT-4处理12000 Token,TTFT约20秒
优化方案:
// 优化前
SearchRequest.query(userQuestion).withTopK(20) // 20个chunk
// 优化后
SearchRequest.query(userQuestion)
.withTopK(5) // 只取最相关的5个
.withSimilarityThreshold(0.7) // 设相似度阈值,过滤不相关的同时:
- 每个chunk限制500字(原来不限制)
- 历史对话只保留最近3轮(原来全保留)
- 对相同问题做Semantic Cache(问题相似度>0.95时直接返回缓存)
优化结果:
- P50延迟:23s → 6s
- P99延迟:38s → 12s
- Token消耗:减少了约60%
- API成本:每月降低约40%
总结
AI服务的慢查询分析,核心思路和数据库慢查询一样:记录、分类、找规律、优化、验证。
不同的是,AI服务的慢查询影响因素更多,优化手段也更多样化:Prompt压缩、模型降级、Embedding本地化、向量检索优化、语义缓存……每一个手段都能在特定场景下带来显著收益。
关键是要有数据。没有完整的调用记录和延迟统计,所有优化都是盲目的。先把监控建起来,再谈优化。
