[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"])는 동일하게 동작한다. 사용자 편의를 위한 통합 인터페이스다.
둘째, regex와 json_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_method로 token_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)
SglRoleBegin과 SglRoleEnd 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()
SglFunction은 run()과 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. 백엔드를 추상화한다. SglSamplingParams가 to_openai_kwargs(), to_anthropic_kwargs(), to_srt_kwargs() 등의 메서드로 각 백엔드 포맷으로 변환된다. 프론트엔드 코드 수정 없이 백엔드를 교체할 수 있다.
관련 포스트
참고
관련 포스트
SGLang 의 다른글
- 이전글 [SGLang] 음성 인식 & ASR 통합: Whisper, Qwen3-ASR 어댑터 구현
- 현재글 : [SGLang] SGL 언어: LLM 프로그래밍을 위한 DSL 설계
- 다음글 [SGLang] 중간 표현(IR): SglGen, SglSelect, SglExpr의 설계
댓글