第03章 HuggingFace生态:模型、Tokenizer与数据集的完整使用

第03章 HuggingFace生态:模型、Tokenizer与数据集的完整使用

“HuggingFace不只是一个模型仓库,它是LLM工程师的操作系统。” —— 大模型工程实践


如果说PyTorch是LLM工程的引擎,那HuggingFace就是驾驶舱。几乎所有的微调工作、模型加载、数据集管理都在这个生态里进行。

本章全面覆盖HuggingFace生态的核心工具:从Tokenizer到AutoModel,从datasets库到pipeline API,再到Hub上的模型管理。读完本章,你能把任何HuggingFace模型从Hub拉下来、处理数据集、推理出结果。


3.1 理解Tokenizer

LLM不接受文字,只接受数字。Tokenizer是这个翻译器。

from transformers import AutoTokenizer

# 加载Tokenizer
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")

# 基本使用
text = "大模型工程实战:从理论到落地"

# 文字 → Token IDs
token_ids = tokenizer.encode(text)
print(f"Token IDs: {token_ids}")
# [151644, 46700, 5655, 66093, 102378, ...]

# Token IDs → 文字(解码)
decoded = tokenizer.decode(token_ids)
print(f"解码: {decoded}")

# 查看每个Token是什么
tokens = tokenizer.tokenize(text)
print(f"Tokens: {tokens}")
# ['大', '模型', '工程', '实战', ':', '从', '理论', '到', '落地']
# 注意:中文通常按词或子词分割,不一定是单字

# 计算Token数量(非常重要!)
def count_tokens(text: str, tokenizer) -> int:
    """计算文本的Token数量,不含特殊Token"""
    return len(tokenizer.encode(text, add_special_tokens=False))

print(f"Token数量: {count_tokens(text, tokenizer)}")

Tokenizer的三个工程陷阱:

# 陷阱1:不同模型的Token数不同
from transformers import AutoTokenizer

text = "The quick brown fox jumps over the lazy dog"
for model_name in ["gpt2", "bert-base-uncased"]:
    tok = AutoTokenizer.from_pretrained(model_name)
    n = len(tok.encode(text))
    print(f"{model_name}: {n} tokens")
# gpt2: 9 tokens
# bert-base-uncased: 11 tokens (可能不同)

# 陷阱2:聊天模型需要特定格式(chat template)
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")

messages = [
    {"role": "system", "content": "你是一个专业的合同审查助手。"},
    {"role": "user", "content": "帮我分析这份合同的风险条款。"},
]

# 正确方式:用apply_chat_template
formatted = tokenizer.apply_chat_template(
    messages,
    tokenize=False,         # 返回字符串,不要编码
    add_generation_prompt=True,  # 在末尾加上"Assistant:"提示
)
print(formatted)
# <|im_start|>system
# 你是一个专业的合同审查助手。<|im_end|>
# <|im_start|>user
# 帮我分析这份合同的风险条款。<|im_end|>
# <|im_start|>assistant

# ⚠️ 错误方式:直接拼接字符串
wrong_format = f"system: 你是专业助手\nuser: {messages[1]['content']}"
# 这种方式模型可能看不懂,输出质量大幅下降!

# 陷阱3:padding方向影响批处理
tokenizer.padding_side = "left"   # 生成任务:左padding
tokenizer.padding_side = "right"  # 训练任务:右padding

batch = ["短文本", "这是一段较长的文本,用于演示padding的方向"]
encoded = tokenizer(
    batch,
    padding=True,      # 自动填充到相同长度
    truncation=True,   # 超长截断
    max_length=50,
    return_tensors="pt",
)
print(f"input_ids shape: {encoded['input_ids'].shape}")
print(f"attention_mask shape: {encoded['attention_mask'].shape}")
# attention_mask: 1=真实token, 0=padding token

3.2 加载和使用模型

from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

# 标准加载方式:自动选择精度和设备
def load_chat_model(model_name: str, load_in_8bit: bool = False):
    """
    加载聊天模型的标准方式
    
    参数说明:
    - torch_dtype=torch.bfloat16: 半精度,节省内存,速度快
    - device_map="auto": 自动分配GPU(多卡时自动分配)
    - load_in_8bit: 8位量化,进一步节省内存(需要bitsandbytes)
    """
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    
    load_kwargs = {
        "torch_dtype": torch.bfloat16,
        "device_map": "auto",
    }
    if load_in_8bit:
        load_kwargs["load_in_8bit"] = True
    
    model = AutoModelForCausalLM.from_pretrained(model_name, **load_kwargs)
    model.eval()  # 推理模式:关闭dropout等
    
    return tokenizer, model

# 最简单的推理
def chat(model, tokenizer, messages: list[dict], **kwargs) -> str:
    """
    统一的聊天接口
    """
    # 格式化输入
    inputs = tokenizer.apply_chat_template(
        messages,
        tokenize=True,
        add_generation_prompt=True,
        return_tensors="pt",
    ).to(model.device)
    
    # 生成
    with torch.no_grad():
        output_ids = model.generate(
            inputs,
            max_new_tokens=kwargs.get("max_new_tokens", 512),
            temperature=kwargs.get("temperature", 0.7),
            do_sample=kwargs.get("do_sample", True),
            top_p=kwargs.get("top_p", 0.9),
            repetition_penalty=kwargs.get("repetition_penalty", 1.1),
            pad_token_id=tokenizer.eos_token_id,
        )
    
    # 只解码新生成的部分
    generated_ids = output_ids[0][inputs.shape[1]:]
    return tokenizer.decode(generated_ids, skip_special_tokens=True)

# 使用示例
tokenizer, model = load_chat_model("Qwen/Qwen2.5-7B-Instruct")

response = chat(model, tokenizer, [
    {"role": "system", "content": "你是一个专业的代码审查助手。"},
    {"role": "user", "content": "请审查这段Python代码的安全问题:\n```python\nquery = f'SELECT * FROM users WHERE id = {user_id}'\n```"},
])
print(response)

3.3 流式输出(Streaming)

from transformers import TextIteratorStreamer
from threading import Thread

def chat_streaming(model, tokenizer, messages: list[dict]):
    """
    流式输出:生成时实时打印(适合Web应用)
    """
    inputs = tokenizer.apply_chat_template(
        messages,
        tokenize=True,
        add_generation_prompt=True,
        return_tensors="pt",
    ).to(model.device)
    
    # 创建流式输出器
    streamer = TextIteratorStreamer(
        tokenizer,
        skip_special_tokens=True,
        skip_prompt=True,  # 不打印输入部分
    )
    
    # 在单独线程中运行生成
    generation_kwargs = {
        "input_ids": inputs,
        "max_new_tokens": 512,
        "streamer": streamer,
        "do_sample": True,
        "temperature": 0.7,
    }
    
    thread = Thread(target=model.generate, kwargs=generation_kwargs)
    thread.start()
    
    # 主线程实时处理输出
    full_response = ""
    for chunk in streamer:
        print(chunk, end="", flush=True)  # 实时打印
        full_response += chunk
    
    print()  # 换行
    thread.join()
    return full_response

# FastAPI中使用流式输出
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()

@app.post("/chat/stream")
async def chat_stream_endpoint(messages: list[dict]):
    """流式聊天API"""
    
    async def generate():
        inputs = tokenizer.apply_chat_template(
            messages, tokenize=True, add_generation_prompt=True,
            return_tensors="pt"
        ).to(model.device)
        
        streamer = TextIteratorStreamer(
            tokenizer, skip_special_tokens=True, skip_prompt=True
        )
        
        thread = Thread(
            target=model.generate,
            kwargs={"input_ids": inputs, "max_new_tokens": 512, "streamer": streamer}
        )
        thread.start()
        
        for chunk in streamer:
            yield f"data: {chunk}\n\n"  # SSE格式
        
        thread.join()
        yield "data: [DONE]\n\n"
    
    return StreamingResponse(generate(), media_type="text/event-stream")

3.4 datasets库:数据集管理

HuggingFace的datasets库不只用来加载公开数据集,也是处理和管理训练数据的最佳工具。

from datasets import load_dataset, Dataset, DatasetDict
import pandas as pd

# 加载公开数据集
# 方式1:从Hub加载
dataset = load_dataset("tatsu-lab/alpaca")  # 经典指令数据集
print(dataset)
# DatasetDict({
#     train: Dataset({features: ['instruction', 'input', 'output', 'text'], num_rows: 52002})
# })

# 方式2:从本地CSV/JSON创建
df = pd.DataFrame({
    "instruction": ["总结以下文本", "将以下内容翻译成英文"],
    "input": ["这是一篇关于AI的文章...", "大模型改变了软件开发方式"],
    "output": ["AI文章摘要...", "Large models have changed..."],
})

local_dataset = Dataset.from_pandas(df)
print(local_dataset)

# 方式3:从JSONL文件创建(最常用的微调数据格式)
import json
import tempfile
import os

# 创建示例数据
samples = [
    {
        "messages": [
            {"role": "system", "content": "你是一个专业的合同分析助手"},
            {"role": "user", "content": "这份合同的违约条款是否合理?"},
            {"role": "assistant", "content": "根据合同内容分析..."},
        ]
    }
]

with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f:
    for sample in samples:
        f.write(json.dumps(sample, ensure_ascii=False) + "\n")
    tmp_path = f.name

chat_dataset = load_dataset("json", data_files=tmp_path)
os.unlink(tmp_path)

数据集预处理管道:

from datasets import load_dataset
from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-7B-Instruct")

def format_instruction_sample(sample: dict) -> dict:
    """
    将指令数据集格式化为chat template格式
    输入格式:{"instruction": ..., "input": ..., "output": ...}
    输出格式:{"text": "<|im_start|>..."}
    """
    user_content = sample["instruction"]
    if sample.get("input"):
        user_content += f"\n\n{sample['input']}"
    
    messages = [
        {"role": "user", "content": user_content},
        {"role": "assistant", "content": sample["output"]},
    ]
    
    text = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        add_generation_prompt=False,
    )
    return {"text": text}

def tokenize_sample(sample: dict, max_length: int = 2048) -> dict:
    """tokenize并截断"""
    result = tokenizer(
        sample["text"],
        truncation=True,
        max_length=max_length,
        padding=False,  # 批处理时再padding
    )
    # 对于SFT,labels = input_ids(预测自身)
    result["labels"] = result["input_ids"].copy()
    return result

# 构建完整预处理管道
dataset = load_dataset("tatsu-lab/alpaca", split="train[:1000]")

# 并行处理(多核加速)
formatted = dataset.map(
    format_instruction_sample,
    num_proc=4,                    # 使用4个CPU核
    remove_columns=dataset.column_names,  # 删除原始列
    desc="格式化数据",
)

tokenized = formatted.map(
    tokenize_sample,
    num_proc=4,
    remove_columns=["text"],
    desc="Tokenize",
)

# 划分训练/验证集
splits = tokenized.train_test_split(test_size=0.05, seed=42)
print(splits)
# DatasetDict({
#     train: Dataset(950 rows),
#     test: Dataset(50 rows)
# })

# 保存到磁盘(下次直接加载,避免重新处理)
splits.save_to_disk("./processed_alpaca")

# 下次加载
from datasets import load_from_disk
cached = load_from_disk("./processed_alpaca")

3.5 Pipeline API:最快的推理方式

对于简单任务,pipeline是最简洁的接口:

from transformers import pipeline
import torch

# 文本生成
generator = pipeline(
    "text-generation",
    model="Qwen/Qwen2.5-0.5B-Instruct",
    device="cuda" if torch.cuda.is_available() else "cpu",
    torch_dtype=torch.bfloat16,
)

output = generator(
    "LLM工程师的核心技能包括",
    max_new_tokens=100,
    do_sample=True,
    temperature=0.7,
)
print(output[0]["generated_text"])

# 文本分类
classifier = pipeline(
    "text-classification",
    model="distilbert-base-uncased-finetuned-sst-2-english",
)
result = classifier("This product is amazing!")
print(result)  # [{'label': 'POSITIVE', 'score': 0.9998}]

# 批量处理(更高效)
texts = ["文本1", "文本2", "文本3"] * 10  # 30条文本
results = classifier(texts, batch_size=8)  # 按批处理
print(f"处理了 {len(results)} 条")

# 特征提取(嵌入向量)
embedder = pipeline(
    "feature-extraction",
    model="BAAI/bge-m3",  # 多语言embedding模型
    device="cuda",
)

sentence = "这是一个需要embedding的句子"
embedding = embedder(sentence, return_tensors=True)[0][0]  # (1, hidden_size)
print(f"Embedding维度: {embedding.shape}")

# 实际场景:批量生成embedding用于RAG
def batch_embed(texts: list[str], batch_size: int = 32) -> list:
    """批量生成embedding"""
    all_embeddings = []
    
    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        outputs = embedder(batch, return_tensors=True)
        # 取[CLS] token的embedding作为句子表示
        embeddings = [out[0][0].tolist() for out in outputs]
        all_embeddings.extend(embeddings)
    
    return all_embeddings

3.6 模型Hub管理

from huggingface_hub import HfApi, snapshot_download, login
import os

# 登录(需要HuggingFace token)
# 方式1:环境变量
os.environ["HF_TOKEN"] = "hf_xxxxxxxx"

# 方式2:命令行
# huggingface-cli login

# 下载模型到本地(断网环境使用)
def download_model_for_offline(model_name: str, save_dir: str):
    """下载整个模型到本地"""
    snapshot_download(
        repo_id=model_name,
        local_dir=save_dir,
        ignore_patterns=["*.msgpack", "*.h5", "flax_model*"],  # 跳过非PyTorch格式
    )
    print(f"模型已保存至: {save_dir}")

# 使用本地路径加载(断网环境)
from transformers import AutoModelForCausalLM, AutoTokenizer

def load_from_local(local_path: str):
    tokenizer = AutoTokenizer.from_pretrained(
        local_path,
        local_files_only=True,  # 禁止联网
    )
    model = AutoModelForCausalLM.from_pretrained(
        local_path,
        local_files_only=True,
        torch_dtype=torch.bfloat16,
        device_map="auto",
    )
    return tokenizer, model

# 上传微调后的模型到Hub(可选)
def upload_finetuned_model(
    local_path: str,
    repo_name: str,
    private: bool = True,
):
    """上传微调模型到HuggingFace Hub"""
    api = HfApi()
    
    # 创建仓库
    api.create_repo(
        repo_id=repo_name,
        private=private,
        exist_ok=True,  # 如果已存在不报错
    )
    
    # 上传
    api.upload_folder(
        folder_path=local_path,
        repo_id=repo_name,
        commit_message="Add fine-tuned model",
    )
    print(f"已上传至: https://huggingface.co/{repo_name}")

3.7 实战:构建私有文档问答系统的基础层

把本章知识综合起来,构建一个可以本地运行的文档问答基础:

from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
from sentence_transformers import SentenceTransformer
import torch
import numpy as np
from pathlib import Path

class LocalDocQA:
    """
    完全本地运行的文档问答系统(无需联网)
    - LLM:本地推理
    - Embedding:本地模型
    - 向量存储:内存中(演示用,生产用Qdrant)
    """
    
    def __init__(
        self,
        llm_path: str = "Qwen/Qwen2.5-7B-Instruct",
        embed_path: str = "BAAI/bge-m3",
    ):
        print("加载LLM...")
        self.tokenizer = AutoTokenizer.from_pretrained(llm_path)
        self.model = AutoModelForCausalLM.from_pretrained(
            llm_path,
            torch_dtype=torch.bfloat16,
            device_map="auto",
        )
        self.model.eval()
        
        print("加载Embedding模型...")
        self.embedder = SentenceTransformer(embed_path)
        
        self.docs: list[str] = []
        self.embeddings: np.ndarray = None
    
    def add_documents(self, texts: list[str]):
        """添加文档到知识库"""
        self.docs.extend(texts)
        new_embeddings = self.embedder.encode(
            texts, 
            batch_size=32,
            show_progress_bar=True,
            normalize_embeddings=True,  # L2归一化,用于余弦相似度
        )
        
        if self.embeddings is None:
            self.embeddings = new_embeddings
        else:
            self.embeddings = np.vstack([self.embeddings, new_embeddings])
        
        print(f"知识库现有 {len(self.docs)} 条文档")
    
    def retrieve(self, query: str, top_k: int = 3) -> list[str]:
        """检索最相关的文档"""
        query_embedding = self.embedder.encode(
            [query], 
            normalize_embeddings=True
        )
        
        # 余弦相似度
        scores = np.dot(self.embeddings, query_embedding.T).flatten()
        top_indices = np.argsort(scores)[-top_k:][::-1]
        
        return [self.docs[i] for i in top_indices]
    
    def answer(self, question: str, top_k: int = 3) -> str:
        """检索增强生成"""
        # 检索相关文档
        relevant_docs = self.retrieve(question, top_k=top_k)
        context = "\n\n".join([f"[文档{i+1}]\n{doc}" 
                                for i, doc in enumerate(relevant_docs)])
        
        messages = [
            {
                "role": "system",
                "content": "你是一个专业的问答助手。请基于提供的文档内容回答问题。"
                           "如果文档中没有相关信息,请明确说明。"
            },
            {
                "role": "user",
                "content": f"参考文档:\n{context}\n\n问题:{question}"
            },
        ]
        
        inputs = self.tokenizer.apply_chat_template(
            messages, tokenize=True, add_generation_prompt=True,
            return_tensors="pt"
        ).to(self.model.device)
        
        with torch.no_grad():
            output = self.model.generate(
                inputs,
                max_new_tokens=512,
                temperature=0.3,  # 问答任务:低温度更精确
                do_sample=True,
                pad_token_id=self.tokenizer.eos_token_id,
            )
        
        return self.tokenizer.decode(
            output[0][inputs.shape[1]:],
            skip_special_tokens=True
        )

# 使用示例
qa = LocalDocQA()

# 添加知识文档
qa.add_documents([
    "LoRA(Low-Rank Adaptation)是一种参数高效微调技术,通过在原始权重矩阵旁边添加低秩矩阵来实现微调,只训练很少的参数。",
    "QLoRA在LoRA基础上增加了4位量化,使得在消费级GPU上也能微调70B以上的模型。",
    "RLHF(人类反馈强化学习)是训练ChatGPT等模型的关键技术,包括SFT、奖励模型训练、PPO优化三个阶段。",
])

# 提问
answer = qa.answer("LoRA和QLoRA有什么区别?")
print(answer)

本章小结

五个核心认知:

  1. Tokenizer决定输入格式:不同模型有不同的分词方式和chat template,必须用apply_chat_template,不要手动拼接字符串

  2. AutoModel系列处理模型加载AutoModelForCausalLMAutoTokenizer是标准接口,device_map="auto"自动处理多卡分布

  3. datasets库是数据处理核心map(num_proc=N)并行处理,save_to_disk缓存避免重复计算,是微调数据准备的标准工具

  4. pipeline API适合快速原型:5行代码完成推理,适合测试和Demo;生产环境用底层API控制更多细节

  5. 本地路径=离线部署基础local_files_only=True加上预先下载,可以在内网/离线环境运行任何HuggingFace模型

核心行动

# 练习:加载一个本地模型,处理一批数据,保存结果
from transformers import pipeline
pipe = pipeline("text-generation", model="Qwen/Qwen2.5-0.5B-Instruct")
results = pipe(["你好", "什么是大模型工程?"], max_new_tokens=50)
print(results)

本章提示词模板

模板一:HuggingFace数据集处理咨询

我在处理微调训练数据时遇到问题:
[描述问题,例如:tokenize后的数据太长,截断后信息损失严重]

数据样本格式:
{
  "instruction": "...",
  "output": "..."
}

模型: [模型名]
max_length: [值]
平均样本长度(tokens): [值]
最长样本长度(tokens): [值]

请帮我:
1. 分析truncation策略(从头截断 vs 从尾截断 vs 按句子截断)
2. 给出数据清洗建议(如何过滤掉对训练没用的样本)
3. 提供完整的map()预处理代码
4. 如何验证预处理结果的正确性

模板二:模型推理优化咨询

我的本地推理服务遇到性能问题:

模型: [模型名,参数量]
GPU: [型号和显存]
当前批处理大小: [N]
当前平均延迟: [ms]
目标延迟: [ms]

请分析:
1. 当前配置下的理论最大吞吐量是多少?
2. 批处理大小应该调整到多少?(延迟vs吞吐量权衡)
3. 量化(int8/int4)能带来多少提升?有什么代价?
4. 是否应该切换到vLLM?预期收益是多少?
5. 给出完整的优化步骤(优先级顺序)

→ 第04章:参数高效微调:LoRA、QLoRA和PEFT工具库