
로컬 LLM에게 동일한 코드를 51번 검토하게 했을 때: 다수결 투표는 정확성보다 지속성을 선택한다
요약
로컬 LLM을 활용한 코드 리뷰 실험을 통해 다수결 투표 방식의 한계를 분석했습니다. 실험 결과, 다수결 방식은 모델의 정확성보다 특정 패턴의 지속성을 선택하는 경향이 있으며, 온도 설정에 따라 결과의 결정론적 특성이 변화함을 확인했습니다.
핵심 포인트
- 다수결 투표는 정확성보다 모델이 반복적으로 출력하는 '지속성'을 선택할 위험이 있음
- 완전 고정된 로컬 환경(Temp 0)에서는 LLM의 결과가 결정론적으로 재현 가능함
- 7B 모델은 높은 정밀도를 보이지만 재현율(Recall)은 낮아 많은 결함을 놓칠 수 있음
- 온도(Temperature) 설정이 높아지면 모델 간 합의(Jaccard 유사도)가 급격히 감소함
만약 코드 리뷰를 위해 LLM을 사용하거나, 여러 번 실행하여 '대부분의 결과가 동의하는 것'을 따른다면, 이 실험은 여러분이 세고 있는 표가 **정확성(correctness)**이 아니라 **지속성(persistence)**을 측정한다는 것을 시사합니다.
요약 (TL;DR)
설정: Ollama 0.31.1을 통해 Qwen2.5-Coder 1.5B / 7B (Apache-2.0) 사용, 단일 RTX 4060 Ti, Q4_K_M 양자화. 미리 정의된 시드 결함을 가진 동일한 작은 Python 파일을 N=51회 반복 감사함(조건당 N=11). 실행별 기록(정규화된 발견 사항 및 각 원본 응답의 SHA-256)과 기준 진실값, 그리고 집계 코드는 공개되어 있으며, 본 기사에 인용된 모든 숫자는 독립적인 검증 스크립트를 통해 고정된 기록으로부터 결정론적으로 재생됩니다.
- 완전히 고정되면 = 결정론적입니다 (이 장비에서). 온도가 0이고 GPU와 양자화가 고정되어 있을 때, 발견 사항 세트는 실행 간에 동일했습니다 — 네 가지 조건 모두에서 Jaccard 유사도는 1.00이었습니다(각 N=11). 프롬프트 테스트에서 하나의 시드 내 6번의 반복과 6개의 다른 시드를 사용했음에도 불구하고 모든 결과는 _바이트 단위로 동일_했습니다: 우리의 temp-0 설정은 또한
top_k=1을 강제합니다 (Ollama/llama.cpp에서의 탐욕적 디코딩). 따라서 시드는 관찰 가능한 영향을 주지 못했습니다. 'LLM은 항상 비결정론적이다'는 속설일 뿐이며, 적어도 완전히 고정된 로컬 조건 하에서는 그렇습니다. - 온도 0.7에서 샘플링하면 합의가 깨집니다. 결함이 시드 된 파일에 대해 — 51회 실행, 실행당 하나의 시드(0–50) — 실행 간 Jaccard 유사도는 0.52 (7B)와 0.07 (1.5B)로 떨어졌습니다. 7B의 경우, ±2 라인의 허용 오차를 허용하면 0.52 → 0.80으로 회복됩니다: 불일치 중 절반 이상은 _다른 라인에서 지적된 동일한 버그_와 일치합니다.
- 핵심 발견: 다수결 투표는 정확성이 아닌 지속성을 선택한다. 모델이 51회 중 45회에 걸쳐 감지한 실제 리소스 누수는 두 개의 인접한 라인에 걸쳐 투표를 분산시켰습니다(26 + 19 — 어떤 실행도 둘 다 보고하지 않았으므로, 이는 정말로 45개의 개별 실행입니다). 엄격한 라인+카테고리 키 하에서, 26의 다수결 임계값은 간신히 한 라인을 유지하지만, k=27에서 단 하나의 표만 높아져도 45번 발견된 결함은 사라집니다.
한편, 두 가지 "회색(grey)" 패턴 경고(_constants_에 대한 eval 및 shell=True, 미끼 파일에서 유래)는 각각 33/51회 및 28/51회 생존했는데, 이는 모델이 매번 정확히 동일한 지점을 지목하기 때문입니다.
- 솔직한 주의사항: 결함이 삽입된 파일(defect-seeded file)을 대상으로, 카테고리 및 ±2라인 매칭을 적용한 사전 선언된 정답(ground truth)과 비교했을 때, 7B 모델의 실행당 재현율(recall)은 0.31에 불과했습니다. 하지만 정밀도(precision)는 0.98이었습니다. 이 벤치마크 내에서 요약하자면: 이 모델은 말을 할 때는 맞히지만, 많은 것을 놓칩니다. 1.5B 모델은 너무 노이즈가 심해 어떤 주장도 뒷받침할 수 없었습니다(51번의 실행 동안 파일의 32개 라인 중 최소 25개를 한 번 이상 탐지함). 따라서 1.5B는 증거가 아닌 대조군으로서의 역할만 수행합니다.
⚠ 재현성 범위: 분산의 존재와 대략적인 규모는 재현됩니다. 정확한 수치는 실험 환경(GPU / 양자화(quantization) / 추론 엔진 버전)에 따라 달라집니다. 리포지토리(repo)에는 실행당 기록이 고정된 상태로 포함되어 있습니다. 이는 정규화된 결과물과 각 원문 응답의 해시(hash) 값이며, 원문 응답 본체 자체는 보관되지 않았습니다. 따라서 독자들은 결정론적인 집계 계층(deterministic aggregation layer)만을 다시 실행할 수 있습니다.
측정 이유
"LLM-as-a-judge"는 어디에나 존재하며, 그 알려진 약점은 비결정론(nondeterminism)입니다. 기존 연구들은 *점수(score)*의 안정성을 측정했습니다. Klishevich et al. (arXiv:2502.20747)은 온도(temp) 0에서 70개의 커밋에 걸친 스칼라 판결(scalar-verdict) 일관성을 측정했고, 일본의 한 산업 블로그는 클라우드 API의 동일 점수율을 측정했습니다. 제가 어디에서도 찾을 수 없었던 측정 항목은 다음과 같습니다: 로컬 모델에서, 재현이 자유롭고, 실행 기록이 고정되어 배포된 상태에서, 발견된 결과의 집합 자체 — 즉, 어떤 이슈가 어느 라인에서 발생하는지 — 가 실행 전반에 걸쳐 안정적인가 하는 점입니다. 이것이 본 연구가 채우고자 하는 공백입니다.
설정 (Setup)
설정 (Setup)
-
모델:
Qwen2.5-Coder1.5B 및 7B (Apache-2.0). Ollama를 통해 로컬에서 실행했습니다. 이 모델들을 선택한 이유는 무료이고, 로컬에서 작동하며, API 키가 필요 없어 누구나 생성을 재실행할 수 있기 때문입니다. -
샘플링 설계: temp-0 조건은 **고정된 시드(fixed seed)**를 사용하고
top_k=1, top_p=1.0을 강제했습니다. 반면, temp-0.7 조건은 Ollama의 기본 샘플러와 함께 **실행당 하나의 시드 (0, 1, …, 50)**를 사용했습니다 (top_k=40, top_p=0.9; 스윕하지 않음). 따라서 temp-0.7 수치는 동일한 시드를 사용했을 때의 실행 비결정성(nondeterminism)이 아니라 **온도 0.7에서의 시드 간 샘플링 가변성(across-seed sampling variability)**을 측정합니다 (고정된 시드의 경우, 반복 결과는 자명하게 동일할 것입니다). -
출력 형식: Ollama의 구조화된 출력(JSON 스키마 강제). 각 발견 사항은
{line_number, category, severity, description}형태이며,category는 열거형(enum)으로 제한됩니다. -
발견 사항의 정의 (Identity of a finding): 복합 키인
(line_number, category)입니다. 문장 재구성은 무시되며, 두 발견 사항이 -
신뢰도 (Reliability) = 실행 간 자카드 유사도 (Jaccard between runs, 정답(GT) 미포함):
일치하는 발견 사항 / 합집합으로 계산하며, **최적 이분 매칭 (optimal bipartite matching)**을 사용하여 계산합니다. 단순 탐욕적 매칭 (naive greedy matching)은 순서에 의존적인 값을 생성합니다 (실제로 제가 배포했다가 수정해야 했던 버그입니다). 특정 조건 내의 모든 실행 쌍에 대한 평균값으로 보고되며, 라인 허용 오차 범위 w (±0 strict / ±2 / ±5)가 함께 보고됩니다 — 이는 식별 키 (identity key)에 대한 민감도 분석 (sensitivity analysis) 역할도 겸합니다. -
타당도 (Validity) = 정답(GT) 대비 정밀도 (precision) / 재현율 (recall) / 거짓 양성 (false positives): 별도의 언급이 없는 한, 재현율/정밀도는
target_a에 대한 실행별 평균이며, 카테고리 엄격 적용 및 라인 ±2 기준입니다. 정밀도는 정의상 빈 실행 (empty runs)을 제외하며,target_a에는 빈 실행이 없었습니다 (0/51). 재현율은 모든 실행에 대해 평균을 냅니다. -
다수결 투표 (Majority voting) = N번의 실행 중 k번 이상 나타나는 발견 사항을 유지. 두 가지 축인 임계값 k × 키 세밀도(key granularity) w를 기준으로 조사했습니다.
-
불확실성 (Uncertainty): **실행 수준 (run level)**에서의 부트스트랩 재표본 추출 (bootstrap resampling)을 통한 95% 신뢰 구간 (CIs).
솔직한 구조적 주의 사항 하나를 덧붙이자면: 동일한 파일에 대한 51번의 감사는 유사 반복 (pseudo-replication)입니다. 여기서 도출된 결과가 다른 코드베이스로 일반화될 수는 없으며, 저 또한 일반화된다고 주장하지 않습니다.
결과 1: 모든 것을 고정하면 결정론적(deterministic)이다
네 가지 temp-0 조건 모두 (2개 모델 × {결함 주입된, 쉬운} 파일, N=11): 모든 허용 오차 수준에서 자카드 유사도 1.00, 실행 간 분산 0을 기록했습니다. 한 가지 조건에 대한 별도의 조사에서, 단일 시드 내 6회 반복과 6개의 서로 다른 시드(1, 2, 3, 101, 202, 303)를 사용했을 때 바이트 단위로 동일한 출력이 생성되었습니다. 우리의 temp-0 설정은 top_k=1을 강제하는데, 즉 Ollama/llama.cpp에서의 탐욕적 디코딩 (greedy decoding)을 의미하므로, 해당 환경에서 시드는 관찰 가능한 영향을 미치지 않았습니다. 속설에는 "LLM은 항상 흔들린다"라고 하지만, 이 장비의 완전히 고정된 로컬 조건 하에서는 그렇지 않았습니다.
📐 "결정론적"이라는 말에 붙는 솔직한 주의 사항
별도의 스파이크(spike) 테스트에서 저는 temperature 0일 때 단 한 번의 카테고리 반전(category flip)을 관찰했습니다. 이는 모델 로드 후 오직 첫 번째 실행에서만 발생했으며, 해당 실행은 지연 시간(latency)도 가장 길었습니다(cold-cache/warm-up 특성). warm-up 실행을 추가하자 잔여 현상은 사라졌습니다. 그 메커니즘을 정확히 짚어낼 수는 없지만(GPU parallel-reduction ordering이 일반적인 원인이며, cold cache 가능성도 배제할 수 없습니다), 시드(seed) 때문은 아닙니다. 이는 Thinking Machines의 "Defeating Nondeterminism in LLM Inference" 내용과 일치하는데, 해당 글은 서빙 시간의 비결정성(nondeterminism)을 주로 **배치 크기 변화(batch-size variation)**의 탓으로 돌립니다. 로컬의 단일 요청 설정은 사실상 고정된 배치(fixed-batch)와 같으며, 이는 여기서 나타난 1.00이라는 수치와 일치합니다. 동일한 메커니즘은 Klishevich 등이 클라우드 API(가변 배치)에서 목격한 temperature-0의 흔들림(flutter) 현상도 예측할 수 있습니다. 다만 그들은 다른 후보 원인들도 논의하고 있으므로, 이를 입증된 귀인(attribution)이 아닌 하나의 제안된 메커니즘과의 일관성으로 이해해야 합니다.
결과 2: temperature 0.7에서 샘플링하면 무너진다
사람들이 temperature를 높이는 이유는 정확히 실행 간의 후보 범위를 넓히기 위해서입니다. 결함이 포함된 파일(실행당 하나의 시드, 0–50)에서 temperature 0.7로 테스트했을 때, 실행 간 Jaccard 유사도는 7B 모델의 경우 0.52 [0.47, 0.59], 1.5B 모델의 경우 0.07 [0.04, 0.11]였습니다.
이 수치들 속에는 두 가지 뚜렷한 실패 모드가 숨어 있습니다:
- 라인 드리프트 (Line drift, 7B): 식별 키(identity key)가 ±2 라인까지 허용될 경우, 0.52는 0.80까지 회복됩니다. 불일치의 절반 이상은 동일한 버그가 다른 라인을 가리키고 있는 것과 일치합니다. 다만 허용 오차를 맞추는 과정에서 우연한 일치가 발생할 수도 있으므로, ±2는 "드리프트"의 상한선으로 취급해야 합니다.
- 카테고리 플러터 (Category flutter, 1.5B 및
easy파일):easy파일에서 7B의 라인 전용 재현율(recall)은 0.81이지만, 카테고리 엄격 재현율(category-strict recall)은 0.42입니다. 즉, 모델이 올바른 _위치_는 계속 찾아내지만, 이를 다른 _문제_로 라벨링하고 있다는 뜻입니다.
| 파일 | 모델 | recall (category-strict) | recall (line-only) |
|---|---|---|---|
| target_a | 7B | 0.31 | 0.31 |
| ... | |||
(target_a의 7B 모델에서 두 recall 수치가 일치함 — 이러한 미검출은 드리프트(drift)가 아닌 실제 미검출(non-detections)임.) |
그리고 여기서 저의 첫 번째 큰 실수를 발견했습니다. 초기 recall 수치는 매우 높았습니다. 그 이유는 타겟 코드에 스스로를 위한 메모로 # DEFECT: SQL injection 주석을 남겨두었고, 파일 전체가 프롬프트에 포함되었기 때문입니다. 모델이 제 정답지를 읽고 있었던 것입니다. 라벨을 삭제하자 recall은 급락했고, 실행 간 변동성(run-to-run variance)은 심화되었습니다. 자세한 내용은 아래의 함정 로그(pitfall log)에 기록되어 있습니다.
결과 3 (핵심): 다수결 투표는 지속성(persistence)에 보상한다
다수결 투표(Majority vote) = "51번의 실행 중 26회 이상 나타나는 결과물을 유지함." 이것이 실제로 무엇을 선택하는지 보여줍니다.

그림: 발견 항목당 투표수 (파란색 = 미리 선언된 시드 결함(seeded defects), 빨간색 = 정답(GT) 외의 지속적인 발견 항목, 점선 = 다수결 임계값 26/51). 수치는 동일한 조건에서 실행된 두 개의 타겟 파일에서 통합되었습니다: 파란색 막대는 결함이 삽입된 파일에서, 빨간색 막대는 미끼(decoy) 파일에서 가져왔습니다.
메커니즘. (line, category)를 식별 키(identity key)로 사용할 경우, 인접한 라인을 가리키는 동일한 버그는 _서로 다른 투표지_가 됩니다. 모델은 45/51번의 실행에서 ResourceLeak을 감지했지만, 투표는 16번 라인에 26표, 17번 라인에 19표로 나뉘었습니다 (단일 실행에서 두 라인을 모두 보고한 적은 없으므로, 26+19는 실제로 45번의 별개 실행임). 질문을 "어디에서든 ResourceLeak이라고 말했는가?"로 완화하면 49/51이 되지만, 이는 6개의 서로 다른 라인(12, 15, 16, 17, 18, 21)에 흩어져 있습니다. 투표수 계산은 이를 인지하지 못합니다. 임계값을 주목하십시오:
| 임계값 k | 16번 라인 (26표) | 17번 라인 (19표) | 결과 |
|---|---|---|---|
| k=19 | 유지됨 | 유지됨 | 둘 다 생존 (중복) |
| ... |
내가 사전에 선언한 정확한 다수결 규칙(k=26)에 따르면, 유출(leak)은 단 한 라인에서 간신히 살아남습니다. 핵심은 다수결 투표가 유출을 제거했다는 것이 아니라, 유출의 생존 여부는 모델의 탐지 능력에 의해 결정되는 것이 아니라, 투표가 어떻게 나뉘는지와 당신이 어디에 경계선(line)을 긋는지에 의해 결정된다는 점입니다. 그리고 이것은 단 하나의 일화가 아닙니다: 7B 모델이 최소 한 번 이상 탐지한 5개의 결함 중 2개(~40%)에서 실행 간 라인 드리프트 (line drift)가 나타났습니다.
두 가지 대조적인 사례가 상황을 더 명확하게 보여줍니다:
- 분산되지 않은 실제 버그는 통과합니다: 9번 라인의 SQL 인젝션 (SQL injection)은 51/51 전원 일치 투표를 받았습니다. 투표는 강력한 탐지를 제거하는 것이 아니라, _불안정한 키(wobbly-keyed)_를 가진 탐지를 제거합니다.
- 모호한 결과는 생존합니다:
eval("2 + 2")(33/51)에 대한 경고와 상수 문자열을 사용하는subprocess.run(..., shell=True)(28/51) — 둘 다 상수 사용 시 위해도가 낮으며, 둘 다 미끼 파일(decoy file)에서 나온 것임에도 불구하고 — 모델이 _패턴 (pattern)_에 반응하고 매번 정확히 같은 지점을 지목하기 때문에 지속됩니다. (추가적인 불안정성: 모델은 후자를CommandInjection대신CodeInjection으로 분류했습니다. 카테고리 또한 흔들립니다.)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기