
내 모델이 더 잘 썼다는 이유로 처벌받았습니다. 3개월 후, 단 한 번의 쿼리로 그 사실을 알게 되었습니다.
요약
GRPO를 이용한 모델 학습 과정에서 발생한 보상 해킹(Reward Hacking) 사례와 이를 해결하기 위한 관측성(Observability)의 중요성을 다룹니다. SigNoz를 활용해 학습 데이터를 재평가함으로써 모델이 의도치 않게 처벌받던 버그를 발견한 경험을 공유합니다.
핵심 포인트
- GRPO 학습 중 보상 함수 취약점을 이용한 모델의 보상 해킹 사례
- 학습 과정의 투명성을 위한 실시간 텔레메트리 및 관측성 스택 구축 필요성
- SigNoz를 활용한 LLM 평가 및 미세 조정 모니터링 방법론
- 저장된 데이터를 통한 사후 평가 파이프라인 구축의 중요성
지난 4월, 저는 기업용 IT 티켓을 해결하기 위한 작은 모델을 학습시켰습니다. Meta x PyTorch 해커톤 마감 전날 새벽 4시, 모델이 스타워즈(Star Wars)에 대해 쓰기 시작했습니다. 손실(Loss) 값은 30분 동안 정확히 0으로 평탄하게 유지되었습니다. GRPO (Group Relative Policy Optimization)에서 이는 그룹 내의 모든 완료(completion)가 동일한 보상(reward)을 받고 있음을 의미하며, 이를 달성하는 가장 저렴한 방법은 배치당 8번 실행되는 하나의 취약점(exploit)을 이용하는 것입니다. 저의 절약성 보상(parsimony reward)에는 역전된 분기(inverted branch)가 있었습니다. 모델은 서버를 고치는 대신 영화사를 쓰는 데 이를 사용했습니다.
부끄러운 부분은 이 대목입니다. 저는 그날 밤 보상 함수(reward function)에 급히 끼워 넣은 글로벌 Python 리스트를 통해 디스크에 덤프된 원본 완료(raw completions)를 직접 손으로 읽다가 이를 발견했습니다. 그 리스트가 저의 관측성 스택(observability stack)이었습니다. 모델이 무엇을 쓰고 있는지 너무나 간절히 확인해야 했기에, 새벽 4시에 상상할 수 있는 최악의 텔레메트리 파이프라인(telemetry pipeline)을 직접 수동으로 만들었습니다.
이틀 전, 저는 그때 했어야 했던 일을 했습니다. 셀프 호스팅된 SigNoz를 연동하여, 실시간으로 계측된 평가 파이프라인(instrumented evaluation pipeline)을 통해 해당 학습 실행(training run)을 다시 평가했습니다. 그 결과 제가 가지고 있는지조차 몰랐던 버그가 드러났는데, 제가 찾으려 했던 버그가 아니었습니다. 제가 성공적이라고 판단했던 실행 중간에, 제 모델은 더 잘 썼다는 이유로 연속 10단계 동안 처벌(punished)을 받고 있었습니다.

제가 실제로 실행한 것
시스템: GRPO를 사용하여 200 스텝 동안 학습된 Qwen2.5-3B, 스텝당 8개의 완료(completions), 각 완료를 평가하는 6개의 보상 함수(형식(format), ROUGE-L 품질(quality), 인용 근거(citation grounding), 보정(calibration), 절약성(parsimony), 반복(repetition)). 학습 중에 parquet 형식으로 기록된 1,600개의 평가된 완료(graded completions). 저는 이번 해커톤 전까지 SigNoz에 대해 들어본 적이 없었으며, 그것이 에이전트 모니터링 도구라고 가정했습니다. 하지만 그것은 범용 관측성 플랫폼(observability platform)이며, 자체 LLM 관측성 가이드에서는 평가(evaluation)와 미세 조정(fine-tuning) 모니터링을 핵심 축으로 나열하고 있습니다. 보상 파이프라인(reward pipeline)은 곧 평가 시스템입니다.
정확히 말하자면, 그래디언트 (gradients)는 전혀 실행되지 않았습니다. 저는 저장된 4월의 프롬프트 (prompts)와 완성문 (completions)을 대상으로, 모든 호출을 감싸는 OpenTelemetry를 사용하여 실제 4월의 코드인 6개의 보상 함수 (reward functions)를 재실행했습니다. WandB는 실험 로그 (experiment log)로서 스텝 (step)당 집계된 곡선을 제공합니다. SigNoz는 비행 기록 장치 (flight recorder)가 되어, 모든 개별 완성문을 제가 만들어낼 수 있는 어떤 속성(attribute)으로든 쿼리(query)하거나 알림(alert)을 보낼 수 있게 되었습니다. SigNoz가 그래디언트 (gradients)나 체크포인트 (checkpoints)를 위한 WandB를 대체하지는 않을 것이며, 저 또한 그렇게 시도하지 않을 것입니다.
빌드 (The build)
foundryctl cast는 이 casting.yaml을 사용하여 Docker에 5GB를 할당한 기본 M1 환경에서 스택을 구축했습니다:
apiVersion: v1alpha1
kind: Installation
metadata
...
무엇인가를 계측(instrumenting)하기 전에, 충실도 게이트 (fidelity gate)를 거쳤습니다: 하나의 parquet 파일을 4월의 기록된 점수와 대조하여 재등급을 매겼습니다. 최대 드리프트 (drift): 2.44e-08. 비트 단위까지 동일한 코드였습니다. (존재했던 아주 미세한 드리프트는 4월의 자체 파이프라인 (pipeline)에서 발생한 float32 지문이었는데, 이는 제가 생각했던 것보다 더 즐겁게 느껴졌습니다.)
스텝당 하나의 트레이스 (trace), 완성문당 하나의 스팬 (span), 보상당 하나의 자식 스팬 (child span)을 적용하여 스텝당 57개의 스팬이 생성되었습니다. 모든 숫자는 reward.score, grpo.advantage, completion.confidence, hack.signature와 같은 속성 (attribute)으로 기록됩니다. 타임스탬프 (Timestamps)는 의도적으로 실제 현재 시간을 따릅니다. SigNoz는 보존 기간 (retention window)보다 오래된 스팬을 삭제하기 때문에, 4월의 시간으로 소급 적용하는 것은 부정직하며 조용히 시스템을 망가뜨리는 일이 될 것이기 때문입니다. 대신 training.step이 대시보드의 x축이 됩니다. 스텝 사이에 2.5초의 휴식 시간을 두어, 실행에는 8분가량 소요되었으며, 제가 지난 4월에 마지막으로 보았던 6개의 차트가 실시간으로 다시 그려졌습니다.

붕괴 (The collapse)

이 데이터를 소유하는 3개월 동안 한 번도 알아채지 못했던 사실이 있습니다. 18단계와 28단계 사이에서 거의 모든 것이 한꺼번에 급락했다가 다시 회복된다는 점입니다. 해당 구간으로 트레이스 (traces)를 필터링한 결과 정확히 11개의 트레이스가 반환되었으며, 각 단계당 하나씩 있었습니다. 88개의 완료 (completions) 항목을 구간별로 일일이 읽는 것은 확장성 (scale)이 떨어지므로, 품질 구간 (quality spans)을 CSV로 내보낸 뒤 짧은 스크립트를 사용하여 부모 구간 (parent spans)과 결합했습니다. (부모 속성 (parent attribute)과 자식 점수 (child score)를 한 번의 쿼리로 결합하는 것은 제 설정에서 실패했지만, 내보내기 (export)는 문제없이 작동했습니다.)
18단계: 깨끗함, 8개 모두 0.173에서 0.260 사이. 22단계: 8개 중 8개가 0임.
첫 번째 가설: 과잉 확신 (overconfidence). 보정 (Calibration)은 1 - (신뢰도 (confidence) - 품질 (quality))의 제곱이므로, 품질이 0일 때 신뢰도가 정확히 1.0이어야만 결과값이 정확히 0이 됩니다. 그다음 부모 구간의 실제 속성을 확인했습니다: completion.confidence는 0이었습니다. 이는 파서 (parser)의 폴백 (fallback)일 뿐, 자백이 아니었습니다. 가설 기각.
두 번째 가설: 잘림 (truncation). 22단계의 완료 항목들은 모두 단어 중간에 끝났으며, 하나는 "cited_artifacts": ["KB-0 내부에서 잘려 있었습니다. 하지만 20단계와 21단계는 완전하고 제대로 닫힌 JSON 형태처럼 보였음에도 각각 5개의 0을 기록하고 있었습니다. 또한 눈으로 대충 훑어보다가 하나의 완료 항목을 잘못 읽기도 했는데, 바로 그때부터 저는 제 눈을 믿지 않기로 했습니다.
그래서 읽기를 멈추고 파싱 (parsing)을 시작했습니다. 펜스 (fence)를 제거한 후 88개 전체에 대해 json.loads를 실행했습니다. 47개가 실패했습니다. 단계별로 파싱 실패 (parse failures)와 0점 (zero scores)을 비교했습니다:
파싱 실패: 0 1 5 5 8 5 4 7 4 6 2
0점 기록: 0 1 5 5 8 5 4 7 4 6 2
두 개의 독립적인 소스, 즉 SigNoz 내보내기 결과와 원본 파케이 (parquets) 파일 모두에서 11개 단계가 완전히 일치했습니다. 해당 구간의 모든 0은 파싱 실패였습니다. 남는 것이 없었습니다.
실패의 83%는 "Invalid control character"였습니다. 한 실패 위치에서의 정확한 바이트는 다음과 같습니다:
' resolve this issue, follow these steps:\n\n1. Use `'
JSON 문자열 내부에 두 개의 실제 줄바꿈 (newline) 바이트가 있었습니다. 번호가 매겨진 목록 바로 직전에 문단 구분(paragraph break)이 있었던 것입니다.

그것이 버그의 전부입니다. GRPO는 모델이 더 길고 잘 조직된 답변을 하도록 유도했고, 모델은 그에 부응했습니다: 섹션 구분, 번호가 매겨진 단계들 말입니다. JSON 문자열 내부의 가공되지 않은 줄바꿈(Raw newlines)은 유효하지 않은 JSON이며, 제 파이프라인은 이를 파싱할 수 없을 때 네 개의 보상(rewards)을 한꺼번에 0으로 만듭니다. 모델은 더 나은 기술 작가(technical writer)가 되었다는 이유로 혹독하게 처벌받았습니다. 18단계의 답변은 짧은 단일 문단이며 깔끔하게 파싱됩니다. 22단계의 답변은 문서(documentation)처럼 보이며 0점을 받습니다. 어떤 곡선(curve)도 이를 보여주지 않습니다. 점수 옆의 완성문(completion)을 직접 읽어야 합니다. (27단계와 28단계는 두 번째 모드인 실제 절단(truncation)을 추가합니다. 하나가 아니라 두 개의 버그입니다.)
속성(attributes) 덕분에 가능해진 두 가지 질문이 더 있습니다. 품질(quality)이 0.2 미만이면서 신뢰도(Confidence)가 0.8 이상인 경우: 143개의 완성문이 있었으며, 그중 94%가 정확히 0.85라고 명시했습니다. 이는 과잉 확신(overconfidence)이라는 습관이 아니라, 사람들이 "꽤 확신한다"라고 말하는 방식처럼 모델이 0.85라고 말하는 것이었습니다. 그리고 품질이 0.15 미만이면서 grpo.advantage가 1 이상인 경우: 23개의 완성문이 있었으며, 이는 옵티마이저(optimizer)가 8개의 그룹 중 가장 덜 나쁜 답변에 대해 보상을 준 결과였습니다. WandB 뷰(view)로는 이 두 질문에 대한 답을 얻을 수 없습니다.
4월에 내게 필요했던 경고
저는 4월의 버그 자체도 부활시켰습니다. v1 절약 함수(parsimony function, 200자 미만이나 400자 초과인 모든 것에 보상을 주고 합리적인 중간값은 0으로 만듦)가 이제 수정된 버전과 나란히 실행되며, 실제 품질이 0.10 미만인데 v1 점수가 0.9 이상이면 hack.signature가 true가 됩니다. 이 수치에 대해 Slack에 연결된 트레이스 기반(trace-based) 경고가 울렸습니다: 5개의 트레이스에 걸쳐 7개의 플래그가 지정된 완성문이 있었고, 그중 한 단계에서 3개가 발생했습니다. 패스(pass)가 끝난 10분 후 상황은 스스로 해결되었습니다. 이것은 원래의 해킹된 롤아웃(rollouts)이 아니라 실제 완성문에 대한 재구성이며, v1은 길이에 의해서만 걸려듭니다. 즉, 사고의 증거가 아니라 함정(tripwire)인 셈입니다. 그럼에도 불구하고, 4월에 이 발견은 저를 새벽 4시 30분까지 고생하게 만들었습니다. 이제는 Slack 알림일 뿐입니다.

(메시지의 숫자 뒤에 붙은 "min"은 SigNoz 단위 레이블의 특이사항이며, 단순한 수치입니다.)
세 가지 독립적인 쿼리(50단계 이후의 grounding zeros, 신뢰도 습관, 옵티마이저 격차)가 계속해서 동일한 두 개의 티켓, TRAIN-00044와 TRAIN-00046으로 수렴했습니다. 그 두 티켓이 무엇을 가르쳐주려 했든 간에, 제 모델은 결코 그것을 배우지 못했고, 그 어떤 곡선(curve)도 저에게 그 사실을 알려주지 않았습니다.
마지막 확인 작업으로, 저는 제 노트에 접근 권한이 없는 새로운 세션에서 Claude Code를 SigNoz의 MCP 서버로 지정한 뒤, 평이한 영어로 과도하게 확신하는 저품질 티켓을 찾아달라고 요청했습니다. Claude Code는 스키마(schema)를 재발견하고, 부모-자식 조인(parent child join)을 스스로 계산해냈으며, 약 18분 만에 다음과 같은 결과를 반환했습니다: 35개의 티켓에 걸쳐 443개의 일치 항목(스모크 테스트를 포함하여 제가 보낸 모든 실행 결과이므로, 제가 확인했던 143개보다 더 넓은 범위입니다). 다시 똑같은 두 개의 티켓이었습니다.
명명된 기능
질문은 제가 가장 유용하다고 느낀 기능에 대해 묻고 있습니다. 트레이스 폭포수(trace waterfall)가 아닙니다. 바로 쿼리 빌더(Query Builder), 특히 이전에 본 적 없는 속성(attribute)을 다루는 방식입니다: reward.score, grpo.advantage, hack.signature는 제가 임의로 만든 단어들이지만, 이 단어들이 등장하는 순간 마치 내장된 필드처럼 필터링, 차트화, 알림 설정이 가능했습니다. 이 포스트의 모든 발견은 저만의 어휘를 사용한 쿼리 결과입니다. 버그를 찾아내는 도구는 당신의 시스템이 이미 생각하고 있는 용어를 이해하는 도구여야 합니다.
제가 얻은 교훈
저는 모델을 파인튜닝 (Fine-tuning) 합니다. 이는 제가 집계된 곡선 (Aggregate curves) 속에서 살아간다는 것을 의미합니다. 손실 (Loss)은 낮아지고, 평균 보상 (Mean reward)은 올라가면, 배포합니다. 실행 중간에 무언가 고장 나면 차트를 뚫어지게 쳐다보거나, 보상 함수 (Reward function)에 리스트를 급히 추가한 뒤 새벽 4시에 원문 텍스트 (Raw text)를 읽습니다. 이것이 많은 강화학습 (RL) 파인튜닝의 최첨단 상태이며, 동시에 절반은 눈이 먼 상태입니다. 제가 처음으로 하나의 완성된 결과물 (Completion)을 클릭하여 정확한 텍스트, 티켓, 단계, 그리고 6개의 점수를 한 패널에서 확인했을 때, 저는 의자 뒤로 몸을 기댔습니다. 이전에는 결코 그런 질문을 던질 수 없었습니다. 빌드 과정은 7시간 동안 이어진 연속적인 밤샘 작업이었습니다. 저는 계속 진행했습니다. 모든 쿼리 (Query)가 저를 한 단계 더 가까이 데려다주었기 때문이며, 솔직히 말해서 관찰성 (Observability) 도구에서 기대하지 못했던 재미까지 느꼈기 때문입니다.
만약 여러분이 보상 함수를 사용하여 파인튜닝을 한다면: 여러분의 보상과 모델의 행동은 텔레메트리 (Telemetry)입니다. 그것을 그렇게 취급하십시오. 그리고 잘못된 이론들은 보고서에 그대로 남겨두십시오. 저의 이론들은 모두 특정 숫자 앞에서 무너졌으며, 최종 결론이 신뢰할 수 있는 이유는 바로 그 이론들이 실패했기 때문입니다.
다음 단계
저장된 완성물 (Completions) 대신 실시간 추론 (Live inference)을 대상으로 하는 동일한 파이프라인 (Pipeline): 이것이 7월 20일에 시작되는 메인 해커톤을 위한 저의 빌드입니다.
파이프라인, casting.yaml, 그리고 전체 조사 로그가 포함된 리포지토리 (Repo): REPO LINK.
환경, 학습된 모델, 그리고 원래의 새벽 4시 이야기: HF SPACE - MODEL - APRIL BLOG.
AI 사용에 관한 참고 사항
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기