규칙 기반 처리에 LLM 폴백 붙이기 — Claude API vs CLI 선택 (환각 없이 AI 쓰는 법)
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
이 파이프라인에서 LLM은 첫 번째 도구가 아니라 마지막 도구입니다. STT가 뱉은 문자열은 먼저 토큰화·숫자 변환·자모 유사도·국토부 실존 조회를 차례로 거칩니다. 이 결정론 경로에서 답이 나오면 LLM은 아예 부르지 않습니다. 빠르고, 결과가 항상 같고, 비용이 0이기 때문입니다.
LLM을 부르는 건 그 경로가 전부 실패했을 때뿐입니다. 발음이 심하게 깨진 건물명, 어떤 조합으로 검색해도 국토부가 빈손으로 답하는 경우처럼, 규칙으로는 손댈 수 없는 애매함이 남았을 때입니다. 폴백의 위치를 이렇게 못 박아 두면, LLM이 잘못 부풀려 답해도 시스템 전체가 흔들리지 않습니다. 이번 편은 그 폴백의 자리와, 그것을 부르는 두 개의 경로를 다룹니다.
시리즈 · 한국 주소 STT 보정 — 음성으로 받은 한국 주소의 인식 오류를 단계별로 고쳐 가는 6편 기록입니다.
- 1. 왜 자꾸 틀리나 — 문제 정의와 파이프라인 개요
- 2. 국토부 도로명주소 API를 정답지로 (JSONP→서버)
- 3. 자모 유사도 매칭 — 한글 분해 + 레벤슈타인
- 4. "이백팔십"을 280으로 — 한글 숫자 되돌리기
- 5. 규칙으로 안 되는 것만 LLM에게 (api vs cli) — 지금 보는 글
- 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)가 세 폴백에 공통이기 때문입니다. 호출구를 하나로 두면 모델을 바꾸거나 오류 처리를 고칠 때 한 곳만 손대면 세 곳에 동시에 반영됩니다. 각 폴백은 자기 프롬프트와 응답 검증만 따로 갖습니다.
- 공유 링크 만들기
- X
- 이메일
- 기타 앱
댓글
댓글 쓰기