OpenAI가 RLHF에 대해 당신을 오해하게 만든 방법

ko생성일: 2025. 8. 18.갱신일: 2025. 8. 21.

저자가 취미로 개발 중인 오픈소스 이미지 캡셔닝 모델 JoyCaption의 베타 원(Beta One) 릴리스를 계기로, LLM 맥락에서의 강화학습(RL)을 SFT에서부터 차근히 확장해 설명하고, RLHF가 단지 ‘안전’과 ‘정렬’만을 위한 것이 아니라 신뢰 가능한 지시 따르기 능력을 만드는 핵심임을 논증한다. 이어 JoyCaption에 적용한 오프라인 DPO 기반 RL 파이프라인, 데이터셋 구축, 심판(판정) 설계, 하이퍼파라미터와 결과를 상세히 공유한다.

JoyCaption은 내가 취미로 작업하는 오픈소스 이미지 캡셔닝 모델(https://github.com/fpgaminer/joycaption)인데, 최근 Beta One 릴리스를 냈다. 이 릴리스는 모델을 개선하기 위해 처음으로 강화학습(RL)을 사용했다는 점에서도 첫 사례였다. DeepSeek R1의 등장 이후 RL이 한창 화제가 되었고, 그럴 만한 이유도 있다. 하지만 나는 많은 사람들이 RL과 그 용도에 대해 근본적으로 오해하고 있다고 생각한다. 그 이유의 큰 부분은 OpenAI에 있다고 본다. 이 글에서는 RL의 미스터리를 밝히고, JoyCaption을 RL의 결투장에 어떻게 세웠는지 구체적인 디테일까지 파고들고자 한다. 나는 커뮤니티가 멋진 것을 만들 수 있도록 내 도구와 지식을 공유하는 것을 좋아한다. 그래서 예전에도 내 모델들에 대한 세부 내용을 공유해 왔다. 그러니 서두는 강화학습이 무엇인지, 그리고 OpenAI가 사람들을 어떻게 오해하게 만들었는지에 할애하겠다. 그런 이야기에 관심이 없거나 이미 숙지했다면, 그리고 모델 학습의 알짜 디테일로 바로 들어가고 싶다면 이 링크로 건너뛰자: “JoyCaption: Beta One에서 RL을 어떻게 썼나”.

LLM 맥락에서의 RL 개념은 종종 난해하고 이해하기 어렵게 설명된다. 하지만 실제로는, LLM의 사후 학습(post-training) 문맥에서 RL은 “정상적인” 방식의 사후 학습에 아주 단순한 확장일 뿐이다. 이 정상적인 방식을 요즘은 감독 미세조정(SFT, Supervised Finetuning)이라고 부르며, 새로운 학습 기법들과 구분하기 위해 더 자주 쓰인다. 과거에 모델을 어떻게 학습했는지 안다면 SFT를 아는 것이고, 그 말은 이미 RL에 대해 일부 알고 있다는 뜻이다! 왜냐하면 SFT는 RL의 부분집합이기 때문이다. 이 점을 염두에 두고, SFT에서 시작해 작은 확장을 하나씩 더해 ‘완전판’ RL로 가는 경로가 가장 쉽다.

전통적 SFT에서는 각 예제가 (프롬프트, 응답)으로 구성된 데이터셋을 사용한다. 학습 중에는 이들이 모델을 통과하고, 원하는 응답의 확률을 경사 하강(AdamW 옵티마이저 등)을 통해 끌어올린다. 즉, 우리는 긍정(Positive) 예제를 잔뜩 가지고 있고, 모델이 그것들을 재현하도록 몰아간다.

첫째, 전통적 SFT에서 예제의 응답은 보통 사람이 쓴다. 인터넷의 기존 소스(예: StackOverflow, Quora 등)에서 모으거나 직접 작성한다. 하지만 학습하려는 그 모델의 응답을 사용해 데이터셋을 만들 수도 있다. 후보 응답을 잔뜩 생성해서 그중 최선을 고르고, 그렇게 데이터셋을 구성하는 것이다. 이는 거절 샘플링(Rejection Sampling)이라고 한다. 아이디어는 이렇다. 모델은 사전학습을 통해 이미 원하는 응답을 ‘내재’하고 있는데, 우리는 원치 않는 응답들 사이에서 그걸 골라내려는 것이다.

둘째, 데이터셋을 학습 전에 미리 만드는 방식을 “오프라인(Offline)” 학습이라고 부른다. 거절 샘플링을 한다면, 이론상 학습 중에 데이터셋을 실시간으로 만들 수도 있다. 이를 “온라인(Online)” 학습이라 한다. 온라인은 재미있다. 학습이 진행되어 모델이 좋아질수록 응답도 함께 좋아지고, 유익한 피드백 루프를 형성하기 때문이다.

셋째, 데이터셋은 전적으로 긍정 예제로 이루어진다. 즉, 우리가 모델이 생성하길 바라는 응답의 예제들이다. 그렇다면 거절 샘플링에서 탈락한 응답들은? 원치 않는 것을 모델에 가르치기 위해 부정(Negative) 예제를 선택할 수 있다면 유용할 것이다. 가능할까? 물론이다! 긍정 예제를 100%로 끌어올리듯이, 부정 예제는 0% 확률로 밀어내면 된다.

마지막으로, 모델을 SFT 하되 ‘너무 많이’는 하고 싶지 않다고 해보자. 학습된 모델이 항상 사전학습 모델과 크게 다르지 않도록 말이다. 이는 간단하다. 원래 모델이 예측했을 확률도 함께 계산하고, 학습된 모델이 그 원래 확률에 가깝도록 하는 손실을 추가하면 된다. 손실의 스케일을 적절히 조절해 두 힘의 균형을 맞추면, 목표에 맞춰 모델을 조정하면서도 원래 모델과의 거리를 유지하는 학습 루프를 만들 수 있다. 이 두 번째 손실을 KL 발산 손실(KL Divergence loss)이라고 한다.

믿거나 말거나, 이제 당신은 LLM을 위한 RL을 이해한 것이다. 실제로 RL의 유일한 특징은 부정 예제의 사용이다. 나머지는 전부 SFT에서도 활용 가능하다. 예를 들어 KL 발산 손실을 사용한 온라인 SFT도 할 수 있다. 부정 예제를 도입하는 순간, 당신은 RL을 하고 있는 것이다.

모든 것을 합치면, 부정 예제와 KL 발산만 추가해도 RL을 할 수 있고, 온라인 학습은 필요할 때 꺼내 쓸 카드로 남겨둘 수 있다. 정말 그것뿐이다. 더 이국적인 형태의 RL도 있지만, DPO와 GRPO가 가장 대중적인 레시피이며, 결국 본질은 같다. DPO는 각 프롬프트에 대해 하나의 긍정 응답과 하나의 부정 응답, 즉 쌍(pair)을 다룬다. GRPO는 각 프롬프트에 대해 여러 응답을 묶음(group)으로 다루며, 각 응답에 부정/긍정을 나타내는 스칼라 점수를 부여한다. 이는 해당 프롬프트-응답 쌍의 손실 스케일을 조정할 뿐이다. 그래서 모델은 아주 좋은 응답과 아주 나쁜 응답에서 더 크게 배우고, “그저 그런” 응답에서는 덜 배운다. GRPO는 학습 내내 이 스칼라를 자동으로 조정(응답들 사이에서 정규화)하여, 기존 RL에서 유행하던 학습 안정성 문제를 완화한다.

온라인과 오프라인 중에서는, 오프라인이 구현이 쉬운 편이다. 오프라인에서는 데이터셋을 천천히 만들 수 있는데, 특히 사람이 응답을 평가하고 등급을 매길 때 중요하다. 원하는 만큼 시간을 들여 데이터셋을 생성하고, 다듬을 수 있다. 반면 온라인은 그 모든 작업을 실시간으로 해야 한다. 상상해 보라. 학습 중 매 배치마다 응답을 생성하고, 사람에게 평가를 받는다면 얼마나 복잡하고 느려질지.

그렇다면 왜 온라인을 하려 할까? 모델이 자신의 출력물을 학습할 때 성능 향상이 더 크기 때문이다. 오프라인에서는 데이터셋 속 응답을 모두 원래 모델이 생성했다 하더라도, 학습 중 모델은 서서히 원래와 달라진다. KL 손실과 짧은 학습으로는 그 차이가 미미하여 오프라인이 잘 작동할 수 있다. 그러나 긴 학습에서는 발산이 재빨리 문제가 된다. Llama 3에서 Meta가 사용한 접근은 반복적(Iterative) 방식으로, 오프라인 DPO를 세 번의 작은 라운드로 수행하되, 각 라운드마다 새 데이터셋을 생성했다. 이는 두 접근 사이에서 균형을 잘 잡는다. 또한 각 라운드 사이에서 모델의 진척을 점검하고 필요하면 전략을 조정할 기회도 생긴다.

이 섹션을 마치기 전에, RL을 읽으며 자주 접할 두 용어를 더 소개하겠다. “Policy(정책)”는 학습 중인 모델을 뜻한다. “Judge(심판, 판정기)”는 응답을 평가하는 것을 뜻한다(사람일 수도, 다른 AI 모델일 수도 있다). 마지막으로 한 가지 매우 중요한 점에 주목하자. 모델이 자신의 출력으로 학습하는 것. 엄밀히 말해 필수는 아니지만, RL이 작동하려면 사실상 필수에 가깝다. 이게 얼마나 중요한지 아무리 강조해도 지나치지 않다. 그 이유는 곧 설명한다.

오랫동안 LLM 세계에서 RL은 RLHF(인간 피드백 기반 강화학습)와 동의어였고, RLHF는 모델의 안전성 향상이나 선호도 튜닝에만 유용하다는 널리 퍼진 믿음이 있었다. 다행히 DeepSeek R1 이후 그 믿음은 빠르게 사라지고 있지만, 나는 여전히 RL이 깊이 오해받고 있다고 본다. RL은 최근의 추론(reasoning) 모델뿐 아니라 초창기부터 모든 성공적인 LLM의 중추였다! 더 중요하게는, RL이 유용한 LLM을 만드는 토대임을 강조하고 싶다. 많은 면에서, RL 없이 제대로 작동하는 LLM을 만드는 것은 불가능하다.

이 오해가 어디서 시작되었는지부터 보자. 2022년 말 ChatGPT가 나오기 전, InstructGPT라는 모델이 있었다. OpenAI의 첫 RLHF 기반 프로덕션 모델이다. 소개 글(https://openai.com/index/instruction-following/)의 일부를 보자:

GPT‑3 언어 모델은 [..] 정교하게 설계된 텍스트 프롬프트를 사용해 자연어 작업을 수행하도록 유도될 수 있습니다. 그러나 이 모델들은 진실되지 않거나, 독성이 있거나, 해로운 감정을 반영하는 출력을 생성하기도 합니다. [..] 우리의 모델을 더 안전하고, 더 유용하며, 더 정렬되도록 만들기 위해, 우리는 인간 피드백 기반 강화학습(RLHF)이라는 기존 기법을 사용합니다.

이 글이 많은 사람들에게 RL에 대한 첫 소개였을 수 있고, 설령 아니었다 해도 ChatGPT의 성공이 OpenAI의 말을 금과옥조로 굳혀 버렸다. 그래서 이 글을 비롯한 많은 글들이 RLHF를 “안전”과 “정렬”에 직접 연결하고, 다른 것에 대해서는 거의 언급하지 않으니, RLHF가 그 용도로만 좋은 것이라 여긴 것은 당연하다.

이는 진실과 거리가 멀다. 다음 섹션들에서 나는 그 오해를 풀고, RL이 현대 AI의 초석인 이유를 지금과 가까운 미래 모두에서 설명하겠다.

OpenAI의 눈가리개를 벗기고 RL을 더 깊이 이해하려면, 먼저 ‘안전(Safety)’과 ‘정렬(Alignment)’이라는 관념을 잠시 치워야 한다. 안전이 중요하지 않다는 뜻은 아니다. 다만 안전에서 출발하는 것은 마치 마라톤을 뒤로 달리려는 것과 같다. 그리고 정렬은 쓸모없는 용어로, 사람들을 오해하게 만들 뿐이다.

대신, 모든 LLM에서 가장 중요한 단 하나의 지표, 즉 RL이 개선하려는 것이며 SFT만으로는 개선할 수 없는 것을 정립하자.

💡

신뢰 가능한 지시 따르기(Reliable Instruction Following) - 주어진 지시를 바탕으로 일관되게 성공적인 응답을 작성하는 능력.

이 단 하나의 지표가 지금까지 모든 LLM 학습의 유일한 초점이었으며, 모델의 유용성을 정의한다. OpenAI가 말하는 안전과 정렬은 이 개념의 부분집합일 뿐이다. 그리고 이것은 훨씬 더 넓은 개념이다. 그러니 자세히 풀어볼 가치가 있다.

지시는 여러분이 익숙한 바로 그것이다. 모델에게 시키는 일. 다만 여기서는 명시적 지시(프롬프트에 직접 쓰는 것)와 암묵적 지시 모두를 고려해야 한다. 챗봇에 질문을 쓰면 우리는 답변을 기대한다. “이 질문에 답하라”는 명시적 지시를 주지 않았어도 말이다. 그것은 챗봇이라는 맥락에서 ‘질문’이라는 형식이 암묵적으로 지시한다. 사실 명시적 지시보다 암묵적 지시가 훨씬 많다. 모델은 챗봇처럼 행동해야 하고, 입력 형식의 의미는 무엇이며, 어떤 종류의 응답을 제공해야 하고, 전반적인 문체는 어떠해야 하는지 등. 모델을 미세조정하여 선호하는 응답을 바꾸는 것은, 새 암묵적 지시를 굽혀 넣는 것과 다름없다!

“성공적”은 “정확”과는 다르다는 점이 중요하다. 수학 로봇을 학습시킨다고 하자. 완벽은 없으니 최고 정확도가 70%라고 하자. 나머지 30%에서는 로봇이 틀린 답을 주길 바랄까, “모르겠어요”라고 말하길 바랄까? 분명 후자다. 그렇지 않으면 신뢰할 수 없는 로봇이 된다. 과제마다 평균 C를 받을 것이다! “모르겠어요”는 엄밀히 말해 ‘정답’은 아니지만 ‘성공적인’ 답이다. 그 로봇은 자신의 응답을 신뢰할 여지를 주고, 올바른 답을 못할 때 다른 해결책을 찾을 기회를 준다. 그러니 우리는 모델이 100% ‘성공적’이길 원하지, 반드시 100% ‘정확’하길 원하는 것은 아니다.

마지막으로, 우리는 모델이 신뢰 가능하길 원한다. 위에서 말한 것들이 일관되게 되지 않으면 소용없다. 이는 곧 로봇이 항상 성공적인 응답을 작성하길 바란다는 것의 다른 표현이다. 성공률을 100%에 최대한 가깝게 끌어올리고 싶다는 뜻이다. 그렇게 될수록 응답의 신뢰가 높아지고, 모델은 더 유용해진다.

정리하면, 모델이 신뢰 가능한 지시 따르기 능력을 갖추면, 항상 지시를 읽고 그에 따라 유용한 응답을 제공한다. 단순하다.

OpenAI가 말하는 안전과 정렬로 돌아오자. 둘 다 모델을 신뢰 가능한 지시 따르기로 만드는 것에 완전히 포함된다. 왜냐하면, 모델이 신뢰 가능한 지시 따르기 모델이라면, 우리 가이드라인을 지시로 코드화하기만 하면 어떤 가이드라인에도 ‘안전’하고 ‘정렬’되게 만들 수 있기 때문이다.

예를 들어 GPT-3에 RLHF를 적용해 아주 다양한 질의에 대해 OpenAI의 시스템/플랫폼 가이드라인에 따라 선호되는 응답을 항상 내놓도록 학습시킨다고 하자. 즉, ChatGPT를 만든다. 실제로 한 일은, 그 모든 가이드라인을 암묵적 지시로 굽힌 ‘신뢰 가능한 지시 따르기’ 모델을 만든 것이다.

내가 “훨씬 더 넓다”고 했던 이유가 여기에 있다. 신뢰 가능한 지시 따르기라는 넓은 개념에서 출발하면, 우리는 모델을 평가할 더 날카로운 도구를 갖게 된다. 안전을 이 관점에서 보면, 신뢰 가능한 지시 따르기에 집중하는 것이 더 나은 안전으로 이어짐을 쉽게 볼 수 있다. 특정 지시 세트만 잘 따르게 만드는 대신, 전반적으로 지시를 더 잘 따르는 모델을 ‘길러내기’ 때문이다.

이 모든 것이 강화학습과 무슨 상관인가? 간단하다. SFT만으로는 모델을 신뢰 가능한 지시 따르기로 만들 수 없다. 오직 RL만이 가능하게 한다. SFT는 과제에 따라 최대 90% 정도까지 모델을 끌어올리는 경향이 있다. SFT만 거친 LLM을 써 보면 직접 확인할 수 있다. AI Dungeon, NovelAI 같은 텍스트 어드벤처 앱에 쓰이는 모델들에서 흔하다. 이런 모델이 ChatGPT만큼 신뢰 가능하지 않음을 금방 느낄 것이다. 10번에 1번에서 20번에 1번 꼴로 글리치가 나온다. NovelAI 같은 플랫폼은 샘플링 알고리즘의 복잡한 연쇄로 이런 행동을 억누르려 한다. 그것들을 끄면 모델이 단어와 구를 끝없이 반복하는 루프에 빠지기도 한다. 그것도 매우 좁은 과제를 수행하는 모델인데도 말이다. 최신 ChatGPT가 그런 글리치를 낸 것을 본 적 있는가? 이것이 OpenAI의 자원이나 컴퓨팅 파워 때문은 아니다. 과거에도, 지금도 100% RL의 결과다.

그 이유는 복잡하다. 다음 섹션에서 파보자.

내 경험상 OpenAI의 모델은 “글리치” 빈도(모델이 쓰레기 응답을 내거나 반복 루프에 빠지는 등)가 약 99.99% 신뢰도(만 응답 중 1회 글리치, 소위 포 나인) 수준이다. 이런 신뢰도는 SFT로는 결코 달성되지 않는다. 왜 SFT는 안 되고 RL은 더 끌어올릴 수 있을까?

설명하려면, 먼저 LLM이 무엇을 하려 하는지부터 확립해야 한다. 그 일은 입력, 즉 지시의 집합에서 출발한다(채팅 맥락에서도 대화 내역에는 명시적/암묵적 지시가 있으며, 우리는 LLM이 그것을 따르길 기대한다. 모든 것은 ‘지시’로 볼 수 있다). 입력에서 시작해, LLM의 일은 성공적인 출력, 즉 모든 지시를 따르고 사용자에게 유용한 응답을 찾는 것이다.

이제 당신이 LLM이라고 상상하자. “헝가리의 수도는?” 같은 입력을 받았을 때, 가능한 성공적 출력은 몇 가지나 될까? “부다페스트”, “헝가리의 수도는 부다페스트입니다.”, “죄송하지만 잘 모르겠어요!” 등은 모두 유효한 해답이다. 헝가리나 부다페스트에 대한 추가 정보를 포함하는 답은? “도움이 되었길 바랍니다. 헝가리에 대해 더 알고 싶은 게 있나요?” 같은 후속 멘트는? 가능한 유효 해법의 집합은 실로 엄청나게 크다는 것을 쉽게 알 수 있다.

그 많은 해법 중 어떤 것을 LLM이 골라야 할까? 공간은 거대하다! 우리는 LLM이 자기회귀적이라는 것을 안다. LLM은 한 번에 한 단어씩 출력하며, 천천히 “해결책”으로 걸어간다. 하지만 그건 해 공간을 줄이지 못한다. 결국 LLM은 어떤 방식으로 도달하든, 많은 해 중 하나를 고르는 것이다. 매 단계(토큰)마다 앞에 펼쳐진 수많은 경로 중 하나를 골라야 한다. 각 분기점에서 예측이 한 번이라도 틀리면, 결국 유효하지 않은 해에 도달해 신뢰성이 무너진다. 모든 LLM은 유한하고, 입력/출력 조합은 사실상 무한하니, 사전학습 LLM이 ‘신뢰 가능’할 수 없다는 것은 분명하다.

해결책은 출력 공간을 제한하는 것이다. 그러나 이것도 문제에 부딪힌다. 모든 가능한 입력에 대해 출력 공간을 제한할 수 있는 방법이 있다고 하자(이미 비현실적 가정). 그러면 그 더 제한된 출력 공간으로 LLM을 학습시키는 것으로 충분할까? SFT가 하는 일이 바로 이것이다. 어떤 이는 SFT가 모델에 “지시 따르기”를 학습시킨다거나, 최소한 잠재된 지시 따르기 능력을 이끌어낸다고 말할지 모른다. 하지만 LLM의 사전학습 데이터셋 전체가 이미 지시 따르기를 모델에게 가르치고 있다. 지시 SFT 데이터셋이 특별한 것이 아니다. 날것의 사전학습 LLM은 이미 당신이 주는 명시적/암묵적 지시를 따르려 한다. 앞서 말한 이유 때문에 실패할 뿐이다. 무한한 공간을 제한된 파라미터로 모델링하라고 요구받기 때문이다. SFT는 출력 공간을 크게 제한하여 LLM이 더 나아 보이게 한다. 하지만 다른 이유로 인해 SFT는 결코 완전한 성공을 거둘 수 없다. 우리는 어떤 유효 해가 “쉬운지” 혹은 “어려운지” 알 수 없기 때문이다.

해의 난이도를 이해하려면, LLM을 스타크래프트 같은 게임에서 유닛을 목적지까지 안내하는 길찾기 알고리즘에 비유해 보자. 다만 한 개의 목적지까지 가는 경로가 아니라, 어떤 유효한 목적지든 도달하는 경로를 찾도록 요구한다는 점이 다르다. LLM은 입력이라는 출발점에서, 유효한 출력이라는 목적지까지 가야 한다. 토큰을 고르며 경로를 걸어간다. 경로의 매 단계에서 LLM은 다음 토큰의 확률을 예측한다. 즉, 거기서 어떤 분기가 유효하고 무효인지 판단하는 것이다. 만약 LLM이 무한히 “영리”하다면 예측은 항상 완벽하고, 따라서 모든 유효 해로 가는 길을 탐색할 수 있고, 무효 해에 이르지 않을 것이다. 하지만 LLM은 매 단계에서 수행할 수 있는 계산 능력이 제한되어 있으므로, 어느 지점에서든 예측의 품질은 들쭉날쭉하다. 예를 들어 입력이 수학 문제일 때, 짧은 경로는 길목보다 더 어렵다. 즉, 답을 곧장 내놓는 응답은 한 번에 문제를 풀어야 하므로 어렵고, 단계별로 푸는 방법은 매 단계에 모델의 용량을 반복 적용할 수 있어 더 쉽다. 그래서 LLM은 거친 지형을 가로지르는 것으로 볼 수 있다. 지형의 기울기는 그 지점에서 다음 단계를 정확히 예측하기 위해 필요한 계산량으로 정의된다. 수학 문제라면, 곧장 답을 주는 경로는 매우 가팔프고, 단계별 방법은 완만하다. LLM이 감당할 수 있는 최대 기울기는 그 모델의 내재된 용량이 결정한다.

어떤 해가 ‘쉬운’ 해인지, 혹은 ‘어려운’ 해인지는 그 해로 가는 경로 전체가 LLM이 감당할 만큼 충분히 완만한지로 결정된다. 경로 중 어딘가가 용량을 초과하면 예측이 완벽하지 않게 되고, 거친 경로일수록 잘못된 발걸음을 내딛어 무효 해에 이를 가능성이 높다.

이로부터 더 강한 LLM이 본질적으로 더 약한 LLM보다 신뢰성이 높은 이유를 알 수 있다. 더 많은 지형이 그들에게는 쉬우므로, 원래라면 어려운 경로를 택하더라도 실수할 확률이 훨씬 낮다.

SFT는 출력 공간을 제한하려는 시도다. 지형을 잘라내 LLM이 탐색할 구역을 제한하는 것과 같다. 하지만 큰 문제가 하나 있다. 우리는 지형을 알지 못한다! 수십억 파라미터 모델을, 우리가 이해하지 못하는 학습 동역학으로, 무한 공간의 문제를 풀게 하고 있다.

이것이 SFT가 결코 완전한 해법이 될 수 없는 이유다. SFT는 우리가 지형을 안다고 전제하고, 로봇이 완만한 곳만 지나가도록 안내하려 한다.

물론 우리는 샘플링으로 지형을 조금은 엿볼 수 있다. LLM을 실행해 응답이 유효한지 아닌지를 보는 것이다. 응답이 무효라면, 그 토큰 공간 경로는 어렵다는 것을 확실히 안다. 그러나 응답이 유효하면? 아무것도 모른다. 어려운 경로는 LLM을 덜 신뢰 가능하게 만들 뿐이니까. 즉, 우리의 관측은 확률적이다. 결론: 어떤 응답이 쉬운지 어려운지 우리는 알 수 없다.

어쩌란 말인가!? RL이 구해준다.

앞서 RL 설명에서 모델이 자신의 출력으로 학습하는 것이 중요하다고 했다. 이제 그 이유를 보여주겠다.

우리는 LLM의 출력 공간을 ‘쉬운 해’로만 제한해야 하지만, 어떤 해가 쉬운지 알 수 없다. 그러므로 단 하나의 방법이 있다. LLM이 우리 대신 쉬운 해를 찾아오도록 하는 것이다.

오로지 LLM 자신이 쓴 응답만으로 RL을 하면, 다음과 같은 루프가 된다.

  • LLM을 탐험에 보낸다.
  • 성공하면, 그 경로를 더 자주 지나간다.
  • 실패하면, 그 경로를 덜 지나간다.

이 루프는 LLM이 유효한 해를 찾을 뿐만 아니라, 쉬운 해를 찾도록 만든다. 한 번 해를 찾으면 LLM은 그 경로를 다시 밟아가려 한다. 계속 성공하면, 그 경로가 쉬운 해로 가는 쉬운 길임을 입증해 간다. 과거에 성공했던 경로를 밟았는데 어딘가에서 실패한다면, 그 경로는 가지치기한다. 합쳐 보면, 이 과정은 출력 공간을 ‘쉬운 경로일 것이라는 높은 신뢰를 가진 경로’로만 제한한다. 다른 모든 경로는 가지치기된다.

자신의 출력으로 학습하는 것과 부정적 강화(negative reinforcement)라는 마법의 재료 덕분에, RL만이 유효하고 쉬운 해로 출력 공간을 제한하는 유일한 방법이 된다.

이제 알겠는가. RLHF를 단지 안전 문제로만 여긴 것은 큰 눈속임이었다. RLHF는 안전과 무관하게 ChatGPT가 애초에 ‘작동’하게 만든 핵심이었다. 어떤 LLM이든 신뢰 가능하게 만드는 유일한 것이라고 해도 과언이 아니다. 사실 나는, 비전이나 확산 모델 등 어떤 AI든(일부 확산 모델이 하는 선호 튜닝 같은 것이 아니라 진짜 RL) 제대로 작동하게 하는 유일한 것이 RL이라고 믿는다.

지금까지는 완전한 RL을 가정했다. 완전한 RL은 보상에 따라 모델을 조정하는 이상적인 알고리즘과, LLM이 공간을 충분히 탐색하는 것을 전제한다. 현실에서는 절대 일어나지 않는다. 실제 세계에서, 지금껏 우리가 가진 가장 발전한 RL 알고리즘조차 계산적으로는 LLM을 ‘콩 하고 머리를 때리는’ 수준이다. 진짜다. 예를 들어 DPO는 좋은 응답의 로그 확률을 통째로 올리고 나쁜 응답의 로그 확률을 통째로 내리는 정도다. 토큰별 섬세함 같은 건 없다. 모델이 아파하는 짓을 못 하게 BONK 하고 때릴 뿐이다.

해 공간도 완전히 탐색하게 두지 않는다. 이미 알다시피, 해 공간은 거대하다. 입력 하나당도 거대하다. 실제로 LLM은 가능성의 아주 얇은 조각만 탐색한다. RL은 수천~수백만 예제 수준에서 돌리는 경우가 흔한데, 사전학습과 비교하면 새 발의 피다. 이는 몇 가지 함의를 가진다.

해 공간을 완전히 탐색하지 않기에, LLM은 안전으로 이끄는 공통 패턴에 집착하는 경향이 있다. ChatGPT 같은 모델에서 특정 단계나 특정 문구, 말투가 늘 반복되는 현상으로 나타난다. 사용자가 다르게 지시해도 말이다. ChatGPT의 “성격”은 OpenAI가 원해서만 그런 것이 아니다. LLM은 RL 과정에서 성격을 ‘발달’시킨다.

이것은 인간이 성격을 형성하는 방식과 매우 비슷하다. 우리 역시 용량을 초과하는 거대한 출력 공간에 맞닥뜨린다. “헝가리의 수도는?” 같은 질문을 받았을 때 가능한 모든 응답 방식을 고려하는가? 아니다. 우리는 익숙하게 밟아온 말하기 방식이 있다. 다양한 시도를 해 보고, 환경에서 보상/처벌을 받으며, 문제를 푸는 유용한 방식의 묶음을 형성한다. 이런 도구, 스크립트, 템플릿이 합쳐져 고유한 성격을 이룬다. 제한된 시간에 신뢰 가능한 경로를 찾아야 하는 LLM에게도 다르지 않다. 한 번 잘 작동하는 것을 찾으면 거기에 집착하고, 그것으로 다른 과제의 신뢰 가능한 해를 찾는 데에까지 써먹는다.

성격이 생기는 것 외에도, 공간을 충분히 탐색하지 않으면 LLM의 ‘지능’을 충분히 활용할 수 없게 된다. 추론 이전 시대에도 명백했다. 요즘의 ‘추론 스타일 RL’은 새로운 종류의 RL이 아니다. DeepSeek R1 뉴스가 마치 RL의 도입인 것처럼 만들었지만 말이다. 이미 앞에서 RL이 단지 안전용이 아니라고 했으니, RL은 늘 존재했다는 것이 분명해졌다. 모델도 근본적으로 바뀌지 않았고, 학습 알고리즘도 그렇다. 이번에 새로워진 것은, 작은 길찾기 LLM들에게 지형을 더 탐험할 자유를 주고, 그렇게 하도록 보상을 준다는 점이다. LLM은 우리가 지금 보는 경로를 예전에도 갈 수 있었지만, 탐험되지 않았던 탓에 이전 RL에서 가지치기되었다. 탐험되지 않은 경로는 본질적으로 위험하므로, LLM은 그들을 회피하는 법을 배운다.

여기까지 읽었다면, 휴. 미안하다. 하지만 이제 실제 모델에 RL을 적용한 알짜 디테일로 들어가자.

이번이 LLM에 RL을 본격 적용하는 초창기 여정 중 하나였기에, 나는 기본에 충실하기로 했다. 여러 라운드의 오프라인 DPO. 이론상으로는 두 가지만 필요하다. 모델이 생성한 응답의 맞대결(헤드투헤드) 평가 데이터셋과, DPO를 수행하도록 손본 학습 스크립트.

학습 파이프라인 측면에서 크게 바뀔 것은 없다. “예제”당 텍스트 한 덩어리 대신 프롬프트+두 개의 응답을 처리하도록 데이터 로더와 학습 루프를 바꾸면 된다. 학습 루프에서는 응답 쌍을 처리하기 위해 프롬프트를 배치 축으로 두 배로 늘리고, 각각의 응답 앞에 붙인다. KL 손실을 처리하려면 고정(frozen)된, 학습 전 모델의 복사본이 필요하다. 이를 통해 기준 로짓을 얻는다. 다행히 LoRA 같은 것을 쓰기에 딱 좋다. LoRA는 효율적이고 안정적으로 학습할 수 있을 뿐 아니라, 학습 중에도 베이스 모델 가중치는 건드리지 않게 해 준다. 그래서 학습 루프에서는 LoRA 어댑터를 끄고 보통처럼 모델을 한 번 돌린다. 그다음 LoRA를 켜고 같은 입력을 ‘정책 모델’(학습 중인 모델)에 돌린다.

다음은 JoyCaption의 RL 코드에서 ‘내부 학습 루프’ 전체다:

python
loss_mask = labels != -100 labels[~loss_mask] = 128004 with torch.no_grad(), text_model.disable_adapter(): ref_outputs = self.model.language_model( inputs_embeds=input_embeds[:, :-1, :], use_cache=False ) ref_logits = ref_outputs.logits per_token_logps = selective_log_softmax(ref_logits, labels) per_token_logps[~loss_mask] = 0 ref_all_logps = per_token_logps.sum(-1) ref_chosen_logps = ref_all_logps[:n_pairs] ref_rejected_logps = ref_all_logps[n_pairs:] outputs = self.model.language_model( inputs_embeds=input_embeds[:, :-1, :], use_cache=False ) logits = outputs.logits per_token_logps = selective_log_softmax(logits, labels) per_token_logps[~loss_mask] = 0 all_logps = per_token_logps.sum(-1) chosen_logps = all_logps[:n_pairs] rejected_logps = all_logps[n_pairs:] chosen_logits = logits[:n_pairs] chosen_labels = labels[:n_pairs] nll_loss = F.cross_entropy( chosen_logits.reshape(-1, chosen_logits.shape[-1]), chosen_labels.reshape(-1), ignore_index=128004, ) mean_chosen_logits = logits[:n_pairs][loss_mask[:n_pairs]].mean() mean_rejected_logits = logits[n_pairs:][loss_mask[n_pairs:]].mean() logits = (chosen_logps - ref_chosen_logps) - (rejected_logps - ref_rejected_logps) losses = -F.logsigmoid(self.config.dpo_beta * logits) losses = losses + self.config.dpo_alpha * nll_loss chosen_rewards = self.config.dpo_beta * (chosen_logps - ref_chosen_logps).detach() rejected_rewards = self.config.dpo_beta * (rejected_logps - ref_rejected_logps).detach() reward_accuracies = (chosen_rewards > rejected_rewards).float() metrics = {} metrics['rewards/chosen'] = chosen_rewards.mean().item() metrics['rewards/rejected'] = rejected_rewards.mean().item() metrics['rewards/accuracies'] = reward_accuracies.mean().item() metrics['rewards/margins'] = (chosen_rewards - rejected_rewards).mean().item() metrics['logps/chosen'] = chosen_logps.mean().item() metrics['logps/rejected'] = rejected_logps.mean().item() metrics['logits/chosen'] = mean_chosen_logits.mean().item() metrics['logits/rejected'] = mean_rejected_logits.mean().item() metrics['nll_loss'] = nll_loss.mean().item()

처음 보면 다소 위압적일 수 있는데, 대부분의 학습 코드는 원래 그렇다. 나는 섹션별로 쪼개 두었다. 언급했듯, 입력은 먼저 “레퍼런스 모델”에, 그다음 “정책 모델”에 통과시킨다. 두 경우 모두 로짓(소프트맥스 이전 원시 출력), 로그확률(logp, 소프트맥스 이후), 그리고 이를 “chosen(선호)”과 “rejected(비선호)” 절반으로 나누어 관리한다. 이는 각 프롬프트에 대해 우리가 더 선호하는 응답과 덜 선호하는 응답의 출력에 해당한다. 패딩 토큰과 프롬프트를 제외하기 위해 손실 마스킹을 많이 사용한다(모델은 응답 부분만 학습하도록).

DPO 손실은 아래 주석

Compute the loss

다음 두 줄에서 계산된다. 나도 이 공식을 직감적으로 받아들이기가 조금 어렵지만, 가능한 한 이해하기 쉽게 설명해 보겠다. 우선 손실의 핵심을 보자:

(chosen_logps - ref_chosen_logps) - (rejected_logps - ref_rejected_logps)

“logps”는 모델이 원하는 토큰(각 응답의 해당 토큰)에 부여한 확률(로그 확률)을 의미한다. 이 변수들을 이렇게 생각하는 게 좋다. 시퀀스 전체의 logp를 합치면, 모델이 그 응답 전체를 얼마나 그럴듯하다고 보느냐를 계산하는 셈이다. 즉 chosen_logps는 선호 응답의 그럴듯함, rejected_logps는 비선호 응답의 그럴듯함이다. 모델이 텍스트를 생성할 때도, 그럴듯하다고 본 응답을 생성하는 경향이 있다.

“ref_”가 붙은 쪽은 현재 모델을 기준 모델(학습 전 모델)과 비교하는 것이다. 이것은 확률을 ‘상대값’으로 만들게 해 주는데, 아주 중요하다. 값을 상대화함으로써 두 가지를 얻는다. 모델을 기준 모델에 가깝게 유지할 수 있고, 학습이 더 안정적이다. 일종의 정규화처럼 생각할 수 있는데, 손실과 그에 따른 그래디언트, 그리고 전반적 학습을 안정화하는 데 큰 도움이 된다.

(DeepSeek 논문을 읽었다면, 그들은 DPO 대신 GRPO를 썼는데 매우 비슷하다. GRPO는 예제 배치 전체에 대한 정규화에 가깝다.)

모두 합치면, 이는 모델이 선호 응답을 비선호 응답에 비해 얼마나 그럴듯하다고 보는지를 대략 계산한다. 값이 양수면 모델이 선호 응답을 더 선호한다는 뜻이다. 모델이 어떤 응답을 더 그럴듯하다고 보면, 추론 시에도 그 응답을 더 생성하려 할 것이다. 나머지 손실 식은 매끄럽게 만들기 위한 단골 ‘손실 요리’일 뿐이다. 최종 결과적으로는 모델이 보게 되는 그래디언트가

(chosen_logps - ref_chosen_logps) - (rejected_logps - ref_rejected_logps)

를 평균적으로 최대한 양수로 만들도록 유도한다.

베타(beta) 파라미터는 단순화해서 말하면 그래디언트 스케일러다. 즉 학습의 공격성을 조절한다. 보통 0.1~0.2 사이가 해피 스팟으로 보인다.

코드에 NLL 손실도 보일 것이다. 이는 선호 응답에 대해 직접적으로 모델을 미세조정하는 보조 손실이다. 사전학습과 SFT에서 쓰는 바로 그 손실이다. 이걸 조금 섞으면 DPO 성능이 오른다고 밝혀져, Llama 같은 모델의 사후 학습에도 쓰였다. 개인적으로는 일종의 ‘접지(grounding) 손실’로 본다. DPO는 아직 다소 요동치고 불안정한데, NLL 손실은 아주 안정적이고 직선적이다. 그래서 NLL 손실이 학습을 꾸준히 밀어주는 데 도움이 된다.

마지막은 각종 메트릭을 로깅(내 경우 wandb)하는 부분이다. 모든 메트릭 해석에 대해 전문가인 척할 생각은 없다. 일반적으로 가장 중요한 메트릭은 정확도(두 응답 중 더 나은 것을 모델이 맞게 고르는 비율)다. 그다음으로는 아마 마진일 텐데, 클수록 좋다. chosen/rejected의 logits/logps는 학습 중 이리저리 흔들리곤 하며, chosen이 내려간다고 해서 꼭 나쁜 건 아니다. 마진이 건강하게 유지된다면 말이다.

실제 JoyCaption에서 DPO를 실행하려면 응답 대 응답 예제 데이터셋이 필요했다. 최선은 사람이 응답을 평가하는 것이었지만, 비용이라는 치명적 문제에 부딪쳤다. 인디 프로젝트로서 평가자를 고용할 여력이 없었다. 게다가 DPO 데이터셋은 대개 1만 건 이상의 예제로 구성되므로, 나 혼자 해치우기에는 터무니없었다. 마지막 한 방: DPO 데이터셋은 소모성이다. 대부분 사용 직후 쓸모가 없어진다. 응답을 생성한 특정 모델에만 적용 가능하기 때문이다. 만약 내가 실수해서 데이터셋을 다시 만들어야 하거나, JoyCaption의 다음 버전에 같은 과정을 반복하고 싶다면, 모든 비용이 다시 든다.

그래서 실용적인 유일한 선택지는 자동화였다. 기존 SOTA 비전 모델들을 판정기(심판)로 써서 응답을 평가하게 하는 것. 그러면 JoyCaption의 능력이 판정기로 쓴 모델들의 한계에 갇히는 것은 아닐까?

다행히 구원투수가 몇 가지 있다. 첫째, 많은 평가는 판정기의 능력과 무관하다. 예컨대 사용자의 지시를 응답이 따랐는지 확인하기, 내가 원하는 톤/형식과 맞는지 확인하기, 응답의 명백한 문제(글리치, 오류 루프 등)를 잡아내기 등. 둘째, 많은 이미지에는 대체 텍스트나 태그 같은 그라운딩 데이터가 있어 판정기를 돕는다. 셋째이자 가장 중요하게, 모델은 생성자(generator)로서보다 판정자(judge)로서 훨씬 낫다.

이미지에 캡션을 쓰려면, 이미지 내용을 이해할 뿐 아니라 사용자의 특정 요구를 따르고, 캡션을 일관되게 구성해야 한다. 내가 수작업으로 캡션을 쓸 때도 보통 세 네 번은 고쳐 썼다. 반면 캡션이 맞는지 아닌지 평가하는 일은 한 번의 패스면 된다. LLM도 생성보다 분석이 더 잘 된다.

물론 판정기만으로 DPO 데이터셋이 완성되지는 않는다. 프롬프트의 다양성도 중요하다. 예를 들면, 이번 JoyCaption은 학습시킨 태스크의 변형이 매우 다양했다. 서로 다른 종류의 캡션, 응답 길이 요구, 피해야 할 단어, 다른 출력 형식 등. 각 변형이 데이터셋에서 충분히 대표되어야 하고, 각기 미묘한 차이가 있다. 프롬프트를 어떻게 생성할지뿐 아니라, 해당 태스크에 맞춰 판정기를 어떻게 지시할지도 달라진다.

이 복잡다단한 데이터 파이프라인을 처리하는 ‘좋은’, 진정 체계적인 방법이 있는지는 모르겠다. 결국 모델을 쓸 인간만큼이나 복잡하고 뒤엉킬 수밖에 없다. 이번 반복(iteration)에 사용한 지저분한 코드의 일부분을 보고 싶다면, 학습(트레이닝)을 제외하고 이 전체 과정을 관리한 RL 노트북은 여기 있다: BuildAlignmentDataset.ipynb

프롬프트 자체를 생성하는 것 외에도, 입력의 미묘한 요소들을 바꾸는 것도 중요하다. 많은 취미 미세조정 LLM은, 예컨대 시스템 프롬프트를 모델에 먹이는 방식이 조금만 달라져도 부서지곤 한다. 줄바꿈 하나만 어긋나도 도메인 밖으로 나간다. 모든 학습 입력이 같은 채팅 템플릿을 따랐기 때문이다. 그래서 시스템/사용자 프롬프트 앞뒤에 여러 공백을 다양하게 붙이는 일은 미묘하지만 중요한 노하우다. 모델을 튼튼하게 유지하려면 꼭 해야 한다.

판정기 LLM에 주는 지시는 무엇보다 중요하다. 사소한 문제라도 모든 예제에 곱배기로 번진다. 지시를 제대로 잡는 데는 많은 반복이 필요했다. 나는 작은 예제 묶음을 판정기에 먼저 돌려서 그 결론을 수작업으로 검토했다. 판정기에 주는 지시에는 ‘말을 많이 하고, 판단 근거를 불릿 포인트로 적으라’고 명시하는 것이 중요하다. 그렇게 하면 이후 검토가 훨씬 쉬워지고(대체로 판정 성능도 좋아진다). 검토 결과 동의하기 어려운 부분이 있으면 프롬프트를 살짝 손보고, 이 과정을 만족할 때까지 반복했다.

추가로, 여러 다른 모델을 판정기로 쓰기로 했다. 데이터셋 전반에서 판정자로 서로 다른 모델을 섞어 쓰면, 특정 모델의 기행이 다른 모델로 상쇄되길 기대할 수 있다. 일종의 판정 앙상블. 예제마다 여러 모델을 상대로 다회전 분석을 시키는 등 다양한 스킴을 쓸 수도 있겠지만, 내 경우에는 예제마다 무작위로 하나를 골랐다. 위의 판정기 지시를 다듬는 과정 덕분에, 최신 SOTA 모델들을 시험해 보고 누가 가장 잘하는지도 확인할 수 있었다. 리뷰를 마칠 때쯤에는 탄탄한 지시문뿐 아니라 실제 적용에 쓸 모델 쇼트리스트도 손에 넣었다.

판정기? 체크. 프롬프트? 체크. 마지막으로 JoyCaption이 응답을 생성해야 했다. 이건 간단하다. 모든 프롬프트에 대해 모델을 두 번 돌리면 된다. 다만 중요한 점 하나. 모델이 RL 동안 더 많이 ‘탐험’할수록 최종 성능이 더 좋아진다. 그래서 온도와 기타 파라미터를 높여 모델이 최대한 넓게 탐험하도록 유도하는 것이 보통 최선이다. 내 경우에는 이를 약간 랜덤화했고, 라운드가 진행될수록 더 높였다.

모든 부품을 맞추고 첫 데이터셋 약 1만 예제를 생성했다. 그런데… 실패였다.

하이퍼파라미터를 아무리 만져도 모델은 유의미하게 좋아지지 않았다. 정확도는 60% 언저리에 머물렀다. 즉 두 응답 중 더 나은 것을 고르는 실력이 간신히 나아진 수준이었다. 많은 삽질 끝에 주된 원인을 이렇게 보았다. 약한 예제가 너무 많았다.

데이터셋을 수작업으로 들여다보니, 상당수 응답이 서로 매우 비슷했다. JoyCaption의 SFT 자체는 이미 꽤 탄탄했기에 놀라운 일은 아니다. 잘하는 영역에서는 좋은 응답이 우수수 나왔고, 못하는 영역에서는 비슷비슷한 나쁜 응답이 우수수 나왔다. 판정기가 고르기 어려웠다. 이를 검증하기 위해 예제를 다른 판정 모델로 재평가해 일치도를 봤다. 일치도가 낮으면 두 응답의 품질이 비슷하다는 뜻이다. 설령 판정기가 더 나은 응답을 일관되게 고를 수 있다 해도, 차이가 미미하면 학습 중 그 응답 쌍에서 모델이 배울 것이 거의 없다.

결국 모델은 이리저리 흔들릴 뿐 유의미한 패턴이나 방향을 배우지 못했다. 내가 필요로 한 것은 강한 예제였다. 응답 쌍의 품질 차이가 극명한 것.

좋다… 그런데 약한 예제와 잘못된 예제를 어떻게 가르나? 내가 택한 해법은 판정기의 지시를 바꾸는 것이었다. 단순히 더 나은 응답을 고르게 하는 대신, 각 응답에 1~10점의 점수를 매기게 했다. 이제 두 응답의 점수가 가깝다면 그 예제를 버리고, 점수 차가 큰 예제를 선호할 수 있게 됐다.

하지만 문제가 또 생겼다. 점수 차가 큰 예제가 많지 않으니, 대부분 예제를 버려야 한다. API 콜이 훨씬 많이 필요해진다. 비싸다.

이를 완화하려면 좋은 예제를 ‘채굴’하는 더 저렴한 1차 판정이 필요했다. 나는 2개 대신 10개의 응답을 입력받아, 그들을 최상~최하로 순위를 매기는 1차 판정 시스템을 만들었다. 맨 위와 맨 아래 응답을 한 쌍으로 뽑으면 강한 예제일 것이라 가정했다. 이를 검증하기 위해, 이렇게 뽑은 쌍을 다시 일반 헤드투헤드 판정기에 보내서 점수 차이를 계산했다.

이 ‘농사’ 덕분에 비용을 크게 줄이면서, 고품질 예제가 데이터셋으로 잘 솎아져 나오게 할 수 있었다.

(여담으로, 이 기회를 이용해 “글리치” 응답을 별도로 모았다. RL로 가장 없애고 싶었던 문제 중 하나다. 단어 반복 루프에 빠지는 응답이다. 휴리스틱 기반 필터를 만들어 탐지했고, 글리치 vs 비글리치 응답이 짝지어진 예제를 우선시했다.)

새롭고 개선된 데이터셋을 손에 쥐고 다시 시도했다.

더 나은 데이터셋으로 진행한 두 번째 시도는 훨씬 잘 됐다. 테스트셋 기준 정확도 76%를 달성했다.

이 첫 DPO 라운드의 실제 효과를 정량화하기 위해 세 가지 분석을 했다. 첫째, 내가 직접 수동 테스트. 둘째, 다양한 프롬프트를 신/구 모델에 넣어 글리치율을 측정. 셋째, 판정 시스템이 이미 있으니 써먹자. 학습 데이터셋 외의 새로운 프롬프트를 양쪽 모델에 주고, 판정기로 두 모델의 응답을 비교했다. 여기서 선호율을 계산할 수 있다. 즉, 판정기가 한 모델의 응답을 다른 모델보다 선호한 비율.

첫 DPO 라운드의 결과:

  • DPO 적용 모델이 원 모델보다 2배 더 자주 선호됨.
  • 평균 응답 품질이 0.46에서 0.56으로 상승(0~1 스케일).
  • 글리치율이 9.6%에서 3.0%로 감소.

다만 이것은 태스크별 선호를 고려하지 않았다. 그렇게 했어야 더 좋았다. 즉, 베이스 모델에서 캡셔닝 성능은 이미 높아 크게 오르기 어려웠고, 반대로 지시 따르기는 크게 올랐을 가능성이 높다. 태스크별로 선호율을 쪼개서 보면 이를 확인하는 데 도움이 됐을 것이다.

1라운드를 성공적으로 마쳤으니 2라운드를 할 차례다. 이전 데이터는 모두 제외하고, 새 모델로 2차 데이터셋을 만들었다. 같은 방식으로 학습했고, 결과는 다음과 같았다.

  • 2라운드 DPO 모델이 1라운드 모델보다 2배 더 자주 선호됨.
  • 평균 응답 품질 0.67로 상승.
  • 글리치율 1.5%로 하락.

특히 “Stable Diffusion 프롬프트 작성” 태스크(가장 글리치가 많았던 태스크)에서는 글리치율이 37%에서 5%로 떨어졌다. 그리고 검출된 글리치를 수동 점검해 보니, 대부분 그리 심각하지 않았다. 검출기가 과검출한 경우부터, 금방 스스로 회복되는 글리치까지 다양했다.

3라운드도 할 가치가 있다고 보지만, 첫 두 라운드가 정신적으로 완전히 탈진시키는 바람에 2에서 멈췄다.

최종 모델 JoyCaption Beta One은 두 번의 DPO 라운드를 거쳤다. 1라운드는 1.1만 예제(응답 쌍), 2라운드는 2.5만 예제를 사용했다.

Round 1 설정:

  • AdamW (beta1=0.9, beta2=0.999, eps=1e-8)
  • 학습률 = 1e-4
  • 가중치 감쇠 = 0.0
  • 기울기 클리핑(노름) = 1.0
  • 배치 크기 = 256
  • 디바이스 배치 크기 = 2
  • DPO Alpha = 0.2
  • DPO Beta = 0.1
  • LoRA Alpha = 128
  • LoRA R = 128
  • LoRA Dropout = 0
  • 코사인 스케줄 + 선형 워밍업
  • 워밍업 샘플 1,000
  • 총 10k 샘플 학습
  • AMP, 모델은 bfloat16로 로드

Round 2 설정(변경점만):

  • 디바이스 배치 크기 = 1
  • DPO Alpha = 0.4
  • 총 25k 샘플 학습
  • 워밍업 샘플 4,000

DPO Alpha는 NLL 손실의 비중을 조절한다. 보통 0.2가 권장 값으로 알려져 있지만, 1라운드 이후 실험해 보니 내 경우 0.4에서 성능이 더 좋았다.

DPO Beta는 전형적인 DPO 파라미터로, 0.1이 권장 범위다.

일반적으로 LoRA Alpha = LoRA R를 유지하라고 권장된다. 내 경우 베이스 모델 학습과 같은 설정인 128을 썼다.

2라운드에서 디바이스 배치 크기를 줄인 것은 데이터 파이프라인을 바꾸면서 예제가 훨씬 길어진 탓이다. 잘라내기보다는 배치 크기를 낮추는 쪽을 택했다.

정확한 학습 커브를 보고 싶다면:

  • 1라운드에서는 정확도가 예쁘게 오른다. 후반부에 마진이 내려가는 것은 마음에 들지 않는다. 아직 손볼 곳이 있다는 신호다.
  • 2라운드는 1라운드보다 정확도가 약간 낮지만, 더 잘하리라 기대하지는 않았다. 2라운드가 이론적으로 더 어렵기 때문이다. 대신 마진은 더 좋다. 괜찮다.
  • 실패했던 1라운드 커브 링크도 포함했다. 많이 손본 이후의 결과인데, 초기 시도는 더 나빴다.

가장 큰 교훈은 강화학습의 절대적 필요성이었다. 다소 씁쓸한 진실이다. RL의 본성상 SFT보다 어렵고 비싸다. SFT는 좋은 데이터셋만 모으면 끝이다. 무언가 손보고 싶거나 다른 모델을 다루더라도 같은 데이터셋을 재사용할 수 있다. RL은 매 라운드가 고유하고 다시는 재사용할 수 없다. “데이터셋”이란 게 없다. 파이프라인과 요령만 남는다. 머신러닝의 해자가 있다면, 그건 RL이다.

첫 시도의 실패는, 모델이 배울 수 있는 강한 예제가 데이터셋에 반드시 포함되어야 함을 가르쳐 주었다.

데이터 처리 측면에 대한 구체적 계획 없이 RL에 곧장 뛰어든 것은 큰 실수였다. 끝없이 탁한 파이썬 코드의 바다를 만들었다. 다음에는 모델이 수행하길 원하는 태스크를 정확히 명세하는 데 더 신경 쓸 것이다. 그러면 각 태스크를 개별적으로 공략하고, 마지막에 하나로 묶는 일이 훨씬 수월해진다. 메트릭 구조화도 쉬워진다. 각 태스크별로 RL 라운드 사이의 성능 변화를 잴 수 있다.

마지막으로, JoyCaption의 능력 중 RL로 그리 개선되지 않은 영역도 있었다. 내 분석에 따르면, 베이스 모델이 아주 못하는 태스크는 이런 기본형 RL로 개선하기가 훨씬 어렵다. 온라인 GRPO 같은 더 정교한 방법이라면 가능할지도 모르겠다. 하지만 최소한 ‘괜찮은’ 출발점이 없으면, 모델이 실제로 학습하고 쌓아 갈 예제가 부족하다. (여담: 내가 아는 한, Stable Diffusion 프롬프트 모드에서 내가 기대하는 방식으로 캡션을 쓰도록 설득할 수 있는 SOTA 모델은 없었다. 공교롭게도 JoyCaption이 RL 이전에 가장 고전하던 모드이기도 했다. 기계들은 그런 식으로 글 쓰는 것을 정말 좋아하지 않는다.)

덧붙여, 이런 연구가 있다. 1라운드 DPO로 만들어진 모델을 이용해 다음 라운드의 데이터셋을 구성하고, 그 과정을 반복할 수 있다는 내용이다. 즉, 처음 한 번은 인간 선호로 DPO를 하고, 그다음부터는 모델이 스스로를 반복적으로 개선하게 하는 것. 다만 그 반복 횟수에는 한계가 있다고 한다.

내가 보기에 JoyCaption에 강화학습을 적용한 것은 큰 성공이었다. 모델은 이전보다 훨씬 얌전하고 튼튼해졌다. 더 중요하게는, 향후 반복을 위한 초석을 다졌다.

이 글이 유익했고, 어쩌면 재미있었길 바란다. 항상 그렇듯:

서로에게 잘하고, 멋진 것을 만들자.