源码精读:DetokenizerManager —— 增量 BPE 解码与流式回写
谁该读这一篇? 想搞清"token id 怎么变成客户端看到的字符串"、为什么流式输出有时会卡半个字的工程师。 前置阅读:
01-overview/02-architecture.md。 耗时: 25 分钟 学完能: 1. 解释为什么"增量 detokenize"不是简单的tokenizer.decode(new_tokens); 2. 看懂DecodeStatus数据结构; 3. 区分 stream 模式下的 surrogate / multi-byte 字符处理; 4. 找到 stop string trim 的位置; 5. 看到客户端收到乱码 / 缺字符能定位是 detokenizer 哪一步出问题。
1. 为什么需要"增量"
朴素做法:每收到一个新 token 就 tokenizer.decode(all_tokens) 返回完整字符串差值。
问题:
- 复杂度 O(n²):每步都要 decode 整段。
- BPE 边界不齐:很多 tokenizer 一个汉字 = 2 token;半个 token 解码出来是 invalid byte。
- 空格 / 换行处理:HF tokenizer 的
decode默认会 add prefix space,逐步调用会出多余空格。
正确做法:维护"已经发出去的字符串末尾位置",每次新 token 来了 decode "从上次位置到现在"那段,做边界对齐。
2. DecodeStatus(65+)
@dataclass
class DecodeStatus:
vid: int # 验证版本号(防 stale)
decoded_text: str # 已经发出去的字符串
decode_ids: List[int] # 完整 token id 列表(含已发的)
surr_offset: int # surrogate buffer 开始
read_offset: int # 已经"读"到的 token 位置
sent_offset: int = 0 # 已经"发"出去的字符位置
关键字段:
read_offset:tokenizer 内部已经 decode 到第几个 token。sent_offset:已经流式回写给 TM 的字符串长度。surr_offset:用于处理"半个 surrogate / 半个 UTF-8 多字节字符"——这部分还不完整,得等下一个 token 才能补齐。
3. DetokenizerManager.__init__
def __init__(self, server_args, port_args):
self.init_ipc_channels(port_args, server_args)
self.init_tokenizer(server_args)
self.init_running_status(server_args)
self.init_request_dispatcher()
- IPC:PULL 从 Scheduler、PUSH 给 TM。
- tokenizer:HF tokenizer 实例(独立加载,和 TM 用同样的 tokenizer 路径)。
- running_status:
LimitedCapacityDict装rid → DecodeStatus,容量上限避免泄漏。 - dispatcher:按消息类型分派(
BatchTokenIDOutput/BatchEmbeddingOutput/FreezeGCReq)。
4. event_loop
def event_loop(self):
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()
注意:
- 同步 while True(不是 asyncio)。
- ZMQ recv 是阻塞的,watchdog 暂时 disable 避免误报。
- 每收一批就 dispatch 一次。
5. handle_batch_token_id_out:主路径
def handle_batch_token_id_out(self, batch: BatchTokenIDOutput) -> BatchStrOutput:
output_strs = []
for i, rid in enumerate(batch.rids):
new_tokens = batch.output_ids[i]
finished = batch.finished[i]
status = self.decode_status.get(rid)
if status is None:
status = DecodeStatus(vid=batch.vids[i], decoded_text="", decode_ids=[], ...)
self.decode_status[rid] = status
# 把新 token 追加
status.decode_ids.extend(new_tokens)
# 增量 decode
new_text = self._incremental_decode(status)
status.sent_offset += len(new_text)
output_strs.append(new_text)
if finished:
# trim stop string
new_text = self.trim_matched_stop(new_text, batch.finished_reasons[i], ...)
del self.decode_status[rid]
return BatchStrOutput(
rids=batch.rids,
output_strs=output_strs,
finished=batch.finished,
...
)
5.1 _incremental_decode 思路
def _incremental_decode(self, status: DecodeStatus) -> str:
# 从上次位置往后 decode
raw = self.tokenizer.decode(
status.decode_ids[status.surr_offset:],
skip_special_tokens=...
)
# 检查是否末尾有不完整的 multi-byte / surrogate
if has_incomplete_utf8(raw):
# 退回到上一个完整字符的位置;后面的字节留着下次再补
complete_part, leftover_bytes = strip_incomplete(raw)
new_text = complete_part[len(status.decoded_text) - status.sent_offset:]
status.decoded_text = complete_part
return new_text
else:
new_text = raw[len(status.decoded_text):]
status.decoded_text = raw
return new_text
实际实现更精细(追踪 surr_offset / read_offset 配合 tokenizer 的 stateful decode),但思路是这样。
6. Stop string trim(trim_matched_stop)
源码:detokenizer_manager.py(搜 def trim_matched_stop)。
def trim_matched_stop(self, output, finished_reason, no_stop_trim):
if no_stop_trim or not finished_reason:
return output
matched = finished_reason.get("matched", None)
if not matched:
return output
# stop 是字符串
if isinstance(matched, str):
pos = output.find(matched)
return output[:pos] if pos != -1 else output
# stop 是 token id
if isinstance(matched, int):
return output[:-1] # 砍掉最后一个 token
return output
行为:
- 如果停止条件是字符串 stop(如
"\n###"),从末尾找到这个串,截断到它之前。 - 如果是 stop token id(如 EOS),输出 token 列表最后一项被吃掉(已经在 detokenize 前砍)。
- 业务想保留 stop 字符串:在 sampling_params 里设
no_stop_trim=True。
7. 多 TM worker 模式
tokenizer_worker_num > 1 时 Detokenizer 不能"all-to-one" 推回——要按 worker 路由。
if server_args.tokenizer_worker_num > 1:
# 走 MultiHttpWorkerDetokenizerMixin 路径
# 内部维护 SocketMapping,按 rid → worker 路由
pass
else:
self.send_to_tokenizer = get_zmq_socket(...)
每个 worker 一个独立的 ZMQ 推送 socket,Detokenizer 按请求 metadata 选对应 socket 发。
multi_http_worker_detokenizer_mixin.py 周围有相关代码。
8. 性能特征
| 操作 | 单次耗时(粗略) |
|---|---|
| tokenizer.decode(增量,1-3 token) | 0.02 - 0.1 ms |
| pickle send 给 TM | 0.05 ms |
| pickle recv 自 Scheduler | 0.05 ms |
Detokenize 是 CPU 工作,量级远小于 GPU forward;很少成为瓶颈。
但在 大 batch(几百路并行) 时,串行 decode 几百次也会累计成 ms 级延迟,这是开 --disable-tokenizer-batch-decode false(默认开 batch decode)的原因。
9. 常见 bug
| 现象 | 原因 |
|---|---|
| 流式输出多了或少了空格 | add_prefix_space 处理错误;tokenizer 版本不一致 |
偶现 ? / 乱码 |
多字节字符被切了一半就发出去 |
| 最后一个 token 没显示 | stop trim 砍多了 |
| 中文 emoji 显示成 box | Detokenizer 用了 ASCII-only 字体(HTTP 层问题,不是 detokenizer) |
| Detokenizer 进程内存涨 | decode_status 字典泄漏;检查 LimitedCapacityDict 容量 |
10. 关键源码索引
| 内容 | 文件:行 |
|---|---|
DecodeStatus |
detokenizer_manager.py:65 |
DetokenizerManager 类 |
detokenizer_manager.py:76 |
event_loop |
detokenizer_manager.py(搜 def event_loop) |
handle_batch_token_id_out |
同上(搜 def handle_batch_token_id_out) |
trim_matched_stop |
同上 |
run_detokenizer_process |
detokenizer_manager.py:426 |
11. 小结
- DetokenizerManager 把 token id 流转字符串流,处理 BPE 边界、stop trim、多字节字符。
DecodeStatus维护"已发位置 / surrogate buffer",避免逐步 decode 的 O(n²) 与边界半字。- 同步 while True 进程,ZMQ 阻塞 recv,watchdog 兜底。
- multi worker 模式按 rid 路由回正确的 TM。
- Detokenize 性能基本不是瓶颈,但 batch decode 仍有意义。
12. 自检
- 朴素
tokenizer.decode(all_tokens)为什么不行?
答案
三个问题:(a) **O(n²) 复杂度**:每收到新 token 都 decode 整段,n 个 token 总开销 n² 次 decode;(b) **BPE 边界不齐**:HF tokenizer `decode` 默认 add prefix space,逐步调用会出多余空格;多字节字符(汉字 / emoji)半个 token 解码出来是 invalid byte,可能崩或返回 `?`;(c) **无法做增量 SSE**:流式回写要拿"新出现的字符串差值",朴素方法每次重新 decode 全文然后 diff,慢且容易丢字。 正确做法见 `_incremental_decode` —— 维护 `read_offset` / `sent_offset` 状态,只解码新增片段并处理 surrogate buffer。DecodeStatus的 4 个 offset 分别表示什么?
答案
([`detokenizer_manager.py:65`](../sglang/python/sglang/srt/managers/detokenizer_manager.py)) - `read_offset`:tokenizer 已经"读"到第几个 token id(包含 prefix context,用于增量 decode 上下文); - `sent_offset`:已经"发"出去给 TM 的字符串长度; - `surr_offset`:surrogate buffer 起始——上次解码出来的尾部不完整字节,下次 prepend 进去再尝试 decode; - `decoded_text`:已经发出去的累计字符串(用于 diff 出新片段)。 不变量:`sent_offset ≤ len(decoded_text)`;surrogate 处理时退回到最后完整字符位置,剩余字节进 surr buffer。- stop string trim 为什么放 Detokenizer 而不是 Scheduler?
答案
Scheduler 只看 token id,不知道 token 序列对应的字符串。stop string(如 `"\n###"`)是字符串级匹配,必须在 detokenize 后做。 而且 stop string 可能横跨多 token(如 `"\n"` + `"#"` + `"##"` 三个 token 拼起来才匹配),Scheduler 不可能做字符串级跨 token 匹配。 Detokenizer 拿到完整字符串后 `output.find(matched_stop)` 截断到匹配前。stop token id(如 EOS)则在 Scheduler 层面就识别(因为它就是单 token 比较)。- 多 worker 模式怎么知道把结果发回哪个 TM?
答案
`MultiHttpWorkerDetokenizerMixin` 维护 `SocketMapping: worker_id -> zmq_socket`。 TM worker 启动时往 Detokenizer 注册自己的 socket;TM 发请求到 Scheduler 时在元数据带上 `worker_id`;Scheduler 转发 `BatchTokenIDOutput` 给 Detokenizer 时带过来;Detokenizer 用 `worker_id` 查 SocketMapping 选对应 socket 推送 `BatchStrOutput`。 不变量:**同 rid 的所有 token 一定回同一 TM worker**——因为 ReqState 只在那个 worker 里。- 客户端收到截断的中文字符,应该怎么排查?
答案
按顺序排查: (a) **tokenizer 不匹配**:server 用的 tokenizer 和模型不一致(如 fast vs slow tokenizer 版本差异)。 (b) **流式被中间件改坏**:nginx / envoy 缓冲 SSE chunk 时把 UTF-8 字节流切错位置;加 `proxy_buffering off`。 (c) **客户端按字节读**:客户端代码用 `read(n bytes)` 而不是 line-by-line 读 SSE,多字节字符被砍。 (d) **Detokenizer surrogate buffer bug**:罕见,但版本升级时 regression。看 server 日志 detokenizer 是否报 invalid utf-8 警告。 (e) **stop trim 砍多了**:业务的 stop 字符串恰好以中文结尾,trim 截到字符中间。 通常 (a) 和 (b) 最常见。13. 下一步
02-tokenizer-manager.md— 上游 TM。08-io-struct.md—BatchTokenIDOutput字段细节。03-scheduler.md— 上游 Scheduler。- 源码:
detokenizer_manager.py。
上游源码:
sglang/python/sglang/srt/managers/detokenizer_manager.py。