Files
ai-agent-book/chapter8/prompt-distillation/compare.py
T
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

356 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Prompt 蒸馏「蒸馏前 vs 蒸馏后」量化对比脚本。
本脚本回答实验 8-8 的核心问题:把「长提示 + 思考型教师」蒸馏成「无提示 + 直接
回答的学生」之后,到底省了多少、质量掉了多少?它在 **不加载任何大模型、不联网** 的
前提下,用真实数据算出一张 before/after 对比表:
1. 输入成本(token):教师每次调用都要带上完整的语言分类提示(约上千 token),
学生只需要原始待分类文本。二者的 token 差就是每次调用省下的输入开销。
2. 任务质量:直接读取 evaluate.py 产出的 evaluation_results.json,得到学生在
相同输入上「与教师标注的一致率」(即蒸馏保真度)。
3. 逐条案例:抽取若干条真实样本,并排展示 教师 token / 学生 token / 教师标签 /
学生预测 / 是否一致,让「多个案例上的 before/after」一目了然。
设计原则:所有数字都来自真实数据与真实分词器,不臆造。延迟(秒级响应时间)需要
在 GPU 上实测,本脚本不做估算,只报告可离线复现的 token 成本与质量。
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
VALID_LABELS = ["ar", "de", "el", "en", "es", "fr", "hi", "ru", "tr", "ur", "vi", "zh", "ot"]
def load_prompt_template(source_file: str) -> str:
"""从 create_data.py 中提取教师使用的语言分类提示模板(避免 import vllm)。"""
src = Path(source_file).read_text(encoding="utf-8")
match = re.search(
r'LANGUAGE_CLASSIFICATION_PROMPT\s*=\s*"""(.*?)"""',
src,
re.DOTALL,
)
if not match:
raise ValueError(
f"无法在 {source_file} 中找到 LANGUAGE_CLASSIFICATION_PROMPT 模板,"
f"请用 --prompt_source 指定包含该常量的文件。"
)
return match.group(1)
def build_token_counter(tokenizer_name: Optional[str]) -> Tuple[Callable[[str], int], str]:
"""
构造一个 token 计数函数,按优先级回退,保证离线可用。
返回 (counter, method_description)
1) 若指定 --tokenizer,用 HuggingFace 分词器精确计数(GPU 机器上可得到 Qwen 的真实 token 数)。
2) 否则用 tiktoken 的 o200k_baseGPT-4o/o1 分词器)作近似,可离线复现。
3) 再退化为「字符数 / 4」的粗略启发式,并明确标注为估算。
每种方法都会在输出里注明,绝不把近似值当成精确值。
"""
if tokenizer_name:
try:
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(tokenizer_name, trust_remote_code=True)
return (lambda s: len(tok.encode(s))), f"HuggingFace 分词器(精确): {tokenizer_name}"
except Exception as exc: # noqa: BLE001
print(f"[warn] 无法加载分词器 {tokenizer_name}{exc}),回退到 tiktoken。", file=sys.stderr)
try:
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
return (lambda s: len(enc.encode(s))), "tiktoken o200k_base(近似,可离线复现)"
except Exception as exc: # noqa: BLE001
print(f"[warn] tiktoken 不可用({exc}),回退到字符启发式。", file=sys.stderr)
return (lambda s: max(1, len(s) // 4)), "字符数/4(粗略估算)"
def load_texts(test_file: str) -> List[str]:
with open(test_file, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
def load_teacher_labels(train_data_file: str) -> Dict[str, str]:
"""从蒸馏训练数据(教师标注)中读取 文本 -> 教师标签 的映射。"""
mapping: Dict[str, str] = {}
if not Path(train_data_file).exists():
return mapping
with open(train_data_file, "r", encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
data = json.loads(line)
msgs = data.get("messages", [])
if len(msgs) >= 2:
mapping[msgs[0].get("content", "")] = msgs[1].get("content", "")
return mapping
def load_eval_results(eval_file: str) -> Optional[Dict]:
if not Path(eval_file).exists():
return None
with open(eval_file, "r", encoding="utf-8") as f:
return json.load(f)
def truncate(text: str, width: int = 42) -> str:
text = text.replace("\n", " ")
return text if len(text) <= width else text[: width - 1] + "…"
def compare(
prompt_template: str,
texts: List[str],
teacher_labels: Dict[str, str],
eval_results: Optional[Dict],
count_tokens: Callable[[str], int],
token_method: str,
num_examples: int,
) -> Dict:
n = len(texts)
# 固定提示开销(模板本身,不含待分类文本)
fixed_overhead = count_tokens(prompt_template.format(text=""))
teacher_input_total = 0
student_input_total = 0
per_text_tokens: List[Tuple[int, int]] = [] # (teacher_tokens, student_tokens)
for text in texts:
teacher_prompt = prompt_template.format(text=text)
t_tok = count_tokens(teacher_prompt)
s_tok = count_tokens(text)
teacher_input_total += t_tok
student_input_total += s_tok
per_text_tokens.append((t_tok, s_tok))
if n == 0:
teacher_avg = student_avg = reduction_pct = 0.0
ratio = float("inf")
else:
teacher_avg = teacher_input_total / n
student_avg = student_input_total / n
reduction_pct = (
100.0 * (1 - student_input_total / teacher_input_total)
if teacher_input_total
else 0.0
)
ratio = (
teacher_input_total / student_input_total
if student_input_total
else float("inf")
)
# 学生预测(与 test_file 逐行对齐)
student_preds: Optional[List[Optional[str]]] = None
accuracy = None
correct = evaluated = None
if eval_results:
student_preds = eval_results.get("predictions")
summary = eval_results.get("summary", {})
accuracy = summary.get("accuracy")
correct = summary.get("correct")
evaluated = summary.get("evaluated")
# 逐条案例:优先覆盖不同语言,并尽量各带上一致/不一致的例子
examples: List[Dict] = []
seen_labels = set()
for idx, text in enumerate(texts):
teacher_label = teacher_labels.get(text, "?")
student_pred = (
student_preds[idx] if student_preds and idx < len(student_preds) else None
)
key = teacher_label
if key in seen_labels and len(examples) >= num_examples:
continue
if len(examples) >= num_examples:
break
if key in seen_labels:
continue
seen_labels.add(key)
t_tok, s_tok = per_text_tokens[idx]
examples.append(
{
"text": text,
"teacher_tokens": t_tok,
"student_tokens": s_tok,
"teacher_label": teacher_label,
"student_pred": student_pred,
"match": (student_pred == teacher_label) if student_pred else None,
}
)
return {
"num_cases": n,
"token_method": token_method,
"fixed_prompt_overhead": fixed_overhead,
"teacher_input_total": teacher_input_total,
"teacher_input_avg": teacher_avg,
"student_input_total": student_input_total,
"student_input_avg": student_avg,
"input_token_reduction_pct": reduction_pct,
"teacher_student_ratio": ratio,
"student_accuracy": accuracy,
"student_correct": correct,
"student_evaluated": evaluated,
"examples": examples,
}
def print_report(r: Dict) -> None:
line = "=" * 78
print("\n" + line)
print("Prompt 蒸馏:蒸馏前 vs 蒸馏后 量化对比")
print(line)
print(f"样本数 : {r['num_cases']}")
print(f"Token 计数方式 : {r['token_method']}")
print(f"固定提示开销 : {r['fixed_prompt_overhead']} tokens(模板本身,每次调用都要重复付费)")
print("\n" + "-" * 78)
print("一、输入成本(每次调用的输入 token)")
print("-" * 78)
print(f"{'维度':<24}{'教师(长提示+思考)':>20}{'学生(无提示)':>18}")
print(f"{'单条平均输入 token':<24}{r['teacher_input_avg']:>20.1f}{r['student_input_avg']:>18.1f}")
print(f"{'全量总输入 token':<24}{r['teacher_input_total']:>20,}{r['student_input_total']:>18,}")
print(
f"\n→ 输入 token 降低 {r['input_token_reduction_pct']:.1f}%"
f"(教师是学生的 {r['teacher_student_ratio']:.1f} 倍)。"
)
print(" 按输入 token 计费的 API 上,这一项直接等比例降低费用;教师端还有未计入的")
print(" 思考(CoT)输出 token,实际差距只会更大。延迟需在 GPU 上实测,此处不估算。")
print("\n" + "-" * 78)
print("二、任务质量(学生在相同输入上与教师标注的一致率 = 蒸馏保真度)")
print("-" * 78)
if r["student_accuracy"] is not None:
print(
f"教师(基准) : 100.00% 学生(蒸馏后) : {r['student_accuracy'] * 100:.2f}%"
f" ({r['student_correct']}/{r['student_evaluated']})"
)
print(
f"→ 无提示、无思考的学生保留了教师约 {r['student_accuracy'] * 100:.1f}% 的判断,"
f"质量损失约 {(1 - r['student_accuracy']) * 100:.1f} 个百分点。"
)
else:
print("未找到 evaluation_results.json(学生尚未评估)。先运行 evaluate.py 生成,")
print("再回来看这一栏。本栏缺失不影响上面的输入成本对比。")
print("\n" + "-" * 78)
print(f"三、逐条案例({len(r['examples'])} 例)")
print("-" * 78)
print(f"{'待分类文本':<44}{'教师tok':>8}{'学生tok':>8}{'教师':>6}{'学生':>6}{'一致':>6}")
for ex in r["examples"]:
if ex["match"] is None:
mark = "—"
else:
mark = "✓" if ex["match"] else "✗"
pred = ex["student_pred"] if ex["student_pred"] else "—"
print(
f"{truncate(ex['text']):<44}"
f"{ex['teacher_tokens']:>8}{ex['student_tokens']:>8}"
f"{ex['teacher_label']:>6}{pred:>6}{mark:>6}"
)
print(line + "\n")
def main():
parser = argparse.ArgumentParser(
description="Prompt 蒸馏「蒸馏前 vs 蒸馏后」量化对比:离线算出输入成本、任务质量与逐条案例",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
parser.add_argument(
"--test_file",
type=str,
default="./example-data/multilingual.txt",
help="待分类文本文件(每行一句),作为对比的输入集合",
)
parser.add_argument(
"--train_data_file",
type=str,
default="./data/prompt_distillation_lang.jsonl",
help="蒸馏训练数据(教师标注),用于取教师标签作为质量基准",
)
parser.add_argument(
"--eval_results",
type=str,
default="./evaluation_results.json",
help="evaluate.py 产出的评估结果,用于读取学生的一致率(可选)",
)
parser.add_argument(
"--prompt_source",
type=str,
default="./create_data.py",
help="包含教师提示模板 LANGUAGE_CLASSIFICATION_PROMPT 的源文件",
)
parser.add_argument(
"--tokenizer",
type=str,
default=None,
help="可选:HuggingFace 分词器名/路径(如 Qwen/Qwen3-30B-A3B-Instruct-2507)。"
"指定后用它精确计数;不指定则用 tiktoken 近似,保证离线可跑",
)
parser.add_argument(
"--num_examples",
type=int,
default=10,
help="逐条案例展示的条数(尽量覆盖不同语言)",
)
parser.add_argument(
"--output_file",
type=str,
default=None,
help="可选:把对比结果(含逐条案例)保存为 JSON 的路径",
)
args = parser.parse_args()
if not os.path.exists(args.test_file):
raise FileNotFoundError(f"待分类文本文件不存在: {args.test_file}")
if not os.path.exists(args.prompt_source):
raise FileNotFoundError(f"提示模板源文件不存在: {args.prompt_source}")
prompt_template = load_prompt_template(args.prompt_source)
texts = load_texts(args.test_file)
teacher_labels = load_teacher_labels(args.train_data_file)
eval_results = load_eval_results(args.eval_results)
count_tokens, token_method = build_token_counter(args.tokenizer)
if not teacher_labels:
print(
f"[warn] 未从 {args.train_data_file} 读到教师标注,逐条案例的教师标签将显示为 '?'。",
file=sys.stderr,
)
if eval_results is None:
print(
f"[warn] 未找到 {args.eval_results},将只给出输入成本对比,跳过质量一栏。",
file=sys.stderr,
)
report = compare(
prompt_template=prompt_template,
texts=texts,
teacher_labels=teacher_labels,
eval_results=eval_results,
count_tokens=count_tokens,
token_method=token_method,
num_examples=args.num_examples,
)
print_report(report)
if args.output_file:
with open(args.output_file, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2, ensure_ascii=False)
print(f"📁 对比结果已保存到: {args.output_file}")
if __name__ == "__main__":
main()