预计阅读 7 分钟

源码精读: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) 返回完整字符串差值。 问题:

  1. 复杂度 O(n²):每步都要 decode 整段。
  2. BPE 边界不齐:很多 tokenizer 一个汉字 = 2 token;半个 token 解码出来是 invalid byte。
  3. 空格 / 换行处理:HF tokenizer 的 decode 默认会 add prefix space,逐步调用会出多余空格。

正确做法:维护"已经发出去的字符串末尾位置",每次新 token 来了 decode "从上次位置到现在"那段,做边界对齐。


2. DecodeStatus(65+)

源码:detokenizer_manager.py

@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_statusLimitedCapacityDictrid → 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. 自检

  1. 朴素 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。
  1. 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。
  1. 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 比较)。
  1. 多 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 里。
  1. 客户端收到截断的中文字符,应该怎么排查?
答案 按顺序排查: (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. 下一步

上游源码:sglang/python/sglang/srt/managers/detokenizer_manager.py