LLM 문항 생성 시 정답 위치 편향 해소를 위한 필수 프로토콜
📌 문제 진단
LLM은 선택지 정답 배치에서 다음과 같은 체계적 편향을 보입니다:
-
4지선다(a\~d): 정답이 b, c에 과집중 (a, d 회피)
-
5지선다(①\~⑤): 정답이 ②, ③번에 과집중 (①, ④, ⑤ 회피)
이는 “랜덤하게 배치하라”는 지시만으로는 해결되지 않습니다.
✅ 필수 실행 프로토콜: “정답 위치 선결정 방식”
[STEP 1] 정답 위치 사전 생성 (문항 생성 전 필수)
문항 생성을 시작하기 전에, 반드시 다음을 먼저 수행하라:
1. 생성할 총 문항 수(N)를 확인한다.
2. 각 문항의 정답 위치를 무작위 함수를 사용하여 미리 결정한다.
- 5지선다: random.choice([1, 2, 3, 4, 5]) 또는 동등 확률 난수
- 4지선다: random.choice(['a', 'b', 'c', 'd']) 또는 동등 확률 난수
3. 결정된 정답 위치 배열을 명시적으로 기록한다.
예시 출력:
┌─────────┬────────────┐
│ 문항 번호 │ 정답 위치 │
├─────────┼────────────┤
│ 1 │ ④ │
│ 2 │ ① │
│ 3 │ ⑤ │
│ 4 │ ② │
│ 5 │ ③ │
└─────────┴────────────┘
[STEP 2] 사전 결정된 위치에 따른 문항 구성
각 문항을 생성할 때:
1. 해당 문항의 사전 결정된 정답 위치를 참조한다.
2. 정답 선택지를 해당 위치에 배치한다.
3. 나머지 위치에 오답 선택지(매력적 오답)를 배치한다.
4. 선택지 길이, 구조, 어휘 수준을 균등하게 조정한다.
⚠️ 절대 금지 사항:
- 정답을 먼저 작성한 후 위치를 "선택"하지 말 것
- "적절해 보이는" 위치에 정답을 배치하지 말 것
- 사전 결정된 위치를 임의로 변경하지 말 것
[STEP 3] 검증 및 분포 확인
모든 문항 생성 완료 후:
1. 정답 분포표를 출력하여 검증한다.
2. 특정 위치에 과집중되지 않았는지 확인한다.
3. 10문항 이상 생성 시, 각 위치별 정답 수가 ±1 범위 내에서 균등한지 점검한다.
검증 출력 예시:
정답 분포: ① 2개 | ② 2개 | ③ 2개 | ④ 2개 | ⑤ 2개
분포 상태: ✓ 균등
📋 통합 지침 (복사하여 프롬프트에 삽입)
<answer_position_protocol>
【정답 위치 편향 해소 필수 프로토콜】
본 프로토콜은 LLM의 내재적 정답 위치 편향(중앙 집중 현상)을
방지하기 위해 반드시 준수해야 한다.
■ 실행 순서 (순서 변경 불가)
PHASE 1: 정답 위치 선결정
- 문항 내용 생성 전, 모든 문항의 정답 위치를 먼저 결정
- 난수 생성기 또는 동등 확률 무작위 선택 사용
- 결정된 위치를 표 형태로 명시적 기록
PHASE 2: 위치 고정 문항 작성
- 각 문항 작성 시 사전 결정된 정답 위치 엄수
- 정답을 해당 위치에 먼저 배치
- 오답을 나머지 위치에 배치
- 모든 선택지의 길이/구조/난이도 균등화
PHASE 3: 분포 검증
- 완성된 문항 세트의 정답 분포 출력
- 편향 여부 점검 및 보고
■ 위반 금지 사항
✗ 정답 내용을 먼저 작성 후 위치를 결정하는 행위
✗ 사전 결정된 위치를 "더 자연스러워 보여서" 변경하는 행위
✗ 분포 검증 단계 생략
■ 출력 형식
문항 생성 결과에 다음을 반드시 포함:
1. [정답 위치 배열] - PHASE 1 결과
2. [문항 본문] - 각 문항 및 선택지
3. [분포 검증] - 위치별 정답 개수 및 균등성 판정
</answer_position_protocol>
💡 적용 예시
입력 요청:
“다음 지문을 바탕으로 5지선다형 문항 5개를 생성하라.”
LLM 응답 시작:
[PHASE 1: 정답 위치 선결정]
난수 생성 결과:
• 문항 1: ⑤
• 문항 2: ①
• 문항 3: ④
• 문항 4: ②
• 문항 5: ③
[PHASE 2: 문항 생성]
1. 윗글의 주제로 가장 적절한 것은?
① [오답]
② [오답]
③ [오답]
④ [오답]
⑤ [정답] ← 사전 결정 위치
... (이하 문항 계속)
[PHASE 3: 분포 검증]
① : 1개 | ② : 1개 | ③ : 1개 | ④ : 1개 | ⑤ : 1개
판정: ✓ 균등 분포 달성
이 프로토콜을 문항 제작 모듈의 <선택지_제작_단계> 앞에 삽입하면, 정답 위치 편향 문제가 구조적으로 해결됩니다.
Answer Key에 정답 분포 통계(a:X, b:X, c:X, d:X)를 자동으로 표시하도록 추가해 주세요.\
\
정답 분포 통계에 균등/편향 판정 아이콘(✓/✗)도 함께 표시되도록 추가해 주세요.
개발자 탭을 프로덕션에서 숨기고 특정 조건(URL 파라미터 등)에서만 보이도록 해주세요.
—-
LLM이 생성한 JSON 데이터(문제와 선택지)를 받아서, 코드를 통해 정답 위치를 강제로 재배치하여 분포를 균등하게 만드는(Shuffling & Balancing) 완벽한 로직을 작성해 드리겠습니다.
이 방식은 LLM에게 “랜덤하게 해”라고 말하는 것보다 훨씬 확실하며, 수학적으로 완벽한 균등 분포를 보장합니다.
1. 처리 전략 (Algorithm Strategy)
단순 랜덤(random)만 돌리면 확률적으로 특정 번호에 몰릴 수 있습니다. “카드 덱(Deck) 방식”을 사용하여 전체 문항 수에 맞춰 정답 위치를 미리 배분한 뒤 섞어야 합니다.
-
정답 슬롯 생성: 전체 문항 수가 10개이고 5지선다라면, 정답 위치 [1,2,3,4,5,1,2,3,4,5]를 생성합니다.
-
슬롯 셔플: 이 위치 배열을 무작위로 섞습니다. (예: [3, 1, 5, 2, 4, …])
-
문항 재조립:
-
기존 JSON에서
정답 텍스트와오답 텍스트들을 분리합니다. -
할당된 정답 위치(예: 3번)에
정답 텍스트를 박아넣습니다. -
나머지 위치에
오답 텍스트들을 채워 넣습니다. -
번호 갱신: 최종적으로 변경된 인덱스를 정답(Answer Key)으로 업데이트합니다.
2. Python 구현 코드 (Backend용)
데이터 처리 파이프라인이나 서버에서 사용할 때 적합한 코드입니다.
Python
import json
import random
import math
def balance_and_shuffle_quiz(json_data):
"""
LLM이 생성한 퀴즈 JSON을 받아 정답 분포를 균등하게 섞어 반환합니다.
"""
questions = json_data['questions']
total_q = len(questions)
if total_q == 0:
return json_data
# 문항 당 선택지 개수 파악 (첫 번째 문항 기준, 보통 4지 or 5지)
num_options = len(questions[0]['options'])
# [STEP 1] 전체 문항에 대해 균등한 정답 위치(인덱스) 리스트 생성
# 예: 10문제, 5지선다 -> [0,1,2,3,4, 0,1,2,3,4] 생성
target_indices = []
repeat_count = math.ceil(total_q / num_options)
for _ in range(repeat_count):
target_indices.extend(list(range(num_options)))
# 필요한 만큼 자르고 랜덤 셔플 (이게 핵심: 전체 분포를 강제 조정)
target_indices = target_indices[:total_q]
random.shuffle(target_indices)
shuffled_questions = []
# [STEP 2] 각 문항별로 재조립
for idx, q in enumerate(questions):
original_options = q['options']
# 기존 정답 인덱스 (LLM이 준 것, 보통 0부터 시작)
# 만약 LLM이 정답 번호를 1부터 줬다면 -1 해줘야 함. 여기선 0-index 기준 가정
correct_idx_original = q['answer_index']
# 정답 내용과 오답 내용 분리
correct_content = original_options[correct_idx_original]
distractors = [opt for i, opt in enumerate(original_options) if i != correct_idx_original]
# 오답들도 순서를 한 번 섞어줌 (오답 끼리의 위치 편향 방지)
random.shuffle(distractors)
# 이번 문항이 가질 새로운 정답 위치 (STEP 1에서 결정된 것)
new_answer_idx = target_indices[idx]
# 새로운 선택지 배열 생성
new_options = [None] * num_options
# 1. 정답 박기
new_options[new_answer_idx] = correct_content
# 2. 나머지 자리에 오답 채우기
distractor_ptr = 0
for i in range(num_options):
if i != new_answer_idx:
# 오답 개수가 부족할 경우(LLM 오류 등)를 대비한 안전장치
if distractor_ptr < len(distractors):
new_options[i] = distractors[distractor_ptr]
distractor_ptr += 1
else:
new_options[i] = "선택지 오류"
# 문항 정보 업데이트
q_copy = q.copy()
q_copy['options'] = new_options
q_copy['answer_index'] = new_answer_idx # 0-based index
# 프론트엔드 표시용 번호 (1-based) 추가
q_copy['answer_number'] = new_answer_idx + 1
shuffled_questions.append(q_copy)
# [STEP 3] 검증용 통계 생성
stats = {i+1: 0 for i in range(num_options)}
for q in shuffled_questions:
stats[q['answer_number']] += 1
return {
"questions": shuffled_questions,
"stats": stats,
"is_balanced": True
}
# --- 테스트 실행 ---
# LLM이 편향되게 생성한 가상의 데이터 (정답이 모두 1번인 상황)
input_json = {
"questions": [
{"id": 1, "text": "Q1", "options": ["정답", "오답A", "오답B", "오답C", "오답D"], "answer_index": 0},
{"id": 2, "text": "Q2", "options": ["정답", "오답A", "오답B", "오답C", "오답D"], "answer_index": 0},
{"id": 3, "text": "Q3", "options": ["정답", "오답A", "오답B", "오답C", "오답D"], "answer_index": 0},
{"id": 4, "text": "Q4", "options": ["정답", "오답A", "오답B", "오답C", "오답D"], "answer_index": 0},
{"id": 5, "text": "Q5", "options": ["정답", "오답A", "오답B", "오답C", "오답D"], "answer_index": 0}
]
}
result = balance_and_shuffle_quiz(input_json)
print(json.dumps(result, indent=2, ensure_ascii=False))
3. JavaScript/TypeScript 구현 코드 (Frontend/Node.js용)
앱 내에서 표시하기 직전에 섞어주는 경우(클라이언트 사이드 렌더링)에 유용합니다.
JavaScript
/**
* 퀴즈 데이터를 받아 정답 분포를 균등하게 섞는 함수
* @param {Object} quizData - questions 배열을 포함한 JSON 객체
*/
function distributeAndShuffleQuiz(quizData) {
const questions = [...quizData.questions]; // 원본 보존을 위한 복사
const totalQ = questions.length;
if (totalQ === 0) return quizData;
const numOptions = questions[0].options.length; // 예: 5지선다
// [STEP 1] 균등 분포를 위한 타겟 인덱스 풀 생성
// 예: [0, 1, 2, 3, 4, 0, 1, 2, 3, 4 ...]
let targetIndices = [];
const loops = Math.ceil(totalQ / numOptions);
for (let i = 0; i < loops; i++) {
for (let j = 0; j < numOptions; j++) {
targetIndices.push(j);
}
}
// 필요한 길이만큼 자르고 섞기 (Fisher-Yates Shuffle)
targetIndices = targetIndices.slice(0, totalQ);
for (let i = targetIndices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[targetIndices[i], targetIndices[j]] = [targetIndices[j], targetIndices[i]];
}
// [STEP 2] 문항 재구성
const processedQuestions = questions.map((q, idx) => {
const originalOptions = q.options;
const correctIdx = q.answer_index; // 0-based index 가정
const correctContent = originalOptions[correctIdx];
// 정답을 제외한 오답들만 추출
let distractors = originalOptions.filter((_, i) => i !== correctIdx);
// 오답들끼리 순서 섞기
distractors.sort(() => Math.random() - 0.5);
// 이번 문제에 배정된 새로운 정답 위치
const newAnswerIdx = targetIndices[idx];
// 새로운 옵션 배열 만들기
const newOptions = new Array(numOptions).fill(null);
newOptions[newAnswerIdx] = correctContent;
// 빈 칸에 오답 채워넣기
let dPtr = 0;
for (let i = 0; i < numOptions; i++) {
if (i !== newAnswerIdx) {
newOptions[i] = distractors[dPtr++] || "Error";
}
}
return {
...q,
options: newOptions,
answer_index: newAnswerIdx,
answer_number: newAnswerIdx + 1 // 사람이 보기 편한 1~5 숫자
};
});
// [STEP 3] 통계 산출
const stats = {};
processedQuestions.forEach(q => {
const key = q.answer_number;
stats[key] = (stats[key] || 0) + 1;
});
return {
questions: processedQuestions,
stats: stats,
isBalanced: true
};
}
// --- 사용 예시 ---
const rawData = {
questions: [
{ text: "Q1", options: ["A(정답)", "B", "C", "D", "E"], answer_index: 0 },
{ text: "Q2", options: ["A(정답)", "B", "C", "D", "E"], answer_index: 0 },
{ text: "Q3", options: ["A(정답)", "B", "C", "D", "E"], answer_index: 0 },
{ text: "Q4", options: ["A(정답)", "B", "C", "D", "E"], answer_index: 0 },
{ text: "Q5", options: ["A(정답)", "B", "C", "D", "E"], answer_index: 0 },
]
};
const finalResult = distributeAndShuffleQuiz(rawData);
console.log(JSON.stringify(finalResult, null, 2));
4. 개발자 탭 표시 제어 (Developer Tab Visibility)
요청하신 “프로덕션에서는 숨기고 URL 파라미터가 있을 때만 통계 보여주기” 기능입니다.
시나리오: ?debug_mode=true 같은 파라미터가 URL에 있을 때만 정답 분포 통계 UI를 렌더링합니다.
React/Next.js 예시 컴포넌트:
JavaScript
import { useSearchParams } from 'next/navigation'; // 또는 react-router-dom
const QuizResultView = ({ quizData }) => {
const searchParams = useSearchParams();
// URL에 'debug=true'가 있는지 확인
const isDebugMode = searchParams.get('debug') === 'true';
return (
<div>
{/* 일반 사용자에게 보이는 퀴즈 영역 */}
{quizData.questions.map(q => (
<div key={q.text} className="mb-4">
<h3>{q.text}</h3>
<ul>
{q.options.map((opt, i) => (
<li key={i} className={i === q.answer_index ? "font-bold" : ""}>
{opt}
</li>
))}
</ul>
</div>
))}
{/* 개발자 전용 통계 패널 (조건부 렌더링) */}
{isDebugMode && (
<div className="fixed bottom-0 right-0 p-4 bg-gray-800 text-white opacity-90 text-xs rounded-tl-lg z-50">
<h4 className="font-bold border-b mb-2">🔧 Dev: Answer Distribution</h4>
<div className="grid grid-cols-5 gap-2">
{Object.entries(quizData.stats).map(([key, count]) => (
<div key={key} className="text-center">
<span className="block text-gray-400">Option {key}</span>
<span className="font-mono text-lg">{count}</span>
</div>
))}
</div>
<div className="mt-2 text-green-400 text-center">
✓ Balanced
</div>
</div>
)}
</div>
);
};
요약
-
LLM 역할: 문제의 내용(Question)과 선택지 내용(Content)만 잘 만들면 됩니다. 정답 위치는 신경 쓰지 말라고 해도 됩니다.
-
코드 역할: 위 코드를
JSON 생성 직후에 실행시키면, 항상 정답 번호가 1, 2, 3, 4, 5번에 골고루 퍼진 완벽한 데이터셋으로 변환됩니다. -
검증: URL 뒤에
?debug=true를 붙여서 개발자 탭을 띄우고 균등하게 배분되었는지 확인하면 됩니다.
No responses yet