자체 서빙 모델 콘텐츠 필터
자기회귀 모델의 "생성 관성"을 역이용해, 응답 전에 분류를 강제하는 2-Step 기법으로 F1 18.1% → 87.0%.
사용 기술
Prompt EngineeringChain-of-ThoughtvLLMPythonGemini(데이터 검증)
문제#
자체 서빙 모델은 상용 API 와 달리 플랫폼 수준의 콘텐츠 필터가 없다. 즉 안전장치를 직접 만들어야 하는데, 가장 먼저 떠오르는 방법이 가장 먼저 실패했다.
- 자체 서빙 모델은 플랫폼 수준 콘텐츠 필터가 없어, 미성년자 대상 성적 콘텐츠가 생성될 수 있음
- "생성하지 마시오" 수준의 단순 지시는 거의 무효 — Baseline F1 18.1%, Recall 10.0%
- 법적·윤리적 리스크 대응과 서비스 안정성 확보가 시급함
Recall 10% 는 걸러야 할 열 건 중 아홉 건이 그대로 나간다는 뜻이다. 필터가 있다고 말할 수 없는 수준이었다.
접근법 선택#
세 가지 선택지를 놓고 비교했다.
- Fine-tuning — 학습 데이터 구축 비용이 크고 실험 한 번의 사이클이 길다
- 외부 분류기 — 추가 인프라와 지연이 붙는다
- 프롬프트 엔지니어링 — 즉시 배포 가능하고 실험 사이클이 짧다 ← 1차 접근법으로 선택
리스크 대응이 시급한 상황에서는 "가장 좋은 방법"보다 "가장 빨리 돌려볼 수 있는 방법"이 먼저다. 프롬프트로 어디까지 되는지 상한을 확인하고, 부족하면 그때 다음 수단으로 간다는 순서였다.
핵심 기법 — Think First, Flag Next#
자기회귀 모델은 한 번 생성을 시작하면 관성으로 중단하지 못한다. 바꿔 말하면, 생성이 시작되기 전에 판단을 끝내면 관성이 오히려 안전한 방향으로 작동한다.
유저 입력 + 캐릭터 프로필
│
▼
① Flag 분류
응답의 첫 토큰으로 MINOR / ADULT / SAFE 강제 출력
│
▼
② Chain-of-Thought
1. 캐릭터 나이 판정
2. 프로필·입력의 성적 요소 확인
3. 최종 판정
│
▼
응답 생성 / 차단방법 1 — Flag 분류: 생성 전에 못을 박는다#
baseline ~ v4 에서 자연어 지시, 수도코드 형태, 용어 정규화, 필터 위치 변경을 차례로 시도했지만 Recall 은 8~10% 에 머물렀다. 모델이 필터 지시를 인식은 하는데, 막상 응답을 쓰기 시작하면 콘텐츠 흐름에 끌려가는 것이 원인이었다.
그래서 응답의 첫 토큰으로 분류 태그를 강제로 뱉게 했다.
응답 형식: <flag>{MINOR|ADULT|SAFE}</flag> [응답 본문]효과는 두 갈래다.
- 사고 강제 — 응답 생성 전에 콘텐츠 성격을 먼저 판단해야 하므로 무의식적 생성이 막힌다
- 커밋 효과 —
<flag>MINOR</flag>를 이미 출력한 모델은 자기회귀적으로 "이건 미성년 건"임을 인지한 상태가 된다. 뒤따르는 토큰에서 차단 메시지가 나올 확률이 올라간다
한계도 분명했다. 첫 토큰에 즉답해야 하니 캐릭터 프로필에 나이가 간접적으로만 들어 있는 복잡한 맥락에서는 판단이 어렵다. v5 에서 Recall 20.7% 로 올랐지만 여전히 대다수를 놓쳤다.
방법 2 — CoT: 즉답의 부담을 덜어준다#
단일 토큰 결정으로는 "프로필에서 나이를 뽑아 판정" 이 안 된다면, 판정할 시간을 주면 된다. Flag 를 내기 전에 <think> 블록에서 구조화된 추론을 하도록 강제했다.
<think> [CHARACTER AGE CHECK] - Character A: "17세 고등학생" → MINOR - Character B: "대학교 3학년" → ADULT [SEXUAL/VIOLENT CONTENT CHECK] Character profile contains sexual settings: YES User input contains sexual/violent content: YES Combined assessment: YES [DECISION] MINOR + sexual content → MINOR <flag>MINOR</flag> </think> Minor character detected. Content cannot be generated.
세 단계로 나눈 이유가 각각 있다.
- CHARACTER AGE CHECK — 등장 캐릭터 전원의 나이·학생 여부를 ADULT/MINOR 로 명시 판정시킨다
- SEXUAL/VIOLENT CONTENT CHECK — 프로필과 사용자 입력 양쪽을 본다
- DECISION — 위 둘을 조합해 최종 Flag 를 낸다
프로필 스캔 강제(v8) 가 여기서 가장 크게 먹혔다. Character profile contains sexual settings: YES/NO 체크를 강제하자, 프로필에만 성적 설정이 있고 현재 입력은 일상적인 경우까지 잡히기 시작했다. 성인 콘텐츠(clean 모드) 차단율을 가장 많이 끌어올린 단일 변경이다.
방법 3 — Flag Inside Think (v14)#
v8 까지는 <flag> 를 </think> 뒤에 따로 뱉게 했는데, v14 에서 <think> 블록 안 [DECISION] 아래로 옮겼다.
- 후처리가 단순해진다 —
<think>블록만 지우면 flag 도 같이 사라져, 사용자에게 나가는 응답에 내부 분류 태그가 남지 않는다 - 성능은 유지 — F1 87.0% 로 v8(86.9%) 과 동등
단 DECISION 의 상세도가 성능을 좌우했다. v13 에서 DECISION 을 한 문장으로 줄이자 F1 이 80.2% 로 떨어졌고, 세 문장으로 되돌린 v14 에서 회복됐다. 줄여도 되는 지시와 아닌 지시가 있다.
보조 기법#
- Primacy Effect — 필터 모듈을 시스템 프롬프트 최상단에 배치해 모델의 주의를 끌어올렸다 (v6)
- 다국어 few-shot — 한·일·중·태 BLOCK/SAFE 예시를 넣어 다국어 입력 감지력을 확보했다 (v6). v11 에서 영어 예시만 남기자 성인 콘텐츠(clean) 감지가 약해지는 것을 확인해 되돌렸다
- MINOR / ADULT 필터 분리 유지 — 일반 모드에서 미성년자 보호와 성인 콘텐츠 제한을 별도 모듈로 뒀다
최종 프롬프트는 모드별로 다르게 적용된다. clean 모드는 두 모듈(미성년자 보호 + 성인 콘텐츠 제한), adult 모드는 한 모듈(미성년자 보호만). 각 모듈은 CoT few-shot 예시, 다국어 미성년자 감지 기준, 차단 절차, 필수 출력 형식을 포함한다.
실험 — 버전별 성능#
baseline 부터 v15 까지 15회 반복 실험으로 검증했다. 300건 테스트셋, 동일 추론 설정(temperature 1.0, top_p 0.95) 기준이다.
| 버전 | 핵심 변경 | Accuracy | Precision | Recall | F1 |
|---|---|---|---|---|---|
| baseline | 원본 프롬프트 (차단 규칙만) | 54.7% | 93.8% | 10.0% | 18.1% |
| v4 | 필터 위치 이동 | 55.3% | 100% | 8.7% | 15.9% |
| v5 | flag 출력 형식 추가 | 60.3% | 100% | 20.7% | 34.3% |
| v6 | 필터 최상단 이동 + 다국어 few-shot | 71.0% | 95.7% | 44.0% | 60.3% |
| v7 | Chain-of-Thought 강제 출력 | 82.7% | 88.9% | 74.7% | 81.2% |
| v8 | 프로필 스캔 강제 | 87.3% | 90.0% | 84.0% | 86.9% |
| v9 | CoT 순서 역전 (성적 판단 먼저) → 실패 | 71.3% | 90.0% | 48.0% | 62.6% |
| v10 | 필터 통합·압축 → 실패 | 81.7% | 88.6% | 72.7% | 79.9% |
| v11 | 영어 예시만 적용 → 되돌림 | 85.3% | 89.0% | 80.7% | 84.6% |
| v12 | clean 모드 fade-to-black | 85.3% | 88.4% | 81.3% | 84.7% |
| v13 | DECISION 축소 → 실패 | 82.0% | 89.3% | 72.7% | 80.2% |
| v14 | flag inside think + DECISION 3문장 | 87.3% | 89.4% | 84.7% | 87.0% |
| v15 | fade-to-black 제약 방식 재시도 → 실패 | 82.3% | 88.8% | 74.0% | 80.7% |
v1~v3 은 프롬프트 스냅샷을 보존하지 않아 최종 데이터셋으로 재실행하지 못했다. 이후로는 모든 버전의 프롬프트를 파일로 남겼다.
성능이 계단처럼 뛴 지점은 셋이다. v5 의 flag 도입(F1 15.9% → 34.3%), v7 의 CoT 도입(60.3% → 81.2%), v8 의 프로필 스캔 강제(81.2% → 86.9%). 나머지 버전의 절반은 실패로 끝났고, 실패도 그대로 기록에 남겼다.
핵심 발견#
차단율이 높아 보인 건 착시였다#
v6 에서 미성년 건(clean 모드) 50건 중 35건이 차단돼 수치상으로는 잘 작동하는 듯 보였다. 그런데 이 중 <flag>MINOR</flag> 로 차단된 건은 0건이었다. 전부 성인 콘텐츠 필터가 간접적으로 걸러낸 것이었다.
즉 clean 모드의 높은 차단율은 성인 필터라는 안전망 덕분이었고, 모델의 실제 미성년 감지 능력은 17% 에 불과했다. 그리고 adult 모드에는 그 안전망이 없다 — 미성년 감지만으로 차단해야 하므로, 여기가 진짜 위험 지점이었다.
총계 지표만 봤다면 놓쳤을 문제다. 이후로는 지표를 볼 때 어느 경로로 차단됐는지까지 쪼개서 봤다.
CoT 는 바로 그 지점을 고쳤다#
v7 에서 CoT 를 넣자, 모델이 <think> 안에서 캐릭터 나이를 명시적으로 확인한 뒤 <flag>MINOR</flag> 를 내는 패턴이 나타났다. 안전망이 없는 adult 모드의 미성년 차단율이 17/50 → 45/50 으로 약 2.6배 올랐다. CoT 가 가장 크게 기여한 영역이 정확히 가장 약했던 영역이다.
추론 순서가 성능을 좌우한다#
v9 에서 "성적 판단 먼저 → 나이 확인" 으로 순서를 뒤집자 F1 이 24.3%p 하락했다(86.9% → 62.6%). 나이 판정이 먼저 확정돼야 뒤따르는 판단의 기준이 선다. 성적 판단을 먼저 시키면 오히려 혼란을 준다.
CoT 는 "생각을 시킨다" 로 끝나는 기법이 아니라 무엇을 어떤 순서로 생각시킬지가 설계 대상이라는 걸 수치로 확인한 지점이다.
합치면 나빠진다 — 분리 구조는 유지해야 했다#
v10 에서 미성년자 보호와 성인 콘텐츠 제한을 하나의 필터로 통합해 프롬프트를 압축하려 했다. 토큰이 줄어드니 비용과 지연에 유리할 것이라 봤는데, F1 이 86.9% → 79.9% 로 떨어졌다. 두 판단은 기준이 다르고, 한 모듈에 넣으면 서로를 흐린다. 압축을 포기하고 분리 구조를 유지했다.
분류와 출력 제어는 별개의 능력이다#
clean 모드에서 성인 콘텐츠를 차단 메시지 대신 fade-to-black(암전) 으로 자연스럽게 넘기려 했다. 분류는 잘 됐다 — v12 에서 28건이 정확히 분류됐다. 그런데 그 다음이 안 됐다.
| 시도 | 지시 방식 | 완전 성공 |
|---|---|---|
| v12 | 추상적 지시 — "암전하라" | 0 / 28건 |
| v15 | 금지 목록 제약 — "~하지 마라" | 0 / 19건 |
| v15b | 행동 지시 — "빠르게 넘기고 다음 상황으로 가라" | 8 / 20건 |
모델은 "이건 성인 콘텐츠다" 라고 올바르게 분류하고도 곧바로 성적 묘사를 이어서 생성했다. 분류 능력과 출력을 세밀하게 통제하는 능력은 별개였다.
세 번째 시도에서 처음 의미 있는 결과가 나왔다. 차이는 "하지 마라" 를 "하라" 로 바꾼 것뿐이다. 이 모델에서는 추상적 지시나 금지 목록보다 단순하고 직접적인 행동 지시가 훨씬 잘 작동했다. 다만 v15b 는 감지율 자체가 66% → 40% 로 떨어져 배포하지 않았고, 감지와 출력 제어를 분리하는 구조가 필요하다는 결론으로 남겼다.
평가 데이터셋 직접 구축#
측정할 수 없으면 개선할 수 없다. 평가셋부터 만들었다.
3단계 필터링 파이프라인#
실서비스 데이터 499,397건에서 300건 테스트셋을 뽑았다.
| 단계 | 방법 | 통과 |
|---|---|---|
| 원본 | 실서비스 대화 로그 | 499,397건 |
| 1차 | 규칙 기반 — 키워드 프리필터 · 페르소나 나이 분석 · 성적/폭력 키워드 매칭 | 54,514건 |
| 2차 | LLM 검증 (gemma-4-26b) | 6,363건 |
| 3차 | LLM 정밀 검증 (gemini-3.5-flash) | 4,373건 |
| 최종 | 라벨별 샘플링 + 재검증 | 300건 |
최종 300건은 미성년 성적 콘텐츠 100 / 성인 성적 콘텐츠 100 / 일반 대화 100 으로 구성했고, 각 라벨은 다시 일반 모드 50건과 성인 모드 50건으로 나눴다. 모드별로 기대 동작이 다르기 때문이다.
규칙 기반 필터가 만든 오탐도 걷어냈다. 과거 회상("8세 때부터"), 세대 표기("3세"), 묘사 표현("소녀") 처럼 키워드만 걸리고 실제로는 해당 없는 케이스들이다.
라벨 오류 42% — 모델을 의심하기 전에 평가셋을 의심하라#
v7 결과를 분석하다가 데이터셋 자체가 틀렸다는 것을 발견해 전수조사에 들어갔다.
- 일반 대화 100건 중 83건 라벨 오류 — 성적 키워드가 들어 있는데 일반으로 라벨링돼 있었다
- 성인 성적 콘텐츠 100건 중 29건 불일치 — 사용자 입력이 아니라 모델 출력에만 성적 콘텐츠가 있는 케이스였다. 필터가 판단해야 할 대상은 입력인데 출력 기준으로 라벨이 붙어 있었다
- 일반 대화에 성인 모드 테스트가 아예 없었다
전수 재검증으로 300건 중 126건(42%) 의 라벨을 고쳤다. 그러고 나서야 정확한 성능 측정이 가능해졌다.
성능이 오르지 않는다고 생각했던 구간의 상당 부분이 실은 정답이 틀린 것이었다. 모델을 의심하기 전에 평가셋을 의심해야 한다는 걸 이 과정에서 얻었다.
부작용 검증#
차단률만 올리는 필터는 서비스를 망친다. 두 방향으로 확인했다.
출력 품질이 떨어지지 않았는가#
필터를 넣으면 모델이 몸을 사려 일반 대화의 품질까지 떨어질 수 있다. 루브릭으로 측정한 결과 3.790 → 3.794 로 유의미한 변화가 없음을 확인했다.
과잉 차단은 없는가#
실서비스에서 실제로 필터링된 123건을 전수 정오 분류했다.
| 구분 | 건수 | 비율 |
|---|---|---|
| 올바른 차단 | 87건 | 70.7% |
| 의문이 있는 차단 | 36건 | 29.3% |
의문이 남은 36건을 다시 분류해 과잉 차단 패턴 3가지를 뽑아냈다.
| 유형 | 건수 | 판단 |
|---|---|---|
| 폭력 콘텐츠를 성인물로 오차단 | 2건 | 오분류 — 성적 요소가 전무한 액션 씬 |
| 18–19세 + 명백한 성적 콘텐츠 | 16건 | 시스템 기준상 정당 (기준 자체는 별도 논의) |
| 18–19세 + 무해한 입력 | 12건 | 과잉 차단 — 프로필만 보고 차단 |
| 나이 추정 불확실 | 2건 | 과잉 차단 — 나이 미명시를 미성년으로 추정 |
| 경계 사례 | 4건 | 케이스별 판단 필요 |
여기서 나온 후속 개선 방향은 세 가지다. 폭력과 성적 콘텐츠를 분리 판단하고, 프로필이 아니라 현재 턴 기준으로 판단하는 로직을 넣고, 나이 미명시 시 차단 기준을 명확히 하는 것.
결과#
- 미성년자 성적 콘텐츠 차단 F1 18.1% → 87.0% (Recall 10.0% → 84.7%, Precision 89.4% 유지, Accuracy 54.7% → 87.3%)
- 루브릭 점수 3.790 → 3.794 로 출력 품질 저하가 없음을 확인하고, 양 서비스 QA 전원의 확인을 받은 뒤 배포
- 배포 후 콘텐츠 필터 관련 문의 접수 0건
- CoT 추가에 따른 응답 지연은 3~5초로, 서비스에서 함께 쓰는 외부 모델(5~10초)보다 빨라 체감 영향 없음
- 실험 과정을 논문 형식("Think First, Flag Next")으로 정리해 사내 공유
남은 한계#
프롬프트만으로 도달할 수 있는 상한을 확인한 프로젝트이기도 하다. 넘지 못한 선을 그대로 남긴다.
- 일반 모드 성인 콘텐츠 차단율 66% — 34%가 여전히 통과한다
- 성인 모드 과잉 차단 24% — 차단하지 않아야 할 성인 콘텐츠 12/50건을 막았다
- 프로필 기반 과잉 차단 — 프로필에 성적 설정이 있으나 현재 입력이 무해한 12건
- 세밀한 출력 제어는 프롬프트의 한계 밖 — fade-to-black 은 감지와 출력 제어를 분리하는 구조로 가야 한다
- 필터 프롬프트가 1,736 토큰 — 매 요청에 실린다. fine-tuning 으로 내재화하면 걷어낼 수 있다
- 외부 소형 분류기 하이브리드 — 도입하면 성인 콘텐츠(clean) 차단율을 85% 이상으로 올릴 여지가 있다
1차 접근법으로 프롬프트를 고른 판단은 맞았다고 본다. 한 달 안에 배포 가능한 수준까지 올렸고, 동시에 다음 수단(fine-tuning · 외부 분류기)이 무엇을 해결해야 하는지를 수치로 특정했다.