[SGLang] DetokenizerManager: 스트리밍 디토큰화와 증분 출력
들어가며
LLM이 토큰을 하나씩 생성할 때, 이를 사용자에게 텍스트로 보여주는 것은 의외로 까다로운 문제다. 한글 '가'는 여러 토큰으로 분할될 수 있고, UTF-8 바이트 단위로 출력하면 깨진 문자(U+FFFD)가 보인다. 스트리밍 응답에서는 "안전하게 출력 가능한 텍스트"만 골라 보내야 하고, 이전에 보낸 부분은 중복 없이 제외해야 한다.
SGLang의 DetokenizerManager는 별도 프로세스에서 이 모든 복잡성을 처리한다. Scheduler로부터 토큰 ID 배치를 받아 텍스트로 변환하고, 증분 디코딩 상태를 관리하여 TokenizerManager에게 깔끔한 문자열 출력을 전달한다. 이 글에서는 python/sglang/srt/managers/detokenizer_manager.py를 중심으로 분석한다.
전체 구조
DetokenizerManager가 파이프라인에서 차지하는 위치는 다음과 같다.
┌──────────────────────────────────────────────────────┐
│ Scheduler Process │
│ GPU forward pass → 새 토큰 생성 │
│ BatchTokenIDOutput 구성 │
└──────────────┬───────────────────────────────────────┘
│ ZMQ PUSH (detokenizer_ipc_name)
▼
┌──────────────────────────────────────────────────────┐
│ DetokenizerManager Process │
│ │
│ recv_from_scheduler ──▶ event_loop() │
│ │ │
│ ├─ BatchTokenIDOutput │
│ │ └─ _decode_batch_token_id_output() │
│ │ ├─ DecodeStatus 초기화/업데이트 │
│ │ ├─ _grouped_batch_decode() │
│ │ ├─ 증분 텍스트 추출 (surrogate 처리) │
│ │ └─ trim_matched_stop() │
│ │ │
│ ├─ BatchEmbeddingOutput → 그대로 전달 │
│ └─ FreezeGCReq → GC 동결 │
│ │
│ send_to_tokenizer ──▶ BatchStrOutput │
└──────────────────────────────────────────────────────┘
│ ZMQ PUSH (tokenizer_ipc_name)
▼
┌──────────────────────────────────────────────────────┐
│ TokenizerManager (Main Process) │
│ _handle_batch_output() → ReqState.event.set() │
└──────────────────────────────────────────────────────┘
DetokenizerManager의 역할은 명확하다. BatchTokenIDOutput(토큰 ID 배치)을 받아 BatchStrOutput(문자열 배치)으로 변환하여 전달한다.
핵심 코드 분석
이벤트 루프와 디스패처
DetokenizerManager는 동기적인 이벤트 루프에서 동작한다. Scheduler로부터 객체를 수신하고, 타입에 따라 적절한 핸들러를 호출한다.
def init_request_dispatcher(self):
self._request_dispatcher = TypeBasedDispatcher(
[
(BatchEmbeddingOutput, self.handle_batch_embedding_out),
(BatchTokenIDOutput, self.handle_batch_token_id_out),
(FreezeGCReq, self.handle_freeze_gc_req),
]
)
def event_loop(self):
"""The event loop that handles requests"""
while True:
with self.soft_watchdog.disable():
recv_obj = self.recv_from_scheduler.recv_pyobj()
output = self._request_dispatcher(recv_obj)
if output is not None:
self.send_to_tokenizer.send_pyobj(output)
self.soft_watchdog.feed()
TokenizerManager가 asyncio 기반인 것과 달리, DetokenizerManager는 단순한 while True 루프다. CPU-bound 작업인 디토큰화는 GIL 아래서 동기적으로 처리해도 충분하고, 별도 프로세스이므로 다른 컴포넌트를 blocking하지 않는다. soft_watchdog은 수신 대기 중에는 비활성화하고, 처리 후에는 feed하여 프로세스 hang을 감지한다.
DecodeStatus: 증분 디코딩 상태
스트리밍 출력의 핵심은 DecodeStatus dataclass다. 각 요청별로 디코딩 진행 상태를 추적한다.
@dataclasses.dataclass
class DecodeStatus:
"""Store the status of incremental decoding."""
decoded_text: str
decode_ids: List[int]
surr_offset: int
read_offset: int
sent_offset: int = 0
decode_ids는 지금까지 생성된 모든 토큰 ID의 누적 리스트다. surr_offset은 surrogate(깨진 문자) 없이 안전하게 디코딩된 마지막 위치, read_offset은 현재까지 읽은 위치를 가리킨다. sent_offset은 TokenizerManager에 이미 전송한 텍스트의 길이로, 증분 출력에서 중복을 방지한다.
이 상태들은 LimitedCapacityDict에 저장된다.
class LimitedCapacityDict(OrderedDict):
def __init__(self, capacity: int, *args, **kwargs):
super().__init__(*args, **kwargs)
self.capacity = capacity
def __setitem__(self, key, value):
if len(self) >= self.capacity:
self.popitem(last=False)
super().__setitem__(key, value)
OrderedDict를 상속하여 FIFO 방식으로 오래된 항목을 자동 제거한다. 기본 용량은 65536(환경변수 SGLANG_DETOKENIZER_MAX_STATES로 조절 가능)으로, 대량의 동시 요청에서 메모리 폭발을 방지한다.
배치 디토큰화: _decode_batch_token_id_output
이 메서드가 DetokenizerManager의 핵심 로직이다. 배치 단위로 토큰 ID를 텍스트로 변환한다.
def _decode_batch_token_id_output(self, recv_obj: BatchTokenIDOutput):
bs = len(recv_obj.rids)
read_ids, surr_ids = [], []
for i in range(bs):
rid = recv_obj.rids[i]
if rid not in self.decode_status:
s = DecodeStatus(
decoded_text=recv_obj.decoded_texts[i],
decode_ids=recv_obj.decode_ids[i],
surr_offset=0,
read_offset=recv_obj.read_offsets[i],
)
self.decode_status[rid] = s
else:
s = self.decode_status[rid]
s.decode_ids.extend(recv_obj.decode_ids[i])
read_ids.append(
self.trim_matched_stop(
s.decode_ids[s.surr_offset:],
recv_obj.finished_reasons[i],
recv_obj.no_stop_trim[i],
)
)
surr_ids.append(s.decode_ids[s.surr_offset:s.read_offset])
첫 번째 단계에서 각 요청의 DecodeStatus를 초기화하거나 업데이트한다. 새 요청이면 DecodeStatus를 생성하고, 기존 요청이면 새 토큰 ID를 누적한다. read_ids와 surr_ids 두 개의 리스트를 구성하는데, 이 둘의 차이가 증분 디코딩의 핵심이다.
그룹별 배치 디코드
토큰 ID를 텍스트로 변환할 때, skip_special_tokens와 spaces_between_special_tokens 설정이 요청마다 다를 수 있다. 이를 효율적으로 처리하기 위해 같은 설정의 요청을 그룹화한다.
def _grouped_batch_decode(
self,
ids_list: List[List[int]],
skip_list: List[bool],
space_list: List[bool],
) -> List[str]:
# fast path
first_skip, first_space = skip_list[0], space_list[0]
if all(s == first_skip for s in skip_list) and all(
sp == first_space for sp in space_list
):
return self.tokenizer.batch_decode(
ids_list,
skip_special_tokens=first_skip,
spaces_between_special_tokens=first_space,
)
# Group indices by (skip, space) tuple
groups: Dict[Tuple[bool, bool], List[int]]
groups = defaultdict(list)
for idx, (skip, space) in enumerate(zip(skip_list, space_list)):
groups[(skip, space)].append(idx)
fast path에서는 모든 요청의 설정이 동일한지 확인하고, 동일하면 한 번의 batch_decode 호출로 처리한다. 설정이 다른 경우에만 그룹별로 나누어 각각 batch_decode를 호출한다. 대부분의 경우 모든 요청이 같은 설정을 사용하므로 fast path를 타게 된다.
증분 텍스트 추출과 surrogate 처리
디코딩된 텍스트에서 실제로 전송할 증분 부분을 추출하는 로직이 가장 정교한 부분이다.
for i in range(bs):
rid = recv_obj.rids[i]
s = self.decode_status[rid]
new_text = read_texts[i][len(surr_texts[i]):]
if recv_obj.finished_reasons[i] is None:
# Streaming chunk: update the decode status
if len(new_text) > 0 and not new_text.endswith("\\ufffd"):
s.decoded_text = s.decoded_text + new_text
s.surr_offset = s.read_offset
s.read_offset = len(s.decode_ids)
new_text = ""
else:
new_text = find_printable_text(new_text)
else:
if rid in self.decode_status:
del self.decode_status[rid]
output_str = self.trim_matched_stop(
s.decoded_text + new_text,
recv_obj.finished_reasons[i],
recv_obj.no_stop_trim[i],
)
incremental_output = output_str[s.sent_offset:]
s.sent_offset = len(output_str)
output_strs.append(incremental_output)
surr_ids를 디코딩한 surr_texts와 read_ids를 디코딩한 read_texts의 차이(new_text)가 새로 생성된 텍스트다. \\ufffd(Unicode replacement character)로 끝나면 아직 불완전한 멀티바이트 문자이므로 find_printable_text를 사용하여 안전한 부분만 추출한다. 요청이 완료되면(finished_reasons[i]가 not None) decode_status에서 해당 항목을 삭제하여 메모리를 해제한다.
stop 문자열/토큰 트리밍
생성이 stop 조건에 의해 종료된 경우, 출력에서 stop 문자열이나 토큰을 제거해야 한다.
def trim_matched_stop(
self, output: Union[str, List[int]], finished_reason: Dict, no_stop_trim: bool
):
if no_stop_trim or not finished_reason:
return output
matched = finished_reason.get("matched", None)
if not matched:
return output
# Trim stop str.
if isinstance(matched, str) and isinstance(output, str):
pos = output.find(matched)
return output[:pos] if pos != -1 else output
# Trim stop token.
if isinstance(matched, int) and isinstance(output, list):
if output[-1] == 200012 and self.is_tool_call_parser_gpt_oss:
return output
assert len(output) > 0
return output[:-1]
return output
stop 문자열이면 해당 위치까지만 출력을 자르고, stop 토큰이면 마지막 토큰을 제거한다. gpt-oss 모델의 tool call 토큰(200012)은 예외적으로 유지한다.
왜 이 설계인가
별도 프로세스 분리: 디토큰화는 CPU-bound 작업이다. 이를 Scheduler(GPU-bound)나 TokenizerManager(I/O-bound)와 분리함으로써, 각 프로세스가 자신의 역할에 집중할 수 있다. HuggingFace tokenizer의 batch_decode는 Python GIL 아래서 동작하므로, 별도 프로세스가 필수적이다.
surrogate 기반 증분 디코딩: surr_offset과 read_offset의 이중 오프셋 구조는 불완전한 UTF-8 시퀀스를 안전하게 처리한다. 새 토큰이 추가될 때마다 전체를 재디코딩하는 대신, surrogate 경계 이후만 재디코딩하여 효율성과 정확성을 모두 달성한다.
LimitedCapacityDict: 동시 요청이 폭발적으로 증가하더라도 메모리가 제한된다. FIFO 방식의 eviction은 구현이 단순하면서도 실용적이다. 장시간 진행 중인 요청이 evict되면 명확한 에러 메시지와 함께 환경변수 조정 가이드를 제공한다.
동기 이벤트 루프: DetokenizerManager는 asyncio를 사용하지 않는다. 수신-처리-전송의 단순한 루프로 충분하며, 이는 디버깅과 프로파일링을 훨씬 쉽게 만든다.
관련 포스트
- SGLang TokenizerManager: 비동기 토큰화 파이프라인의 설계와 구현 - DetokenizerManager의 결과를 수신하는 상대편 분석
- SGLang IO 데이터 구조: 요청에서 응답까지의 직렬화 설계 - BatchTokenIDOutput, BatchStrOutput 등 데이터 구조
- SGLang Multi-Tokenizer: 다중 모델 토크나이저 동시 관리 - multi-worker 모드에서의 라우팅 확장
참고
- SGLang GitHub Repository
python/sglang/srt/managers/detokenizer_manager.py- DetokenizerManager 구현python/sglang/srt/managers/multi_tokenizer_mixin.py- multi-worker 이벤트 루프- GitHub Issue #2812 - DETOKENIZER_MAX_STATES 관련 논의
관련 포스트
- [SGLang] OpenAI 호환 API: Chat, Completions, Embedding 엔드포인트 구현
- [논문리뷰] StreamChar: Long-Horizon Streaming Character Audio-Video Generation with Decoupled Orchestration
- [cpython] tarfile 스트리밍 모드(r|*) 성능 개선: 파이썬 압축 파일 처리의 숨겨진 병목 제거
- [sglang] DeepSeek-V4의 Latency 최적화: Fused mHC Post/Pre Kernel 도입
- [sglang] sglang ROCm MXFP4 어텐션에서 불필요한 contiguous copy 제거를 통한 성능 최적화
SGLang 의 다른글
- 이전글 [SGLang] TokenizerManager: 비동기 토큰화 파이프라인의 설계와 구현
- 현재글 : [SGLang] DetokenizerManager: 스트리밍 디토큰화와 증분 출력
- 다음글 [SGLang] IO 데이터 구조: 요청에서 응답까지의 직렬화 설계
댓글