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) 방식”을 사용하여 전체 문항 수에 맞춰 정답 위치를 미리 배분한 뒤 섞어야 합니다.

  1. 정답 슬롯 생성: 전체 문항 수가 10개이고 5지선다라면, 정답 위치 [1,2,3,4,5,1,2,3,4,5]를 생성합니다.

  2. 슬롯 셔플: 이 위치 배열을 무작위로 섞습니다. (예: [3, 1, 5, 2, 4, …])

  3. 문항 재조립:

  4. 기존 JSON에서 정답 텍스트오답 텍스트들을 분리합니다.

  5. 할당된 정답 위치(예: 3번)에 정답 텍스트를 박아넣습니다.

  6. 나머지 위치에 오답 텍스트들을 채워 넣습니다.

  7. 번호 갱신: 최종적으로 변경된 인덱스를 정답(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>
  );
};

요약

  1. LLM 역할: 문제의 내용(Question)과 선택지 내용(Content)만 잘 만들면 됩니다. 정답 위치는 신경 쓰지 말라고 해도 됩니다.

  2. 코드 역할: 위 코드를 JSON 생성 직후에 실행시키면, 항상 정답 번호가 1, 2, 3, 4, 5번에 골고루 퍼진 완벽한 데이터셋으로 변환됩니다.

  3. 검증: URL 뒤에 ?debug=true를 붙여서 개발자 탭을 띄우고 균등하게 배분되었는지 확인하면 됩니다.

#

No responses yet

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다