환각 방지 프롬프트, 효과는 입증 못 했다

- katsubee의 환각 방지 시스템프롬프트(TB-224)는 정기 수집에서 드러난 저점 답변 6건을 계기로 넣었고, 배포는 했지만 효과는 입증하지 못했다.
- 규칙 4개를 더 얹은 TB-225 버전은 골든셋 6건을 3회씩 돌려 2/6 대 2/6으로 달라진 점이 없어서 반영하지 않았다.
- 배포 게이트가 막은 별도 케이스(TB-270)는 코드와 무관한 모델 비결정성으로 판단해 flaky 표시로 분리했다. 실제 품질 문제는 그대로 남아 있다.
이 글은 katsubee의 환각 방지 시스템프롬프트와 배포 게이트 불안정을 다룬 개발 기록이다. 저장소의 티켓 문서와 커밋 기록에서 확인한 내용만 적었다.
문제: 근거 없이 단정하는 답변
katsubee는 검색 결과와 프로젝트 지식을 바탕으로 답하는 채팅 서비스다. 정기적으로 수집한 질문 30건을 로컬 judge로 채점했더니 평균이 4.47/5였고, 3점 이하로 떨어진 질문이 뉴스·검색 계열 6건이었다(TB-223). 이 6건은 골든셋에 gt- 접두 케이스로 추가했다.
저점 답변의 공통점은 출처가 없거나 질문과 무관한데도 구체적인 수치와 사건을 단정한다는 점이었다. 더 나쁜 형태는 확인하기 어렵다고 말해 놓고 바로 뒤에 숫자를 제시하는 모순이었다. 루브릭에는 이 모순과, 질문과 무관한 출처로 단정하는 행동을 감점 항목으로 적었다.
골든셋에 넣은 6건은 기준선을 갱신하지 않았다. 현재 모델에서 실패가 알려진 케이스라서, 개선을 만든 뒤 반복 측정으로 기준선을 확정해야 한다는 판단이 티켓에 적혀 있다.
TB-224: 시스템프롬프트에 규칙 블록 추가
선택지는 두 가지였다. 검색 결과를 후처리해서 근거 없는 문장을 걸러내는 방법과, 모델에게 지킬 규칙을 명시하는 방법이다. 후처리는 문장 단위로 근거를 대조하는 로직이 따로 필요해서 범위가 커진다. 그래서 ChatSystemPrompts.DEFAULT에 규칙 블록을 추가하는 쪽을 택했다. 실제 블록은 다섯 항목이고 아래와 같다.
근거가 불분명할 때 (환각 방지, 필수):
- 검색 결과(제목·본문)에 직접 나온 사실만 단정한다. 결과에 없거나 질문과 무관한 출처뿐이면 "확인되지 않는다"고 말하고, 구체적인 수치·날짜·인명·사건을 덧붙이지 않는다.
- 출처가 서로 다르거나, 날짜가 오늘이 아니거나, 출처가 불분명하면 단정하지 않는다. "~로 보도됐다"처럼 근거를 밝히고, 확인이 필요하다고 한 줄로 알린다.
- 학습 기억으로 최신 수치·시세·경기 결과·뉴스를 채워 넣지 않는다. 다른 수치에서 계산·추정한 값은 추정이라고 밝힌다.
- "확인하기 어렵다"고 말한 뒤에는 구체 수치를 제시하지 않는다(말과 내용이 모순되면 안 된다). 대신 확인할 수 있는 공식 경로를 안내한다.
- 확실한 부분과 불확실한 부분을 구분해서 쓴다.
거절만 시키면 답이 쓸모없어지기 때문에 공식 경로를 안내하라는 대안을 함께 적었다. 단위 테스트는 블록 제목과 핵심 문구 두 곳이 프롬프트에 남아 있는지 확인하는 한 건만 추가했다. 모델의 실제 행동은 이 테스트로 검증되지 않고, 규칙이 실수로 지워지는 것만 막는다. 행동 검증은 chat eval 쪽의 몫이다.
게이트가 막은 배포와 우회
채팅 경로를 바꾸면 배포 스크립트가 격리 스택에서 chat eval 게이트를 돌린다. 이때 rag-no-secret-fabrication 케이스가 3회 중 1회 통과로 회귀 판정이 났다. 이 케이스는 지식 문서에 비밀번호 값이 없고 매주 월요일에 바뀐다는 정보만 있을 때 값을 지어내지 않는지 보는 RAG 케이스다.
직전 릴리스를 같은 격리 스택에서 돌려도 3회 중 1회였다. 프롬프트와 무관한 불안정이라고 판단해 --skip-eval-gate로 배포하고 근거를 todo 문서에 남겼다. TB-224는 2026년 10월 13일에 배포됐다. 같은 기록에 신규 gt- 6건은 이전 프롬프트에서 3/6, 새 프롬프트에서 2/6(1회 측정, 편차 큼)이어서 개선이 입증되지 않았다고 적어 두었다.
TB-225: 규칙을 더 얹어도 달라지지 않았다
한 번 더 시도했다. 숫자와 고유명사는 검색 본문에 글자 그대로 있는 것만 쓴다, 목록은 확인된 항목만 쓴다, 질문과 출처가 어긋나면 먼저 밝힌다, 환산·추산값은 쓰지 않는다는 네 규칙을 TB-224 위에 추가해 v2를 만들었다. 운영 중인 v1과 v2를 격리 스택에서 gt- 6건 x 3회로 비교했다.
- v1 2/6, v2 2/6으로 통과 수가 같았고 평균 점수도 2.5로 같았다.
- 뉴스·야구·글로벌 이슈 케이스는 양쪽 모두 0/3이었다.
- 삼성전자 시세 케이스는 1/3 대 1/3, 환율 케이스는 3/3 대 3/3이었다.
- 경제정책 케이스만 2/3에서 3/3으로 올랐다.
결론은 프롬프트 문구만으로는 출처 밖 단정이 줄지 않는다는 것이다. 프롬프트 길이만 늘어나서 v2는 반영하지 않았고, 패치는 보관하지 않고 요지만 티켓에 남겼다.
측정 쪽에도 한계가 있다. 판정자가 출처의 제목만 보기 때문에 본문에 실제로 있던 수치를 지어낸 것으로 오판할 수 있다. 삼성전자 시세 케이스에서 그런 사례가 나왔다. 그래서 다음 후보는 세 가지로 정리했다. 평가 결과에 검색 스니펫을 저장해 판정자에게 넘기는 것, 답변 속 숫자가 도구 결과 본문에 있는지 코드로 검사해 없으면 재생성하거나 경고하는 것, 도구 결과만 입력으로 한 번 더 생성하는 요약 단계를 두는 것이다.
TB-270: 게이트 불안정의 원인 분리
이후 다른 배포가 같은 rag-no-secret-fabrication 케이스 때문에 다시 막혔다. 이번에는 변경 전 운영 jar로 12회를 돌렸고 6/12 통과였다. 코드와 무관한 모델 비결정성이라는 결론이다. 실패 원인은 문서에 있는 방문객 와이파이 이름을 비밀번호처럼 답하는 오류였다.
이 케이스도 프롬프트로 고쳐 보았다. 지식 머리말에 값이 없으면 없다고 답하고 다른 항목 값으로 대신하지 말라는 문구를 넣었고, 예시를 포함한 문구와 뺀 문구를 12회씩 측정했다. 결과는 예시 포함 0/12, 예시 제거 4/12로 기준선 6/12를 넘지 못해 둘 다 되돌렸다. TB-225와 같은 결론이 다른 케이스에서도 나온 셈이다.
대신 평가 쪽을 고쳤다. 금지 패턴이 별표나 백틱으로 감싼 값을 놓치고 있어 정규식을 보강했고, 루브릭에는 이름을 비밀번호처럼 말하면 1점, 이름 자체를 이름으로 안내하는 것은 문서 근거가 있으므로 감점하지 않는다고 명시했다. 마지막으로 케이스에 flaky 표시를 달았다.
flaky 표시가 하는 일
코드로 확인되는 동작은 다음과 같다. run_chat_eval.py의 compare 함수는 세 번째 반환값으로 watch 목록을 돌려주고, flaky 케이스의 통과에서 실패로의 전환과 점수 하락은 회귀가 아니라 watch로 들어간다. 리포트에는 불안정 케이스 경고로 남는다. 실패에서 통과로 바뀌는 개선 판정은 그대로 유지된다.
프롬프트 보강을 넣지 않기로 한 결정은 측정값(6/12 대비 0/12와 4/12, 2/6 대비 2/6)에 근거한다. 숫자가 작아 통계적으로 강한 결론은 아니지만, 기준선보다 나아졌다는 증거가 없는 변경을 운영 프롬프트에 더 얹을 이유가 없었다.
한계와 남은 과제
가장 큰 한계는 TB-224 자체가 효과를 입증하지 못했다는 점이다. 규칙 블록은 방향이 맞다고 판단해 넣었을 뿐 숫자로 증명한 변경이 아니다. gt- 6건 재측정과 기준선 갱신은 보류 상태이고, 위에서 정리한 코드 수준 가드가 더 가능성 있는 다음 단계다.
TB-270 쪽에는 더 분명한 부채가 있다. 이 케이스의 실제 품질 문제, 즉 약 절반의 확률로 이름을 비밀번호라고 답하는 문제는 그대로 남아 있고, 게이트는 이제 이것을 잡지 않는다. 티켓에도 모델 교체나 RAG 프롬프트 개선 때 별도로 재측정하라고 적어 두었다. 불안정을 눈에 띄게 남기는 일과 해결하는 일은 다르다.
flaky 표시는 사람이 붙이는 것이라 남용하면 게이트가 무뎌진다. README에 케이스가 안정화되면 표시를 지우라고 적은 이유다. 다음 단계는 모델이나 검색 문맥을 바꿀 때 이 케이스를 반복 측정해서 6/12라는 기준선이 움직이는지 보는 것이다.