사이드 프로젝트로 “자기 전에 말 걸어주는 AI”를 만들고 있다. 핵심은 보이스 에이전트 — 사용자가 말하면 AI가 음성으로 응답하는 실시간 파이프라인이다. LiveKit Agents SDK로 이걸 세팅한 과정을 기록한다.


음성 파이프라인의 구조

실시간 음성 대화는 세 단계를 거친다:

사용자 음성 → [STT] → 텍스트 → [LLM] → 응답 텍스트 → [TTS] → AI 음성

각 단계에 서로 다른 모델이 필요하고, 이걸 WebRTC로 실시간 연결해야 한다. 직접 구현하면 끝이 없다. LiveKit Agents SDK가 이 파이프라인을 추상화해준다.


프로젝트 구조

agent/
├── src/agent/
│   ├── __init__.py
│   ├── main.py          # 엔트리포인트
│   └── worker.py        # 음성 파이프라인 + 프롬프트
├── pyproject.toml        # 의존성 (uv)
├── Dockerfile
└── .python-version       # 3.12

패키지 매니저는 uv를 썼다. pip 대비 10~100배 빠르다. pyproject.toml 하나로 의존성과 빌드 설정을 관리한다.


의존성

[project]
requires-python = ">=3.12"
dependencies = [
    "livekit-agents[silero,turn-detector]>=1.4.5",
    "livekit-plugins-openai>=1.0.0",
    "livekit-plugins-elevenlabs>=1.0.0",
    "python-dotenv>=1.2.2",
]

[project.scripts]
agent = "agent.main:main"

livekit-agents가 코어고, 나머지는 플러그인이다.

  • silero — VAD(Voice Activity Detection). 사용자가 말하고 있는지 침묵인지 감지
  • turn-detector — 발화 턴 감지. 사용자가 말을 끝냈는지 판단
  • livekit-plugins-openai — STT(Whisper)와 LLM(GPT) 연결
  • livekit-plugins-elevenlabs — TTS 연결

[project.scripts]agent라는 CLI 커맨드를 등록했다. uv run agent dev로 개발 모드 실행, uv run agent start로 프로덕션 실행.


엔트리포인트

# main.py
from dotenv import load_dotenv
from .worker import run

def main() -> None:
    load_dotenv()
    run()

load_dotenv().env 파일에서 API 키를 로드한다. 필요한 키는 네 개:

LIVEKIT_API_KEY=...
LIVEKIT_API_SECRET=...
OPENAI_API_KEY=...
ELEVENLABS_API_KEY=...

LiveKit Cloud에서 프로젝트를 만들면 API key/secret을 발급받을 수 있다. OpenAI와 ElevenLabs도 각각 API 키가 필요하다.


Worker: 음성 파이프라인의 본체

여기가 핵심이다. 전체 코드가 70줄 안쪽이다.

# worker.py
from livekit import agents
from livekit.agents import AgentSession, Agent, RoomInputOptions
from livekit.plugins import openai, silero, elevenlabs
from livekit.plugins.turn_detector.multilingual import MultilingualModel

SYSTEM_PROMPT = """\
너는 '토닥'이라는 이름의 수면 전 대화 친구야.

규칙:
- 짧고 부드럽게 말해. 한 번에 1~2문장.
- 조언, 훈계, 분석하지 마.
- 감정을 수용하고 공감해.
- 질문은 한 번에 하나만.
- 한국어로 대화해.

지금은 사용자가 자기 전이야. 편안하고 다정하게 대해줘.
"""

SESSION_GREETING = "오늘 하루는 어땠어?"

시스템 프롬프트에서 가장 중요한 규칙은 **“조언하지 마”**다. LLM은 기본적으로 문제를 해결하려 한다. 수면 에이전트에서 원하는 건 해결이 아니라 수용이다. 이 한 줄이 없으면 에이전트가 “그런 상황에서는 이렇게 해보는 건 어떨까요?”라고 코칭을 시작한다.

Agent 클래스

class TodakAgent(Agent):
    def __init__(self) -> None:
        super().__init__(instructions=SYSTEM_PROMPT)

livekit.agents.Agent를 상속받는다. instructions에 시스템 프롬프트를 넘기면 끝. 나중에 대화 중 상태 변화(공감 → 수면 유도 전환)를 넣으려면 이 클래스에 메서드를 추가하면 된다.

세션 설정: STT + LLM + TTS + VAD

async def entrypoint(ctx: agents.JobContext) -> None:
    await ctx.connect()

    participant = await ctx.wait_for_participant()

    session = AgentSession(
        stt=openai.STT(
            model="gpt-4o-transcribe",
            language="ko",
        ),
        llm=openai.LLM(
            model="gpt-5-mini",
            reasoning_effort="low",
        ),
        tts=elevenlabs.TTS(
            voice_id="hWXqitL3DEOLD49pgNWR",
            model="eleven_flash_v2_5",
            voice_settings=elevenlabs.VoiceSettings(
                stability=0.5,
                similarity_boost=0.5,
                speed=1.0,
            ),
            language="ko",
        ),
        vad=silero.VAD.load(),
        turn_detection=MultilingualModel(),
    )

각 컴포넌트의 선택 이유:

STT — OpenAI gpt-4o-transcribe

한국어 실시간 인식이 필요했다. Deepgram도 고려했는데, OpenAI Whisper 계열이 한국어 정확도에서 앞섰다. language="ko"를 명시해야 인식률이 올라간다 — 안 하면 영어로 인식하려는 경향이 있다.

LLM — gpt-5-mini (reasoning_effort=“low”)

수면 전 대화에 복잡한 추론은 필요 없다. “그랬구나, 힘들었겠다” 수준이면 충분하다. reasoning_effort="low"로 설정하면 응답 레이턴시가 줄어든다. 사용자가 말을 끝내고 2초 이상 침묵이 흐르면 “AI랑 대화하고 있다”는 느낌이 깨지기 때문에, 속도가 품질보다 중요하다.

TTS — ElevenLabs eleven_flash_v2_5

이 프로젝트에서 목소리는 타협할 수 없는 부분이다. Google TTS는 저렴하지만 딱딱하다. ElevenLabs는 호흡, 톤, 속도를 세밀하게 제어할 수 있다.

  • stability=0.5 — 너무 높으면 로봇 같고, 너무 낮으면 불안정하다
  • similarity_boost=0.5 — 원본 목소리와의 유사도
  • model="eleven_flash_v2_5" — 음질을 약간 양보하는 대신 레이턴시를 줄인 모델

VAD — Silero

Voice Activity Detection. 사용자가 말하고 있는지, 침묵인지를 실시간으로 감지한다. 이게 없으면 에이전트가 사용자 발화 중간에 끼어든다.

Turn Detection — MultilingualModel

사용자가 말을 끝냈는지 판단한다. VAD와 다르다 — VAD는 “소리가 있다/없다”이고, turn detection은 “이 사람이 말을 마쳤다”를 판단한다. 한국어는 문장 끝에 “~요”, “~다” 같은 어미가 있어서 영어보다 턴 감지가 까다롭다. MultilingualModel이 이걸 처리해준다.

세션 시작과 첫 인사

    await session.start(
        room=ctx.room,
        agent=TodakAgent(),
        room_input_options=RoomInputOptions(),
    )

    await session.generate_reply(
        instructions=SESSION_GREETING,
    )

session.start()가 호출되면 에이전트가 LiveKit room에 참여한다. 그다음 generate_reply()로 첫 인사를 보낸다 — “오늘 하루는 어땠어?”

이 시점부터 에이전트가 자동으로 사용자 음성을 듣고, STT → LLM → TTS 파이프라인을 타서 응답한다. 별도의 루프를 짤 필요가 없다.

Worker 등록

def run() -> None:
    agents.cli.run_app(
        agents.WorkerOptions(entrypoint_fnc=entrypoint)
    )

agents.cli.run_app()이 LiveKit Cloud와의 연결, room 할당, 워커 관리를 전부 해준다. entrypoint 함수만 넘기면 된다.


Docker 세팅

ARG PYTHON_VERSION=3.12
FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-bookworm-slim AS base

WORKDIR /app

COPY pyproject.toml uv.lock ./
RUN mkdir -p src
RUN uv sync --locked --no-install-project

COPY . .
RUN uv sync --locked

RUN uv run agent download-files

CMD ["uv", "run", "agent", "start"]

주의할 점:

  • uv의 공식 Docker 이미지(ghcr.io/astral-sh/uv)를 베이스로 쓴다
  • uv run agent download-files가 중요하다. Silero VAD 모델 파일을 미리 다운로드한다. 이걸 빌드 타임에 안 하면 런타임에 다운로드를 시도하고, 네트워크 환경에 따라 실패할 수 있다
  • gcc, g++, python3-dev가 필요하다 — 일부 의존성이 C 확장을 빌드한다

Docker Compose에서는 이렇게 연결한다:

agent:
  build:
    context: ./agent
    dockerfile: Dockerfile
  environment:
    LIVEKIT_URL: ${LIVEKIT_URL}
    LIVEKIT_API_KEY: ${LIVEKIT_API_KEY}
    LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET}
    OPENAI_API_KEY: ${OPENAI_API_KEY}
    ELEVEN_API_KEY: ${ELEVEN_API_KEY}
  command: ["uv", "run", "agent", "dev"]

개발 시에는 dev 모드, 배포 시에는 start 모드로 실행한다. dev 모드는 코드 변경 시 자동 재시작을 해준다.


삽질 기록

1. VAD 모델 다운로드

처음에 Dockerfile에서 download-files를 안 했더니 컨테이너 시작할 때마다 300MB짜리 모델을 다운로드했다. 빌드 타임에 한 번만 받아두면 된다.

2. 한국어 STT language 설정

language="ko"를 안 넣으면 Whisper가 영어로 인식하려고 한다. “안녕하세요”를 “annyeonghaseyo”로 로마자 변환해버리는 경우도 있었다. 명시적으로 한국어를 지정해야 한다.

3. ElevenLabs voice_id

ElevenLabs에서 목소리를 고를 때 웹 콘솔에서 미리 들어봐야 한다. voice_id는 콘솔에서 목소리를 선택하면 URL에 표시된다. 한국어에 맞는 목소리를 찾는 게 은근 시간이 걸린다 — 영어에서 자연스러운 목소리가 한국어에서는 어색할 수 있다.

4. turn_detection 없이 하면

VAD만 있고 turn detection이 없으면, 사용자가 0.5초만 쉬어도 에이전트가 응답을 시작한다. “오늘… (0.5초 쉼) 회사에서…”라고 말하는 중간에 끼어드는 거다. MultilingualModel을 넣으면 문장이 끝났는지를 문맥으로 판단해준다.


다음 단계

현재는 STT → LLM → TTS 1턴 왕복이 동작하는 상태다. 남은 건:

  • 대화 기억: 세션 종료 시 요약 생성 → 다음 세션 프롬프트에 주입
  • 디에스컬레이션: 대화 후반으로 갈수록 톤을 낮추고 말을 느리게
  • 안전 장치: 극단적 발언 감지 시 위기 상담 안내

이 파이프라인 위에 LangGraph를 얹어서 대화 상태를 관리할 예정이다. 그건 다음 글에서.