Engineering · Evaluation

광고 심의 에이전트: 판정 권한을 LLM에서 코드 검증기로 옮겨 실제 심의 34건의 판정을 고정하기

XGEN 캔버스로 광고 심의를 자동화하는 PoC에서 10/10이던 판정이 재실행에 8/10으로 흔들린 원인을 비결정성 세 층으로 분해했습니다. 최종 판정을 코드 검증기가 확정하게 바꾸고 추출과 심의를 6개 에이전트로 나눠, 실제 심의 34건 판정 34/34와 지적 재현율 67%를 얻었습니다.

기간
2026.07 – 2026.08
소속
개인 프로젝트
역할
설계·구현·실험

Key Results

34/34
판정 일치실제 심의 제출물 34건(반려 13·승인 21), 검증 규칙 동결 후 전량 실행
21/21
승인 건 무오탐같은 34건 중 승인 21건을 반려로 뒤집지 않음
38% → 67%
지적 재현율심의원 실제 지적 24건 기준, 단일 에이전트 대비 6에이전트 분업(16/24)

광고 심의는 심의원이 광고 시안과 영상을 처음부터 끝까지 보며 필수 문구, 출처와 증빙, 표현, 금전을 연상시키는 이미지, 영상 속 음성까지 확인하는 업무입니다. 이 업무를 XGEN 캔버스 워크플로우로 대체할 수 있는지 확인하는 개인 PoC를 진행했습니다. 첫날 결과는 좋았습니다. 위반 6종을 의도적으로 넣은 테스트 광고에서 에이전트는 1차 실행에 6건을 모두 적발했고, 같은 날 OCR, 세그멘테이션, 판례 검색 도구를 스스로 연쇄 호출해 실제 판례 접수번호를 인용한 반려 판정문까지 만들었습니다.

문제는 실제 심의 데이터로 옮긴 뒤에 드러났습니다. 10건 중 10건의 판정이 맞았다고 보고한 회차를 처음부터 다시 실행하자 8건으로 내려갔습니다. 같은 입력에 대한 판정이 실행마다 달라진다면 그 시스템은 심의 업무에 쓸 수 없습니다. 이후의 모든 작업은 "한 번 맞았다"가 아니라 "반복해도 같은 판정이 나온다"를 목표로 바뀌었습니다.

이 글은 판정이 흔들린 원인을 층위별로 분해한 과정, 최종 판정 권한을 LLM에서 코드 검증기로 옮긴 결정, 그 검증기 자체가 반복 실행에서 여러 번 뚫린 경위, 그리고 추출과 심의를 분리한 6에이전트 구성으로 지적 재현율을 끌어올린 결과를 다룹니다.

심의 업무를 도구와 에이전트로 옮긴 구조

요구사항은 실무자 매뉴얼을 기준으로 여섯 분류로 정리했습니다. 필수 문구(심의필 번호, 등록번호, 상품별 유의문구), 문구 크기(유의문구는 영상 세로의 1/40 이상으로 일정 시간 이상 노출), 금지 이미지(지폐, 동전, 화폐 기호 등), 금지 문구(절판 마케팅 등), 특정 상품명 노출 시 블러, 플랫폼별 규칙입니다. 분류마다 OCR, 비전, 규칙 엔진 중 어떤 능력이 필요한지 매핑했고, 그 능력을 MCP 도구 서버로 구현해 XGEN 에이전트 노드에 연결했습니다.

XGEN 캔버스
  입력(광고 파일 경로)
    → 에이전트 노드 (Claude Sonnet 계열, temperature 0)
        ├─ MCP 도구 서버
        │    ├─ 비전 OCR·의심 영역 탐지 (이미지·PDF·영상 프레임)
        │    ├─ SAM 2 세그멘테이션 (CPU)
        │    ├─ 음성 전사 (Whisper, 청크 병렬)
        │    └─ 결정론 검사 (필수 문구, 증빙 수치 대조)
        └─ 판례·규정 검색 (조건부 심의 사례 6,221건 임베딩)
    → 판정문 (지적사항 확실 / 참고사항)

정답은 실제 심의를 거친 제출물에서 가져왔습니다. 승인된 건과 반송된 건을 함께 모으고, 반송 건은 심의원이 실제로 남긴 반송의견을 정답으로 삼았습니다. 최종 평가셋은 34건(반려 13건, 승인 21건)이고, 반송의견에 적힌 심의원 지적은 24건입니다.

설계를 지배한 제약은 둘이었습니다. 첫째, XGEN의 MCP 도구 호출은 1회 30초의 하드 타임아웃을 가집니다. 에이전트가 이미지 검사를 6개 병렬로 던지면 4개만 성공했고, 12개를 던지면 30.7초 만에 전부 실패했습니다. 둘째, MCP 실행 서버는 세션의 공용 출력 큐에서 요청 ID를 대조하지 않고 첫 줄을 반환하는 구조라 한 세션에서 두 호출이 동시에 진행되면 응답이 뒤바뀔 수 있었습니다. 그래서 무거운 작업은 도구 내부에서 병렬화해 한 번의 호출 안에서 끝내는 쪽으로 설계했습니다. 영상 프레임 12장 검사를 도구 하나로 묶자 4.1~4.9초에 끝났습니다.

실데이터가 드러낸 첫 패턴: 못 읽은 것을 "없음"으로 단정한다

실제 제출물로 처음 실행했을 때 오판의 상당수는 같은 모양이었습니다. 9쪽짜리 PDF 중 3쪽이 30초 안에 검사되지 못했는데, 에이전트는 그 쪽에 있던 필수 문구를 "누락"으로 보고해 승인 광고를 조건부 승인으로 오판했습니다. 세로 17,208px짜리 시안은 고정 3분할로 읽으면 비전 API가 이미지를 축소해 글자가 뭉개졌고, 그 상태에서 보고한 "필수 문구 누락 4건"은 정답과 우연히 일치한 것이었습니다. 적응형 분할(밴드 약 1,200px)로 다시 읽자 4건 모두 실제로 존재했습니다.

원인을 좁히려고 실패 케이스를 텍스트 레이어 유무로 나눠 보니, 오판이 텍스트 레이어가 0자인 이미지형 PDF에 예외 없이 몰려 있었습니다. 여기서 도구 서버의 결함이 연달아 나왔습니다. PDF 검사가 고정 2타일이라 세로 19,983px 페이지는 타일당 1만px가 되어 API가 거부했고 페이지 OCR이 0자가 됐습니다. 더 파괴적인 것은 작업 디렉토리 이름이 초 단위 타임스탬프여서, 같은 초에 시작한 병렬 페이지 검사가 서로의 타일 파일을 덮어쓴 것입니다. 다른 페이지의 타일을 그 페이지 내용으로 OCR하는 일이 생겼고, 같은 문서를 3회 읽은 OCR 총량이 최대 70%까지 차이 났습니다. 원자 카운터와 프로세스 ID를 붙인 뒤에는 편차가 1.5%로 줄었습니다.

이 단계에서 내린 판단은 "판정에 관여하는 사실 확인은 LLM에서 떼어낸다"였습니다. 필수 문구 검사는 PDF 텍스트 레이어를 직접 읽어 정규화와 유사도로 매칭하고(0.16초, LLM 미개입), 텍스트 레이어가 없으면 누락 판정 자체를 거부하게 했습니다. OCR 텍스트에는 문자열 유사도 대신 3-gram 커버리지를 썼습니다. 한글 OCR은 "광고심의"를 "공고실의"처럼 시각적으로 닮은 음절로 바꾸기 때문에, 문자열 유사도로는 문구가 있을 때(0.56~0.79)와 없을 때(0.12~0.19)가 깔끔하게 갈리지 않았지만 3-gram 커버리지는 0.49~0.71과 0.00~0.03으로 분리됐습니다.

평가 하네스의 결함도 이때 찾았습니다. 한 회차에서 승인 광고가 "오탐 0건"으로 나왔는데, 실제로는 MCP 세션이 죽어 판정문이 0~42바이트였고 채점기가 빈 파일을 "지적 없음"으로 읽은 것이었습니다. 더 심각한 문제는 모든 실행이 같은 대화 식별자(default)를 공유한 것입니다. A 광고를 요청했는데 워크플로우가 이전 대화의 B 광고를 받아 B에 대한 완결된 판정문을 냈고, 로그에 A의 파일명은 한 번도 나오지 않았습니다. 오류가 아니라 그럴듯한 오심이라 눈으로는 잡을 수 없는 종류였습니다. 실행마다 고유 식별자를 부여하고, 워크플로우가 실제로 받은 입력과 요청 케이스를 대조해 다르면 저장을 거부하며, 300자 미만이거나 판정 헤더가 없는 판정문은 무효로 분리했습니다.

10/10이 8/10으로 내려간 이유: 비결정성을 세 층으로 분해

10건 전량 실행을 6회 반복해 재현성 매트릭스를 만들었습니다. 반려 6건은 6회 모두 반려, 승인 2건은 6회 모두 승인이었지만, 승인 영상 1건은 3회 흔들렸고 다른 승인 1건은 반려로 수렴했습니다. 흔들림을 추적하자 세 층이 나왔습니다.

첫 번째는 비전 OCR의 temperature가 설정되지 않아 기본값 1.0으로 돌던 것입니다. 같은 타일에서 동전탑 아이콘이 어떤 실행에서는 보이고 어떤 실행에서는 보이지 않았습니다. 비전, 음성 전사, 에이전트 모두 temperature 0으로 고정했습니다.

두 번째는 에이전트의 병렬 도구 호출입니다. 프롬프트에 "도구는 한 번에 하나씩"을 명시했지만 지시는 확률적으로 어겨졌고, 음성 전사와 영상 검사를 동시에 던진 회차에서는 직렬 큐에 걸린 두 호출이 모두 30초 타임아웃으로 실패해 전멸했습니다. 지시를 강화하는 대신 서버 안에서 두 작업을 병렬로 실행해 한 번에 반환하는 통합 도구를 만들어, 병렬로 호출할 여지 자체를 없앴습니다.

세 번째가 가장 근본적이었습니다. 판정문은 지적을 "확실"과 "참고"로 나누는데, 어떤 지적을 확실로 둘지는 모델이 붙인 자연어 라벨에만 의존했습니다. 모델이 스스로 자격을 부여하고 스스로 승인하는 구조였고, 그 결과가 실행마다 달랐습니다.

정확도와 지연 시간이 맞물린다는 것도 이 과정에서 배웠습니다. 밀집 타일의 OCR 응답이 잘리는 문제를 고치려고 max_tokens를 올리자 정확 모델이 텍스트를 끝까지 쓰면서 타일당 생성 시간이 몇 배로 늘었고, 8케이스 배치 전체가 타임아웃으로 실패했습니다. 타일이 2개를 넘으면 빠른 모델을 쓰는 자동 모드로 해결했고, 이후 한 축을 고치면 반대 축을 반드시 다시 측정하는 것을 절차로 삼았습니다.

판정을 코드로 옮기다: 세 가지 자격과 자동 강등

세 번째 층을 해결하기까지 세 가지 방식을 거쳤습니다.

선택지 장점 단점 판단
프롬프트 규칙 강화 구현 비용이 없음 자연어 지시는 확률적으로 어겨짐 기각
라벨([유형1/2/3]) 기준 코드 강등 판정 근거가 구조화됨 모델이 정답 지적을 다른 라벨로 분류하면 정답까지 강등(재현율 69% → 43%) 폐기
라벨과 무관한 3자격 코드 검증 모델 출력 형식과 독립, 사고마다 회귀 테스트로 고정 가능 강등만 할 수 있고 승격은 못 함, 확실 지적에서 빠지는 정답이 생김 채택

채택한 검증기는 판정문을 받아 지적마다 세 자격 중 하나를 갖췄는지 코드로 검사합니다. 자격 A는 도구가 낸 판정값을 근거로 인용했는가, 자격 B는 금지 표현 인용이 증거 원문에 실재하는가, 자격 C는 인용한 판례 접수번호가 이 광고의 증거에 실재하는가입니다. 하나도 갖추지 못한 지적은 "참고사항"으로 강등되고, 판정 라벨은 남은 확실 지적으로 다시 계산됩니다. 에이전트는 마지막 단계에서 이 검증 도구를 반드시 호출하고 반환문을 그대로 출력해야 하며, 러너는 검증 스탬프가 없는 판정문을 거부합니다.

def validate(verdict_md, evidence_text):
    findings = split_findings(section(verdict_md, "지적사항"))
    kept, demoted = [], []
    for f in findings:
        ok, why = check_finding(f, evidence_text)   # 자격 A/B/C 중 하나라도
        (kept if ok else demoted).append((f, why))
    label = "반려" if kept else "승인"              # 라벨은 코드가 재계산
    return render(kept, reference=demoted, label=label)

검증기는 실측 사고가 날 때마다 회귀 단언을 추가하는 방식으로 키웠습니다. 필수 문구 원문 인용("~있습니다")을 헤지로 오인한 과폭, 라벨 의존으로 인한 재현율 추락, 음성 전사 오류("1년입니다"가 "이현입니다"로 전사)를 "음성 수정 필요"로 지적한 사례를 차례로 고정했습니다. 회귀 단언은 12개에서 시작해 단일 에이전트 최종 회차에 39개, 멀티에이전트 최종 회차에 52개가 됐습니다.

가장 중요한 교훈은 v14와 v15에서 나왔습니다. v14가 10/10을 기록한 직후 같은 조건으로 재실행한 v15는 7/10이었습니다. 승인 광고 3건이 반려로 뒤집혔고, 원인은 자격 게이트 두 곳이 뚫려 있던 것이었습니다. 자격 A는 도구 필드명이나 노출 시간 추정값 같은 중립 측정치가 텍스트에 등장하기만 해도 통과했고, 자격 C는 아무 판례 번호나 인용해도 통과했습니다. 유사 판례를 끌어와 해석을 "확실"로 만든 것입니다. v14의 10/10은 그 회차에 에이전트가 우연히 구멍을 밟지 않았을 뿐이었습니다. 자격 A는 위반 방향의 판정값(missing, 출처 불완전, 금전 이미지 confirmed 등)만, 자격 C는 이 광고 증거에 실재하는 번호만 인정하도록 좁혔습니다. 에이전트가 검증 결과를 다시 타이핑하며 개행을 삼키면 섹션 매치가 실패해 검증 전체가 조용히 우회되는 문제도 이때 발견해 헤더 정규화를 넣었습니다.

검증기를 바꿀 때는 과거 회차를 다시 실행하지 않고, 저장된 판정문에 새 규칙을 오프라인으로 재적용하는 재검증 스크립트를 먼저 실행했습니다. 새 규칙이 과거의 진짜 반려를 잃지 않는지(회귀 0)를 확인한 뒤에만 전량 실행에 들어갔습니다.

추출과 심의를 나눈 6에이전트 구성

규칙을 동결한 단일 에이전트 최종 회차(v20)는 판정 34/34, 승인 무오탐 21/21을 기록했지만, 심의원 지적 24건 중 확실 지적으로 재현한 비율은 38%였습니다. 판정 라벨은 안정됐는데 지적 구성이 빈약했습니다. 단일 에이전트가 추출, 심의, 검증을 한 컨텍스트에서 수행하다 보니 추출 단계의 누락이 그대로 심의 누락으로 이어졌습니다. 원본 로그에는 도구가 금지 표현을 읽은 기록이 있는데, 장편 영상의 전사 347세그먼트와 프레임 32장 출력 속에서 판정문까지 올라오지 못한 경우가 있었습니다.

영상 파이프라인을 6개 에이전트로 재설계했습니다. 심사 프로파일 에이전트가 파일 경로에서 담당 심의원과 상품, 매체를 파싱하고 판례를 검색해 이 건의 심사 기준을 만듭니다. 음성 전사, 프레임 OCR, 비주얼 분석 에이전트가 각각 추출을 전담하고, 정합 검증 에이전트가 음성과 자막의 수치, 오전사, 검출 모순을 대조합니다. 총합 QA 에이전트는 모든 결과를 읽고 심의한 뒤 검증기를 반드시 통과합니다. 추출 전담 에이전트는 출력 전체가 전사와 OCR이라 증거가 유실되지 않습니다.

여기에 결정론 장치를 더했습니다. 금지 표현 후보 발굴을 모델의 주의력에 맡기지 않고, 도구가 검증기와 같은 금지 사전으로 원문을 스캔해 결과에 동봉합니다. 규정 검색은 능동 검색만으로는 검색을 건너뛰는 실행이 있어 규정 컨텍스트 강제 주입을 병행하도록 배선했습니다. 멀티에이전트를 연결하는 중에는 앞 에이전트의 스트리밍 결과가 리스트로 모일 때 XGEN 에이전트 노드가 이를 문자열로 캐스팅해 <generator object>가 프롬프트에 박히는 엔진 결함을 찾았습니다. 성공과 실패가 입력 리스트의 첫 항목 타입에 따라 갈리던 비결정성의 정체였고, 로컬 스택에 패치해 해결했습니다. 한때 수정을 컨테이너가 실제로 마운트하지 않는 경로에 적용해 놓고 1회 성공을 반영의 증거로 착각한 일도 있었는데, 재확인에서 오류가 재발해 바로잡았습니다.

1차 멀티 회차(n3)의 재현율은 42%였습니다. 미탐 14건을 전수 분석해 QA 강등 7건, QA 미반영 3건, 추출 미검출 4건으로 귀속하고 일곱 가지를 고쳤습니다. 예를 들어 필수 문구 지적을 항목별로 분리했고(여러 항목을 묶은 지적 하나가 한 항목 때문에 통째로 강등됐습니다), 장편 전사가 8~16K 토큰에서 잘려 15분 지점 이후 표현이 사라지는 문제는 max_tokens를 32768로 올리고 스캔 섹션을 출력 맨 앞으로 옮겨 해결했습니다.

결과

회차 비교는 판정 일치, 승인 무오탐, 지적 재현율 순의 사전식으로 했습니다. 오탐 1건은 재현 몇 건보다 치명적이기 때문입니다. 성적이 가장 좋은 회차를 정본으로 두고 새 회차는 항상 정본과 비교했으며, 프롬프트에 정답 문구나 케이스명을 넣는 개정은 금지했습니다.

지표 이전 이후 측정 조건
판정 일치(기존 10건) 8/10 (v7 재실행) 10/10 (v16·v17·v20) 같은 10건 반복 실행
판정 일치(전체) 28/34 (v16 첫 배치) 34/34 (v20, 규칙 동결) 반려 13·승인 21
승인 무오탐 17/21 (v17) 21/21 (v20·n3·n4) 승인 21건
지적 재현율 38% (v20 단일) 67%, 16/24 (n4 멀티) 심의원 실제 지적 24건
같은 문서 OCR 총량 편차 70% 1.5% 같은 PDF 3회, 디렉토리 충돌 수정 전후
장편 영상 도구 실행 시간 36.4~50.4초 27.1초 11분 영상, 30초 제한

v20의 34/34 중 1건은 1차 실행이 세션 오염으로 게이트에 거부된 뒤 세션을 재생성한 재실행에서 정답에 도달한 결과입니다.

회고와 남은 과제

판정 라벨은 안정됐지만 지적 구성은 여전히 회차마다 달라집니다. 실행 조건(규정 주입, 노드 스펙, 검색 모드)만 바꾼 베이스라인 n5는 30/34로 내려갔고, 회귀 4건을 모두 원인에 귀속했습니다. 금지 사전의 "명의"가 "원인불명의"의 부분 문자열에 걸린 형태소 오탐, 지적문 속 등록번호 10자리가 접수번호 정규식에 걸려 자격 C를 사칭한 경우, 도구의 간헐 실패 뒤 QA가 자체 판단해 검증기가 지적을 모두 강등한 경우, 한도 표기에 대한 문맥 판단 편차입니다. n4 당시에는 규정 강제 주입이 임베딩 한도 초과로 조용히 동작하지 않았다는 사실도 이때 확인했습니다. 개정을 얹은 n6도 32/34로 정본을 넘지 못했습니다.

반복된 실패의 뿌리는 결정론적으로 전달해야 할 사실(전사, OCR 원문, 판정값)을 LLM의 전사와 재량에 맡긴 데 있다고 진단했습니다. 다음 회차(n7)는 도구 서버가 원문과 위반 판정값을 직접 보관하고, 검증기가 QA가 구성한 증거 대신 그 원본과 대조하는 구조로 설계했습니다. 사전 검증에서 저장소 단위 테스트 11/11, 저장소가 없을 때 과거 회차 변경 0, n5와 n6 오탐 5건의 오프라인 복구를 확인했지만, 실행은 8번째 케이스에서 API 크레딧이 소진되어 중단됐습니다. 그 전에 완주한 문서 7건의 부분 채점은 판정 4/4, 무오탐 3/3이었습니다.

남는 교훈은 단순합니다. 한 번의 10/10은 결과가 아니고, 자연어 규칙은 확률적이며, 검증기 자체도 실험 대상입니다. 판정에 관여하는 규칙은 코드로 옮기고, 뚫린 사례는 즉시 회귀 테스트로 고정해야 합니다.