본문으로 건너뛰기

[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.pyChatTemplate 클래스가 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.pyChatTemplate은 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-vlqwen 템플릿을 구분한다.

@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 의 다른글