음성 AI 에이전트 — 듣고, 찾고, 말하는 파이프라인
입을 여는 시각 — SSE 스트리밍·첫 토큰 지연·KV 캐시
한 줄 요약
음성 비서가 입을 여는 시각은 LLM 이 답을 다 쓴 시각이 아니라 첫 문장을 끝낸 시각이다. 그래서 응답을 한 덩어리로 받지 않고 토큰이 나오는 대로 받는다(SSE 스트리밍). 첫 토큰까지의 시간(TTFT)은 대부분 프롬프트를 계산하는 시간(prefill) 이고 프롬프트 길이에 비례한다. 같은 앞부분을 다시 보내면 서버가 KV 캐시를 재사용해 그 시간을 거의 없앤다 — 단, 맨 앞 한 글자만 바뀌어도 뒤 전부를 다시 계산한다.
왜 이게 필요했나
LabHub 회화 연습의 한 턴은 ASR 0.4초 + 모델 0.6초 + TTS 2.3초 ≈ 3.3초다(2026-09-23 실측, backend/app/lang_talk.py). 모델 답을 다 받은 뒤 TTS 로 넘기는 직렬 구조라, 답이 길어지면 그대로 늘어난다 — 그래서 그 파일은 답을 문장 수로 묶고 넘치면 코드에서 자른다("모델이 신나서 다섯 문장을 쓰면 12초"). 스트리밍으로 받으면 첫 문장이 끝나는 순간 TTS 를 시작할 수 있다. 이 모듈은 그 '첫 문장' 이 언제 오는지, 무엇이 그것을 늦추는지를 잰다.
어떻게 동작하나
llama-server 와 SSE. llama.cpp 의 llama-server 는 OpenAI 와 같은 모양의 /v1/chat/completions 를 연다. "stream": true 로 보내면 응답이 Server-Sent Events 로 온다 — data: {JSON} 줄과 빈 줄의 반복이고, 조각마다 choices[0].delta.content 에 새 글자가 있으며, 마지막은 data: [DONE] 이다. 첫 조각은 대개 역할(role)만 있고 내용이 비어 있다. 그걸 첫 토큰으로 세면 TTFT 가 실제보다 짧게 잡힌다. 마지막 조각에는 서버가 잰 timings(prompt_n · prompt_ms · predicted_n …)가 붙는다.
TTFT = 대기 + prefill + 첫 토큰 하나. 모델은 먼저 프롬프트 전체를 한 번에 계산해 각 층의 K·V 를 만들고(prefill), 그다음 한 토큰씩 쓴다(decode). prefill 은 프롬프트 토큰 수에 비례한다. 이 이미지의 Qwen2.5-0.5B-Instruct Q4_0 을 CPU 2코어 파드에 올리면(nuc1 실측) 35 토큰짜리 프롬프트의 첫 토큰이 127 ms, 258 토큰이면 801 ms 였고, 이후 생성은 초당 약 50 토큰이었다. RAG 로 문서를 몇 개 붙이는 순간 첫 토큰이 몇 배 늦어진다는 뜻이다.
KV 캐시와 앞부분 일치. 서버는 슬롯(대화 자리)마다 직전 요청의 K·V 를 들고 있다. 새 요청의 앞부분이 그것과 같으면 같은 만큼 건너뛰고 새 토큰만 계산한다(cache_prompt). 같은 258 토큰 프롬프트를 두 번째 보내면 prompt_n 이 1 이 되고 첫 토큰이 24 ms 로 줄었다(nuc1 실측). 그런데 비교는 앞에서부터다. 시스템 프롬프트 맨 앞에 '지금 시각: 14:05' 같은 바뀌는 줄을 두면 매 요청이 처음부터 다시 계산된다. 바뀌는 것은 뒤에, 고정된 것은 앞에 둔다.
이 이미지의 서버는 --cache-ram 0 으로 띄운다. 최신 llama-server 는 지난 프롬프트들의 KV 를 호스트 메모리에 모아 두었다가 비슷한 요청이 오면 되살리는데, 기본 상한이 8,192 MiB 라 파드 한도(2 GiB)를 넘을 수 있고, 켜 두면 슬롯을 비운 뒤에도 예전 프롬프트가 되살아나 캐시 실험이 흐려진다(실측: 처음 보낸 프롬프트도 prompt_n 이 1). 운영에서는 대화가 여럿일 때 쓸모가 있는 기능이다.
첫 문장과 max_tokens. 음성에서는 긴 답이 곧 긴 대기다. 첫 문장이 끝나는 시각 — 마침표·물음표 뒤에 공백이 오는 순간 — 을 따로 재고, max_tokens 로 답의 상한을 둔다. 공백까지 기다리는 이유는 '3.5' 나 'a.m.' 에서 자르지 않기 위해서다(모듈 7 에서 더 다룬다).
끼어들기 = 연결 끊기. 사용자가 끼어들면 남은 생성은 버릴 일이다. 스트림을 읽던 HTTP 연결을 닫으면 llama-server 는 그 작업을 취소한다(로그에 cancel task). 읽기만 멈추고 연결을 붙잡고 있으면 서버는 끝까지 생성하며 CPU 를 쓴다 — 2코어를 TTS 와 나눠 쓰는 파드에서는 다음 턴이 그만큼 느려진다.
현장에서 만나는 모습
작은 모델은 지시를 잘 안 지킨다. 이 이미지의 0.5B 모델에게 '정확히 세 문장으로' 라고 해도 한 문장이나 번호 목록으로 답하는 일이 흔했다. 그래서 운영하는 쪽은 모델 말만 믿지 않고 길이를 코드로 묶는다 — max_tokens, 문장 수 자르기, 첫 문장을 짧게 쪼개 먼저 읽기(모듈 7). LabHub 회화 연습이 문장 수를 지시문에 못박고 넘치면 코드에서 자르는 것도 같은 이유다.
다음 실습에서 할 것
voice-llm up 으로 서버를 켜고 /health·/props 를 저장한다. SSE 를 한 줄씩 읽는 sse.py 를 만들어 조각의 시각을 기록하고, 캐시를 끈 채 첫 토큰 지연을 다섯 번 잰다. 문서 0·4·12개를 붙여 prefill 을 재고, 캐시가 앞부분을 재사용하는 모습과 맨 앞 한 줄이 캐시를 깨는 모습을 본다. 첫 문장이 끝나는 시각을 재고, 0.5초 만에 연결을 끊어 서버가 멈추는지 /slots 와 로그로 확인한다.