goodchuck 블로그

AI 가 쓴 글에서 지어낸 숫자 걸러 내기 — 숫자 검사로 자동 게시까지 가 본 기록

9분 읽기

오시레이더에는 버튜버의 한 주를 정리하는 결산 페이지가 있습니다. 순위표는 데이터에서 자동으로 뽑지만, 표 위의 해설을 매주 손으로 쓰기는 부담스러웠습니다. 그래서 Claude 에게 맡기기로 했습니다.

문제는 사람 이름이 들어가는 통계 글이라는 점입니다. "1만 명 늘었어요"를 "5만 명 늘었어요"로 쓰면 그대로 틀린 정보가 됩니다. LLM 이 없는 사실을 지어내는 환각이 통계 글에서는 이렇게 숫자로 나타납니다. 결론부터 적으면 네 겹으로 막았습니다.

  • 입력을 줄입니다. 모델에게는 해설에 필요한 표만 넘깁니다.
  • 규칙을 적습니다. 있는 숫자만 쓰고, 이유를 짐작하지 않게 합니다.
  • 숫자를 코드로 검사합니다. 데이터에 없는 숫자가 나오면 공개하지 않습니다.
  • 뜻은 사람이 봅니다. 코드로 못 잡는 오류가 남기 때문입니다.

이 글은 그중 세 번째인 숫자 검사가 무엇을 잡고 무엇을 놓치는지를, 실제 코드를 돌려 본 결과로 정리한 기록입니다.

프롬프트 규칙 — 필요하지만 믿지는 않는다

시스템 프롬프트에는 이런 규칙을 넣었습니다.

  • JSON 에 있는 사실과 숫자만 씁니다.
  • 왜 늘었는지처럼 데이터에 없는 이유를 짐작하지 않습니다.
  • 이름 · 소속은 받은 그대로 쓰고, 성별 대명사를 쓰지 않습니다.
  • 표를 그대로 읽지 말고 위아래 간격, 소속사 쏠림처럼 표만 봐서는 안 보이는 것을 말합니다.

마지막 규칙 때문에 위험이 생깁니다. 표를 그대로 옮기라고 하면 안전하지만 읽을 이유가 없고, 해석하라고 하면 표에 없는 값을 만들 여지가 생깁니다. 게다가 규칙은 모델이 지키기를 바라는 것이지 보장이 아닙니다. 그래서 프롬프트와 별개로 코드로 확인하는 단계를 뒀습니다.

입력에서 뺀 것 — 방송 제목은 남이 쓴 글이다

컨텍스트를 쌓는 글이 AI 에게 자료를 더 주는 이야기였다면, 여기서는 반대로 뺍니다. 모델에게 넘기기 전에 표를 한 번 거릅니다.

def model_input(report: dict[str, Any]) -> dict[str, Any]:
    """모델에 넘길 표 — 그림 주소 · slug · 방송 제목은 뺀다(제목은 남이 쓴 글이다)."""
    drop = {"imageUrl", "thumbnailUrl", "posterUrl", "slug", "title", "body", "notes", "timeline"}

    def clean(v: Any) -> Any:
        if isinstance(v, dict):
            return {k: clean(x) for k, x in v.items() if k not in drop}
        return [clean(x) for x in v] if isinstance(v, list) else v
    return {str(k): v for k, v in clean(report).items()}

그림 주소 같은 값은 글에 쓸 일이 없고, 방송 제목은 입력으로 믿을 수 없습니다. 제목은 누구나 마음대로 쓸 수 있는 글이라, "지시를 무시해" 같은 문장이 그대로 모델에게 들어갈 수 있습니다. 테스트에도 이 문장을 넣어 입력에 섞이지 않는지 확인합니다.

def test_model_input_drops_titles_and_urls() -> None:
    sent = json.dumps(model_input(REPORT), ensure_ascii=False)
    assert "지시를 무시해" not in sent and "https://" not in sent and "옛 글" not in sent
    assert "11841" in sent

응답도 모양을 고정했습니다. JSON 스키마로 들어가는 글(paragraphs)과 표마다 놓을 글(notes)을 받고, 끝까지 쓰지 못했거나(stop_reason 이 end_turn 이 아닐 때) 내용이 비어 있으면 실패로 처리합니다.

숫자 검사 — 글의 숫자가 표에 있는가

원리는 단순합니다. 글에서 숫자를 뽑고, 그 숫자가 표에서 나올 수 있는 값인지 봅니다. 어려운 곳은 같은 값을 쓰는 방법이 여러 가지라는 점입니다.

표의 11841 은 글에서 1만 1,841명, 1.2만 명, 약 1만 2천 명으로 쓰일 수 있습니다. 그래서 양쪽을 다 넓혔습니다.

표 쪽: 숫자마다 쓰일 수 있는 꼴을 미리 만들어 둡니다.

def add(n: float) -> None:
    out.add(n)
    if n != int(n):
        out.update({round(n), int(n)})
        return
    for unit in (10, 100, 1000, 10000):
        if n >= unit:
            out.update({round(n / unit) * unit, n // unit * unit})

그대로의 값, 10~1만 단위로 반올림하거나 내린 값을 모두 넣습니다. 방송 시간은 분으로 저장돼 있어서, minutes 는 시간과 나머지 분도 추가합니다. 911분이 글에서 "15시간 11분"이 되기 때문입니다.

글 쪽: 한국어 숫자 표기를 값으로 바꿉니다.

for m in re.finditer(r"(\d+(?:\.\d+)?)만(?:\s*(\d[\d,]*)(천)?)?|\d[\d,]*(?:\.\d+)?", text):
    raw = m.group(0)
    if m.group(1):
        rest = float((m.group(2) or "0").replace(",", ""))
        value = float(m.group(1)) * 10000 + rest * (1000 if m.group(3) else 1)
    else:
        value = float(raw.replace(",", ""))

처음에는 이 정규식이 2만 3천 을 제대로 읽지 못해서 한 번 고쳤습니다. 10 이하의 수는 "3위까지"처럼 표와 상관없이 자연스럽게 쓰이니 검사에서 뺐습니다.

실제로 돌려 본 결과

말로만 하면 믿기 어려우니, 운영 코드의 함수를 그대로 꺼내 문장을 넣어 봤습니다. 표에는 7194, 2597, 2273 같은 값을 넣었습니다.

문장결과판단
"5만 명이 늘었어요"경고맞음, 표에 없는 값
"60%가 늘었어요"경고맞음, 표에 없는 값
"2,200명에서 2,600명 사이"통과맞음, 2,273과 2,597의 반올림 · 내림
"15시간 넘게"통과맞음, 911분에서 나온 값
"4,597명 벌어져 있어요"경고틀림, 7,194 − 2,597 로 맞는 값
"9천 명 넘게 벌어져 있어요"통과틀림, 표에 없는 값

아래 두 줄이 이 검사의 한계를 그대로 보여 줍니다.

숫자 검사가 못 잡는 것 세 가지

1. 계산한 값은 맞아도 잡힌다

"4위와 5위 사이는 4,597명"은 맞는 말이지만, 표에 그 값이 없으니 경고가 납니다. 잘못 잡는 쪽이라 글이 공개되지 않을 뿐 틀린 정보가 나가지는 않습니다. 그래서 코드에 한계를 적어 두고 그대로 두었습니다.

"""데이터에 없는 숫자. shortcut: 두 값의 차이 · 합은 잡히니 사람이 확인한다."""

2. '천' 단독 표기는 그냥 통과한다

이 글을 쓰려고 돌려 보다 찾은 구멍입니다. 정규식은 만 뒤에 오는 천 만 읽기 때문에, "9천 명"은 9 로 읽힙니다. 10 이하라 검사를 그냥 통과합니다. 실제 첫 결산에도 "4천 명 넘게 벌어져 있어요"라는 문장이 있는데, 이 문장은 내용이 맞았지만 검사 덕분에 통과한 것은 아니었습니다.

테스트에 4만 1천 은 있었지만 4천 은 없었습니다. 검사 코드에도 빠진 경우가 있을 수 있으니, 만든 뒤 일부러 틀린 문장을 넣어 보는 일이 필요했습니다. 이 구멍은 따로 고칠 일로 남겼습니다.

3. 숫자가 맞아도 뜻이 틀릴 수 있다

가장 근본적인 한계입니다. 첫 초안은 표의 견준 인원을 잘못 읽었습니다. 숫자 자체는 표에 있으니 검사로는 잡을 수 없는 오류입니다.

"이 사람이 1위" 같은 문장에서 이름과 숫자가 뒤바뀌어도 마찬가지입니다. 숫자가 존재하는지만 볼 뿐, 어디에 붙은 숫자인지는 보지 않기 때문입니다. 이번 오류는 프롬프트에 인원의 뜻을 풀어 적고, "그 인원수는 데이터에 없으니 쓰지 않습니다"라는 규칙을 넣어 막았습니다. 다음에 어떤 오류가 나올지는 미리 알 수 없습니다.

그래도 자동 게시로 간 이유

처음 결정은 "AI 가 초안을 쓰고, 사람이 고쳐 PR 로 올린다"였습니다. 그런데 매주 손이 가면 해설을 자동으로 만드는 의미가 줄어듭니다. 지금은 방문이 많지 않아 틀렸을 때 피해가 작기도 해서, 같은 날 결정을 바꿨습니다. AI 세션을 나눠 돌리는 글에 적은 것처럼 코드의 머지와 배포는 여전히 사람이 정하지만, 결산 글은 공개한 뒤에도 고칠 수 있어서 기준을 다르게 잡았습니다. 다만 공개 조건을 나눴습니다.

경우처리
주간, 숫자 경고 없음바로 공개하고 알림
주간, 숫자 경고 있음숨기고 알림
해설 실패, API 키 없음표는 남기고 숨김
월간항상 숨기고, 사람이 본 뒤 공개
이미 있는 기간건드리지 않음 (고친 글 보호)

코드로는 한 줄입니다.

status = PUBLISHED if is_week and not warnings else HIDDEN

"일단 공개하고 고치기"를 택한 만큼, 고칠 길을 함께 두었습니다.

  • 알림: 공개되면 알림이 와서 글을 읽어 봅니다.
  • 표시: 페이지에 "이 결산의 글은 AI 가 표를 보고 썼어요. 표의 숫자는 기록에서 그대로 가져왔어요."라고 밝힙니다.
  • 신고: 바로 옆에 "틀린 곳 알려 주기" 버튼을 둡니다.
  • 수정: 고친 글은 다시 덮어쓰지 않습니다.

첫 결산은 직접 넣었고, 자동 게시는 끝난 주가 생기는 11월 9일부터라 아직 실제로 돌지 않았습니다. 몇 번 돌려 본 뒤 경고가 얼마나 나오고 뜻이 틀린 글이 얼마나 나오는지는 따로 확인할 생각입니다.

정리

막는 곳방법잡는 것못 잡는 것
입력필요한 값만 넘김제목에 숨긴 지시, 쓸데없는 값표 안의 값을 잘못 읽는 일
프롬프트규칙 명시이유 짐작, 표기 실수보장이 아니라 지키기를 바라는 것
숫자 검사표의 값과 대조없는 숫자계산값(과하게 잡음), 9천 표기, 뜻
공개 조건경고 · 실패면 숨김위에서 잡힌 것이 나가는 일경고 없이 틀린 글
사람알림 · 신고 · 수정뜻이 틀린 글읽기 전까지의 시간

AI 글을 믿을 수 있게 만드는 한 가지 방법은 없었습니다. 겹겹이 거르고, 각 겹이 무엇을 놓치는지 아는 것이 현실적인 답이었습니다. 특히 검증 코드는 통과하는 예시만 보면 안심하기 쉬워서, 틀린 문장을 일부러 넣어 보기 전까지는 믿지 않는 편이 안전합니다.