AI 推理工程:详细解释与示例

本文有一个短视频:TikTok

1. 概览

当你调用某个聊天机器人的 API 并等待回复逐个显示在屏幕上时,你正在见证 Large Language Model(LLM)的 推理 过程。本文解释推理与训练的区别、为什么它会消耗大量资源,以及工程师用来让它更快、更便宜的技术:KV cache、continuous batching、quantization、speculative decoding。读完这篇文章后,你将理解基准测试中的 TTFT/TPOT 数字,以及如何编写 Java 代码来测量实际推理端点的性能。

注意: 本文不深入讨论 transformer 的内部(attention、feed-forward 等)。重点是基础设施视角——一个请求如何通过推理系统“运行”。

2. 推理是什么?

训练是从数据中学习权重(weights)的过程——需要数千个 GPU 花费数周时间,但只做一次。推理是模型已学完后,你输入 prompt 并获得输出的过程——这在每次有请求时发生,生产环境中每天有数百万个请求。因此,虽然训练在一次性成本上更“昂贵”,但推理才是决定长期运营成本和用户体验(延迟)的因素。

重要的区别在于:在训练中,模型一次看到整个句子来计算损失。在推理文本生成中,模型必须逐个生成 token,后面的 token 依赖于前面的 token——这被称为 autoregressive decoding。

2.1. 两个阶段:Prefill 和 Decode

一个推理请求总是经历 2 个阶段:

  • Prefill:模型一次性读取整个输入 prompt,为所有输入 token 计算注意力。这个阶段充分利用 GPU,因为处理是并行的(compute-bound)。
  • Decode:模型逐个生成输出 token,每次一个,基于之前的所有上下文。这个阶段按顺序运行,每一步只计算 1 个新 token(memory-bound——受内存带宽限制,而不是计算能力)。

用简单的 Java 循环来说明 decode 的顺序特性(模拟,不是真实模型):

public class AutoregressiveDecoder {

    public List<String> generate(List<String> prompt, int maxNewTokens) {
        List<String> context = new ArrayList<>(prompt);
        List<String> generated = new ArrayList<>();

        for (int step = 0; step < maxNewTokens; step++) {
            String nextToken = predictNextToken(context); // 1 次前向传递
            if (nextToken.equals("<EOS>")) break;

            context.add(nextToken);   // 新 token 成为下一步的输入
            generated.add(nextToken);
        }
        return generated;
    }

    private String predictNextToken(List<String> context) {
        // 模拟:真实模型运行前向传递遍历整个 transformer
        return "token_" + context.size();
    }
}

每次调用 predictNextToken 时,模型都必须重新读取所有现有的上下文来计算注意力——如果不优化,成本会随着句子长度而增加。这正是第 4 部分中 KV cache 出现的动力。

3. 测量推理性能:TTFT 和 TPOT

对 LLM serving 进行基准测试时的两个最重要指标:

  • TTFT(Time To First Token):从发送请求到收到第一个 token 的时间——反映 prefill 阶段的延迟。
  • TPOT(Time Per Output Token):在 decode 阶段生成每个后续 token 的平均时间。
public record InferenceMetrics(Duration ttft, Duration avgTpot, int totalTokens) {

    public Duration totalLatency() {
        return ttft.plus(avgTpot.multipliedBy(totalTokens - 1));
    }
}

totalLatency() 函数展示了实际公式:总时间 ≈ TTFT(1 次)+ TPOT × (剩余 token 数)。对于 200 个 token 的响应,TPOT 如果只是 10ms/token 慢也会增加 2 秒的总时间——这就是为什么 TPOT 在 serving 基准测试中比 TTFT 受到更多关注。

实际数据示例(7B 模型,batch size 1,GPU A100):
TTFT  ≈ 80ms
TPOT  ≈ 15ms/token
200 token → 80ms + 15ms × 199 ≈ 3.07s

4. KV Cache:避免重新计算已知的内容

如果不缓存,每个 decode 步骤都必须为整个 token 序列重新计算注意力——复杂度按长度的平方增加。KV cache 保存已处理 token 的 key/value 向量,以便下一步只需为新 token 计算,然后重用旧缓存。

public class KvCache {

    private final Map<Integer, float[]> keyCache = new HashMap<>();
    private final Map<Integer, float[]> valueCache = new HashMap<>();

    public void store(int position, float[] key, float[] value) {
        keyCache.put(position, key);
        valueCache.put(position, value);
    }

    public List<float[]> allKeysUpTo(int position) {
        return IntStream.rangeClosed(0, position)
            .mapToObj(keyCache::get)
            .toList();
    }
}

store 为每个新生成的 token 调用一次;allKeysUpTo 允许下一个 decode 步骤检索所有已计算的 key,无需从头再做前向传递。这就是为什么长请求(长上下文)会快速消耗 VRAM——此缓存与 batch_size × sequence_length × num_layers × hidden_size 成正比。

注意: KV cache 是导致长上下文窗口(32K、128K token)消耗大量 GPU RAM 的主要原因,而不是计算。这也是为什么 paged attention 等技术(在 vLLM 中使用)出现的原因——像虚拟内存(分页)一样管理缓存以避免碎片。

5. Continuous batching:聚合请求以提高吞吐量

GPU 按批处理会比逐个处理请求更高效。但每个请求的生成长度不同——请求 A 在 50 个 token 处完成,请求 B 继续到 300 个 token。Static batching(等待整个批处理完成才接收新批)会浪费 GPU,因为必须等最慢的请求。Continuous batching 解决方案是:当批中的一个请求完成时,它的“槽位”立即被队列中的新请求替换,无需等待整个批。

public class ContinuousBatchScheduler {

    private final int batchSlots;
    private final Queue<InferenceRequest> waiting = new LinkedList<>();
    private final List<InferenceRequest> active;

    public ContinuousBatchScheduler(int batchSlots) {
        this.batchSlots = batchSlots;
        this.active = new ArrayList<>(Collections.nCopies(batchSlots, null));
    }

    public void onStep() {
        for (int slot = 0; slot < batchSlots; slot++) {
            InferenceRequest req = active.get(slot);
            if (req == null || req.isFinished()) {
                active.set(slot, waiting.poll()); // 立即填充新请求
            } else {
                req.decodeNextToken();
            }
        }
    }
}

onStep() 在每个 decode 步骤运行:如果任何槽位为空或请求刚完成,它立即从 waiting 获取下一个等待请求。这样,GPU 始终运行完整批,没有“空闲”槽位等待慢请求——这是 vLLM、TGI(Text Generation Inference)和 TensorRT-LLM 的核心机制。

6. Quantization:牺牲精度换取速度

原始模型通常以 FP16 或 BF16(16 位)存储权重。Quantization 减少存储位数(INT8、INT4)来减少 VRAM 占用并增加吞吐量,代价是精度略微下降。

7B 参数模型,容量比较:
- FP16: ~14 GB VRAM
- INT8: ~7 GB VRAM
- INT4: ~3.5 GB VRAM

使用 vLLM 等引擎进行服务时,只需声明配置:

model: meta-llama/Llama-3-8B
quantization: awq
dtype: float16
gpu_memory_utilization: 0.9

quantization: awq 指定使用 AWQ(Activation-aware Weight Quantization)方法——一种 INT4 技术,通过优先保留最重要的权重,保持比“朴素”量化更好的精度。

注意: 过度量化(INT4 及以下)可能导致模型产生更多“幻觉”(hallucination)或推理能力下降。量化后始终要重新基准测试输出质量(不仅仅是速度),特别是对于需要高精度的任务,如代码生成或数学计算。

7. Speculative decoding:预测以节省步骤

顺序解码很慢,因为每一步只生成 1 个 token。Speculative decoding 使用一个小的、快速的“draft model”来预测接下来的几个 token,然后大模型“target model”只需在一次前向传递中验证这些 token,而不是多次。

Draft model 预测:"河" "内" "是" "首" "都"
Target model 在 1 次前向传递中同时验证全部 5 个 token:
  - 如果 4/5 token 与大模型的分布匹配 → 保留 4 个 token,丢弃第 5 个,从那里开始重新解码。
  - 如果从一开始就错了 → 只消耗 1 次前向传递,和普通解码一样,没有浪费。

这种技术不会降低输出质量(因为始终用 target model 验证),只是减少必须在大模型上顺序运行的前向传递次数——当 draft model 预测准确时效果最好。

8. 实践:从 LLM serving 端点测量 TTFT/TPOT

假设你有一个 vLLM 服务器在 http://localhost:8000 运行 OpenAI 兼容的 API。添加 WebClient 依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

编写一个用流式响应测量 TTFT 的客户端:

@Service
public class LlmBenchmarkClient {

    private final WebClient webClient;

    public LlmBenchmarkClient(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("http://localhost:8000").build();
    }

    public InferenceMetrics measure(String prompt) {
        Instant start = Instant.now();
        AtomicReference<Instant> firstTokenTime = new AtomicReference<>();
        AtomicInteger tokenCount = new AtomicInteger();

        webClient.post()
            .uri("/v1/completions")
            .bodyValue(Map.of("prompt", prompt, "stream", true, "max_tokens", 100))
            .retrieve()
            .bodyToFlux(String.class)
            .doOnNext(chunk -> {
                if (firstTokenTime.get() == null) {
                    firstTokenTime.set(Instant.now());
                }
                tokenCount.incrementAndGet();
            })
            .blockLast();

        Duration ttft = Duration.between(start, firstTokenTime.get());
        Duration total = Duration.between(start, Instant.now());
        Duration avgTpot = tokenCount.get() > 1
            ? total.minus(ttft).dividedBy(tokenCount.get() - 1)
            : Duration.ZERO;

        return new InferenceMetrics(ttft, avgTpot, tokenCount.get());
    }
}

每当一个新的 chunk(token)到达时,doOnNext 被调用——第一次调用记录 firstTokenTime 时刻就是 TTFT。在流结束后(blockLast),我们通过总时间减去 TTFT,再除以剩余 token 数来计算平均 TPOT。

8.1. 使用 JUnit 5 和 AssertJ 进行测试

@SpringBootTest
class LlmBenchmarkClientTest {

    @Autowired
    LlmBenchmarkClient client;

    @Test
    void shouldMeetLatencySla() {
        InferenceMetrics metrics = client.measure("在 2 句话内解释 KV cache。");

        assertThat(metrics.ttft()).isLessThan(Duration.ofMillis(500));
        assertThat(metrics.avgTpot()).isLessThan(Duration.ofMillis(50));
        assertThat(metrics.totalTokens()).isGreaterThan(0);
    }
}

这个测试不检查回复的内容,只检查延迟 SLA——这正是“推理工程”的重点:确保系统响应足够快,而不是评估模型质量(那是质量评估,超出了本文的范围)。

注意: 这个测试需要一个实际运行的 LLM 服务器(或用 MockWebServer mock WebClient)。在 CI 中,应该将这类测试分离为集成测试,不要与常规单元测试一起运行,因为它依赖外部基础设施。

9. 常见错误和何时不应自建推理引擎

  • 从头重写 batching/KV cache:除非你在做深度 ML 基础设施研究,否则不要尝试重新构建 vLLM、TGI、TensorRT-LLM 已经精心优化的东西——你需要花数月时间才能达到他们现有的性能。

  • 仅用吞吐量进行基准测试,忽视尾部延迟(tail latency):高吞吐量但 p99 延迟差仍会导致用户体验糟糕。始终测量 p50/p95/p99,而不仅仅是平均值。

  • 量化后不重新测试质量:如第 6 部分所述,减少位数总是有代价的——测量速度而忽视质量测量是常见错误。

  • 对于实际变化的流量使用 static batching:如果请求的输出长度差异很大,static batching 会严重浪费 GPU——应该从一开始就选择支持 continuous batching 的引擎。

  • 不区分 prefill-heavy 和 decode-heavy 工作负载:一些生产系统分离处理 prefill 和 decode 的节点(分离服务),以优化资源——如果你的流量有明确的模式(例如 RAG 提示很长,输出很短),值得考虑这种架构。

10. 结论

推理工程是在延迟、吞吐量和 GPU 成本之间的优化问题——不是关于训练模型的。掌握 prefill/decode、KV cache、continuous batching、quantization 和 speculative decoding 将帮助你理解基准测试,选择正确的服务引擎(vLLM、TGI 等),以及用正确的指标(TTFT、TPOT)测量你的系统,而不仅仅说“它快还是慢”。下一步自然是尝试在真实配置中改变 quantization 或 gpu_memory_utilization,观察这些指标在你自己的工作负载上如何变化。

llminferenceperformancespring-bootjava