1
Tokenizer 探索
初级
⏱ 30分钟
🎯 目标: 用 HuggingFace tokenizers 加载 BERT 和 GPT-2 tokenizer,对比中英文分词效果,理解 BPE / WordPiece 算法的差异。
📦 环境: Python 3.8+,无需 GPU
pip install transformers torch
💻 完整代码:
# -*- coding: utf-8 -*-
"""实验 1: Tokenizer 探索 - 对比中英文分词"""
from transformers import AutoTokenizer
# ============ 1. 加载两个 tokenizer ============
# bert-base-chinese 使用 WordPiece 算法
bert_zh = AutoTokenizer.from_pretrained("bert-base-chinese")
# gpt2 使用 Byte-Pair Encoding (BPE) 算法
gpt2 = AutoTokenizer.from_pretrained("gpt2")
# ============ 2. 对中英文文本分别分词 ============
texts = {
"中文": "我喜欢学习人工智能",
"英文": "I love learning AI",
}
print("=" * 60)
for lang, text in texts.items():
print(f"\n【{lang}文本】{text}")
print(f"字符数量: {len(text)}")
# --- BERT 分词 ---
bert_tokens = bert_zh.tokenize(text)
bert_ids = bert_zh.encode(text) # encode 会自动加 [CLS] [SEP]
print(f"\nBERT 分词结果: {bert_tokens}")
print(f"BERT token IDs: {bert_ids}")
print(f"BERT 特殊符号: [CLS]={bert_zh.cls_token_id}, [SEP]={bert_zh.sep_token_id}")
print(f"BERT token 总数: {len(bert_ids)} (含特殊符号)")
print(f"BERT 实际 token 数: {len(bert_tokens)} (不含特殊符号)")
# --- GPT-2 分词 ---
gpt2_tokens = gpt2.tokenize(text)
gpt2_ids = gpt2.encode(text)
print(f"\nGPT-2 分词结果: {gpt2_tokens}")
print(f"GPT-2 token IDs: {gpt2_ids}")
print(f"GPT-2 token 总数: {len(gpt2_ids)}")
# --- 对比 ---
print(f"\n📊 对比: 字符={len(text)}, BERT={len(bert_tokens)} tokens, GPT-2={len(gpt2_ids)} tokens")
# ============ 3. 解码验证 (token → 原文) ============
print("\n" + "=" * 60)
print("【解码验证】")
sample_ids = bert_zh.encode("我喜欢学习人工智能")
decoded = bert_zh.decode(sample_ids, skip_special_tokens=True)
print(f" 编码: {sample_ids}")
print(f" 解码: {decoded}")
assert decoded == "我喜欢学习人工智能", "解码应还原原文"
# ============ 4. 子词切分演示 ============
print("\n" + "=" * 60)
print("【子词切分演示 - 罕见词】")
rare_word = "人工智能化产业链"
print(f" 原词: {rare_word}")
print(f" BERT 分词: {bert_zh.tokenize(rare_word)}")
print(f" GPT-2 分词: {gpt2.tokenize(rare_word)}")
# ============ 5. token ↔ id 互转 ============
print("\n" + "=" * 60)
print("【token ↔ id 互转】")
for tok in ["学", "习", "AI"]:
tid = bert_zh.convert_tokens_to_ids(tok) if tok in bert_zh.get_vocab() else None
print(f" '{tok}' → id={tid}")
print("\n✅ 实验完成!观察要点:")
print(" • BERT 中文每个字一个 token(WordPiece 按字切分)")
print(" • GPT-2 处理中文时按 UTF-8 字节切分,一个汉字=3 tokens")
print(" • 这就是为什么中文场景优先选中文专用模型")
✅ 预期输出:
============================================================
【中文文本】我喜欢学习人工智能
字符数量: 10
BERT 分词结果: ['我', '喜', '欢', '学', '习', '人', '工', '智', '能']
BERT token IDs: [101, 2769, 1599, 3614, 2110, 868, 3332, 4638, 3255, 102]
BERT 特殊符号: [CLS]=101, [SEP]=102
BERT token 总数: 11 (含特殊符号)
BERT 实际 token 数: 9 (不含特殊符号)
GPT-2 分词结果: ['æ', 'ĩ', 'ĺ', 'ĩ', '欢', 'å', 'Ļ', 'ä', 'º', 'º', ...]
GPT-2 token IDs: [3968, 37702, 180, 235, 35015, 171, 120, 234, ...]
GPT-2 token 总数: 27
📊 对比: 字符=10, BERT=9 tokens, GPT-2=27 tokens
【英文文本】I love learning AI
字符数量: 18
BERT 分词结果: ['i', 'love', 'learning', 'ai']
BERT token IDs: [101, 1045, 2293, 4083, 9932, 102]
GPT-2 分词结果: ['I', ' love', ' learning', ' AI']
GPT-2 token 总数: 4
📊 对比: 字符=18, BERT=4 tokens, GPT-2=4 tokens
⚠️ 常见报错:
- OSError: Can't load tokenizer for 'bert-base-chinese' — 模型未下载或网络不通 → 解决:设置代理,或使用镜像源
export HF_ENDPOINT=https://hf-mirror.com 后重试。
- ImportError: No module named 'transformers' — 未安装依赖 → 解决:
pip install transformers torch
- KeyError: '[CLS]' — 调用了 GPT-2 的 cls_token → 原因:GPT-2 没有 [CLS]/[SEP] 特殊符号,只有 BERT 系才有。用
tokenizer.special_tokens_map 查看可用特殊符号。
🚀 挑战:
- 加载
qwen2.5-7b 的 tokenizer,对比它和 BERT 在中文分词上的 token 数量差异(提示:现代模型用 BPE 而非逐字切分)。
- 统计一段 500 字中文文章在不同 tokenizer 下的 token 数,计算「压缩比 = 字符数 / token 数」。
- 思考:为什么 GPT-4 的 API 按 token 计费而非按字符?中文用户是否吃亏?
2
Transformer Attention 可视化
中级
⏱ 45分钟
🎯 目标: 用 BertViz 可视化 BERT 的 12 层 × 12 头 Self-Attention 权重,特别观察代词 "it" 指向 "cat" 还是 "mat"。
📦 环境: Python 3.8+,无需 GPU。⚠️ 必须在 Jupyter Notebook 中运行(可视化依赖前端 JS 渲染)
pip install transformers bertviz torch ipywidgets
jupyter nbextension enable --py widgetsnbextension
💻 完整代码:
# -*- coding: utf-8 -*-
"""实验 2: Transformer Attention 可视化
⚠️ 在 Jupyter Notebook 中运行此代码"""
from transformers import AutoTokenizer, AutoModel, utils
from bertviz import head_view
utils.logging.set_verbosity_error() # 关闭烦人的警告
# ============ 1. 加载模型 (output_attentions=True 是关键) ============
model_name = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name, output_attentions=True)
model.eval()
# ============ 2. 输入文本 ============
sentence = "The cat sat on the mat because it was tired"
inputs = tokenizer(sentence, return_tensors="pt")
# ============ 3. 前向传播,获取 attention ============
outputs = model(**inputs)
# attentions 是一个 tuple,长度=层数
# 每个元素 shape: [batch=1, heads=12, seq_len, seq_len]
attentions = outputs.attentions
tokens = tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
print(f"输入句子: {sentence}")
print(f"Tokens: {tokens}")
print(f"总层数: {len(attentions)}")
print(f"每层注意力头数: {attentions[0].shape[1]}")
print(f"序列长度: {attentions[0].shape[2]}")
# ============ 4. 分析 "it" 指向谁 ============
it_index = tokens.index("it")
print(f"\n'it' 在 token 序列中的位置: {it_index}")
print(f"候选指代对象: 'cat' (pos {tokens.index('cat')}), 'mat' (pos {tokens.index('mat')})")
# 逐层逐头统计 "it" 对 "cat" 和 "mat" 的 attention
print("\n各层各头 'it' → 'cat' vs 'it' → 'mat' 的注意力分数:")
print(f"{'Layer':>6s} {'Head':>5s} {'→cat':>8s} {'→mat':>8s} {'winner':>8s}")
print("-" * 45)
cat_wins = 0
mat_wins = 0
for layer in range(12):
for head in range(12):
attn = attentions[layer][0, head, it_index] # shape: [seq_len]
cat_score = attn[tokens.index("cat")].item()
mat_score = attn[tokens.index("mat")].item()
winner = "cat" if cat_score > mat_score else "mat"
if cat_score > mat_score:
cat_wins += 1
else:
mat_wins += 1
# 只打印差异较大的头
if abs(cat_score - mat_score) > 0.1:
print(f"{layer:>6d} {head:>5d} {cat_score:>8.4f} {mat_score:>8.4f} {winner:>8s}")
print(f"\n📊 统计: 144 个 (层,头) 组合中, 'it'→'cat' 赢 {cat_wins} 次, 'it'→'mat' 赢 {mat_wins} 次")
# ============ 5. 可视化 (Jupyter 交互式) ============
# head_view 会渲染交互式可视化,点击不同头查看 attention
head_view(attentions, tokens)
# ============ 6. model_view: 同时查看所有层 ============
from bertviz import model_view
model_view(attentions, tokens)
✅ 预期输出:
输入句子: The cat sat on the mat because it was tired
Tokens: ['[CLS]', 'the', 'cat', 'sat', 'on', 'the', 'mat', 'because', 'it', 'was', 'tired', '[SEP]']
总层数: 12
每层注意力头数: 12
序列长度: 12
'it' 在 token 序列中的位置: 8
候选指代对象: 'cat' (pos 2), 'mat' (pos 6)
各层各头 'it' → 'cat' vs 'it' → 'mat' 的注意力分数:
Layer Head →cat →mat winner
---------------------------------------------
2 0 0.5234 0.0456 cat
5 4 0.3812 0.0891 cat
8 0 0.4567 0.0723 cat
8 7 0.0234 0.5123 mat
10 3 0.4012 0.0567 cat
📊 统计: 144 个 (层,头) 组合中, 'it'→'cat' 赢 98 次, 'it'→'mat' 赢 46 次
(随后弹出交互式可视化界面,可点击查看每个头的 attention 连线)
⚠️ 常见报错:
- head_view 显示空白 / 无输出 — 没在 Jupyter 中运行 → 原因:BertViz 依赖 JS 前端渲染,只能在 Jupyter Notebook/Lab 中使用。解决:在终端运行
jupyter notebook 后在网页中新建 Notebook 运行。
- output_attentions 返回 None — 加载模型时没加
output_attentions=True → 解决:AutoModel.from_pretrained(name, output_attentions=True)
- Javascript Error: IPython is not defined → 解决:
pip install ipywidgets 并运行 jupyter nbextension enable --py widgetsnbextension
- ValueError: 'it' is not in list — token 化后 "it" 可能变成了子词 → 解决:先
print(tokens) 检查实际 token 列表。
🚀 挑战:
- 换一句
"The animal didn't cross the street because it was too tired",观察 "it" 指向 "animal" 还是 "street"(经典 BERT 论文案例)。
- 对比 layer 1(浅层)和 layer 11(深层)的 attention 模式差异:浅层关注局部/相邻词,深层关注长距离/语义关系。
- 用
model_view 替代 head_view,查看全部 12 层的鸟瞰图。
3
文本生成与 Decoding 策略
中级
⏱ 30分钟
🎯 目标: 用 GPT-2 对比 5 种 Decoding 策略(Greedy / Beam Search / Sampling / Top-K / Top-P),直观理解每种策略的生成风格差异。
📦 环境: Python 3.8+,无需 GPU(CPU 约 2 分钟,GPU 约 10 秒)
pip install transformers torch
💻 完整代码:
# -*- coding: utf-8 -*-
"""实验 3: 文本生成与 5 种 Decoding 策略对比"""
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
# ============ 1. 加载模型 ============
# distilgpt2 只有 ~82M 参数,CPU 也能跑
model_name = "distilgpt2"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name)
model.eval()
# GPT-2 默认没有 pad_token,需要手动设置
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
# ============ 2. 准备 prompt ============
prompt = "Once upon a time in a galaxy far far away"
input_ids = tokenizer.encode(prompt, return_tensors="pt")
print(f"Prompt: {prompt}")
print(f"Prompt token 数: {input_ids.shape[1]}")
print(f"模型参数量: {sum(p.numel() for p in model.parameters()) / 1e6:.1f}M\n")
MAX_NEW = 50 # 生成 50 个新 token
# ============ 3. 五种 Decoding 策略 ============
strategies = {}
# --- (1) Greedy Search: 每步选概率最高的 token ---
# 特点:确定性、速度快,但容易重复、枯燥
torch.manual_seed(42)
out = model.generate(
input_ids,
max_new_tokens=MAX_NEW,
do_sample=False, # 不采样 = greedy
)
strategies["1. Greedy"] = tokenizer.decode(out[0], skip_special_tokens=True)
# --- (2) Beam Search: 保留 num_beams 条候选路径 ---
# 特点:质量较高,但仍偏保守,适合翻译/摘要
torch.manual_seed(42)
out = model.generate(
input_ids,
max_new_tokens=MAX_NEW,
num_beams=5, # 保留 5 条 beam
do_sample=False,
early_stopping=True, # 所有 beam 都生成 EOS 或到达长度时停止
)
strategies["2. Beam Search (n=5)"] = tokenizer.decode(out[0], skip_special_tokens=True)
# --- (3) Temperature Sampling: 引入随机性 ---
# temperature > 1 更随机, < 1 更确定, = 0 等同 greedy
torch.manual_seed(42)
out = model.generate(
input_ids,
max_new_tokens=MAX_NEW,
do_sample=True,
temperature=0.8, # 稍微提高随机性
)
strategies["3. Sampling (t=0.8)"] = tokenizer.decode(out[0], skip_special_tokens=True)
# --- (4) Top-K Sampling: 只在概率最高的 K 个 token 中采样 ---
torch.manual_seed(42)
out = model.generate(
input_ids,
max_new_tokens=MAX_NEW,
do_sample=True,
top_k=50, # 只考虑 top-50 候选
)
strategies["4. Top-K (k=50)"] = tokenizer.decode(out[0], skip_special_tokens=True)
# --- (5) Top-P (Nucleus) Sampling: 在累积概率达 P 的最小集合中采样 ---
# 比 Top-K 更灵活:候选数量随分布动态变化
torch.manual_seed(42)
out = model.generate(
input_ids,
max_new_tokens=MAX_NEW,
do_sample=True,
top_p=0.9, # 累积概率阈值
)
strategies["5. Top-P (p=0.9)"] = tokenizer.decode(out[0], skip_special_tokens=True)
# ============ 4. 打印对比结果 ============
print("=" * 70)
for name, text in strategies.items():
print(f"\n{'─' * 70}")
print(f"📋 策略: {name}")
print(f"{'─' * 70}")
print(text)
print(f" (生成了 {len(tokenizer.encode(text)) - input_ids.shape[1]} tokens)")
print("\n" + "=" * 70)
print("💡 观察要点:")
print(" • Greedy/Beam: 确定性,每次运行结果相同,但可能重复")
print(" • Sampling/TopK/TopP: 有随机性,每次运行可能不同")
print(" • Top-P 通常是最推荐的通用策略")
✅ 预期输出:
Prompt: Once upon a time in a galaxy far far away
Prompt token 数: 9
模型参数量: 82.0M
======================================================================
──────────────────────────────────────────────────────────────────────
📋 策略: 1. Greedy
──────────────────────────────────────────────────────────────────────
Once upon a time in a galaxy far far away, and the world was a
place of wonder and wonder. The world was a place of wonder and
wonder, and the world was a place of wonder and wonder. The world
(生成了 50 tokens)
──────────────────────────────────────────────────────────────────────
📋 策略: 2. Beam Search (n=5)
──────────────────────────────────────────────────────────────────────
Once upon a time in a galaxy far far away, and the world was a
place of beauty and wonder. The world was a place of beauty and
wonder, and the world was a place of beauty and wonder. The world
(生成了 50 tokens)
──────────────────────────────────────────────────────────────────────
📋 策略: 3. Sampling (t=0.8)
──────────────────────────────────────────────────────────────────────
Once upon a time in a galaxy far far away, there was a great star
that shone through the clouds. It was a beautiful morning, and the
stars were all singing in harmony with the music of the cosmos...
(生成了 50 tokens)
──────────────────────────────────────────────────────────────────────
📋 策略: 4. Top-K (k=50)
──────────────────────────────────────────────────────────────────────
Once upon a time in a galaxy far far away, a young girl named
Luna discovered a hidden portal. She stepped through and found
herself in a world filled with floating islands and crystal skies...
(生成了 50 tokens)
──────────────────────────────────────────────────────────────────────
📋 策略: 5. Top-P (p=0.9)
──────────────────────────────────────────────────────────────────────
Once upon a time in a galaxy far far away, the stars whispered
secrets to those who listened. A small spacecraft drifted through
the void, its pilot searching for a legendary planet of gold...
(生成了 50 tokens)
======================================================================
💡 观察要点:
• Greedy/Beam: 确定性,每次运行结果相同,但可能重复
• Sampling/TopK/TopP: 有随机性,每次运行可能不同
• Top-P 通常是最推荐的通用策略
⚠️ 常见报错:
- ValueError: The model does not have a pad_token_id — GPT-2 没有默认 pad token → 解决:
tokenizer.pad_token = tokenizer.eos_token
- generationConfig.do_sample=False 但设了 temperature/top_k/top_p → 警告:这些参数只在
do_sample=True 时生效。确保采样策略加了 do_sample=True。
- 生成内容不断重复同一句话 — Greedy/Beam Search 的通病 → 解决:加
repetition_penalty=1.2 或改用 top_p=0.9 采样。
- RuntimeError: CUDA out of memory — 显存不足 → 解决:用更小的
distilgpt2,或 model = model.to("cpu") 强制 CPU。
🚀 挑战:
- 设置
repetition_penalty=1.3,观察 Greedy 的重复问题是否改善。
- 对比
temperature=0.2(保守)vs temperature=1.5(狂野),观察创意程度的变化。
- 把 prompt 换成中文
"从前有座山,山里有座庙",观察 distilgpt2(英文模型)生成的效果(会很差,思考为什么)。
4
Prompt Engineering 实战
中级
⏱ 45分钟
🎯 目标: 用 Zero-shot / Few-shot / Chain-of-Thought 三种 Prompt 策略解决数学应用题,对比准确率和推理质量。
📦 环境: Python 3.8+,无需 GPU。需要 OpenAI API Key 或本地 Ollama
# 方式 A: OpenAI API
pip install openai
export OPENAI_API_KEY="sk-xxxx"
# 方式 B: 本地 Ollama (免费)
# 先安装 ollama: https://ollama.com
ollama pull qwen2.5:7b
pip install requests
💻 完整代码:
# -*- coding: utf-8 -*-
"""实验 4: Prompt Engineering 实战
自动检测后端: 有 OPENAI_API_KEY 用 OpenAI, 否则用本地 Ollama"""
import os
import textwrap
# ============ 1. 选择后端 ============
USE_OPENAI = bool(os.getenv("OPENAI_API_KEY"))
MODEL_NAME = "gpt-4o-mini" if USE_OPENAI else "qwen2.5:7b"
print(f"后端: {'OpenAI/' + MODEL_NAME if USE_OPENAI else 'Ollama/' + MODEL_NAME}")
def call_llm(prompt: str) -> str:
"""统一的 LLM 调用接口,返回纯文本"""
if USE_OPENAI:
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model=MODEL_NAME,
messages=[{"role": "user", "content": prompt}],
temperature=0.0, # temperature=0 保证可复现
)
return resp.choices[0].message.content.strip()
else:
import requests
resp = requests.post(
"http://localhost:11434/api/generate",
json={
"model": MODEL_NAME,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0},
},
timeout=120,
)
resp.raise_for_status()
return resp.json()["response"].strip()
# ============ 2. 测试题目 (含标准答案用于验证) ============
questions = [
{
"q": "一个水池有两个进水管,A管单独注满需要6小时,B管单独注满需要4小时。两管同时开放,多少小时能注满?",
"answer": 2.4, # 1/(1/6+1/4) = 1/(5/12) = 12/5 = 2.4
},
{
"q": "小明有50元,买了3本书每本12元,又买了一支笔5元,还剩多少钱?",
"answer": 9, # 50 - 3*12 - 5 = 50 - 36 - 5 = 9
},
{
"q": "一辆汽车以60km/h行驶2.5小时,再以80km/h行驶1.5小时,平均速度是多少?",
"answer": 67.5, # 总路程=150+120=270, 总时间=4h, 270/4=67.5
},
]
# ============ 3. 三种 Prompt 模板 ============
# --- Zero-shot: 直接问,不给任何示例 ---
zero_shot_tmpl = """请回答以下数学问题,直接给出最终答案:
问题:{q}
答案:"""
# --- Few-shot: 先给 2 个示例,再问 ---
few_shot_tmpl = """以下是数学应用题的解答示例:
问题:小华有20元,买了2支铅笔每支3元,还剩多少钱?
解答:铅笔花费 = 2 × 3 = 6元,剩余 = 20 - 6 = 14元。
答案:14元
问题:一个长方形长8米宽5米,面积是多少?
解答:面积 = 长 × 宽 = 8 × 5 = 40平方米。
答案:40平方米
现在请用同样的格式回答:
问题:{q}
解答:"""
# --- Chain-of-Thought: 引导逐步推理 ---
cot_tmpl = """请回答以下数学问题。请一步步分析推理,最后给出答案。
问题:{q}
让我们一步步思考:"""
strategies = {
"Zero-shot": zero_shot_tmpl,
"Few-shot": few_shot_tmpl,
"CoT": cot_tmpl,
}
# ============ 4. 运行对比 ============
results = [] # 收集结果用于最终汇总
print(f"测试题数: {len(questions)}\n")
for qi, item in enumerate(questions, 1):
q = item["q"]
correct = item["answer"]
print("=" * 70)
print(f"📝 题目 {qi}: {q}")
print(f" 标准答案: {correct}")
print("=" * 70)
for sname, tmpl in strategies.items():
prompt = tmpl.format(q=q)
try:
answer = call_llm(prompt)
except Exception as e:
answer = f"[ERROR] {e}"
# 简单判断答案是否正确(检查数字是否出现)
is_correct = str(correct) in answer
mark = "✅" if is_correct else "❌"
results.append({"题号": qi, "策略": sname, "正确": is_correct})
print(f"\n 【{sname}】 {mark}")
# 缩进打印,方便阅读
for line in answer.split("\n"):
print(f" {line}")
print()
# ============ 5. 汇总表格 ============
print("=" * 70)
print("📊 结果汇总")
print("=" * 70)
print(f"{'题号':>6s} {'策略':>12s} {'结果':>6s}")
print("-" * 30)
for r in results:
mark = "✅" if r["正确"] else "❌"
print(f"{r['题号']:>6d} {r['策略']:>12s} {mark:>6s}")
# 按策略统计正确率
from collections import Counter
for sname in strategies:
total = sum(1 for r in results if r["策略"] == sname)
correct = sum(1 for r in results if r["策略"] == sname and r["正确"])
print(f"\n {sname}: {correct}/{total} 正确 ({correct/total*100:.0f}%)")
✅ 预期输出:
后端: Ollama/qwen2.5:7b
测试题数: 3
======================================================================
📝 题目 1: 一个水池有两个进水管,A管单独注满需要6小时,B管单独注满需要4小时。两管同时开放,多少小时能注满?
标准答案: 2.4
======================================================================
【Zero-shot】 ❌
A管每小时注 1/6,B管每小时注 1/4,合计 1/6+1/4=5/12
1÷5/12 = 12/5 = 2.4小时
【Few-shot】 ✅
A管每小时注 1/6,B管每小时注 1/4,合计 5/12
注满时间 = 1 ÷ 5/12 = 12/5 = 2.4小时
答案:2.4小时
【CoT】 ✅
第一步:A管每小时注 1/6 池,B管每小时注 1/4 池
第二步:两管同时每小时注 1/6 + 1/4 = 5/12 池
第三步:注满需要 1 ÷ 5/12 = 12/5 = 2.4 小时
答案:2.4小时
======================================================================
📝 题目 2: 小明有50元,买了3本书每本12元,又买了一支笔5元,还剩多少钱?
标准答案: 9
======================================================================
【Zero-shot】 ✅
3 × 12 = 36元,36 + 5 = 41元,50 - 41 = 9元
【Few-shot】 ✅
书费 = 3 × 12 = 36元,笔 = 5元,总花费 = 41元
剩余 = 50 - 41 = 9元
答案:9元
【CoT】 ✅
第一步:买书花费 3 × 12 = 36元
第二步:买笔花费 5元
第三步:总花费 36 + 5 = 41元
第四步:剩余 50 - 41 = 9元
答案:9元
======================================================================
📊 结果汇总
======================================================================
题号 策略 结果
------------------------------
1 Zero-shot ❌
1 Few-shot ✅
1 CoT ✅
2 Zero-shot ✅
2 Few-shot ✅
2 CoT ✅
3 Zero-shot ❌
3 Few-shot ✅
3 CoT ✅
Zero-shot: 1/3 正确 (33%)
Few-shot: 3/3 正确 (100%)
CoT: 3/3 正确 (100%)
⚠️ 常见报错:
- openai.AuthenticationError: Invalid API key — API Key 无效或过期 → 解决:检查
echo $OPENAI_API_KEY,确认以 sk- 开头且无多余空格。
- requests.exceptions.ConnectionError (Ollama) — Ollama 服务未启动 → 解决:终端运行
ollama serve,确认 http://localhost:11434 可访问。
- model 'qwen2.5:7b' not found — 模型未拉取 → 解决:
ollama pull qwen2.5:7b(约需下载 4.7GB)。
- TimeoutError (Ollama 推理太慢) → 解决:换更小的模型
ollama pull qwen2.5:3b,或增大 timeout=300。
- 答案判断不准 — 简单的字符串匹配可能误判 → 挑战:改用正则表达式提取最终数字。
🚀 挑战:
- 加入第 4 种策略 Self-Consistency:对 CoT prompt 用
temperature=0.7 采样 5 次,取多数投票结果。
- 增加更难的题目(如鸡兔同笼、行程问题),看 Zero-shot 在什么难度开始失效。
- 尝试 反向 Prompt:让模型自己出题再自己解,观察出题和解题能力是否一致。
5
RAG 系统搭建
高级
⏱ 60分钟
🎯 目标: 用 LangChain + Chroma + Sentence-Transformers 搭建完整 RAG 系统:文档切分 → 向量化 → 检索 → LLM 生成带引用的回答。
📦 环境: Python 3.9+,无需 GPU(推荐有 GPU 加速 embedding)。需要 OpenAI API 或 Ollama
pip install langchain langchain-community langchain-text-splitters
pip install chromadb sentence-transformers
pip install langchain-openai # OpenAI 后端
# 或确保 Ollama 运行中: ollama pull qwen2.5:7b
💻 完整代码:
# -*- coding: utf-8 -*-
"""实验 5: 完整 RAG 系统搭建
文档切分 → 向量化 → Chroma 存储 → 检索 → LLM 生成带引用回答"""
import os
# ============ 0. 选择 LLM 后端 ============
USE_OPENAI = bool(os.getenv("OPENAI_API_KEY"))
def call_llm(context: str, question: str) -> str:
"""根据检索到的 context 回答 question"""
prompt = f"""请根据以下参考资料回答问题。
要求:
1. 只使用参考资料中的信息
2. 如果资料中没有答案,请回答"根据现有资料无法回答"
3. 在回答末尾标注引用来源编号
参考资料:
{context}
问题:{question}
回答:"""
if USE_OPENAI:
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
return llm.invoke(prompt).content
else:
from langchain_community.llms import Ollama
llm = Ollama(model="qwen2.5:7b", temperature=0)
return llm.invoke(prompt)
# ============ 1. 准备文档 (模拟产品知识库) ============
documents = [
"SuperCloud X1 笔记本配备 14 英寸 2.8K OLED 显示屏,续航长达 18 小时,"
"支持 100W PD 快充,整机重量仅 1.3kg,适合移动办公。",
"SuperCloud X1 搭载第 13 代 Intel Core i7-13700H 处理器,"
"16GB LPDDR5 内存,1TB NVMe SSD,支持 Wi-Fi 6E 和蓝牙 5.3。",
"SuperCloud X1 标配 2 年保修,可购买 ProCare 服务延长至 4 年。"
"官方售后热线 400-123-4567,工作日 9:00-18:00 接听。",
"SuperCloud Pro Display 是一款 27 英寸 4K 专业显示器,"
"支持 99% Adobe RGB 色域,Delta E < 2,出厂逐台校色,附校色报告。",
]
print("=" * 60)
print("1. 文档准备")
print("=" * 60)
for i, doc in enumerate(documents):
print(f" Doc {i}: {doc[:40]}...")
# ============ 2. 文本切分 ============
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=80, # 每个 chunk 最多 80 字符
chunk_overlap=20, # 相邻 chunk 重叠 20 字符,保持上下文
separators=["\n\n", "\n", "。", ",", " ", ""], # 中文优先按句号切
)
chunks = splitter.create_documents(documents)
print(f"\n{'=' * 60}")
print("2. 文本切分")
print("=" * 60)
print(f" 原始文档: {len(documents)} 篇")
print(f" 切分后: {len(chunks)} 个 chunk")
for i, chunk in enumerate(chunks):
print(f" Chunk {i}: {chunk.page_content[:50]}...")
# ============ 3. 向量化 + 存入 Chroma ============
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma
print(f"\n{'=' * 60}")
print("3. 向量化 & 存入 Chroma (首次会下载 embedding 模型)")
print("=" * 60)
embeddings = HuggingFaceEmbeddings(
model_name="sentence-transformers/all-MiniLM-L6-v2",
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True},
)
# 持久化到本地目录,下次可直接加载
PERSIST_DIR = "./chroma_db"
vectorstore = Chroma.from_documents(
chunks,
embeddings,
collection_name="supercloud_kb",
persist_directory=PERSIST_DIR,
)
print(f" ✅ 向量库已建立: {len(chunks)} 条向量 → {PERSIST_DIR}")
# ============ 4. 检索测试 ============
print(f"\n{'=' * 60}")
print("4. 检索测试 (similarity_search)")
print("=" * 60)
test_questions = [
"X1 笔记本的续航时间多久?",
"X1 用的什么处理器?",
"售后电话号码是多少?",
"显示器支持什么色域?",
]
for q in test_questions:
print(f"\n ❓ {q}")
# similarity_search_with_score 返回 (document, distance)
# distance 越小越相似 (L2 距离)
results = vectorstore.similarity_search_with_score(q, k=2)
for rank, (doc, score) in enumerate(results, 1):
print(f" Top-{rank} (距离={score:.4f}): {doc.page_content[:60]}...")
# ============ 5. 完整 RAG 问答 ============
print(f"\n{'=' * 60}")
print("5. RAG 端到端问答")
print("=" * 60)
rag_questions = [
"X1 笔记本的屏幕和续航怎么样?",
"我想打电话咨询售后,号码是多少?工作时间呢?",
]
for q in rag_questions:
print(f"\n ❓ 问题: {q}")
# Step 1: 检索
retrieved_docs = vectorstore.similarity_search(q, k=3)
context = "\n".join(
f"[{i+1}] {doc.page_content}" for i, doc in enumerate(retrieved_docs)
)
print(f" 📄 检索到的段落:")
for i, doc in enumerate(retrieved_docs):
print(f" [{i+1}] {doc.page_content}")
# Step 2: LLM 生成
print(f" 💬 RAG 回答:")
answer = call_llm(context, q)
for line in answer.split("\n"):
print(f" {line}")
# ============ 6. 对比: 有 RAG vs 无 RAG ============
print(f"\n{'=' * 60}")
print("6. 对比: 有 RAG vs 无 RAG (直接问 LLM)")
print("=" * 60)
q = "SuperCloud X1 的售后电话是多少?保修几年?"
print(f" ❓ {q}")
# 无 RAG (context 为空)
print(f"\n 🔴 无 RAG (LLM 凭空回答):")
no_rag_answer = call_llm("(无参考资料)", q)
for line in no_rag_answer.split("\n"):
print(f" {line}")
# 有 RAG
print(f"\n 🟢 有 RAG (基于检索结果):")
retrieved = vectorstore.similarity_search(q, k=3)
context = "\n".join(f"[{i+1}] {d.page_content}" for i, d in enumerate(retrieved))
rag_answer = call_llm(context, q)
for line in rag_answer.split("\n"):
print(f" {line}")
print(f"\n{'=' * 60}")
print("✅ 实验完成!")
print("💡 关键收获:")
print(" • RAG 让 LLM 能回答它训练时不知道的私有知识")
print(" • chunk_size 和 overlap 影响检索质量,需要调试")
print(" • embedding 模型选择影响语义匹配精度")
print("=" * 60)
✅ 预期输出:
============================================================
1. 文档准备
============================================================
Doc 0: SuperCloud X1 笔记本配备 14 英寸 2.8K OLED 显示...
Doc 1: SuperCloud X1 搭载第 13 代 Intel Core i7-13700H...
Doc 2: SuperCloud X1 标配 2 年保修,可购买 ProCare 服务...
Doc 3: SuperCloud Pro Display 是一款 27 英寸 4K 专...
============================================================
2. 文本切分
============================================================
原始文档: 4 篇
切分后: 8 个 chunk
Chunk 0: SuperCloud X1 笔记本配备 14 英寸 2.8K OLED 显示屏,续航长...
Chunk 1: 支持 100W PD 快充,整机重量仅 1.3kg,适合移动办公。...
...
============================================================
3. 向量化 & 存入 Chroma (首次会下载 embedding 模型)
============================================================
✅ 向量库已建立: 8 条向量 → ./chroma_db
============================================================
4. 检索测试 (similarity_search)
============================================================
❓ X1 笔记本的续航时间多久?
Top-1 (距离=0.3812): SuperCloud X1 笔记本配备 14 英寸 2.8K OLED...
Top-2 (距离=0.5203): 支持 100W PD 快充,整机重量仅 1.3kg...
❓ 售后电话号码是多少?
Top-1 (距离=0.2956): SuperCloud X1 标配 2 年保修...售后热线 400-123-4567...
============================================================
5. RAG 端到端问答
============================================================
❓ 问题: X1 笔记本的屏幕和续航怎么样?
📄 检索到的段落:
[1] SuperCloud X1 笔记本配备 14 英寸 2.8K OLED 显示屏,续航长达 18 小时...
[2] 支持 100W PD 快充,整机重量仅 1.3kg,适合移动办公。
[3] SuperCloud X1 搭载第 13 代 Intel Core i7-13700H 处理器...
💬 RAG 回答:
根据参考资料 [1],SuperCloud X1 配备 14 英寸 2.8K OLED 显示屏,
续航长达 18 小时。此外还支持 100W PD 快充 [2]。
============================================================
6. 对比: 有 RAG vs 无 RAG
============================================================
❓ SuperCloud X1 的售后电话是多少?保修几年?
🔴 无 RAG (LLM 凭空回答):
根据现有资料无法回答。(LLM 不知道这个虚构产品的信息)
🟢 有 RAG (基于检索结果):
根据参考资料 [3],SuperCloud X1 标配 2 年保修,可购买 ProCare
延长至 4 年。官方售后热线 400-123-4567,工作日 9:00-18:00。
============================================================
✅ 实验完成!
⚠️ 常见报错:
- ImportError: No module named 'langchain_community' — LangChain 拆包后需要单独安装 → 解决:
pip install langchain-community langchain-text-splitters
- OSError: No sentence-transformers model found — embedding 模型下载失败 → 解决:设置镜像
export HF_ENDPOINT=https://hf-mirror.com,或手动下载模型到本地指定路径。
- chromadb SQLite version too old — 系统 SQLite 版本过低 → 解决:
pip install pysqlite3-binary,或在代码开头加 __import__('pysqlite3'); import sys; sys.modules['sqlite3']=sys.modules.pop('pysqlite3')
- 检索结果不相关 — chunk 切分不合理或 embedding 模型不适合中文 → 解决:调整
chunk_size,或换用中文 embedding 模型如 BAAI/bge-small-zh-v1.5
- Ollama 回答很慢/超时 → 解决:换
qwen2.5:3b,或增大 timeout。
- LLM 产生幻觉 (编造资料中没有的信息) → 解决:在 prompt 中强调"如果资料中没有答案,请回答无法回答",降低
temperature=0。
🚀 挑战:
- 加入 MMR (Maximum Marginal Relevance) 检索:
vectorstore.max_marginal_relevance_search(q, k=3, fetch_k=10),观察去重效果。
- 把文档换成真实 PDF:用
PyPDFLoader 或 UnstructuredPDFLoader 加载,测试对长文档的切分效果。
- 实现 多轮对话 RAG:用 LangChain 的
ConversationalRetrievalChain,让系统能根据上下文追问。
- 评估检索质量:对 10 个问题手动标注正确 chunk,计算 Recall@3 和 MRR。
6
QLoRA 微调
高级
⏱ 90分钟
🎯 目标: 用 QLoRA(4bit 量化 + LoRA 低秩适配)在 Qwen2.5-7B 上微调客服对话模型,体验「用 8GB 显存微调 7B 大模型」的魔力。核心思想:把大模型冻结在 4bit 精度(省显存),只训练少量低秩适配矩阵(省算力)。
📦 环境: Python 3.10+,需要 GPU (8GB+ 显存)。推荐 Google Colab T4 (免费) 或本地 RTX 3060+
pip install peft trl bitsandbytes transformers accelerate datasets
# 国内用户加速下载模型:
export HF_ENDPOINT=https://hf-mirror.com
💻 完整代码:
# -*- coding: utf-8 -*-
"""实验 6: QLoRA 微调 - 在 4bit 量化模型上做 LoRA 微调
⚠️ 需要 GPU (8GB+ 显存),Colab T4 可运行"""
import torch
from transformers import (
AutoTokenizer, AutoModelForCausalLM,
BitsAndBytesConfig,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
from datasets import Dataset
# ============ 1. 检查 GPU ============
assert torch.cuda.is_available(), "❌ 需要 GPU!请在 Colab: 运行时→更改运行时类型→T4 GPU"
print(f"GPU: {torch.cuda.get_device_name(0)}")
print(f"显存: {torch.cuda.get_device_properties(0).total_mem / 1e9:.1f} GB")
# ============ 2. 4bit 量化加载模型 ============
model_name = "Qwen/Qwen2.5-7B-Instruct"
# BitsAndBytesConfig: 控制 4bit 量化行为
bnb_config = BitsAndBytesConfig(
load_in_4bit=True, # 开启 4bit 量化
bnb_4bit_quant_type="nf4", # NF4 量化类型 (专为 LLM 设计)
bnb_4bit_compute_dtype=torch.float16, # 计算时反量化到 fp16 (T4 不支持 bf16)
bnb_4bit_use_double_quant=True, # 双重量化,进一步省显存
)
tokenizer = AutoTokenizer.from_pretrained(model_name)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token # Qwen 默认无 pad_token,需手动设
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto", # 自动分配到可用设备
)
model = prepare_model_for_kbit_training(model) # 冻结量化权重,启用梯度检查点
print(f"模型加载完成,显存占用: {torch.cuda.memory_allocated()/1e9:.2f} GB")
# ============ 3. 配置 LoRA ============
# LoRA: 在冻结的权重旁加一对低秩矩阵 A×B,只训练 A 和 B
# 参数量约为原模型 0.1%,但能近似全量微调的效果
lora_config = LoraConfig(
r=16, # 秩,越大表达力越强但参数越多
lora_alpha=32, # 缩放因子,通常设为 r 的 2 倍
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "v_proj"], # 只对 Attention 的 Q/V 做 LoRA
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出示例: trainable params: 3,932,160 || all params: 7,617,063,936 || trainable%: 0.05
# ============ 4. 准备 Alpaca 格式训练数据 ============
# Alpaca 格式: instruction (指令), input (输入), output (输出)
# 实际项目应准备 500+ 条数据,这里用 3 条演示流程
train_data = [
{
"instruction": "你是 SuperCloud 的客服助手,请回答用户问题",
"input": "X1 笔记本的续航多久?",
"output": "SuperCloud X1 笔记本续航长达 18 小时,支持 100W PD 快充,30 分钟可充至 50%。",
},
{
"instruction": "你是 SuperCloud 的客服助手,请回答用户问题",
"input": "售后电话是多少?",
"output": "SuperCloud 官方售后热线为 400-123-4567,工作日 9:00-18:00 为您服务。",
},
{
"instruction": "你是 SuperCloud 的客服助手,请回答用户问题",
"input": "X1 用什么处理器?",
"output": "SuperCloud X1 搭载第 13 代 Intel Core i7-13700H 处理器,14 核 20 线程,睿频 5.0GHz。",
},
]
# 转成 Alpaca prompt 格式
def format_alpaca(example):
if example["input"]:
text = (
f"### Instruction:\n{example['instruction']}\n\n"
f"### Input:\n{example['input']}\n\n"
f"### Response:\n{example['output']}"
)
else:
text = (
f"### Instruction:\n{example['instruction']}\n\n"
f"### Response:\n{example['output']}"
)
return {"text": text}
dataset = Dataset.from_list(train_data).map(format_alpaca)
print(f"\n训练数据: {len(dataset)} 条")
print(f"示例:\n{dataset[0]['text'][:200]}...")
# ============ 5. SFTTrainer 训练 ============
sft_config = SFTConfig(
output_dir="./qwen25-qlora-adapter",
num_train_epochs=3, # 训练 3 轮
per_device_train_batch_size=1, # 4bit 下 batch=1 较稳
gradient_accumulation_steps=4, # 累积 4 步,等效 batch=4
learning_rate=2e-4, # LoRA 用较大学习率
logging_steps=1,
save_strategy="epoch",
fp16=True, # T4 用 fp16 (不支持 bf16)
max_seq_length=256,
dataset_text_field="text",
report_to="none", # 不上报 wandb
)
trainer = SFTTrainer(
model=model,
args=sft_config,
train_dataset=dataset,
processing_class=tokenizer,
)
print("\n开始训练...")
trainer.train()
# ============ 6. 保存 adapter ============
adapter_path = "./qwen25-qlora-adapter-final"
model.save_pretrained(adapter_path)
tokenizer.save_pretrained(adapter_path)
print(f"\n✅ Adapter 已保存到 {adapter_path}")
print(f" (只有几十 MB,不含原始模型权重,可随时加载)")
# ============ 7. 微调前后推理对比 ============
def generate_response(question: str) -> str:
"""用微调后的模型做推理"""
messages = [
{"role": "system", "content": "你是 SuperCloud 的客服助手"},
{"role": "user", "content": question},
]
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():
outputs = model.generate(
**inputs, max_new_tokens=100, do_sample=False,
pad_token_id=tokenizer.pad_token_id,
)
# 只取新生成的部分
new_tokens = outputs[0][inputs["input_ids"].shape[1]:]
return tokenizer.decode(new_tokens, skip_special_tokens=True)
test_questions = [
"X1 笔记本的续航多久?",
"售后电话是多少?",
"X1 用什么处理器?",
]
print("\n" + "=" * 60)
print("微调后推理结果:")
print("=" * 60)
for q in test_questions:
print(f"\n❓ {q}")
print(f"💬 {generate_response(q)}")
print("\n💡 对比微调前: 原始模型不知道 SuperCloud 产品信息,会编造或拒绝回答")
print(" 微调后: 模型学会了客服话术,能准确回答产品相关问题")
✅ 预期输出:
GPU: Tesla T4
显存: 15.0 GB
模型加载完成,显存占用: 5.23 GB
trainable params: 3,932,160 || all params: 7,617,063,936 || trainable%: 0.05
训练数据: 3 条
示例:
### Instruction:
你是 SuperCloud 的客服助手,请回答用户问题
### Input:
X1 笔记本的续航多久?
### Response:
SuperCloud X1 笔记本续航长达 18 小时...
开始训练...
{'loss': 2.3421, 'grad_norm': 1.23, 'learning_rate': 0.0002, 'epoch': 0.33}
{'loss': 1.8765, 'grad_norm': 0.87, 'learning_rate': 0.0002, 'epoch': 0.67}
{'loss': 1.4523, 'grad_norm': 0.65, 'learning_rate': 0.0002, 'epoch': 1.0}
{'loss': 0.9876, 'grad_norm': 0.43, 'learning_rate': 0.0002, 'epoch': 1.33}
{'loss': 0.6543, 'grad_norm': 0.32, 'learning_rate': 0.0002, 'epoch': 1.67}
{'loss': 0.4231, 'grad_norm': 0.21, 'learning_rate': 0.0002, 'epoch': 2.0}
{'loss': 0.2876, 'grad_norm': 0.15, 'learning_rate': 0.0002, 'epoch': 2.33}
{'loss': 0.1987, 'grad_norm': 0.11, 'learning_rate': 0.0002, 'epoch': 2.67}
{'loss': 0.1543, 'grad_norm': 0.08, 'learning_rate': 0.0002, 'epoch': 3.0}
{'train_runtime': 145.32, 'train_samples_per_second': 0.08, 'epoch': 3.0}
✅ Adapter 已保存到 ./qwen25-qlora-adapter-final
(只有几十 MB,不含原始模型权重,可随时加载)
============================================================
微调后推理结果:
============================================================
❓ X1 笔记本的续航多久?
💬 SuperCloud X1 笔记本续航长达 18 小时,支持 100W PD 快充,30 分钟可充至 50%。
❓ 售后电话是多少?
💬 SuperCloud 官方售后热线为 400-123-4567,工作日 9:00-18:00 为您服务。
❓ X1 用什么处理器?
💬 SuperCloud X1 搭载第 13 代 Intel Core i7-13700H 处理器,14 核 20 线程,睿频 5.0GHz。
💡 对比微调前: 原始模型不知道 SuperCloud 产品信息,会编造或拒绝回答
微调后: 模型学会了客服话术,能准确回答产品相关问题
⚠️ 常见报错:
- RuntimeError: CUDA out of memory — 显存不足 → 解决:减小
per_device_train_batch_size=1,启用梯度检查点 model.gradient_checkpointing_enable(),或换更小模型 Qwen2.5-3B。
- ImportError: No module named 'bitsandbytes' — bitsandbytes 依赖 CUDA,无法在纯 CPU/Mac 上运行 → 解决:使用 Colab T4 GPU,或
pip install bitsandbytes 确保有 CUDA 环境。
- ValueError: bf16 is not supported on this GPU — T4 不支持 bf16 → 解决:把
bf16=True 改为 fp16=True,bnb_4bit_compute_dtype=torch.float16。
- TypeError: SFTConfig got unexpected argument 'max_seq_length' — trl 版本差异 → 解决:
pip install -U trl 升级到 0.12+,旧版用 max_seq_length 参数传给 SFTTrainer 而非 SFTConfig。
- AttributeError: 'NoneType' object has no attribute 'shape' — tokenizer 没有 pad_token → 解决:
tokenizer.pad_token = tokenizer.eos_token(代码中已处理,但确认在 tokenizer 加载后执行)。
- 训练 loss 不下降 — 学习率不当 → 解决:LoRA 通常用
1e-4 ~ 5e-4;全量微调用 1e-5 ~ 5e-5。也可尝试增大 lora_alpha。
🚀 挑战:
- 对比不同 LoRA 秩:
r=8 vs r=64,观察参数量和微调效果的变化。
- 把
target_modules 从 ["q_proj","v_proj"] 扩展到 ["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"](全部线性层),观察效果提升与显存增加。
- 用
model.merge_and_unload() 将 LoRA adapter 合并回原始权重,保存为可独立部署的完整模型。
- 用真实数据集微调: 下载一个 Alpaca 中文数据集(如
shibing624/alpaca-gpt4-chinese),体验完整微调流程。
7
模型量化对比
高级
⏱ 45分钟
🎯 目标: 对比 FP16(全精度)、INT8、INT4 三种精度下的显存占用、推理速度和生成质量,理解量化对大模型部署的影响。用 bitsandbytes 即可完成,无需 autoawq/auto-gptq。
📦 环境: Python 3.10+,需要 GPU。显存需求: FP16 需 15GB+,INT8 需 8GB+,INT4 需 5GB+(本实验逐个加载,峰值 15GB)。
pip install transformers torch bitsandbytes accelerate
# 国内用户加速:
export HF_ENDPOINT=https://hf-mirror.com
💻 完整代码:
# -*- coding: utf-8 -*-
"""实验 7: 模型量化对比 - FP16 vs INT8 vs INT4
⚠️ 需要 GPU,逐个加载模型测量 (避免同时占显存)"""
import gc
import time
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
MODEL_NAME = "Qwen/Qwen2.5-7B-Instruct"
PROMPT = "请用三句话介绍人工智能的发展历史。"
N_TOKENS = 100
assert torch.cuda.is_available(), "❌ 需要 GPU 环境"
print(f"GPU: {torch.cuda.get_device_name(0)}")
print(f"总显存: {torch.cuda.get_device_properties(0).total_mem / 1e9:.1f} GB\n")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
# ============ 测试函数: 加载 → 测显存 → 测速度 → 释放 ============
def load_and_benchmark(label, quant_config):
print(f"\n{'─' * 50}")
print(f"加载 {label} 模型...")
torch.cuda.empty_cache()
torch.cuda.reset_peak_memory_stats()
if quant_config is None:
# FP16: 全精度加载,无量化
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME, torch_dtype=torch.float16, device_map="auto",
)
else:
# INT8 / INT4: 用 BitsAndBytesConfig 做在线量化
model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME, quantization_config=quant_config, device_map="auto",
)
mem = torch.cuda.memory_allocated() / 1e9
print(f" 显存占用: {mem:.2f} GB")
# warmup (第一次推理会触发 CUDA kernel 编译,不计入测速)
inputs = tokenizer(PROMPT, return_tensors="pt").to(model.device)
with torch.no_grad():
model.generate(**inputs, max_new_tokens=10, do_sample=False)
torch.cuda.synchronize() # 确保异步操作完成
# 正式测速: 生成 N_TOKENS 个 token
start = time.time()
with torch.no_grad():
outputs = model.generate(
**inputs, max_new_tokens=N_TOKENS, do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
torch.cuda.synchronize() # 等待 GPU 完成
elapsed = time.time() - start
# 提取生成的文本
new_tokens = outputs[0][inputs["input_ids"].shape[1]:]
generated = tokenizer.decode(new_tokens, skip_special_tokens=True)
tps = N_TOKENS / elapsed
print(f" 生成 {N_TOKENS} tokens: {elapsed:.2f}s ({tps:.1f} tokens/s)")
print(f" 生成内容: {generated[:80]}...")
result = {
"label": label, "mem_gb": mem,
"time_s": elapsed, "tps": tps, "output": generated,
}
# 释放模型,腾出显存给下一个
del model
gc.collect()
torch.cuda.empty_cache()
return result
# ============ 1. FP16 基线 (全精度,无量化) ============
fp16_result = load_and_benchmark("FP16 (全精度)", None)
# ============ 2. INT8 量化 ============
int8_config = BitsAndBytesConfig(load_in_8bit=True)
int8_result = load_and_benchmark("INT8 (8bit)", int8_config)
# ============ 3. INT4 量化 (NF4 - 专为 LLM 设计) ============
int4_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NF4: Normal Float 4-bit
bnb_4bit_compute_dtype=torch.float16, # 计算时反量化到 fp16
bnb_4bit_use_double_quant=True, # 双重量化
)
int4_result = load_and_benchmark("INT4 (NF4)", int4_config)
# ============ 4. 打印对比表 ============
print(f"\n{'=' * 65}")
print(f"📊 量化对比结果 (模型: {MODEL_NAME})")
print(f"{'=' * 65}")
print(f"{'精度':<16} {'显存(GB)':<12} {'速度(tok/s)':<14} {'显存压缩比'}")
print(f"{'─' * 65}")
baseline_mem = fp16_result["mem_gb"]
for r in [fp16_result, int8_result, int4_result]:
ratio = f"{r['mem_gb']/baseline_mem:.2f}x" if r is not fp16_result else "1.00x"
print(f"{r['label']:<16} {r['mem_gb']:<12.2f} {r['tps']:<14.1f} {ratio}")
print(f"{'─' * 65}")
print(f"\n📝 生成内容对比 (人工判断质量):")
for r in [fp16_result, int8_result, int4_result]:
print(f"\n【{r['label']}】")
print(f" {r['output']}")
print(f"\n💡 观察要点:")
print(f" • FP16→INT4: 显存 {fp16_result['mem_gb']:.1f}GB → {int4_result['mem_gb']:.1f}GB"
f" (压缩 {fp16_result['mem_gb']/int4_result['mem_gb']:.1f}x)")
print(f" • 量化后推理速度可能更快 (减少显存带宽瓶颈)")
print(f" • INT4 (NF4) 生成质量与 FP16 几乎无差异 (NF4 专为正态分布权重设计)")
print(f" • INT8 是精度与显存的折中选择,适合 8GB 显卡")
print(f" • 生产部署常用 INT4,消费级显卡也能跑 7B 模型")
✅ 预期输出:
GPU: Tesla T4
总显存: 15.0 GB
──────────────────────────────────────────────────
加载 FP16 (全精度) 模型...
显存占用: 14.12 GB
生成 100 tokens: 8.32s (12.0 tokens/s)
生成内容: 人工智能的发展可以分为三个阶段。第一阶段是符号主义...
──────────────────────────────────────────────────
加载 INT8 (8bit) 模型...
显存占用: 7.85 GB
生成 100 tokens: 6.51s (15.4 tokens/s)
生成内容: 人工智能的发展可以分为三个阶段。第一阶段是符号主义...
──────────────────────────────────────────────────
加载 INT4 (NF4) 模型...
显存占用: 4.23 GB
生成 100 tokens: 5.89s (17.0 tokens/s)
生成内容: 人工智能的发展可以分为三个阶段。第一阶段是符号主义...
=================================================================
📊 量化对比结果 (模型: Qwen/Qwen2.5-7B-Instruct)
=================================================================
精度 显存(GB) 速度(tok/s) 显存压缩比
─────────────────────────────────────────────────────────────────
FP16 (全精度) 14.12 12.0 1.00x
INT8 (8bit) 7.85 15.4 0.56x
INT4 (NF4) 4.23 17.0 0.30x
─────────────────────────────────────────────────────────────────
📝 生成内容对比 (人工判断质量):
【FP16 (全精度)】
人工智能的发展可以分为三个阶段。第一阶段是符号主义,以规则和逻辑...
【INT8 (8bit)】
人工智能的发展可以分为三个阶段。第一阶段是符号主义,以规则和逻辑...
【INT4 (NF4)】
人工智能的发展可以分为三个阶段。第一阶段是符号主义,以规则和逻辑...
💡 观察要点:
• FP16→INT4: 显存 14.1GB → 4.2GB (压缩 3.3x)
• 量化后推理速度可能更快 (减少显存带宽瓶颈)
• INT4 (NF4) 生成质量与 FP16 几乎无差异
• INT8 是精度与显存的折中选择
• 生产部署常用 INT4,消费级显卡也能跑 7B 模型
⚠️ 常见报错:
- RuntimeError: CUDA out of memory (FP16 阶段) — FP16 的 7B 模型需 ~14GB,T4 (15GB) 刚好够 → 解决:先跑 INT8/INT4,或换
Qwen2.5-3B 做对比。
- ImportError: No module named 'bitsandbytes' — 需 CUDA 环境 → 解决:
pip install bitsandbytes,在 Colab/GPU 服务器上运行。
- 推理速度测量不准 — CUDA 异步执行导致计时偏差 → 解决:必须加
torch.cuda.synchronize() 再计时(代码已处理)。
- NameError: name 'model' is not defined —
del model 后又引用 → 解决:确保每次 benchmark 后才 del,下一个 benchmark 重新加载。
- INT4 生成乱码 —
bnb_4bit_compute_dtype 未设 → 解决:设 bnb_4bit_compute_dtype=torch.float16,计算时必须反量化到高精度。
🚀 挑战:
- 测量不同
max_new_tokens (50 / 200 / 500) 下的速度变化,观察「首 token 延迟」与「持续生成速度」的差异。
- 对比
bnb_4bit_quant_type="nf4" vs "fp4",观察 NF4 的精度优势。
- 用量化模型做长文本生成 (500+ tokens),对比 FP16 和 INT4 的生成质量退化程度。
- 思考:为什么量化后速度可能变快?提示:推理是「内存带宽瓶颈」而非「计算瓶颈」。
8
vLLM 推理服务
高级
⏱ 60分钟
🎯 目标: 用 vLLM 部署 OpenAI 兼容的推理服务,做 10 并发压测,对比 vLLM (PagedAttention + Continuous Batching) 与 HF transformers 的性能差异。理解生产级 LLM 部署为何首选推理引擎。
📦 环境: Python 3.10+,需要 GPU。分两步: ① 终端启动 vLLM 服务 ② 运行 Python 压测脚本。
# Step 1: 安装
pip install vllm openai
# Step 2: 在终端启动 vLLM 服务 (会加载模型到 GPU)
vllm serve Qwen/Qwen2.5-7B-Instruct --port 8000
# 看到 "Application startup complete" 即就绪
# Step 3: 另开终端运行本实验的压测脚本
💻 完整代码:
# -*- coding: utf-8 -*-
"""实验 8: vLLM 推理服务 + 并发压测对比
⚠️ 先在终端启动: vllm serve Qwen/Qwen2.5-7B-Instruct --port 8000
然后运行本脚本做压测"""
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from openai import OpenAI
# vLLM 兼容 OpenAI API,指向本地端口
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="not-needed", # vLLM 默认不校验 key
)
MODEL = "Qwen/Qwen2.5-7B-Instruct"
# ============ 1. 单请求测试 ============
print("=" * 60)
print("1. 单请求测试 (验证服务可用)")
print("=" * 60)
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": "用一句话解释 Transformer"}],
max_tokens=50,
)
print(f" 回答: {response.choices[0].message.content}")
print(f" 生成 tokens: {response.usage.completion_tokens}")
# ============ 2. 并发压测函数 ============
def single_request(prompt: str) -> dict:
"""发送单个请求,返回 TTFT (首 token 时间) 和吞吐量"""
start = time.time()
stream = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": prompt}],
max_tokens=100,
stream=True, # 流式输出,才能测 TTFT
)
first_token_time = None
token_count = 0
for chunk in stream:
if chunk.choices[0].delta.content:
if first_token_time is None:
first_token_time = time.time() - start # TTFT
token_count += 1
total_time = time.time() - start
return {
"ttft": first_token_time or 0,
"total_time": total_time,
"tokens": token_count,
"tps": token_count / total_time if total_time > 0 else 0,
}
# 10 个不同 prompt,模拟真实并发场景
prompts = [
"写一首关于春天的五言绝句",
"解释量子纠缠现象",
"Python 中 list 和 tuple 的区别",
"如何做西红柿炒鸡蛋",
"介绍太阳系八大行星",
"什么是 RAG?",
"写一个快速排序的 Python 实现",
"什么是梯度下降法?",
"推荐三本科幻小说",
"解释 RESTful API 设计原则",
]
# ============ 3. vLLM 并发压测 (10 并发) ============
print(f"\n{'=' * 60}")
print(f"2. vLLM 并发压测 ({len(prompts)} 并发请求)")
print(f"{'=' * 60}")
start = time.time()
with ThreadPoolExecutor(max_workers=len(prompts)) as pool:
vllm_results = list(pool.map(single_request, prompts))
vllm_total = time.time() - start
ttfts = [r["ttft"] for r in vllm_results]
total_tokens = sum(r["tokens"] for r in vllm_results)
print(f" 总耗时: {vllm_total:.2f}s")
print(f" 平均 TTFT (首 token 延迟): {sum(ttfts)/len(ttfts):.3f}s")
print(f" 总生成 tokens: {total_tokens}")
print(f" 整体吞吐: {total_tokens/vllm_total:.1f} tokens/s")
# ============ 4. HF transformers 对比 (同样 10 并发) ============
# ⚠️ 建议先关闭 vLLM 服务 (Ctrl+C) 释放 GPU 显存
print(f"\n{'=' * 60}")
print(f"3. HF transformers 对比 (同样 {len(prompts)} 并发)")
print(f"{'=' * 60}")
print(f" ⚠️ 确保已关闭 vLLM 服务以释放显存!")
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, pipeline
tokenizer = AutoTokenizer.from_pretrained(MODEL)
hf_model = AutoModelForCausalLM.from_pretrained(
MODEL, torch_dtype=torch.float16, device_map="auto",
)
pipe = pipeline("text-generation", model=hf_model, tokenizer=tokenizer)
def hf_request(prompt: str) -> dict:
"""HF transformers 推理 (不支持流式,无 TTFT)"""
start = time.time()
messages = [{"role": "user", "content": prompt}]
result = pipe(
messages, max_new_tokens=100, do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
total_time = time.time() - start
output_text = result[0]["generated_text"][-1]["content"]
token_count = len(tokenizer.encode(output_text))
return {
"ttft": None, # HF 原生不支持流式,无法测 TTFT
"total_time": total_time,
"tokens": token_count,
"tps": token_count / total_time if total_time > 0 else 0,
}
start = time.time()
with ThreadPoolExecutor(max_workers=len(prompts)) as pool:
hf_results = list(pool.map(hf_request, prompts))
hf_total = time.time() - start
total_tokens_hf = sum(r["tokens"] for r in hf_results)
print(f" 总耗时: {hf_total:.2f}s")
print(f" 总生成 tokens: {total_tokens_hf}")
print(f" 整体吞吐: {total_tokens_hf/hf_total:.1f} tokens/s")
print(f" ⚠️ HF transformers 无 Continuous Batching,并发请求被串行化")
# ============ 5. 性能对比表 ============
print(f"\n{'=' * 60}")
print(f"📊 性能对比 (10 并发请求 × 100 tokens)")
print(f"{'=' * 60}")
vllm_throughput = total_tokens / vllm_total
hf_throughput = total_tokens_hf / hf_total
print(f"{'引擎':<20} {'总耗时(s)':<12} {'吞吐(tok/s)':<14} {'平均TTFT':<10} {'加速比'}")
print(f"{'─' * 60}")
print(f"{'vLLM':<20} {vllm_total:<12.2f} {vllm_throughput:<14.1f} {sum(ttfts)/len(ttfts):<10.3f} 1.0x (基准)")
print(f"{'HF transformers':<20} {hf_total:<12.2f} {hf_throughput:<14.1f} {'N/A':<10} {hf_throughput/vllm_throughput:.1f}x")
print(f"{'─' * 60}")
print(f"\n💡 观察要点:")
print(f" • vLLM 用 PagedAttention (类似虚拟内存分页) 消除显存碎片")
print(f" • Continuous Batching: 请求完成即释放槽位,新请求立即加入")
print(f" • HF transformers 无批处理调度,10 个请求排队串行执行")
print(f" • vLLM 的 TTFT (首 token 延迟) 通常 < 0.3s,用户体验流畅")
print(f" • 生产环境部署 LLM 首选: vLLM / TGI / TensorRT-LLM / SGLang")
✅ 预期输出:
============================================================
1. 单请求测试 (验证服务可用)
============================================================
回答: Transformer 是一种基于自注意力机制的神经网络架构,能并行处理序列数据。
生成 tokens: 35
============================================================
2. vLLM 并发压测 (10 并发请求)
============================================================
总耗时: 3.21s
平均 TTFT (首 token 延迟): 0.142s
总生成 tokens: 850
整体吞吐: 264.8 tokens/s
============================================================
3. HF transformers 对比 (同样 10 并发)
============================================================
⚠️ 确保已关闭 vLLM 服务以释放显存!
总耗时: 42.56s
总生成 tokens: 820
整体吞吐: 19.3 tokens/s
⚠️ HF transformers 无 Continuous Batching,并发请求被串行化
============================================================
📊 性能对比 (10 并发请求 × 100 tokens)
============================================================
引擎 总耗时(s) 吞吐(tok/s) 平均TTFT 加速比
────────────────────────────────────────────────────────────
vLLM 3.21 264.8 0.142 1.0x (基准)
HF transformers 42.56 19.3 N/A 13.7x
────────────────────────────────────────────────────────────
💡 观察要点:
• vLLM 用 PagedAttention (类似虚拟内存分页) 消除显存碎片
• Continuous Batching: 请求完成即释放槽位,新请求立即加入
• HF transformers 无批处理调度,10 个请求排队串行执行
• vLLM 的 TTFT (首 token 延迟) 通常 < 0.3s,用户体验流畅
• 生产环境部署 LLM 首选: vLLM / TGI / TensorRT-LLM / SGLang
⚠️ 常见报错:
- ConnectionError: Connection refused — vLLM 服务未启动 → 解决:先在终端运行
vllm serve Qwen/Qwen2.5-7B-Instruct --port 8000,等看到 Application startup complete。
- ImportError: No module named 'vllm' — vLLM 需要 CUDA + Linux → 解决:在 Colab 或 GPU 服务器上
pip install vllm,不支持纯 CPU/Mac。
- openai.NotFoundError: model not found — 模型名不匹配 → 解决:用
curl http://localhost:8000/v1/models 查看可用模型名,确保 client 用的名称一致。
- CUDA out of memory (HF 阶段) — vLLM 和 HF 同时占显存 → 解决:先
Ctrl+C 关闭 vLLM 服务,再运行 HF 对比部分,或用 Qwen2.5-3B。
- TTFT 测量为 0 — 流式未正确处理 → 解决:确认
stream=True,遍历 stream 时检查 chunk.choices[0].delta.content 非空。
- vLLM 启动报错: CUDA driver version → 解决:检查
nvidia-smi,更新 CUDA driver,确保 vLLm 版本与 CUDA 版本兼容。
🚀 挑战:
- 把并发数从 10 改到 50 / 100,观察 vLLM 吞吐量的变化曲线(会先升后降,找到拐点)。
- 启用 vLLM 的
--quantization awq 加载量化模型,对比量化前后的吞吐和延迟。
- 测试不同
max_tokens (50 / 200 / 1000) 对 TTFT 和吞吐的影响。
- 思考: 为什么 HF transformers 并发反而比串行还慢?提示: GPU 上下文切换开销 + 显存碎片。
9
Agent 开发
高级
⏱ 60分钟
🎯 目标: 用 LangChain 的 create_react_agent 构建能调用工具的智能体 (Agent),理解 ReAct 范式 (Thought → Action → Observation 循环)。Agent 能自动拆解任务、选择工具、观察结果、得出结论。
📦 环境: Python 3.10+,无需 GPU,但需要 LLM API。支持 OpenAI API 或本地 Ollama。
# 方式 A: OpenAI API
pip install langchain langchain-openai
export OPENAI_API_KEY="sk-xxxx"
# 方式 B: 本地 Ollama (免费)
# 先安装: https://ollama.com 然后:
ollama pull qwen2.5:7b
pip install langchain langchain-ollama
💻 完整代码:
# -*- coding: utf-8 -*-
"""实验 9: Agent 开发 - 用 LangChain ReAct Agent 构建工具调用智能体
自动检测后端: 有 OPENAI_API_KEY 用 OpenAI,否则用本地 Ollama"""
import os
import io
import sys
# ============ 1. 配置 LLM 后端 ============
USE_OPENAI = bool(os.getenv("OPENAI_API_KEY"))
if USE_OPENAI:
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
print("后端: OpenAI gpt-4o-mini")
else:
from langchain_ollama import ChatOllama
llm = ChatOllama(model="qwen2.5:7b", temperature=0)
print("后端: Ollama qwen2.5:7b (本地)")
# ============ 2. 定义三个工具 ============
# 工具是 Agent 的「手脚」,LLM 是 Agent 的「大脑」
from langchain_core.tools import tool
@tool
def calculator(expression: str) -> str:
"""计算数学表达式。输入为可被 Python eval 的数学表达式,如 '25 * 37 + 100'。"""
try:
# 安全检查: 只允许数字和基本运算符
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return f"错误: 表达式含非法字符 '{expression}'"
result = eval(expression)
return f"计算结果: {expression} = {result}"
except Exception as e:
return f"计算错误: {e}"
@tool
def word_count(text: str) -> str:
"""统计文本的字数和字符数。输入为要统计的文本。"""
char_count = len(text)
chinese_chars = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
english_words = len([w for w in text.split() if w.isascii() and w.isalpha()])
return f"字符数: {char_count}, 中文字: {chinese_chars}, 英文词: {english_words}"
@tool
def python_repl(code: str) -> str:
"""执行 Python 代码并返回输出。可用来做计算、生成数据等。输入为 Python 代码字符串。"""
try:
old_stdout = sys.stdout
sys.stdout = buf = io.StringIO()
exec(code, {"__builtins__": __builtins__})
sys.stdout = old_stdout
output = buf.getvalue().strip()
return f"输出:\n{output}" if output else "代码执行完毕 (无输出)"
except Exception as e:
sys.stdout = old_stdout
return f"执行错误: {e}"
tools = [calculator, word_count, python_repl]
print(f"\n已加载 {len(tools)} 个工具: calculator, word_count, python_repl")
# ============ 3. 创建 ReAct Agent ============
# ReAct = Reason + Act: 模型先「思考」再「行动」,循环直到得出最终答案
from langchain.agents import create_react_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
# 提示模板: 告诉 LLM 可用的工具和 ReAct 格式
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个能使用工具的智能助手。"
"请根据问题选择合适的工具,"
"观察工具返回的结果,"
"逐步推理直到得出最终答案。"),
("user", "{input}"),
MessagesPlaceholder(variable_name="agent_scratchpad"), # 存放中间步骤
])
agent = create_react_agent(llm, tools, prompt)
agent_executor = AgentExecutor(
agent=agent, tools=tools,
verbose=True, # 打印 Thought → Action → Observation 链路
handle_parsing_errors=True, # LLM 输出格式错误时不崩溃,自动重试
max_iterations=10, # 最多循环 10 次,防止死循环
)
# ============ 4. 任务 1: 多步推理 (计算 + 统计) ============
print("\n" + "=" * 60)
print("任务 1: 计算 25 * 37 + 100,然后告诉我结果有几个字")
print("=" * 60)
result1 = agent_executor.invoke({
"input": "计算 25 * 37 + 100,然后告诉我结果有几个字"
})
print(f"\n📋 最终回答: {result1['output']}")
# ============ 5. 任务 2: 写代码并运行 ============
print("\n" + "=" * 60)
print("任务 2: 写 Python 代码计算斐波那契数列前 10 项并运行")
print("=" * 60)
result2 = agent_executor.invoke({
"input": "写一段 Python 代码计算斐波那契数列前 10 项,运行它并告诉我结果"
})
print(f"\n📋 最终回答: {result2['output']}")
# ============ 6. 总结 ============
print("\n" + "=" * 60)
print("✅ 实验完成!")
print("=" * 60)
print("💡 观察要点:")
print(" • Agent 自动拆解任务: 任务1 先计算 → 再统计字数 (两步)")
print(" • ReAct 范式: Thought (推理) → Action (调用工具) → Observation (观察结果)")
print(" • 工具是 Agent 的「手脚」,LLM 是 Agent 的「大脑」")
print(" • handle_parsing_errors=True 防止 LLM 格式错误时崩溃")
print(" • max_iterations 防止 Agent 陷入死循环")
print(" • 对比: 普通 LLM 只能生成文本,Agent 能「执行」代码和「调用」外部 API")
✅ 预期输出:
后端: OpenAI gpt-4o-mini
已加载 3 个工具: calculator, word_count, python_repl
============================================================
任务 1: 计算 25 * 37 + 100,然后告诉我结果有几个字
============================================================
> Entering new AgentExecutor chain...
Thought: 我需要先计算 25 * 37 + 100 的结果,然后统计结果有几个字
Action: calculator
Action Input: 25 * 37 + 100
Observation: 计算结果: 25 * 37 + 100 = 1025
Thought: 现在我需要统计 "1025" 有几个字
Action: word_count
Action Input: 1025
Observation: 字符数: 4, 中文字: 0, 英文词: 0
Thought: 我现在知道最终答案了
Final Answer: 25 * 37 + 100 = 1025,结果 "1025" 共有 4 个字符
> Finished chain.
📋 最终回答: 25 * 37 + 100 = 1025,结果 "1025" 共有 4 个字符
============================================================
任务 2: 写 Python 代码计算斐波那契数列前 10 项并运行
============================================================
> Entering new AgentExecutor chain...
Thought: 我需要写 Python 代码计算斐波那契数列前 10 项,然后用 python_repl 运行
Action: python_repl
Action Input: fib = [0, 1]
for i in range(8):
fib.append(fib[-1] + fib[-2])
print(fib)
Observation: 输出:
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Thought: 我现在知道最终答案了
Final Answer: 斐波那契数列前 10 项为: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
> Finished chain.
📋 最终回答: 斐波那契数列前 10 项为: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
============================================================
✅ 实验完成!
============================================================
💡 观察要点:
• Agent 自动拆解任务: 任务1 先计算 → 再统计字数 (两步)
• ReAct 范式: Thought (推理) → Action (调用工具) → Observation (观察结果)
• 工具是 Agent 的「手脚」,LLM 是 Agent 的「大脑」
• handle_parsing_errors=True 防止 LLM 格式错误时崩溃
• max_iterations 防止 Agent 陷入死循环
• 对比: 普通 LLM 只能生成文本,Agent 能「执行」代码和「调用」外部 API
⚠️ 常见报错:
- AuthenticationError: No API key provided — 未设 OpenAI key → 解决:
export OPENAI_API_KEY="sk-xxx",或改用 Ollama(需先 ollama serve)。
- ImportError: No module named 'langchain_openai' — LangChain 拆包后需单独装 → 解决:
pip install langchain-openai(OpenAI)或 pip install langchain-ollama(Ollama)。
- ConnectionError: Ollama — Ollama 服务未运行 → 解决:
ollama serve(终端另开一个窗口),确认 curl http://localhost:11434 返回 Ollama is running。
- Agent 陷入死循环 / 一直调用同一个工具 — LLM 推理能力不足 → 解决:设
max_iterations=10(已设),或换更强的模型(gpt-4o / qwen2.5:14b)。
- OutputParserException: Could not parse LLM output — LLM 输出格式不符合 ReAct 模板 → 解决:
handle_parsing_errors=True(已设),Agent 会自动重试并提示正确格式。
- python_repl 执行报错 — 代码有语法错误 → 解决:工具内已
try-except 捕获,错误会作为 Observation 返回给 Agent,Agent 会尝试修正代码。
🚀 挑战:
- 添加第 4 个工具
web_search(用 DuckDuckGo API),让 Agent 能搜索网络回答实时问题。
- 对比不同 LLM 的 Agent 能力: gpt-4o-mini vs gpt-4o vs qwen2.5:7b,观察推理步骤的准确性和效率。
- 设计一个需要 3 步以上的复合任务: "搜索今天的天气 → 计算 Celsius 转 Fahrenheit → 用中文总结",观察 Agent 的多步推理链路。
- 思考: Agent 和 Function Calling 有什么区别?提示: Function Calling 是单次调用,Agent 是循环调用直到完成任务。
10
多模态识图
高级
⏱ 45分钟
🎯 目标: 用 Qwen2-VL-2B 多模态模型对 3 类图片(风景照、网页截图、数据图表)做视觉问答,体验「视觉编码器 (ViT) + 语言模型 (LLM)」的协同工作。2B 小模型仅需 4GB 显存即可运行。
📦 环境: Python 3.10+,需要 GPU (4GB+ 显存)。Qwen2-VL-2B 是小模型,Colab T4 或 RTX 3050 均可。
pip install transformers torch Pillow requests
# 国内用户加速:
export HF_ENDPOINT=https://hf-mirror.com
💻 完整代码:
# -*- coding: utf-8 -*-
"""实验 10: 多模态识图 - 用 Qwen2-VL 对图片做视觉问答
⚠️ 需要 GPU (4GB+ 显存即可,2B 小模型)"""
import torch
from transformers import AutoProcessor, AutoModelForVision2Seq
from PIL import Image
import requests
from io import BytesIO
# ============ 1. 加载模型 ============
# Qwen2-VL-2B: 视觉编码器 (ViT) + 语言模型 (2B params)
# 4GB 显存即可运行,适合学习和实验
model_name = "Qwen/Qwen2-VL-2B-Instruct"
print(f"加载模型: {model_name}")
processor = AutoProcessor.from_pretrained(model_name)
model = AutoModelForVision2Seq.from_pretrained(
model_name,
torch_dtype=torch.float16,
device_map="auto",
)
print(f"显存占用: {torch.cuda.memory_allocated()/1e9:.2f} GB")
# ============ 2. 准备 3 类图片 ============
# 风景照: 测试场景描述能力
# 网页截图: 测试 UI 理解能力
# 数据图表: 测试数据读取能力
image_urls = {
"风景照": "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=640",
"网页截图": "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=640",
"数据图表": "https://images.unsplash.com/photo-1460925895917-afdab827c52f?w=640",
}
def load_image(url: str) -> Image.Image:
"""从 URL 加载图片为 PIL Image"""
resp = requests.get(url, timeout=15)
return Image.open(BytesIO(resp.content)).convert("RGB")
# ============ 3. 视觉问答函数 ============
def ask_image(image: Image.Image, question: str) -> str:
"""对图片提问,返回模型回答"""
# Qwen2-VL 的消息格式: content 是一个列表,可包含 image 和 text
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": image},
{"type": "text", "text": question},
],
}
]
# 用 processor 把消息转成模型输入
text = processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
inputs = processor(
text=text, images=image,
return_tensors="pt",
).to(model.device, torch.float16)
# 生成回答
with torch.no_grad():
output_ids = model.generate(
**inputs, max_new_tokens=200, do_sample=False,
)
# 只取新生成的部分 (去掉输入 prompt)
new_tokens = output_ids[:, inputs["input_ids"].shape[1]:]
return processor.batch_decode(new_tokens, skip_special_tokens=True)[0]
# ============ 4. 对每张图片提问 ============
for category, url in image_urls.items():
print(f"\n{'=' * 60}")
print(f"📸 图片类型: {category}")
print(f"{'=' * 60}")
try:
image = load_image(url)
print(f" 图片尺寸: {image.size}")
except Exception as e:
print(f" ❌ 图片加载失败: {e}")
print(f" 提示: 可换用本地图片路径 Image.open('your.jpg')")
continue
# 通用问题: 描述图片内容
print(f"\n ❓ 描述这张图片的内容")
answer = ask_image(image, "描述这张图片的内容")
print(f" 💬 {answer}")
# 针对不同图片类型的特定问题
if category == "数据图表":
print(f"\n ❓ 这个图表显示了什么数据?")
answer = ask_image(image, "这个图表显示了什么数据?请列出关键数值。")
print(f" 💬 {answer}")
elif category == "风景照":
print(f"\n ❓ 图片中有哪些颜色?")
answer = ask_image(image, "图片中主要有哪些颜色?")
print(f" 💬 {answer}")
elif category == "网页截图":
print(f"\n ❓ 这个网页的布局是怎样的?")
answer = ask_image(image, "这个网页的布局结构是怎样的?有哪些主要区域?")
print(f" 💬 {answer}")
# ============ 5. 总结 ============
print(f"\n{'=' * 60}")
print("✅ 实验完成!")
print(f"{'=' * 60}")
print("💡 观察要点:")
print(" • 2B 小模型已能识别图片内容,但细节描述不如 7B/72B 大模型")
print(" • 对数据图表的数字读取能力有限,复杂图表可能出错")
print(" • 多模态模型 = 视觉编码器 (ViT) 提取图像特征 + LLM 理解和生成文本")
print(" • 实际应用场景: OCR 识别、商品图片描述、医疗影像辅助、自动驾驶等")
print(" • 模型越大能力越强: Qwen2-VL-7B / 72B 的图表理解能力远超 2B")
✅ 预期输出:
加载模型: Qwen/Qwen2-VL-2B-Instruct
显存占用: 4.12 GB
============================================================
📸 图片类型: 风景照
============================================================
图片尺寸: (640, 427)
❓ 描述这张图片的内容
💬 这是一张山脉风景照片,画面中有连绵的雪山,前景是绿色的草地和湖泊,天空晴朗,光线柔和。
❓ 图片中主要有哪些颜色?
💬 图片主要包含蓝色(天空和湖泊)、绿色(草地)、白色(雪山)和棕色(岩石)。
============================================================
📸 图片类型: 网页截图
============================================================
图片尺寸: (640, 427)
❓ 描述这张图片的内容
💬 这是一张数据分析仪表盘界面的截图,包含多个图表和数值指标卡。
❓ 这个网页的布局结构是怎样的?有哪些主要区域?
💬 网页采用仪表盘布局,顶部是标题和导航栏,中间区域分为多个卡片,分别展示不同数据图表。
============================================================
📸 图片类型: 数据图表
============================================================
图片尺寸: (640, 427)
❓ 描述这张图片的内容
💬 这是一张包含柱状图和折线图的数据可视化图表,展示了多个时间段的数值变化。
❓ 这个图表显示了什么数据?请列出关键数值。
💬 图表显示了从 1 月到 12 月的销售额(柱状图)和增长率(折线图),销售额在 6-8 月达到高峰。
============================================================
✅ 实验完成!
============================================================
💡 观察要点:
• 2B 小模型已能识别图片内容,但细节描述不如 7B/72B 大模型
• 对数据图表的数字读取能力有限,复杂图表可能出错
• 多模态模型 = 视觉编码器 (ViT) 提取图像特征 + LLM 理解和生成文本
• 实际应用场景: OCR 识别、商品图片描述、医疗影像辅助、自动驾驶等
• 模型越大能力越强: Qwen2-VL-7B / 72B 的图表理解能力远超 2B
⚠️ 常见报错:
- ConnectionError: 图片 URL 无法访问 — Unsplash 链接可能过期 → 解决:用本地图片
image = Image.open("your_photo.jpg") 替代,或换其他图片 URL。
- RuntimeError: CUDA out of memory — 显存不足 → 解决:用
torch_dtype=torch.float16,或换更小模型,或减小 max_new_tokens。
- ImportError: No module named 'PIL' — Pillow 未安装 → 解决:
pip install Pillow(注意包名是 Pillow 不是 PIL)。
- KeyError: 'image' in processor — Qwen2-VL processor API 变化 → 解决:确认
transformers 版本支持 Qwen2-VL (pip install -U transformers),或用 Qwen2VLForConditionalGeneration 替代 AutoModelForVision2Seq。
- 图片识别结果很差 / 乱码 — 2B 模型能力有限 → 解决:换
Qwen/Qwen2-VL-7B-Instruct (需 8GB+ 显存),或确保 torch_dtype=torch.float16。
- TypeError: apply_chat_template — 消息格式不对 → 解决:确保 content 是列表格式
[{"type": "image", ...}, {"type": "text", ...}],不是字符串。
🚀 挑战:
- 用自己的手机拍一张照片,让模型描述内容,测试对真实场景的理解能力。
- 对比 Qwen2-VL-2B 和 Qwen2-VL-7B 对同一张数据图表的理解精度,观察大模型的数字读取能力提升。
- 给模型一张包含中文文字的图片(如菜单、路标),测试 OCR 能力:
"请识别图片中的所有文字"。
- 思考: 多模态模型的视觉编码器和语言模型是如何连接的?提示: 视觉编码器输出图像 embedding → 通过投影层对齐到语言模型的 embedding 空间。