← 이력서로 돌아가기

자체 서빙 모델 콘텐츠 필터

자기회귀 모델의 "생성 관성"을 역이용해, 응답 전에 분류를 강제하는 2-Step 기법으로 F1 18.1% → 87.0%.

사용 기술

Prompt EngineeringChain-of-ThoughtvLLMPythonGemini(데이터 검증)

문제#

자체 서빙 모델은 상용 API 와 달리 플랫폼 수준의 콘텐츠 필터가 없다. 즉 안전장치를 직접 만들어야 하는데, 가장 먼저 떠오르는 방법이 가장 먼저 실패했다.

Recall 10% 는 걸러야 할 열 건 중 아홉 건이 그대로 나간다는 뜻이다. 필터가 있다고 말할 수 없는 수준이었다.

접근법 선택#

세 가지 선택지를 놓고 비교했다.

리스크 대응이 시급한 상황에서는 "가장 좋은 방법"보다 "가장 빨리 돌려볼 수 있는 방법"이 먼저다. 프롬프트로 어디까지 되는지 상한을 확인하고, 부족하면 그때 다음 수단으로 간다는 순서였다.

핵심 기법 — 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> [응답 본문]

효과는 두 갈래다.

한계도 분명했다. 첫 토큰에 즉답해야 하니 캐릭터 프로필에 나이가 간접적으로만 들어 있는 복잡한 맥락에서는 판단이 어렵다. 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.

세 단계로 나눈 이유가 각각 있다.

프로필 스캔 강제(v8) 가 여기서 가장 크게 먹혔다. Character profile contains sexual settings: YES/NO 체크를 강제하자, 프로필에만 성적 설정이 있고 현재 입력은 일상적인 경우까지 잡히기 시작했다. 성인 콘텐츠(clean 모드) 차단율을 가장 많이 끌어올린 단일 변경이다.

방법 3 — Flag Inside Think (v14)#

v8 까지는 <flag></think> 에 따로 뱉게 했는데, v14 에서 <think> 블록 안 [DECISION] 아래로 옮겼다.

단 DECISION 의 상세도가 성능을 좌우했다. v13 에서 DECISION 을 한 문장으로 줄이자 F1 이 80.2% 로 떨어졌고, 세 문장으로 되돌린 v14 에서 회복됐다. 줄여도 되는 지시와 아닌 지시가 있다.

보조 기법#

최종 프롬프트는 모드별로 다르게 적용된다. clean 모드는 두 모듈(미성년자 보호 + 성인 콘텐츠 제한), adult 모드는 한 모듈(미성년자 보호만). 각 모듈은 CoT few-shot 예시, 다국어 미성년자 감지 기준, 차단 절차, 필수 출력 형식을 포함한다.

실험 — 버전별 성능#

baseline 부터 v15 까지 15회 반복 실험으로 검증했다. 300건 테스트셋, 동일 추론 설정(temperature 1.0, top_p 0.95) 기준이다.

버전핵심 변경AccuracyPrecisionRecallF1
baseline원본 프롬프트 (차단 규칙만)54.7%93.8%10.0%18.1%
v4필터 위치 이동55.3%100%8.7%15.9%
v5flag 출력 형식 추가60.3%100%20.7%34.3%
v6필터 최상단 이동 + 다국어 few-shot71.0%95.7%44.0%60.3%
v7Chain-of-Thought 강제 출력82.7%88.9%74.7%81.2%
v8프로필 스캔 강제87.3%90.0%84.0%86.9%
v9CoT 순서 역전 (성적 판단 먼저) → 실패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%
v12clean 모드 fade-to-black85.3%88.4%81.3%84.7%
v13DECISION 축소 → 실패82.0%89.3%72.7%80.2%
v14flag inside think + DECISION 3문장87.3%89.4%84.7%87.0%
v15fade-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 결과를 분석하다가 데이터셋 자체가 틀렸다는 것을 발견해 전수조사에 들어갔다.

전수 재검증으로 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건케이스별 판단 필요

여기서 나온 후속 개선 방향은 세 가지다. 폭력과 성적 콘텐츠를 분리 판단하고, 프로필이 아니라 현재 턴 기준으로 판단하는 로직을 넣고, 나이 미명시 시 차단 기준을 명확히 하는 것.

결과#

남은 한계#

프롬프트만으로 도달할 수 있는 상한을 확인한 프로젝트이기도 하다. 넘지 못한 선을 그대로 남긴다.

1차 접근법으로 프롬프트를 고른 판단은 맞았다고 본다. 한 달 안에 배포 가능한 수준까지 올렸고, 동시에 다음 수단(fine-tuning · 외부 분류기)이 무엇을 해결해야 하는지를 수치로 특정했다.

최종 수정 2026.08.12 · 요약은 이력서에 있습니다.