LLM을 파인튜닝하다 보면 모델마다 학습 데이터 포맷이 다르다는 걸 알게 됩니다. Llama 3는 <|start_header_id|>...<|end_header_id|>, Mistral은 [INST]...[/INST], ChatML 계열은 <|im_start|>user... 도대체 이 포맷은 누가, 왜 정하는 걸까요?
결론부터 말하면, 데이터 포맷은 모델이 결정합니다. 각 모델은 사전학습 및 RLHF 단계에서 특정 포맷으로 대화 데이터를 학습했고, 이걸 Chat Template이라고 부릅니다. 파인튜닝할 때 이 포맷을 맞춰줘야 모델이 "이게 instruction이고, 이게 response다"를 제대로 이해할 수 있어요.
대표적인 예시를 보면:
[INST] 계열 (Llama 2, Mistral): <s>[INST] 질문 [/INST] 답변</s><|start_header_id|>user<|end_header_id|>\n\n질문<|eot_id|><|im_start|>user\n질문<|im_end|>### Instruction:\n질문\n### Response:\n답변이건 TRL이나 Unsloth 같은 라이브러리가 정하는 게 아니라, 모델 제작자가 정한 규약입니다.
참고: ChatML은 OpenAI가 도입한 대화 마크업 형식으로, 커뮤니티에서 "Chat Markup Language"라는 비공식 풀네임으로 불립니다. OpenAI 공식 문서에서 이 풀네임을 명시적으로 정의한 것은 아니지만, 사실상 표준처럼 널리 쓰이고 있어요.
그러면 TRL, Unsloth 같은 학습 라이브러리는 포맷과 관련해서 어떤 역할을 할까요? 바로 이 포맷을 자동으로 적용해주는 도우미 역할을 합니다.
HuggingFace tokenizer에는 apply_chat_template()이라는 메서드가 있습니다. 이 메서드는 모델의 tokenizer_config.json에 정의된 chat_template(Jinja2 형식)을 읽어서 자동으로 변환해줍니다.
그래서 실제로는 데이터를 아래와 같은 표준 형태로 준비하면 다음과 같습니다.
[
{"role": "system", "content": "너는 도움이 되는 AI야"},
{"role": "user", "content": "질문"},
{"role": "assistant", "content": "답변"}
]
그리고 앞서 말한 라이브러리들이 모델에 맞는 포맷으로 알아서 바꿔주는 역할을 하는거죠.
여기서 의문이 생깁니다. 모델마다 포맷이 자유라면, 라이브러리가 그걸 다 지원할 수 있을까요?
답은 구조 자체가 그 문제를 해결한다는 겁니다. 라이브러리가 각 포맷을 하드코딩하는 게 아니라, 모델이 자기 변환 규칙을 스스로 들고 다니는 구조이기 때문이에요.
모델 제작자가 원하는 포맷을 설계하고, 그걸 Jinja2 템플릿으로 작성해서 tokenizer_config.json의 chat_template 필드에 넣습니다. 라이브러리는 그 템플릿을 읽어서 실행할 뿐이에요. Jinja2는 범용 템플릿 엔진이니까 어떤 형태든 표현할 수 있습니다.
예를 들어 Llama 3의 템플릿은 이런 모습입니다. 각 메시지가 <|start_header_id|>와 <|end_header_id|>로 role을 감싸고, <|eot_id|>로 턴을 구분하는 것이 핵심이에요:
{% set loop_messages = messages %}
{% for message in loop_messages %}
{% set content = '<|start_header_id|>' + message['role'] + '<|end_header_id|>\n\n' + message['content'] | trim + '<|eot_id|>' %}
{% if loop.index0 == 0 %}
{% set content = bos_token + content %}
{% endif %}
{{ content }}
{% endfor %}
{{ '<|start_header_id|>assistant<|end_header_id|>\n\n' }}
위 템플릿을 적용하면 실제 출력은 다음과 같습니다:
<|begin_of_text|><|start_header_id|>system<|end_header_id|>
너는 도움이 되는 AI야<|eot_id|><|start_header_id|>user<|end_header_id|>
질문<|eot_id|><|start_header_id|>assistant<|end_header_id|>
답변<|eot_id|>
참고로 Llama 4에서는 토큰 이름이 또 바뀌어서 <|header_start|>, <|header_end|>, <|eot|>를 사용합니다. 구조적 컨셉은 Llama 3와 비슷하지만 토큰 이름이 다르니, 이런 사소한 차이도 Chat Template이 알아서 처리해주는 거예요.
정리하면 이런 구조입니다:
role이 system / user / assistant (간혹 tool 추가)로 사실상 업계 표준으로 고정되어 있습니다. OpenAI가 만든 관행이 굳어진 거예요.템플릿 안에서 message['role'] == 'system' 같은 코드로 매칭하기 때문에, 양쪽이 약속된 필드명을 공유하는 한 어떤 조합이든 가능합니다.
apply_chat_template 사실상 필수입니다마지막으로 중요한 포인트는, apply_chat_template() 은 실무에서 사실상 필수라는 것입니다.
물론, 해당 메서드를 사용하지 않는다 해서 학습이 불가능한 건 아닙니다. 모델에 들어가는 건 결국 토큰 시퀀스일 뿐이니까, 해당 모델이 필요로 하는 양식으로 직접 데이터를 가공해서 넣으면 이 메서드 없이도 똑같이 작동해요.
하지만 양식이 맞지 않는다고 오류가 나지는 않습니다. 이게 오히려 더 위험한 상황인데, 모델 입장에서는 그냥 토큰 시퀀스가 들어온 것이므로 학습 자체는 돌아가고 loss도 줄어들어요. 하지만 실제로는 모델이 instruction 경계를 제대로 못 잡아서 응답 품질이 나오지 않습니다. 에러 없이 실패하는 조용한 실패(silent failure) 가 되는 거예요.
그래서 실무에서는 apply_chat_template()을 쓰는 게 안전합니다. 모델을 바꿀 때 데이터 전처리를 다시 하지 않아도 되고, 템플릿 오타 같은 실수도 방지할 수 있어요.