언어 모델(language model)에게 텍스트를 채점하게 하고, 단어 수를 세고, 점수를 합산하며, 채점 규칙까지 적용하도록 요청하면, 보통은 자신감 있는 답변을 내놓습니다. 때로는 그 답변이 틀릴 수 있는데, 출력 결과가 그럴듯해 보여서 알아차리지 못할 수도 있습니다.
저는 독일 B1/B2 시험 준비 플랫폼인 ExamReady에서 작문 및 말하기 연습 기능을 구축하는 과정에서 이런 문제에 직면했습니다. 제가 최종적으로 구현한 방식은 간단하게 설명할 수 있습니다. LLM이 평가를 담당하고, 계산 가능한 모든 것은 제 코드가 처리합니다. 이 글에서는 그 경계가 어디인지, 그리고 왜 제가 그곳을 설정했는지 설명합니다.
ExamReady는 독립 프로젝트이며 ÖSD, ÖIF 또는 Goethe-Institut의 보증을 받지 않습니다.
문제점
작문 시험에서 학습자는 과제를 받고 텍스트를 작성한 다음, 여러 공식 기준에 따라 채점됩니다. 결과와 관련하여 엄격한 규칙들도 존재합니다. 한 예로, 만약 텍스트의 단어 수가 요구되는 수의 50% 미만이라면 0점을 받습니다.
이 규칙은 언어 품질과는 아무 상관이 없습니다. 순전히 산술적인 문제입니다. 텍스트가 잘 쓰였더라도 너무 짧다는 이유로 0점을 받을 수 있습니다.
유혹적인 접근 방식은 전체 채점 가이드라인을 하나의 프롬프트에 넣고 모델에게 최종 점수를 생성하도록 하는 것입니다. 이는 API 호출 한 번과 결과 하나를 의미하며, 혼자 개발하는 사람에게는 매력적입니다. 하지만 이것은 매우 다른 두 가지 작업을 혼합합니다.
모델이 잘하는 것과 그렇지 않은 것
모델은 개수 세기나 산술 계산에 있어서 신뢰성이 떨어집니다. 텍스트의 단어 수를 세거나, 그 수가 요구되는 수의 절반 이상인지 미만인지를 판단하고, 여러 기준 점수를 합산하여 총점을 내는 일들은 몇 줄의 코드가 매번 정확하게 수행할 수 있는 작업들입니다. 모델에게 이러한 작업을 요청하면 보통은 맞는 결과를 얻지만, 감지하기 어렵고 재현하기 불가능한 방식으로 가끔 틀린 결과가 나옵니다.
그래서 저는 작업을 분리했습니다.
분리된 역할 (The split)
LLM(Gemini)이 과제와 학습자의 텍스트를 받고, 공식 기준별 점수와 서면 피드백을 반환합니다. 모델은 총점을 내지 않으며, 텍스트가 충분히 길었는지 여부도 결정하지 않습니다.
그 후 코드가 다음 작업을 수행합니다:
- 학습자의 텍스트 단어 수를 계산합니다.
- 공식 규칙을 적용합니다. 예를 들어, 텍스트가 요구되는 단어 수의 50% 미만이면 0점을 부여합니다.
- 기준별 점수들을 조합하여 최종 결과를 만듭니다.
제가 요청하는 모델 출력 형태는 다음과 같습니다:
type CriterionGrade = {
criterion: string; // 공식 기준 중 하나
points: number; // 해당 기준에서 허용되는 범위 내의 점수
...
}
그리고 코드에 속하는 부분은 다음과 같습니다:
function countWords(text: string): number {
return text.trim().split(/\\\s+/).filter(Boolean).length;
}
...
여기서 두 가지 작은 세부 사항이 중요합니다. 첫째, 단어 수는 제가 단위 테스트(unit-test)할 수 있는 함수입니다. 텍스트를 입력하여 결과를 확인할 수 있는데, 이는 프롬프트로는 할 수 없습니다. 둘째, 모델의 점수는 각 기준에 허용되는 범위로 제한됩니다. 왜냐하면 모델은 해당 척도가 허용하는 범위를 벗어나는 값을 반환할 수 있기 때문입니다. 저는 모델 출력을 판결(verdict)이 아니라 검증이 필요한 입력으로 취급합니다.
학습자의 텍스트에 대한 수정 사항 검증 (Verifying corrections against the learner's text)
제가 모델을 무조건적으로 신뢰하기를 멈춘 두 번째 영역은 '수정 사항(corrections)'입니다. 유용한 피드백 기능 중 하나는 학습자에게 자신의 문장에서 구체적인 오류와 더 나은 버전을 보여주는 것입니다. 실패 모드는 모델이 학습자가 한 적 없는 것을 인용하거나,
따라서 모든 교정은 학습자 자신의 텍스트를 기반으로 검증된 후에 표시됩니다. 본질적으로는 다음과 같습니다:
function verifiedCorrections(text: string, list: ModelGrading["corrections"]): any {
return list.filter((c) => text.includes(c.original));
}
인용된 구절이 실제 텍스트에 없으면 해당 교정은 제외됩니다. 학습자가 쓰지 않은 문장을 가리키는 잘못된 교정보다 누락된 교정이 더 낫습니다. 실제 검사는 공백(whitespace)에 대해 좀 더 관대할 수 있지만, 원칙은 같습니다. 즉, 코드가 모델의 주장을 소스(source)와 비교하여 확인한다는 것입니다.
왜 모든 것을 프롬프트 내에서 처리하지 않을까요?
그렇게 할 만한 논리는 이해합니다. 최신 모델들은 훌륭하고, 종종 정확하게 계산할 것입니다. 하지만 '종종'은 학습자가 합격할지 여부를 결정하는 것에 제가 원하는 기준이 아닙니다. 분리하는 것을 선호하는 몇 가지 이유가 있습니다:
- 재현성(Reproducibility). 동일한 텍스트는 항상 동일한 단어 수와 동일한 규칙 적용을 받습니다. 판단 부분만 달라집니다.
- 테스트 가능성(Testability). 코드의 규칙은 테스트를 할 수 있습니다. 프롬프트의 규칙은 '느낌'에 의존합니다.
- 디버깅 용이성(Debuggability). 점수가 잘못된 것처럼 보일 때, 그 원인이 규칙 때문인지 모델의 판단 때문인지를 알 수 있습니다.
- 규칙 변경. 규칙이 변경되면, 재검증해야 하는 프롬프트가 아니라 함수를 수정합니다.
대가로 더 많은 코드와 모델로부터 더 구조화된 출력을 얻어야 한다는 점입니다. 하지만 제가 얻는 가치에 비하면 그 비용은 저렴하다고 생각합니다.
말하기(Speaking)에 대한 참고 사항
말하기 연습은 Gemini Live를 기반으로 구축된 실시간 AI 대화 파트너를 사용합니다. 이것은 문서가 아니라 대화이기 때문에 문제의 형태 자체가 다르고, 한 번의 말하기 과제당 약 10센트가 비용이 발생합니다. 저는 아직 적절한 경계가 어디인지 배우는 중이라서, 이 포스트는 작문에 관한 내용으로 유지하겠습니다.
요약
- LLM에게 언어적 판단을 맡기고, 코드가 계산하고 비교하며 점수를 매기게 하세요.
- 모델 출력을 신뢰할 수 없는 입력(untrusted input)으로 취급하세요: 범위와 형태를 검증하세요.
- 공식 규칙은 코드에 적용하여 테스트 및 재현이 가능하도록 만드세요.
- 인용이나 수정 사항을 보여주기 전에 학습자 자신의 텍스트와 비교하여 확인하세요.
- 잘못된 피드백보다는 누락된 피드백이라도 더 낫습니다.
이 모든 것이 영리한 것은 아닙니다. 대부분은 언어 모델에게 함수가 더 잘 할 수 있는 일을 시키지 않는 것에 관한 것입니다.
저는 examready.at에서 이것을 구축하고 있으며, 피드백을 환영합니다.