
AI 수정 루프가 좋은 원고를 망치는 문제를 keep-best로 방지하기
요약
AI를 활용한 원고 생성 및 채점 루프에서 품질이 퇴화하는 문제를 해결하기 위한 'keep-best' 메커니즘 도입 과정을 다룹니다. 수정 과정에서 점수가 하락할 경우 최적의 결과물을 유지하고 루프를 중단하는 로직을 설명합니다.
핵심 포인트
- AI 수정 루프에서 발생하는 품질 퇴화(Regression) 문제 지적
- 최고 점수 원고를 보존하는 keep-best 전략 도입
- 채점 결과에서 점수를 추출하는 extractScore 구현 방식
- 점수 하락 시 루프를 중단하는 정지 조건 설정 방법
서론
기사의 초안을 AI에게 쓰게 하고, 다른 AI에게 채점하게 한 뒤, 돌아온 지적 사항대로 수정하게 한다.
이 루프를 몇 차례 반복하다 보면, 점수가 올라가는 차례와 내려가는 차례가 생깁니다.
곤란한 점은, 점수가 떨어진 차례의 원고가 그대로 파일에 남게 될 때입니다.
이 기사는 자신의 기사 생성 파이프라인(voice-kit/tools/run-pipeline.mjs)의 채점 루프에 그 대책을 도입했을 때의 개발 로그입니다.
이런 분들을 위해 작성했습니다
- AI에게 문장이나 코드를 자동으로 개선시키는 루프를 돌리고 있는 사람
- 생성→채점→수정을 동일한 파일 덮어쓰기로 진행하고 있는 사람
- 자동 생성의 품질을 매번 사람이 감시하지 않아도 유지되는 메커니즘을 찾고 있는 사람
이 기사에서 알 수 있는 것
- 덮어쓰기형 개선 루프에서 가장 좋았던 결과물이 사라지는 메커니즘
- 채점 결과 문장에서 점수를 추출하는
extractScore의 구현 방법 - 최고점 원고를 유지하는 keep-best와, 점수가 떨어지면 중단하는 정지 조건 작성법
전제 조건
- Node.js로 제작된 CLI(
node voice-kit/tools/run-pipeline.mjs)에 관한 이야기입니다. - 생성·수정 역할은
claude, 채점 역할은codex를 각각 외부 명령어로 호출하고 있습니다. - 채점 합격 라인은 85점입니다. 원고는 임시 파일(
draft.md)에 두고 차례마다 덮어쓰기를 합니다.
자동화하고 싶지만 품질이 불안하다는 질문에 대한 답변
기사 제작을 자동화하고 싶어지는 이유는 대개 손이 모자라기 때문입니다.
하지만 자동화하면 이번에는 "이상한 것이 그대로 나오지 않을까" 하는 불안함이 생깁니다.
이 기사에서 채택한 방법은 채점 역할을 별도로 세워, 점수가 기준에 도달할 때까지 수정하게 만드는 루프입니다.
이 기사에서 다루는 것은 그 루프 자체가 가지고 있는 함정(Pitfall)에 관한 것입니다.
채점하고 수정하게 하는 단계까지는 만들 수 있어도, 어느 차례의 원고를 남길지 결정하지 않으면, 좋아졌던 원고가 다음 차례에서 덮어쓰여 사라지게 됩니다.
무엇을 만들었나: 채점과 수정을 반복하는 checkerStage
파이프라인 안에 checkerStage라는 함수가 있습니다.
수행하는 작업은 단순하며, 다음과 같은 순환 구조입니다.
codex에게 원고 파일을 채점하게 한다- 돌아온 문장에서 합계 점수를 추출한다
- 85점 이상이면 합격으로 간주하고 종료한다
- 도달하지 못하면 지적 사항을
claude에게 전달하여 원고를 수정하게 하고, 동일한 파일에 덮어쓴다
이 함수는 두 곳에서 호출됩니다.
팩트 체크(factcheck)와 문체 체크(stylecheck)입니다.
반복 횟수 상한은 factcheck가 CLI의 --max-rounds(기본값 5, 1~10 사이 지정 가능)이며, stylecheck는 5회로 고정되어 있습니다.
왜 손을 댔나: 덮어쓰기 루프는 최상의 원고를 남기지 않는다
단순하게 말하면, 이 루프는 매 차례의 원고로 동일한 파일을 덮어씁니다.
즉, 루프가 끝났을 때 남는 것은 마지막 차례의 원고입니다.
점수가 매 차례 깔끔하게 올라간다면 문제없습니다.
하지만 수정 역할은 AI이므로, 지적 사항을 고치려다 다른 부분을 망가뜨릴 수 있습니다.
점수가 한 번 올라갔다가 떨어지면, 남는 것은 가장 좋았던 원고가 아니라 퇴화한 최종본입니다.
코드에는 다음과 같이 주석을 달았습니다 (직접 인용).
// 수정 루프는 각 회차에서 채점되며, 해당 회차의 원고가 그대로 채점 대상이 된다. 최고점이었던 회차의 원고를 유지하고,
// 수정 역할이 양호한 원고(≥75점)를 악화시키면 거기서 중단하고 최상 버전으로 되돌린다(80→0과 같은 퇴화를 방지).
참고로 "실제로 몇 번이나 이런 퇴화가 일어났는가"는 실행 로그의 회차별 점수를 세어보지 않으면 단정할 수 없습니다.
여기서 쓸 수 있는 내용은 코드가 어떤 조건에서 어떻게 동작하는가까지입니다.
언제 발생하는가
동일한 파일을 덮어쓰고, 최종본의 평가가 중간의 최고점을 밑돌며, 최고점 버전을 별도로 저장하지 않았을 때 최상의 결과물이 유실됩니다.
평가가 중간에 떨어지더라도 그 후에 최고점을 갱신하면 최종본이 그대로 최상본이 되므로, 조건이 '덮어쓰기'와 '비단조적 평가(Non-monotonic evaluation)'만으로는 부족합니다. 최고점 버전을 어디에도 남겨두지 않았다는 조건까지 갖춰져야 비로소 되돌릴 수 없게 됩니다.
AI인지 여부와 상관없이, 이 세 가지가 갖춰진 시점에 발생하는 구조적 문제입니다.
대책 1: 점수를 읽어오는 extractScore
먼저 채점 역할의 응답에서 합계 점수를 추출해야 합니다.
extractScore
는 "채점 문구에서 0~100 사이의 점수를 추출하는 것"입니다.
채점 역할의 작성 방식이 매번 같지는 않으므로, 정규 표현식 (Regular Expression)을 사용하여 여러 표기 방식에 대응합니다.
function extractScore(text) {
const patterns = [
/合計(?:点|スコア)?[^\d\r\n]{0,20}(\d{1,3})(?:\s*点|\s*[\//]\s*100)?/iu,
...
패턴은 8개를 나열해 두었으며, 위에서부터 순서대로 일치하는 것을 채택합니다.
어느 것에도 일치하지 않으면 null을 반환합니다.
호출하는 쪽에서는 null을 0점으로 취급합니다.
const parsedScore = extractScore(parsed.message);
const score = parsedScore ?? 0;
읽어오지 못한 회차는 attempts에 note: 'score-parse-failed'가 남으며, 스테이지가 불합격으로 종료된 경우에는 reason에도 'score-parse-failed'가 남습니다.
점수를 읽을 수 없다는 사실을 조용히 85점 미만으로 처리하고 넘어가는 것이 아니라, 기록에 남기는 방식입니다.
대책 2: 최고점의 원고를 유지하는 keep-best
본론입니다.
점수와 원고를 쌍(pair)으로 가지고 있다가, 최고점을 갱신한 회차만 교체합니다.
const REGRESSION_PROTECT_SCORE = 75;
let best = { score: -1, content: null };
루프 내부은 다음과 같습니다.
const scoredContent = fs.readFileSync(draftPath, 'utf8');
const regressed = best.score >= 0 && score < best.score;
if (score > best.score) best = { score, content: scoredContent };
포인트는 scoredContent를 해당 점수가 매겨진 시점에 파일에서 읽고 있다는 점입니다.
점수만 기억하고 원고가 덮어씌워지면 의미가 없으므로, 그 회차의 내용 자체를 따로 보관합니다.
best.score의 초기값이 -1이므로, 첫 번째 회차에서 regressed는 false가 됩니다.
비교할 대상이 아직 없는 회차를 퇴보(regression)로 오판하지 않기 위해서입니다.
대책 3: 좋은 원고의 점수가 떨어지면 중단하기
퇴보를 감지했을 때, 그대로 계속 돌린다고 해서 결과가 좋아질 것이라는 보장은 없습니다.
일정한 수준에 도달한 원고의 점수가 떨어지면, 그 즉시 루프를 빠져나옵니다.
if (regressed && best.score >= REGRESSION_PROTECT_SCORE) break;
조건은 두 가지입니다.
점수가 최고점보다 낮아진 것(regressed)과, 최고점이 REGRESSION_PROTECT_SCORE(75) 이상에 도달했을 것.
아직 75점에 도달하지 못한 원고는 "원래 수정 중인 단계"이므로, 다소 점수가 오르내리더라도 계속 진행합니다.
여기서 extractScore의 역할이 중요해집니다.
점수를 읽어오지 못한 회차는 0점이 되기 때문에, best.score가 75점 이상이라면 해당 회차는 퇴보로 간주되어 중단됩니다.
채점 불능 상태를 "최악의 점수"로 몰아넣음으로써, 안전하게 멈출 수 있도록 합니다.
대책 4: 마지막에 best를 다시 쓰기
합격(85점 이상)이라면 그 자리에서 return true를 하고 빠져나갑니다.
문제는 합격에 도달하지 못한 채 회차를 마쳤을 때입니다.
// 합격에 도달하지 못한 경우, 마지막(퇴보했을 가능성이 있는) 원고가 아니라 최고점이었던 원고를 남김 = 격리/구제의 질을 높임.
if (best.content !== null) fs.writeFileSync(draftPath, best.content, 'utf8');
const finalScore = best.score >= 0 ? best.score : (attempts.at(-1)?.score ?? 0);
파일에 최고점 원고를 다시 쓰고, 기록하는 점수도 best.score로 설정합니다.
이렇게 하면 로그에 찍히는 점수와 실제로 남아 있는 원고가 일치하게 됩니다.
factcheck이 불합격된 원고는 下書き\Miyoki-Labs\_blocked\
측으로 「⚠️공개 불가(팩트 체크 미합격)」라는 주석과 함께 저장됩니다.
자동 공개 대상에서 제외하여 격리하는 폴더이므로, 그곳에 떨어지는 것이 최종고(final draft)가 아니라 최량고(best draft)가 되었다는 점은 효과적입니다.
같은 구조로 발생할 수 있는 상황
덮어쓰기를 하고 있고, 평가가 단조롭게 상승한다고 보장할 수 없으며, 최량 버전을 별도로 저장하지 않는다. 이 세 가지가 갖춰지면 AI의 문장 생성에 국한되지 않고 동일한 일이 발생합니다.
머신러닝 (Machine Learning) 학습 루프에서 마지막 에포크 (Epoch)의 모델만 저장하는 운용. best checkpoint를 별도로 보유하지 않으면, 중간의 가장 좋은 가중치 (Weight)가 남지 않습니다.
프롬프트 (Prompt) 자동 튜닝. 생성 → 평가 → 프롬프트 재작성을 동일한 파일에 덮어쓰면, 평가가 낮아진 버전이 마지막에 남게 됩니다.
테스트 통과 수를 지표로 한 코드 자동 수정. 수정할 때마다 동일한 소스를 덮어쓰면, 통과 수가 최대였던 상태로 되돌릴 수 없게 됩니다.
곤란한 포인트
이러한 종류의 열화가 알아차리기 어려운 이유를 나열해 둡니다.
열화 감지 그 자체는 예외 (Exception)를 던지지 않습니다. 다만 체커 (Checker)는 불합격을 반환하고, 파이프라인 (Pipeline) 전체는 종료 코드 1이 됩니다.
후속 처리는 스테이지 (Stage)마다 다릅니다. factcheck 불합격 시에는 원고를 _blocked에 저장하고 정지합니다. stylecheck 불합격 시에는 gate로 진행하지만, 파이프라인 전체는 실패 종료됩니다.
매 회차(Iteration)의 점수를 보지 않으면 알 수 없습니다. 마지막 점수만 보고 있으면, 중간에 높은 점수가 나왔었다는 사실을 알아차릴 수 없습니다.
중간고는 복원할 수 없습니다. 매 회차의 점수와 지적 사항은 attempts에 남지만, 해당 회차의 본문은 저장하지 않기 때문에, 최고점 이외의 중간고는 나중에 되찾을 수 없습니다 (최고점고는 best.content에 유지되며, 종료 시 draft.md로 다시 쓰여집니다).
채점 불능과 저품질이 같은 모습으로 나타납니다. parsedScore ?? 0에 의해 0점이 되기 때문에, 점수의 겉모습만으로는 구별할 수 없습니다 (note를 확인해야 합니다).
결과 · 배움
도입한 것은 4가지입니다.
점수를 여러 표기에서 읽어오는 extractScore, 점수와 본문을 쌍으로 유지하는 keep-best, 75점 이상에 도달한 후의 열화 시 중단하는 정지 조건, 그리고 마지막에 best를 다시 쓰는 처리입니다.
모두 몇 줄 되지 않으며, 어려운 것을 하지는 않았습니다.
배운 점은, 매 회차의 덮어쓰기는 암묵적으로 "마지막이 가장 좋다"라고 가정하고 있다는 점에 귀결됩니다.
AI에게 수정을 맡기는 루프에서는 그 가정이 성립하지 않는 회차가 섞이게 됩니다.
그렇기 때문에 정지 조건을 "상한 회차에 도달했는가"만으로 만들지 않고, **점수의 이력 (History)**으로 만들 필요가 있었습니다.
남아 있는 숙제도 있습니다.
REGRESSION_PROTECT_SCORE의 75라는 값이 타당한지는 실행 로그의 회차별 점수를 세어보지 않으면 말할 수 없습니다.
이 부분은 숫자를 임의로 정해둔 상태이므로, 실행 로그가 쌓이면 재검토할 예정입니다.
개인적으로 AI 도구나 업무 자동화를 만들고 있는 Miyoki라고 합니다.
이 기사의 기사 생성 파이프라인처럼, 자신의 작업을 편하게 하기 위한 작은 도구를 만들고 그 개발 로그를 Zenn과 Qiita에 쓰고 있습니다.
만든 것과 포트폴리오는 이쪽입니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기