엔지니어링AI 답변 검사가 멀쩡한 답까지 버리던 문제
· 핀치
- #llm
- #guardrails
- #prompt-engineering
핀치의 AI 서버는 LLM이 쓴 설명을 사용자에게 보내기 전에 자동 검사 10개를 거칩니다. 숫자를 직접 썼는지, 근거 각주가 실제 근거를 가리키는지, "매수 적기" 같은 매매 권유 표현이 있는지, 문장 수가 정해진 범위인지 같은 것들입니다. 하나라도 걸리면 걸린 이유를 붙여 다시 생성하고, 그래도 안 되면 그 섹션은 내보내지 않습니다.
검사가 엄격하면 틀린 설명은 막지만, 멀쩡한 설명까지 버리게 됩니다. 버려진 만큼 다시 생성하니 토큰도 더 들고요. 운영을 시작하고 나서 반려 사유를 세어 보니 꽤 많은 반려가 오탐(문제가 없는데 걸린 것)이었습니다. 이 글은 그 오탐을 어떻게 찾아서 줄였는지에 대한 기록입니다.
반려 1위의 일부는 각주 표기 문제
운영 초기 반려 사유 1위는 "원시 수치"(자리표시자 없이 숫자를 직접 쓴 것) 137건, 2위는 근거 검사 102건이었습니다. 백엔드 파트에서 이슈로 짚어 준 것을 따라가 보니 둘의 뿌리가 같았습니다.
본문 각주는 [^cit_5] 형식이어야 하는데, 모델이 괄호 없이 ^cit_5라고 쓰는 일이 있었습니다. 숫자 검사는 각주를 먼저 지운 다음 숫자를 찾는데, 괄호 없는 각주는 지워지지 않아서 숫자 5가 남았고, 이게 원시 수치 위반으로 잡혔습니다.
모델에게 형식을 더 잘 지키라고 하는 대신, 서버가 본문의 각주 표기를 표준 형식으로 모으도록 했습니다. 검사는 오히려 더 정확해졌습니다. 예전에는 괄호 없는 각주가 검사에 보이지 않아서 지어낸 근거가 통과할 수 있었는데, 이제는 잡힙니다.
프롬프트와 검사가 서로 다른 말을 할 때
각주 문제를 고친 뒤에도 원시 수치 반려는 계속 나왔습니다. 운영 6시간 동안의 재생성 사유를 세어 보니 원시 수치 21건 중 대부분이 오탐이었습니다.
12건
순위·호기의 '1'
6건
날짜·연도 '2026'
3건
', 주'·'한 주'
프롬프트는 연도를 그대로 쓰라고 했는데 검사는 "2026년"만 허용하고 있었습니다. 수량을 찾는 정규식은 쉼표 하나로도 시작할 수 있어서 ", 주"를 수량으로 봤고요. 날짜·연도·순위·설비 번호·기간으로서의 "한 주"를 허용 목록에 넣고, 정규식이 숫자로 시작해야만 맞도록(\d[\d,]*) 고쳤습니다.
프롬프트 쪽에도 "자동 검사에 걸려 폐기되는 표현"이라는 절을 따로 만들어, 검사기가 실제로 보는 낱말을 그대로 적었습니다. 모델이 어떤 표현이 버려지는지 모르면 같은 실수를 반복하기 때문입니다.
고친 지시가 반대로 넘친 일
문장 수도 비슷했습니다. 그 전에 프롬프트에 "짧게 끝내는 편이 안전합니다"라고 적어 둔 게 과교정을 불렀습니다. 새 프롬프트로 배치를 돌리자 섹션 145개 중 26개가 버려졌고, 1위 사유는 종목 분석의 "최소 3문장 필요: 1문장"이었습니다. 반대로 포트폴리오 진단은 5~9문장으로 넘쳤습니다. "한 종목", "네 가지" 같은 개수 표현도 수량으로 반려됐고요.
그래서 프롬프트의 문장 수를 검사 기준과 정확히 같게 적었습니다. 종목 분석은 "정확히 3문장, 근거가 넉넉할 때만 4문장. 1~2문장도 5문장도 폐기"로, 진단은 항목마다 정확히 3문장으로요. 모자라도 넘쳐도 버려진다는 것, 개수 표현도 수량이라는 것을 함께 적었습니다.
3문장을 썼는데 1문장으로 센 이유
그다음 배치에서도 폐기 35건 중 13건이 여전히 "최소 3문장 필요: 1문장"이었습니다. 이번에는 모델 잘못이 아니었습니다. 모델은 3문장을 썼는데, 각주를 마침표 뒤에 붙였습니다.
…늘었습니다.[^cit_1] 다음 문장은…문장 끝 판정이 "종결부호 다음에 공백"만 보고 있어서, 마침표 다음에 각주가 오면 문장이 끝난 것으로 보지 않았습니다. 그래서 문단 전체가 1문장으로 세어졌습니다. 문장을 세기 전에 마침표 뒤 각주를 마침표 앞으로 옮기도록 고쳤습니다.
_SENTENCE_END_RE = re.compile(r"(?<!\d)[.!?]+(?=\s|$)")
_TRAILING_CITATION_RE = re.compile(r"([.!?]+)((?:\s*\[\^[^\]]+\])+)")
def split_sentences(text: str) -> list[str]:
text = _TRAILING_CITATION_RE.sub(lambda m: m.group(2).strip() + m.group(1), text)
...이번 수정은 검사만 바꾸고 프롬프트는 건드리지 않았습니다. 프롬프트가 바뀌면 프롬프트 버전이 바뀌어서 캐시된 분석이 전부 무효가 되는데, 검사만 고치면 캐시는 그대로 살고 실패했던 섹션만 다음 배치가 다시 만듭니다.
채팅에서 나온 오탐
같은 날 백엔드 중계까지 연결한 뒤 첫 채팅이 차단으로 끝났습니다. 답변의 "1년간", "1종목", "3종목"이 수량으로 반려된 것이었습니다. "1년간 변동성" 같은 기간 표기는 비율·금액·수량 어디에도 속하지 않으니 허용 목록으로 옮겼습니다. 종목 수는 도구가 자리표시자를 주지 않아서 모델이 숫자를 쓸 수밖에 없는 자리였기 때문에, 채팅 프롬프트에 개수를 세지 말고 종목 이름으로 나열하라고 적었습니다.
정리
오탐을 고칠 때마다 검사를 느슨하게 하지 않으려고 했습니다. 지어낸 근거나 직접 쓴 숫자는 여전히 막고, 같은 것을 다르게 적었거나 검사가 잘못 센 경우만 걸러냈습니다.
돌아보면 오탐의 원인은 대부분 모델이 아니었습니다. 프롬프트와 검사가 서로 다른 기준을 말하고 있었거나(연도, 문장 수), 검사가 실제 출력 모양을 몰랐습니다(괄호 없는 각주, 마침표 뒤 각주). 그래서 반려 사유를 세어 보고, 걸린 문장을 직접 읽어 보는 것부터 했습니다.