본문으로 건너뛰기

[SGLang] SGL 언어: LLM 프로그래밍을 위한 DSL 설계

들어가며: 왜 DSL이 필요한가

LLM 애플리케이션을 만들 때 가장 흔한 방식은 OpenAI API를 직접 호출하거나, LangChain 같은 프레임워크를 사용하는 것이다. 하지만 이 접근들에는 근본적인 한계가 있다.

Raw API 호출은 프롬프트를 문자열로 조립하고, 응답을 파싱하고, 다시 프롬프트에 삽입하는 반복 코드를 양산한다. LangChain은 이를 추상화하지만, Chain/Agent 패턴이 과도하게 복잡하고, 생성 제어(constrained decoding, structured output)를 세밀하게 다루기 어렵다.

SGLang은 이 문제를 Domain-Specific Language(DSL) 로 해결한다. Python 함수 안에서 LLM 생성을 일급 연산(first-class operation)으로 다루는 임베디드 DSL을 제공하여, 프롬프트 구성과 생성 제어를 하나의 프로그램으로 통합한다. 핵심 코드는 python/sglang/lang/api.py에 있다.

SGL 언어 구조도

SGL의 아키텍처는 세 계층으로 나뉜다.

┌─────────────────────────────────────────────┐
│  SGL Frontend (api.py)                      │
│  @function, gen(), select(), system/user/... │
├─────────────────────────────────────────────┤
│  SGL IR (ir.py)                             │
│  SglFunction, SglGen, SglSelect, SglExpr    │
├─────────────────────────────────────────────┤
│  Backend (OpenAI, Anthropic, SGLang Runtime) │
│  interpreter.py → 각 백엔드로 디스패치       │
└─────────────────────────────────────────────┘

Frontend API가 사용자가 작성하는 DSL이고, 이것이 IR 노드로 변환된 뒤, Interpreter적절한 백엔드로 실행을 위임한다. 이 글에서는 Frontend 계층, 즉 SGL 언어 자체에 집중한다.

핵심 코드 분석

@function 데코레이터

SGL 프로그램의 진입점은 @sgl.function 데코레이터다. api.py의 구현을 보자.

def function(
    func: Optional[Callable] = None, num_api_spec_tokens: Optional[int] = None
):
    if func:
        return SglFunction(func, num_api_spec_tokens=num_api_spec_tokens)

    def decorator(func):
        return SglFunction(func, num_api_spec_tokens=num_api_spec_tokens)

    return decorator

@sgl.function@sgl.function() 두 형태를 모두 지원하는 표준 데코레이터 패턴이다. 반환되는 SglFunction은 IR 계층에 정의된 클래스로, 핵심 제약이 하나 있다.

# ir.py - SglFunction.__init__
argspec = inspect.getfullargspec(func)
assert argspec.args[0] == "s", 'The first argument must be "s"'
self.arg_names = argspec.args[1:]

첫 번째 인자는 반드시 s여야 한다. 이 s는 현재 생성 상태(state)를 담는 객체로, SGL 프로그램 내에서 프롬프트를 누적하고 생성 결과를 저장하는 컨텍스트 역할을 한다.

gen(): 생성 호출의 핵심

gen()은 LLM에게 텍스트 생성을 요청하는 함수다. api.py에서 sampling 파라미터 전체를 선언적으로 받는다.

def gen(
    name: Optional[str] = None,
    max_tokens: Optional[int] = None,
    stop: Optional[Union[str, List[str]]] = None,
    temperature: Optional[float] = None,
    regex: Optional[str] = None,
    json_schema: Optional[str] = None,
    choices: Optional[List[str]] = None,
    # ... 기타 파라미터
):
    if choices:
        return SglSelect(name, choices, ...)

    if regex is not None:
        re.compile(regex)  # 유효성 검사

    return SglGen(name, max_tokens, ...)

주목할 점이 두 가지 있다.

첫째, choices 인자가 있으면 내부적으로 SglSelect를 반환한다. 즉 gen(choices=["A", "B"])select(choices=["A", "B"])는 동일하게 동작한다. 사용자 편의를 위한 통합 인터페이스다.

둘째, regexjson_schema를 직접 지원한다. Constrained decoding을 API 호출 레벨이 아닌 DSL 레벨에서 선언할 수 있다는 뜻이다.

SGL은 타입별 편의 함수도 제공한다.

def gen_int(name: Optional[str] = None, ...):
    return SglGen(name, ..., dtype=int, ...)

def gen_string(name: Optional[str] = None, ...):
    return SglGen(name, ..., dtype=str, ...)

gen_int()는 내부적으로 dtype=int를 설정하여 정수만 생성하도록 제약한다.

select(): 선택지 제한 생성

select()는 LLM의 출력을 미리 정의한 선택지 중 하나로 제한한다.

def select(
    name: Optional[str] = None,
    choices: Optional[List[str]] = None,
    temperature: float = 0.0,
    choices_method: ChoicesSamplingMethod = token_length_normalized,
):
    assert choices is not None
    return SglSelect(name, choices, temperature, choices_method)

기본 temperature가 0.0이고, choices_methodtoken_length_normalized를 사용한다. 이는 선택지의 토큰 길이가 다를 때 공정한 비교를 위해 log-probability를 토큰 수로 정규화하는 방식이다.

Role 관리: system(), user(), assistant()

SGL은 chat template의 role 관리를 언어 수준에서 지원한다.

def _role_common(name: str, expr: Optional[SglExpr] = None):
    if expr is None:
        return SglExprList([SglRoleBegin(name), SglRoleEnd(name)])
    else:
        return SglExprList([SglRoleBegin(name), expr, SglRoleEnd(name)])

def system(expr: Optional[SglExpr] = None):
    return _role_common("system", expr)

def user(expr: Optional[SglExpr] = None):
    return _role_common("user", expr)

def assistant(expr: Optional[SglExpr] = None):
    return _role_common("assistant", expr)

SglRoleBeginSglRoleEnd IR 노드로 role 경계를 표현한다. 백엔드가 이를 해석하여 <|im_start|>system, [INST] 등 모델별 chat template으로 변환한다. 개발자는 chat template 포맷을 신경 쓸 필요가 없다.

블록 스타일 대신 begin/end를 명시적으로 사용할 수도 있다.

def user_begin():
    return SglRoleBegin("user")

def user_end():
    return SglRoleEnd("user")

실행: run()과 run_batch()

SglFunctionrun()run_batch() 메서드로 실행된다. run()은 단일 요청, run_batch()는 배치 실행을 처리한다.

# ir.py - SglFunction.run()
def run(self, *args, max_new_tokens=128, temperature=1.0, backend=None, ...):
    from sglang.lang.interpreter import run_program

    default_sampling_para = SglSamplingParams(
        max_new_tokens=max_new_tokens,
        temperature=temperature, ...
    )
    backend = backend or global_config.default_backend
    return run_program(self, backend, args, kwargs, default_sampling_para, ...)

run() 레벨에서 전역 sampling 파라미터를 설정하고, 개별 gen() 호출에서 이를 오버라이드할 수 있는 구조다.

기존 방식과의 비교

Before: Raw API로 multi-turn 대화 + 분류

import openai

# 1단계: 요약 생성
response = openai.chat.completions.create(
    model="gpt-4",
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": f"Summarize: {article}"},
    ],
    max_tokens=256,
)
summary = response.choices[0].message.content

# 2단계: 감성 분류 (별도 API 호출)
response2 = openai.chat.completions.create(
    model="gpt-4",
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": f"Summarize: {article}"},
        {"role": "assistant", "content": summary},
        {"role": "user", "content": "Is the sentiment positive or negative?"},
    ],
    max_tokens=16,
)
sentiment = response2.choices[0].message.content  # 파싱 필요

After: SGL DSL로 동일 작업

import sglang as sgl

@sgl.function
def summarize_and_classify(s, article):
    s += sgl.system("You are a helpful assistant.")
    s += sgl.user("Summarize: " + article)
    s += sgl.assistant(sgl.gen("summary", max_tokens=256))
    s += sgl.user("Is the sentiment positive or negative?")
    s += sgl.assistant(sgl.select("sentiment", choices=["positive", "negative"]))

KV cache가 자동으로 재사용되고, select()가 출력을 두 선택지로 제한하므로 파싱 로직이 필요 없다.

비교표

항목 Raw API (OpenAI) LangChain SGL DSL
KV cache 재사용 불가 (매 호출 독립) 불가 자동 (RadixAttention)
Constrained output 별도 파싱 필요 OutputParser 설정 select(), regex=, json_schema= 내장
Multi-turn 구성 messages 리스트 수동 관리 Chain 연결 += 연산자로 누적
배치 실행 asyncio 직접 구현 제한적 run_batch() 내장
Chat template 모델별 직접 처리 모델별 설정 system(), user() 자동 변환
백엔드 전환 코드 전면 수정 Provider 교체 set_default_backend() 한 줄
타입 안전 생성 없음 없음 gen_int(), gen_string(), dtype=

설계 철학 정리

SGL 언어의 설계에서 세 가지 원칙이 드러난다.

1. 생성을 일급 연산으로 다룬다. gen()select()는 문자열을 반환하는 함수가 아니라, IR 노드(SglGen, SglSelect)를 반환하는 표현식이다. 이 덕분에 실행 전에 프로그램 구조를 분석하고 최적화할 수 있다.

2. 선언적 제약을 언어 수준에서 지원한다. regex=, json_schema=, choices= 같은 constrained decoding 옵션이 생성 호출에 직접 포함된다. 후처리 파싱이 아닌 생성 시점 제약이다.

3. 백엔드를 추상화한다. SglSamplingParamsto_openai_kwargs(), to_anthropic_kwargs(), to_srt_kwargs() 등의 메서드로 각 백엔드 포맷으로 변환된다. 프론트엔드 코드 수정 없이 백엔드를 교체할 수 있다.

관련 포스트

참고

댓글

관련 포스트

SGLang 의 다른글