垂直领域问答助手开发
前段时间做了一个垂直领域的问答助手项目,从零把"数据合成→参数高效微调→量化压缩→本地部署"整条链路跑通了。网上碎片教程很多,但很多坑只有亲手踩过才知道,这里把完整方案和踩坑记录分享出来。基座模型用 Qwen3,显存 8G 就能玩。
一、整体方案
原始文档(txt)
│ LangChain + Ollama(qwen3) 批量生成问答对
▼
instruction-tuning 数据集 (JSON, 200+条)
│ bitsandbytes 4bit + LoRA (peft)
▼
QLoRA 微调后的基座模型
│ llama.cpp 转 GGUF → INT4 量化
▼
1GB 左右的量化模型, CPU/低端GPU 可跑
核心思路:没有现成语料就自己合成。用强模型(qwen3:7b)从非结构化文档里"出题",生成指令微调数据,再反哺小模型——这就是所谓的 Self-Instruct / 弱到强蒸馏的平民版。
二、环境
# 云端 GPU 机器
pip install langchain-core langchain-community langchain-text-splitters langchain-ollama
pip install torch transformers peft datasets bitsandbytes accelerate
pip install llama-cpp-python gguf
- 基座:Qwen3-1.7B(练习)/ 7B(生产),HuggingFace 可下
- 推理服务:Ollama,模型
qwen3:7b - 显存参考:1.7B 4bit 微调 ~6GB;7B 4bit 微调 ~12GB(batch 调小可行)
三、第一步:用 LangChain 从文档合成指令数据
3.1 分块
文档先切块(chunk_size≈512),逐块喂给模型出题:
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, chunk_overlap=50,
separators=["\n\n", "\n", "。", ",", " ", ""],
)
chunks = splitter.split_text(text)
中文分隔符一定要给 "。",否则切出来的块会从句子中间断开。
3.2 Prompt 模板三要素
一个稳定的出题模板 = 角色设定 + 任务说明 + 严格的输出格式约束:
PROMPT = PromptTemplate.from_template(
"你是一个擅长总结和出题的AI助手,精通{domain}领域知识。\n"
"任务说明:请根据下面的【文档片段】,生成 {n} 个具体的问答对。\n"
"要求:\n"
"1. 问题必须基于片段中的事实,答案必须能在片段中找到依据;\n"
"2. 问题要具体、多样,不要重复;\n"
"3. 严格输出 JSON 格式,不要输出任何其他内容,格式如下:\n"
'[{{"instruction": "问题1", "input": "", "output": "答案1"}}, ...]\n\n'
"【文档片段】\n{chunk}\n"
)
3.3 ⚠️ 最大的坑:Qwen3 的 <think> 标签
Qwen3 默认开启思考模式,输出是:
<think>用户想要...我应该...</think>
[{"instruction": ...}]
直接 json.loads 必炸。解析前必须剥离思考段:
def strip_think(text):
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
def extract_json(text):
text = strip_think(text)
m = re.search(r"\[.*\]", text, flags=re.DOTALL) # 模型可能用```json```包裹
if not m: return None
try:
data = json.loads(m.group(0))
return data if isinstance(data, list) else None
except json.JSONDecodeError:
return None
3.4 数据质量控制
合成数据≠能用数据,三道过滤必须有:
def valid_item(item, domain, seen):
if not isinstance(item, dict): return None
ins = str(item.get("instruction","")).strip()
out = str(item.get("output","")).strip()
if len(ins) < 5 or len(out) < 10: return None # 太短=无效
key = ins[:40]
if key in seen: return None # 去重
seen.add(key)
return {"instruction": ins, "input": "", "output": out, "domain": domain}
实测 1.7B 模型重复率明显高于 7B,目标 200 条时每个文档要把分块循环复用(块不够就 round_i % len(chunks) 轮转),直到每域攒够 60+ 条。
四、第二步:QLoRA 微调
4.1 数据模板与 labels 对齐
用 ### 指令:\n{instruction}\n### 回答:\n{output} 的朴素模板(比 chat template 更适合让小模型学格式),只对答案部分计算 loss:
def build_example(tokenizer, item, max_len):
prompt = f"### 指令:\n{item['instruction']}\n### 回答:\n"
target = item["output"] + tokenizer.eos_token
prompt_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
full_ids = tokenizer(prompt + target, add_special_tokens=False)["input_ids"][:max_len]
labels = [-100] * min(len(prompt_ids), len(full_ids)) + full_ids[len(prompt_ids):]
return {"input_ids": full_ids, "labels": labels, "attention_mask": [1]*len(full_ids)}
4.2 4bit 加载 + LoRA 挂载
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NF4 精度优于普通 int4
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(base, quantization_config=bnb_config, device_map="auto")
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=8, lora_alpha=16, lora_dropout=0.05,
target_modules=["q_proj","k_proj","v_proj","o_proj"],
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
# trainable params: 3.2M / 1.7B = 0.19%
0.19% 的可训练参数,单卡轻松跑。
4.3 三个编译不过的坑(transformers 5.x 实测)
apply_chat_template返回 BatchEncoding 不是 tensor:推理时要取enc["input_ids"].to(device),直接.to()会 AttributeErrorwarmup_ratio参数被移除:用warmup_steps=10(新旧版本都兼容)- 自定义 labels 的数据集不能用
DataCollatorForLanguageModeling(报 excessive nesting),换DataCollatorForSeq2Seq(tokenizer, padding=True)——它会自动用 -100 pad labels
训练参数(200条数据实测):
TrainingArguments(
num_train_epochs=3, per_device_train_batch_size=4,
gradient_accumulation_steps=4, learning_rate=2e-4,
lr_scheduler_type="cosine", warmup_steps=10,
bf16=True, eval_strategy="steps", eval_steps=50,
)
# 1 epoch 15 秒, 3 epoch 后 loss 3.2 → 1.1
4.4 合并权重保存
4bit 模型不能直接 merge。要重新以 FP16 加载基座再合并:
del model, trainer; torch.cuda.empty_cache() # 先释放
fp16 = AutoModelForCausalLM.from_pretrained(base, torch_dtype=torch.float16, device_map="auto")
merged = PeftModel.from_pretrained(fp16, "lora_adapter").merge_and_unload()
merged.save_pretrained("checkpoint-best", safe_serialization=True)
tokenizer.save_pretrained("checkpoint-best")
检查产物:config.json + model.safetensors + tokenizer.json 三件套齐,from_pretrained 直接可用。
五、第三步:GGUF INT4 量化
FP16 的 1.7B 有 3.4GB,量化后 1GB,这是能塞进 8GB 显存/纯 CPU 部署的关键。
# 1. HF 格式 → GGUF (convert.py 是旧名, 新版叫 convert_hf_to_gguf.py)
python llama.cpp/convert_hf_to_gguf.py checkpoint-best \
--outfile base.gguf --outtype f16
# 2. FP16 → INT4
./llama.cpp/build/bin/llama-quantize base.gguf model_int4.gguf q4_0
实测坑:
- f16 大文件在容器/overlay 文件系统可能写失败(报 Not enough free space 但 df 显示充足)→ 改
--outtype q8_0,quantize 加--allow-requantize - quantize 完记得对比体积:3.44GB → 1.05GB(3.3x)
六、第四步:部署与冒烟测试
6.1 llama-cpp-python 推理
from llama_cpp import Llama
llm = Llama(model_path="model_int4.gguf", n_ctx=2048, n_gpu_layers=99, verbose=False)
out = llm("### 指令:\n什么是金融危机?\n### 回答:\n",
max_tokens=96, temperature=0.1,
repeat_penalty=1.15, # 防复读, 必加!
stop=["###"])
print(out["choices"][0]["text"].strip())
repeat_penalty=1.15 是血泪教训:不加的话小模型很容易输出"用口语化表达。用简单词汇。用口语化表达..."的复读机模式。
6.2 更省事的替代:GGUF 挂进 Ollama
echo "FROM ./model_int4.gguf" > Modelfile
ollama create my-int4 -f Modelfile
ollama run my-int4 "你是谁?"
6.3 冒烟测试怎么写
固定 3 个问题(身份题+通识题+领域题),每题记录:输出文本(查乱码/复读/断裂)、token 数、耗时、tok/s,落一份报告文件。实测量化后 155 tok/s(4090),与量化前持平,质量无肉眼可见差异。
七、总结
| 环节 | 耗时 | 关键点 |
|---|---|---|
| 数据合成 216 条 | ~40min | 剥 think 标签 + 去重过滤 |
| QLoRA 3 epochs | ~2min | labels 对齐、只学答案 |
| GGUF 量化 | ~3min | f16 写盘失败就 q8_0 |
| 冒烟部署 | ~5min | repeat_penalty 防复读 |
整条链路最贵的不是算力,是数据质量:合成数据宁可少不可脏,去重和长度过滤做扎实,微调才有意义。
欢迎评论区交流,踩坑互助。如果对你有帮助,点赞收藏支持一下~
八、完整代码附录(复制即用)
8.1 数据合成 gen_dataset.py(全文)
# -*- coding: utf-8 -*-
"""
任务一:数据整理 —— langchain + ollama(qwen3) 批量生成指令微调数据集
用法:
python gen_dataset.py # 默认 qwen3:7b, 每域60条
python gen_dataset.py --model qwen3:0.5b # 快速测试用小模型
依赖:
pip install langchain-core langchain-community langchain-ollama
输出:
/home/user/workspace/model_b/data/dataset.json (>=200条, 每域>=60条)
要点(考场易错):
1. qwen3 默认输出 <think>...</think> 思考过程 -> 解析前必须剥离, 否则JSON解析失败
2. 模型可能把 JSON 包在 ```json ```代码块里 -> 用正则提取第一个 [...] 块
3. 读取 txt 一律 encoding="utf-8"
4. 去重与校验: instruction 非空且不重复, output 长度>10 才算有效数据
"""
import argparse
import json
import re
from pathlib import Path
from langchain_community.llms import Ollama
from langchain_core.prompts import PromptTemplate
from langchain_text_splitters import RecursiveCharacterTextSplitter
# ---------------- 配置 ----------------
DATA_DIR = Path("/home/user/workspace/data")
OUT_PATH = Path("/home/user/workspace/model_b/data/dataset.json")
# 三个领域文档 -> 主题标签
DOCS = {
"金融": DATA_DIR / "美国次贷危机.txt",
"医疗": DATA_DIR / "尿毒性心包炎.txt",
"法规": DATA_DIR / "生成式人工智能服务管理暂行办法.txt",
}
# ============== 步骤1-1: Prompt 模板(角色设定+任务说明+格式要求) ==============
PROMPT_TEMPLATE = PromptTemplate.from_template(
"你是一个擅长总结和出题的AI助手,精通{domain}领域知识。\n"
"任务说明:请根据下面的【文档片段】,生成 {n} 个具体的问答对(一个问题和它的标准答案)。\n"
"要求:\n"
"1. 问题必须基于片段中的事实,答案必须能在片段中找到依据;\n"
"2. 问题要具体、多样,不要重复;\n"
"3. 严格输出 JSON 格式,不要输出任何其他内容,格式如下:\n"
'[{{"instruction": "问题1", "input": "", "output": "答案1"}},\n'
' {{"instruction": "问题2", "input": "", "output": "答案2"}}]\n\n'
"【文档片段】\n{chunk}\n"
)
def strip_think(text: str) -> str:
"""剥离 qwen3 的 <think>...</think> 思考内容"""
return re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
def extract_json(text: str):
"""从模型输出中稳健地提取 JSON 列表(容错: 代码块包裹/前后废话)"""
text = strip_think(text)
m = re.search(r"\[.*\]", text, flags=re.DOTALL) # 找第一个 [...] 块
if not m:
return None
try:
data = json.loads(m.group(0))
return data if isinstance(data, list) else None
except json.JSONDecodeError:
return None
def valid_item(item, domain_label, seen):
"""校验一条数据是否'有效', 并打上主题标签"""
if not isinstance(item, dict):
return None
ins = str(item.get("instruction", "")).strip()
out = str(item.get("output", "")).strip()
if len(ins) < 5 or len(out) < 10: # 太短视为无效
return None
key = ins[:40] # 粗粒度去重
if key in seen:
return None
seen.add(key)
return {"instruction": ins, "input": "", "output": out,
"domain": domain_label} # domain 字段便于统计, 训练时忽略
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default="qwen3:7b", help="ollama 模型名")
ap.add_argument("--per-domain", type=int, default=60, help="每个领域目标条数")
ap.add_argument("--batch-per-chunk", type=int, default=4, help="每次调用生成条数")
ap.add_argument("--chunk-size", type=int, default=512, help="文档分块大小")
args = ap.parse_args()
# ============== 步骤1-2: 分块(chunk_size约512) + 循环调用 ==============
splitter = RecursiveCharacterTextSplitter(
chunk_size=args.chunk_size, chunk_overlap=50,
separators=["\n\n", "\n", "。", ",", " ", ""],
)
llm = Ollama(model=args.model, temperature=0.7)
seen = set()
dataset = []
for domain, path in DOCS.items():
text = path.read_text(encoding="utf-8")
chunks = splitter.split_text(text)
print(f"[{domain}] {path.name}: {len(text)}字 -> {len(chunks)} 块")
got = 0 # 该领域已生成条数
round_i = 0
while got < args.per_domain and round_i < len(chunks) * 6:
chunk = chunks[round_i % len(chunks)]
round_i += 1
prompt = PROMPT_TEMPLATE.format(
domain=domain, n=args.batch_per_chunk, chunk=chunk)
try:
resp = llm.invoke(prompt)
except Exception as e:
print(f" 调用失败(重试): {e}")
continue
for item in (extract_json(resp) or []):
v = valid_item(item, domain, seen)
if v:
dataset.append(v)
got += 1
print(f" [{domain}] 已生成 {got}/{args.per_domain} 条")
if got < args.per_domain:
print(f" 警告: [{domain}] 只生成 {got} 条(可增大重试上限或换模型)")
# ============== 步骤1-3: 保存 ==============
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUT_PATH.write_text(
json.dumps(dataset, ensure_ascii=False, indent=2), encoding="utf-8")
# 统计报告
from collections import Counter
c = Counter(d["domain"] for d in dataset)
print(f"\n完成! 共 {len(dataset)} 条 -> {OUT_PATH}")
print("领域分布:", dict(c))
assert len(dataset) >= 200, "总数不足200条!"
for k, n in c.items():
assert n >= 60, f"{k} 不足60条!"
if __name__ == "__main__":
main()
8.2 QLoRA 微调 qlora.py(全文)
# -*- coding: utf-8 -*-
"""
步骤2-2:QLoRA 微调脚本(50分)
对 /home/user/workspace/QwenPretrain/ 的 QWEN3 模型做 QLoRA 4-bit 微调。
训练数据: /home/user/workspace/model_b/data/dataset.json (任务一生成)
模板: 严格遵循 /home/user/workspace/tmp.doc:
"### 指令:\n{instruction}\n### 回答:\n{output}"
训练完合并 LoRA 权重, 保存完整模型到 /home/user/workspace/model_b/checkpoint-best/
用法:
python qlora.py # 完整微调
python qlora.py --epochs 1 --max-len 256 # 快速跑通(练习时先用)
依赖:
pip install torch transformers peft datasets bitsandbytes accelerate
"""
import argparse
import json
import os
import torch
from datasets import Dataset
from transformers import (AutoModelForCausalLM, AutoTokenizer, Trainer,
TrainingArguments, BitsAndBytesConfig, DataCollatorForSeq2Seq)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training, PeftModel
BASE_MODEL = "/home/user/workspace/QwenPretrain"
DATA_PATH = "/home/user/workspace/model_b/data/dataset.json"
ADAPTER_DIR = "/home/user/workspace/model_b/lora_adapter" # LoRA 适配器
MERGED_DIR = "/home/user/workspace/model_b/checkpoint-best" # 合并后完整模型(步骤2-3)
# ---------- 数据处理: 拼模板 + labels 对齐 ----------
def build_example(tokenizer, item, max_len):
"""按 tmp.doc 模板拼接; 提示部分 label=-100 只学答案"""
instruction = item["instruction"]
inp = item.get("input", "")
output = item["output"]
prompt = f"### 指令:\n{instruction}\n"
if inp:
prompt += f"### 输入:\n{inp}\n"
prompt += "### 回答:\n"
target = output + tokenizer.eos_token
prompt_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
full_ids = tokenizer(prompt + target, add_special_tokens=False)["input_ids"]
full_ids = full_ids[:max_len]
labels = [-100] * min(len(prompt_ids), len(full_ids)) + full_ids[len(prompt_ids):]
return {"input_ids": full_ids, "labels": labels,
"attention_mask": [1] * len(full_ids)}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--base-model", default=BASE_MODEL)
ap.add_argument("--epochs", type=int, default=3)
ap.add_argument("--batch", type=int, default=4)
ap.add_argument("--grad-accum", type=int, default=4)
ap.add_argument("--lr", type=float, default=2e-4)
ap.add_argument("--max-len", type=int, default=512)
args = ap.parse_args()
tokenizer = AutoTokenizer.from_pretrained(args.base_model, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
# ---------- 1. 4-bit 量化加载 (bitsandbytes) ----------
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4", # NF4 精度优于普通 int4
bnb_4bit_compute_dtype=torch.float16,
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
args.base_model,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
)
model.config.use_cache = False
# ---------- 2. LoRA 配置 ----------
model = prepare_model_for_kbit_training(model)
lora_config = LoraConfig(
r=8,
lora_alpha=16,
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# ---------- 3. 加载数据集 ----------
with open(DATA_PATH, encoding="utf-8") as f:
raw = json.load(f)
ds = Dataset.from_list([
build_example(tokenizer, it, args.max_len) for it in raw
]).train_test_split(test_size=0.05, seed=42)
print(f"训练 {len(ds['train'])} 条 / 验证 {len(ds['test'])} 条")
# ---------- 4. 训练参数 ----------
targs = TrainingArguments(
output_dir="/home/user/workspace/model_b/qlora_out",
num_train_epochs=args.epochs,
per_device_train_batch_size=args.batch,
per_device_eval_batch_size=args.batch,
gradient_accumulation_steps=args.grad_accum,
learning_rate=args.lr,
lr_scheduler_type="cosine",
warmup_steps=10, # 注: transformers 5.x 移除了 warmup_ratio, 用 steps 新旧版均兼容
logging_steps=10,
eval_strategy="steps", # 旧版 transformers 用 evaluation_strategy
eval_steps=50,
save_strategy="no", # 只存最终 adapter, 省 IO
bf16=torch.cuda.is_bf16_supported(),
report_to="none",
remove_unused_columns=False,
)
trainer = Trainer(
model=model, args=targs,
train_dataset=ds["train"], eval_dataset=ds["test"],
data_collator=DataCollatorForSeq2Seq(tokenizer, padding=True, pad_to_multiple_of=8),
)
# ---------- 5. 训练 + 保存 LoRA ----------
trainer.train()
os.makedirs(ADAPTER_DIR, exist_ok=True)
model.save_pretrained(ADAPTER_DIR)
tokenizer.save_pretrained(ADAPTER_DIR)
print(f"LoRA adapter 已保存: {ADAPTER_DIR}")
# ---------- 6. 合并 LoRA + 基座, 保存完整模型 (步骤2-3) ----------
# 4bit 模型不能直接 merge -> 重新以 fp16 加载基座再合并
del model, trainer
torch.cuda.empty_cache()
fp16_model = AutoModelForCausalLM.from_pretrained(
args.base_model, torch_dtype=torch.float16, device_map="auto",
trust_remote_code=True)
merged = PeftModel.from_pretrained(fp16_model, ADAPTER_DIR)
merged = merged.merge_and_unload()
os.makedirs(MERGED_DIR, exist_ok=True)
merged.save_pretrained(MERGED_DIR, safe_serialization=True) # 存 .safetensors
tokenizer.save_pretrained(MERGED_DIR)
print(f"合并模型已保存: {MERGED_DIR}")
print("验证: python inference.py --model", MERGED_DIR)
if __name__ == "__main__":
main()
8.3 推理验证 inference.py(全文)
# -*- coding: utf-8 -*-
"""
步骤2-1:基础模型加载验证(20分)
从 /home/user/workspace/QwenPretrain/ 加载 QWEN3 模型和 Tokenizer,
当输入 "你是谁?" 时输出合理自我介绍。裁判用此脚本检查基础模型可正常调用。
用法:
python inference.py # 默认问 "你是谁?"
python inference.py --q "什么是次贷危机?"
python inference.py --model /path/to/checkpoint-best # 也可验证微调后模型
依赖:
pip install torch transformers
"""
import argparse
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
MODEL_PATH = "/home/user/workspace/QwenPretrain" # 考场: /home/user/workspace/QwenPretrain/
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default=MODEL_PATH, help="模型目录")
ap.add_argument("--q", default="你是谁?", help="输入问题")
ap.add_argument("--max-new", type=int, default=256)
args = ap.parse_args()
# 1. 加载 tokenizer 和模型(GPU 可用则用 GPU)
tokenizer = AutoTokenizer.from_pretrained(args.model, trust_remote_code=True)
device = "cuda" if torch.cuda.is_available() else "cpu"
model = AutoModelForCausalLM.from_pretrained(
args.model,
torch_dtype=torch.float16 if device == "cuda" else torch.float32,
device_map=device,
trust_remote_code=True,
)
model.eval()
print(f"[已加载模型] {args.model} 设备: {device}")
# 2. 用 chat 模板构造输入(Qwen3 是对话模型,直接拼字符串效果差)
# 注意 transformers 5.x: apply_chat_template 返回 BatchEncoding, 取 input_ids
messages = [{"role": "user", "content": args.q}]
enc = tokenizer.apply_chat_template(
messages, add_generation_prompt=True,
tokenize=True, return_tensors="pt", return_dict=True,
enable_thinking=False, # 关闭 qwen3 思考模式, 直接出答案
)
inputs = enc["input_ids"].to(model.device)
# 3. 推理
with torch.no_grad():
out = model.generate(
inputs,
max_new_tokens=args.max_new,
do_sample=False, # 贪心解码, 输出稳定
temperature=None, top_p=None, top_k=None, # do_sample=False 时禁用采样参数
pad_token_id=tokenizer.eos_token_id,
)
response = tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)
# 若仍带思考标签则剥离
import re
response = re.sub(r"<think>.*?</think>", "", response, flags=re.DOTALL).strip()
print(f"问题: {args.q}")
print(f"回答: {response}")
if __name__ == "__main__":
main()
8.4 冒烟自检 self_check.py(全文)
# -*- coding: utf-8 -*-
"""
步骤3-3:本地冒烟测试与性能记录(15分)
加载量化后的 GGUF 模型, 回答 3 个固定问题, 记录输出与推理速度,
与原始模型(FP16, transformers 加载)对比, 写入 self_check_report.txt
用法:
pip install llama-cpp-python
python self_check.py # 默认测 model_int4.gguf
依赖: pip install llama-cpp-python transformers torch
"""
import json
import time
from pathlib import Path
from llama_cpp import Llama
GGUF_PATH = Path("/home/user/workspace/model_b/quantization/model_int4.gguf")
BASE_GGUF = Path("/home/user/workspace/model_b/quantization/base.gguf") # FP16 对照(存在则测)
REPORT_PATH = Path("/home/user/workspace/model_b/quantization/self_check_report.txt")
QUESTIONS = [
"你是谁?",
"请简要介绍人工智能。",
"什么是金融危机?",
]
def ask(llm, question, max_new=96):
"""单次推理, 返回 (回答文本, 生成token数, 耗时秒)"""
prompt = f"### 指令:\n{question}\n### 回答:\n" # 与微调模板一致
t0 = time.time()
out = llm(prompt, max_tokens=max_new, temperature=0.1,
stop=["###"], echo=False)
dt = time.time() - t0
text = out["choices"][0]["text"].strip()
n_tok = out["choices"][0].get("tokens_evaluated", 0) or len(text) // 2
return text, n_tok, dt
def bench(path, n_ctx=2048, n_gpu_layers=99): # GPU offload 全层
print(f"\n===== 测试: {path.name} =====")
llm = Llama(model_path=str(path), n_ctx=n_ctx,
n_gpu_layers=n_gpu_layers, verbose=False)
results = []
for q in QUESTIONS:
text, n_tok, dt = ask(llm, q)
speed = n_tok / dt if dt > 0 else 0.0
results.append({"q": q, "a": text, "tokens": n_tok, "sec": dt, "speed": speed})
print(f"Q: {q}\nA: {text[:80]}...\n [{n_tok} tok / {dt:.1f}s = {speed:.1f} tok/s]")
return results
def main():
lines = ["=" * 60, "量化模型本地自检报告 (self_check_report.txt)", "=" * 60]
int4 = bench(GGUF_PATH)
base = None
if BASE_GGUF.exists():
base = bench(BASE_GGUF) # FP16 GGUF 作为"原始模型"对照
lines.append(f"\n量化模型: {GGUF_PATH.name} (INT4 q4_0)")
s4 = [r["speed"] for r in int4]
avg4 = sum(s4) / len(s4)
lines.append(f"平均推理速度: {avg4:.1f} tok/s")
lines.append(f"文件大小: {GGUF_PATH.stat().st_size/1e9:.2f} GB")
for r in int4:
lines.append(f" Q: {r['q']}")
lines.append(f" A: {r['a'][:120]}")
lines.append(f" ({r['tokens']} tok / {r['sec']:.1f}s = {r['speed']:.1f} tok/s)")
ok = "正常" if len(r["a"]) > 4 and not _garbled(r["a"]) else "异常!"
lines.append(f" 输出质量: {ok}")
if base:
sb = [r["speed"] for r in base]
avgb = sum(sb) / len(sb)
lines.append(f"\n原始模型对照: base.gguf (FP16)")
lines.append(f"平均推理速度: {avgb:.1f} tok/s")
lines.append(f"文件大小: {BASE_GGUF.stat().st_size/1e9:.2f} GB")
lines.append(f"压缩比: {BASE_GGUF.stat().st_size/GGUF_PATH.stat().st_size:.1f}x")
lines.append(f"速度对比: INT4 是 FP16 的 {avg4/avgb:.2f}x")
lines.append("\n结论: " + (
"三个问题输出均连贯, 无乱码/重复/语义断裂, 量化模型可正常工作。"
if all(len(r["a"]) > 4 and not _garbled(r["a"]) for r in int4)
else "存在异常输出, 需返回步骤3-2调整量化参数!"))
REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
print(f"\n报告已写入: {REPORT_PATH}")
def _garbled(text):
"""粗查乱码: 常见替换字符/控制字符"""
return ("�" in text) or any(0 < ord(c) < 9 for c in text)
if __name__ == "__main__":
main()