LLM 后训练实践
第5课:压缩部署与扩展

第5课 上机实验

量化实验(必做):多精度加载 Qwen3-8B 并评估压缩影响;微调后量化(选做):LoRA 合并与静态量化部署;能力扩展选做(三选一):蒸馏分析、多模态实验、工具调用实验

实验概览

本课实验分为三部分:

部分内容时长要求
实验 A(必做)量化实验——测量压缩对模型质量的影响~60 分钟全部完成
实验 B(选做)微调后量化——LoRA 合并 → 静态量化 → 部署评估~50 分钟推荐完成
实验 C(选做)能力扩展——蒸馏/多模态/工具使用三选一~50 分钟完成一项

预计总计算时间:A100-40G 约 80-130 分钟。


实验 A:量化实验(必做)

实验目标

  • 以 FP16、INT8、INT4 三种精度加载 Qwen3-8B,对比显存占用和加载时间
  • 测量不同精度下的推理速度(tokens/sec、首 token 延迟)
  • 在 GSM8K 数学推理、指令跟随、中文任务上评估量化对质量的影响
  • 使用 LLM-as-Judge 进行系统化评分

环境准备

# 安装依赖
# pip install "transformers>=4.51.0" bitsandbytes accelerate torch datasets

import torch
import time
import json
import gc
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig

# 验证 GPU
print(f"GPU: {torch.cuda.get_device_name(0)}")
print(f"GPU 显存: {torch.cuda.get_device_properties(0).total_mem / 1024**3:.1f} GB")

Step 1:多精度加载对比(20 分钟)

定义加载函数

MODEL_NAME = "Qwen/Qwen3-8B"  # 或本地路径

def load_model(precision="fp16"):
    """以指定精度加载模型,返回模型和元信息"""
    torch.cuda.empty_cache()
    gc.collect()
    torch.cuda.reset_peak_memory_stats()

    start_time = time.time()

    if precision == "fp16":
        model = AutoModelForCausalLM.from_pretrained(
            MODEL_NAME,
            torch_dtype=torch.float16,
            device_map="auto",
        )
    elif precision == "int8":
        config = BitsAndBytesConfig(load_in_8bit=True)
        model = AutoModelForCausalLM.from_pretrained(
            MODEL_NAME,
            quantization_config=config,
            device_map="auto",
        )
    elif precision == "int4":
        config = BitsAndBytesConfig(
            load_in_4bit=True,
            bnb_4bit_quant_type="nf4",
            bnb_4bit_use_double_quant=True,
            bnb_4bit_compute_dtype=torch.bfloat16,
        )
        model = AutoModelForCausalLM.from_pretrained(
            MODEL_NAME,
            quantization_config=config,
            device_map="auto",
        )
    else:
        raise ValueError(f"不支持的精度: {precision}")

    load_time = time.time() - start_time
    memory_gb = torch.cuda.max_memory_allocated() / 1024**3

    return model, {
        "precision": precision,
        "load_time_s": round(load_time, 1),
        "memory_gb": round(memory_gb, 2),
    }

依次加载三种精度并记录数据

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

results = {}
for precision in ["fp16", "int8", "int4"]:
    print(f"\n{'='*50}")
    print(f"正在加载 {precision.upper()} 模型...")
    model, info = load_model(precision)
    results[precision] = info
    print(f"  加载时间: {info['load_time_s']}s")
    print(f"  显存占用: {info['memory_gb']} GB")

    # 简单验证:生成一条回复
    test_input = tokenizer("你好,请自我介绍一下。", return_tensors="pt").to(model.device)
    with torch.no_grad():
        output = model.generate(**test_input, max_new_tokens=50, do_sample=False)
    print(f"  验证回复: {tokenizer.decode(output[0], skip_special_tokens=True)[:100]}")

    del model
    torch.cuda.empty_cache()
    gc.collect()

# 打印对比表
print("\n" + "="*60)
print(f"{'精度':<10} {'显存(GB)':<12} {'加载时间(s)':<14} {'相对FP16'}")
print("-"*60)
fp16_mem = results['fp16']['memory_gb']
for p, info in results.items():
    ratio = info['memory_gb'] / fp16_mem
    print(f"{p.upper():<10} {info['memory_gb']:<12} {info['load_time_s']:<14} {ratio:.2f}x")

Step 2:推理速度测试(15 分钟)

TEST_PROMPTS = [
    "请解释什么是量子计算?",
    "用Python写一个快速排序算法。",
    "请将以下中文翻译成英文:人工智能正在深刻改变我们的生活方式。",
    "写一首关于春天的七言绝句。",
    "请解释相对论的核心思想。",
    "什么是知识蒸馏?请用简单的语言解释。",
    "请列出机器学习的五个主要应用领域。",
    "解释什么是梯度下降法。",
    "为什么大语言模型需要后训练?",
    "请比较 Python 和 Java 的主要区别。",
]

def benchmark_inference(model, tokenizer, prompts, max_new_tokens=128):
    """测量推理速度"""
    total_tokens = 0
    first_token_latencies = []

    for prompt in prompts:
        messages = [{"role": "user", "content": prompt}]
        text = tokenizer.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )
        inputs = tokenizer(text, return_tensors="pt").to(model.device)
        input_len = inputs["input_ids"].shape[1]

        # 测量首 token 延迟
        start = time.time()
        with torch.no_grad():
            output = model.generate(
                **inputs, max_new_tokens=1, do_sample=False
            )
        first_token_time = time.time() - start
        first_token_latencies.append(first_token_time)

        # 测量完整生成速度
        start = time.time()
        with torch.no_grad():
            output = model.generate(
                **inputs, max_new_tokens=max_new_tokens, do_sample=False
            )
        gen_time = time.time() - start
        gen_tokens = output.shape[1] - input_len
        total_tokens += gen_tokens

    avg_first_token = sum(first_token_latencies) / len(first_token_latencies)
    tokens_per_sec = total_tokens / sum(
        [time.time() - start for _ in range(1)]  # 简化计算
    )

    return {
        "avg_first_token_latency_ms": round(avg_first_token * 1000, 1),
        "total_tokens": total_tokens,
    }

# 对每种精度运行速度测试
speed_results = {}
for precision in ["fp16", "int8", "int4"]:
    print(f"\n测试 {precision.upper()} 推理速度...")
    model, _ = load_model(precision)

    # 预热
    warmup_input = tokenizer("hello", return_tensors="pt").to(model.device)
    with torch.no_grad():
        model.generate(**warmup_input, max_new_tokens=10)

    # 正式测试
    start_total = time.time()
    total_gen_tokens = 0
    first_latencies = []

    for prompt in TEST_PROMPTS:
        messages = [{"role": "user", "content": prompt}]
        text = tokenizer.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )
        inputs = tokenizer(text, return_tensors="pt").to(model.device)
        input_len = inputs["input_ids"].shape[1]

        # 首 token
        t0 = time.time()
        with torch.no_grad():
            out1 = model.generate(**inputs, max_new_tokens=1, do_sample=False)
        first_latencies.append(time.time() - t0)

        # 完整生成
        with torch.no_grad():
            out_full = model.generate(
                **inputs, max_new_tokens=128, do_sample=False
            )
        total_gen_tokens += out_full.shape[1] - input_len

    total_time = time.time() - start_total

    speed_results[precision] = {
        "tokens_per_sec": round(total_gen_tokens / total_time, 1),
        "avg_first_token_ms": round(
            sum(first_latencies) / len(first_latencies) * 1000, 1
        ),
    }

    print(f"  tokens/s: {speed_results[precision]['tokens_per_sec']}")
    print(f"  首token延迟: {speed_results[precision]['avg_first_token_ms']}ms")

    del model
    torch.cuda.empty_cache()
    gc.collect()

Step 3:质量评估(25 分钟)

# === 评估数据集 ===

# 1. GSM8K 数学推理(5题)
gsm8k_problems = [
    {
        "question": "小明有15个苹果,他给了小红3个,又买了7个。他现在有多少个苹果?",
        "answer": 19
    },
    {
        "question": "一个教室有35个学生,其中男生比女生多5人。男生有多少人?",
        "answer": 20
    },
    {
        "question": "火车以每小时120公里的速度行驶,3.5小时能走多少公里?",
        "answer": 420
    },
    {
        "question": "商店里一件衣服原价200元,打八折后又减30元,最终价格是多少?",
        "answer": 130
    },
    {
        "question": "一个长方形的长是12厘米,宽是8厘米。它的周长和面积分别是多少?",
        "answer": "周长40厘米,面积96平方厘米"
    },
]

# 2. 指令跟随(5条)
instruction_prompts = [
    "请用恰好三个词概括机器学习。",
    "请以JSON格式输出以下信息:姓名张三,年龄25,职业工程师。",
    "请列出5个中国历史朝代,每个朝代用一句话描述其特点。",
    "请将以下句子改写为疑问句:大语言模型正在改变世界。",
    "请写一段不超过50字的产品描述,产品是一款智能手表。",
]

# 3. 中文理解/生成(5条)
chinese_prompts = [
    "请解释成语'画龙点睛'的含义,并造一个句子。",
    "请为一家新开的咖啡店写一段开业宣传语,风格温馨文艺。",
    "请分析鲁迅《狂人日记》的主题思想。",
    "请比较唐诗和宋词在风格上的主要差异。",
    "请用通俗的语言解释'内卷'这个网络用语的含义。",
]


def evaluate_model(model, tokenizer, prompts, max_new_tokens=256):
    """生成模型回复"""
    responses = []
    for prompt in prompts:
        if isinstance(prompt, dict):
            content = prompt["question"]
        else:
            content = prompt

        messages = [{"role": "user", "content": content}]
        text = tokenizer.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )
        inputs = tokenizer(text, return_tensors="pt").to(model.device)

        with torch.no_grad():
            output = model.generate(
                **inputs,
                max_new_tokens=max_new_tokens,
                do_sample=False,
                temperature=1.0,
            )
        response = tokenizer.decode(
            output[0][inputs["input_ids"].shape[1]:],
            skip_special_tokens=True
        )
        responses.append(response)

    return responses


# 收集所有精度的回复
all_responses = {}
for precision in ["fp16", "int8", "int4"]:
    print(f"\n评估 {precision.upper()} 模型...")
    model, _ = load_model(precision)

    all_responses[precision] = {
        "gsm8k": evaluate_model(model, tokenizer, gsm8k_problems),
        "instruction": evaluate_model(model, tokenizer, instruction_prompts),
        "chinese": evaluate_model(model, tokenizer, chinese_prompts),
    }

    del model
    torch.cuda.empty_cache()
    gc.collect()

# 保存回复用于后续 LLM-as-Judge 评分
with open("quantization_responses.json", "w", encoding="utf-8") as f:
    json.dump(all_responses, f, ensure_ascii=False, indent=2)

Step 4:LLM-as-Judge 评分

# 使用强模型(如 Qwen3-32B API)进行评分
# 如果没有 API,可以用本地加载的 FP16 模型作为 Judge

JUDGE_PROMPT_TEMPLATE = """请你作为一个公正的评委,对以下AI助手的回复进行评分。

评分标准(1-10分):
- 准确性:回复内容是否正确
- 完整性:是否充分回答了问题
- 格式规范:是否遵循了指令要求的格式
- 语言质量:表达是否清晰、自然

用户问题:
{question}

AI回复:
{response}

请给出评分(1-10分)和简要评语。格式:
评分:X/10
评语:...
"""

def judge_responses(questions, responses_by_precision, judge_model=None):
    """使用 LLM-as-Judge 评分"""
    scores = {p: [] for p in responses_by_precision}

    for i, question in enumerate(questions):
        q_text = question["question"] if isinstance(question, dict) else question

        for precision, responses in responses_by_precision.items():
            prompt = JUDGE_PROMPT_TEMPLATE.format(
                question=q_text,
                response=responses[i]
            )

            # 方式1:调用 API
            # score = call_judge_api(prompt)

            # 方式2:使用本地模型
            if judge_model:
                messages = [{"role": "user", "content": prompt}]
                text = tokenizer.apply_chat_template(
                    messages, tokenize=False, add_generation_prompt=True
                )
                inputs = tokenizer(text, return_tensors="pt").to(judge_model.device)
                with torch.no_grad():
                    output = judge_model.generate(
                        **inputs, max_new_tokens=200, do_sample=False
                    )
                judge_response = tokenizer.decode(
                    output[0][inputs["input_ids"].shape[1]:],
                    skip_special_tokens=True
                )
                # 从评分中提取分数
                import re
                match = re.search(r'评分[::]\s*(\d+)', judge_response)
                score = int(match.group(1)) if match else 5
                scores[precision].append(score)

    return scores

# 运行评估(使用 FP16 模型作为 Judge)
print("加载 Judge 模型(FP16)...")
judge_model, _ = load_model("fp16")

for task_name, questions in [
    ("gsm8k", gsm8k_problems),
    ("instruction", instruction_prompts),
    ("chinese", chinese_prompts),
]:
    task_responses = {p: all_responses[p][task_name] for p in all_responses}
    scores = judge_responses(questions, task_responses, judge_model)

    print(f"\n{task_name} 评分结果:")
    print(f"{'精度':<10} {'平均分':<10} {'各题分数'}")
    for p, s in scores.items():
        avg = sum(s) / len(s) if s else 0
        print(f"{p.upper():<10} {avg:<10.1f} {s}")

结果记录模板

# 汇总所有结果
print("\n" + "="*70)
print("量化实验结果汇总")
print("="*70)
print(f"\n{'指标':<20} {'FP16':<15} {'INT8':<15} {'INT4':<15}")
print("-"*65)
print(f"{'显存 (GB)':<20} {results['fp16']['memory_gb']:<15} "
      f"{results['int8']['memory_gb']:<15} {results['int4']['memory_gb']:<15}")
print(f"{'加载时间 (s)':<20} {results['fp16']['load_time_s']:<15} "
      f"{results['int8']['load_time_s']:<15} {results['int4']['load_time_s']:<15}")
print(f"{'tokens/s':<20} {speed_results['fp16']['tokens_per_sec']:<15} "
      f"{speed_results['int8']['tokens_per_sec']:<15} "
      f"{speed_results['int4']['tokens_per_sec']:<15}")
print(f"{'首token延迟 (ms)':<20} {speed_results['fp16']['avg_first_token_ms']:<15} "
      f"{speed_results['int8']['avg_first_token_ms']:<15} "
      f"{speed_results['int4']['avg_first_token_ms']:<15}")

实验 B:微调后量化与部署(选做)

量化实验的延伸——将前序课程微调的模型进行静态量化,对比量化前后的质量损失,并完成从训练到部署的完整闭环。

实验目标

项目内容
前置依赖第2-4课任一微调实验的 LoRA adapter
实验目标掌握"微调 → 合并 → 静态量化 → 部署"全流程
预计时长~50 分钟
硬件要求A100-40G 或同等 GPU

与实验 A 的关系:实验 A 测试的是推理时动态量化(bitsandbytes 在加载时即时量化),本实验做的是离线静态量化(GPTQ/AWQ 预先量化并保存权重)。静态量化是生产部署的标准做法,量化后的模型可以直接分发,无需原始 FP16 权重。

背景:从训练到部署的完整链路

为什么需要静态量化而不是直接用 bitsandbytes?

  • bitsandbytes 的动态量化每次加载都要重新量化,启动慢
  • 静态量化(GPTQ/AWQ)使用校准数据集优化量化参数,精度更高
  • 静态量化的模型可以被 vLLM、TGI、llama.cpp 等推理框架直接加载
  • 量化后的模型文件可以直接上传 HuggingFace Hub 供他人使用

实验步骤

环境准备

# 安装依赖(在实验 A 的基础上额外安装)
# pip install peft auto-gptq optimum autoawq

import torch
import time
import json
import gc
from pathlib import Path
from transformers import AutoModelForCausalLM, AutoTokenizer

# 验证 GPU
print(f"GPU: {torch.cuda.get_device_name(0)}")
print(f"GPU 显存: {torch.cuda.get_device_properties(0).total_mem / 1024**3:.1f} GB")

Step 1:合并 LoRA Adapter(10 分钟)

如果你在第2-4课使用 LoRA/QLoRA 进行了微调,需要先将 adapter 合并回基座模型,得到一个完整的 FP16 模型。

from peft import PeftModel, PeftConfig
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

# ============================
# 配置区:请根据你的实际情况修改
# ============================
BASE_MODEL_NAME = "Qwen/Qwen3-8B"          # 微调时使用的基座模型
ADAPTER_PATH = "./my-sft-adapter"           # 你的 LoRA adapter 路径
MERGED_OUTPUT_PATH = "./qwen3-8b-sft-merged"  # 合并后模型的保存路径

# Step 1.1:加载基座模型(在 CPU 上操作以节省显存)
print("正在加载基座模型...")
base_model = AutoModelForCausalLM.from_pretrained(
    BASE_MODEL_NAME,
    torch_dtype=torch.float16,
    device_map="cpu",  # 合并在 CPU 上做,避免显存不足
)
tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL_NAME)
print(f"基座模型加载完成,参数量: {sum(p.numel() for p in base_model.parameters()) / 1e9:.2f}B")

# Step 1.2:加载 LoRA adapter
print("正在加载 LoRA adapter...")
peft_config = PeftConfig.from_pretrained(ADAPTER_PATH)
print(f"LoRA 配置: r={peft_config.r}, alpha={peft_config.lora_alpha}, "
      f"目标模块={peft_config.target_modules}")

model = PeftModel.from_pretrained(base_model, ADAPTER_PATH)

# 查看可训练参数比例
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
total = sum(p.numel() for p in model.parameters())
print(f"可训练参数: {trainable / 1e6:.1f}M / {total / 1e6:.1f}M "
      f"({trainable / total * 100:.2f}%)")

# Step 1.3:合并并卸载 adapter
print("正在合并 LoRA 权重到基座模型...")
merged_model = model.merge_and_unload()

# Step 1.4:保存合并后的完整模型
print(f"正在保存合并后的模型到 {MERGED_OUTPUT_PATH}...")
merged_model.save_pretrained(MERGED_OUTPUT_PATH)
tokenizer.save_pretrained(MERGED_OUTPUT_PATH)

# 验证保存的文件
saved_files = list(Path(MERGED_OUTPUT_PATH).glob("*"))
total_size = sum(f.stat().st_size for f in saved_files if f.is_file()) / 1024**3
print(f"保存完成!文件数: {len(saved_files)}, 总大小: {total_size:.2f} GB")

# 释放内存
del base_model, model, merged_model
gc.collect()

没有 adapter 怎么办? 如果你没有完成前序课程的微调实验,可以:1) 直接使用 Qwen/Qwen3-8B 跳过合并步骤,从 Step 2 开始;2) 使用 HuggingFace Hub 上的社区微调模型;3) 快速用第2课的代码做一个简单的 SFT(10 分钟即可)。

Step 2:静态量化(20 分钟)

提供 GPTQ 和 AWQ 两种方案,选择其中一种完成即可。

准备校准数据集

静态量化需要一批"校准数据"来确定最优的量化参数。校准数据应该和你的实际使用场景尽量接近。

from transformers import AutoTokenizer

MODEL_PATH = "./qwen3-8b-sft-merged"  # 或 BASE_MODEL_NAME(如果跳过了 Step 1)
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)

# 校准数据:覆盖多种任务类型
calibration_texts = [
    # 数学推理
    "小明有15个苹果,他给了小红3个,又买了7个。请问他现在有多少个苹果?让我们一步步思考:首先小明有15个苹果,给出3个后剩12个,再买7个,所以是12+7=19个。",
    "一个教室有35个学生,其中男生比女生多5人。设女生有x人,则男生有x+5人,x+(x+5)=35,解得x=15,所以男生有20人。",
    # 指令跟随
    "请以JSON格式输出以下信息:姓名张三,年龄25,职业工程师。\n```json\n{\"name\": \"张三\", \"age\": 25, \"occupation\": \"工程师\"}\n```",
    "请用恰好三个词概括机器学习:数据驱动预测。",
    # 中文理解
    "请解释成语'画龙点睛'的含义:画龙点睛原指画龙时最后点上眼睛使之活灵活现,比喻在关键处用几句话点明主旨,使内容更加生动传神。",
    "请分析鲁迅《狂人日记》的主题思想:《狂人日记》通过一个'狂人'的视角,深刻揭示了封建礼教'吃人'的本质,是中国现代文学史上第一篇白话小说。",
    # 代码生成
    "用Python写一个快速排序算法:\ndef quicksort(arr):\n    if len(arr) <= 1:\n        return arr\n    pivot = arr[len(arr) // 2]\n    left = [x for x in arr if x < pivot]\n    middle = [x for x in arr if x == pivot]\n    right = [x for x in arr if x > pivot]\n    return quicksort(left) + middle + quicksort(right)",
    "用Python实现二分查找:\ndef binary_search(arr, target):\n    left, right = 0, len(arr) - 1\n    while left <= right:\n        mid = (left + right) // 2\n        if arr[mid] == target:\n            return mid\n        elif arr[mid] < target:\n            left = mid + 1\n        else:\n            right = mid - 1\n    return -1",
    # 翻译
    "请将以下中文翻译成英文:人工智能正在深刻改变我们的生活方式。\nArtificial intelligence is profoundly changing the way we live.",
    # 知识问答
    "什么是知识蒸馏?知识蒸馏是一种模型压缩技术,通过让小模型(学生)学习大模型(教师)的输出分布来转移知识,使小模型获得接近大模型的性能。",
    "什么是LoRA?LoRA(Low-Rank Adaptation)是一种参数高效微调方法,通过在预训练权重矩阵旁添加低秩分解矩阵来实现微调,大幅减少可训练参数量。",
    "解释梯度下降法:梯度下降是一种优化算法,通过计算损失函数对参数的梯度,沿梯度反方向更新参数,逐步找到损失函数的最小值。",
    "为什么大语言模型需要后训练?预训练让模型学会了语言知识,但后训练(包括SFT、RLHF等)让模型学会遵循人类指令、对齐人类偏好,成为真正有用的AI助手。",
]

def prepare_calibration_data(texts, tokenizer, max_length=512):
    """准备校准数据集"""
    calibration_data = []
    for text in texts:
        messages = [{"role": "user", "content": text}]
        formatted = tokenizer.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )
        tokenized = tokenizer(
            formatted,
            return_tensors="pt",
            max_length=max_length,
            truncation=True,
            padding="max_length",
        )
        calibration_data.append(tokenized)
    return calibration_data

calibration_data = prepare_calibration_data(calibration_texts, tokenizer)
print(f"校准数据集准备完成:{len(calibration_data)} 条样本")

GPTQ 使用二阶信息(Hessian 矩阵近似)逐层量化,量化质量在学术评测中通常优于 AWQ,但速度较慢。

from transformers import AutoModelForCausalLM, AutoTokenizer
from auto_gptq import AutoGPTQForCausalLM, BaseQuantizeConfig
import torch

MODEL_PATH = "./qwen3-8b-sft-merged"
GPTQ_OUTPUT = "./qwen3-8b-sft-gptq-int4"

# GPTQ 量化配置
quantize_config = BaseQuantizeConfig(
    bits=4,                 # 量化位数:4-bit
    group_size=128,         # 分组量化的组大小,128 是常用值
    damp_percent=0.1,       # 阻尼系数,防止 Hessian 矩阵奇异
    desc_act=False,         # 是否按激活值降序排列列,True 更准但更慢
    sym=True,               # 对称量化
)

print("正在加载模型用于 GPTQ 量化...")
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)
model = AutoGPTQForCausalLM.from_pretrained(
    MODEL_PATH,
    quantize_config=quantize_config,
)

# 准备校准数据(GPTQ 格式)
gptq_calibration = []
for text in calibration_texts:
    tokenized = tokenizer(text, return_tensors="pt", truncation=True, max_length=512)
    gptq_calibration.append(tokenized.input_ids)

# 执行量化
print("开始 GPTQ 量化(8B 模型约需 20-40 分钟)...")
start_time = time.time()

model.quantize(gptq_calibration)

quant_time = time.time() - start_time
print(f"GPTQ 量化完成,耗时: {quant_time / 60:.1f} 分钟")

# 保存量化模型
model.save_quantized(GPTQ_OUTPUT)
tokenizer.save_pretrained(GPTQ_OUTPUT)

# 检查量化后的文件大小
quant_size = sum(
    f.stat().st_size for f in Path(GPTQ_OUTPUT).glob("*") if f.is_file()
) / 1024**3
print(f"量化后模型大小: {quant_size:.2f} GB")

del model
torch.cuda.empty_cache()
gc.collect()

AWQ(Activation-aware Weight Quantization)通过分析激活值分布来保护重要权重通道,量化速度显著快于 GPTQ。

from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer

MODEL_PATH = "./qwen3-8b-sft-merged"
AWQ_OUTPUT = "./qwen3-8b-sft-awq-int4"

# 加载模型
print("正在加载模型用于 AWQ 量化...")
model = AutoAWQForCausalLM.from_pretrained(MODEL_PATH)
tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH)

# AWQ 量化配置
quant_config = {
    "zero_point": True,     # 是否使用零点量化
    "q_group_size": 128,    # 分组大小
    "w_bit": 4,             # 量化位数
    "version": "GEMM",      # 量化内核版本:GEMM 兼容性好
}

# 执行量化(AWQ 速度比 GPTQ 快很多)
print("开始 AWQ 量化...")
start_time = time.time()

model.quantize(tokenizer, quant_config=quant_config)

quant_time = time.time() - start_time
print(f"AWQ 量化完成,耗时: {quant_time / 60:.1f} 分钟")

# 保存
model.save_quantized(AWQ_OUTPUT)
tokenizer.save_pretrained(AWQ_OUTPUT)

quant_size = sum(
    f.stat().st_size for f in Path(AWQ_OUTPUT).glob("*") if f.is_file()
) / 1024**3
print(f"量化后模型大小: {quant_size:.2f} GB")

del model
torch.cuda.empty_cache()
gc.collect()

Step 3:量化后质量评估(15 分钟)

对比微调模型在量化前后的质量变化。

import re
import os
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from auto_gptq import AutoGPTQForCausalLM  # 如果用了 GPTQ

# 模型路径配置
MODELS = {
    "sft-fp16": {
        "path": "./qwen3-8b-sft-merged",
        "loader": "hf",
        "desc": "微调后 FP16(未量化)",
    },
    "sft-gptq-int4": {
        "path": "./qwen3-8b-sft-gptq-int4",
        "loader": "gptq",
        "desc": "微调后 GPTQ-INT4",
    },
    # 如果也做了 AWQ,可以加上:
    # "sft-awq-int4": {
    #     "path": "./qwen3-8b-sft-awq-int4",
    #     "loader": "awq",
    #     "desc": "微调后 AWQ-INT4",
    # },
}

def load_model_by_config(config):
    """根据配置加载不同格式的模型"""
    torch.cuda.empty_cache()
    gc.collect()
    torch.cuda.reset_peak_memory_stats()
    start_time = time.time()

    if config["loader"] == "hf":
        model = AutoModelForCausalLM.from_pretrained(
            config["path"], torch_dtype=torch.float16, device_map="auto",
        )
    elif config["loader"] == "gptq":
        model = AutoGPTQForCausalLM.from_quantized(
            config["path"], device_map="auto",
        )
    elif config["loader"] == "awq":
        from awq import AutoAWQForCausalLM
        model = AutoAWQForCausalLM.from_quantized(
            config["path"], fuse_layers=False, device_map="auto",
        )
    else:
        raise ValueError(f"未知的加载方式: {config['loader']}")

    load_time = time.time() - start_time
    memory_gb = torch.cuda.max_memory_allocated() / 1024**3
    return model, {
        "load_time_s": round(load_time, 1),
        "memory_gb": round(memory_gb, 2),
    }


# 评估数据集
gsm8k_problems = [
    {"question": "小明有15个苹果,他给了小红3个,又买了7个。他现在有多少个苹果?", "answer": 19},
    {"question": "一个教室有35个学生,其中男生比女生多5人。男生有多少人?", "answer": 20},
    {"question": "火车以每小时120公里的速度行驶,3.5小时能走多少公里?", "answer": 420},
    {"question": "商店里一件衣服原价200元,打八折后又减30元,最终价格是多少?", "answer": 130},
    {"question": "一个长方形的长是12厘米,宽是8厘米。它的周长和面积分别是多少?", "answer": "周长40厘米,面积96平方厘米"},
]

instruction_prompts = [
    "请用恰好三个词概括机器学习。",
    "请以JSON格式输出以下信息:姓名张三,年龄25,职业工程师。",
    "请列出5个中国历史朝代,每个朝代用一句话描述其特点。",
    "请将以下句子改写为疑问句:大语言模型正在改变世界。",
    "请写一段不超过50字的产品描述,产品是一款智能手表。",
]

chinese_prompts = [
    "请解释成语'画龙点睛'的含义,并造一个句子。",
    "请为一家新开的咖啡店写一段开业宣传语,风格温馨文艺。",
    "请分析鲁迅《狂人日记》的主题思想。",
    "请比较唐诗和宋词在风格上的主要差异。",
    "请用通俗的语言解释'内卷'这个网络用语的含义。",
]


def generate_responses(model, tokenizer, prompts, max_new_tokens=256):
    """生成模型回复"""
    responses = []
    for prompt in prompts:
        content = prompt["question"] if isinstance(prompt, dict) else prompt
        messages = [{"role": "user", "content": content}]
        text = tokenizer.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )
        inputs = tokenizer(text, return_tensors="pt").to(model.device)
        with torch.no_grad():
            output = model.generate(
                **inputs, max_new_tokens=max_new_tokens, do_sample=False
            )
        response = tokenizer.decode(
            output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
        )
        responses.append(response)
    return responses


# 收集所有模型的回复
tokenizer = AutoTokenizer.from_pretrained(list(MODELS.values())[0]["path"])
all_responses = {}
model_meta = {}

for model_name, config in MODELS.items():
    print(f"\n{'='*50}")
    print(f"评估模型: {config['desc']}")
    model, meta = load_model_by_config(config)
    model_meta[model_name] = meta
    print(f"  显存: {meta['memory_gb']} GB | 加载时间: {meta['load_time_s']}s")

    all_responses[model_name] = {
        "gsm8k": generate_responses(model, tokenizer, gsm8k_problems),
        "instruction": generate_responses(model, tokenizer, instruction_prompts),
        "chinese": generate_responses(model, tokenizer, chinese_prompts),
    }

    print(f"\n  样例回复(GSM8K 第1题):")
    print(f"  {all_responses[model_name]['gsm8k'][0][:200]}...")

    del model
    torch.cuda.empty_cache()
    gc.collect()

# 保存回复
with open("sft_quantization_responses.json", "w", encoding="utf-8") as f:
    json.dump(all_responses, f, ensure_ascii=False, indent=2)
print("\n所有回复已保存到 sft_quantization_responses.json")

Step 4:交叉分析与结果汇总(5 分钟)

回答关键问题:微调后的模型和原始预训练模型,谁对量化更敏感?

print("\n" + "="*70)
print("微调后量化实验 —— 结果汇总")
print("="*70)

# 资源对比
print("\n📊 资源占用对比")
print(f"{'模型':<25} {'显存(GB)':<12} {'加载时间(s)':<14}")
print("-"*55)
for m, meta in model_meta.items():
    desc = MODELS[m]["desc"]
    print(f"{desc:<25} {meta['memory_gb']:<12} {meta['load_time_s']:<14}")

print("\n" + "="*70)
print("💡 思考题:与实验 A 的交叉对比")
print("="*70)
print("""
如果你已完成实验 A,请对比以下数据并回答问题:

┌─────────────────┬──────────┬──────────┬──────────┐
│                 │  FP16    │  INT8    │  INT4    │
├─────────────────┼──────────┼──────────┼──────────┤
│ 预训练模型(实验A) │  ?.?     │  ?.?    │  ?.?     │
│ 微调后模型(本实验) │  ?.?     │   -     │  ?.?     │
└─────────────────┴──────────┴──────────┴──────────┘

请填入你的实际数据,并思考:
1. 微调后模型的 FP16 得分相比预训练模型是否提升?提升了多少?
2. 量化(INT4)导致的分数下降,在微调模型和预训练模型上谁更大?
3. 即使量化后,微调模型是否仍然优于未量化的预训练模型?
4. 这些发现对实际部署决策有什么指导意义?
""")

拓展(可选):导出 GGUF 并用 Ollama 部署

如果你想体验从训练到可运行的完整闭环,可以将量化模型转换为 GGUF 格式,然后用 Ollama 在本地部署。

# 安装 llama.cpp(需要先编译)
git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp && make -j

# 将 HuggingFace 模型转为 GGUF 格式
python convert_hf_to_gguf.py \
    ../qwen3-8b-sft-merged/ \
    --outfile ../qwen3-8b-sft-q4_k_m.gguf \
    --outtype q4_k_m

# 用 llama.cpp 测试
./llama-cli -m ../qwen3-8b-sft-q4_k_m.gguf \
    -p "请解释什么是大语言模型的后训练?" \
    -n 256

# 或用 Ollama 部署
cat > Modelfile << 'EOF'
FROM ./qwen3-8b-sft-q4_k_m.gguf
PARAMETER temperature 0.7
PARAMETER top_p 0.9
SYSTEM 你是一个有用的AI助手。
EOF

ollama create my-sft-model -f Modelfile
ollama run my-sft-model

实验 C:能力扩展选做(三选一)

选项 1:蒸馏模型分析

目标:分析 DeepSeek-R1-Distill-Qwen-1.5B 的推理能力和推理链风格。

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer

# === Step 1: 加载蒸馏模型 ===
distill_model_name = "deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B"
distill_model = AutoModelForCausalLM.from_pretrained(
    distill_model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
distill_tokenizer = AutoTokenizer.from_pretrained(distill_model_name)

print(f"蒸馏模型参数量: {sum(p.numel() for p in distill_model.parameters()) / 1e9:.2f}B")
print(f"显存占用: {torch.cuda.max_memory_allocated() / 1024**3:.2f} GB")


# === Step 2: GSM8K 评估 ===
gsm8k_test = [
    {"question": "一个商店卖苹果,每斤5元。小明买了3斤苹果,付了20元,应找回多少钱?", "answer": 5},
    {"question": "一列火车有12节车厢,每节车厢坐60人。如果火车满员,共有多少乘客?", "answer": 720},
    {"question": "甲、乙两人合作完成一项工作,甲单独做需10天,乙单独做需15天。两人合作几天能完成?", "answer": 6},
    {"question": "一个正方形的周长是24厘米,它的面积是多少平方厘米?", "answer": 36},
    {"question": "小红有一些糖果,她给了小明8颗后还剩12颗。她原来有多少颗糖果?", "answer": 20},
    {"question": "3个箱子共有45个球,第一个箱子比第二个多5个,第二个比第三个多5个。每个箱子各有多少球?", "answer": "20,15,10"},
    {"question": "一本书共300页,小明第一天读了全书的1/5,第二天读了剩下的1/4。第二天读了多少页?", "answer": 60},
    {"question": "把一根绳子对折3次后剪一刀,绳子被剪成几段?", "answer": 9},
    {"question": "一个数的3倍减去7等于20,这个数是多少?", "answer": 9},
    {"question": "长方形花坛长8米,宽5米。沿花坛外侧修一条1米宽的小路,小路面积是多少?", "answer": 32},
]

correct = 0
for i, problem in enumerate(gsm8k_test):
    messages = [{"role": "user", "content": problem["question"]}]
    text = distill_tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    inputs = distill_tokenizer(text, return_tensors="pt").to(distill_model.device)

    with torch.no_grad():
        output = distill_model.generate(
            **inputs, max_new_tokens=1024, do_sample=False, temperature=1.0
        )
    response = distill_tokenizer.decode(
        output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
    )

    print(f"\n问题 {i+1}: {problem['question']}")
    print(f"回复: {response[:300]}...")
    print(f"标准答案: {problem['answer']}")

    # 简单检查答案是否正确
    if str(problem["answer"]) in response:
        correct += 1
        print("✓ 正确")
    else:
        print("✗ 可能错误(需人工验证)")

print(f"\n准确率: {correct}/{len(gsm8k_test)} = {correct/len(gsm8k_test)*100:.1f}%")


# === Step 3: 分析推理链风格 ===
print("\n" + "="*60)
print("推理链风格分析")
print("="*60)

analysis_prompt = "一个工厂第一天生产了120件产品,第二天比第一天多生产20%,第三天生产的数量是前两天总和的一半。三天共生产了多少件产品?"

messages = [{"role": "user", "content": analysis_prompt}]
text = distill_tokenizer.apply_chat_template(
    messages, tokenize=False, add_generation_prompt=True
)
inputs = distill_tokenizer(text, return_tensors="pt").to(distill_model.device)

with torch.no_grad():
    output = distill_model.generate(
        **inputs, max_new_tokens=2048, do_sample=False
    )
full_response = distill_tokenizer.decode(
    output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
)

print(f"完整推理链:\n{full_response}")
print(f"\n推理链长度: {len(full_response)} 字符")

# 检查推理链特征
features = {
    "有逐步计算": "步" in full_response or "step" in full_response.lower(),
    "有自我验证": "验证" in full_response or "检查" in full_response,
    "有think标签": "<think>" in full_response,
    "有回溯修正": "不对" in full_response or "重新" in full_response,
}
print("\n推理链特征:")
for feat, present in features.items():
    print(f"  {feat}: {'是' if present else '否'}")

与第4课 GRPO 模型对比:如果你在第4课完成了 GRPO 训练实验,请对比蒸馏模型和 GRPO 模型的推理链风格。蒸馏模型通常更"流畅"但可能不够"深入",而 GRPO 模型可能出现更多"探索性"推理。

选项 2:迷你多模态实验

目标:使用 Qwen3-VL-2B-Instruct 测试视觉理解能力并评估幻觉率。

import torch
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
from PIL import Image
import requests
from io import BytesIO

# === Step 1: 加载 VLM 模型 ===
vl_model_name = "Qwen/Qwen3-VL-2B-Instruct"
vl_model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
    vl_model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
processor = AutoProcessor.from_pretrained(vl_model_name)

print(f"VLM 模型已加载,显存: {torch.cuda.max_memory_allocated() / 1024**3:.2f} GB")


# === Step 2: 准备测试图片 ===
# 使用公开的测试图片 URL
test_images = [
    {
        "url": "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png",
        "description": "带透明背景的骰子图片",
        "questions": [
            "请描述图片中的内容。",
            "图片中有几个骰子?",
            "图片中有猫吗?",  # 幻觉测试
        ]
    },
    # 可以添加更多测试图片...
]

# 也可以使用本地图片
def load_image(image_source):
    """加载图片,支持 URL 和本地路径"""
    if image_source.startswith("http"):
        response = requests.get(image_source)
        return Image.open(BytesIO(response.content)).convert("RGB")
    else:
        return Image.open(image_source).convert("RGB")


# === Step 3: 视觉问答测试 ===
def vqa_test(image, question):
    """对图片进行视觉问答"""
    messages = [
        {
            "role": "user",
            "content": [
                {"type": "image", "image": image},
                {"type": "text", "text": question},
            ],
        }
    ]

    text = processor.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=True
    )
    inputs = processor(
        text=[text], images=[image], return_tensors="pt"
    ).to(vl_model.device)

    with torch.no_grad():
        output = vl_model.generate(**inputs, max_new_tokens=256, do_sample=False)

    response = processor.decode(output[0], skip_special_tokens=True)
    # 提取助手回复部分
    if "assistant" in response:
        response = response.split("assistant")[-1].strip()
    return response


# === Step 4: 运行测试 ===
print("\n视觉问答测试结果:")
print("="*60)

for img_info in test_images:
    try:
        image = load_image(img_info["url"])
        print(f"\n图片: {img_info['description']}")

        for question in img_info["questions"]:
            response = vqa_test(image, question)
            print(f"\n  Q: {question}")
            print(f"  A: {response[:200]}")
    except Exception as e:
        print(f"加载图片失败: {e}")
        continue


# === Step 5: 幻觉测试 ===
print("\n" + "="*60)
print("幻觉测试")
print("="*60)

# 准备幻觉测试问题(对不存在的内容提问)
hallucination_questions = [
    "图片中的文字写了什么?",      # 如果图中没有文字
    "图片中的人在做什么?",        # 如果图中没有人
    "图片中有几辆汽车?",          # 如果图中没有汽车
    "图片背景中的建筑是什么风格?",  # 如果没有建筑
    "图片中动物的品种是什么?",      # 如果没有动物
]

hallucination_count = 0
total_tests = 0

for img_info in test_images:
    try:
        image = load_image(img_info["url"])
        for question in hallucination_questions:
            response = vqa_test(image, question)
            total_tests += 1

            # 检查是否产生幻觉(模型编造了不存在的内容)
            refusal_keywords = ["没有", "不存在", "看不到", "无法", "图中没有", "not"]
            is_refusal = any(kw in response for kw in refusal_keywords)

            if not is_refusal:
                hallucination_count += 1
                status = "⚠️ 可能幻觉"
            else:
                status = "✓ 正确拒绝"

            print(f"  Q: {question}")
            print(f"  A: {response[:150]}")
            print(f"  状态: {status}")
    except Exception as e:
        continue

if total_tests > 0:
    print(f"\n幻觉率: {hallucination_count}/{total_tests} "
          f"= {hallucination_count/total_tests*100:.1f}%")

选项 3:迷你工具使用实验

目标:测试 Qwen3-1.7B 的函数调用能力,构建简单的智能体循环。

import torch
import json
import re
from transformers import AutoModelForCausalLM, AutoTokenizer

# === Step 1: 加载模型 ===
model_name = "Qwen/Qwen3-1.7B"
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
tokenizer = AutoTokenizer.from_pretrained(model_name)


# === Step 2: 定义工具 ===
tools = [
    {
        "type": "function",
        "function": {
            "name": "calculator",
            "description": "一个简单的计算器,可以执行数学运算",
            "parameters": {
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "数学表达式,如 '2 + 3 * 4'"
                    }
                },
                "required": ["expression"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "获取指定城市的天气信息",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {
                        "type": "string",
                        "description": "城市名称"
                    }
                },
                "required": ["city"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "search_knowledge",
            "description": "搜索知识库获取信息",
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "搜索关键词"
                    }
                },
                "required": ["query"]
            }
        }
    },
]


# === Step 3: 模拟工具执行 ===
def execute_tool(name, arguments):
    """模拟工具执行"""
    if name == "calculator":
        try:
            expr = arguments.get("expression", "")
            # 安全地计算数学表达式
            result = eval(expr, {"__builtins__": {}}, {})
            return json.dumps({"result": result})
        except Exception as e:
            return json.dumps({"error": str(e)})

    elif name == "get_weather":
        city = arguments.get("city", "未知")
        # 模拟天气数据
        mock_weather = {
            "北京": {"temp": 22, "condition": "晴", "humidity": 35},
            "上海": {"temp": 26, "condition": "多云", "humidity": 65},
            "深圳": {"temp": 30, "condition": "阵雨", "humidity": 80},
        }
        weather = mock_weather.get(city, {"temp": 20, "condition": "晴", "humidity": 50})
        return json.dumps(weather, ensure_ascii=False)

    elif name == "search_knowledge":
        query = arguments.get("query", "")
        # 模拟知识库搜索
        knowledge = {
            "LoRA": "LoRA是一种参数高效微调方法,通过低秩分解减少可训练参数。",
            "量化": "量化将模型权重从高精度压缩到低精度,减少显存占用。",
            "DPO": "DPO是直接偏好优化方法,无需奖励模型即可进行偏好对齐。",
        }
        for key, value in knowledge.items():
            if key in query:
                return json.dumps({"result": value}, ensure_ascii=False)
        return json.dumps({"result": "未找到相关信息"}, ensure_ascii=False)

    return json.dumps({"error": "未知工具"})


# === Step 4: 智能体循环 ===
def agent_loop(user_query, max_turns=3):
    """完整的智能体循环"""
    messages = [
        {"role": "system", "content": "你是一个有用的助手,可以使用工具来回答问题。"},
        {"role": "user", "content": user_query},
    ]

    print(f"\n用户: {user_query}")

    for turn in range(max_turns):
        # 生成回复
        text = tokenizer.apply_chat_template(
            messages, tools=tools, tokenize=False, add_generation_prompt=True
        )
        inputs = tokenizer(text, return_tensors="pt").to(model.device)

        with torch.no_grad():
            output = model.generate(
                **inputs, max_new_tokens=512, do_sample=False
            )
        response_text = tokenizer.decode(
            output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
        )

        print(f"\n助手 (Turn {turn+1}): {response_text[:300]}")

        # 检查是否包含工具调用
        tool_call_match = re.search(
            r'<tool_call>\s*(\{.*?\})\s*</tool_call>',
            response_text, re.DOTALL
        )

        if tool_call_match:
            try:
                tool_call = json.loads(tool_call_match.group(1))
                tool_name = tool_call.get("name", "")
                tool_args = tool_call.get("arguments", {})

                print(f"  → 调用工具: {tool_name}({json.dumps(tool_args, ensure_ascii=False)})")

                # 执行工具
                result = execute_tool(tool_name, tool_args)
                print(f"  ← 工具返回: {result}")

                # 将工具调用和结果添加到消息中
                messages.append({"role": "assistant", "content": response_text})
                messages.append({
                    "role": "tool",
                    "name": tool_name,
                    "content": result
                })
            except json.JSONDecodeError:
                print("  ⚠️ 工具调用解析失败")
                break
        else:
            # 没有工具调用,直接返回
            print(f"\n最终回复: {response_text}")
            return response_text

    return "达到最大轮数"


# === Step 5: 测试场景 ===
test_queries = [
    "请帮我计算 (15 + 27) * 3 - 40 的结果。",
    "北京今天天气怎么样?",
    "帮我查一下 LoRA 是什么?",
    "上海的温度是多少?如果温度超过25度请提醒我带伞。",
    "请帮我计算一个圆的面积,半径是5厘米。(使用3.14159计算)",
]

correct = 0
total = len(test_queries)

for query in test_queries:
    print("\n" + "="*60)
    result = agent_loop(query)
    # 人工判断是否正确(可自动化)
    print(f"\n请评估此回复是否正确 (y/n): ", end="")
    # 在实际实验中可以记录结果


# === Step 6: 与小模型对比(可选)===
print("\n\n" + "="*60)
print("与 Qwen3-0.6B 对比测试")
print("="*60)

# 加载 0.6B 模型
small_model_name = "Qwen/Qwen3-0.6B"
small_model = AutoModelForCausalLM.from_pretrained(
    small_model_name,
    torch_dtype=torch.bfloat16,
    device_map="auto",
)
small_tokenizer = AutoTokenizer.from_pretrained(small_model_name)

# 用相同的测试场景对比
for query in test_queries[:3]:  # 测试前3个
    print(f"\n查询: {query}")

    messages = [
        {"role": "system", "content": "你是一个有用的助手,可以使用工具来回答问题。"},
        {"role": "user", "content": query},
    ]
    text = small_tokenizer.apply_chat_template(
        messages, tools=tools, tokenize=False, add_generation_prompt=True
    )
    inputs = small_tokenizer(text, return_tensors="pt").to(small_model.device)

    with torch.no_grad():
        output = small_model.generate(
            **inputs, max_new_tokens=512, do_sample=False
        )
    response = small_tokenizer.decode(
        output[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True
    )
    print(f"Qwen3-0.6B 回复: {response[:200]}")

模型规模对工具使用的影响:实验中你可能会发现,1.7B 模型的函数调用准确率显著高于 0.6B 模型。工具使用需要模型同时具备理解用户意图、选择合适工具、生成正确参数格式三项能力,这对小模型来说是较大挑战。


交付物清单

完成本次实验后,请提交以下内容:

实验 A:量化实验报告(必做)

  • 三种精度的显存占用对比表
  • 推理速度对比表(tokens/s、首 token 延迟)
  • 三类任务的质量评分对比表(GSM8K、指令跟随、中文任务)
  • "压缩率 vs 质量保持率"的关系图
  • 简要分析:量化对哪类任务影响最大?为什么?

实验 B:微调后量化报告(选做)

  • LoRA adapter 合并过程记录(adapter 配置、参数量对比)
  • GPTQ 或 AWQ 量化配置与耗时记录
  • 量化前后的文件大小、显存占用对比表
  • 三类任务的 LLM-as-Judge 评分对比(量化前 vs 量化后)
  • 交叉分析表:预训练模型量化损失 vs 微调模型量化损失
  • 回答:微调后的模型对量化更敏感还是更鲁棒?你的证据是什么?

实验 C:选做实验报告(三选一)

  • 选项 1:蒸馏模型的推理链分析、与 GRPO 模型的风格对比
  • 选项 2:VLM 的视觉问答结果、幻觉率统计
  • 选项 3:函数调用准确率、智能体循环运行示例

总结反思(1 页)

  • 后训练各环节(SFT → DPO → GRPO → 量化/蒸馏)的关系与选择策略
  • 对于一个实际的 LLM 应用项目,你会如何选择后训练技术组合?
  • (如完成实验 B)先量化再微调(QLoRA)和先微调再量化,各有什么优劣?