Lepisode Tech 로고Lepisode Tech

[INST]? ChatML? — Instruction LLM 파인튜닝 데이터 포맷의 구조와 사용법

유도영

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>
  • Header 태그 계열 (Llama 3/3.1/3.2/3.3): <|start_header_id|>user<|end_header_id|>\n\n질문<|eot_id|>
  • ChatML 계열 (Qwen, Yi 등): <|im_start|>user\n질문<|im_end|>
  • Alpaca 스타일: ### 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.jsonchat_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이 알아서 처리해주는 거예요.


Input은 고정, Output은 자유

정리하면 이런 구조입니다:

  • Input 포맷 (messages 구조): rolesystem / user / assistant (간혹 tool 추가)로 사실상 업계 표준으로 고정되어 있습니다. OpenAI가 만든 관행이 굳어진 거예요.
  • Output 포맷 (실제 토큰 배열): 모델마다 완전히 자유입니다.
  • Chat Template: 고정된 input을 자유로운 output으로 변환하는 매핑 규칙입니다.

템플릿 안에서 message['role'] == 'system' 같은 코드로 매칭하기 때문에, 양쪽이 약속된 필드명을 공유하는 한 어떤 조합이든 가능합니다.


실무에서 apply_chat_template 사실상 필수입니다

마지막으로 중요한 포인트는, apply_chat_template() 은 실무에서 사실상 필수라는 것입니다.

물론, 해당 메서드를 사용하지 않는다 해서 학습이 불가능한 건 아닙니다. 모델에 들어가는 건 결국 토큰 시퀀스일 뿐이니까, 해당 모델이 필요로 하는 양식으로 직접 데이터를 가공해서 넣으면 이 메서드 없이도 똑같이 작동해요.

하지만 양식이 맞지 않는다고 오류가 나지는 않습니다. 이게 오히려 더 위험한 상황인데, 모델 입장에서는 그냥 토큰 시퀀스가 들어온 것이므로 학습 자체는 돌아가고 loss도 줄어들어요. 하지만 실제로는 모델이 instruction 경계를 제대로 못 잡아서 응답 품질이 나오지 않습니다. 에러 없이 실패하는 조용한 실패(silent failure) 가 되는 거예요.

그래서 실무에서는 apply_chat_template()을 쓰는 게 안전합니다. 모델을 바꿀 때 데이터 전처리를 다시 하지 않아도 되고, 템플릿 오타 같은 실수도 방지할 수 있어요.