AI Inference Engineering: 자세한 설명과 예제

이 글에는 짧은 영상이 있습니다:TikTok

1. 개요

채팅봇 API를 호출하고 화면에 응답이 천천히 흘러나오는 것을 기다릴 때, 당신은 Large Language Model (LLM)의 inference 과정을 목격하고 있습니다. 이 글은 inference가 training과 어떻게 다른지, 왜 그렇게 많은 리소스를 소모하는지, 그리고 엔지니어들이 이를 더 빠르고 저렴하게 만들기 위해 사용하는 기법들을 설명합니다: KV cache, continuous batching, quantization, speculative decoding. 읽은 후, 당신은 벤치마크의 TTFT/TPOT 수치를 이해할 수 있고, 실제 inference 엔드포인트의 성능을 측정하는 Java 코드를 작성할 수 있을 것입니다.

주의: 이 글은 transformer의 내부 구조(attention, feed-forward…)를 깊이 있게 다루지 않습니다. 초점은 인프라 엔지니어링 관점입니다 — 어떻게 request가 inference 시스템을 “통과”하는지를 봅니다.

2. Inference란 무엇인가?

Training은 데이터로부터 가중치(weights)를 학습하는 과정입니다 — 수천 개의 GPU로 몇 주 동안 진행되지만, 단 한 번만 합니다. Inference는 model이 이미 학습을 마친 후, 프롬프트를 입력하고 출력을 받는 과정입니다 — 이것은 매 request마다 발생하며, production에서는 하루에 수백만 개의 request가 있습니다. 따라서 training이 한 번의 비용으로는 “비싸”지만, inference가 장기적인 운영 비용과 사용자 경험(latency)을 결정하는 것입니다.

중요한 차이점은: training에서 model은 전체 문장을 한 번에 보고 loss를 계산합니다. Inference에서 텍스트 생성할 때, model은 한 번에 한 토큰씩 생성해야 하고, 다음 토큰은 이전 토큰에 의존합니다 — 이를 autoregressive decoding이라고 합니다.

2.1. 두 가지 단계: Prefill과 Decode

하나의 inference request는 항상 두 단계를 거칩니다:

  • Prefill: model이 전체 입력 프롬프트를 한 번에 읽고, 모든 입력 토큰에 대해 attention을 계산합니다. 이 단계는 병렬 처리가 가능하므로 GPU를 잘 활용합니다(compute-bound).
  • Decode: model이 출력 토큰을 하나씩 순차적으로 생성하며, 각 단계에서 이전의 모든 context를 기반으로 새로운 토큰 하나를 계산합니다(memory-bound — 메모리 대역폭 읽기가 병목, 계산이 아님).

간단한 Java 루프로 decode의 순차적 특성을 보여드리겠습니다(실제 model이 아닌 시뮬레이션):

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); // 한 번의 forward pass
            if (nextToken.equals("<EOS>")) break;

            context.add(nextToken);   // 새 토큰이 다음 단계의 입력이 됨
            generated.add(nextToken);
        }
        return generated;
    }

    private String predictNextToken(List<String> context) {
        // 시뮬레이션: 실제 model은 전체 transformer를 통해 forward pass를 실행
        return "token_" + context.size();
    }
}

predictNextToken을 호출할 때마다, model은 이미 있는 모든 context를 읽고 attention을 계산해야 합니다 — 최적화되지 않으면, 비용이 문장 길이에 따라 증가합니다. 이것이 바로 섹션 4의 KV cache가 나온 이유입니다.

3. Inference 성능 측정: TTFT와 TPOT

LLM serving을 벤치마킹할 때 가장 중요한 두 가지 지표:

  • TTFT (Time To First Token): request를 보낸 후 첫 번째 토큰을 받을 때까지의 시간 — prefill 단계의 지연을 반영합니다.
  • TPOT (Time Per Output Token): decode 단계에서 각 토큰을 생성하는 데 걸리는 평균 시간입니다.
public record InferenceMetrics(Duration ttft, Duration avgTpot, int totalTokens) {

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

totalLatency() 함수는 실제 공식을 보여줍니다: 총 시간 ≈ TTFT (한 번) + TPOT × (남은 토큰 수). 200 토큰 응답에서 TPOT가 10ms/토큰만 느려도 추가로 2초가 걸립니다 — 이것이 serving 벤치마크에서 TPOT가 TTFT보다 더 자세히 검토되는 이유입니다.

실제 수치 예제 (7B 모델, batch size 1, GPU A100):
TTFT  ≈ 80ms
TPOT  ≈ 15ms/token
200 tokens → 80ms + 15ms × 199 ≈ 3.07초

4. KV Cache: 이미 알고 있는 것을 다시 계산하지 않기

Cache가 없으면, decode의 매 단계마다 전체 토큰 시퀀스부터 다시 attention을 계산해야 합니다 — 복잡도가 길이의 제곱으로 증가합니다. KV cache는 이미 처리한 토큰의 key/value 벡터를 저장하여, 다음 단계에서 새 토큰에 대해서만 계산하고 나머지 cache를 재사용할 수 있도록 합니다.

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는 새로 생성된 각 토큰마다 한 번씩 호출되고, allKeysUpTo는 다음 decode 단계가 처음부터 forward pass를 다시 실행할 필요 없이 계산된 모든 key를 가져올 수 있게 합니다. 이것이 긴 request(깊은 context)가 VRAM을 빠르게 소모하는 이유입니다 — cache는 batch_size × sequence_length × num_layers × hidden_size에 비례합니다.

주의: KV cache는 긴 context window(32K, 128K 토큰)가 엄청난 RAM을 소모하는 주된 원인입니다, 계산이 아니라. 이것이 paged attention(vLLM에서 사용)처럼 cache를 가상 메모리처럼 관리하는 기법이 나온 이유입니다 — fragmentation을 피하기 위해.

5. Continuous batching: request를 모아서 처리량 증가시키기

GPU가 batch로 처리하면 개별 request보다 효율적입니다. 하지만 각 request는 생성할 토큰 수가 다릅니다 — request A는 50 토큰에서 끝나고, request B는 300 토큰까지 계속됩니다. Static batching(batch 전체가 끝날 때까지 기다림)은 가장 느린 request 때문에 GPU를 낭비합니다. Continuous batching은 이를 해결합니다: batch 내 request가 완료되자마자, 그 “슬롯”을 대기 중인 새 request로 채우고, 전체 batch를 기다리지 않습니다.

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()); // 즉시 새 request로 채우기
            } else {
                req.decodeNextToken();
            }
        }
    }
}

onStep()은 decode의 각 단계마다 실행됩니다: 빈 슬롯이나 방금 끝난 request가 있으면, 대기 중인 waiting queue에서 즉시 새 request를 가져옵니다. 덕분에 GPU는 항상 가득 찬 batch를 실행하고, 느린 request를 기다리는 “유휴” 슬롯이 없습니다 — 이것이 vLLM, TGI(Text Generation Inference), TensorRT-LLM의 핵심 메커니즘입니다.

6. Quantization: 정확도와 속도의 트레이드오프

원본 model은 보통 FP16 또는 BF16(16비트)으로 가중치를 저장합니다. Quantization은 비트 수를 줄여(INT8, INT4) VRAM 사용량을 줄이고 처리량을 높이지만, 정확도는 약간 감소합니다.

7B 모델 파라미터, 용량 비교:
- FP16: ~14 GB VRAM
- INT8: ~7 GB VRAM
- INT4: ~3.5 GB VRAM

vLLM 같은 engine으로 serving할 때, 단지 설정을 선언하면 됩니다:

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

quantization: awq는 AWQ(Activation-aware Weight Quantization) 방법을 지정합니다 — 단순 quantization보다 정확도를 더 잘 유지하기 위해 가장 중요한 가중치를 보존하는 INT4 기법입니다.

주의: 너무 깊은 quantization(INT4 이하)은 model의 “환각”(hallucination)을 증가시키거나 reasoning 능력을 감소시킬 수 있습니다. Quantization 후에는 항상 출력 품질을 다시 벤치마크하세요(속도만이 아니라), 특히 코드 생성이나 계산처럼 정확도가 중요한 작업에서는 더욱 그렇습니다.

7. Speculative decoding: 미리 추측해서 단계 절약하기

순차적 decode는 느립니다. 매 단계마다 토큰 1개씩만 생성하니까요. Speculative decoding은 작은, 빠른 모델(“draft model”)을 사용해서 다음 몇 토큰을 미리 추측하고, 큰 모델(“target model”)이 그 토큰들을 한 번의 forward pass로 검증하도록 하여 여러 번의 단계를 건너뜁니다.

Draft model이 추측: "하노이" "는" "베트남" "의" "수도"
Target model이 5개 토큰을 한 forward pass에서 검증:
  - 5개 중 4개가 target model의 분포와 일치 → 4개 토큰을 유지하고, 5번째는 버리고 거기서 다시 decode.
  - 처음부터 틀림 → 일반 decode처럼 1 forward pass 비용만 지불, 손실 없음.

이 기법은 출력 품질을 감소시키지 않습니다(항상 target model로 검증하므로), 단지 큰 model에서 순차적으로 실행해야 하는 forward pass 횟수를 줄입니다 — draft model이 많이 맞힐 때 가장 효과적입니다.

8. 실전: LLM serving endpoint에서 TTFT/TPOT 측정하기

http://localhost:8000에서 OpenAI 호환 API를 제공하는 vLLM 서버가 있다고 가정합시다. WebClient 의존성을 추가합니다:

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

Streaming 응답으로 TTFT를 측정하는 client를 작성합니다:

@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());
    }
}

doOnNext는 새 청크(토큰)가 올 때마다 호출됩니다 — 첫 호출에서 firstTokenTime을 기록하는 것이 TTFT입니다. Stream이 끝난 후(blockLast), 총 시간에서 TTFT를 빼고 남은 토큰 수로 나누어 TPOT 평균을 계산합니다.

8.1. JUnit 5와 AssertJ로 테스트하기

@SpringBootTest
class LlmBenchmarkClientTest {

    @Autowired
    LlmBenchmarkClient client;

    @Test
    void shouldMeetLatencySla() {
        InferenceMetrics metrics = client.measure("KV cache를 2문장으로 설명해주세요.");

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

이 테스트는 응답 내용을 검사하지 않고 latency SLA만 확인합니다 — “inference engineering”의 정확한 초점입니다: 시스템이 충분히 빠르게 응답하도록 보장하는 것, model 품질을 평가하는 것이 아닙니다(그것은 다른 범위).

주의: 이 테스트는 실제 LLM 서버가 실행 중이어야 합니다(MockWebServer로 WebClient를 mock할 수도 있음). CI에서는 이런 종류의 테스트를 integration test로 분리하고 일반 unit test와 함께 실행하지 마세요. 외부 인프라에 의존하기 때문입니다.

9. 흔한 실수와 언제 inference engine을 직접 만들면 안 되는지

  • 처음부터 batching/KV cache를 직접 작성하기: ML 인프라 연구를 깊이 있게 하는 것이 아니라면, vLLM, TGI, TensorRT-LLM이 이미 최적화한 것을 다시 만들지 마세요 — 수개월을 들여도 그들의 성능을 따라잡을 수 없을 것입니다.
  • 처리량으로만 벤치마킹하고 latency tail을 무시하기: 처리량이 높아도 p99 latency가 나쁘면 사용자 경험이 좋지 않습니다. 평균뿐 아니라 p50/p95/p99를 항상 측정하세요.
  • 품질 테스트 없이 quantize하기: 섹션 6에서 언급했듯이, 비트 감소는 항상 트레이드오프가 있습니다 — 속도를 측정하면서 품질을 무시하는 것은 흔한 실수입니다.
  • 변동성이 높은 실제 트래픽에 static batching 사용하기: request의 출력 길이가 크게 다르면, static batching은 GPU를 심각하게 낭비합니다 — 처음부터 continuous batching을 지원하는 engine을 선택하세요.
  • Prefill-heavy와 decode-heavy 워크로드를 분리하지 않기: 일부 production 시스템은 prefill 처리 노드와 decode 처리 노드를 따로 분리합니다(disaggregated serving) — 당신의 트래픽이 명확한 패턴을 보인다면(예: RAG로 프롬프트는 길고 출력은 짧음), 이 아키텍처를 고려할 가치가 있습니다.

10. 결론

Inference engineering은 latency, 처리량, GPU 비용 사이의 최적화 문제입니다 — model 학습이 아닙니다. Prefill/decode, KV cache, continuous batching, quantization, speculative decoding을 이해하면 벤치마크를 읽을 수 있고, 올바른 serving engine(vLLM, TGI…)을 선택할 수 있으며, 올바른 지표(TTFT, TPOT)로 당신의 시스템을 측정할 수 있습니다 — “빠르다”/“느리다”가 아니라요. 다음 단계는 당신의 실제 워크로드에서 config의 quantization이나 gpu_memory_utilization을 바꿔보고 이 지표들이 어떻게 변하는지 관찰하는 것입니다.

llminferenceperformancespring-bootjava