상담 초안 자동화: 모델이 지어낸 숫자는 버리게 만들었다
목차
개요
내 결제 기록과 PG 정산 파일이 안 맞는 건이 잡히면, 상담원이 고객에게 안내 문구를 써야 합니다. 그 초안 쓰기를 모델에게 맡기고, 모델이 지어낸 숫자가 섞이면 그 초안을 버리게 만들었습니다. 고객에게 바로 보내지 않고 상담원이 검토·수정한 뒤 발송하는 구조입니다.
대사는 내 결제 기록과 PG(결제대행사)가 보내주는 정산 파일을 하루치씩 대조해 안 맞는 건을 찾는 작업입니다.
상황은 둘이었습니다. 모델을 아직 못 붙이는 동안 잣대부터 만든 것, 그리고 실제로 붙였더니 그 잣대의 계약이 틀렸던 것. 두 번 다 모델이 아니라 모델을 재는 잣대가 문제였습니다.
이 글의 수치는 통계적 성능 평가가 아닙니다. 표본은 대사 예외 4건이고, 그 4건으로 프롬프트도 루브릭도 가점도 고쳤습니다. 이미 개발 세트라 홀드아웃이 아닙니다. 아래 숫자는 전부 그 4건 위에서 돌린 탐색 실행의 결과로 읽어야 맞습니다.
| 상황 | 다룬 것 |
|---|---|
| 상황 1 | 모델을 못 붙이는 동안 잣대부터 만들었다 |
| 상황 2 | 실제로 붙였더니 그 잣대의 계약이 틀렸다 |
개인 프로젝트로 만든 결제 시스템 pay의 개발 기록입니다. 실무 운영 경험이 아닙니다.
상황 1. 모델을 못 붙이는 동안 잣대부터 만들었다
이 프로젝트가 실제로 금융 규제를 적용받는 건 아닙니다. 다만 금융사 내부 업무망에 배치한다고 가정하면 결제 데이터를 외부 생성형 AI API로 보내는 설계는 그대로 쓸 수 없다고 봤고, 그 제약을 스스로 걸었습니다. 그동안 포트와 숫자 검증기를 먼저 만들었습니다. 포트는 초안을 만드는 자리를 인터페이스로만 뚫어두고 구현을 갈아끼우게 한 것이고, 검증기는 초안에 나온 숫자가 코드가 낸 값인지 대조하는 장치입니다. 코드가 낸 숫자만 쓰고 모델이 지어낸 숫자는 버린다는 계약이 요점입니다.
순서를 정해놨었다
AI 운영 자동화를 검토하면서 만드는 순서를 정해뒀습니다.
① 타임라인 조립기(AI 없음) → ② 규칙 분류기(AI 없음) → ③ CS 답변 초안(AI) → ④ 잔여 후보(AI)①②는 백오피스 확정 화면을 만들면서 끝냈습니다(10편). ③을 만들 차례였습니다.
자리는 명확했습니다. 대사 원인 8종 중 6종은 산수로 결정됩니다. 거기에 모델을 쓰면 이미 결정된 것을 추측하게 만드는 셈입니다. 반대로 “이 상황을 고객이 읽을 문장으로 바꾸기”는 코드가 못 하고 모델이 잘합니다. 그게 남은 자리였습니다.
그래서 어떤 모델을 쓸지 알아보다가, 먼저 확인할 게 있었습니다.
규제를 찾아봤다
2026년 4월 20일 시행된 전자금융감독규정시행세칙 개정 내용이 있었습니다. 금융회사가 일정 보안 규율을 지키면 내부 업무망에서 별도 혁신금융서비스 심사 없이 SaaS를 쓸 수 있게 됐습니다.
여기까지 읽고 “됐네” 싶었는데, 그 다음 문장이 있었습니다.
SaaS에 이어 향후 생성형 AI 서비스 도입 등과 관련해서도 금융권과 적극적으로 소통하며 신속히 망분리 규제 예외가 적용될 수 있도록 추진할 계획
생성형 AI는 아직 그 대상이 아닙니다. 열릴 예정인 상태입니다. 코어뱅킹처럼 개인신용정보를 직접 처리하는 시스템은 여전히 강한 망분리가 걸립니다.
pay가 다루는 건 주문번호, 결제금액, 취소이력입니다. 결제 데이터를 외부 API로 내보내는 코드를 지금 도메인에 박아두면, 규제가 열릴 때가 아니라 지금 문제가 됩니다.
또 하나: 잣대를 먼저 만들어야 했다
Klarna 사례를 다시 봤습니다. AI 상담으로 해결시간을 11분에서 2분으로 줄였다가 되돌린 회사입니다. CEO가 인정한 실패 원인은 기술이 아니었습니다.
비용이 지나치게 지배적인 평가 요소였고, 그래서 품질이 낮아졌다
지표 설계 순서 문제였습니다. 업계 권고도 같았습니다. “eval harness를 첫 주에 만들고 모든 프롬프트·모델 변경을 그걸로 게이팅하라.”
모델을 고른 뒤에 잣대를 만들면 잣대가 그 모델에 맞춰 휩니다. 당연한 얘기 같은데, 실제로 그래서 되돌린 회사가 있습니다.
그래서 포트만 만들었다
OrderTimeline + CauseSuggestion ← 결정적으로 조립 (10편에서 만든 것) ↓ FactPack ← 코드가 만든 사실 문장 + 허용 금액·날짜 집합 ↓ DraftPort ← 템플릿이든 모델이든 여기서 갈립니다 ↓ NumericProvenanceGuard ← 초안의 금액·날짜를 FactPack과 대조 ↓ 상담원이 검토·수정 후 발송기본 구현은 외부 호출이 없는 템플릿입니다.
이걸 “임시물”로 두지 않은 게 중요합니다. 두 가지 일을 합니다.
기준선. 모델이 붙었을 때 “그래서 얼마나 나아졌나”를 잴 상대가 필요합니다. 기준선 없이 모델을 넣으면 좋아 보이는 것과 좋아진 것을 구분할 수 없습니다.
폴백. 모델이 죽거나 검증에 걸려 초안이 없을 때 상담원이 빈 화면을 보면 안 됩니다. 사실 나열만으로도 처음부터 조사하는 것보단 낫습니다.
알아보니 Spring AI가 이미 같은 형태를 표준으로 제공하고 있었습니다. ChatModel 뒤에서 OpenAI·Anthropic·로컬 Ollama가 의존성과 설정만 바꿔 교체되고 비즈니스 로직은 그대로입니다. 내가 만든 DraftPort는 그보다 좁은(초안 생성 한 가지) 포트라, 어댑터가 Spring AI를 쓰든 직접 호출하든 상관없습니다.
NumericProvenanceGuard가 요점이다
이름을 나중에 바꿨습니다. 처음엔 NumberGuard였는데, 이 장치가 보장하는 건 출처뿐입니다.
초안에 나온 숫자가 코드가 낸 값인지만 봅니다. 그 숫자가 맞는 주장에 쓰였는지는 못 봅니다.
외부 기록에 100,000원이 있으면 “청구 금액은 그대로 유지됩니다”도 숫자 검증은 통과합니다.
그 층은 채점 기준과 다른 계열 모델의 재채점이 맡는데, 그건 12편에서 다룹니다. NumberGuard는 “숫자를 지킨다”로 읽혀 실제 보장보다 넓었습니다.
모델이 지어낸 숫자는 근거 있는 숫자와 문장에서 구별되지 않습니다.
차액 3,000원이 확인되었습니다.
차액 3,200원이 확인되었습니다.
읽는 사람에게 똑같이 그럴듯합니다. 그래서 사람 검토에 맡길 수 없습니다. 검토자도 그 숫자가 어디서 왔는지 모르기 때문입니다.
그래서 초안에 나온 금액과 날짜를 전부 뽑아 FactPack과 대조하고, 하나라도 없으면 초안을 버립니다. 프로덕션에서 쓰이는 방식이 이거였습니다. 출처 시스템에서 찾을 수 없는 값을 참조하면 그 출력을 거부합니다. Uber Genie도 같은 계열로, LLM에 “제공된 sub-context 안에서만 답하고 출처 URL을 인용하라”를 명시적으로 지시합니다.
검증에 실패한 초안을 “참고용”으로도 안 보여줍니다. 사람은 눈앞의 문장에 끌려가고(앵커링), 그게 틀렸다는 걸 확인할 방법은 어차피 없습니다.
검사 범위를 어디까지 잡을까
이게 고민이었습니다.
| 허용 목록 | 넓히면 | 좁히면 |
|---|---|---|
| 맨 숫자까지 검사 | ”항목 2개”, “3일 이내” 같은 정상 초안이 대량 반려 | — |
| 금액·날짜만 (택함) | — | 원 없는 지어낸 수치가 샐 수 있음 |
초안이 실제로 주장하는 사실이 금액과 날짜라서 이 경계로 잡았습니다. 모델이 붙은 뒤 실측해서 조정할 값입니다.
잣대가 잣대인지도 검증했다
여기가 이번에 제일 마음에 드는 부분입니다.
지금은 템플릿 구현뿐이라 검증 통과가 당연합니다. 그래서 지금 만들 수 있습니다. 정답이 명확한 동안 잣대를 고정하는 것입니다.
그런데 통과가 당연하면 하네스가 제대로 도는지 알 수 없습니다. 그래서 일부러 값을 지어내는 가짜 구현을 같이 돌렸습니다.
[잣대 검증] template 초안 3/4 (75%) 사실정합 3/3 (100%) fixture:invents-amount 초안 3/4 (75%) 사실정합 0/3 (0%) └ 승인만: [출처에 없는 금액: 270원] └ 승인+부분취소: [출처에 없는 금액: 270원] fixture:invents-date 초안 3/4 (75%) 사실정합 0/3 (0%) └ 승인만: [출처에 없는 날짜: 2026-01-01]100% 대 0%로 갈립니다. 전부 통과시키는 검증 장치(하네스)는 잣대가 아닙니다.
invents-amount가 만드는 문장이 “수수료 270원이 차감되었습니다”인데, 이게 진짜 그럴듯해서 좋아하는 픽스처입니다. 수수료율 2.7%에서 나올 법한 숫자고, 실제로 그런 케이스가 있습니다. 그런데 그 주문의 사실 묶음엔 270원이 없습니다. 그래서 걸립니다.
기대치도 적어뒀다
찾아본 수치들입니다.
| 사례 | 성능 |
|---|---|
| Uber Genie | 도움률 48.9% (7만+ 질문, 13,000 엔지니어링 시간 절감) |
| Meta 인시던트 RCA | 근본원인이 상위 5개 안에 42% |
| Zalando 사후분석 | 최신 모델도 표면적 귀인오류 ~10% |
“알아서 다 해준다”는 사례가 어디에도 없었습니다. 절반쯤 맞는 게 정상이고, 이 전제로 설계해야 합니다. 그래서 초안이지 답변이 아니고, 검증에 걸리면 버립니다.
국내 사례도 보도 기준으로는 있습니다. 토스뱅크 상담 어시스턴트가 상담원에게 실시간 요약과 응답 초안을 제공하는 형태로 알려져 있습니다. 기술 블로그로 공개된 1차 자료는 찾지 못해 보도된 내용까지만 적습니다. ③과 정확히 같은 모양입니다. AI가 고객에게 직접 보내지 않습니다.
상황 1이 남긴 것
처음엔 “어떤 모델 쓸까”부터 생각했습니다. 그런데 알아볼수록 모델을 고르는 건 마지막 문제였습니다.
먼저 정해야 했던 건 세 가지입니다. 데이터가 밖으로 나가도 되는가(규제), 무엇이 좋은 초안인가(잣대), 틀린 숫자를 어떻게 잡는가(검증). 이 셋이 정해지면 모델은 어댑터 하나입니다. 안 정해지면 어떤 모델을 써도 답이 없습니다.
AI를 안 붙인 게 아니라, 붙일 자리를 다 만들어놓고 마지막 하나를 규제 때문에 비워둔 상태입니다. 이건 실제로 그렇게 해야 하는 상황이라 오히려 기록할 만한 것 같습니다.
상황 2. 실제로 붙였더니 그 잣대의 계약이 틀렸다
여기까지가 “붙일 자리를 만든” 이야기입니다. 아래는 실제로 모델을 붙이고 나서 벌어진 일입니다.
붙이기 전에 실데이터부터
앞에서 상담 초안 포트와 숫자 검증기를 만들었습니다. 잣대를 먼저 만든다는 게 그때의 판단이었습니다.
그런데 잣대만 만들고 재지 않으면 잣대인지 알 수 없습니다. 그래서 앱을 띄우고 진짜 대사 불일치를 만들었습니다. 주문 생성 → 결제 승인 → 부분취소 → PG 정산 파일 업로드 → 대사 실행까지 실제 API로.
네 건이 나왔고, 초안 파이프라인에 태웠습니다.
id=1 FEE_CALCULATION_DIFF (DECISIVE) verified=false rejected=['출처에 없는 금액: 270원']id=3 SUSPECTED_TAMPERING (WEAK) verified=false rejected=['출처에 없는 금액: 8,888원', '출처에 없는 금액: 270원']4건 중 2건 반려. 그리고 하필 최악의 두 건이었습니다.
FEE_CALCULATION_DIFF: 확신이 가장 높은 규칙. 차액이 수수료율과 원 단위까지 맞는 건SUSPECTED_TAMPERING: 초안이 가장 필요한 건. 근거가 “설명되지 않는다”뿐이라 사람이 처음부터 조사해야 합니다
검증기의 계약이 잘못 정의돼 있었다
반려된 8,888과 270은 분류기가 계산한 근거였습니다.
차액 8,888원이 수수료(270원)로도 취소로도 설명되지 않는다
이 숫자들은 타임라인 항목의 금액 필드가 아니라 규칙 엔진이 산출한 값입니다. 그런데 허용 목록을 타임라인 금액으로만 만들고 있었습니다.
내가 지키려던 게 뭐였는지를 다시 생각해야 했습니다.
- 처음 계약: “타임라인에 있는 숫자만 써라”
- 고친 계약: “코드가 낸 숫자만 쓰고, 모델이 지어낸 숫자는 쓰지 마라”
분류기는 코드입니다. 그 근거의 숫자는 통과해야 맞습니다.
단위 테스트 7개가 전부 통과하고 있었다
이게 제일 뼈아팠습니다. 전부 손으로 만든 입력을 썼기 때문입니다. 내가 만든 FactPack에는 내가 넣은 숫자만 있었고, 분류기가 계산한 값이 섞이는 상황이 없었습니다.
고친 뒤 다시 돌려서 4/4 통과를 확인했습니다. 회귀 테스트 2개로 고정했는데, 하나는 “분류기 근거 금액이 통과한다”고, 다른 하나는 “그럼에도 근거에 없는 숫자는 여전히 걸린다”고 잡습니다. 허용 범위를 넓힌 게 아니라 제자리로 돌린 것임을 두 번째가 지킵니다.
이제 진짜 모델을 붙였다
Ollama는 현업 운영에 쓰는 게 아니다
알아보고 알았습니다. 자체 호스팅 운영은 보통 vLLM(또는 SGLang, TGI)입니다.
| 항목 | Ollama | vLLM |
|---|---|---|
| 용도 | 개발·테스트·엣지 | 운영 서빙 |
| 최대 처리량(공개 벤치) | 41 tok/s | 793 tok/s |
| P99 지연 | 673ms | 80ms |
공개 벤치마크 수치이고 모델·양자화·GPU·동시성이 같지 않아 그대로 비교할 값은 아닙니다. 요점은 순위가 아니라 목표가 다르다는 것입니다. Ollama는 로컬 실험 편의를, vLLM은 연속 배칭 같은 처리량 중심 서빙을 노립니다. 이 실험은 노트북 단일 사용자라 Ollama를 골랐습니다.
그런데 널리 쓰는 구성이 정확히 “개발·테스트는 Ollama, 운영은 vLLM, 앱은 게이트웨이 뒤에서 그대로” 였습니다. 앞에서 만든 포트가 그 게이트웨이라 노트북 실험엔 Ollama가 맞습니다.
모델은 처음에 qwen2.5:7b라고 적어놨는데 근거 없이 고른 값이었습니다. 찾아보니 지금 한국어 로컬 모델은 Qwen3 8B가 1순위로 꼽혀 바꿨습니다.
붙이자마자 둘이 걸렸다
답을 내기 전에 사고 과정을 길게 써 내려가는 추론 토큰이 켜져 있어 한 건에 19초 걸렸습니다. 서술만 시키는 일이라 껐습니다. 5초대가 됐습니다.
프롬프트 레이블이 잘못돼 있었습니다. [쓸 수 있는 금액]이라고 썼더니 모델이 그걸 잔액으로 읽었습니다.
사용 가능한 금액은 10,000원과 270원입니다.
목록의 성격을 이름에 박아야 했습니다. [인용해도 되는 금액] — 사실이 아니라 <표기 허용 목록>입니다.
모델이 템플릿은 못 찾는 버그를 찾았다
4건을 3번씩, 12회 돌렸습니다. 12회 중 7회 통과(58%). 성능 측정이 아니라 검증 경로가 실제로 도는지 보는 탐색 실행입니다.
반려가 전부 같은 값이었습니다. 2026-09-06입니다. 에스크로 사실 문장의 자동해제 예정 2026-09-06T...입니다. 코드가 만든 진짜 날짜인데, 허용 날짜를 entry.at()(사건이 일어난 시각)으로만 모으고 문장이 말하는 다른 날짜를 빠뜨렸습니다.
여기서 이번 실험의 제일 재밌는 부분이 나옵니다. 결함 3건은 찾을 수 있는 층이 각각 달랐습니다.
| # | 결함 | 발견 조건 |
|---|---|---|
| 1 | 분류기 근거 금액 반려 | 실데이터 + 템플릿 |
| 2 | 사실 문장 속 금액 반려 | 실제 모델만 |
| 3 | 사실 문장 속 날짜 반려 | 실제 모델 + 12회 반복만 |
2번은 템플릿이 절대 못 찾습니다. 템플릿은 허용 목록을 그대로 찍을 뿐 사실 문장을 읽고 인용하지 않기 때문입니다. 3번은 12회를 돌려야 보였습니다. 모델이 매번 같은 문장을 쓰지 않으니까.
셋 다 뿌리가 같았습니다. 구조화 필드만 모으고 문장 속 값을 빠뜨린 것.
정규식 하나가 더 있었다
날짜를 뽑을 때 \b(\d{4}-\d{2}-\d{2})\b로 짰다가 테스트가 잡았습니다. 2026-09-06T11:06의 T는 단어 문자라 뒤쪽 \b가 성립하지 않습니다. ISO 시각이 통째로 안 잡혔습니다.
고친 뒤 12회 전부 통과. 같은 4건 위에서입니다.
그런데 숫자가 맞는다고 쓸 만한 건 아니었다
통과율은 100%인데 초안을 읽어보니 두 가지가 문제였습니다.
내부 용어가 샜습니다.
대사 AMOUNT_MISMATCH로 인해 … 주문 상태 PAID 확인 중입니다
하필 어려운 건에서 약했습니다. 위변조 의심 건, 곧 초안이 가장 필요한 자리의 초안이 이랬습니다.
2026-08-30 결제 승인 완료 및 주문 상태 PAID 확인 중입니다.
에스크로 보류 자동 해제 예정일은 2026-09-06입니다.
고객이 물어본 “금액이 왜 다른가”를 한 마디도 말하지 않았습니다.
두 문제의 처방이 달랐다
찾아보니 이랬습니다.
| 문제 | 처방 |
|---|---|
| 내부 용어 누출 | 글로서리를 프롬프트에 싣고, 출하 전 용어 검수를 따로 |
| 어려운 건에서 약함 | few-shot 예시: 좋은 예와 나쁜 예를 같이 |
두 번째가 핵심이었습니다. 업계 논의는 *“실패는 프롬프트 실패가 아니라 아키텍처 실패다. 아무리 표현을 다듬어도 빠진 정보를 메울 수 없다”*고 말하는데, 우리 경우는 정보가 빠진 게 아니라 좋은 답이 어떻게 생겼는지를 안 보여준 것이었습니다.
그래서 나쁜 예를 나쁘다고 이름 붙여 같이 줬습니다.
나쁜 초안 (이렇게 쓰지 마십시오): 2026-08-30 결제 승인 완료 및 주문 상태 PAID 확인 중입니다. 에스크로 보류 자동 해제 예정일은 2026-09-06입니다.나쁜 이유: (1) 고객이 물어본 <금액이 왜 다른가>를 한 마디도 말하지 않았습니다. (2) PAID 같은 내부 코드를 그대로 썼습니다. (3) 에스크로 일정은 이 문의와 상관없는 정보입니다.숫자와 용어를 같은 잣대로 다루지 않았다
여기가 이번에 제일 고민한 부분입니다.
| 결함 | 지어낸 숫자 | 내부 용어 누출 |
|---|---|---|
| 성격 | 사실이 틀림 | 틀린 게 아님 |
| 상담원이 고칠 수 있나 | 못 고침 (출처를 모름) | 고칠 수 있음 |
| 처리 | 초안을 버린다 | 표시하고 센다 |
같은 잣대로 버리면 쓸 만한 초안까지 사라집니다. 세는 이유는 프롬프트를 고쳤을 때 나아졌는지 알기 위해서입니다.
사전은 닳는다는 것도 고려했습니다. enum이 늘면 사전이 뒤처지니까, 탐지는 사전이 아니라 모양으로 합니다. 한국어 문장 안의 대문자 라틴 토큰은 사실상 전부 내부 용어입니다.
결과
| 지표 | 전 | 후 |
|---|---|---|
| 검증 통과율 | 58% | 100% |
| 용어 누출 | 발생 | 0 / 12 = 0% |
| 지연 중앙값 | 5,233ms | 8,825ms ← 늘었다 |
지연이 68% 늘었습니다. 사전 22줄과 예시 3개가 붙은 대가입니다. 섀도 모드는 비동기라 문제없지만 사람이 기다리는 경로면 8.8초는 길습니다. 문서에 그대로 적어뒀습니다.
어려운 건은 이렇게 바뀌었습니다.
2026-08-30 결제 승인 10,000원이 정상 확인됩니다.
결제사 정산 내역과의 금액 차이 8,888원 원인을 확인 중입니다.
확인이 완료되면 안내드리겠습니다.
예시를 베낀 건 아닌지 확인했다
few-shot을 넣으면 출력이 예시를 닮는 게 당연합니다. 그래서 “나아졌다”가 모델이 예시를 베낀 것인지 구분해야 합니다. 예시에 없는 케이스 두 개로 확인했습니다. 그중 하나는 이렇습니다.
결제사 정산 자료 도착 지연으로 인해 내부 기록과 차이가 발생했으며, 원인을 확인 중입니다.
둘 다 용어 누출 0이고, 이건 글로서리 번역이 실제로 적용된 것입니다(PG_FILE_DELAY → “결제사 정산 자료 도착 지연”). 구조를 베낀 게 아니라 규칙이 옮겨졌습니다.
어조는 이렇게 남겼다
외부에만 있는 건의 초안이 이렇게 씁니다.
저희 내부 기록에는 해당 주문이 없습니다
사실은 맞는데 고객이 읽으면 불안합니다. 검증기도 사전도 못 잡습니다. 상담원이 판단할 영역이라 그대로 뒀습니다. 어디까지가 기계의 몫이고 어디부터가 사람의 몫인지의 경계가 여기라고 봤습니다.
그리고 “상담 시간 N% 단축” 같은 숫자는 안 적었습니다. 초안 생성과 검증까지는 자동화됐지만, 그게 쓸 만한지는 사람이 판단해야 하고 그 사람이 없습니다. 있는 것만 적는 게 맞습니다.
상황 2가 남긴 것
모델이 잘하는지 보려고 붙였는데, 먼저 나온 건 내가 만든 것의 결함이었습니다.
그리고 그중 둘은 모델을 붙여야만 보였습니다. 템플릿은 허용 목록을 그대로 찍으니 사실 문장을 인용할 일이 없고, 그래서 그 경로의 버그를 영원히 못 만납니다. 실제 사용자(여기서는 모델)가 예상 밖으로 코드를 쓸 때 드러나는 종류였습니다.
손으로 만든 입력으로 통과하는 테스트는 손으로 만든 만큼만 검증합니다. 이번에 제일 크게 배운 게 그거입니다.
두 상황을 관통하는 것
잣대를 먼저 만든 건 결과적으로 맞았습니다. 모델을 못 붙이는 동안 할 수 있는 일이 그것뿐이기도 했지만,
붙이고 나서 보니 잣대가 없었으면 무엇이 좋아졌는지 말할 수가 없었습니다.
그런데 그 잣대의 계약이 틀려 있었습니다. 지키려던 게 “타임라인에 있는 숫자만 써라”인 줄 알았는데
실은 “코드가 낸 숫자만 쓰고 모델이 지어낸 숫자는 쓰지 마라”였습니다. 분류기가 산출한 값도 코드가 낸
숫자인데, 허용 목록을 타임라인 금액으로만 만들어서 멀쩡한 초안을 반려하고 있었습니다.
그리고 그건 실제 모델을 붙여야만 보였습니다. 템플릿은 허용 목록을 그대로 찍으니 사실 문장을
인용할 일이 없고, 그래서 그 경로의 버그를 영원히 못 만납니다.
손으로 만든 입력으로 통과하는 테스트는 손으로 만든 만큼만 검증합니다.
여기까지는 “숫자가 맞는가”입니다. 그다음 질문은 “그래서 그 초안은 보낼 만한가”이고,
그걸 재려다 잣대가 네 번 나를 속였습니다(12편). 무르면 나쁜 게 통과하고, 빡빡하면 좋은 게 떨어지고, 요구하면 대상이 요구에 맞춰 변형되고, 맞아도 검사 밖은 못 봅니다.
그 잣대가 값을 한 건 두 번째 기능에서였습니다
잣대를 먼저 만든 값은 두 번째 기능이 그대로 재사용할 때 나왔습니다.
대사 화면에서 “이 주문에 무슨 일이 있었나”를 한 문단으로 돌려주는 운영자용 서술을 붙였습니다. 사실 묶음도, 숫자 출처 검증기도, 포트도 이미 있던 것을 그대로 썼습니다. 새로 만든 건 어댑터와 창구뿐입니다.
받는 사람이 상담원이 아니라 운영자라 가드가 오히려 줄었습니다.
| 고객용 초안 | 운영자용 서술 | |
|---|---|---|
| 내부 용어 | 막아야 한다 | 그대로 나와야 한다 — 화면과 대조해야 하니까 |
| 말투 | 다듬어야 한다 | 상관없다 |
| 없는 숫자 | 버린다 | 버린다 (유일하게 남는 위험) |
가드를 필요 없는 곳에 늘리면 무엇이 왜 있는지 흐려집니다. 남는 위험 하나만 막았습니다.
같은 기준으로 채점했습니다
포트를 열어 둔 값이 여기서 또 나옵니다. 모델 없는 템플릿 구현을 기준선으로 두고 같은 사실 10건에 나란히 태웠습니다.
| 출처 없는 숫자 | 기권 | 평균 길이 | |
|---|---|---|---|
| 템플릿(기준선) | 0건 | 0건 | 324자 |
| qwen3:8b | 0건 | 0건 | 232자 |
숫자로는 28% 짧습니다. 이 숫자를 개선으로 읽은 게 나중에 틀린 것으로 드러납니다(아래). 일단 길이만으로는 판단이 안 돼서 문장을 봤습니다.
템플릿 — 목록에 접속사만 붙인 것이라 원래 목록보다 읽기 어렵습니다.
주문 ORD-N000 — 2026-08-01 · ORDER · 주문 생성 10,000원 그다음 2026-08-01 · PAYMENT · 결제 READY → IN_PROGRESS (USER, TOSS_PAYMENTS) 그다음 …
qwen3:8b
주문번호 ORD-N000은 2026-08-01에 주문 생성 후 결제 상태가 READY에서 IN_PROGRESS로 변경되었고, 이후 DONE 상태로 변경되었습니다. … 2026-08-02에 대사 상태가 AMOUNT_MISMATCH로 변경되었으며, 내부 10,000원, 외부 9,700원으로 불일치했습니다.
상태 이름은 그대로 남아 화면과 대조됩니다. 의도한 그대로입니다.
길이를 품질로 읽은 게 틀렸습니다
찾아보니 평가자는 긴 답을 선호합니다. 자기 출력을 선호하고, 제시 순서에도 영향받습니다. 잘 연구된 편향이라 설계로 우회할 수 있는 것들인데, 저는 그걸 모르고 짧아진 것을 좋아졌다고 읽었습니다. 편향을 반대로 뒤집어 해석한 셈입니다.
길이는 품질의 프록시가 아닙니다. 지표에서 뺐습니다.
대신 둘을 나란히 놓고 고르게 했습니다
절대 점수는 회귀 감시에 쓰고, 프롬프트나 모델을 A/B로 견줄 때는 쌍 비교(pairwise) 를 씁니다. 둘을 나란히 놓고 어느 쪽이 나은지 고르게 하는 방식이 절대 점수보다 사람 판단과 잘 맞습니다. Chatbot Arena·MT-Bench가 그 계열입니다.
알려진 편향 셋을 설계로 막았습니다.
| 편향 | 어떻게 |
|---|---|
| 긴 답 선호 | 길이를 지표에서 뺐다 |
| 제시 순서 | 순서를 무작위로 하고 그 순서를 함께 저장한다 |
| 출처를 알면 기운다 | 고르기 전에는 어느 쪽이 모델인지 안 보여준다 |
동점도 답으로 받습니다. 억지로 고르게 하면 그 선택은 신호가 아닙니다. 그리고 한 번 고르면 바꾸지 않습니다 — 되돌리면 그건 다른 실험입니다.
POST /admin/orders/{orderNo}/narrative/compare → A/B 두 문단 (출처 없음)POST /admin/orders/narrative/compare/{id} → 고른다. 고른 뒤에야 출처 공개GET /admin/orders/narrative/compare/stats → 집계그런데 기본값은 안 바꿨습니다
“읽기 좋은가”는 저 셋으로 안 잡힙니다. 짧다고 좋은 문장이 아닙니다. 사람이 읽고 판단한 표본이 있어야 하는데 아직 없습니다.
같은 프로젝트에서 그 순서를 한 번 어겼습니다. 대사 잔여 원인에 모델을 붙일 때 기준을 고친 그 표본에서 다시 잰 값으로 켰다가, 홀드아웃에서 다시 재고 껐습니다(13편). 거기서 배운 건 이겁니다 — 켜는 기준은 절대 품질이 아니라 기존 방식 대비 개선이고, 그건 사람이 확인해야 한다.
분류는 규칙이 이미 답하고 있어서 껐고, 서술은 규칙이 아예 못 해서 붙일 값이 있습니다. 그래도 켜는 건 표본을 만든 다음입니다. 지금 쌍 비교 표본은 0건입니다.
심판 모델을 세워 봤더니, 순서만 보고 있었습니다
사람이 고른 표본이 0건이라 켜지 못하고 있었습니다. 업계 관행이 심판 모델로 대부분을 재고 사람은 보정과 엣지 케이스만 본다는 것이라, 1차 신호라도 만들어 보기로 했습니다.
편향 둘을 알고 있었으니 설계에 넣었습니다.
- 자기선호 편향 — 심판과 생성자가 같은 모델이거나 같은 계열이면 자기 출력을 후하게 줍니다. 그래서 서술은
qwen3:8b가 쓰고 심판은 계열이 다른llama3.1:8b가 봤습니다 - 위치 편향 — A/B 를 바꿔 두 번 묻고, 답이 뒤집히면 동점으로 셌습니다
결과입니다.
템플릿 0 · 모델 0 · 동점 8 (그중 순서 뒤집힘 8)여덟 건 전부 뒤집혔습니다. 심판이 고른 건 글의 질이 아니라 앞에 놓인 쪽이었습니다.
여기가 중요합니다. 순서를 안 뒤집었으면 8:0 이든 0:8 이든 깨끗한 숫자가 나왔을 것이고, 저는 그걸 근거로 켰을 겁니다. 편향 대책을 미리 설계에 넣어 둔 덕에 그게 신호가 아니라는 걸 알았습니다.
이 글 앞부분과 같은 이야기이기도 합니다 — 잣대가 나를 속이려 한 자리가 또 나온 겁니다. 12편에서는 채점 기준이 네 번 속였고, 여기서는 심판의 순서 편향이 속이려 했습니다.
그래서 기본값은 그대로 template 입니다. 다만 이유가 정확해졌습니다 — 표본이 없어서가 아니라, 모델 심판으로는 이 차이를 못 가린다는 걸 확인해서입니다. 사람이 골라야 합니다.
만들고 나서 다시 보니 남의 문장이 프롬프트에 들어가고 있었습니다
현업 개발자에게 “운영에 AI를 붙일 거면 보안이나 거버넌스를 신경쓰라, AI가 활동할 수 있는 범위를 확실히 제약하고 통제 가능한 걸 보여주라”는 조언을 받고 방금 만든 걸 다시 봤습니다.
사실 묶음의 문장이 전부 코드가 만든 게 아니었습니다.
"분쟁 접수 (%s) — %s".formatted(d.chargebackId(), d.reason())reason 은 카드사가 보낸 웹훅 페이로드에서 옵니다. 그 값이 타임라인 요약 → 사실 묶음 → 모델 프롬프트까지 그대로 갑니다. 제 문서에 “상품명·확정 노트·분쟁 사유가 전부 자유 텍스트다” 라고 경고를 이미 적어 뒀는데, 정작 프롬프트를 만들 때 그걸 안 봤습니다.
막는 게 아니라 담습니다
프롬프트 인젝션은 기준 자체가 예방에서 봉쇄로 옮겨갔습니다. 2026년 판 OWASP LLM Top 10 도 이걸 1위에 그대로 두면서 “모델은 속는다”를 전제로 깔고, 최소 권한·입출력 필터링·사람 승인·감사 로그를 권합니다.
| 권고 | 여기서 |
|---|---|
| 외부 내용을 지시와 분리해 제시 | 사실 목록을 경계 안에 넣고 그 안은 데이터라고 명시 |
| 입력 필터링 | 개행·제어문자 제거, 200자 상한 |
| 출력 필터링 | 출처 없는 숫자가 하나라도 있으면 문단째 폐기 |
| 최소 권한 | 쓰기 권한 없음. 읽기 전용 |
| 사람 승인 | 확정은 사람이 한다 |
| 감사 로그 | 모델 이름·출력·판정·사실 개수·시각 |
내용은 검열하지 않습니다. 무엇이 위험한 문장인지 판정하려 들면 그 판정이 또 틀립니다. 지키는 것은 구조뿐입니다 — 한 줄은 한 줄로 남게.
그리고 이걸로 막히지 않습니다. 진짜 방어선은 권한입니다. 뚫려도 할 수 있는 게 사람에게 거짓말하는 것까지고, 장부는 못 바꿉니다.
감사 로그에서 안 남기는 것
프롬프트 본문은 저장하지 않습니다. 사실 묶음에서 결정적으로 재구성되기 때문입니다. 대신 사실 개수와 완전성 플래그를 남겨 그때와 지금이 같은 입력인지 대조합니다.
재구성되는 걸 또 저장하면 두 곳이 언젠가 갈라지고, 갈라지면 어느 쪽이 맞는지 알 수 없습니다. 이 글 앞부분이 정확히 그 실패였습니다 — 허용 금액 목록을 코드가 만든 문장에서 되뽑다가 멀쩡한 초안을 반려했죠.
버린 것도 남깁니다. 화면이 빈 이유를 나중에 답할 수 있어야 하고, 폐기율 자체가 이 기능의 상태를 말합니다.
참고
- Klarna AI 상담 초기 발표: klarna.com/press
- Klarna CEO의 품질 저하 인정: Bloomberg, TechCrunch
- Uber Genie 온콜 코파일럿(도움률 48.9%): Uber 엔지니어링
- Meta 인시던트 대응(근본원인 상위 5개 안에 42%): engineering.fb.com
- Zalando 사후분석 파이프라인(표면적 귀인오류): Zalando 엔지니어링
- 쌍 비교가 절대 점수보다 사람 판단과 잘 맞는다: LLM-as-a-judge vs human evaluation (SuperAnnotate), Evaluating LLM-Evaluators (Eugene Yan)
- 평가자의 길이·순서·자기선호 편향: Limitations of the LLM-as-a-Judge Approach (arXiv 2410.20266)
- 프롬프트 인젝션은 예방이 아니라 봉쇄: OWASP 2026 LLM Top 10 — “The model will be fooled”, OWASP GenAI LLM Top 10 2026
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.