← 返回首页
动手实验

🔧 10 个动手实验

从入门到进阶,每个实验含完整代码、预期输出、报错处理

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:用 PyPDFLoaderUnstructuredPDFLoader 加载,测试对长文档的切分效果。
  • 实现 多轮对话 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=Truebnb_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 defineddel 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 空间。