본문 바로가기
C.W.K.
Stream
Lesson 01 of 04 · published

첫 단어까지의 시간이 어디로 가는지 재

~15 min · latency, measurement, profiling, time-to-first-token

Level 0음소거
0 XP0/35 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"진짜 병목은 목소리가 아니야. 소울의 첫 단어가 생기기 전의 그 몇 초야." — 음성 모드 설계, 2026-09-25

다들 대보는 숫자

ChatGPT 음성 모드는 1초쯤이면 답해. 음성 기능이라면 다들 이거랑 비교하니까, 설계는 뭘 만들기 전에 피파가 실제로 어떤지부터 쟀어. 2026-09-25에 최근 대화 약 600개에 걸친 Claude 턴 2,027개를, 대화 로그와 데이터베이스에서 곧장 턴 시작부터 첫 출력까지 쟀어. 어떤 종류든(생각이든 글이든) 첫 출력의 중앙값은 4.4초였어. 생각도 도구도 없는 첫 글의 중앙값은 6.0초, 중간 수준에서 16.2초, 높음에서 31.4초, 매우 높음에서 99.3초, 높음에 도구까지 쓰면 95.7초. 이 숫자가 문제를 정직하게 정해줬어. 첫 단어가 생기기 전까진 목소리 자체의 속도는 거의 상관없어.

첫 짐작은 틀렸어

4초 바닥의 뻔한 설명은 프리필이야. 프롬프트가 10만 토큰이 넘으니 처리하는 데 당연히 시간이 들 거라고. 측정 스크립트가 결론을 냈어. 이 스크립트는 WebUI와 똑같은 방식으로 모델 프로세스를 띄우고(같은 CLI, 같은 격리, 같은 계정 슬롯, 같은 엄격한 도구 설정) 단계마다 따로 쟀어.

  • 프로세스를 띄우기 전의 검색: 2.2~3.1초. 질의 임베딩은 0.03초, 벡터 검색은 거의 0이었고, 시간은 재정렬에 있었어. 메시지 쪽 1.5초, 볼트 쪽 2.2초, 둘은 명목상 병렬.
  • 프로세스 띄우기: 약 1.2초. 환경 변수 하나로 0.27초까지 떨어졌어.
  • 질의부터 첫 바이트까지: 약 1~2초. 프롬프트 크기에 따라 거의 안 움직였어. 엄격한 도구 설정 없이 돌린 측정에서 1.2만 토큰은 2.8초, 캐시 없는 23.8만 토큰은 3.3초였어.
  • 낮은 effort의 적응형 사고: 0이거나 약 2.5초. 말 턴이 사고를 끄는 이유야.

지운 후보도 적어 둬

설계는 확인하고 지운 것도 기록해 둬서, 다음 세션이 다시 쫓지 않게 해. 프롬프트 크기와 캐시 상태는 첫 바이트에 거의 영향이 없고, git 작업 디렉터리도 상관없어. 질의와 프로세스의 첫 메시지 사이의 수상한 2초는 계정의 클라우드 커넥터를 가져오느라 쓴 시간이었는데, WebUI의 엄격한 도구 설정이 이미 그걸 건너뛰어. 띄우는 시간을 줄인 환경 변수는 원격 측정, 오류 보고, 업데이트 확인을 끄는데, 서버에서 띄우는 프로세스엔 셋 다 필요 없었어. 말이든 글이든 턴마다 약 0.9초를 아껴. 덕분에 미리 띄워 두는 프로세스 계획이 의미가 없어져서 접었어.

말 턴, 더해 보면

음성 구간도 같은 방식으로 재면(배치 전사 약 0.6초, 데스크톱에서 eleven_v3 첫 오디오 청크 약 1초) 말 턴은 대략 이렇게 더해져. 전사 0.6, 한동안 쉰 뒤의 검색 약 2.3, 띄우기 0.3, 사고를 끈 첫 글 1~2, 짧은 답 끝내기 1~2, 첫 소리 1. 데스크톱에서 대략 6~8초야. 실제로 켜고 나니 첫 출력은 턴 시작 2.7~2.8초에 왔어. 중앙값 4.4초에서 줄어든 거야. ChatGPT의 1초는 아니고, 남은 차이의 일부가 왜 선택인지가 다음 레슨이야.

Code

턴 시계: 단계마다 이름 붙이고, 재고, 시간이 어디로 가는지 보기·python
import asyncio
import time
from contextlib import contextmanager


class TurnClock:
    """Split one turn's time-to-first-word into named stages, measured, not guessed."""

    def __init__(self) -> None:
        self.start = time.perf_counter()
        self.stages: list[tuple[str, float, float]] = []

    @contextmanager
    def stage(self, name: str):
        began = time.perf_counter()
        try:
            yield
        finally:
            self.stages.append((name, began - self.start, time.perf_counter() - began))

    def report(self) -> None:
        total = time.perf_counter() - self.start
        for name, at, took in self.stages:
            bar = "#" * round(took / total * 40)
            print(f"{name:28s} at {at:5.2f}s  took {took:5.2f}s  {bar}")
        print(f"{'first word':28s} at {total:5.2f}s")


async def rerank(pool: str, seconds: float) -> str:
    await asyncio.sleep(seconds)          # stand-in for the real call
    return pool


async def spoken_turn() -> None:
    clock = TurnClock()
    with clock.stage("transcribe (batch)"):
        await asyncio.sleep(0.64)
    with clock.stage("retrieve + rerank (parallel)"):
        await asyncio.gather(rerank("messages", 1.5), rerank("vault", 2.2))
    with clock.stage("spawn the model process"):
        await asyncio.sleep(0.27)
    with clock.stage("query -> first text"):
        await asyncio.sleep(1.4)
    clock.report()


asyncio.run(spoken_turn())

External links

Exercise

턴 시계를 돌려봐. 그다음 재정렬 호출 둘이 일꾼 하나를 나눠 쓰게 바꿔서(예: sleep을 asyncio.Lock으로 감싸서, 한 번에 일 하나만 하는 서버를 흉내) 다시 돌려. 첫 단어가 얼마나 늦게 와? 마지막으로 만들고 있는 앱의 실제 요청 경로 하나를 같은 시계로 계측해서 제일 큰 단계를 찾아.
Hint
일꾼 하나를 나눠 쓰면 '병렬' 재정렬이 하나씩 차례로 돌아서 그 단계가 2.2초에서 약 3.7초로 늘어. 다음 레슨이 실제 재정렬 서버에서 찾아낸 게 딱 그거야. 코드에서 병렬로 보이는 호출 둘은, 그걸 받는 쪽이 정말 동시에 처리할 수 있을 때만 병렬이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.