목록으로
AI

규칙으로 끝나는 일을 확률에 맡기지 마라

전화 상담봇이 금액과 전화번호를 잘못 읽었어요. LLM에게 읽는 법을 맡겨봤다가 환각을 만났고, 결국 규칙 기반으로 돌아왔습니다. 예전 같으면 엄두가 안 났을 정규식 작업이 지금은 왜 할 만해졌는지도 같이 적었어요.

2026-09-10

규칙으로 끝나는 일을 확률에 맡기지 마라

TL;DR

  • 음성 합성 엔진은 금액이나 번호를 대부분 제대로 읽지만 가끔 틀리게 읽습니다. 통화에서는 한 번만 틀려도 그 안내가 무용지물이 돼요.
  • LLM한테 미리 한글로 바꿔 쓰게 해봤는데, 이쪽도 확실하지 않았어요. 지시를 건너뛰거나 숫자를 지어냅니다.
  • 그런데 숫자를 어떻게 읽을지는 입력이 정해지면 답도 하나로 정해지는 일이에요. 판단이 아니라 계산입니다.
  • 오픈소스 라이브러리와 직접 만든 정규식을 우리 통화 문장으로 비교해 정규식을 골랐습니다.
  • 예전 같으면 정규식 짜는 데 한 세월이었는데, 지금은 초안을 클로드가 쓰고 사람은 검수만 봅니다. 덕분에 "유월", "스물한 개" 같은 예외까지 덮었어요.

저희가 만드는 콜봇

대학 대표번호로 전화를 걸면 사람 대신 받는 AI 상담봇이에요. 문의를 듣고 안내하고, 필요하면 담당 부서로 연결해줍니다. 지금도 여러 기관에서 쓰이고 있어요.

전화에는 화면이 없고, 방금 들은 말을 되돌려 다시 들을 수도 없어요. 채팅이었다면 눈으로 읽고 넘어갔을 한 글자가 여기서는 안내 하나를 통째로 못 쓰게 만듭니다. 숫자를 어떻게 읽을지도 그중 하나예요.

화면에서 맞는 글자가, 귀에서는 틀린다

저희는 전화를 받는 AI 상담봇을 만들어요. 문의를 듣고 안내하고, 필요하면 담당 부서로 연결해줍니다.

채팅이라면 화면에 1,500,000원이라고 띄우면 끝나요. 사람이 눈으로 읽고 알아서 이해하니까요. 전화에서는 이 글자가 음성 합성 엔진을 통과해 소리가 됩니다.

대부분은 제대로 읽어요. 그런데 가끔 1,500,000원이 "일오공만원"으로 나가고, 3시가 "삼시"가 되고, 062-530-1003이 "육십이 다시 오백삼십 다시 천삼"으로 들립니다.

한국어에서 숫자를 읽는 방법은 상황에 따라 워낙 다양해요. 같은 3도 시각이면 세, 분이면 삼이고, 금액은 자릿수로 읽지만 번호는 한 자리씩 부르죠. 이 경우를 전부 학습시킬 수는 없으니 엣지 케이스가 남습니다.

이메일 주소는 사정이 좀 달라요. 엔진은 알파벳이 붙어 있으면 하나의 단어로 읽으려고 합니다. 도메인이 들어간 주소를 그렇게 읽으면 무슨 소리인지 알 수 없는 말이 나와요. 받아 적는 건 불가능하고요.

하필 이런 엣지 케이스가 금액, 전화번호, 이메일 주소에서 걸립니다. 전화로 안내받는 사람이 꼭 받아 적어야 하는 것들이죠. 되감기도 안 되니까 잘못 들은 금액을 그대로 믿고 끊거나, 없는 번호로 다시 전화를 겁니다.

때로는 대부분의 경우에서 잘한다는 것만으로 만족해서는 안 될 때가 있어요. 이럴 때 필요한 건 확률이 아니라 확정적인 보장이니까요.


지금은 이렇게 처리하고 있어요. 오른쪽 칸은 설명이 아니라 콜봇에 들어 있는 변환 함수를 그대로 돌린 출력이에요.

LLM이 쓴 문장 / 그대로 넘겼을 때 들리는 소리 / 규칙을 통과한 뒤를 비교한 표와, 고유어·한자어로 갈리는 단위 목록

소리로 들으면 차이가 분명해요. 같은 안내를 그대로 넘겼을 때와 규칙을 통과시킨 뒤입니다.


LLM이 안전 장치가 될 수 있을까

엔진이 놓치는 걸 앞에서 막아주면 되지 않을까 싶었어요. 답을 만드는 건 어차피 LLM이니까, 만드는 김에 엔진이 헷갈리지 않을 형태로 써달라고 했습니다. 금액과 숫자는 한글로 풀어서 쓰고, 전화번호는 0을 "공"으로 읽는 식으로 한 자리씩 떼어 쓰고, 이메일 주소는 한 글자씩 또박또박 쓰라고요.

한동안은 괜찮아 보였어요. 그런데 통화 기록을 보다가 두 가지 실패를 발견했습니다.

먼저, 시키는 대로 안 할 때가 있어요. 3시, 168만원을 그대로 내보내는 경우가 꾸준히 나왔습니다.

지시를 짧게 쓰면 잘 안 지켜져요. 한국어 숫자 읽기는 예외가 많아서 한두 줄에 담기지 않습니다. 그렇다고 예시를 여러 개 붙여 꼼꼼하게 적으면 이번엔 자리를 많이 씁니다. 저희 봇은 검색해온 자료를 근거로 답하는 구조라, 컨텍스트는 원래 그 자료가 채워야 할 공간이에요. 학사 일정이나 규정 문서가 들어갈 자리를 "3시는 세시로 읽으세요"가 차지하게 됩니다.

두 번째는 더 나빴어요. 없는 번호를 말합니다. 봇이 안내한 전화번호에서 한 자리가 빠지거나 다른 숫자로 바뀌어 있었어요.

원인을 뜯어보면 당연했습니다. 그 지시는 LLM에게 두 가지 일을 겹쳐서 시켜요. 자료에서 정확한 번호를 기억해내는 일, 그리고 그걸 다른 표기로 바꾸는 일. 바꾸는 동안 원본을 그대로 붙들고 있어야 하는데, 그건 언어 모델이 특별히 잘하는 일이 아닙니다.

게다가 이런 오류는 티가 안 나요. "공 육 이, 오 삼 공, 일 이 공 사"는 문법적으로 완벽하고 자신감 있게 들립니다. 예외도 안 나고 지표도 안 떨어지죠. 사람이 통화 기록을 직접 들여다볼 때에야 드러납니다.

안전 장치를 하나 더 붙였다고 생각했는데, 실제로는 어긋날 수 있는 단계가 하나 더 생긴 것이었어요.


규칙 기반 시스템이 더 잘할 수 있는 일

0을 "공"으로 바꾸는 데 문맥이나 추론이 필요한가? 아니에요. 입력이 정해지면 출력이 하나로 정해집니다. 판단이 아니라 계산이에요.

그런데 저희는 그 계산을 확실하지 않은 단계 두 개 위에 올려두고 있었습니다. LLM이 바꿔 쓴 결과를 엔진이 다시 해석하는 구조였으니까요. 각 단계가 대부분 맞아도, 둘을 이어 붙이면 틀릴 확률은 더 커집니다.

입력이 출력을 유일하게 결정하는 일은 코드로 처리한다. 모델의 비결정성은 판단이 필요한 곳에만 쓴다.

당연해 보이는데 실전에서는 잘 안 지켜져요. LLM이 워낙 잘하다 보니 "이것도 프롬프트에 한 줄 넣으면 되잖아"가 먼저 떠오릅니다. 프롬프트 한 줄은 5분이고 정규식을 만들고 테스트를 붙이는 건 반나절이에요. 그 반나절을 아끼는 대가로 가끔 조용히 틀리는 시스템을 갖게 됩니다.

그래서 역할을 바꿨어요. LLM은 자료에 있는 값을 그대로 쓰고, 읽는 방법은 소리가 되기 직전에 코드가 정합니다. LLM이 "등록금은 1,500,000원입니다"라고 쓰면 상담 기록에는 그대로 남고, 엔진에는 "등록금은 백오십만원입니다"가 넘어가요.

프롬프트로 막던 구조와 규칙으로 정하는 구조의 비교 — 확실하지 않은 단계가 두 개에서 없어진다

앞에서 말한 이메일 주소도 이 자리에서 같이 처리합니다. 한 글자씩 떼어놓고 @는 "골뱅이", .은 "닷"처럼 기호를 말로 바꿔서 넘겨요. 사람이 전화로 주소를 불러줄 때 하는 방식 그대로입니다.

읽히는 문장과 들리는 문장이 갈라지는 것도 이 방식의 이점이에요. 상담 기록에는 원래 표기가 남습니다. 나중에 사람이 통화 기록을 열었을 때 한 글자씩 풀린 주소가 적혀 있으면 아무도 못 읽으니까요.


바퀴를 발명해야 할 때

한국어 숫자 읽기는 이미 만들어진 오픈소스 도구가 있는 영역이라, 먼저 후보로 놓고 비교했습니다. 우리 통화에서 실제로 나온 문장들을 모아놓고 둘 다 돌려봤어요. 벤치마크 데이터셋이 아니라 우리 봇이 실제로 말해야 하는 문장으로요.

결과는 정규식 쪽이 나았습니다. 오픈소스 도구는 일반적인 문장에서는 잘 동작하는데, 저희 도메인에서 자주 나오는 단위에서 어긋났어요. 형태소 분석기라는 무거운 의존성도 필요했습니다. 정규식은 따로 설치할 것이 없고 문장당 처리 시간이 사실상 0이라, 실시간 통화에서는 그 차이도 컸어요.

숫자를 보고 판단하는 대신, 뒤에 붙은 단위가 어느 체계를 쓰는지로 읽는 법을 정하게 했어요.

처리 시간도 재봤어요. 실제 통화에 나오는 문장으로 재보면 한 문장에 10마이크로초가 안 걸려요. 실시간 통화에서 이 정도면 없는 것과 같습니다.

직접 만드는 게 늘 맞지는 않아요. 다만 우리 도메인의 실제 문장으로 재봤더니 이쪽이 낫더라는 근거가 있으면 만들 만합니다. 그 근거가 없으면 이미 있는 걸 다시 만드는 일이 되고요.


클로드가 바꾼 개발의 방향

몇 년 전이었다면 저희도 그냥 라이브러리를 썼을 거예요. 한국어 숫자 읽기를 정규식으로 덮는 건 원래 엄두가 안 나는 일이었습니다. 예외가 끝없이 나와서 하나 추가할 때마다 다른 게 깨지고, 그걸 다 잡으려면 몇 주가 걸렸어요. "이쪽이 맞긴 한데 비용이 안 맞는다"가 흔한 결론이었습니다.

지금은 계산이 달라졌어요. 저희 팀은 개발에 클로드를 쓰는데, 이런 작업에서 역할이 자연스럽게 나뉩니다.

기계는 초안을 쓰고, 예외를 반영해 고치고, 케이스마다 테스트를 붙여요. 사람이 하면 지루하고 실수하기 쉬운 부분입니다. 사람은 무엇이 맞는 읽기인지 판정하고, 빠진 케이스를 찾고, 건드리면 안 되는 걸 건드리지 않았는지 봅니다. 한국어를 아는 사람만 할 수 있는 일이에요.

이 배치를 뒤집으면 안 됩니다. 유월이 맞는지 육월이 맞는지는 결국 사람이 정해야 하니까요. 대신 그 판정을 코드로 옮기고 테스트로 고정하는 지루한 과정이 사라지니, 사람의 시간이 전부 판단에 쓰입니다.

몇 주짜리로 보이던 일이 하루이틀짜리가 됐어요. 그러면서 "비용이 안 맞는다"는 판단 자체가 뒤집혔습니다. 도구가 바뀌면 할 수 있는 설계도 바뀌더라고요.


규칙 기반 시스템으로 얻을 수 있는 것

비용이 내려가니 예전 같으면 포기했을 것들을 다 넣게 됐어요. 한국어 숫자 읽기의 까다로운 부분은 대부분 예외에 있습니다.

읽는 방식을 정하는 건 숫자가 아니라 단위예요. 3시는 "세시"인데 3분은 "삼분"입니다. 12시 16분은 한 호흡에 두 체계가 섞여요. 그래서 숫자를 보고 판단하는 대신, 단위가 어떤 체계를 쓰는지로 결정하게 했습니다.

달 이름에는 불규칙이 있어요. 6월은 "육월"이 아니라 "유월", 10월은 "십월"이 아니라 "시월". 이건 표로 정리해두는 것 말고는 방법이 없습니다.

세는 말은 형태가 변해요. 20개는 "스무 개"인데 21개는 "스무하나 개"가 아니라 "스물한 개"입니다. 하나·둘·셋은 뒤에 단위가 붙으면 한·두·세로 바뀌고, 스물은 혼자 쓰일 때 스무가 됐다가 21이 되면 다시 스물로 돌아와요. 한국어 쓰는 사람은 무의식적으로 하는 구분인데, 코드로 적으려면 하나하나 다 적어야 합니다.

이런 게 수십 개예요. 하나씩 보면 사소한데 안 덮으면 통화에서 그대로 들립니다. 사람은 "백오십만원"을 제대로 읽은 건 그냥 넘어가도 "스무하나 개"는 바로 알아채요.


정리하면

입력이 정해지면 답도 정해지는 일은 코드에 맡기고, 모델은 판단이 필요한 자리에만 쓴다. 이 글에서 계속 한 이야기입니다.

이걸 놓치면 확실하지 않은 단계가 소리 없이 쌓여요. 저희도 LLM에 한 번, 엔진에 또 한 번 맡겨두고 있었습니다. 각각이 대부분 맞아도 이어 붙이면 얘기가 달라지고, 그렇게 생긴 오류가 제일 곤란해요. 예외도 안 나고 지표도 안 떨어지는데 전화를 받은 사람만 알거든요. 이런 건 잘 잡는 것보다 안 생기는 구조로 만드는 편이 낫습니다.

지금 저희 콜봇은 금액도 전화번호도 이메일 주소도 사람이 불러주는 방식 그대로 읽습니다. 받아 적을 수 있고, 다시 물을 일이 없어요. 통화 한 번으로 끝나는 안내가 되려면 이 정도는 확실해야 한다고 봤습니다.


같은 콜봇에서 만난 다른 문제도 하나 적어뒀어요. 사람이 "네네" 하고 맞장구를 칠 때 봇이 말을 멈춰버리던 문제, 무엇을 신호로 삼아 말을 끊을지에 대한 이야기입니다. → 실시간 음성에서 "언제 멈출까"를 정하는 법