LabHub
시작하기
배우기 러닝패스 코스

음성 AI 에이전트 — 듣고, 찾고, 말하는 파이프라인

첫 토큰은 언제 오나 — SSE 를 손으로 읽고 prefill·캐시·취소를 잰다

LabHub 에서 이어서 보기

목표

파드 안의 작은 LLM 서버에서 스트리밍 응답을 직접 읽어 첫 토큰 지연·prefill·KV 캐시·첫 문장 시각을 재고, 연결을 끊어 생성을 멈추게 한다.

왜 중요한가

음성 비서는 첫 문장이 끝나는 순간 말을 시작할 수 있다. 그 시각을 늦추는 가장 큰 몫이 프롬프트 계산(prefill)이고, 그것을 줄이는 가장 싼 방법이 KV 캐시다. 둘 다 숫자로 봐야 프롬프트를 어떻게 짜야 하는지 판단할 수 있다. 채점기는 LLM 서버에 묻지 않고 여러분이 저장한 원시 SSE·측정 JSON·서버 로그만 읽으며, 기계마다 다른 절대 시간 대신 같은 실행 안의 관계(첫 토큰 < 전체, 긴 프롬프트 > 짧은 프롬프트, 캐시 적중 < 캐시 없음)를 봅니다.

단계

  1. voice-llm up 으로 서버를 켜고 /health 를 /root/voice/llm/health.json, /props 를 /root/voice/llm/props.json 에 저장하세요.
  2. SSE 를 한 줄씩 읽는 /root/voice/llm/sse.py 를 만들어, 받은 줄을 그대로 /root/voice/llm/sse_raw.txt 에, 내용 조각을 시각과 함께 /root/voice/llm/chunks.jsonl 에 저장하세요.
  3. cache_prompt: false 로 같은 질문을 다섯 번 보내 첫 토큰·전체 시간·토큰 수를 /root/voice/llm/ttft.json 에 적으세요.
  4. KB 문서 0 · 4 · 12개를 시스템 프롬프트에 붙여(캐시 끔) prompt_n 과 첫 토큰 시각을 /root/voice/llm/prefill.json 에 적으세요.
  5. 캐시를 켜고 같은 긴 프롬프트를 두 번, 맨 앞에 시각 한 줄을 붙여 한 번 더 보내 /root/voice/llm/cache.json 에 적으세요.
  6. 세 문장 답을 스트리밍으로 받아 첫 문장이 끝난 시각을 /root/voice/llm/sentence.json 에 적으세요.
  7. 긴 답을 요청한 뒤 0.5초 만에 연결을 끊고, 서버가 멈추기까지의 시간을 /root/voice/llm/cancel.json 에 적으세요.
  8. 측정을 모은 /root/voice/llm/report.json 을 만드세요.

참고

서버를 켜고 무엇이 떴는지 본다

voice-llm up 으로 LLM 서버를 켠 뒤 curl -s localhost:8080/health 를 /root/voice/llm/health.json, curl -s localhost:8080/props 를 /root/voice/llm/props.json 에 저장하세요.

/health 가 {"status":"ok"} 면 모델을 다 읽은 것입니다. /props 에는 모델 경로, 문맥 길이(default_generation_settings.n_ctx), 슬롯 수(total_slots)가 있습니다.

SSE 를 한 줄씩 읽는다

/root/voice/llm/sse.py 에 stream(messages, max_tokens=64, cache_prompt=True, raw_out=None) 을 만들어 /v1/chat/completions 에 stream: true 로 보내고, 받은 줄을 raw_out 에 그대로 쓰며, 내용이 있는 조각마다 {"t_ms": 요청 직전부터의 ms, "text": 조각} 을 모아 (조각 목록, 전체 ms, timings) 를 돌려주게 하세요. 시스템 프롬프트 'You are a clinic phone assistant. Answer in one short sentence.' 와 질문 'What should a new patient bring?' 로 한 번 돌려 /root/voice/llm/sse_raw.txt 와 /root/voice/llm/chunks.jsonl 을 만드세요.

줄이 'data: ' 로 시작하면 뒤를 JSON 으로 풉니다. 'data: [DONE]' 이 끝입니다. choices[0].delta.content 가 비어 있는 조각(첫 조각의 role)은 건너뜁니다. 시각은 time.perf_counter() 로 잽니다.

첫 토큰 지연을 다섯 번 잰다

02 의 질문을 cache_prompt=False 로 다섯 번 보내 회마다 ttft_ms(첫 내용 조각 시각)·total_ms·tokens(timings 의 predicted_n)를 runs 에, 두 시간의 중앙값을 ttft_p50·total_p50 에 적은 /root/voice/llm/ttft.json 을 만드세요.

캐시를 끄면 매번 프롬프트 전체를 다시 계산합니다. 중앙값은 정렬한 다섯 값의 세 번째입니다. 첫 회는 서버가 막 떠서 조금 느릴 수 있습니다 — 그래서 한 번이 아니라 여러 번 잽니다.

프롬프트가 길면 첫 토큰이 늦다

/opt/lab/fixtures/voice/kb/*.md 를 이름순으로 읽어 앞에서 0 · 4 · 12개를 시스템 프롬프트에 붙이고, 질문 'When is the clinic open on Saturday?' 를 cache_prompt=False, max_tokens=32 로 보내 {"docs": n, "prompt_n": …, "prompt_ms": …, "ttft_ms": …} 셋을 /root/voice/llm/prefill.json 배열로 적으세요.

prompt_n·prompt_ms 는 마지막 조각의 timings 에 있습니다. 토큰이 늘수록 첫 토큰이 거의 비례해 늦어지는 것을 보세요. 캐시가 켜져 있으면 앞 요청과 겹치는 앞부분이 빠져 숫자가 흐려집니다.

KV 캐시 — 같은 앞부분은 건너뛴다

KB 문서 전부를 붙인 긴 시스템 프롬프트로, 먼저 짧은 다른 요청('hi')을 한 번 보내 슬롯을 비운 뒤 cache_prompt=True 로 두 번(first·second) 보내고, 시스템 프롬프트 맨 앞에 'Current time: 14:05. ' 를 붙여 한 번 더(changed_prefix) 보내 각각의 prompt_n·ttft_ms 를 /root/voice/llm/cache.json 에 적으세요.

서버는 슬롯에 남은 직전 요청의 K·V 와 새 요청을 앞에서부터 비교해 같은 만큼 건너뜁니다. 맨 앞 한 줄이 다르면 첫 토큰부터 다르니 아무것도 재사용하지 못합니다. 바뀌는 줄을 맨 뒤에 붙이면 어떻게 될지도 생각해 보세요.

첫 문장은 언제 끝나나

시스템 프롬프트 'You are a clinic phone assistant. Answer in exactly three short sentences.' 와 질문 'How do I prepare for a fasting blood test?' 를 max_tokens=120 으로 스트리밍하며, 이어 붙인 글에서 처음으로 [.!?] 뒤에 공백이 온 조각의 시각을 first_sentence_ms, 그 문장을 first_sentence 로 적은 /root/voice/llm/sentence.json(ttft_ms·first_sentence_ms·total_ms·first_sentence·text)을 만드세요. 끝까지 공백이 안 오면 전체가 한 문장입니다.

마침표만 보고 자르면 '3.5' 나 'a.m.' 에서 틀립니다. 부호 뒤에 공백이 붙어 온 순간이 '문장이 끝났다' 는 더 안전한 신호입니다. 작은 모델은 '세 문장' 지시를 자주 어깁니다 — 그래서 길이를 코드로 다룹니다.

끼어들면 생성을 멈춘다

'List the numbers from 1 to 300, separated by commas.' 를 max_tokens=256 으로 스트리밍하다가 0.5초 만에 연결을 닫고, 그때까지 받은 내용 조각 수를 received_tokens, 닫은 뒤 /slots 의 is_processing 이 false 가 되기까지의 ms 를 slot_idle_after_ms 로 /root/voice/llm/cancel.json(max_tokens·received_tokens·closed_at_ms·slot_idle_after_ms)에 적으세요.

http.client 연결의 close() 가 곧 '그만' 입니다. 서버 로그(voice-llm log)에 'cancel task' 가 찍히면 서버가 알아들은 것입니다. 길게 말할 것이 확실한 요청을 고르세요 — 끊기 전에 생성이 끝나면 시험이 되지 않습니다.

보고서

/root/voice/llm/report.json 에 ttft_p50_ms·total_p50_ms(ttft.json), ttft_12docs_ms·prompt_n_12docs(prefill.json 마지막), cache_ttft_ms(cache.json 의 second), first_sentence_ms(sentence.json), cancel_idle_ms(cancel.json 의 slot_idle_after_ms)를 옮겨 적으세요.

앞 단계 파일을 읽어 옮깁니다. 숫자를 나란히 두면 어디를 줄여야 입을 빨리 여는지 보입니다.