正在获取所在地区…
正在读取天气
音乐
暂未播放
DeepInfer 源码拆解(二十七):Python 前端与 token 策略——从 `LLM.generate` 到引擎,以及 BOS、PAD、图像哨兵如何对齐
本篇要回答的问题
前面二十多篇沿着 DeepInfer 的源码把调度器、KV 块账、MoE、投机解码、CUDA Graph、CP、显存 profiling、采样这些「引擎内部」的机制拆得比较细了。但还有一段路一直没有走:一个 prompt 字符串是怎么变成 np.int32 的 token id 数组、然后被塞进引擎的,以及引擎对 tokenizer 到底有哪些不信任、做了哪些强制修正。
这一段代码量不大——deepinfer/entrypoints/llm.py 不到 200 行,deepinfer/hugging_face/tokenizer.py 只有 121 行,deepinfer/multimodal_tokens.py 41 行——但它恰好是大多数推理框架里最容易出事的地方。原因很朴素:tokenizer 来自 Hugging Face 生态,模型来自自家训练流程,引擎对 token 的编号有一套自己的假设(BOS 在 0、图像 token 在词表之外从哪里开始、pad token 是几),三者之间只要有一个数字对不上,通常不会报错,而是静默地生成一堆看似正常、实则全错的输出。
这篇按调用顺序讲三件事:
LLM.generate这条 Python 前端路径:批量提交、SamplingParams复制、step()与flush_inflight_reqs()的收尾、RequestOutput.clone()的保命操作、结果按数字 request id 排序。EngineArgs在__post_init__里对引擎类型的隐式推导——engine_type如何决定零开销调度与多模态池两个环境变量的默认值。- token 策略:
get_hf_tokenizer的三条解析路径、_apply_release_fixups的 BOS/PAD/多模态占位符校验,以及ImageTokenOffset与CompressedTokenizer怎么把「vocab_size 之外」的 id 纳入合法性边界。
回顾一下 DeepInfer 在整张源码地图里的位置:它在仓库层面把「Python 前端(entrypoints/、hugging_face/、ve/)」「Python 引擎与执行体(engine/、worker/)」「C++ 核心(csrc/engine/)」「内核与通信(vendor/ 下的 DeepGEMM、DeepEP、FlashMLA 等)」分得很开。前面各篇基本都在后三层打转,前端这一层只被顺带提过一句「hugging_face/ 是 HF tokenizer / 配置适配」。这篇补上它。

上图来自 DeepInfer 官方仓库 doc/images/architecture_overview.png,它把整条装配线画在一起:左侧是请求与 token 的输入形态,中间是引擎与调度,右侧落到各层内核。这张图在整体架构与模块全景里出现过一次,这里只需要记住最左边的输入部分——token id 一旦进入引擎,前端就没有任何事可做了,之后的所有错误都只能靠引擎内部的断言和内核行为来暴露。
一、生成结果的形态:为什么 LLM.generate 返回的是列表的列表
DeepInfer 的离线入口是 deepinfer/entrypoints/llm.py 里的 LLM。它和 vLLM 的离线 LLM.generate 接口几乎同形,但返回值的语义值得先讲清楚:它返回 list[list[RequestOutput] | str]。
默认 decode_sequence=True 时每个元素是解码后的字符串;置为 False 时每个元素是一条 RequestOutput 列表。后者才是底层真相:每个 RequestOutput 代表「某个 token 位置的一次记录」,而不是「一条最终文本」。投机解码、MTP 的 nextn 以及变长验证,会使得一次 step 对同一请求产出多个 token,所以一条请求的完整生成结果天然就是一串按位置排列的记录。
RequestOutput 的字段也按「一个位置」设计:token_id 是该位置采样出的 token;token_logprobs 与 top_logprobs 是该位置的分布信息;finished 与 finish_reason 标记这一步是否让请求结束;num_output_tokens 是该请求累计已生成数;draft_tok、accept_tok 与 confidence_score 则只在投机解码路径上有意义。把多个 RequestOutput 拼成一条文本时,必须按它们在列表中的先后顺序取 token_id,而不是按 num_output_tokens 去重——后者只是计数器,不是位置索引。
1# deepinfer/entrypoints/llm.py(节选)2def generate(self, prompts=None, sampling_params=None, prompt_token_ids=None,3 use_tqdm=True, multi_modal_data=None):4 if prompts is None and prompt_token_ids is None:5 raise ValueError("Either prompts or prompt_token_ids must be provided.")6 if isinstance(prompts, str):7 prompts = [prompts]8 multi_modal_data = [multi_modal_data]9 ...10 for i in range(num_requests):11 self._add_request(prompts[i] if prompts is not None else None,12 sampling_params if isinstance(sampling_params, SamplingParams) else sampling_params[i],13 None if prompt_token_ids is None else prompt_token_ids[i],14 None if multi_modal_data is None else multi_modal_data[i])15 return self._run_engine(use_tqdm)SamplingParams 本身还有一处容易被误读的写法。构造时:
1if self.temperature < SAMPLING_EPS:2 self.temperature = SAMPLING_EPS3 self.top_p = 1.04 self.top_k = -1SAMPLING_EPS = 1e-5。于是用户传 temperature=0 并不会得到严格的贪心,而是得到一个极低温度、放开 top-p/top-k 的分布。在 batch-invariant 的确定性采样里,这个极低温度配合融合采样内核,结果与贪心几乎一致,但它终究不是 argmax。调试「为什么 temperature=0 时结果仍会偶发漂移」时,应当先从这一点查起,而不是怀疑注意力内核。
这里有两个容易忽略的细节。
第一,sampling_params 支持「一份共享」与「逐条列表」两种形态。共享时注释写明「_add_request 内部会 deepcopy」,这与 EngineArgs.__post_init__ 里对默认值的保守处理是同一思路——采样参数对象一定会被引擎在请求生命周期内反复读取,甚至携带运行时的 eos_id,所以必须在入队时切断外部引用。看 _add_request:
1def _add_request(self, prompt, sampling_params, prompt_token_ids=None, multi_modal_data=None):2 sampling_params = copy.deepcopy(sampling_params)3 request_id = str(next(self.request_counter))4 if prompt_token_ids is None:5 prompt_token_ids = self.llm_engine.tokenizer.encode(prompt)6 prompt_token_ids = np.asarray(prompt_token_ids, dtype=np.int32)7 ...8 return self.llm_engine.add_request(user_id="0", request_id=request_id, ...)三个动作都不可省:
deepcopy:后面会看到llm_engine.add_request里会写sampling_params.eos_id = self.eos_id,以及可能在 YoCo 截断模式下把prompt_logprobs强制改成False。如果调用方传的是同一个对象,改动就会反向污染调用方的参数。np.asarray(..., dtype=np.int32):引擎和 C++ 侧统一用int32表示 token id。Pythonint默认按平台字长处理,在prompt_token_ids以np.ndarray[np.int32]或list[list[int]]传入时显式收窄一次,避免 numpy 自动推导出int64后触发后续的 dtype 断言。str(next(self.request_counter)):id 是递增整数转成的字符串。
第二个细节是多模态请求即使不带图像,也要传一个空结构:
1if multi_modal_data is None and self.llm_engine.multi_modal_processor is not None:2 multi_modal_data = dict(image_bytes=[], image_sizes=[])原因在后文 add_request 里:只要引擎是多模态引擎(multi_modal_processor 不为空),overwrite_prompt 就会被调用一次,用来把图像占位符展开成 pad token 序列。纯文本请求也要走这一步,只是图像列表是空的、overwrite_prompt 原样返回一份新数组。为什么不直接跳过?因为 overwrite_prompt 还负责清理 multi_modal_data 里的原始图像字节键,并对每个请求挂上 segment_input——调度器后处理时依赖这个结构做图像 token 计数。传 None 会让下游出现「有 VE 引擎却没有 segment 信息」的分支不一致。
二、驱动循环:step() 之外还要 flush_inflight_reqs()
_run_engine 是这个前端里最值得逐行读的部分,因为它把 ZOS(零开销调度)对离线 API 的影响暴露了出来:
1def _run_engine(self, use_tqdm):2 outputs = defaultdict(list)3 while True:4 unfinished = self.llm_engine.has_unfinished_requests()5 step_outputs = self.llm_engine.step() if unfinished else self.llm_engine.flush_inflight_reqs()6 for raw_output in step_outputs:7 output = RequestOutput.clone(raw_output)8 outputs[output.request_id].append(output)9 if output.finished and use_tqdm:10 pbar.update(1)11 if not unfinished:12 break13 ...三个问题,逐个回答。
为什么循环条件要重新判断一次 has_unfinished_requests()? 因为 has_unfinished_requests 里做了跨 DP rank 的 all-reduce(ReduceOp.MAX),语义是「所有 DP rank 都没有未完成请求才算完成」。在数据并行推理里,某个 DP rank 的本地等待队列可能已经清空,但同组的另一个 rank 还有请求;此时若按本地状态退出,会让已经交给该 rank 的请求永久悬空。前端用这个全局完成条件决定何时结束循环,本质上是把「组内一致退出」这件事挪到了 API 层。
为什么 unfinished 为假时还要 flush_inflight_reqs()? 这是 ZOS 的收尾约定。ZOS 把调度与后处理延后一拍——本拍调度的请求要到下一拍才真正被执行完并产出结果;如果最后一拍调度之后再没有新的拍,就必须把在途的结果冲出来:
1def flush_inflight_reqs(self):2 request_outputs = self._run_workers("flush_inflight_reqs")3 self.deferred_release.collect_request_outputs(request_outputs)4 self.process_output(request_outputs)5 return request_outputsflush_inflight_reqs 本身只做三件事:让 workers 冲掉在途请求、把输出指针登记到延迟释放账本、再走一遍 process_output。这是异步调度与零开销调度 ZOS与引擎与 Worker 的四段握手共同决定的收尾约定,前端不得不为它留一个出口;否则最后 1 到 nextn 个 token 会悄悄消失,单请求测试几乎发现不了。
为什么必须 RequestOutput.clone(raw_output)? 注释写得直白:「step_outputs 将被下一步释放」。DeepInfer 的 RequestOutput 来自固定槽位的对象池,step() 返回的是裸指针;clone 在 C++ 侧做一次拷贝构造,把「本步的租约」变成 Python 真正持有的副本。对象池的容量、延迟释放与 clone/release 的所有权模型在变长验证的扁平行布局与 RequestOutput 对象池里已经逐行拆过,这里只强调前端侧的唯一义务:循环里必须克隆,不能把 raw_output 直接存进 outputs。省掉这一步,循环结束后拿到的一串指针早已被下一步复用,输出会错位到别的请求,且错位程度随批次大小变化;它不会崩溃,只会给出看似合理的结果。
为什么结果要按数字排序? request_ids = sorted(outputs, key=int)。因为 request_id 是 str(next(counter)),字典按字符串键迭代时 "10" 会排在 "2" 前面。按 int 重排才能恢复「输出顺序与输入顺序一致」的 API 约定。
最后 decode_sequence 分支把每条请求的 token_id 列表交给 tokenizer 解码:
1results = [self.llm_engine.tokenizer.decode([x.token_id for x in output]) for output in results]注意这里完全没有过滤:如果 tokenizer 的策略没把特殊 token 注册成 special token,decode 会把这些特殊 token(BOS、EOS、图像哨兵、多模态 span)一并打印进文本里。下一节讲 _apply_release_fixups 时就能看到 DeepInfer 为此专门注册了多模态 span token。
采样参数与请求对象:两个容易混淆的复制点
generate 的循环里,sampling_params 若为列表会按索引取;但列表长度与请求数不一致时,Python 会先抛 IndexError 而不是给出可读的报错。调用方有责任保证一一对应。_add_request 返回的 Sequence 在离线路径里被直接丢弃——离线 API 不暴露句柄,中止只能靠 LLMEngine.abort_request,而 LLM 并没有把它包出来。所以严格来说,离线入口是一个「提交后必须等完」的批处理器,没有逐请求取消能力;需要中途取消的场景必须走引擎级接口。
generate 还允许完全跳过文本、直接传 prompt_token_ids,并明确接受 np.ndarray[np.int32]。这条路径在评测里常用:基准数据集已经把 token id 存好,再走一次 tokenizer 编码既浪费又可能因为版本差异改变结果。此时 multi_modal_data 仍然按同一个下标对齐,纯 token 列表与图像列表一一对应。
三、EngineArgs.__post_init__:引擎类型如何悄悄改写环境变量
LLM 的显式参数有很长一串(model、expert_parallel_size、gpu_memory_utilization、decode_sequence 等),其余全部透传给 EngineArgs。除了 stop_preempted_request 的取值,这里还有一处值得注意的细节:expert_parallel_size 是 LLM 的参数,而 data_parallel_size、context_parallel_size、engram_parallel_size 只能通过 **kwargs 传入。换句话说,离线入口只把专家并行提升为一等参数,这与 DeepInfer 主打 Wide-EP 的部署形态一致;DP 与 CP 的规模通常由启动脚本里的 rank 拓扑推导,离线 API 的使用者一般不需要手调。
1kwargs.setdefault("disable_log_stats", True)2kwargs.setdefault("stop_preempted_request", False)3os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")4engine_args = EngineArgs(model=model, tokenizer=tokenizer, ..., offline_inference=True, **kwargs)5self.llm_engine = LLMEngine.from_engine_args(engine_args)disable_log_stats=True 同样是「离线默认」:统计日志的价值主要在多请求在线服务里观察吞吐曲线,离线跑一批时每步打印统计只会增加 CPU 开销并刷屏。需要时显式传 disable_log_stats=False 即可,与运行时 Profiling 与统计日志里那套滑动窗口统计是同一个开关。
stop_preempted_request 的默认值在两层里是相反的:EngineArgs 默认 True,而离线 LLM 把它 setdefault 成 False。含义在调度器 _preempt 里:True 时被抢占的请求直接以 FINISHED_PREEMPTED 结束;False 时请求被 reset_for_recompute() 后放回 waiting 队首,重新 prefill。离线批处理选择后者,是因为结果完整性优先于延迟——被抢占的请求如果直接结束,LLM.generate 返回的列表里会出现一条短得莫名其妙的输出;重算则保证每条请求都跑到自己的停止条件。服务化场景通常相反:宁可让个别长请求失败,也不要让一次抢占触发整段 prefill 重算,拖慢同批其他请求。
EngineArgs 看起来只是一个 dataclass,但它的 __post_init__ 有两个隐式推导:
1def __post_init__(self):2 if self.tokenizer is None:3 self.tokenizer = self.model4 os.environ.setdefault("DEEPINFER_ZERO_OVERHEAD_SCHEDULING",5 "1" if self.engine_type.default_zero_overhead_scheduling else "0")6 environ.rearm("DEEPINFER_ZERO_OVERHEAD_SCHEDULING")7 os.environ.setdefault("DEEPINFER_USE_MULTIMODAL_POOL",8 "1" if self.engine_type != EngineType.COMPLETION else "0")9 environ.rearm("DEEPINFER_USE_MULTIMODAL_POOL")逐行拆:
tokenizer独立于model存在,缺省时回落到model路径。这个设计是为了支持「模型与 tokenizer 分目录存放」,在 P/D 分离场景里,completion 引擎可以指向同一个 tokenizer 目录而不必重复下载。DEEPINFER_ZERO_OVERHEAD_SCHEDULING的默认值由EngineType决定。EngineType只有三个值,定义在engine_type.py:
1class EngineType(enum.Enum):2 NORMAL = 0 # process prompts & completions3 PROMPT = enum.auto() # only process prompts4 COMPLETION = enum.auto() # only process completions5
6 @property7 def default_zero_overhead_scheduling(self) -> bool:8 return self in (EngineType.NORMAL, EngineType.COMPLETION)PROMPT 引擎默认不开 ZOS,COMPLETION 和 NORMAL 默认开。 为什么?ZOS 收益的前提是「decode 每一步都有稳定的节拍」,把后处理延后一拍能藏住 CPU 开销;PROMPT 引擎只做 prefill,prefill 的调用频率取决于请求到达,延后一拍只会推迟 TTFT 且没有可重叠的稳定节拍。而 EngineType::PROMPT 在 Scheduler::_schedule 里有一条特殊分支:「PROMPT 引擎即使没调度到请求也返回(空的)prefill 结果」——它的节拍本来就和 decode 不同。
-
DEEPINFER_USE_MULTIMODAL_POOL只在非 COMPLETION 时默认开启。多模态预处理产出的图像张量需要放进一块 pinned host 共享池(DEEPINFER_MULTIMODAL_POOL_SIZE_GB),供所有 rank 直接读取;纯 decode 的 COMPLETION 引擎不会有图像输入,这块池子就是纯浪费。 -
setdefault+environ.rearm的组合非常关键。setdefault意味着用户在环境变量里显式设置过就不覆盖;而environ.rearm是因为deepinfer.environ模块里的envs是一个惰性读取的包装类——它在启动早期就构造了,如果不重新装载,这次setdefault写进去的值不会生效。所有通过环境变量做默认推导的地方,都必须配一次rearm,否则读到的还是空。
create_engine_configs 接着把 EngineArgs 拆成四个配置对象,其中与 token 相关的一条是:
1model_metadata = ModelMetadata(model_config.model_cfg.gpt)2resolved_tokens_per_block = model_metadata.resolved_tokens_per_block3cache_config = CacheConfig(resolved_tokens_per_block, self.gpu_memory_utilization, self.lookahead_tokens)block size 不是用户参数,而是从模型元数据推导出来的;如果用户显式传了 tokens_per_block 且与推导值不一致,直接断言失败。这是 DeepInfer 面向自家模型特化的典型做法——KV 块大小和模型的注意力结构(压缩比、滑窗宽度)是绑定的,用户不该有自由度。
这里还埋着一条与 token 直接相关的分支:若模型启用 Engram,create_engine_configs 会把 model_config.use_compressed_tokenizer 与 engram_tokenizer 填上,而这两个值来自 config.json 的 engram_compressed_vocab_size 与 engram_cfg.tokenizer_name_or_path。也就是说,压缩 tokenizer 的选择发生在 EngineArgs 里,而不是在 add_request 时临时判断。这样做的原因是 CompressedTokenizer 的初始化在 worker 侧(worker/base.py)只执行一次,并且要把 lookup_table 注入 C++ 调度器;如果等到请求到达才决定,多个请求之间可能出现一半压缩、一半不压缩的状态。
启动顺序:tokenizer 为什么必须最先加载
LLMEngine.__init__ 的顺序不是随意排的:
1self.tokenizer = get_hf_tokenizer(...)2full_cfg = model_config.model_cfg3model_class = _get_model_architecture(model_config)4mm_enabled = hasattr(model_class, "multi_modal_mapper") and engine_type != EngineType.COMPLETION5if mm_enabled:6 _bind_numa(local_rank=0)7 self.multi_modal_processor = MultimodalProcessor(model_config, self.tokenizer, ...)MultimodalProcessor._resolve_processor 需要从 tokenizer 词表里查找 image_token_id,并与 config.json 的 mm_config.image_token_id 做交叉校验;所以 tokenizer 必须先于多模态处理器存在。反过来,mm_enabled 又依赖模型类是否有 multi_modal_mapper,因此模型架构解析必须夹在两者之间。
COMPLETION 引擎跳过整个多模态处理器,这与前面 DEEPINFER_USE_MULTIMODAL_POOL=0 的默认值互相印证:decode 引擎不需要图像编码,也不需要 pinned 池与 fork 出来的预处理进程。PROMPT 引擎则必须保留多模态处理器,因为图像展开发生在 prefill 侧——这也是 P/D 分离时两端配置不能简单复制的原因之一。
四、tokenizer 的三条解析路径
现在进入本篇的核心。LLMEngine.__init__ 里第一件事就是加载 tokenizer:
1self.tokenizer = get_hf_tokenizer(2 model_config.tokenizer,3 trust_remote_code=model_config.trust_remote_code,4 tokenizer_revision=model_config.tokenizer_revision,5 revision=model_config.revision,6)get_hf_tokenizer 做的事情比 vLLM 的同名函数更「强硬」,因为它对 release checkpoint 做了一套不可绕过的修正。完整函数如下:
1def get_hf_tokenizer(tokenizer_name, *args, trust_remote_code=False, tokenizer_revision=None, **kwargs):2 model_config_path = Path(tokenizer_name) / "config.json"3 compatibility = (4 json.loads(model_config_path.read_text()).get("tokenizer_compatibility", "release")5 if model_config_path.is_file() else "release"6 )7 if compatibility not in ("release", "native"):8 raise ValueError(f"Unsupported tokenizer_compatibility: {compatibility!r}")9
10 for filename in ("tokenizer.json", "tokenizer_config.json"):11 path = os.path.join(tokenizer_name, filename)12 if not os.path.isfile(path):13 raise FileNotFoundError(f"HF tokenizer file not found: {path}")14
15 tokenizer_config = get_tokenizer_config(str(tokenizer_name), **kwargs)16
17 if "auto_map" in tokenizer_config:18 class_ref = tokenizer_config["auto_map"]["AutoTokenizer"][0]19 module_file, class_name = class_ref.split(".")20 file_path = os.path.join(str(tokenizer_name), module_file + ".py")21 tokenizer_class = load_class_from_file(file_path, class_name)22 elif compatibility == "native":23 tokenizer_class = AutoTokenizer24 else:25 tokenizer_class = LlamaTokenizerFast26
27 tokenizer = tokenizer_class.from_pretrained(...)28 return _apply_release_fixups(tokenizer) if compatibility == "release" else tokenizer按顺序解释每一个决策。
第一,tokenizer_compatibility(后文「工程视角:一张 token 合约表」的 token 合约流水线图里,右上角标出了这个开关)来自模型目录下的 config.json,而不是引擎参数。 这个开关写在模型侧,意味着「这个 checkpoint 需要哪一套 token 策略」是发布方定义的事实,运维不能在部署时改。两条路:
release(默认):用LlamaTokenizerFast加载,然后执行_apply_release_fixups,强制 BOS/PAD/多模态占位符符合运行时假设。native:用AutoTokenizer加载,按 tokenizer 自带配置原样使用。
第二,为什么 release 固定用 LlamaTokenizerFast 而不是 AutoTokenizer? 关键在 add_bos_token。LlamaTokenizerFast 实现了一个可配置的 add_bos_token 属性,而这正是 release 策略要改写的东西。用 AutoTokenizer 加载同目录时,虽然大概率也解析到 fast 实现,但属性是否可写取决于具体的 tokenizer_class;锁定 LlamaTokenizerFast 让行为可预测。这个选择也有代价:若某个 checkpoint 的词表是 BPE 而非 SentencePiece 风格,用 LlamaTokenizerFast 硬跑可能得到不正确的切分——所以发布方必须保证 release 路径下的 checkpoint 与它兼容。
第三,auto_map 优先级最高。 如果 tokenizer_config.json 里有 auto_map.AutoTokenizer,就用 load_class_from_file 从本地 tokenizer_name/module_file.py 加载自定义类。注意这里不看 trust_remote_code——它只加载已下载到本地目录的文件,不回源拉远程代码。之后是否执行 _apply_release_fixups 仍由 compatibility 决定:默认的 release 会执行,native 则不执行。因此自定义类若声明 native,就必须自己保证 BOS/PAD 布局与引擎一致;声明 release 则要能被这套断言接受。
第四,只接受本地已下载的两个文件。 两个文件任一缺失直接 FileNotFoundError,不会走 HF Hub 自动下载。这与引擎整体部署方式一致:推理镜像里 tokenizer 与权重一起预下载、校验哈希,运行时不访问网络。
五、_apply_release_fixups:一套「不满足就报错」的契约
这个函数不长,但每一行都是一个前置假设。先看常量:
1_RELEASE_PAD_TOKEN = "<|▁pad▁|>"2_RELEASE_MM_SPAN_TOKENS = tuple(f"<|place_holder_mm_span_{i:04d}|>" for i in range(21))PAD 用的是一个带全角竖线风格的特殊 token,而不是 EOS。为什么不用 EOS 做 pad?因为引擎需要区分「填充位置」与「结束标记」:调度器的 _check_stop 只在采样出 eos_token_id 时判定停止;如果 PAD 与 EOS 同值,任何把 pad 当成普通输入的位置都会被读成结束信号,而 get_hf_tokenizer 的 pad_token_id != 2 断言也会失败。专用 pad 让填充位置有独立编号,不与停止条件共用。21 个多模态 span 占位符是给 ve/ 侧的视觉编码链路用的——它们出现在 prompt 里,代表「这里要插入一张按分辨率展开的图像」。为什么是 21?源码没有给出推导,只在常量与注释里把它当成发布约定:这 21 个 slot 是运行时与 tokenizer 共享的固定布局,不能按需增减。真正重要的不是这个数字本身,而是它必须与 ve/ 侧、C++ 侧使用同一个起点(129280);任何一处单独改动都会让“看起来合法的 token”落到错误位置。它与 ImageTokenOffset 的五种图像特殊 token 是两套完全独立的体系,后文会专门区分。
函数分两段:先全量校验,再最小修复,最后再校验一次。
1vocab = tokenizer.get_vocab()2if tokenizer.bos_token != "<|begin▁of▁sentence|>" or tokenizer.bos_token_id != 0:3 raise ValueError("Unsupported HF tokenizer BOS: expected <|begin▁of▁sentence|> at id 0")4if tokenizer.eos_token != "<|end▁of▁sentence|>" or tokenizer.eos_token_id != 1:5 raise ValueError("Unsupported HF tokenizer EOS: expected <|end▁of▁sentence|> at id 1")6if vocab.get(_RELEASE_PAD_TOKEN) != 2 or tokenizer.pad_token not in (tokenizer.eos_token, _RELEASE_PAD_TOKEN):7 raise ValueError("Unsupported HF tokenizer PAD: expected EOS alias or <|▁pad▁|> at id 2")8if getattr(tokenizer, "add_bos_token", None) not in (False, True) or getattr(tokenizer, "add_eos_token", None) is not False:9 raise ValueError("Unsupported HF tokenizer BOS/EOS policy: expected configurable BOS and add_eos_token=False")这几条断言固定了三个编号:BOS = 0,EOS = 1,PAD = 2。为什么 PAD 允许「EOS 别名」?因为不少 HF tokenizer 发布时把 pad_token 设成了 eos_token(LLaMA 系常见做法)。引擎并不反对这种写法,它只要求:当 PAD 不是别名时,必须是 id 2 上那个专用 pad token。同时 add_eos_token 必须为 False——引擎自己管结束判定,tokenizer 自动追加 EOS 会让 prompt 末尾多一个不属于用户的 token,影响前缀缓存命中与 KV 计数。
接下来是对多模态 span 的预检:
1missing = []2for index, token in enumerate(_RELEASE_MM_SPAN_TOKENS):3 expected_id = 129280 + index4 if token in vocab:5 actual_id = vocab[token]6 else:7 actual_id = len(tokenizer) + len(missing)8 missing.append(token)9 if actual_id != expected_id:10 raise ValueError(f"Unsupported HF tokenizer multimodal token layout: {token} would have id {actual_id}, expected {expected_id}")这段逻辑的精髓在「添加前先算添加后」:对尚未注册的 token,它的 id 会是当前词表长度加上此前尚未注册的 token 个数。逐个模拟插入,确认全部 21 个占位符在追加后恰好落在 129280..129300。如果 tokenizer 已经预注册了一部分,就要求已注册的那部分也在正确位置。
len(tokenizer) 而不是 len(vocab) 是关键:add_tokens 分配的编号总是从「Hugging Face tokenizer 的当前总长度」起算,而不是从 get_vocab() 的最大 id 加一。对一个带 added_tokens_decoder 的 tokenizer,两者可能相差几百,用错基数会让模拟插入的结论整体偏移。
这样做的原因很直接——运行时(ve/ 的预处理与 deepseek_vl.py 的输入拼接)是用常量 129280 计算 span id 的,不会去查 tokenizer。一旦 tokenizer 的插入顺序有任何偏差,运行时算出来的 id 就指向别的 token,而且不会有形状错误。
这里还藏着一个容易忽略的事实:129280 恰好等于模型词表大小。DeepSeek-V4.1-Flash-1017 的 text_config.vocab_size 就是 129280,repo 里 sum_smaller_probs、fused_sample、store_mtp_info 等测试也以这个值作为默认。于是 span 起点与 V 重合,ImageTokenOffset 的 V+k 与 span 区间在数值上重叠。
具体到这份 checkpoint 的 tokenizer.json,base BPE 词表是 128000,added_tokens 共 1283 个。其中 129280..129283 是加载时补注册的 span 占位符,129284..129300 则已经在文件里;<|deepseek_image|> 本身在 129264,是一个非 special 的 added token。这意味着前四个 span id 与 V+0..V+3 完全重合,另外十七个大于等于 V+4、落在 embedding 表之内,是真正可被模型读取的占位行。
为什么没有出问题?因为两类 id 出现在序列的不同阶段:V+k 只在 overwrite_prompt 展开之后才出现,用来表示图像像素 embedding;span 占位符是 prompt 里的锚点,在此之前就已经被各自消费,不会与图像 id 同时出现在同一条序列的同一位置。这仍然是任何改动都必须重新核对的边界条件——若某个 checkpoint 的 V 与 129280 不同,或 span 起点被改动,重叠关系就会改变,add_request 的越界校验也会随之失准。
修复部分尽量小:
1if not tokenizer.add_bos_token:2 tokenizer.add_bos_token = True3if tokenizer.pad_token != _RELEASE_PAD_TOKEN:4 tokenizer.pad_token = _RELEASE_PAD_TOKEN5if missing:6 tokenizer.add_tokens(missing, special_tokens=True)- 打开 BOS 插入:docstring 里写明「纯文本编码插入 BOS;多模态 prompt 自带 BOS」。这意味着多模态请求在进入
encode之前就已经在字符串里带了 BOS,而纯文本请求靠add_bos_token=True补。 - 把 PAD 从 EOS 别名改成专用 pad token。这一步改写会改变
pad_token_id——这也是CompressedTokenizer初始化时要用lookup_table[DEEPINFER_ENGRAM_PAD_TOKEN]重新算压缩域 pad 的原因。 - 只注册缺失的多模态占位符,且必须以
special_tokens=True注册:否则解码时它们会被当成普通文本打印出来,输出里就会出现place_holder_mm_span_0003这类乱码。
最后再校验一次结果:
1if tokenizer.encode("", add_special_tokens=True) != [0] or tokenizer.pad_token_id != 2:2 raise ValueError(...)3if any(tokenizer.convert_tokens_to_ids(token) != 129280 + index for index, token in enumerate(_RELEASE_MM_SPAN_TOKENS)):4 raise ValueError(...)encode("") 返回 [0] 是整套契约的浓缩:空字符串经过 tokenizer 后必须恰好是单个 BOS。这句话同时验证了「BOS 在 0」「BOS 被自动插入」「没有多余的 EOS 或 pad 被追加」三件事。任何一项不成立,都会在这里被拦下来。
修复日志也设计得很克制:
1if repairs:2 logger.info("Configured HF tokenizer BOS/PAD and multimodal token policy: %s", "; ".join(repairs))只有真正发生过修复才打印,内容是「做了什么修复」而不是「校验通过」,这样日志里出现这一行就说明 checkpoint 的 tokenizer 策略与 release 预期有偏差,值得追溯。
六、ImageTokenOffset:vocab_size 之外的世界
到这一步,token id 仍然全部落在 [0, vocab_size) 内。但多模态推理需要在序列里放图像,图像的特殊 token 并不在词表里。DeepInfer 的处理方式是用偏移量构造虚拟 id:
1class ImageTokenOffset(IntEnum):2 IMAGE_START = 03 IMAGE_PAD = 14 IMAGE = 25 IMAGE_NEW_LINE = 36 IMAGE_END = 47 NUM_SPECIAL = 5token_id = vocab_size + offset。vocab_size 来自模型配置:ModelConfig.get_vocab_size() 返回 self.model_cfg.gpt.vocab_size。所以:
| 虚拟 token | id |
|---|---|
<image_start> | V + 0 |
<image_pad> | V + 1 |
<image> | V + 2 |
<image_new_line> | V + 3 |
<image_end> | V + 4 |
这个枚举在 Python(multimodal_tokens.py)和 C++(csrc/engine/sequence.hpp 里的 MMTokenOffset)各有一份,注释明确要求两者保持同步。C++ 侧在 Sequence 的输入处理、Chunk Prefill 对齐和 build_request_outputs 里判定图像 token 时都会用到这个偏移。
为什么不「像 span token 那样直接注册成 added token」?因为 span id 的取值是固定的发布常量(129280..129300),而图像特殊 token 的数量必须跟随模型维度:换一个 V 不同的 checkpoint,<image> 的位置也要跟着变。用 V + k 表示它们,才能让「合法 token id 的上界」变成一个可以算出来的值,而不是查表;同时这张表在 Python 与 C++ 两端各存一份,保证调度器与 ve/ 用的是同一个函数。
合法性边界:为什么多模态请求的上界多 5
LLMEngine.add_request 里有一段 token 取值校验:
1vocab_size = self.model_config.get_vocab_size()2...3max_valid_token_id = vocab_size + (ImageTokenOffset.NUM_SPECIAL if multi_modal_data is not None else 0)4if prompt_token_ids.size > 0:5 min_token_id, max_token_id = prompt_token_ids.min(), prompt_token_ids.max()6 if min_token_id < 0 or max_token_id >= max_valid_token_id:7 logger.info(f"Drop request {request_id} from {user_id=} due to invalid token "8 f"(min={min_token_id}, max={max_token_id}, expected in [0, {max_valid_token_id}))")9 return None这是唯一一处多模态与纯文本使用不同上界的地方。纯文本请求的上界就是 V,因为纯文本 prompt 里没有任何机会出现虚拟图像 id;多模态请求放宽到 V + 5。这个判断在 overwrite_prompt 调用之后,所以此时图像占位符已经被展开成了 V+2 与 V+1 构成的序列。
注意越界处理是「记录日志、丢弃请求、返回 None」,而不是抛异常。离线 API 遇到被丢弃的请求不会中断,只在结果列表里少一条——LLM._run_engine 按 outputs 字典的键排序,缺少的请求不会被补成空字符串,调用方需要自行核对请求数量。这是一个在服务化场景里需要额外包一层的点。
add_request 里还有两条与 token 直接相关、但容易被忽略的分支:
- 空 prompt 直接抛异常:
prompt_token_ids.size == 0时raise ValueError("prompt must not be empty")。对纯文本请求,这意味着tokenizer.encode("")的结果必须非空——在 release 策略下,这个结果恰好是[0](单个 BOS),所以「空字符串」在编码后是一条合法请求;若换成add_bos_token=False的 tokenizer,同样的输入就会变成空数组,请求直接失败。 eos_id由引擎写入采样参数:sampling_params.eos_id = self.eos_id发生在add_request的第一段,在overwrite_prompt与越界校验之前。这就是_add_request必须deepcopy的原因:SamplingParams在这里被引擎改写,共享对象会把改写带回调用方;多次generate共用同一份参数时,第二次调用拿到的eos_id已经是引擎上一次写进去的值。
图像 token 如何变成 embedding
V+k 与 V 的相对关系决定了 embed_tokens 遇到它们时的行为,deepseek_vl.py 给出的答案是:
1def _special_image_token_mask(self, input_ids):2 """标记 [vocab_size, vocab_size + NUM_SPECIAL) 区间内的特殊图像 token。"""3 vocab_size = self.cfg.vocab_size4 return (input_ids >= vocab_size) & (input_ids < vocab_size + ImageTokenOffset.NUM_SPECIAL)5
6# 无效 id(负数与 >= vocab_size 的特殊图像 token)由 gather kernel 置零,无需先行 mask7hidden_states = self.model.embed_tokens(input_ids)两个要点:
- 无效 id——负数与超出 embedding 表范围的 id——由 gather kernel 置零,所以不需要先做 mask。这里的取舍是把「越界」统一交给 kernel 处理:如果 embedding 表恰好只覆盖到
V,那么V+k全部落在这个分支;如果 embedding 表被有意扩到覆盖图像 token,它们就会先查到自己的行,再由下一步的 tuner 覆盖。 - 随后
vision_language_tuner根据mm_token_type张量,把图像对应的位置替换成视觉编码器输出的 embedding(replace_input_embedskernel 按TokenType值替换)。TokenType的IMAGE = 2恰好与ImageTokenOffset.IMAGE = 2同值,但语义不同:前者是mm_token_type张量里的类型码,后者是 id 偏移量。源码注释特别强调「wire format 不可改值」,因为ve/产出的类型码、MoE gate 的判断、replace kernel 三方是逐值对齐的。
这也就解释了为什么 image_embed_ops.py 的测试构造里会画一个 [pad, img*N, pad...] 的布局——IMAGE_NEW_LINE、IMAGE_START、IMAGE_END 被用作「分隔 pad」,真实的图像 embedding 是连续 N 个 IMAGE token,分隔符只是为了让注意力计算在图像边界上有可寻址的锚点。
overwrite_prompt:占位符按分辨率展开
ve/multimodal_processor.py 的 overwrite_prompt 是连接 tokenizer 与图像输入的桥梁:
1placeholders = np.flatnonzero(token_ids == self.mm_config.image_token_id)2...3for image_index, position in enumerate(placeholders.tolist()):4 text = token_ids[cursor:position]5 pieces.append(text)6 segments.append(TextLengthInput(length=len(text)))7 image_input = ImageInput(width=..., height=..., binary=...)8 num_image_tokens = self.mm_processor.image_processor.estimate_num_tokens(image_input)9 image_tokens = np.full(num_image_tokens, self.mm_image_pad_token, dtype=np.int32)10 image_tokens[:1] = self.mm_image_token11 ...每张图被展开成 [<image>, <image_pad> × (N-1)],其中 N 由该分辨率下图像处理器的 token 估算决定。图像占位符的位置由用户 prompt 决定,展开长度由图像分辨率决定——同一段文本带上不同尺寸的图片,展开后的序列长度完全不同。这也是为什么 tokenizer 侧必须保证占位符是一个可识别的 image_token_id:如果用户把 <image> 拼错或用了别名,flatnonzero 找不到任何位置,图像就被静默忽略,prompt 按纯文本处理。
_resolve_processor 里还有一层来源交叉校验:
1image_token_id = next((tid for token in candidates if (tid := tokenizer.vocab.get(token, -1)) != -1), -1)2...3assert self.mm_config.image_token_id == image_token_id, (4 f"config.json image_token_id={self.mm_config.image_token_id} 与 tokenizer 的 {image_token_id} 不一致"5)候选 token 是 ("<image>", "<|deepseek_image|>")——按顺序尝试两种写法。config.json 里的 image_token_id 与 tokenizer 词表里查到的 id 必须一致;不一致时直接断言,因为「config 说图像 token 是 128815、tokenizer 实际把它分给了别的词」这种错位不会让图像失效,而是让每个图像位置被当成某个普通文本词处理。
七、CompressedTokenizer 与图像 sentinel:压缩域里的边界
Engram 路径下,token 进入哈希之前要先经过一次词表压缩(细节见Engram 条件推理路径)。这里只补它和前端 token 策略的接缝。
ModelMetadata 从 config.json 的 engram_compressed_vocab_size 推出是否启用压缩,并对缺少该字段却启用了 Engram 的模型直接断言:
1assert not self.engram_enabled or self.engram_cfg.enable_tokenizer_compression, (2 "engram requires engram_cfg.enable_tokenizer_compression=True"3)CompressedTokenizer.encode 是对 lookup_table 的一次逐元素查表,越界元素映射成 IMAGE_SENTINEL = -1:
1@classmethod2def encode(cls, token_ids):3 assert cls.lookup_table_np is not None, "CompressedTokenizer is not initialized"4 return encode_compressed(token_ids, cls.lookup_table_np, cls.IMAGE_SENTINEL)与前端的关系有两个点:
- 压缩发生在
add_request里,且在合法性校验之前:长度必须一致,这是压缩映射「逐 token 替换」的契约。负值和1if self.model_config.use_compressed_tokenizer:2compressed_prompt_token_ids = CompressedTokenizer.encode(prompt_token_ids)3else:4compressed_prompt_token_ids = prompt_token_ids5...6assert compressed_prompt_token_ids.shape[0] == prompt_token_ids.shape[0]>= V的 id 经过压缩后都变成-1,因此压缩之后的数组再也不能用来做合法性判断——所以校验必须用压缩之前的prompt_token_ids。 - 图像 token 在压缩域里是
-1,不是某个合法 id。这个哨兵在 Engram 的 n-gram 哈希 kernel 里被识别并跳过(gather_engram_tokens_zos.py里对token == image_sentinel做分支)。如果有人在调试时看到-1出现在ngram_token_ids里,通常意味着「这个位置是图像」或「压缩表越界」,两者需要通过输入序列反查。
压缩词表本身来自 tokenizer.json:CompressedVocabulary(tokenizer_path) 先对每个词做 NFKC、去重音、小写与空白折叠,再按键去重生成 old2new 映射。这也意味着压缩域和图像 sentinel 是两套独立的「越界表示」:一套是 V+k(合法但不在词表中),一套是 -1(不在任何域中)。
八、同一份 prompt,换一套 tokenizer 就换一个「长度」
前面几节都在讨论 token 的编号(BOS 在几、span 在几、图像 id 从哪开始)。编号错了会启动失败或被断言拦下,属于「响亮的错误」。还有一类错误同样来自 tokenizer,但它不会报错:同一段文字,用不同的切分方式会得到不同长度的 token 序列,进而让「按 token 计的预算」完全走样。
先看切分本身。主流大模型 tokenizer 都来自子词单元(subword units)思路,BPE 是其中最有代表性的一种。Sennrich 等人 2016 年在 ACL 发表的 Neural Machine Translation of Rare Words with Subword Units 给出了 BPE 的完整流程:先按字符拆开,再统计相邻符号对的频次,反复把最高频的对合并成一个新符号,直到词表达到目标大小。训练得到的合并表(merges)就是推理时的切分规则:给定一段文本,按合并顺序贪心地把它压缩成最少的已知符号。
下面的伪代码还原了这一步,它也是理解「为什么同一个词在不同上下文里会被切成不同段」的关键——切分是数据驱动的,而不是按词或按字:
1初始化:每个词 = 字符序列 + 词尾标记2
3重复直到达到目标词表大小:4 统计语料中所有相邻符号对的频次5 best_pair = argmax 频次6 merges.append(best_pair)7 在所有出现 best_pair 的位置上,把它替换为新符号8
9编码(推理时):10 按 merges 的顺序,依次尝试对当前序列做替换11 输出最终符号序列的 id有两个工程含义直接连着 DeepInfer 的前端:
-
add_tokens追加的特殊符号一定优先切分。这也是第 41 行那段「全角竖线风格」token 的意义:<|begin▁of▁sentence|>、<|▁pad▁|>这类符号包含▁(SentencePiece 的「空格」标记)与|(全角竖线),如果不注册为 added token,BPE 会把它们拆成若干个常见子词,bos_token_id与pad_token_id也就无从谈起。_apply_release_fixups要求pad_token必须能解析到 id 2,前提正是这个符号以原子形式存在于词表里。 -
长度预算的「单位」是 token,而 token 的密度随语言与切分规则剧烈变化。DeepInfer 后端所有长度参数(
max_model_len、max_num_batched_tokens、ignore_len、max_tokens)都是以 token 计,不认字符。一旦 tokenizer 的切分习惯对某种书写系统「更差」,同一个 prompt 模板的实际长度可能翻倍。这一点在后文「token 预算必须以 token 计」一节里有具体数据。
九、为什么先占位、后编码,而不是先把图像编码成 embedding
多模态 tokenizer 的训练方式为此提供了一个对照。下面的图来自 Next Token Prediction Towards Multimodal Intelligence 综述(arXiv 2412.18619)的 Figure 5:不同模态的 tokenizer 各有自己的训练目标,训练完成之后,模型看到的是离散 token 序列,而不是连续的图像特征。

DeepInfer 的 place_holder_mm_span_xxxx 属于「在文本序列里预留锚点」这一侧:它们是 release 策略在加载时注册进 tokenizer 的固定符号,作用是让一段多模态输入在进入引擎时就有确定的长度。图像真正的特征并不在 tokenizer 里,而在 ve/ 的视觉编码器中。
下面的图来自 DeepSeek-VL2(arXiv 2412.10302)的 Figure 2:LLaVA 式结构由「视觉编码器 → VL adapter → LLM」组成。DeepInfer 的工程选择是把这张图里的前半段放进 ve/,把「图像特征如何覆盖到 LLM 输入」这一步放进 vision_language_tuner,而两者之间的接口正是一串 V+k 的虚拟 id。

为什么不让 tokenizer 直接把图像编码成 embedding?因为调度器只认识整数 token id:KV 块按 token 编号、前缀缓存按 token 序列哈希、chunk prefill 按 token 区间切片。如果图像在进入引擎前就变成 embedding,序列长度要在调度之后才能确定,块表与预算都会失效。先占位、再回填的顺序保证了调度期就能知道这条请求有多长;代价是 embedding 层必须容忍「词表之外的 id」,并依赖 vision_language_tuner 在正确的时机覆盖它们。
十、工程视角:一张 token 合约表
一张表

上图把本篇的内容串成一条流水线:蓝色是入口与解析,橙色是 EngineType 与 release 修复这两套策略,紫色是 ImageTokenOffset 与合法性边界,绿色是进入设备之后的 embedding 与压缩映射。红色框标出的是最容易出错、而且出错时不报错的位置。
图里值得单独指出两点。第一,_apply_release_fixups 与 ImageTokenOffset 处在不同的层:前者改的是 HF tokenizer 对象本身(BOS/PAD/span),后者是引擎自己构造的一类 id,tokenizer 根本不知道它们存在。于是 tokenizer.decode 遇到 V+2 时不会把它报成特殊 token,只会因为越界输出一个替换符。第二,Engram 的压缩映射发生在合法性校验之后(图中 add_request bounds 到 embed_tokens 之间),这也解释了为什么压缩表里会出现 -1:原始 id 已经通过了 [0, V+5) 的检查,压缩只是把它们重新编号。
把所有约定汇总成一张表,这张表几乎就是 DeepInfer 对 tokenizer 的「全部要求」:
| 位置 | 取值 | 由谁保证 | 违反时的症状 |
|---|---|---|---|
| BOS | id 0,自动插入 | _apply_release_fixups | 启动时 ValueError |
| EOS | id 1,不自动追加 | _apply_release_fixups | 启动时 ValueError |
| PAD | 专用 pad token,id 2 | _apply_release_fixups | 启动时 ValueError |
| 多模态 span | place_holder_mm_span_xxxx,129280..129300 | 预检 + 追加注册 | 启动时 ValueError |
| 图像虚拟 id | V + 0..4 | ImageTokenOffset(Python 与 C++ 双份) | 两侧不同步时错误 id 静默落入 |
| 合法上界 | 纯文本 V;多模态 V+5 | add_request | 请求被丢弃、返回 None |
| 压缩域图像 | -1 | CompressedTokenizer | Engram 哈希取错行 |
| 压缩域长度 | 与原始序列等长 | encode_compressed | 断言失败 |
token 预算必须以 token 计
后端所有与长度相关的参数——max_model_len、max_num_batched_tokens、ignore_len、max_tokens、runtime_max_model_len——单位都是 token。这在多语言场景里会带来数量级差异。arXiv 2510.12389 统计了不同书写系统下「每句平均 token 数」(Tokens Per Sentence):拉丁字母约 50.2,缅甸文高达 357.2,全语言均值 90.3。同一段语义信息用不同文字表达,进入引擎后的 token 数可能相差 3–4 倍。

这对部署的直接含义有三条:
max_model_len = 4096只保证「4096 个 token」,不保证「多少字」。同一个 prompt 模板,换成另一种语言的输入可能从 2K token 涨到 6K,超过prompt_limit后被调度器以FINISHED_IGNORED丢弃。排查「请求无输出」时,应先打印len(prompt_token_ids),而不是看字符数。ignore_len的默认值128 << 20(约 1.34 亿)同样是 token 数上界,其实际作用是「不因长度过滤任何请求」,真正的长度约束由max_model_len与 block manager 容量承担。max_tokens的默认值只有 16。离线批量评测忘了显式设置时,所有生成都会在 16 个 token 处被FINISHED_LENGTH_CAPPED截断,且不会报错。
runtime_max_model_len 则在 max_model_len 之上加了一段余量:
1nextn = envs.DEEPINFER_USE_NEXTN_DECODE2scheduler_overhead = nextn + (nextn + 1 if envs.DEEPINFER_ZERO_OVERHEAD_SCHEDULING else 0)3draft_overhead = nextn4return max_model_len + scheduler_overhead + draft_overhead它是所有运行时位置索引(RoPE cache、位置编码缓冲)的开区间上界。ZOS 会多跑一拍、MTP/DFlash 会预取草稿 token,实际被索引到的 position 可能比 max_model_len 多出 2×nextn+1 行。调度器在 _check_stop 里用 get_len() > max_model_len 判定长度截断,而在进入前向之前用 position_end <= runtime_max_model_len 做容量硬校验——一个管语义,一个管容量,不能混用。
多模态 span 与虚拟图像 id 是两套东西
很多讨论会把 place_holder_mm_span_xxxx 与 V+k 混为一谈。它们的区别如下:
- span token 在 tokenizer 的词表尾部。129284..129300 在 V4.1 的 tokenizer.json 里已有定义、且小于
V,因此有真实的 embedding 行;129280..129283 是加载时补注册的,与V+0..V+3共用数值,不保证有 embedding 行。它们的共同作用是在编码/解码时保持为特殊符号,而不是被 BPE 拆成普通子词。 V+k是运行时构造的图像 id。它们只在overwrite_prompt展开之后才出现,ImageTokenOffset用V加偏移算出;模型 embedding 表只有V行,所以这些 id 要么越界置零,要么(在少数扩表配置里)落到专门的占位行,再由vision_language_tuner覆盖。
两者在真实流程里接力出现:文本 prompt 携带 span 锚点 → overwrite_prompt 把图像输入展开成 [V+2, V+1 × (N-1)] → vision_language_tuner 用视觉编码器输出覆盖这些位置。任何一步错位(span 顺序、image_token_id、V 的值),都会让图像静默失效或落到错误位置。
四类静默失败
以下四种情况不会触发断言,需要额外警惕:
mm_config.image_token_id与 tokenizer 一致、但用户 prompt 里写的不是这个 token:flatnonzero找不到任何占位,图像被忽略,prompt 按纯文本推理。multi_modal_data的image_sizes数量多于占位符:多出的图像被静默忽略;少于占位符时,多余占位符「作为纯 token 输入」,日志里有 error,但请求不失败。V随 checkpoint 变化:ImageTokenOffset以V为基准,不同 checkpoint 的V不同。若有人硬编码了某个 checkpoint 的绝对 id(比如129280之外的V+2数值),换 checkpoint 后就会指向别的词。- span 常量跨版本漂移:前端校验的是「tokenizer 自己是否落在 129280..129300」,而
ve/侧用的是同一组常量;只有单侧升级时才会出现「前端通过了、运行时算错」的组合。
排查「输出异常但没有报错」时,第一步应当是排除这四条:打印 input_ids.min()/max() 与长度、核对 image_sizes 与占位符的数量、确认 tokenizer_compatibility 与 max_tokens 的取值。
十一、和另外几个框架的对照
vLLM。 vLLM 的 tokenizer 加载走 AutoTokenizer + 自动下载,对特殊 token 只做少量的 pad/eos 对齐,模型侧的假设是「tokenizer 跟着模型走,引擎不改」。DeepInfer 的做法恰好相反:引擎定义契约、tokenizer 迁就引擎。原因在于 DeepInfer 只服务自家模型,可以在发布时就保证每个 checkpoint 都满足 BOS=0/PAD=2 的布局;而通用框架必须容纳任意 HF 发布方式。
这个差异会传导到「谁来兜底」。vLLM 遇到 tokenizer 与模型配置不一致时,通常是在第一次解码出乱码之后才暴露;DeepInfer 把大量检查提前到 get_hf_tokenizer,用启动失败换运行时确定性。对生产部署来说,后者更贵(一个坏 checkpoint 无法上线),但一旦上线,运行时不会出现「某个请求的 BOS 多了一个」这种只在特定 prompt 上复现的问题。
SGLang。 SGLang 有 special_tokens 体系与多模态占位符,但它的图像 token 通常是注册在词表里的真实 token(例如 <image> 在词表中占一行),这样 embed_tokens 可以直接查表。DeepInfer 把图像 token 放在词表之外,省下了 embedding 表的行数,代价是 embedding kernel 必须处理越界 id。
TensorRT-LLM。 TensorRT-LLM 把 tokenizer 的策略写进 tokenizer.json 的 added_tokens 与 tokenizer_config.json 的 bos_token/eos_token,引擎侧只读不改;它对多模态的 mm_hash 与占位符也更多依赖上游 transformers 的 processor。DeepInfer 自己实现 MultimodalProcessor,是为了把图像预处理放进独立的 fork 进程,并通过 pinned 内存池与主进程共享——这一点在视觉编码器 VE里已经展开。
llama.cpp。 llama.cpp 用 GGUF 文件内嵌 tokenizer 元数据(tokenizer.ggml.*),llama_token_bos/eos 等由加载器读出来交给上层;它的强项是「一个文件搞定」,不存在「模型与 tokenizer 目录不同步」的问题,但也无法像 DeepInfer 这样对一批 token 做预检、模拟插入。
DeepInfer 与前三者的另一个区别在「多模态 token 的所有权」。 vLLM 与 SGLang 把多模态占位符当作模型输入的一部分,由 processor 把图像特征拼进 inputs_embeds;TensorRT-LLM 也是类似路径。DeepInfer 把「占位」与「特征」彻底分开:占位符是 tokenizer 词表里的 span token 加上 V+k 的虚拟 id,特征由 vision_language_tuner 在 LLM 内部回填。好处是调度器只看见整数 token id,不需要理解图像;代价是 embedding 计算必须知道哪些 id 是图像,_special_image_token_mask 与 TokenType 的逐值对齐就成了不能改的 wire format。
部署前的一次端到端核对
把前面各节的契约压成一组可以直接执行的检查,能提前发现绝大多数 token 层问题。第一步不启动引擎,只验证 tokenizer 对象:
1from deepinfer.hugging_face.tokenizer import get_hf_tokenizer2
3tok = get_hf_tokenizer(model_dir) # 走 config.json 里的 compatibility4assert tok.encode("") == [0] # 单个 BOS,且 BOS 在 05assert tok.pad_token_id == 2 # 专用 PAD,不是 EOS 别名6span_ids = [tok.convert_tokens_to_ids(f"<|place_holder_mm_span_{i:04d}|>")7 for i in range(21)]8assert span_ids == list(range(129280, 129301))第二步验证模型侧的 image_token_id 与 tokenizer 对齐。config.json 里的 mm_config.image_token_id 必须等于 tokenizer 对 <image>(或 <|deepseek_image|>)的解析结果;这一步不需要加载权重,读两个 JSON 即可:
1import json2cfg = json.load(open(f"{model_dir}/config.json"))3image_token_id = cfg["mm_config"]["image_token_id"]4assert tok.convert_tokens_to_ids("<image>") == image_token_id \5 or tok.convert_tokens_to_ids("<|deepseek_image|>") == image_token_id第三步才是跑一次最小生成,并显式打开所有影响结果口径的参数:
1from deepinfer.entrypoints.llm import LLM2from deepinfer.sampling_params import SamplingParams3
4llm = LLM(model=model_dir, tokenizer=model_dir, max_model_len=4096)5outs = llm.generate(6 prompts=["Hello"],7 sampling_params=SamplingParams(temperature=0.6, max_tokens=64),8 decode_sequence=False,9)10positions = outs[0]11print(len(positions), positions[-1].finished, positions[-1].finish_reason)12print(tok.decode([p.token_id for p in positions]))三件事一眼可辨:len(positions) 是否与预期的生成长度相符、finish_reason 是自然停止还是 FINISHED_LENGTH_CAPPED、解码出的文本是否把特殊 token 打印成了可读乱码。若第三步文本正常但长度异常,问题通常不在 kernel,而在 max_tokens 的默认值或 runtime_max_model_len 的余量;若文本里出现 place_holder_mm_span_xxxx 之类的字面量,则说明 span 没有以 special_tokens=True 注册,回看 _apply_release_fixups 的修复分支。
小结
这篇从 LLM.generate 出发,走完了 DeepInfer 的 Python 前端和 token 策略:
- 返回值的单位是「位置」不是「文本」:每个
RequestOutput描述一个 token 位置;离线默认temperature=0、max_tokens=16、抢占后重算,这三项都影响结果口径。 LLM.generate只负责批量提交与结果整理;它用逐条deepcopy、np.int32收窄与递增字符串 id 把外部对象与引擎隔离;_run_engine必须用全局 all-reduce 的完成条件,并在最后一拍用flush_inflight_reqs()冲出在途结果,每个输出都必须clone才能越过对象池的释放边界。EngineArgs.__post_init__把engine_type转写成两个环境变量的默认值,再rearm让惰性 envs 重新生效;PROMPT 引擎不开 ZOS、COMPLETION 不分配多模态池。get_hf_tokenizer按auto_map→native→LlamaTokenizerFast三条路径解析,release 路径执行_apply_release_fixups:BOS=0、EOS=1、PAD=2、add_eos=False、span 在 129280..129300,全部「先校验、最小修复、再校验」。ImageTokenOffset把图像特殊 token 放在vocab_size之外的五个偏移上;add_request按是否多模态给出V或V+5的上界;embed_tokens对越界 id 输出零向量,由vision_language_tuner回填图像 embedding。- Engram 的压缩域把越界统一成
-1,压缩映射与合法性校验必须分开执行。 - token 预算的单位是 token 而不是字符:子词切分是数据驱动的,同一段语义换一种书写系统,进入引擎后的长度可能相差数倍;
max_model_len与max_tokens的语义都必须按实际len(prompt_token_ids)来读。
下一篇不再继续拆 DeepInfer 的某一个模块,而是把五个框架在调度器准入算术上的做法放在一张表里横向对比:同样面对「prefill 与 decode 抢同一份 KV 预算」,vLLM 的分块预填充打洞、SGLang 的 PrefillAdder 三重预算、TensorRT-LLM 的容量准入与微批预看、llama.cpp server 的 slot 批组装、DeepInfer 的 DP 投票与抢占,究竟差在哪几个变量上。
参考资料
- DeepInfer 官方仓库
- DeepInfer entrypoints/llm.py(离线 LLM 入口与驱动循环)
- DeepInfer hugging_face/tokenizer.py(tokenizer 解析与 release 修复)
- DeepInfer engine/arg_utils.py(EngineArgs 与环境变量推导)
- DeepInfer engine/engine_type.py(NORMAL/PROMPT/COMPLETION)
- DeepInfer multimodal_tokens.py(ImageTokenOffset 与 TokenType)
- DeepInfer ve/multimodal_processor.py(图像占位符展开与 mm 配置校验)
- DeepInfer csrc/engine/outputs.hpp(RequestOutput 与对象池)
- DeepInfer csrc/engine/pybind_output.cpp(RequestOutput.clone 与池化绑定)
- DeepInfer model_executor/models/deepseek_vl.py(图像特殊 token 掩码与 embedding 回填)
- Hugging Face Transformers 文档:Fast tokenizers 的设计
- Sennrich et al., Neural Machine Translation of Rare Words with Subword Units
- Tokenization Disparities as Infrastructure Bias: How Subword Systems Create Inequities in LLM Access and Efficiency
- Next Token Prediction Towards Multimodal Intelligence: A Comprehensive Survey
- DeepSeek-VL2: Mixture-of-Experts Vision-Language Models for Advanced Multimodal Understanding
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



