LLM 推理延迟优化——从 Prompt 裁剪到 Streaming 首 Token 加速
一、LLM 推理延迟的构成
大语言模型推理的延迟主要由三个部分构成:
- Prefill 阶段:模型处理 Prompt 的输入 Token,这一阶段的时间与 Prompt 长度基本呈线性关系。
- Decode 阶段:逐 Token 生成响应内容,每个 Token 的生成时间相对固定。
- 网络传输与排队:请求在 API Gateway 和推理服务端的排队等待时间。
对于终端用户而言,延迟的感知分为两个关键时刻:首 Token 延迟(Time to First Token,TTFT)和完整响应延迟(End to End Latency)。首 Token 延迟决定了"系统开始响应"的感知速度,对用户体验影响最大。
二、Prompt 裁剪——减少 Prefill 阶段的开销
Prompt 裁剪是降低首 Token 延迟的最有效手段之一。在实际的 Agent 系统中,Prompt 往往会因为上下文累积而不断膨胀,剔除冗余信息可以显著减少 Prefill 阶段的计算量。
/** * Prompt 裁剪器——根据预设的 Token 预算对 Prompt 进行智能裁剪。 * * 为什么需要智能裁剪而非简单截断: * 简单截断可能丢失最关键的上下文信息(如最近的对话轮次), * 智能裁剪需要根据信息的时效性和相关性进行优先级排序。 */ @Component public class PromptTrimmer { private final TokenCounter tokenCounter; // 为什么预算设为 6000 而非 8192: // 预留约 2000 Token 给响应输出,确保在达到 maxTokens 前 Prompt 已经处理完毕 private static final int DEFAULT_TOKEN_BUDGET = 6000; public PromptTrimmer(TokenCounter tokenCounter) { this.tokenCounter = tokenCounter; } /** * 按优先级裁剪 Prompt 消息列表。 * * 裁剪策略: * 1. System Prompt——永远保留(角色定义不可丢失) * 2. 最近的用户消息——永远保留(当前任务不可丢失) * 3. 工具调用结果——按 token 预算从新到旧保留 * 4. 历史对话——从中间开始移除(保留最早和最近的消息) */ public List<Message> trim(List<Message> messages, int maxTokens) { int budget = maxTokens > 0 ? maxTokens : DEFAULT_TOKEN_BUDGET; int currentTokens = tokenCounter.count(messages); if (currentTokens <= budget) { return messages; } List<Message> result = new ArrayList<>(); // 第1步:保留 System Message int systemTokens = 0; for (Message msg : messages) { if (msg.getRole() == MessageRole.SYSTEM) { result.add(msg); systemTokens = tokenCounter.count(msg); break; } } // 第2步:倒序收集最近的消息(含当前用户消息) // 为什么从后向前收集:最近的消息对当前任务最重要 List<Message> reversed = new ArrayList<>(); int consumedTokens = systemTokens; boolean userMessageFound = false; for (int i = messages.size() - 1; i >= 0; i--) { Message msg = messages.get(i); if (msg.getRole() == MessageRole.SYSTEM) { continue; // 已处理 } int msgTokens = tokenCounter.count(msg); // 最近一条 User Message 无条件保留 if (!userMessageFound && msg.getRole() == MessageRole.USER) { reversed.add(msg); consumedTokens += msgTokens; userMessageFound = true; continue; } if (consumedTokens + msgTokens > budget) { break; // 预算耗尽 } reversed.add(msg); consumedTokens += msgTokens; } // 第3步:翻转回正序 Collections.reverse(reversed); result.addAll(reversed); log.info("Prompt裁剪完成, originalTokens={}, trimmedTokens={}, " + "budget={}", currentTokens, consumedTokens, budget); return result; } }三、Streaming 首 Token 加速
Streaming(流式输出)是降低用户感知延迟的关键手段。但仅开启 Streaming 是不够的,还需要优化首 Token 到达时间。
/** * LLM 流式调用客户端——通过 SSE(Server-Sent Events)接收流式输出, * 并在首 Token 到达后立即返回给下游,减少用户等待感知。 * * 为什么使用 WebClient 而非 RestTemplate 做流式调用: * RestTemplate 是阻塞式 API,会等待完整响应再返回; * WebClient 的 Flux 支持异步流式处理,能够在首 Token 到达时立即消费。 */ @Service public class StreamingLlmClient { private static final Logger log = LoggerFactory.getLogger( StreamingLlmClient.class); private final WebClient webClient; private final MeterRegistry meterRegistry; public StreamingLlmClient( @Value("${llm.api.endpoint}") String apiEndpoint, @Value("${llm.api.key}") String apiKey, MeterRegistry meterRegistry) { this.webClient = WebClient.builder() .baseUrl(apiEndpoint) .defaultHeader("Authorization", "Bearer " + apiKey) .defaultHeader("Content-Type", "application/json") .build(); this.meterRegistry = meterRegistry; } /** * 发起流式推理请求,返回首 Token 到�流和完整响应的 Mono。 * * @param messages 对话消息列表 * @return StreamingResult 包含首 Token 到达时间和完整响应 */ public StreamingResult streamInference(List<Message> messages) { long requestStartTime = System.nanoTime(); AtomicLong firstTokenTime = new AtomicLong(0); AtomicBoolean firstTokenReceived = new AtomicBoolean(false); StringBuilder fullResponse = new StringBuilder(); Map<String, Object> requestBody = buildRequestBody(messages); Flux<String> tokenStream = webClient.post() .uri("/v1/chat/completions") .bodyValue(requestBody) .retrieve() .bodyToFlux(String.class) .doOnNext(chunk -> { if (!firstTokenReceived.getAndSet(true)) { firstTokenTime.set(System.nanoTime()); long ttftMs = TimeUnit.NANOSECONDS.toMillis( firstTokenTime.get() - requestStartTime); meterRegistry.timer("llm.ttft").record( Duration.ofMillis(ttftMs)); log.debug("首Token到达, ttftMs={}", ttftMs); } fullResponse.append(extractContent(chunk)); }) .doOnComplete(() -> { long totalMs = TimeUnit.NANOSECONDS.toMillis( System.nanoTime() - requestStartTime); meterRegistry.timer("llm.total.latency").record( Duration.ofMillis(totalMs)); log.info("流式推理完成, ttftMs={}, totalMs={}, tokens={}", TimeUnit.NANOSECONDS.toMillis( firstTokenTime.get() - requestStartTime), totalMs, fullResponse.length()); }) .doOnError(error -> { log.error("流式推理异常, 原因={}", error.getMessage()); meterRegistry.counter("llm.stream.error").increment(); }); return new StreamingResult(tokenStream, fullResponse, firstTokenTime, requestStartTime); } private Map<String, Object> buildRequestBody(List<Message> messages) { Map<String, Object> body = new LinkedHashMap<>(); body.put("model", "qwen-7b-chat"); body.put("messages", messages.stream() .map(this::toApiFormat) .collect(Collectors.toList())); body.put("stream", true); // 为什么 temperature 在流式场景下设为 0.1 而非 0.7: // 较低的 temperature 使输出更确定,减少不必要的 Token 生成, // 同时也降低了"闲聊式"的输出概率 body.put("temperature", 0.1); body.put("max_tokens", 2048); return body; } private String extractContent(String chunk) { // 解析 SSE 格式的 chunk data if (chunk == null || !chunk.startsWith("data: ")) { return ""; } String data = chunk.substring(6).trim(); if ("[DONE]".equals(data)) { return ""; } try { // 简化的 JSON 解析,实际项目中应使用 ObjectMapper int contentStart = data.indexOf("\"content\":\""); if (contentStart == -1) { return ""; } int contentEnd = data.indexOf("\"", contentStart + 11); if (contentEnd == -1) { return ""; } return data.substring(contentStart + 11, contentEnd); } catch (Exception e) { return ""; } } private Map<String, Object> toApiFormat(Message msg) { Map<String, Object> map = new LinkedHashMap<>(); map.put("role", msg.getRole().name().toLowerCase()); map.put("content", msg.getContent()); return map; } }四、首 Token 延迟的关键影响因素
graph LR A[请求发送] --> B{Prompt Token数} B -->|多| B1[Prefill时间长] B -->|少| B2[Prefill时间短] B1 --> C{网络传输} B2 --> C C --> D{GPU调度排队} D -->|排队| D1[等待其他请求] D -->|无排队| E[Prefill计算] D1 --> E E --> F[首Token输出] style B fill:#f96,stroke:#333 style D fill:#ff9,stroke:#333 style F fill:#6f6,stroke:#333首 Token 延迟的优化策略按优先级排序:
- Prompt 裁剪——减少 Prefill 阶段的输入 Token 数量(效果最直接)。
- Speculative Sampling——用小模型快速生成候选 Token,大模型验证加速(适用于自建推理服务)。
- KV Cache 预热——对于高频 Prompt,预计算并缓存 Key-Value 对。
- 模型量化(INT8/INT4)——减小模型体积,加速 GPU 加载。
- 减少 API 调用链路——将 LLM 部署在低延迟网络区域,减少 RTT。
五、总结
LLM 推理延迟优化是一个系统工程,需要从 Prompt 设计、模型部署、网络链路和工程实现四个维度协同推进。Prompt 裁剪是成本最低、见效最快的优化手段,应当优先实施。Streaming 输出和首 Token 加速则直接影响用户感知,在交互式产品中不可或缺。在建立基础的延迟监控体系后,可以进一步探索 Speculative Sampling 等更深入的优化方案。
需要注意的是,延迟优化往往与质量和成本之间存在权衡。例如过度的 Prompt 裁剪可能导致模型丢失上下文而产生错误回答,因此需要在生产环境中建立延迟与质量的联合监控。
Speculative Sampling 的工程实现
Speculative Sampling(推测采样)是降低 Decode 阶段延迟的有效技术——用小模型(Draft Model)快速生成多个候选 Token,再用大模型(Target Model)并行验证,对于验证通过的 Token 一次性采纳。在我们的内部测试中,使用 Qwen-1.8B 作为 Draft 模型配合 Qwen-7B 作为 Target 模型,在 Code Generation 场景下 Decode 速度从 25 tokens/s 提升到 48 tokens/s(1.9x 加速)。
工程实现的重点在:(1) Draft 模型和 Target 模型需要运行在同一 GPU 上,显存的额外消耗是 Draft 模型的权重(约 3.6GB);(2) Draft 模型每步生成 3~5 个候选 Token,验证通过率约 60%~80%(取决于任务类型,代码类最高、创意写作类最低);(3) 当验证拒绝率超过 40% 时,Draft 模型的 Token 生成反而成为额外开销,需要动态关闭 Speculative Sampling。实现方式是监控accepted_tokens / total_draft_tokens的比率,低于 0.6 时自动回退到常规 Decode。
KV Cache 的核心意义与量化压缩
Prefill 阶段最耗时的部分不是 Token 计算本身,而是将计算结果写入 KV Cache。KV Cache 的大小 =2 × 层数 × 隐藏维度 × Token数 × 精度位数。对于 Qwen-7B(32 层、4096 隐藏维度),每 1000 Token 的 KV Cache 约占用 1GB 显存。KV Cache 量化(如 FP16 → INT8)可将显存占用减半,代价是约 1% 的精度损失。对于显存已满但 GPU 利用率有余量的场景,KV Cache 量化比模型量化更安全(只影响 Attention 计算,不影响 FFN 计算),是首选优化方向。在生产部署中,建议将 TTFT(Time To First Token)、TPOT(Time Per Output Token)和总生成延迟三项指标分别监控,TTFT 优化重点在 Prompt 裁剪和 Prefill 加速,TPOT 优化重点在 Speculative Sampling 和 KV Cache 量化,总延迟则是两者的叠加效果。同时建议对不同类型的请求(短问答 vs 长文生成)分别设置延迟 SLO,短问答 TTFT < 500ms,长文生成 TTFT < 2s,避免用一个笼统的延迟指标掩盖了不同场景的真实体验差异。