LLM 推理 on NPU:Qwen2.5-0.5B 本地部署
1. 背景
1.1 补齐最后一块拼图
回顾之前的章节:
- RAG(§7):embedding 在 NPU 上,但 LLM 走外部 API——”模型在外面”
- Mini-GPT(§10):手写 Transformer,全在本地,但模型只有 11M 参数——”有模型但太小”
本章的目标是补齐:在 NPU 上运行真正的预训练模型推理。选用 Qwen2.5-0.5B-Instruct:
- 0.5B 参数,~1GB,64GB HBM 完全够
- 中文优化,与 RAG 文档语料匹配
- Qwen2 架构,transformers 4.38.2 原生支持
1.2 为什么是 0.5B
0.5B 参数在 LLM 中属于”极小”级别(GPT-3 是 175B)。但它有一个关键优势:单卡推理无任何优化压力。约 1.2 GB HBM 占用意味着模型加载后还有 62+ GB 空闲,可以同时跑 embedding 模型做 RAG。
更大的模型(7B/14B)需要更精细的显存管理——参见 Qwen2.5-7B FP16 NaN 诊断报告,该文档记录了一次完整的数值稳定性诊断过程:7B 模型在 FP16 下因深层激活值溢出产生 NaN,最终确认是模型在 BF16 训练后迁移到 FP16 NPU 栈时的精度兼容性问题。
2. 自回归生成原理
理解 LLM 推理,首先要把它与前面见过的神经网络推理区分开。
2.1 一次 forward pass vs 多次 forward pass
前面学过的模型(ResNet-50、BGE embedding)都是一次前向传播出结果:输入 → 网络 → 输出。但 LLM 不同——它是自回归(autoregressive)的:
输入: "NPU 是"
→ 第 1 次 forward: logits[最后一个位置] → softmax → 采样 → token "华为"
→ 第 2 次 forward: "NPU 是华为" → ... → token "昇腾"
→ 第 3 次 forward: "NPU 是华为昇腾" → ... → token "的"
→ ...直到生成 eos_token 或达到 max_new_tokens
| 每次 forward pass 都依赖之前生成的所有 token:$$y_t = \arg\max P(y_t | x_1, …, x_n, y_1, …, y_{t-1})$$ |
这意味着生成 256 个 token 需要跑 256 次完整的 forward pass。这就是为什么 LLM 推理比训练一个 batch 的感受慢很多——因为计算是串行的,无法并行。
2.2 为什么只要最后一个位置的 logits
一次 forward pass 的输出是一个矩阵 [seq_len, vocab_size]——每个位置都给出对下一个 token 的预测。但生成时,我们只取最后一个位置的 logits:
next_token_logits = logits[-1, :] # 只取 seq 最后一行的预测
前面位置的预测不需要,因为我们已经有正确答案(prompt 或之前生成的 token)。
2.3 KV Cache:避免重复计算
每次 forward pass 都把完整的序列重新编码——包括已经算过的前缀。这意味着同一个前缀(”NPU 是”)在生成第 1、2、3… 个 token 时被重复计算。
KV Cache 的解决方案:第一次 forward 把每层 Attention 的 Key 和 Value 缓存下来,后续 forward 只对新 token 做 attention,旧 token 直接用缓存。这样每个新 token 只需要 O(1) 的 attention 计算,而非 O(N)。
无 KV Cache: 第 N 步 forward 计算量 ∝ N (整条序列重新算)
有 KV Cache: 第 N 步 forward 计算量 ∝ 1 (只算新 token)
transformers 的 model.generate() 默认使用 KV Cache(配置中 use_cache=True),无需手动处理。这是 LLM 推理能从”不可用”变为”可用”的关键优化。
3. 对话格式与推理实现
3.1 为什么需要 ChatML 模板
预训练模型见过的数据是”裸文本”(网页、书籍、代码),它不知道什么是”对话”。Instruction-tuned 模型在训练时见过特定格式的数据,推理时也必须使用相同格式——否则模型会”迷失方向”。
Qwen2.5-Instruct 使用 ChatML 格式:
<|im_start|>system
You are a helpful assistant.<|im_end|>
<|im_start|>user
什么是 NPU?<|im_end|>
<|im_start|>assistant
← 模型从这里开始生成
ChatML 通过特殊 token(<|im_start|>、<|im_end|>)标记每条消息的边界和角色。与直接拼字符串不同,这些标记是模型词表中的真实 token,有对应的 embedding,模型在训练中学会了它们的行为含义:
<|im_start|>后的内容标识消息角色(system/user/assistant)<|im_end|>表示该条消息结束<|im_start|>assistant\n是生成开始的信号——模型看到它就”知道”该说话了
如果不用 ChatML,直接用 "你好,请回答问题" 作为 prompt,对 instruction-tuned 模型来说相当于”裸文本续写”,输出会不可控。这在 0.5B 上表现为回答质量差,在 7B 上则可能完全崩溃。
tokenizer.apply_chat_template() 自动完成这个格式化:
messages = [
{"role": "system", "content": "你是一个有帮助的助手。"},
{"role": "user", "content": "什么是 NPU?"},
]
# add_generation_prompt=True 在末尾追加 <|im_start|>assistant\n
text = tokenizer.apply_chat_template(messages, tokenize=False,
add_generation_prompt=True)
3.2 采样策略:temperature、top-p、top-k
模型输出的 logits 经过 softmax 后得到概率分布。如何从这个分布中”选择一个 token”?
Greedy Decoding(贪婪解码):
next_token = argmax(logits) # 永远选概率最高的
问题:总是选最高概率的 token,输出往往无聊、重复、缺乏变化。一旦进入循环,永远出不来。
Temperature:
probs = softmax(logits / temperature)
temperature → 0:分布强烈尖锐→趋近贪婪解码→输出确定但保守temperature = 0.7:适中平滑→保留随机性但不失控temperature → ∞:分布趋于均匀→纯随机→完全不可用
temperature 不改变哪个 token 得分最高,只改变概率分布的”集中程度”。
Top-p (Nucleus Sampling):
1. 按概率从高到低排序所有 token
2. 从最高开始累加概率,直到累计 > p(如 0.9)
3. 只在这个"核心集合"中采样,其余 token 概率置 0
top-p 动态决定了”候选集”的大小——分布集中时候选少,分布分散时候选多。
Top-k:
只保留概率最高的 K 个 token,其余概率置 0,重新归一化
top-k 简单但粗暴:K 太小(如 5)会让分布尖锐时丢失合理选项,K 太大(如 100)会让分布平坦时混入噪声。top-p 更灵活,实践中常用 top-p + temperature 组合。
3.3 代码实现
# 环境:复用 rag-env(transformers 4.38.2 + torch_npu 2.1.0),无需新建 venv
from transformers import AutoModelForCausalLM, AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2.5-0.5B-Instruct",
torch_dtype=torch.float16, # FP16 加载,~1GB HBM
).to("npu:0").eval()
# 构建对话
messages = [{"role": "user", "content": "什么是 NPU?"}]
text = tokenizer.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True,
)
inputs = tokenizer(text, return_tensors="pt").to("npu:0")
input_len = inputs.input_ids.shape[1]
# 自回归生成
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=256,
temperature=0.7, # 控制随机性
do_sample=True, # temperature>0 时启用采样
top_p=0.9, # 核心采样
pad_token_id=tokenizer.eos_token_id,
)
answer = tokenizer.decode(outputs[0][input_len:], skip_special_tokens=True)
4. 性能数据
测试环境:Ascend 910B3, CANN 8.0.1, NPU 7, FP16。
| 指标 | 数值 |
|---|---|
| 参数量 | 494M |
| 模型加载时间 | 9s(缓存后) |
| HBM 占用 | 1.1 GB(稳态), 1.2 GB(峰值) |
| 生成速度 | 18.7-20.7 tok/s |
| 128 token 生成耗时 | ~6-7s |
推理示例:
Prompt: 什么是深度学习?
回答: 深度学习是一种机器学习的分支,它利用神经网络结构来自动发现
数据中的模式和结构。与传统的基于特征的学习方式不同,深度学习能...
Prompt: 介绍一下华为昇腾 NPU 的特点。
回答: 华为昇腾 NPU 是一种面向深度学习应用的专用芯片,具有以下主要特点:
1. 高性能:昇腾 NPU 采用了先进的神经网...
0.5B 模型的知识面有限——对于具体技术问题只能给出泛泛的描述。这是模型能力的天花板,而非 NPU 推理的问题。
5. 与 RAG 的集成
本章的 LLM 推理已与 RAG pipeline 完成对接:rag_pipeline.py 中的 LocalLLMClient 类封装了本地模型加载与推理,通过 --local 参数启用。
# rag_pipeline.py 中的对接方式
class LocalLLMClient:
def chat(self, messages, temperature=0.3, max_tokens=512):
text = tokenizer.apply_chat_template(messages, tokenize=False,
add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to("npu:0")
outputs = model.generate(**inputs, max_new_tokens=max_tokens, ...)
return tokenizer.decode(...)
全链路本地化的 RAG:
文档 → NPU embedding (BGE) → FAISS 检索 → NPU LLM (Qwen 0.5B) → 回答
全程无需网络、无需 API Key,延迟完全可控。
6. 代码结构
11_llm_inference/
├── README.md
├── 01_llm_inference_on_npu.md # 本文
├── 02_fp16_nan_debug.md # 7B FP16 NaN 诊断报告
└── llm_inference.py # LLM 推理脚本(~220 行)
├── load_model() — 加载模型 + tokenizer(含 NPU 可用性检查)
├── run_inference() — 单次推理
├── interactive_chat() — 交互对话
├── benchmark() — 性能测试
└── main() — CLI(infer / chat / benchmark)
7. 后续扩展
- 量化推理:INT8/INT4 量化,将 7B 模型的 HBM 占用从 14GB 降至 7GB/3.5GB,同时可能缓解 FP16 溢出问题(量化过程引入的缩放因子有助于数值稳定)
- 大模型推理:当前 CANN 8.0.1 + torch_npu 2.1.0 栈上 7B 模型需要 FP32 加载(~29.4 GB HBM),升级 CANN 后可用 FP16/BF16
- 流式输出:使用
TextIteratorStreamer逐 token 返回,实现打字机效果 - batching 多路推理:同时处理多个请求,提升 NPU 利用率