음성봇을 스텁부터 만든 이유 — 테스트로 설계를 먼저 굳히다

시리즈 · 음성봇 직접 만들기 — 실시간 음성봇의 뼈대를 바닥부터 만든 8편 기록입니다. 1. 끼어들기(barge-in) — 재생 중인 TTS를 실시간으로 끊는 법 2. "말이 끝났다"는 판단 — VAD 엔드포인터 3. 엔진을 코드 수정 없이 바꾸는 인터페이스 설계 4. 로컬 LLM 대화 두뇌와 도구 호출 루프 5. 매 턴 지연시간을 재는 이유 6. 브라우저 마이크에서 서버까지 — 오디오 프로토콜 7. 로컬 Whisper STT 연결하기 8. 스텁부터 만든 이유 — 테스트 우선 설계 (완결) — 지금 보는 글 돌아보며 시작하는 마지막 편 이 시리즈를 시작할 때부터 정해 둔 규칙이 하나 있었습니다. 진짜 음성 인식 모델과 진짜 합성 모델이 준비되기 전까지는, 그 둘을 흉내만 내는 가짜 엔진으로 전체 구조를 먼저 돌려본다는 규칙입니다. 여덟 편을 거치며 끼어들기 처리, 상태 흐름, 프로토콜, 엔진 인터페이스를 하나씩 다뤘는데, 그 모든 것이 사실 이 가짜 엔진 위에서 검증됐습니다. 지금 코드를 다시 열어 보면 그때는 별생각 없이 세운 규칙이 결과적으로 시리즈 전체의 진행 속도를 결정했다는 게 눈에 들어옵니다. 이번 편에서는 왜 굳이 가짜부터 만들었는지, 그리고 그 선택이 시리즈 전체에 어떤 영향을 남겼는지 정리하면서 이 시리즈를 마무리하려 합니다. 실제 모델을 기다리지 않기로 한 이유 음성봇의 핵심 로직은 사실 음성 인식이나 음성 합성 자체가 아닙니다. 사용자가 말을 시작했는지 끝냈는지 판단하는 상태머신, 합성 중에 끼어들기가 들어왔을 때 스트림을 멈추는 동시성 처리, 이벤트가 순서대로 오가는 흐름 제어 같은 것들이 훨씬 까다롭습니다. 반면 실제 인식·합성 모델은 무겁고 설치가 까다롭고 실행 속도도 느립니다. 이 두 가지를 한꺼번에 붙잡고 있으면 어려운 동시성 버그를 실제 모델의 로딩 시간과 뒤섞어 디버깅하게 됩니다. 모델이 느려서 반응이 늦은 건지, 상태머신이 잘못 짜여서 늦은 건지 구분이 안 되는 상황을 미...

규칙 기반 처리에 LLM 폴백 붙이기 — Claude API vs CLI 선택 (환각 없이 AI 쓰는 법)

이 파이프라인에서 LLM은 첫 번째 도구가 아니라 마지막 도구입니다. STT가 뱉은 문자열은 먼저 토큰화·숫자 변환·자모 유사도·국토부 실존 조회를 차례로 거칩니다. 이 결정론 경로에서 답이 나오면 LLM은 아예 부르지 않습니다. 빠르고, 결과가 항상 같고, 비용이 0이기 때문입니다.

LLM을 부르는 건 그 경로가 전부 실패했을 때뿐입니다. 발음이 심하게 깨진 건물명, 어떤 조합으로 검색해도 국토부가 빈손으로 답하는 경우처럼, 규칙으로는 손댈 수 없는 애매함이 남았을 때입니다. 폴백의 위치를 이렇게 못 박아 두면, LLM이 잘못 부풀려 답해도 시스템 전체가 흔들리지 않습니다. 이번 편은 그 폴백의 자리와, 그것을 부르는 두 개의 경로를 다룹니다.

시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다.

  1. 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요
  2. 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버)
  3. 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인
  4. 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기
  5. 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli) — 지금 보는 글
  6. 6. 브라우저 프로토타입 → Vercel 서버리스

1. 폴백 세 종류, 그런데 호출구는 하나

LLM 폴백은 세 가지입니다. 첫째, 주소 정규화 — 룰 토크나이저가 못 풀었을 때 STT 원문을 표준 주소 문자열로 정리합니다. 둘째, 건물명 매칭 — 자모 유사도로도 답이 안 나올 만큼 심하게 깨진 건물명을, 국토부가 반환한 실제 후보 목록 중 하나로 골라 줍니다. 셋째, 검색어 제안 — 고정 조합으로 검색을 다 해봐도 실패했을 때 새 검색어 조합을 최대 4개까지 제안합니다.

세 용도는 성격이 다르지만, 실제로 모델을 부르는 자리는 callClaude 함수 한 곳입니다. 프롬프트를 만들고, 응답에서 JSON을 뽑고, 안전장치를 거는 흐름을 세 폴백이 공유합니다. 새 폴백을 추가하고 싶으면 프롬프트만 새로 쓰면 되고, 모델 교체나 오류 처리 변경은 이 한 곳만 고치면 세 곳에 동시에 적용됩니다.

2. 목록에 없는 답은 버린다 — 공유하는 안전장치

호출구를 합치니 안전장치도 한 번만 잘 짜면 됐습니다. 핵심 규칙은 하나입니다. LLM은 국토부 API가 실제 반환한 후보 안에서만 고르게 강제하고, 목록에 없는 답은 코드에서 무시한다.

건물명 매칭이 대표적입니다. 프롬프트에서 "반드시 목록에 있는 값 하나를 그대로 고르거나, 없으면 null"이라고 지시하지만, 지시만 믿지 않습니다. 응답을 받은 뒤에도 그 값이 실제 후보 목록에 들어 있는지 코드로 다시 확인하고, 없으면 버립니다.

const parsed = parseJsonFromText(text);
// 안전장치: 목록에 없는 답은 절대 신뢰하지 않음
if (!parsed.best_match || !uniqueCandidates.includes(parsed.best_match)) return null;
return parsed;

검색어 제안도 같은 결입니다. LLM이 내놓은 검색어 중 시군구 이름이 안 들어간 건 엉뚱한 지역을 뒤질 위험이 있어 걸러 냅니다. 프롬프트로 부탁하고, 코드로 다시 검증하는 이중 방어입니다. LLM의 창의성은 후보를 좁히는 데만 쓰고, 실존 여부의 최종 판단은 끝까지 국토부 데이터에 맡깁니다.

3. 두 경로: API와 CLI

같은 callClaude가 안에서 두 갈래로 갈립니다. 설정값 하나(LLM_PROVIDER)로 어느 경로를 탈지 정하고, 프롬프트와 파싱은 두 경로가 그대로 공유합니다.

api는 Anthropic 메시지 API를 HTTP로 부릅니다. cli는 로컬에 설치된 claude를 헤드리스 모드(claude -p)로 실행합니다. 겉보기 결과는 같지만 성질이 꽤 다릅니다.

항목api (기본)cli
호출 방식Anthropic /v1/messages HTTP로컬 claude -p 헤드리스
비용API 크레딧 차감구독 쿼터 사용, 크레딧 0
응답 속도약 0.5~1초약 5~7초
서버리스 배포가능불가(바이너리·로그인 세션 필요)
주 용도프로덕션테스트 반복

기본값은 api입니다. 서버리스 환경에는 claude 바이너리도, 로그인 세션도 없으니 배포 경로는 반드시 이쪽이어야 합니다.

4. 왜 CLI 경로를 따로 두었나

그런데도 cli를 남겨 둔 이유는 테스트 때문입니다. 보정 정확도를 재려면 같은 하네스를 수십·수백 번 반복해서 돌려야 하는데, 그때마다 API 크레딧을 태우면 아깝습니다. cli는 크레딧 대신 구독 쿼터를 쓰므로, 크레딧을 한 푼도 쓰지 않고 정확도를 반복 측정할 수 있습니다. 그래서 용도를 갈랐습니다. 프로덕션은 api, 테스트 반복은 cli.

대신 느립니다. cli는 매 호출이 5~7초쯤 걸립니다. 특히 규칙이 전부 실패한 "느린 경로"에서는 LLM을 순차로 두 번 부르기 때문에(정규화 후 재검색, 그래도 안 되면 검색어 제안), cli로 돌리면 한 건에 10초를 넘기기도 합니다. 프로덕션에서 이 지연은 감당하기 어렵지만, 오프라인 정확도 측정에서는 문제가 되지 않습니다. 속도를 내주고 크레딧을 아끼는 교환입니다.

CLI 경로에는 자잘한 안전장치도 넣었습니다. 프롬프트를 명령행 인자 대신 stdin으로 넘겨 길이 제한과 이스케이프 사고를 피하고, --max-turns 1로 단발 응답에 고정해 도구를 쓰는 에이전트 루프로 번지지 않게 막았습니다.

5. 없으면 조용히 결정론으로 돌아간다

CLI에는 함정이 하나 있습니다. claude 바이너리가 없거나 로그인이 안 돼 있으면 호출 자체가 실패한다는 점입니다. 여기서 예외를 그대로 터뜨리면, 테스트 설정 하나 때문에 주소 보정 전체가 죽습니다.

그래서 CLI 경로는 실패를 조용히 삼킵니다. 바이너리를 못 찾으면(ENOENT) 경로 설정을 확인하라고 로그만 남기고, 타임아웃이나 파싱 실패도 마찬가지로 null을 돌려줍니다. 폴백이 null을 주면, 파이프라인은 앞 단계까지 계산해 둔 결정론 결과로 되돌아갑니다.

if (err) {
  const reason = err.code === "ENOENT"
    ? `claude CLI 를 찾을 수 없음 (${cliPath})`
    : err.killed ? `CLI 타임아웃` : err.message;
  log?.(`LLM(CLI) 호출 실패: ${reason}`);
  return resolve(null); // 장애로 죽지 않고 결정론 폴백으로
}

핵심은 LLM이 없어도 시스템은 계속 돈다는 것입니다. LLM은 정확도를 끌어올리는 보조일 뿐, 없다고 멈추는 필수 부품이 아닙니다. 참고로 기본 모델은 claude-sonnet-5이고, 정규화처럼 자릿수 정확도가 중요한 폴백에는 적응형 사고를 켜고 토큰을 넉넉히 주며, 간단한 분류에는 사고를 꺼 토큰을 아낍니다.

이렇게 규칙·조회·LLM 폴백까지 보정 로직은 완성됐습니다. 다음 편은 이 백엔드를 실제로 배포하는 이야기입니다. 브라우저에서 키를 노출하며 직접 부르던 프로토타입을, 왜 그리고 어떻게 서버리스 함수로 옮겼는지 — API 경로가 기본값이 될 수밖에 없던 배경을 마지막 6편에서 다룹니다.

자주 묻는 질문

Q. LLM이 목록에 없는 주소를 지어내면 어떻게 되나요? 프롬프트로 "목록 안에서만 고르라"고 지시하지만, 그것만 믿지 않습니다. 응답을 받은 뒤 그 값이 국토부가 실제 반환한 후보 목록에 들어 있는지 코드로 다시 확인하고, 없으면 버립니다. 검색어 제안도 시군구 이름이 빠진 건 걸러 냅니다. 실존 판단의 최종 권한은 끝까지 국토부 데이터에 있습니다.

Q. api와 cli 중 무엇을 언제 쓰나요? 프로덕션은 항상 api입니다. 빠르고(0.5~1초) 서버리스에 올릴 수 있기 때문입니다. cli는 오프라인 테스트 전용입니다. 크레딧 대신 구독 쿼터를 써서, 정확도 하네스를 반복해 돌려도 비용이 0입니다. 대신 5~7초로 느리고 서버리스에서는 아예 동작하지 않습니다.

Q. 폴백 세 개를 굳이 한 함수로 묶은 이유가 있나요? 프롬프트·JSON 파싱·안전장치·경로 분기(api/cli)가 세 폴백에 공통이기 때문입니다. 호출구를 하나로 두면 모델을 바꾸거나 오류 처리를 고칠 때 한 곳만 손대면 세 곳에 동시에 반영됩니다. 각 폴백은 자기 프롬프트와 응답 검증만 따로 갖습니다.

댓글

이 블로그의 인기 게시물

디스코드 구글캘린더 연동 봇 만들기: 카톡 캡처 OCR 자동 등록 시스템 구축

클로드 코드 비용 줄이는 법 — ollama 로컬 LLM 연동 (M3 맥북)

유튜브 노션 자동 저장 봇 만들기 — Claude CLI MCP + Discord 233일 운영기