Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

온프레미스 음성 AI 상담 시스템 개발기 (1) — 상담 런타임과 오디오 파이프라인 본문

외주 개발일지

온프레미스 음성 AI 상담 시스템 개발기 (1) — 상담 런타임과 오디오 파이프라인

HM소프트 2026. 6. 18. 01:09
반응형

온프레미스 음성 AI의 오디오 한 턴이 들어와 응답 음성으로 나가는 런타임을 다룬다. STT·LLM·TTS·RAG를 Protocol 계약으로 먼저 못 박고 mock을 기본 어댑터로 두어 GPU 없이 엔드투엔드를 돌렸다. 첫 음성 KPI 시뮬레이션, lazy import seam, 응급은 LLM을 우회하는 오케스트레이터까지 기록한다.

시리즈를 시작하며

전화로 걸려 오는 문의를 자연어로 알아듣고, 그 기관이 보유한 데이터에 근거해 적절한 안내를 하고, 예약 신청까지 이어 주는 음성 AI 상담 시스템을 만들고 있다. 한 의료기관의 상담 전화를 사람 대신 받아 1차 응대를 하는 제품이라고 보면 된다. 특이한 제약이 하나 붙는다 — STT(음성 인식)·LLM·TTS(음성 합성)·RAG(검색)가 전부 기관 내부 서버에서 동작해야 한다는 것. 상담 내용에는 개인정보와 민감 정보가 섞이기 때문에, 데이터가 외부 클라우드로 단 한 번도 나가지 않는 구조를 처음부터 전제로 깔았다.

세 편으로 나눠 기록한다.

  1. (이 글) 상담 런타임과 오디오 파이프라인 — 음성이 들어와 다시 음성으로 나가기까지
  2. 예약 tool-call과 DB 기반 RAG — AI가 멋대로 말하지 않게
  3. 관리자 콘솔과 보안 다지기 — 세션·역할·머신 키

특정 기관명·서비스명은 전부 빼고 구현만 다룬다. 이번 1편의 주제는 가장 아래층, 즉 오디오 한 턴이 들어와 응답 음성이 나가기까지의 런타임이다. 코드로 치면 runtime/ 패키지와, 그 위에서 흐름을 조립하는 오케스트레이터다.

왜 "계약"부터 못 박았는가

온프레미스 음성 AI에는 골치 아픈 현실이 있다. 핵심 엔진(STT·LLM·TTS·임베딩)이 전부 무겁고, 전부 GPU를 원하고, 전부 "지금 당장은 못 붙이는" 상태로 개발이 시작된다는 것이다. GPU 쿼터 승인은 늦어지고, 한국어 TTS 엔진 선정은 아직이고, 전화망 연동은 회선·장비가 필요하다. 이런 상황에서 "엔진 다 준비되면 시작하자"고 하면 영원히 시작 못 한다.

그래서 첫 커밋에서 한 일은 코드가 아니라 공통 계약(contract)을 정의하는 것이었다. 상담 런타임은 결국 "오디오 → 텍스트 → 응답 → 다시 음성"이라는 파이프라인인데, 각 단계의 엔진은 나중에 갈아 끼울 수 있어야 한다. 그래서 단계마다 입력과 출력을 Protocol로 먼저 못 박고, 실제 엔진은 그 계약을 만족하는 어댑터로 끼우기로 했다. 이게 runtime/interfaces.py다.

이 파일은 의도적으로 어떤 실엔진에도 의존하지 않는다. import faster_whisper도 없고 import openai도 없다. 순수한 타입(Transcript, AudioChunk, LLMResult, RAGDoc)과 Protocol뿐이다. 덕분에 이 모듈은 GPU도, 무거운 패키지도 없는 노트북에서 즉시 import된다. 실엔진은 STT는 faster-whisper, TTS는 온프레미스 한국어 엔진, LLM은 vLLM(OpenAI 호환), RAG는 pgvector로 가되, 그건 전부 "나중에 어댑터로" 끼우면 된다.

이 계약 하나로 작업을 3트랙 병렬로 쪼갤 수 있었다. ① 학습 파이프라인(과목별 LoRA 설정·평가셋), ② AI 두뇌(LLM·RAG), ③ 음성 런타임 — 세 트랙이 계약만 공유하면 서로를 기다릴 필요가 없다. 어떤 트랙이 외부 승인에 막혀도 나머지가 굴러간다. 이게 온프레미스 AI 제품 초기에 속도를 내는 거의 유일한 방법이라고 생각한다.

기본값이 전부 mock이라는 설계

계약을 정의한 다음 중요한 결정은 "기본 어댑터를 전부 mock으로 둔다"는 것이었다. 런타임을 조립하는 factory.py를 보면 의도가 분명하다.

아무 인자도 주지 않으면 mock STT·mock TTS·mock LLM·로컬 검색기로 조립된다. 즉 build_runtime() 한 줄이면 GPU도 외부 서버도 없이 엔드투엔드 파이프라인이 돈다. 실엔진으로 가고 싶으면 인자만 바꾼다. build_runtime(stt=FasterWhisperSTT(...), llm=VLLMClient(base_url="http://localhost:8000/v1", ...)) 식이다. 오케스트레이터 코드는 단 한 줄도 바뀌지 않는다.

이 mock-기본 전략이 실제로 가져다준 이득은 컸다. CI에서 매 커밋마다 오케스트레이터의 전체 흐름과 지연(KPI) 측정 로직을 검증하는데, mock이 결정적(deterministic)이라 같은 입력이면 같은 출력이 보장된다. 무거운 모델 없이도 "분기가 맞는가, 시간이 임계 안에 드는가"를 테스트로 못 박을 수 있다. mock은 임시변통이 아니라 테스트 가능성을 위한 1급 설계 요소다.

mock STT/TTS는 가짜가 아니라 "현실적인 가짜"여야 한다

mock을 대충 만들면 의미가 없다. 핵심은 현실적인 지연과 형태를 흉내 내는 것이다. 음성 AI에서 가장 중요한 KPI는 "첫 음성까지의 시간(time-to-first-speech)"이다. 발신자가 말을 끝내고 1초 안에 AI가 입을 떼야 통화가 자연스럽다. 그래서 mock TTS는 텍스트 길이에 비례한 가짜 PCM과, 현실적인 latency를 만들어 낸다.

여기 숨어 있는 건 운영 전략이다. 운영에서는 LLM이 응답을 다 만들 때까지 기다리면 첫 음성이 늦는다. 그래서 "네, 확인해 드릴게요" 같은 filler 문구를 사전 캐싱해 두고 첫 청크로 먼저 흘린 뒤, 뒤이어 LLM 실응답을 이어 붙이는 스트리밍 전략을 쓴다. mock TTS의 synthesize_stream은 이 전략의 시간 구조를 그대로 흉내 낸다 — 첫 청크는 빠르게(120ms), 후속 청크는 발화 길이만큼. 이렇게 해 두면 실엔진을 붙이기 전부터 "첫 음성 KPI를 만족하는가"를 측정할 수 있다.

실엔진 어댑터는 lazy import + seam

mock 옆에는 실엔진 어댑터가 나란히 있다. STT의 FasterWhisperSTT, TTS의 OnPremTTS다. 이 둘의 설계 원칙은 동일하다 — 무거운 의존성은 절대 import 시점에 로드하지 않는다. 미설치 환경(개발 노트북)에서도 모듈 import와 객체 생성은 통과시키고, 실제 추론을 시도하는 순간에만 친절한 설치 안내 에러를 낸다.

세 가지를 짚어 둘 만하다. 첫째, local_files_only=download_root is not None — 온프레미스 정책상 모델 가중치는 외부에서 받지 않고 사내망에서 사전 배포한다. 다운로드 경로가 지정되면 "이 경로의 모델만 쓰고 외부로 나가지 마라"가 강제된다. 둘째, warmup() — lazy 로드는 첫 호출이 느려진다는 함정이 있어서, 운영 부팅 시 미리 한 번 로드해 첫 발신자가 손해 보지 않게 했다. 셋째, transcribe 안에서 16-bit PCM bytes를 float32 [-1, 1]로 정규화하는 변환을 어댑터가 책임진다. 이렇게 엔진별 입력 포맷 차이를 어댑터가 흡수하므로, 오케스트레이터는 그냥 bytes만 넘기면 된다.

TTS의 OnPremTTS도 같은 패턴이다. VITS/Coqui 계열 엔진을 lazy 로드하고, float waveform을 16-bit PCM으로 변환해 돌려준다. 미설치 시에는 "사내망 사전배포 모델 경로가 필수"라는 안내 에러를 낸다. 외부 의존이 큰 부분일수록 인터페이스를 먼저 긋고 가짜를 꽂아 둔 뒤, 실엔진은 seam(이음새)으로만 정의하는 이 패턴이 이 프로젝트 전체에서 반복적으로 효자 노릇을 했다.

오케스트레이터 — 한 턴의 전체 흐름

이제 위층, runtime/orchestrator.pyConsultationRuntime이다. 이게 한 턴의 흐름을 지휘한다. 오디오(또는 텍스트)가 들어오면 STT → PII 마스킹 → 응급 룰매칭으로 가고, 응급이면 LLM을 우회해 즉시 응답하고, 아니면 과목 라우팅 → RAG 검색 → 프롬프트 조립 → LLM → TTS로 간다.

여기서 가장 중요한 설계 결정은 응급은 LLM을 우회한다는 것이다. 의료 맥락의 음성 상담에서 위험 신호(red flag)가 담긴 발화는 LLM의 생성을 기다릴 여유가 없다. 그래서 STT 결과 텍스트를 받자마자 룰베이스 키워드 매칭으로 응급 여부를 즉시 판정하고, hit이면 사전 캐싱된 안내 문구를 곧바로 TTS로 송출한다. 목표 지연은 500ms. LLM의 1~2초를 기다리지 않는다.

주석에 적어 둔 "가드레일 off와 무관"이라는 한 줄이 중요하다. 뒤(2편)에서 다룰 가드레일 설정으로 특정 과목 응대를 꺼 둘 수 있는데, 응급 분기는 그 설정과 무관하게 항상 동작한다. 안전과 직결된 경로는 어떤 운영 설정으로도 끌 수 없게 막아 둔 것이다.

지연을 단계별로 쪼개 측정한다

오케스트레이터는 단순히 흐름만 잇는 게 아니라, 각 단계의 소요 시간을 측정한다. STT·PII·응급매칭·라우팅·RAG·LLM·TTS를 전부 StageTiming으로 기록하고, 그걸로 첫 음성 시점과 전체 응답 시간을 계산해 KPI를 판정한다.

첫 음성 시점은 "TTS 직전까지 전처리 합 + TTS 첫 청크 latency"로 계산한다. 즉 STT·마스킹·응급매칭·라우팅·RAG까지 더한 뒤 TTS 첫 청크가 나오는 순간이다. 이 값이 일반 1초, 응급 500ms 안에 들어와야 한다. mock 어댑터로도 이 계산이 그대로 돌기 때문에, GPU가 없는 CI에서도 "흐름이 KPI 예산 안에서 도는가"를 매번 확인할 수 있다. 실엔진을 붙였을 때 어느 단계가 예산을 초과하는지 곧바로 짚을 수 있는 계기판을 미리 깔아 둔 셈이다.

백엔드와의 이음새 — 통화가 데이터로 남는다

런타임 자체는 순수한 "오디오 처리기"지만, 실제 제품에서는 통화가 데이터로 남아야 한다. 그 배선은 백엔드 쪽 consultation 서비스가 맡는다. 핵심은 런타임을 앱 수명주기 동안 1회만 조립해 재사용하고, 무거운 import를 모듈 로드가 아니라 첫 사용 시점으로 미루는 것이다.

_runtime = None

def get_runtime():
    """ConsultationRuntime 싱글턴(기본 mock/로컬 어댑터). 첫 호출 시 조립."""
    global _runtime
    if _runtime is None:
        from runtime.factory import build_runtime
        stt = None
        if settings.stt_engine == "faster_whisper":
            from runtime.voice.stt import FasterWhisperSTT
            stt = FasterWhisperSTT(
                settings.whisper_model_size, device=settings.whisper_device,
                compute_type=settings.whisper_compute_type,
                download_root=settings.whisper_download_root,
            )
        _runtime = build_runtime(stt=stt)  # stt=None → factory 가 MockSTT 사용
    return _runtime

설정(stt_engine)이 faster_whisper면 실엔진을, 아니면 mock을 쓴다. 이 분기 덕분에 같은 코드가 개발 노트북(mock)과 운영 박스(faster-whisper)에서 그대로 돈다. 그리고 오디오 턴 엔드포인트는 WAV를 받아 16-bit PCM으로 풀어 런타임에 넣고, 런타임이 돌려준 응답 음성을 다시 WAV로 싸서 내려보낸다. 전화 게이트웨이든 마이크 클라이언트든 동일한 계약으로 붙을 수 있게, 변환 책임을 백엔드 경계에 모아 두었다.

전화망(SIP) 연동도 같은 철학이다. 실제 SIP 게이트웨이는 회선·장비가 필요해 당장 못 붙이므로, "오디오를 턴 단위로 흘려 주는 가짜 전화기"를 시뮬레이터로 두고 그 자리만 비워 뒀다. 회선 없이도 "전화가 걸려 온 것처럼" 엔드투엔드 흐름을 검증할 수 있다.

정리하며

  • 온프레미스 음성 AI는 엔진이 전부 무겁고 전부 "나중에 붙는" 상태로 시작한다. 그래서 구현보다 계약(Protocol)을 먼저 못 박고, 학습·두뇌·런타임 3트랙을 병렬로 굴렸다.
  • 기본 어댑터를 전부 mock으로 두니 GPU 없이 엔드투엔드가 돈다. mock은 임시변통이 아니라 결정적 테스트와 KPI 측정을 가능케 하는 1급 설계다.
  • mock TTS는 "현실적인 가짜" — 첫 음성 KPI(≤1초)의 시간 구조를 사전캐싱 filler 전략 그대로 흉내 낸다.
  • 실엔진(faster-whisper·온프레미스 TTS)은 lazy import + seam + local_files_only로, 미설치 환경을 통과시키되 운영에선 사내망 모델만 쓰게 했다.
  • 오케스트레이터는 한 턴을 STT→마스킹→응급→라우팅→RAG→LLM→TTS로 지휘하고, 응급은 LLM을 우회해 500ms 안에 응답한다. 안전 경로는 어떤 운영 설정으로도 끌 수 없다.
  • 모든 단계의 지연을 측정해 KPI 계기판을 미리 깔아 뒀다 — 실엔진을 붙였을 때 병목을 즉시 짚기 위해서다.

다음 2편에서는 이 런타임의 "두뇌" — AI가 그 기관의 실제 보유 데이터에 근거해서만 답하게 만든 예약 tool-call과 DB 기반 RAG를 다룬다.

반응형