书接上回,上一章我们已经使用 Unsloth 对 Qwen3-0.6B 模型进行了自动化微调工作。
考虑到算力不足的情况,必须将微调后的模型进行量化后使用。为了方便后续工作的开展,在微调的最后环节我们已经将 LoRA 权重合并到基础模型中,合并后文件如下图所示:
bash
(base) yuanzhenhui@MacBook-Pro FinalBestModelMerged % ls
added_tokens.json config.json merges.txt
special_tokens_map.json tokenizer_config.json chat_template.jinja
generation_config.json model.safetensors tokenizer.json
vocab.json
好了,接下来我们就开始进行量化处理吧。
在开始之前我们需要明确一点。由于生产环境我们是打算使用 CPU 作为算力驱动的。因此,在量化时除了考虑损失精度问题外,还需要从实施难度、可靠程度、推理高效等多个方面综合考虑实施方案。目前能找到的基于 CPU 驱动的可靠实施方案包括: llama.cpp、llamafile、IPEX-LLM、ONNX Runtime、OpenVINO Runtime、Hugging Face Transformers + 插件... 在经过多轮的验证后最终选择 OpenVINO Runtime 方案(这个有点炒冷饭的嫌疑了,关于 OpenVINO Runtime 实现可以参考以下文章 ...(苦笑))。
【AIGC】Mac Intel 本地 LLM 部署经验汇总(CPU Only)
不过这次的 OpenVINO 量化方案我会选择 "OpenVINO IR + IR 量化" 这种方式来实现,前期先以 Weight-only 做 INT4 权重量化,后续还会将其优化成 PTQ 模式,添加 calibration_dataset 做激活校准。因此还是会有干活的...(哈哈)
首先先更新一下最新的 optimum 插件(官方)
bash
python -m pip install --upgrade pip
pip install --upgrade --upgrade-strategy eager "optimum[openvino]"
接下来就开始做配置,留意项目 nlp_cnf.yml 文件中的两个新参数
yaml
models:
reasoning:
...
unsloth_merge_model: <dir>/trained_models/20251010/FinalBestModelMerged
openvino_model: <dir>/trained_models/20251010/OpenVINOModel
其中 unsloth_merge_model 路径是上一章微调后合并的模型目录,而 openvino_model 路径是存放最终 OpenVINO 量化模型的路径。
接下来上代码。
python
class ExportOpenvinoModel:
def __init__(self):
# 初始化函数
def openvino_ir_export(self):
# 将 huggingface transformers 模型导出成 openvino ir 格式
def quantize_openvino(self):
# 量化 openvino 模型
def has_ir(self, dirpath_str: str) -> bool:
# 目录中是否存在 ir 模型
def start_to_export(self):
# 开始导出
if __name__ == "__main__":
eom = ExportOpenvinoModel()
eom.start_to_export()
首先从 start_to_export 开始
python
def start_to_export(self):
# 调用 openvino_ir_export 函数,先将 ir 模型导出
self.openvino_ir_export()
# 判断是否正常导出,若不正常则报错并退出
if not self.exported:
logger.error("EXPORT FAILED. Aborting.")
sys.exit(1)
# 判断 ir 模型是否已经导出成功,若不成功退出
if not self.has_ir(self.openvino_full_model_dir):
logger.error("Warning: export completed but .xml/.bin not found under", self.openvino_full_model_dir)
logger.error("Proceeding to quantize attempt anyway (some optimum versions create wrapped folders).")
sys.exit(2)
# 将 ir 模型量化
self.quantize_openvino()
# 判断量化是否成功
if not self.quant_ok:
logger.error("Automatic (no-calib) quantization failed. Please ensure you have 'optimum[intel]' and 'openvino-dev' installed.")
logger.error("You can still use the exported IR under", self.openvino_full_model_dir)
sys.exit(3)
# 判断新路径下的量化模型是否存在
if not self.has_ir(self.openvino_int4_model_dir):
logger.error("Warning: quantized output dir exists but .xml/.bin not found under", self.openvino_int4_model_dir)
sys.exit(4)
logger.info("Done.")
由上面的代码可以看出,核心的函数分别是 openvino_ir_export 和 quantize_openvino。既然这样,就先看看 openvino_ir_export 做了什么。
python
def openvino_ir_export(self):
try:
# 加载 Unsloth 合并后的 huggingface transformers 模型
load_model = AutoModelForCausalLM.from_pretrained(
self.unsloth_merge_model_dir,
trust_remote_code=TRUST_REMOTE_CODE
)
try:
# 用 optimum.exporters.openvino.export_from_model() 函数导出 OpenVINO 模型
logger.info("Using optimum.exporters.openvino.export_from_model(...) to export OpenVINO IR...")
# 传入导出所需的变量
kwargs = {"task": TASK}
if TRUST_REMOTE_CODE:
kwargs["trust_remote_code"] = True
# 调用 export_from_model() 函数导出模型。export_from_model 函数来自 optimum.exporters.openvino
export_from_model(
model=load_model,
output=self.openvino_full_model_dir,
**kwargs
)
# 模型导出成功后将导出状态设置为 True
self.exported = True
logger.info("export_from_model completed.")
except TypeError:
...
except Exception as e:
...
except Exception as e:
...
当然了,你也可以使用 subprocess 通过命令行的方式使用 optimum-cli export 指令导出 ir 模型,伪代码如下:
python
cmd = [
"optimum-cli", "export",
"--model", self.unsloth_merge_model_dir,
"--format", "openvino",
"--output", self.openvino_full_model_dir,
"--task", TASK
]
if TRUST_REMOTE_CODE:
cmd += ["--trust-remote-code"]
subprocess.run(cmd, check=True)
不过为了更好地贴合 Python 整体的技术栈我用了 export_from_model 而已。
好,接下来就到 quantize_openvino 函数了。
python
def quantize_openvino(self):
try:
# 设置 weight-only 权重量化模式,并量化为 INT4 精度
quantization_config = OVWeightQuantizationConfig(bits=4)
# 这里后续做 PTQ 模式的适配,增加 calibration_dataset 激活校准
# 加载 INT4 精度模型
openvino_int4_model = OVModelForCausalLM.from_pretrained(self.openvino_full_model_dir, quantization_config=quantization_config)
# 将模型保存到新路径 self.openvino_int4_model_dir
openvino_int4_model.save_pretrained(self.openvino_int4_model_dir)
# 设置量化变量为 True
self.quant_ok = True
logger.info("Quantization completed.")
except Exception as e:
logger.error("Quantization failed:", e)
就这么简单。
By the way,ExportOpenvinoModel 类启动之后会自动创建 OpenVINO 所需的文件夹的,并且每次做量化也会自动删除之前的量化模型,可以实现重复操作。实际操作输出如下:
bash
(brain_mix) yuanzhenhui@MacBook-Pro brain-mix % /Users/yuanzhenhui/Documents/anaconda3/envs/brain_mix/bin/python /Users/yuanzhenhui/Documents/code_space/git/processing/sel
f/brain-mix/nlp/models/reasoning/step2_export_openvino_model.py
/Users/yuanzhenhui/Documents/anaconda3/envs/brain_mix/lib/python3.10/site-packages/torch/onnx/_internal/registration.py:167: OnnxExporterWarning: Symbolic function 'aten::scaled_dot_product_attention' already registered for opset 14. Replacing the existing function with new function. This is unexpected. Please report it on https://github.com/pytorch/pytorch/issues.
warnings.warn(
[2025-10-11 17:21:10,352]openvino_ir_export - Using optimum.exporters.openvino.export_from_model(...) to export OpenVINO IR...
`loss_type=None` was set in the config but it is unrecognised.Using the default loss: `ForCausalLMLoss`.
[2025-10-11 17:22:01,336]openvino_ir_export - export_from_model completed.
INFO:nncf:Statistics of the bitwidth distribution:
┍━━━━━━━━━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┑
│ Weight compression mode │ % all parameters (layers) │ % ratio-defining parameters (layers) │
┝━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┥
│ int8_asym │ 26% (1 / 197) │ 0% (0 / 196) │
├───────────────────────────┼─────────────────────────────┼────────────────────────────────────────┤
│ int4_asym │ 74% (196 / 197) │ 100% (196 / 196) │
┕━━━━━━━━━━━━━━━━━━━━━━━━━━━┷━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┷━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┙
Applying Weight Compression ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100% • 0:00:15 • 0:00:00
[2025-10-11 17:22:25,821]quantize_openvino - Quantization completed.
[2025-10-11 17:22:25,970]start_to_export - Done.
至此,量化工作结束。
但是,先别急着走开。这里还有些干货分享给各位的(Q&A)
- 为什么你会选择 OpenVINO 方案?
答:其实基于 CPU 的量化处理目前业界最优的方式应该 GGUF + llama.cpp。但为什么我没有用呢?第一,是因为本机在长期测试后发现使用低参数模型会经常出现输出"乱文"的情况,并且得知要解决这个问题需耗费大量的时间精力后决定放弃。第二,是想保留 Python 技术栈的完整性,brain-mix 项目尽可能保持 Python 风格(算是前程序员的一些小毛病吧)。第三,在 Intel i5 这种低端 CPU 上 llama.cpp 并没有发挥明显优势。
- 为什么我按照你说的将模型做了 OpenVINO 量化了,性能还是无法提升?
答:OpenVINO 方案针对 Intel CPU 做了做了算子级、内存访问与量化(NNCF)优化,生产级支持和工具链(转换、量化、perf 调优)比较完善;在很多 Intel 平台上能显著提速。如果你的 CPU 不是 Intel 的是得不到这种性能优化的。除此之外,还有没有其他的方案呢?有,ONNX Runtime 就是一个不错的选择,但这个又是另外一个故事了。
- 你有找过为什么 llama.cpp 会出现"乱文"吗?
答:当然有啦,总括来说无非是以下 4 种情况:
- Tokenizer/encoding 不匹配:llama.cpp 用的 tokenizer 解释可能和 HF tokenizer 不一致,导致输出是 token id 解码成"乱文"。
- 转换脚本未正确处理 special_tokens 或者字节级 tokenization:一些模型中文模型有自己专门的 tokenizer,若在 convert 步骤中没有正确传入 tokenizer 文件,输出也会崩。
- 转换/量化过程中自定义 operator 丢失:llama.cpp 的转换针对 LLaMA 系列做了很多假定,若模型架构稍有不同(attention layout、norm、rotary 等)就会出错。
- 文本生成解码处理不同:llama.cpp 里如果 sampling/decoding 策略与 HF 不一致(例如 byte-level vs bpe),也会出现这种情况。
这 4 种情况都处理过,问题依然出现,没有办法了先放置一下吧。
- 为什么需要校准数据(calibration_dataset)?代码例子种没有校准也能量化,那么为什么还需要做校准呢?
答:当模型被量化时,我们必须确定如何把连续浮点数(如 [-3.27, 5.88])映射为离散整数(如 int8 ∈ [-128,127])。这需要计算量化比例 scale 与 zero_point。假设要量化激活值:
python
min = -3.0, max = 5.0
scale = (max - min) / (2^8 - 1) ≈ 0.031
zero_point = -min / scale ≈ 97
而这些 min/max 就是通过 calibration dataset 得到的。没有样本数据,框架就无法知道激活值的实际分布范围。所以,校准数据的目的,是"观察"模型在真实输入下的激活统计,从而避免量化后溢出或精度崩坏。
- 我看到你说要做 PTQ 改造,PTQ 是什么?我看 PTQ(Post-Training Quantization)和 QAT(Quantization-Aware Training)经常会被一并提到,他们分别是什么?
答:PTQ(后训练量化) 和 QAT(量化感知训练)是两个模型量化的流派。PTQ 的特点有三,不需要重新训练模型;只需要少量样本做校准(calibration);能够快速、简单,适合生产部署。正如上面第 4 个问题所说,PTQ 在拿到训练好的浮点模型后需要准备一部分样本数据做校准,从而统计激活值、权重的分布范围(min/max 或直方图)。之后通过计算量化比例 scale 与偏移 zero_point,得到浮点模型数值映射的整数。通过这种方式就能够将模型量化成 INT8 或者 INT4。
而 QAT 是在训练时让模型感知量化。它的特点也有三,需要重新训练;模型在训练时模拟量化误差;得到的量化模型精度最高。QAT 在在训练阶段引入"假量化"算子,这些算子在正向传播时模拟量化误差。与此同时,反向传播时梯度仍在浮点域传播,并且模型逐步"学会适应"量化误差,因此最终可以量化出较好的权重。
站在我们真实的场景中,QAT 需要重新训练,而且需要全量的训练数据,因此并不适合我们,我才使用 PTQ 的。
以上代码均发布到 brain-mix 项目中,欢迎各位的指导。
gitee:gitee.com/yzh0623/bra...
github:github.com/yzh0623/bra...
下一章将继续讲解模型推理效果与测试验证,敬请留意。
(未完待续...)