본문으로 건너뛰기

쪼개고, 묶고, 다시 나누기 — 콜은 65% 줄었는데 비용은 왜 20%만 줄었을까

박재민
개발 프로젝트AILLM

들어가며

안녕하세요, 개발자 박재민이에요. 1편에서는 원문 전체를 한 번에 다시 쓰게 했다가 543자를 통째로 복제하는 문제를 겪었어요. 이번 편에서는 그 뒤에 재작성 구조를 어떻게 바꿨는지, 그리고 매번 무엇이 예상과 달랐는지를 정리해볼게요.

문단 단위로 쪼갰어요

처음에는 원문 전체를 문단 단위로 나눴어요. 실험에 사용한 원문은 5,102자, 31문단, 95문장으로 구성돼 있었어요. 문단마다 LLM 호출(콜)을 두 번 썼어요. 다시 쓰기 1콜과, 원래 문단과 비교해 뜻이 같은지 보는 검증 1콜이에요. 문제가 있는 문단만 지적 사항을 피드백으로 붙여 다시 썼고 마지막에는 조립한 기사 전체를 원문과 한 번 더 대조했어요.

같은 원문을 한 번에 처리하는 1콜 방식(1편의 프롬프트 v5)을 5회 실행한 결과와, 문단 단위로 1회 실행한 결과를 비교했어요. 문단 단위 결과는 아직 한 번뿐이라 나쁜 회차가 없었다고 단정할 수는 없어요. 결과는 다음과 같았어요.

  • 40자 이상 연속 복제: 1~10건, 5회 중 3회 발생 → 0건
  • 원문에 없는 발화 동사 생성: 12건 → 0건
  • 세 번 연속 빠지던 사실: 다시 포함됨
  • 창작한 수치: 0건
  • 누락한 수치: 0건
  • 짧은 문장을 그대로 복제한 경우: 0/19

원문에 없는 발화 동사를 만들어내는 문제도 사라졌는데, 문단이 짧아 원문에 있던 동사가 눈앞에 있기 때문이라고 봐요. 세 번 연속 빠지던 사실도 다시 살아났어요.

문장 1:1 대응은 일부러 강제하지 않았어요. 원문 문장의 21%가 30자 미만이었기 때문이에요. 짧은 문장을 1:1로 맞추면 바꿀 여지가 없어서 그대로 나와요. 문장 병합과 분할을 허용하자 짧은 문장 복제가 사라졌어요.

오류가 국소화된다는 점도 좋았어요. 31문단 중 3문단에서 검증기가 문제를 잡았고 해당 문단만 다시 써서 2건을 고쳤어요. 남은 1건은 고치지 못한 채 남았는데, 그 지적은 검증기가 옳았어요. 예를 들어 원문의 「점유율을 늘려야 한다」가 「독식해야 하며」로 바뀌면 뜻이 더 강해져요. 실제 기사 문장이 아니라 같은 유형을 보여주기 위한 가상 예시예요.

대신 대가가 컸어요. 한 번이던 요청이 65번으로 늘었고 비용은 약 7.7배가 됐어요. 기사 하나를 완성하는 데 65~86번의 요청을 순차로 실행해야 해서 30분 주기 배치에 걸릴 만큼 지연도 커졌어요.

작업 단위를 작게 쪼개면 모델이 집중해야 할 범위가 줄어 품질은 좋아져요. 대신 요청 수가 곱으로 늘어나요.

검증을 줄이려다 기각한 코드 스크리닝

같은 원문을 세 모델로 돌린 결과를 다시 보니 문단 검증 31번 중 20~29번이 '문제 없음'이었어요. 가장 많을 때는 29번, 즉 94%였어요. 검증 요청 대부분이 아무 문제도 찾지 못하니 아깝다는 생각이 들었어요. 그래서 LLM에 보내기 전에 코드로 문단을 먼저 걸러내는 방법을 검토했어요.

원문과 겹치는 가장 긴 연속 구간의 길이(최장 공통 문자열), 수치 누락, 새 글과 원문의 길이 비율(분량비)을 계산해서 의심되는 문단만 LLM 검증에 넘기는 방식이었어요. 그런데 기존 결과로 재현해보니 LLM 검증이 잡아낸 문단 16건 중 코드 스크리닝에 걸린 건 4건뿐이었어요. 16건과 4건은 세 모델을 합친 수치이고, 모델별로는 0/2, 1/3, 3/11이었어요.

코드와 LLM은 서로 다른 것을 측정했어요. 코드는 복제와 수치를 보고, LLM 검증은 의미 왜곡을 봐요. 「늘리기」가 「독식」으로 바뀐 사례처럼 수치와 고유명사가 그대로인 의미 변화는 코드에서 보이지 않아요.

문단 길이나 인용 유무로 예측하는 방법도 실패했어요. 검증에 걸린 문단의 중앙값은 189자였는데, 걸리지 않은 문단 중 가장 긴 것은 435자였어요.

코드로 거르면 검증을 줄이는 게 아니라 없애는 셈이 돼요. 그래서 이 방법은 기각했어요. 비싼 검사를 싼 검사로 대체하려면 두 검사가 같은 것을 측정하는지부터 확인해야 해요.

문단을 묶었더니 비용 예측이 빗나갔어요

문단 단위 방식은 품질이 좋아졌지만 요청 수와 지연이 너무 컸어요. 그래서 이번에는 문단을 묶었어요. 문단을 400자가 넘을 때까지 이어 붙여 하나의 묶음으로 만들고 묶음마다 다시 쓰기 1번과 검증 1번을 실행했어요. 앞에서 기각한 코드 스크리닝과 달리 검증은 그대로 유지했어요.

문단은 쪼개지 않았어요. 한 문단이 400자보다 크면 그 문단 자체를 하나의 묶음으로 삼았어요. 문단 경계는 검수자가 원문과 결과를 대조할 때 사용하는 유일한 단서이기 때문이에요.

묶음 크기는 통째 복제 위험의 크기이기도 해요. 원문 약 5,000자를 한 번에 넣었을 때 543자가 복제됐어요. 400자를 목표로 하면 최대 묶음은 644자로, 한 번에 넣던 약 5,000자의 1/8 정도예요.

묶음 기준을 600자로 올리면 요청 수는 22번에서 16번으로 줄어들지만 최대 묶음은 644자에서 796자로 커져요. 요청 수를 줄이는 이득이 크지 않아서 400자를 유지했어요.

그 결과 31문단이 11묶음으로 줄었고 요청 수는 문단 단위로 처리한 기준 회차의 68번에서 24~28번으로 감소했어요. 약 59~65% 줄어든 셈이에요.

그런데 비용은 14~24%만 줄었어요. 처음에는 52~64% 줄어들 거라고 추정했으니, 실제 비용은 추정치의 약 2배였어요.

이유는 요청을 줄여서 사라진 것이 요청마다 반복되던 시스템 프롬프트(매 호출 앞에 붙는 고정 지시문)뿐이었기 때문이에요. 원문을 읽는 입력 토큰과 새 기사를 작성하는 출력 토큰의 총량은 거의 그대로였어요. 기사 길이는 그대로였으니까요.

그래서 묶기의 이득은 비용이 아니라 지연이었어요. 순차로 실행해야 하는 요청 수가 줄었고 원래 목표였던 30분 주기 안에 작업을 끝낼 수 있었어요.

'요청 수를 줄이면 비용도 줄어든다'는 직관을 믿기 전에 입력 토큰과 출력 토큰이 어디에서 발생하는지 나눠 봐야 해요. 줄어드는 건 반복되는 고정분뿐일 수도 있어요.

LLM이 못 지키는 것은 코드로 세요

LLM이 잘 지키지 못하는 형식 규칙도 있었어요. 의미 왜곡은 LLM이 봐야 하지만 개수와 문자열처럼 셀 수 있는 것은 코드가 더 정확했어요.

먼저 문단 수를 검사했어요. 프롬프트에 '문단 수를 유지하라'고 적어두었는데도 한 묶음이 3문단을 1문단으로 합쳐 결과가 31문단에서 28문단으로 줄었어요. 코드로 문단 수를 세는 검사를 넣자 다음 회차는 31/31을 유지했고 재생성 횟수도 3회에서 1회로 줄었어요.

앞에서 본 코드 스크리닝과는 정반대인 결과였어요. 코드는 의미 왜곡은 잡지 못하지만 문단 수는 확실히 셌어요.

문체가 섞이는 문제도 있었어요. 원문은 전부 '~다'체였는데 한 모델이 31문단 중 7문단을 '~습니다'체로 바꿨어요. '~다'가 '~습니다'로 바뀌면서 분량비도 1.21까지 올랐어요. 문체 규칙 한 줄을 추가하자 혼입은 0이 됐고 분량비는 1.10~1.12로 내려왔어요.

기존 지표인 '어미 다양성'은 이 결함을 잡지 못했어요. 오히려 존댓말이 섞이면서 다양성 점수가 올라갔어요.

조수사(물건을 세는 단위 말)도 문제였어요. 원문에서 GPU를 '수천 장'이라고 표현했는데 결과에서 '수천 대'로 바뀐 사례가 있었어요. 이것도 규칙 한 줄을 넣어 대응했어요.

원문을 글자 그대로 옮겼는지 보는 축자 복제 검사를 할 때는 고유명사와 수치가 겹쳐도 정상인 경우가 있어서 이를 먼저 지우고 비교해요. 처음에는 지울 목록을 LLM 추출 결과로 만들었는데 회차마다 69~276개로 크게 달라졌어요. 많이 추출된 회차에서는 정상적으로 남아 있어야 할 겹침까지 지워져서 잔여 겹침이 0자로 뭉개졌어요. 그래서 마스킹할 재료는 원문에서 코드로 추출했어요.

출력 순서도 바꿨어요. 모델 응답을 JSON 스키마로 받는 구조화 출력에서 facts를 body보다 앞에 두었어요. facts는 해당 문단에 포함된 사실 목록이고 body는 새로 작성한 문단이에요. 모델이 사실을 먼저 적은 뒤 문장을 작성하게 하려는 의도였어요. -196℃가 빠지거나 7개가 일곱으로 바뀌던 문제가 계기였어요.

셀 수 있는 것인 개수, 문자열, 형식은 코드가 보고, 셀 수 없는 의미는 LLM이 봐야 해요. 이 역할을 바꾸면 둘 다 놓치게 돼요.

모델을 바꾸자 복제가 돌아왔어요

2026년 8월 중순, 모델 공급사를 Gemini에서 GPT로 바꾸는 방안을 검토하면서 같은 프롬프트와 같은 원문으로 비교해 봤는데, Gemini에서 0.064였던 축자율이 GPT에서는 0.41로 올랐어요.

축자율은 ngram8 지표로 측정했어요. 새 기사의 8글자 조각 중 원문에도 있는 조각의 비율이에요. Claude Opus는 0.20, Sonnet은 0.178이었어요. Sonnet은 1회 관측 결과예요.

먼저 프롬프트를 강화했어요. '충돌하면 표현 변경을 우선', '다 쓴 뒤 원문과 대조'라는 지침을 추가했어요. 강화 전에 다시 잰 축자율은 0.459, 강화 후는 0.412였지만 같은 조건으로 반복해도 결과가 흩어지는 범위(산포) 안이라 효과가 없었어요.

검출 임계값도 바꿨어요. 연속 일치 기준을 30자에서 20자로 낮췄는데 축자율은 오히려 0.461로 올랐어요. 재생성은 5회에서 11회로 늘었지만 지적을 받아도 모델이 문제를 고치지는 못했어요.

그래서 구조를 바꿨어요. 작성 콜이 원문을 보지 못하도록 두 콜로 나누는 블라인드 방식을 시도했어요.

  • 콜 A: 원문 묶음에서 압축된 사실 목록 추출
  • 콜 B: 사실 목록만 보고 문단 작성

원문이 작성 콜의 컨텍스트에 없으면 복제도 줄어들 것이라고 생각했어요. 그런데 첫 결과는 축자율 0.596, 최장 일치 184자(1회 관측)로 오히려 가장 나빴어요.

원인은 콜 A가 사실 목록에 원문 문장을 통째로 넣었기 때문이에요. 콜 B는 원문을 직접 보지 않았지만 콜 A가 넣은 원문 문장을 그대로 다시 썼어요. 베끼기가 사라진 게 아니라 앞 단계로 옮겨간 거였어요.

그래서 팩트 추출기에 원래 있던 '어구를 연속으로 옮기지 않는다', '최소 표현으로 압축한다'는 규칙을 콜 A에도 넣었어요. 그러자 5회 반복에서 축자율이 0.20 ± 0.005로 재현됐고 커버리지(사실 보존율)도 1.00이었어요.

측정 방식에도 함정이 있었어요. 재구성 1묶음만 따로 측정하면 파이프라인 전체 값과 2배 가까이 차이 났어요. 재구성 1묶음만 측정했을 때는 0.257이었지만 전체 사이클에서는 0.459였어요. 재생성 루프와 기사 전체 대조가 결과에 영향을 주기 때문이에요. 축자율은 반드시 전체 사이클로 측정해야 해요.

최종적으로 축자율 0.20을 받아들였어요. Gemini를 사용했을 때의 0.064보다 3배 높은 값이라 완벽하다고 할 수는 없어요. 그래도 시도한 방법과 실패한 이유를 기록해 두고 받아들였어요. 이 판단은 3편에서 다시 문제가 돼요.

이번 실험에서 두 가지를 배웠어요. 원문을 다루는 콜이 어디에 있든 같은 복제 방지 규칙이 필요해요. 데이터가 어느 단계로 흘러가는지 끝까지 따라가야 해요.

또 프롬프트와 임계값, 재생성 루프는 특정 모델의 습관에 맞춰져 있어요. 모델을 바꾸는 일은 이름 한 줄을 바꾸는 일이 아니라 다시 조율하는 프로젝트예요.

마무리

구조를 바꿀 때마다 품질 검사, 즉 '게이트를 통과했나'를 기준으로 판단했어요. 그런데 그 게이트는 믿을 만했을까요?

다음 편에서는 검사기가 거꾸로 일하고 있었다는 사실을 다뤄볼게요.

연재 목차

공개된 편부터 링크를 걸어 둘게요.

  1. "베끼지 마"는 프롬프트로 막을 수 없었다
  2. 쪼개고, 묶고, 다시 나누기 — 콜은 65% 줄었는데 비용은 왜 20%만 줄었을까 (지금 읽는 글)
  3. 검사기가 거꾸로 일하고 있었다 — LLM 품질 게이트를 의심하는 법
  4. 고친 자리 옆에 결함이 다시 생긴다 — 일본어 재구성과 “사람에게 넘기기”
  5. 1 billion은 100억이 아니다 — 숫자 번역을 LLM에게 맡기지 않는 이유
  6. 모델이 "다 했어요"라고 할 때 — 잘린 번역, 섞인 한글, 정규식 한 글자
  7. 검사기를 운영한다는 것 — 오탐 48%, 묻힌 기사, 그리고 자동 발행