[SGLang] Chat Template 관리: Jinja 템플릿과 모델별 대화 포맷
들어가며
LLM은 모델마다 대화 포맷이 다르다. Llama 3는 <|start_header_id|>user<|end_header_id|>를 쓰고, Qwen은 <|im_start|>user를 쓰고, DeepSeek은 <|User|>를 쓴다. 같은 프롬프트라도 포맷이 맞지 않으면 성능이 급격히 떨어진다.
SGLang은 이 문제를 두 계층으로 해결한다. 프론트엔드 DSL에서는 python/sglang/lang/chat_template.py의 ChatTemplate 클래스가 role별 prefix/suffix 기반으로 프롬프트를 조립하고, 서빙 엔진(SRT) 측에서는 python/sglang/srt/parser/jinja_template_utils.py가 HuggingFace의 Jinja2 템플릿을 분석하여 content format을 자동 감지한다.
템플릿 처리 구조도
┌─────────────────────────────────────────────────┐
│ 사용자 메시지 입력 │
│ [{"role":"system","content":"..."}, ...] │
└──────────────────────┬──────────────────────────┘
│
┌────────────┼────────────┐
▼ ▼
┌───────────────┐ ┌──────────────────────┐
│ ChatTemplate │ │ jinja_template_utils │
│ (Frontend DSL)│ │ (SRT Server-side) │
└───────┬───────┘ └──────────┬───────────┘
│ │
▼ ▼
prefix/suffix 기반 Jinja2 AST 분석
프롬프트 조립 content format 감지
│ ("string"/"openai")
▼ │
┌───────────────┐ ▼
│ 모델 경로 → │ ┌──────────────────┐
│ 템플릿 자동 │ │ 멀티모달 콘텐츠 │
│ 매칭 함수들 │ │ 포맷 변환 │
└───────────────┘ └──────────────────┘
chat_template_registry detect_jinja_template
matching_function_registry _content_format()
핵심 코드 분석
ChatTemplate 클래스
python/sglang/lang/chat_template.py의 ChatTemplate은 dataclass로 정의되어 있다. 각 모델의 대화 포맷을 role별 prefix와 suffix 쌍으로 표현한다.
class ChatTemplateStyle(Enum):
PLAIN = auto()
LLAMA2 = auto()
@dataclass
class ChatTemplate:
name: str
default_system_prompt: str
role_prefix_and_suffix: Dict[str, Tuple[str, str]]
stop_str: List[str] = ()
image_token: str = "<image>"
audio_token: str = "<audio>"
style: ChatTemplateStyle = ChatTemplateStyle.PLAIN
role_prefix_and_suffix가 핵심이다. 각 role(system, user, assistant)에 대해 (prefix, suffix) 튜플을 지정한다. style은 두 가지가 있다. PLAIN은 단순히 prefix + content + suffix를 이어붙이고, LLAMA2는 system 프롬프트를 첫 user 메시지와 합치는 Llama 2 고유의 방식을 처리한다.
Llama 2 스타일의 특수 처리
LLAMA2 스타일에서는 system 프롬프트가 첫 번째 user 메시지의 prefix 안에 삽입되어야 한다. get_prefix_and_suffix 메서드가 이 로직을 처리한다.
def get_prefix_and_suffix(self, role: str, hist_messages: List[Dict]) -> Tuple[str, str]:
prefix, suffix = self.role_prefix_and_suffix.get(role, ("", ""))
if self.style == ChatTemplateStyle.LLAMA2:
if role == "system" and not hist_messages:
user_prefix, _ = self.role_prefix_and_suffix.get("user", ("", ""))
system_prefix, system_suffix = self.role_prefix_and_suffix.get("system", ("", ""))
return (user_prefix + system_prefix, system_suffix)
elif (role == "user" and len(hist_messages) == 1
and hist_messages[0]["content"] is not None):
return ("", suffix)
return prefix, suffix
첫 번째 system 메시지에 user의 prefix를 붙이고, 바로 다음 user 메시지에서는 prefix를 생략한다. 결과적으로 [INST] <<SYS>>\nsystem prompt\n<</SYS>>\n\nuser message [/INST] 형태가 된다.
레지스트리와 모델 자동 매칭
SGLang은 20개 이상의 템플릿을 레지스트리에 등록하고, 모델 경로에서 자동으로 적절한 템플릿을 찾는다.
chat_template_registry: Dict[str, ChatTemplate] = {}
matching_function_registry: List[Callable] = []
def register_chat_template(template):
chat_template_registry[template.name] = template
def register_chat_template_matching_function(func):
matching_function_registry.append(func)
def get_chat_template_by_model_path(model_path):
for matching_func in matching_function_registry:
template_name = matching_func(model_path)
if template_name is not None:
return get_chat_template(template_name)
return get_chat_template("default")
매칭 함수는 정규식으로 모델 경로를 검사한다. 예를 들어 DeepSeek 모델은 다음과 같이 매칭된다.
@register_chat_template_matching_function
def match_deepseek(model_path: str):
if re.search(r"deepseek-(v3|r1)", model_path, re.IGNORECASE) and not re.search(
r"base", model_path, re.IGNORECASE
):
return "deepseek-v3"
deepseek-v3 또는 deepseek-r1이 경로에 포함되되 base 모델은 제외한다. Qwen의 경우 VL(Vision-Language) 모델인지 여부에 따라 qwen2-vl과 qwen 템플릿을 구분한다.
@register_chat_template_matching_function
def match_chat_ml(model_path: str):
if re.search(r"qwen.*vl", model_path, re.IGNORECASE):
return "qwen2-vl"
if re.search(r"qwen.*(chat|instruct)", model_path, re.IGNORECASE) and not re.search(
r"llava", model_path, re.IGNORECASE
):
return "qwen"
Jinja2 AST 기반 Content Format 감지
서빙 엔진 측의 python/sglang/srt/parser/jinja_template_utils.py는 HuggingFace 모델이 제공하는 Jinja2 chat template을 파싱하여 content format을 자동 감지한다.
def detect_jinja_template_content_format(chat_template: str) -> str:
# 멀티모달 키워드 단축 경로
if any(keyword in chat_template for keyword in ["image", "audio", "video", "vision"]):
return "openai"
jinja_ast = _try_extract_ast(chat_template)
if jinja_ast is None:
return "string"
# AST에서 content 반복 패턴 탐색
for loop_ast in jinja_ast.find_all(jinja2.nodes.For):
loop_iter = loop_ast.iter
if _is_var_or_elems_access(loop_iter, "message", "content"):
return "openai"
return "string"
Jinja 템플릿 AST에서 for content in message['content'] 같은 반복 패턴을 찾으면 openai format(구조화된 content 리스트)으로 판단하고, 없으면 string format(단순 문자열)으로 판단한다. 이 감지는 멀티모달 입력 처리에 핵심적이다.
Content Format별 메시지 변환
감지된 format에 따라 process_content_for_template_format이 메시지 콘텐츠를 변환한다.
def process_content_for_template_format(msg_dict, content_format, image_data,
video_data, audio_data, modalities,
use_dpsk_v32_encoding=False):
if not isinstance(msg_dict.get("content"), list):
return {k: v for k, v in msg_dict.items() if v is not None}
if content_format == "openai" or use_dpsk_v32_encoding:
processed_content_parts = []
for chunk in msg_dict["content"]:
chunk_type = chunk.get("type")
if chunk_type == "image_url":
image_data.append(ImageData(url=image_obj["url"], ...))
processed_content_parts.append({"type": "image"})
elif chunk_type == "text":
if use_dpsk_v32_encoding:
text_parts.append(chunk["text"])
else:
processed_content_parts.append(chunk)
elif content_format == "string":
# 텍스트만 추출하여 단일 문자열로 변환
text_parts = []
for chunk in msg_dict["content"]:
if isinstance(chunk, dict) and chunk.get("type") == "text":
text_parts.append(chunk["text"])
new_msg["content"] = " ".join(text_parts)
openai format은 image_url, video_url, audio_url 타입을 파싱하여 데이터를 추출하고 {"type": "image"} 같은 단순 타입으로 정규화한다. string format은 텍스트 부분만 이어붙여 단일 문자열로 변환한다. DeepSeek V3.2 전용 인코딩(use_dpsk_v32_encoding)도 별도로 처리한다.
주요 모델 템플릿 비교 테이블
| 모델 | 템플릿 이름 | User Prefix | User Suffix | Stop Token | System 처리 |
|---|---|---|---|---|---|
| Llama 3 | llama-3-instruct |
<|start_header_id|>user<|end_header_id|>\n\n |
<|eot_id|> |
<|eot_id|> |
별도 role |
| Llama 4 | llama-4 |
<|header_start|>user<|header_end|>\n\n |
<|eot|> |
<|eot|> |
별도 role |
| Qwen | qwen |
<|im_start|>user\n |
<|im_end|>\n |
<|im_end|> |
default prompt 있음 |
| DeepSeek V3/R1 | deepseek-v3 |
<|User|> |
(없음) | <|end▁of▁sentence|> |
없음 |
| Gemma | gemma-it |
<start_of_turn>user\n |
<end_of_turn>\n |
없음 | 없음 |
| Mistral | mistral |
[INST] |
[/INST] |
</s> |
[SYSTEM_PROMPT] |
| Llama 2 | llama-2-chat |
[INST] |
[/INST] |
없음 | user와 합침 (LLAMA2 style) |
| Claude | claude |
\n\nHuman: |
(없음) | 없음 | 없음 |
| Vicuna | vicuna_v1.1 |
USER: |
|
없음 | default prompt 있음 |
Qwen과 Vicuna는 default_system_prompt가 지정되어 있어, system 메시지가 None이면 기본 프롬프트가 자동 삽입된다. DeepSeek은 전각 문자(|)를 특수 토큰으로 사용하는 것이 특이점이다.
관련 포스트
- SGLang 멀티 백엔드: OpenAI, Anthropic, VertexAI, LiteLLM 통합 (본 시리즈 이전 글)
참고
관련 포스트
SGLang 의 다른글
- 이전글 [SGLang] 멀티 백엔드: OpenAI, Anthropic, VertexAI, LiteLLM 통합
- 현재글 : [SGLang] Chat Template 관리: Jinja 템플릿과 모델별 대화 포맷
- 다음글 [SGLang] TokenizerManager: 비동기 토큰화 파이프라인의 설계와 구현
댓글