3.2 配置、入口与一次 generate 的全流程
从 example.py 出发,拆解 Config / SamplingParams / LLM / LLMEngine,看懂 generate 主循环如何驱动 add_request、step 与 postprocess
📑 目录
- 1. 从一段示例代码进入
- 2. Config:全局配置的”宪法”
- 3. SamplingParams:采样超参
- 4. LLM 与 LLMEngine:薄封装背后的初始化
- 5. 主循环:add_request → step → postprocess
- 总结
- 自我检验清单
- 参考资料
1. 从一段示例代码进入
读引擎最好的入口,是看它「怎么被用」。仓库里的 example.py 浓缩了全部对外 API:
from nanovllm import LLM, SamplingParams
# 1) 构造引擎:传入本地模型路径与若干全局开关
llm = LLM("/your/model/path", enforce_eager=True, tensor_parallel_size=1)
# 2) 定义采样策略
sampling_params = SamplingParams(temperature=0.6, max_tokens=256)
# 3) 批量生成
prompts = ["Hello, nano-vllm."]
outputs = llm.generate(prompts, sampling_params)
# 4) 取结果:每个元素是 {"text": ..., "token_ids": ...}
print(outputs[0]["text"])
四步就跑通了一次推理。注意两个细节:
- 构造
LLM时第一个参数是本地目录路径(不是 HuggingFace 仓库名),因为 nano-vllm 直接用safetensors加载本地权重,不负责下载。 generate返回的是 dict 列表(含"text"和"token_ids"),这是它与 vLLM 接口的一个小差异——vLLM 返回的是RequestOutput对象。
⚠️ 注意:示例里 apply_chat_template 通常在业务脚本里手动调用。和第 1 章讲的一样,直接把裸文本喂给 Chat 模型会导致输出质量下降;分词与模板是「引擎之外」的职责。
接下来我们顺着这四步往里看:SamplingParams 和 Config 是什么,LLM 构造时发生了什么,generate 内部又是怎样一个循环。
2. Config:全局配置的”宪法”
config.py 里的 Config 是一个 @dataclass(slots=True),它把引擎运行需要的全局约束集中在一处。核心字段与默认值如下(示意,省略类型别名):
@dataclass(slots=True)
class Config:
model: str # 本地模型目录(必填)
max_num_batched_tokens: int = 16384 # 单步最多处理的 token 总数(Token Budget)
max_num_seqs: int = 512 # 单步最多并发的序列数
max_model_len: int = 4096 # 支持的最大上下文长度
gpu_memory_utilization: float = 0.9 # 允许占用的显存比例
tensor_parallel_size: int = 1 # 张量并行 GPU 数
enforce_eager: bool = False # True 则禁用 CUDA Graph
hf_config = None # 延迟加载的 HuggingFace 配置
eos: int = -1 # EOS token id(初始化后由 tokenizer 填入)
kvcache_block_size: int = 256 # 每个 KV 块容纳的 token 数
num_kvcache_blocks: int = -1 # KV 块总数(运行时按显存推算)
构造后 __post_init__ 会做三条硬校验,并把上下文长度对齐到模型能力:
def __post_init__(self):
assert os.path.isdir(self.model) # 必须是存在的本地目录
assert self.kvcache_block_size % 256 == 0 # 块大小需 256 的整数倍
assert 1 <= self.tensor_parallel_size <= 8 # TP 度在 1~8 之间
self.hf_config = AutoConfig.from_pretrained(self.model)
self.max_model_len = min(self.max_model_len,
self.hf_config.max_position_embeddings)
几个值得停下来想一想的设计:
- 🔑
kvcache_block_size = 256:这是「每个 KV 块装多少个 token」。块越大,块表(Block Table)越短、管理开销越小,但内部碎片(最后一块没填满的浪费)越大;块越小则相反。256 是在碎片与管理开销之间取的折中,% 256 == 0的断言也和底层内核的对齐要求配套。
⏰ 信息时效:这里的
256是 nano-vllm 当前默认值,和 vLLM 常见的block_size=16并不相同——不要把两者的默认值混为一谈。
- 🔑
max_num_batched_tokens与max_num_seqs:前者限制「一步最多算多少 token」(主要约束 prefill),后者限制「一步最多有多少条序列」(主要约束 decode)。它们共同构成调度器的 Token Budget,是 3.5 的主角。 - 🔑
gpu_memory_utilization = 0.9:引擎不会按最大长度预留 KV,而是先加载权重、warmup 测峰值,再把「显存配额 − 权重 − 峰值开销」全部切成 KV 块(num_kvcache_blocks因此初始为-1,运行时才算出)。这正是高显存利用率的来源,3.6 会看到具体公式。
3. SamplingParams:采样超参
相比 vLLM 动辄几十个采样参数,nano-vllm 的 SamplingParams 极其克制,只有三个字段:
@dataclass(slots=True)
class SamplingParams:
temperature: float = 1.0
max_tokens: int = 64
ignore_eos: bool = False
def __post_init__(self):
assert self.temperature > 1e-10, "greedy sampling is not permitted"
temperature:温度,控制采样随机性。max_tokens:最多生成多少个 token,默认64(示例里显式设成了256)。它既是停止条件,也用于 benchmark 时固定生成量。ignore_eos:是否忽略 EOS 继续生成,压测时常设为True保证每条序列都跑满max_tokens,吞吐数据才可比。
📌 关键点:__post_init__ 直接断言温度必须大于 1e-10——nano-vllm 不支持贪心采样(temperature=0)。这是一个有意的简化:它的 Sampler 走的是统一的随机采样路径(见 3.3),没有为 temperature=0 单独写一条 argmax 分支,所以干脆在入口处挡掉。想要确定性输出,就把温度设到一个极小的正数。
4. LLM 与 LLMEngine:薄封装背后的初始化
LLM 这个类本身几乎没有逻辑——它是 LLMEngine 的薄封装,把 generate 等方法直接暴露给用户。真正干活的是 LLMEngine.__init__,它在构造时完成了四件大事:
class LLMEngine:
def __init__(self, model, **kwargs):
# 1) 从 kwargs 里挑出 Config 认识的字段,构造全局配置
config_fields = {f.name for f in fields(Config)}
config = Config(model, **{k: v for k, v in kwargs.items()
if k in config_fields})
# 把块大小同步给 Sequence(它用类变量记 block_size)
Sequence.block_size = config.kvcache_block_size
# 2) 张量并行:用 spawn 启动 rank>0 的 worker 子进程
ctx = mp.get_context("spawn")
self.ps, self.events = [], []
for rank in range(1, config.tensor_parallel_size):
event = ctx.Event()
p = ctx.Process(target=ModelRunner, args=(config, rank, event))
p.start()
self.ps.append(p); self.events.append(event)
# rank 0 的 ModelRunner 跑在主进程
self.model_runner = ModelRunner(config, 0, self.events)
# 3) 分词器只在主进程持有,并把 EOS 回填到 config
self.tokenizer = AutoTokenizer.from_pretrained(config.model, use_fast=True)
config.eos = self.tokenizer.eos_token_id
# 4) 调度器 + 退出清理
self.scheduler = Scheduler(config)
atexit.register(self.exit)
这里有几个工程上的讲究:
- 🔑 为什么用
spawn而不是默认的fork:CUDA 上下文不能被fork安全继承(子进程会拿到一个已初始化但不可用的 CUDA 状态)。spawn启动的是干净的新进程,各自初始化自己的 GPU。 - 🔑 主从进程靠
Event做栅栏:rank>0的ModelRunner在子进程里循环等待任务,主进程通过共享内存下发方法名与参数,用Event通知「可以读了」。张量并行的协同细节放在 3.6。 - 分词器只在主进程:
detokenize是 CPU 任务,没必要在每个 TP rank 都跑一份。 atexit清理:退出时给子进程发"exit",join回收,避免僵尸进程。
💡 提示:Sequence.block_size = config.kvcache_block_size 这一行容易被忽略——Sequence 用一个类变量记录块大小,所以所有序列共享同一个值,不必每个实例都存一份。
5. 主循环:add_request → step → postprocess
终于到了引擎的心脏。generate 的骨架可以提炼成下面这样(去掉了 tqdm 进度条与吞吐统计):
def generate(self, prompts, sampling_params):
# 广播:单个 SamplingParams 复制给每条 prompt
for prompt, sp in zip(prompts, sampling_params):
self.add_request(prompt, sp)
outputs = {}
while not self.is_finished():
finished, num_tokens = self.step() # 跑一步,拿回本步完成的序列
for seq_id, token_ids in finished:
outputs[seq_id] = token_ids
# 按 seq_id 排序后解码成文本
outputs = [outputs[i] for i in sorted(outputs)]
return [{"text": self.tokenizer.decode(ids), "token_ids": ids}
for ids in outputs]
两个关键方法:
add_request:把一条 prompt 变成一个 Sequence 放进调度器。prompt 可以是字符串(内部先 encode)也可以是现成的 token id 列表:
def add_request(self, prompt, sampling_params):
if isinstance(prompt, str):
prompt = self.tokenizer.encode(prompt)
self.scheduler.add(Sequence(prompt, sampling_params))
step:一次完整的「调度 → 前向 → 写回」:
def step(self):
seqs, is_prefill = self.scheduler.schedule() # 选 batch
token_ids = self.model_runner.call("run", seqs, is_prefill) # 跑模型 + 采样
self.scheduler.postprocess(seqs, token_ids, is_prefill) # 写回、判断结束
outputs = [(seq.seq_id, seq.completion_token_ids)
for seq in seqs if seq.is_finished]
num_tokens = sum(seq.num_scheduled_tokens for seq in seqs) if is_prefill else -len(seqs)
return outputs, num_tokens
📌 关键点:注意 num_tokens 的符号编码——prefill 时返回正数(这一步处理的 token 总量),decode 时返回负的 batch 大小(-len(seqs))。generate 就靠这个符号区分当前是 prefill 还是 decode 阶段,分别计算并显示 Prefill / Decode 两种吞吐(tok/s)。用一个数的正负同时编码「阶段」和「数量」,是个很省事的小技巧。
整个循环的分工一目了然:model_runner.call("run", ...) 负责「怎么算」,scheduler 负责「算谁、算完怎么办」。下面三节分别放大执行层(3.3)、KV 管理(3.4)和调度层(3.5),最后 3.6 回到 ModelRunner 把它们重新串起来。
📝 总结
example.py展示了全部对外 API:LLM+SamplingParams+generate,返回含"text"的 dict 列表。Config是@dataclass(slots=True),集中管理显存比例、Token Budget、TP 度、KV 块大小,并在__post_init__做硬校验、对齐上下文长度。SamplingParams只有三个字段,且断言温度 >1e-10——不支持贪心采样。LLMEngine.__init__用spawn启动 TP worker、只在主进程持分词器、注册atexit清理。- 主循环
generate → add_request → step → postprocess中,step用num_tokens的正负区分 prefill / decode 阶段。
🎯 自我检验清单
- 能用
LLM和SamplingParams写出一段最简推理代码,并取出outputs[0]["text"] - 能说出
Config的max_num_batched_tokens、max_num_seqs、gpu_memory_utilization、kvcache_block_size各自约束什么 - 能解释 nano-vllm 为什么不支持贪心采样
- 能说清
LLMEngine为什么用spawn而非fork启动张量并行进程 - 能复述
step()的三步,并解释num_tokens符号编码的用途