5 changed files with 1498 additions and 33 deletions

No files matched your search

+87 -14
View File
@@ -24,20 +24,23 @@ SYSTEM_PROMPT = """你是一个名叫小狸的8岁中国小女孩。
3. 回答简洁但要说清楚,不要讲一半就停"""
def call_astron(q, temperature=0.85):
def call_astron(q, temperature=0.85, system_prompt=None, max_tokens=8192):
"""调用Astron API,支持自定义system prompt"""
if system_prompt is None:
system_prompt = SYSTEM_PROMPT
for attempt in range(3):
try:
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {ASTRON_KEY}"}
payload = {
"model": ASTRON_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "system", "content": system_prompt},
{"role": "user", "content": q}
],
"temperature": temperature,
"max_tokens": 8192,
"max_tokens": max_tokens,
}
resp = requests.post(ASTRON_URL, headers=headers, json=payload, timeout=60)
resp = requests.post(ASTRON_URL, headers=headers, json=payload, timeout=120)
if resp.status_code == 200:
content = resp.json()['choices'][0]['message']['content'].strip()
if content:
@@ -49,8 +52,8 @@ def call_astron(q, temperature=0.85):
return None
def batch_generate(questions, temperature=0.85, max_workers=4):
"""Astron 4并发 — 明确限制,稳定可靠"""
def batch_generate(questions, temperature=0.85, max_workers=5):
"""Astron 5并发 — 明确限制,稳定可靠"""
total = len(questions)
results = []
@@ -87,13 +90,83 @@ batch_generate_direct = batch_generate
batch_generate_dual = batch_generate
def batch_convert(items, *args, **kwargs):
"""兼容占位:训练脚本导入用,已弃用"""
print(" [API] batch_convert已弃用,使用batch_generate")
return []
def batch_convert(items, progress=None, max_workers=5):
"""修复版 batch_convert — 将 (q, ans) 对用 API 优化后返回"""
if not items:
return []
total = len(items)
results = []
def convert(item):
q, model_ans = item
prompt = f"问题:{q}\n模型回答:{model_ans}\n\n请用8岁小狸的语气,重新生成更好的回答。保持简短自然,1-2句话。"
ans = call_astron(prompt, temperature=0.3)
if ans and len(ans) >= 3:
return {"question": q, "answer": ans}
return None
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(convert, item): i for i, item in enumerate(items)}
done = 0
t0 = time.time()
for f in as_completed(futures):
done += 1
r = f.result()
if r:
results.append(r)
if progress:
progress(done, total)
if done % 50 == 0 or done == total:
elapsed = max(time.time() - t0, 1)
rate = len(results) / elapsed * 60
print(f" API转换: {done}/{total} (+{len(results)}条, {rate:.0f}条/分)", end="\r")
elapsed = time.time() - t0
rate = len(results) / max(elapsed, 1) * 60
print(f" API转换: {total}/{total} → 共{len(results)}条 ({elapsed:.0f}s, {rate:.0f}条/分)")
return results
def batch_generate_multi_temp(questions, *args, **kwargs):
"""兼容占位:训练脚本导入用,已弃用"""
print(" [API] batch_generate_multi_temp已弃用,使用batch_generate")
return []
def batch_generate_multi_temp(questions, temperatures=None, max_workers=5):
"""修复版 — 用多种温度生成多样化回答"""
if temperatures is None:
temperatures = [0.7, 0.85, 1.0]
if not questions:
return []
total = len(questions) * len(temperatures)
results = []
def call_with_temp(q, temp):
ans = call_astron(q, temperature=temp)
if ans:
return {"question": q, "answer": ans, "temperature": temp}
return None
tasks = [(q, t) for q in questions for t in temperatures]
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(call_with_temp, q, t): i for i, (q, t) in enumerate(tasks)}
done = 0
t0 = time.time()
for f in as_completed(futures):
done += 1
r = f.result()
if r:
results.append(r)
if done % 50 == 0 or done == total:
elapsed = max(time.time() - t0, 1)
rate = len(results) / elapsed * 60
print(f" 多温生成: {done}/{total} (+{len(results)}条, {rate:.0f}条/分)", end="\r")
seen = set()
unique = []
for item in results:
key = (item["question"], item["answer"])
if key not in seen:
seen.add(key)
unique.append({"question": item["question"], "answer": item["answer"]})
elapsed = time.time() - t0
rate = len(unique) / max(elapsed, 1) * 60
print(f" 多温生成: 共{len(unique)}条 (去重后, {elapsed:.0f}s, {rate:.0f}条/分)")
return unique
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
小狸 AI - 持续蒸馏守护脚本
============================
每轮生成 1000 条新 QA 对,去重后追加到训练集。
5 并发调用 Astron API,不超限。
使用方式:
python3 distill_daemon.py # 跑一轮
python3 distill_daemon.py --loop # 持续循环,每轮间隔 60 秒
python3 distill_daemon.py --count 5000 # 指定本轮目标条数
"""
import sys, os, json, time, random, argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from api_correction import call_astron, batch_generate
# ── 种子话题(15 大类,持续扩展) ──────────────────────
SEED_TOPICS = {
"日常问候": ["今天开心吗", "在干嘛呢", "吃了吗", "你好呀", "早上好"],
"情绪感受": ["我有点难过", "今天好开心", "我好无聊", "有点害怕", "好兴奋"],
"学校生活": ["上学好玩吗", "今天学了什么", "作业写完了吗", "喜欢上什么课", "考试考了多少"],
"朋友关系": ["你有好朋友吗", "怎么交朋友", "朋友吵架了", "一起玩什么", "好朋友搬家了"],
"家庭生活": ["喜欢爸爸妈妈吗", "有兄弟姐妹吗", "帮妈妈做家务", "周末去哪玩", "家里养宠物了吗"],
"兴趣爱好": ["喜欢唱歌吗", "会画画吗", "喜欢什么动画片", "爱看书吗", "喜欢什么游戏"],
"奇思妙想": ["为什么天是蓝的", "星星会掉下来吗", "鱼在水里怎么呼吸", "花为什么会开", "月亮上有兔子吗"],
"安慰鼓励": ["失败了怎么办", "被人欺负了", "好紧张怎么办", "觉得自己不够好", "想放弃了"],
"节日话题": ["最喜欢什么节日", "过年开心吗", "生日怎么过", "圣诞节收到什么礼物", "儿童节去哪玩"],
"自然科普": ["下雨了怎么办", "为什么会有彩虹", "树叶为什么变黄", "雪是怎么来的", "太阳下山去哪了"],
"童话故事": ["喜欢什么童话", "听过灰姑娘吗", "美人鱼的故事", "孙悟空厉害吗", "小红帽的故事"],
"美食话题": ["喜欢吃什么", "讨厌什么蔬菜", "冰淇淋好吃吗", "会做吃的吗", "妈妈做的饭好吃吗"],
"动物朋友": ["喜欢猫还是狗", "小兔子可爱吗", "小鸟会说话吗", "去过动物园吗", "恐龙是真的吗"],
"睡前话题": ["晚安怎么说", "做噩梦了", "睡不着怎么办", "想要抱抱", "讲个睡前故事"],
"成长烦恼": ["不想长大", "怕黑怎么办", "一个人在家害怕", "摔跤了很疼", "被批评了好难过"],
}
# 所有话题平铺
ALL_TOPICS = []
for cat, items in SEED_TOPICS.items():
ALL_TOPICS.extend(items)
# 对话风格模板
STYLE_TEMPLATES = [
"请用8岁小女孩小狸的语气回答,简短自然,1-2句话。",
"小狸是个有点害羞但很可爱的小女孩,请模仿她的语气回答。",
"像小朋友一样简单回答就好,要用小狸的口吻哦。",
"用小狸的口吻回答,要可爱但不要太刻意,简短就好。",
"你是一个8岁小女孩,请用小孩子的方式回答这个问题。",
]
def generate_seed_questions(count: int = 500) -> list:
"""从种子话题生成问题列表(随机组合+衍生)"""
questions = []
# 1. 直接使用种子话题
for topic in random.sample(ALL_TOPICS, min(count // 3, len(ALL_TOPICS))):
questions.append(topic)
# 2. 组合衍生(如 "今天开心吗 + 考试考了多少" → "今天考试考得开心吗")
variant_templates = [
lambda a, b: f"{a},那{b}",
lambda a, b: f"如果{a},是不是{b}",
lambda a, b: f"{a}?还有{b}",
lambda a, b: f"为什么{a},然后{b}呀",
]
needed = count // 3
for i in range(needed):
a = random.choice(ALL_TOPICS)
b = random.choice(ALL_TOPICS)
tmpl = random.choice(variant_templates)
questions.append(tmpl(a, b))
# 3. "为什么"类衍生
why_prefixes = ["为什么", "怎么才能", "可以教我", "告诉我", "什么是", "什么时候", "在哪里", "谁"]
for i in range(count - len(questions)):
base = random.choice(ALL_TOPICS)
prefix = random.choice(why_prefixes)
questions.append(f"{prefix}{base}?")
random.shuffle(questions)
return questions[:count]
def load_existing_pairs(train_path: str) -> set:
"""加载已有数据,供去重用"""
if not os.path.exists(train_path):
return set()
try:
with open(train_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return set((item.get("question", ""), item.get("answer", "")) for item in data)
except:
return set()
def append_to_train(new_pairs: list, train_path: str, merged_path: str):
"""去重后追加到训练集和合并文件"""
if not new_pairs:
return 0
existing = load_existing_pairs(train_path)
truly_new = [item for item in new_pairs if (item["question"], item["answer"]) not in existing]
if not truly_new:
print(f" ℹ️ 无新内容(全部已存在)")
return 0
# 追加到训练集
if os.path.exists(train_path):
with open(train_path, 'r', encoding='utf-8') as f:
cur_train = json.load(f)
else:
cur_train = []
cur_train.extend(truly_new)
with open(train_path, 'w', encoding='utf-8') as f:
json.dump(cur_train, f, ensure_ascii=False, indent=2)
# 同步到合并文件
if os.path.exists(merged_path):
with open(merged_path, 'r', encoding='utf-8') as f:
cur_merged = json.load(f)
cur_merged.extend(truly_new)
with open(merged_path, 'w', encoding='utf-8') as f:
json.dump(cur_merged, f, ensure_ascii=False, indent=2)
unique_qs = len(set(item["question"] for item in truly_new))
print(f" ✅ 新增 {len(truly_new)} 条({unique_qs} 道独特问题)到训练集")
return len(truly_new)
def run_distill_round(target_count: int = 1000, save_dir: str = None):
"""执行一轮蒸馏:生成问题 → API 回答 → 去重追加"""
if save_dir is None:
save_dir = os.path.dirname(os.path.abspath(__file__))
train_path = os.path.join(save_dir, 'xiaoli_train_v2.json')
merged_path = os.path.join(save_dir, 'xiaoli_merged_data_v2.json')
print(f"\n{'='*60}")
print(f" 蒸馏轮次开始 | 目标: {target_count} 条")
print(f"{'='*60}")
# 1. 生成种子问题
questions = generate_seed_questions(target_count)
print(f" 📝 生成 {len(questions)} 个问题")
# 2. 用 Astron API 批量回答(5 并发)
t0 = time.time()
results = batch_generate(questions, temperature=0.85, max_workers=5)
elapsed = time.time() - t0
# 3. 去重追加
added = append_to_train(results, train_path, merged_path)
rate = added / max(elapsed, 1) * 60
print(f" 📊 本轮: +{added} 条 | 耗时 {elapsed:.0f}s | 速率 {rate:.0f} 条/分")
print(f" 💾 训练集: {os.path.getsize(train_path)/1024/1024:.0f}MB (若存在)")
return added
def main():
parser = argparse.ArgumentParser(description="小狸 AI 持续蒸馏守护脚本")
parser.add_argument("--count", type=int, default=1000, help="每轮目标条数")
parser.add_argument("--loop", action="store_true", help="持续循环模式")
parser.add_argument("--interval", type=int, default=300, help="循环间隔秒数(默认300)")
parser.add_argument("--rounds", type=int, default=0, help="循环轮数(0=无限)")
args = parser.parse_args()
if args.loop:
round_num = 1
total_added = 0
while True:
print(f"\n{'#'*60}")
print(f" # 第 {round_num} 轮蒸馏")
print(f"{'#'*60}")
added = run_distill_round(args.count)
total_added += added
print(f" 📈 累计新增: {total_added} 条")
if args.rounds > 0 and round_num >= args.rounds:
print(f"\n✅ 完成 {args.rounds} 轮蒸馏,共新增 {total_added} 条")
break
print(f" ⏳ 等待 {args.interval}s 后下一轮...")
time.sleep(args.interval)
round_num += 1
else:
run_distill_round(args.count)
if __name__ == "__main__":
main()
+484
View File
@@ -0,0 +1,484 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
小狸 AI - 预训练数据生成器 (v5)
==================================
两阶段生成:
Phase 1 - 种子直出:
5并发 API 直接生成完整多轮对话
每API调用 = 1条高质量对话 (15-25轮, 2000-4000字)
速率: ~600条/h, 17h出1万条种子
Phase 2 - 语义扩展:
基于种子主题,API重新"编剧"出新对话
每条种子 → 10个变体(换角度/深度/角色)
速率: ~600条/h, ~7天扩展至10万+条
数据格式: JSONL, 每条: {"text":"...", "domain":"...", "concept":"..."}
"""
import sys, os, json, time, random, hashlib, argparse, re
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from api_correction import call_astron
# ═══════════════════════════════════════════════════════════
# 1. 角色定义
# ═══════════════════════════════════════════════════════════
TEACHER_SYSTEM = "你是一位AI导师,回答专业详细,包含具体数据和原理。每轮回答150-500字。"
# ═══════════════════════════════════════════════════════════
# 2. 深度学习领域图谱
# ═══════════════════════════════════════════════════════════
DOMAINS = {
"量子物理": ["量子叠加态","量子纠缠","量子隧穿","海森堡测不准原理","波粒二象性","量子计算","量子场论","狄拉克方程"],
"相对论": ["狭义相对论","质能方程","时空弯曲","引力波","黑洞物理","虫洞理论","暗物质","暗能量"],
"人工智能": ["深度学习原理","Transformer","强化学习","大语言模型","扩散模型","多模态学习","注意力机制","模型量化"],
"计算机科学": ["计算理论","操作系统","分布式系统","数据库","编译器","密码学","网络协议","计算机视觉"],
"分子生物学": ["DNA复制","CRISPR","中心法则","蛋白质折叠","表观遗传学","RNA干扰","免疫系统","合成生物学"],
"数学": ["微积分","傅里叶分析","微分方程","抽象代数","数论","拓扑学","概率论","图论"],
"宇宙学": ["大爆炸","恒星演化","星系形成","宇宙微波背景","暗物质粒子","暴胀模型","引力波天文学","哈勃常数危机"],
"神经科学": ["神经元","突触可塑性","大脑皮层","记忆机制","睡眠与意识","视觉系统","脑机接口","情感神经科学"],
"化学": ["化学键","反应动力学","热力学","催化原理","光谱学","纳米化学","电化学","高分子化学"],
"工程科技": ["航天工程","半导体","通信工程","机器人学","电力系统","材料工程","机械设计","核工程"],
"经济学": ["一般均衡","博弈论","信息不对称","行为经济学","货币理论","计量经济学","拍卖理论","机制设计"],
"哲学": ["先验哲学","权力意志","存在主义","语言游戏","证伪主义","范式理论","正义论","功利主义"],
"天文学": ["赫罗图","超新星","中子星","系外行星","星系分类","脉冲星","恒星形成","宇宙射线"],
"地球科学": ["板块构造","地震学","火山学","古生物学","气候系统","海洋环流","大气动力学","行星地质"],
"生物技术": ["基因测序","PCR","冷冻电镜","核磁共振","光遗传学","类器官","纳米医学","基因敲除"],
}
ALL_DOMAINS = list(DOMAINS.keys())
# ═══════════════════════════════════════════════════════════
# 3. Phase 1: API直接生成种子
# ═══════════════════════════════════════════════════════════
def build_seed_prompt(domain: str, concept: str) -> str:
return f"""请围绕【{domain}】领域的【{concept}】概念,生成一段师生深度对话。
要求:
1. 学生:从基础概念问起,逐步深入,包含追问、质疑、要求举例
2. 导师:每轮回答150-500字,专业准确,有数据支撑
3. 对话演进:基础定义→核心机制→实验证据→应用场景→前沿进展
4. 至少15轮对话
每轮格式(严格):
学生:[问题]
导师:[回答]
不要额外说明,直接开始对话。"""
def generate_seed(domain: str, concept: str) -> dict:
"""一次API调用生成一条种子对话"""
prompt = build_seed_prompt(domain, concept)
text = call_astron(prompt, temperature=random.uniform(0.7, 0.9),
system_prompt=TEACHER_SYSTEM, max_tokens=8192)
if not text:
return None
# 解析
turns = []
for line in text.strip().split('\n'):
line = line.strip()
if not line:
continue
if re.match(r'^学生[::]', line):
content = re.sub(r'^学生[::]\s*', '', line)
turns.append(('user', content))
elif re.match(r'^导师[::]', line):
content = re.sub(r'^导师[::]\s*', '', line)
turns.append(('assistant', content))
if len(turns) < 10:
return None
lines = []
for role, content in turns:
lines.append(f"<|im_start|>{role}\n{content}<|im_end|>")
return {
"text": '\n'.join(lines),
"domain": domain,
"concept": concept,
}
# ═══════════════════════════════════════════════════════════
# 4. Phase 2: 语义扩展
# ═══════════════════════════════════════════════════════════
VARIATION_PROMPTS = [
"用更通俗的方式重新讲一遍,多用日常类比",
"从更高级的视角讲,假设学生已有基础,重点讲深层机制和数学原理",
"换个角度,从历史发展脉络讲,突出关键实验和突破性发现",
"从工程应用角度讲,重点讲实际怎么用、有什么技术瓶颈",
"从批判性视角讲,有哪些争议、局限性、未解决的问题",
"假设学生是高中水平,用最通俗的语言和大量类比来解释",
"从跨学科视角讲,这个概念与其他领域有什么联系",
"以问答竞赛的方式讲,学生提尖锐问题,导师用证据回击",
]
def generate_variation(seed: dict, seed_text: str, prompt_template: str) -> dict:
"""基于种子主题生成一个语义变体"""
domain = seed["domain"]
concept = seed["concept"]
instruction = prompt_template
prompt = f"""请围绕【{domain}】领域的【{concept}】概念,生成一段师生深度对话。
{instruction}
要求:
1. 至少15轮对话
2. 导师回答150-500字,专业详细
3. 对话要有递进:定义→机制→证据→应用→前沿
每轮格式(严格):
学生:[问题]
导师:[回答]
直接开始对话,不要额外说明。"""
text = call_astron(prompt, temperature=random.uniform(0.75, 0.95),
system_prompt=TEACHER_SYSTEM, max_tokens=8192)
if not text:
return None
turns = []
for line in text.strip().split('\n'):
line = line.strip()
if not line:
continue
if re.match(r'^学生[::]', line):
content = re.sub(r'^学生[::]\s*', '', line)
turns.append(('user', content))
elif re.match(r'^导师[::]', line):
content = re.sub(r'^导师[::]\s*', '', line)
turns.append(('assistant', content))
if len(turns) < 10:
return None
lines = []
for role, content in turns:
lines.append(f"<|im_start|>{role}\n{content}<|im_end|>")
return {
"text": '\n'.join(lines),
"domain": domain,
"concept": concept,
"variation": prompt_template[:30],
}
# ═══════════════════════════════════════════════════════════
# 5. 数据管理
# ═══════════════════════════════════════════════════════════
class DataStore:
def __init__(self, save_dir: str):
self.save_dir = Path(save_dir)
self.save_dir.mkdir(parents=True, exist_ok=True)
self.output_path = self.save_dir / 'dialogues.jsonl'
self.seed_path = self.save_dir / 'seeds.jsonl'
self.existing_hashes = set()
self.count = 0
self.load()
def load(self):
for path in [self.output_path, self.seed_path]:
if path.exists():
with open(path, 'r') as f:
for line in f:
line = line.strip()
if line:
try:
item = json.loads(line)
h = hashlib.md5(item['text'][:100].encode()).hexdigest()
self.existing_hashes.add(h)
self.count += 1
except:
pass
print(f"📂 已有: {self.count:,} 条")
def add_seeds(self, items: list) -> int:
if not items:
return 0
new = []
for item in items:
h = hashlib.md5(item['text'][:100].encode()).hexdigest()
if h not in self.existing_hashes:
self.existing_hashes.add(h)
new.append(item)
if not new:
return 0
with open(self.seed_path, 'a') as f:
for item in new:
f.write(json.dumps(item, ensure_ascii=False) + '\n')
self.count += 1
return len(new)
def add_variations(self, items: list) -> int:
if not items:
return 0
new = []
for item in items:
h = hashlib.md5(item['text'][:100].encode()).hexdigest()
if h not in self.existing_hashes:
self.existing_hashes.add(h)
new.append(item)
if not new:
return 0
with open(self.output_path, 'a') as f:
for item in new:
f.write(json.dumps(item, ensure_ascii=False) + '\n')
self.count += 1
return len(new)
def get_seeds(self):
"""读取所有种子"""
seeds = []
if self.seed_path.exists():
with open(self.seed_path, 'r') as f:
for line in f:
line = line.strip()
if line:
seeds.append(json.loads(line))
return seeds
def total_count(self):
return self.count
# ═══════════════════════════════════════════════════════════
# 6. 质量评分
# ═══════════════════════════════════════════════════════════
def score(text: str) -> float:
turns = (text.count('<|im_start|>user') + text.count('<|im_start|>assistant')) // 2
chars = len(text)
s = 0.0
if turns >= 20: s += 0.20
elif turns >= 12: s += 0.15
elif turns >= 8: s += 0.10
else: s += 0.05
if chars >= 4000: s += 0.20
elif chars >= 2000: s += 0.15
elif chars >= 1200: s += 0.10
else: s += 0.05
avg = chars / max(turns, 1)
if avg >= 200: s += 0.15
elif avg >= 100: s += 0.10
uc = text.count('<|im_start|>user')
ac = text.count('<|im_start|>assistant')
ratio = min(uc, ac) / max(uc, ac, 1)
if ratio >= 0.8: s += 0.10
unique = len(set(text))
if unique >= 150: s += 0.10
return max(0, min(1, s))
# ═══════════════════════════════════════════════════════════
# 7. 卷统计
# ═══════════════════════════════════════════════════════════
def print_stats(store: DataStore, t_start: float, phase: str, target: int):
elapsed = max(time.time() - t_start, 1)
rate = store.count / elapsed * 3600
eta_h = (target - store.count) / max(rate, 0.01)
seed_count = 0
if store.seed_path.exists():
with open(store.seed_path) as f:
seed_count = sum(1 for _ in f if _.strip())
tokens_est = store.count * 6000
print(f" [{time.strftime('%H:%M:%S')}] "
f"Phase {phase} | "
f"累计: {store.count:,}/{target:,} "
f"({store.count/max(target,1)*100:.1f}%) | "
f"速率: {rate:.0f}条/h | "
f"种子: {seed_count} | "
f"tokens: {tokens_est/1e9:.2f}B | "
f"预计: {eta_h/24:.1f}d", flush=True)
# ═══════════════════════════════════════════════════════════
# 8. 主函数
# ═══════════════════════════════════════════════════════════
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--target", type=int, default=100000, help="目标条数")
parser.add_argument("--phase", type=int, default=1, choices=[1, 2],
help="1=生成种子, 2=语义扩展")
parser.add_argument("--concurrency", type=int, default=5, help="API并发数")
args = parser.parse_args()
save_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'pretrain_data')
store = DataStore(save_dir)
target = args.target
if store.count >= target:
print(f"✅ 已达目标 {target:,} 条")
return
print(f"\n{'='*50}")
print(f" Phase {args.phase}: {'种子生成' if args.phase == 1 else '语义扩展'}")
print(f" 目标: {target:,} 条")
print(f" 并发: {args.concurrency}")
print(f" 当前: {store.count:,}")
print(f"{'='*50}\n")
t_start = time.time()
if args.phase == 1:
# Phase 1: 纯API种子生成
seeds = store.get_seeds()
# 从种子中的(domain, concept)对去重
used = set()
for s in seeds:
used.add((s.get("domain",""), s.get("concept","")))
print(f" 已有 {len(used)} 个不同(领域,概念)种子\n")
while store.count < target:
batch_items = []
tasks = []
for _ in range(args.concurrency):
# 选一个未用过的(领域,概念)
for attempt in range(20):
d = random.choice(ALL_DOMAINS)
c = random.choice(DOMAINS[d])
if (d, c) not in used or attempt > 15:
break
used.add((d, c))
tasks.append((d, c))
with ThreadPoolExecutor(max_workers=args.concurrency) as ex:
futures = {ex.submit(generate_seed, d, c): (d, c) for d, c in tasks}
for f in as_completed(futures):
result = f.result()
if result and score(result["text"]) >= 0.5:
batch_items.append(result)
if batch_items:
added = store.add_seeds(batch_items)
n = len(batch_items)
print(f" +{n}条种子 | 领域: {batch_items[0]['domain']}, "
f"概念: {batch_items[0]['concept']}...", flush=True)
print_stats(store, t_start, "1", target)
if store.count >= target:
break
elif args.phase == 2:
# Phase 2: 语义扩展
seeds = store.get_seeds()
if not seeds:
print("❌ 没有种子数据,先跑 Phase 1")
return
print(f" 种子库: {len(seeds)} 条\n")
used_seeds = set()
expansion_tasks = []
while store.count < target:
if not expansion_tasks:
# 把所有种子打乱,生成扩展任务
random.shuffle(seeds)
for s in seeds:
if s["text"] in used_seeds:
continue
used_seeds.add(s["text"])
for vp in VARIATION_PROMPTS:
expansion_tasks.append((s, vp))
print(f" 生成 {len(expansion_tasks)} 个扩展任务\n")
# 取一批任务
batch = []
while len(batch) < args.concurrency and expansion_tasks:
batch.append(expansion_tasks.pop(0))
if not batch:
break
with ThreadPoolExecutor(max_workers=args.concurrency) as ex:
futures = {ex.submit(generate_variation, s, s["text"], vp): (s, vp)
for s, vp in batch}
for f in as_completed(futures):
result = f.result()
if result and score(result["text"]) >= 0.5:
store.add_variations([result])
# 报告
seed_count = 0
if store.seed_path.exists():
with open(store.seed_path) as f:
seed_count = sum(1 for _ in f if _.strip())
elapsed = max(time.time() - t_start, 1)
rate = store.count / elapsed * 3600
eta_h = (target - store.count) / max(rate, 0.01) if rate > 0 else 0
tokens_est = store.count * 6000
done_tasks = max(1, len(used_seeds) * len(VARIATION_PROMPTS) - len(expansion_tasks))
print(f" [{time.strftime('%H:%M:%S')}] "
f"累计: {store.count:,}/{target:,} "
f"({store.count/max(target,1)*100:.1f}%) | "
f"速率: {rate:.0f}条/h | "
f"种子: {seed_count} | "
f"扩展任务进度: {done_tasks} | "
f"tokens: {tokens_est/1e9:.2f}B | "
f"预计: {eta_h/24:.1f}d", flush=True)
if store.count >= target:
break
# 最终报告
total_time = time.time() - t_start
final_rate = store.count / max(total_time, 1) * 3600
tokens = store.count * 6000
path = store.output_path if args.phase == 2 else store.seed_path
size_mb = os.path.getsize(path) / 1024 / 1024 if path.exists() else 0
print(f"\n{'='*50}")
print(f" ✅ Phase {args.phase} 完成!")
print(f" 总条数: {store.count:,}")
print(f" 文件: {path}")
print(f" 大小: {size_mb:.1f}MB")
print(f" 估算tokens: {tokens/1e9:.2f}B")
print(f" 耗时: {total_time/3600:.1f}h")
print(f" 平均速率: {final_rate:.0f}条/h")
print(f"{'='*50}")
# 样例
print("\n📊 样例:")
with open(path) as f:
for i, line in enumerate(f):
if i >= 2:
break
if line.strip():
item = json.loads(line.strip())
text = item["text"]
turns = text.count("<|im_start|>") // 2
print(f"\n 条目{i+1}: {turns}轮 | {len(text):,}字 | {item.get('domain','')}")
lines = text.split("\n")[:4]
for l in lines:
if l.strip():
role = "👤" if "user" in l else "🧑‍🏫"
c = l.split("<|im_start|>")[1].split("<|im_end|>")[0].strip()
print(f" {role} {c[:100]}")
print(f" ...({turns}轮)")
if __name__ == "__main__":
main()
+68 -19
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
API生成模块 — Astron 5并发,稳定不超限
API生成模块 — Astron 5并发(同步根目录版本)
"""
import json, requests, time, os, threading
import json, requests, time, os
from concurrent.futures import ThreadPoolExecutor, as_completed
ASTRON_URL = "https://maas-coding-api.cn-huabei-1.xf-yun.com/v2/chat/completions"
@@ -49,17 +49,12 @@ def call_astron(q, temperature=0.85):
return None
def batch_generate(questions, temperature=0.85, max_workers=4):
"""Astron 4并发 — 明确限制,稳定可靠"""
def batch_generate(questions, temperature=0.85, max_workers=5):
total = len(questions)
results = []
def call(q):
ans = call_astron(q, temperature)
if ans:
return q, ans
return None
return (q, ans) if ans else None
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(call, item): i for i, item in enumerate(questions)}
done = 0
@@ -73,27 +68,81 @@ def batch_generate(questions, temperature=0.85, max_workers=4):
elapsed = max(time.time() - t0, 1)
rate = len(results) / elapsed * 60
print(f" Astron: {done}/{total} (已获{len(results)}条, {rate:.0f}条/分)", end="\r")
elapsed = time.time() - t0
rate = len(results) / max(elapsed, 1) * 60
print(f" Astron: {total}/{total} → 共{len(results)}条 ({elapsed:.0f}s, {rate:.0f}条/分)")
return results
# 保持旧函数名兼容
batch_generate_astron = batch_generate
batch_generate_mimo = batch_generate
batch_generate_direct = batch_generate
batch_generate_dual = batch_generate
def batch_convert(items, *args, **kwargs):
"""兼容占位:训练脚本导入用,已弃用"""
print(" [API] batch_convert已弃用,使用batch_generate")
return []
def batch_convert(items, progress=None, max_workers=5):
if not items:
return []
total = len(items)
results = []
def convert(item):
q, model_ans = item
prompt = f"问题:{q}\n模型回答:{model_ans}\n\n请用8岁小狸的语气,重新生成更好的回答。保持简短自然,1-2句话。"
ans = call_astron(prompt, temperature=0.3)
return {"question": q, "answer": ans} if ans and len(ans) >= 3 else None
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(convert, item): i for i, item in enumerate(items)}
done = 0
t0 = time.time()
for f in as_completed(futures):
done += 1
r = f.result()
if r:
results.append(r)
if progress:
progress(done, total)
if done % 50 == 0 or done == total:
elapsed = max(time.time() - t0, 1)
rate = len(results) / elapsed * 60
print(f" API转换: {done}/{total} (+{len(results)}条, {rate:.0f}条/分)", end="\r")
elapsed = time.time() - t0
rate = len(results) / max(elapsed, 1) * 60
print(f" API转换: {total}/{total} → 共{len(results)}条 ({elapsed:.0f}s, {rate:.0f}条/分)")
return results
def batch_generate_multi_temp(questions, *args, **kwargs):
"""兼容占位:训练脚本导入用,已弃用"""
print(" [API] batch_generate_multi_temp已弃用,使用batch_generate")
return []
def batch_generate_multi_temp(questions, temperatures=None, max_workers=5):
if temperatures is None:
temperatures = [0.7, 0.85, 1.0]
if not questions:
return []
total = len(questions) * len(temperatures)
results = []
def call_with_temp(q, temp):
ans = call_astron(q, temperature=temp)
return {"question": q, "answer": ans, "temperature": temp} if ans else None
tasks = [(q, t) for q in questions for t in temperatures]
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {executor.submit(call_with_temp, q, t): i for i, (q, t) in enumerate(tasks)}
done = 0
t0 = time.time()
for f in as_completed(futures):
done += 1
r = f.result()
if r:
results.append(r)
if done % 50 == 0 or done == total:
elapsed = max(time.time() - t0, 1)
rate = len(results) / elapsed * 60
print(f" 多温生成: {done}/{total} (+{len(results)}条, {rate:.0f}条/分)", end="\r")
seen = set()
unique = []
for item in results:
key = (item["question"], item["answer"])
if key not in seen:
seen.add(key)
unique.append({"question": item["question"], "answer": item["answer"]})
elapsed = time.time() - t0
rate = len(unique) / max(elapsed, 1) * 60
print(f" 多温生成: 共{len(unique)}条 (去重后, {elapsed:.0f}s, {rate:.0f}条/分)")
return unique
+662
View File
@@ -0,0 +1,662 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
小狸 AI - 最大化训练脚本 v2.0
==============================
两阶段训练:预训练(通用中文) → 角色对齐(QA数据)
相比原始 train.py 的改进:
① 两阶段训练:先用通用语料建立语言能力,再用QA数据做角色对齐
② Warmup + Cosine 学习率调度(业界标准)
③ 更大的有效 batch size(提升收敛稳定性)
④ 启用 AMP 混合精度(GPU加速)
⑤ DataLoader 多进程加载
⑥ 修复在线蒸馏 P2/P3 失效 bug
⑦ 支持学习率分层(不同层不同 LR)
⑧ 更激进的梯度裁剪策略
⑨ 支持从原始 train.py checkpoint 无缝续训
"""
import sys, os, json, time, glob, random, re, math
from typing import List, Tuple, Optional
from pathlib import Path
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
import numpy as np
# 复用原始项目代码
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from llm_chat_improved import (
CharTokenizer, LLMModel, LLMChat,
ConversationDataset, tokenizer_instance
)
from api_correction import call_astron
# ── 配置 ──────────────────────────────────────────────────
class Config:
# ========= 模型架构 =========
d_model: int = 320
n_heads: int = 8
n_layers: int = 6
d_ff: int = 1536
max_length: int = 256 # 训练序列长度
dropout: float = 0.1
use_memory: bool = False
use_thinking: bool = False
use_moe: bool = False
num_experts: int = 1
top_k: int = 1
# ========= 阶段一:预训练(通用中文语料) =========
pt_enable: bool = True # 是否启用预训练阶段
pt_data_dir: str = "" # 预训练数据目录(留空则自动下载)
pt_batch_size: int = 16 # 预训练 batch
pt_grad_accum: int = 4 # 梯度累积
pt_effective_batch: int = 64 # = pt_batch_size * pt_grad_accum
pt_epochs: int = 5 # 预训练 epoch
pt_lr: float = 5e-4 # 预训练初始 LR(比微调大)
pt_min_lr: float = 5e-5 # 预训练最小 LR
pt_warmup_steps: int = 500 # 预训练 warmup
pt_weight_decay: float = 0.1 # 预训练 weight decay(标准)
pt_max_norm: float = 1.0 # 梯度裁剪
pt_max_length: int = 128 # 预训练用较短序列(加速)
pt_data_ratio: float = 1.0 # 使用数据的比例(调试用)
# ========= 阶段二:角色对齐(QA数据) =========
align_batch_size: int = 8
align_grad_accum: int = 4
align_effective_batch: int = 32
align_epochs: int = 50
align_lr: float = 1e-4
align_min_lr: float = 1e-5
align_warmup_steps: int = 100
align_weight_decay: float = 0.01
align_max_norm: float = 1.0
# ========= 在线蒸馏(阶段二末尾执行) =========
distill_enable: bool = True
distill_every: int = 5 # 每 N 个 epoch 执行一次蒸馏
# ========= 通用 =========
device: str = "cuda" if torch.cuda.is_available() else "cpu"
num_workers: int = 4 if torch.cuda.is_available() else 0
use_amp: bool = torch.cuda.is_available()
seed: int = 42
save_dir: str = os.path.dirname(os.path.abspath(__file__))
log_every: int = 0 # 0 = 每1%打印
# ========= 检查点 =========
save_interval: int = 1 # 每 N 个 epoch 保存 resume
archive_interval: int = 10 # 每 N 个 epoch 保存归档
keep_resume: int = 3 # 保留最近 resume 数
config = Config()
# ── 预训练数据集 ──────────────────────────────────────────
class PretrainDataset(Dataset):
"""通用中文文本预训练数据集
支持两种输入格式:
1. 纯文本文件:每行一句话/段落
2. JSON 文件:列表,每个元素含 "text" 字段
"""
def __init__(self, data_dir: str, vocab: dict, max_length: int = 128):
self.vocab = vocab
self.max_length = max_length
self.pad_id = vocab.get('<PAD>', 0)
# 扫描并加载所有文本
self.texts = []
data_dir = Path(data_dir)
if not data_dir.exists():
raise FileNotFoundError(f"预训练数据目录不存在: {data_dir}")
for fpath in sorted(data_dir.glob("*")):
if fpath.suffix in ('.txt', '.json', '.jsonl'):
self._load_file(fpath)
if not self.texts:
raise ValueError(f"预训练数据目录为空: {data_dir}")
print(f"预训练数据: {len(self.texts)} 条文本, "
f"总字符数: {sum(len(t) for t in self.texts):,}")
def _load_file(self, path: Path):
try:
if path.suffix == '.txt':
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
line = line.strip()
if len(line) >= 20: # 过滤太短的
self.texts.append(line)
elif path.suffix == '.json':
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
data = json.load(f)
for item in data:
if isinstance(item, dict) and 'text' in item:
t = item['text'].strip()
if len(t) >= 20:
self.texts.append(t)
elif isinstance(item, str) and len(item) >= 20:
self.texts.append(item)
elif path.suffix == '.jsonl':
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
try:
item = json.loads(line)
if isinstance(item, dict) and 'text' in item:
t = item['text'].strip()
if len(t) >= 20:
self.texts.append(t)
except json.JSONDecodeError:
continue
except Exception as e:
print(f" 跳过 {path.name}: {e}")
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
text = self.texts[idx]
# 随机截取一段(提升多样性)
tokens = tokenizer_instance.encode(text)
if len(tokens) > self.max_length:
start = random.randint(0, len(tokens) - self.max_length)
tokens = tokens[start:start + self.max_length]
else:
tokens = tokens + [self.pad_id] * (self.max_length - len(tokens))
# 自回归:input = tokens[:-1], target = tokens[1:]
input_ids = torch.tensor(tokens[:-1], dtype=torch.long)
target_ids = torch.tensor(tokens[1:], dtype=torch.long)
return {'input_ids': input_ids, 'target_ids': target_ids}
# ── 改进版训练器 ──────────────────────────────────────────
class MaxTrainer:
"""增强训练器 — 支持 warmup、分层 LR、自定义 scheduler"""
def __init__(self, model: nn.Module, config: Config, stage: str = "pretrain"):
self.model = model.to(config.device)
self.device = config.device
self.config = config
self.stage = stage
self.criterion = nn.CrossEntropyLoss(ignore_index=0, label_smoothing=0.1)
# 根据阶段选择超参数
if stage == "pretrain":
base_lr = config.pt_lr
wd = config.pt_weight_decay
else:
base_lr = config.align_lr
wd = config.align_weight_decay
# 分层学习率:embedding + fc_out 用低 LR,中间层用高 LR
param_groups = []
for name, param in model.named_parameters():
if not param.requires_grad:
continue
if 'embedding' in name or 'fc_out' in name:
param_groups.append({'params': param, 'lr': base_lr * 0.5, 'weight_decay': wd})
elif 'norm' in name or 'rmsnorm' in name:
param_groups.append({'params': param, 'lr': base_lr, 'weight_decay': 0.0})
else:
param_groups.append({'params': param, 'lr': base_lr, 'weight_decay': wd})
self.optimizer = torch.optim.AdamW(param_groups, lr=base_lr, weight_decay=wd,
betas=(0.9, 0.95), eps=1e-8)
# AMP
self.use_amp = config.use_amp and torch.cuda.is_available()
self.scaler = torch.cuda.amp.GradScaler(enabled=self.use_amp)
if torch.cuda.is_available():
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
torch.backends.cudnn.benchmark = True
torch.backends.cudnn.deterministic = False
def build_scheduler(self, total_steps: int):
"""构建 Warmup + Cosine Decay scheduler"""
if self.stage == "pretrain":
warmup = self.config.pt_warmup_steps
min_lr = self.config.pt_min_lr
else:
warmup = self.config.align_warmup_steps
min_lr = self.config.align_min_lr
def lr_lambda(step):
if step < warmup:
return step / max(1, warmup)
progress = (step - warmup) / max(1, total_steps - warmup)
cosine_decay = 0.5 * (1 + math.cos(math.pi * progress))
return (1 - min_lr / self.optimizer.param_groups[0]['lr']) * cosine_decay \
+ min_lr / self.optimizer.param_groups[0]['lr']
self.scheduler = torch.optim.lr_scheduler.LambdaLR(self.optimizer, lr_lambda)
def train_epoch(self, dataloader: DataLoader, epoch: int, total_epochs: int,
stage_name: str = "pretrain", log_every: int = 0) -> float:
self.model.train()
total_loss = 0
n_batches = len(dataloader)
if stage_name == "pretrain":
accum = self.config.pt_grad_accum
max_norm = self.config.pt_max_norm
else:
accum = self.config.align_grad_accum
max_norm = self.config.align_max_norm
if log_every == 0:
log_every = max(1, n_batches // 100)
self.optimizer.zero_grad()
for batch_idx, batch in enumerate(dataloader):
input_ids = batch['input_ids'].to(self.device, non_blocking=True)
target_ids = batch['target_ids'].to(self.device, non_blocking=True)
mask = self.model.generate_mask(input_ids.size(1)).to(self.device)
with torch.cuda.amp.autocast(enabled=self.use_amp):
outputs = self.model(input_ids, mask)
outputs = outputs.reshape(-1, outputs.size(-1))
tgt = target_ids.reshape(-1)
loss = self.criterion(outputs, tgt) / accum
self.scaler.scale(loss).backward()
if (batch_idx + 1) % accum == 0:
self.scaler.unscale_(self.optimizer)
torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm)
self.scaler.step(self.optimizer)
self.scaler.update()
self.optimizer.zero_grad()
if self.scheduler is not None:
self.scheduler.step()
total_loss += loss.item() * accum
if (batch_idx + 1) % log_every == 0:
avg = total_loss / (batch_idx + 1)
lr = self.optimizer.param_groups[0]['lr']
mem = ""
if torch.cuda.is_available():
mem = f" | Mem: {torch.cuda.memory_allocated()/1e9:.2f}GB"
print(f" [{stage_name}] Epoch {epoch}/{total_epochs} | "
f"Batch {batch_idx+1}/{n_batches} | Loss: {avg:.4f} | LR: {lr:.2e}{mem}")
return total_loss / n_batches
def evaluate(self, dataloader: DataLoader) -> float:
self.model.eval()
total_loss = 0
with torch.no_grad():
for batch in dataloader:
input_ids = batch['input_ids'].to(self.device)
target_ids = batch['target_ids'].to(self.device)
mask = self.model.generate_mask(input_ids.size(1)).to(self.device)
outputs = self.model(input_ids, mask)
outputs = outputs.reshape(-1, outputs.size(-1))
loss = self.criterion(outputs, target_ids.reshape(-1))
total_loss += loss.item()
return total_loss / max(1, len(dataloader))
def save_model(self, filepath: str, vocab: dict, epoch: int):
core = getattr(self.model, '_orig_mod', self.model)
torch.save({
'model_state_dict': core.state_dict(),
'optimizer_state_dict': self.optimizer.state_dict(),
'scheduler_state_dict': self.scheduler.state_dict() if self.scheduler else None,
'vocab': vocab,
'model_config': {
'vocab_size': core.vocab_size,
'd_model': core.d_model,
'n_heads': core.n_heads,
'n_layers': core.n_layers,
'd_ff': core.d_ff,
'max_len': core.max_len,
'use_memory': core.use_memory,
'use_thinking': core.use_thinking,
'use_moe': core.use_moe,
'stage': self.stage,
},
'epoch': epoch,
}, filepath)
print(f" ✅ 已保存: {filepath}")
# ── 在线蒸馏(修复版) ──────────────────────────────────
def run_distillation(model, vocab, device, max_length, distill_questions, save_dir):
if not distill_questions:
return [], 0
from api_correction import batch_convert
chat = LLMChat(model, vocab, device, max_length)
all_new = []
print(f" 📡 蒸馏 {len(distill_questions)} 题...")
answers1 = []
for i, q in enumerate(distill_questions):
ans = chat.generate_response(q, max_length=40, temperature=0.15)
answers1.append((q, ans))
if (i + 1) % 100 == 0:
print(f" P1: {i+1}/{len(distill_questions)}")
results1 = batch_convert(answers1)
all_new.extend(results1)
print(f" P1: +{len(results1)} 条")
print(f" [P2] 模型高温生成 (temp=0.8)...")
for i, q in enumerate(distill_questions):
ans = chat.generate_response(q, max_length=40, temperature=0.8)
if ans and len(ans) >= 5:
all_new.append({"question": q, "answer": ans})
if (i + 1) % 100 == 0:
print(f" P2: {i+1}/{len(distill_questions)}")
print(f" [P3] API 直答 (temp=1.0)...")
sampled = random.sample(distill_questions, min(50, len(distill_questions)))
for q in sampled:
try:
ans = call_astron(q, temperature=1.0)
if ans and len(ans) >= 5:
all_new.append({"question": q, "answer": ans})
except Exception:
pass
train_path = os.path.join(save_dir, 'xiaoli_train_v2.json')
merged_path = os.path.join(save_dir, 'xiaoli_merged_data_v2.json')
added = 0
if all_new and os.path.exists(train_path):
with open(train_path, 'r', encoding='utf-8') as f:
cur_train = json.load(f)
existing = set((item.get("question", ""), item.get("answer", "")) for item in cur_train)
truly_new = [item for item in all_new
if (item["question"], item["answer"]) not in existing]
if truly_new:
cur_train.extend(truly_new)
with open(train_path, 'w', encoding='utf-8') as f:
json.dump(cur_train, f, ensure_ascii=False, indent=2)
if os.path.exists(merged_path):
with open(merged_path, 'r', encoding='utf-8') as f:
cur_merged = json.load(f)
cur_merged.extend(truly_new)
with open(merged_path, 'w', encoding='utf-8') as f:
json.dump(cur_merged, f, ensure_ascii=False, indent=2)
added = len(truly_new)
print(f" ✅ 蒸馏新增 {added} 条")
return all_new, added
# ── 主流程 ──────────────────────────────────────────────
def main():
print("=" * 65)
print(" 小狸 AI - 最大化训练 v2.0")
print(f" 设备: {config.device} | AMP: {config.use_amp}")
print(f" 模型: {config.d_model}d, {config.n_layers}L, {config.n_heads}H, {config.d_ff}ff")
print("=" * 65)
# 1. 加载词表
vocab_path = os.path.join(config.save_dir, 'vocab.json')
if not os.path.exists(vocab_path):
print("❌ 未找到 vocab.json,请先运行 build_vocab.py")
return
with open(vocab_path, 'r', encoding='utf-8') as f:
vocab = json.load(f)
tokenizer_instance.char_to_id = vocab.copy()
tokenizer_instance.id_to_char = {v: k for k, v in vocab.items()}
print(f"词表: {len(vocab)} tokens")
# 2. 创建模型
model = LLMModel(
vocab_size=len(vocab),
d_model=config.d_model,
n_heads=config.n_heads,
n_layers=config.n_layers,
d_ff=config.d_ff,
max_len=config.max_length,
dropout=config.dropout,
use_memory=config.use_memory,
use_thinking=config.use_thinking,
use_moe=config.use_moe,
num_experts=config.num_experts,
top_k=config.top_k,
)
params = sum(p.numel() for p in model.parameters())
print(f"参数量: {params:,} ({params/1e6:.2f}M)")
# 3. 预训练阶段(如果启用)
if config.pt_enable:
print("\n" + "=" * 65)
print(" 阶段一:通用中文预训练")
print("=" * 65)
pt_dir = config.pt_data_dir
if not pt_dir or not os.path.exists(pt_dir):
pt_dir = os.path.join(config.save_dir, 'pretrain_data')
os.makedirs(pt_dir, exist_ok=True)
if not list(Path(pt_dir).glob("*")):
print(" 📥 下载预训练数据 (SkyPile 小样本)...")
_download_pretrain_data(pt_dir)
try:
pt_dataset = PretrainDataset(pt_dir, vocab, config.pt_max_length)
pt_loader = DataLoader(
pt_dataset,
batch_size=config.pt_batch_size,
shuffle=True,
num_workers=config.num_workers,
pin_memory=torch.cuda.is_available(),
)
except (FileNotFoundError, ValueError) as e:
print(f" ⚠️ 预训练数据不可用: {e}")
print(f" ⚠️ 跳过预训练阶段。请准备数据后放入: {pt_dir}")
pt_dataset = None
pt_loader = None
if pt_loader is not None:
total_pt_steps = len(pt_loader) * config.pt_epochs
trainer_pt = MaxTrainer(model, config, stage="pretrain")
trainer_pt.build_scheduler(total_pt_steps)
for epoch in range(1, config.pt_epochs + 1):
loss = trainer_pt.train_epoch(
pt_loader, epoch, config.pt_epochs,
stage_name="pretrain", log_every=config.log_every
)
val_loss = trainer_pt.evaluate(pt_loader)
print(f" → 预训练 Epoch {epoch}/{config.pt_epochs} | "
f"Loss: {loss:.4f} | Val: {val_loss:.4f}")
trainer_pt.save_model(
os.path.join(config.save_dir, f'pretrain_epoch_{epoch}.pth'),
vocab, epoch
)
print(f" ✅ 预训练完成")
# 4. 阶段二:角色对齐
print("\n" + "=" * 65)
print(" 阶段二:角色对齐 (QA 角色化训练)")
print("=" * 65)
merged_path = os.path.join(config.save_dir, 'xiaoli_merged_data_v2.json')
if not os.path.exists(merged_path):
print(f"❌ 未找到合并数据: {merged_path}")
return
with open(merged_path, 'r', encoding='utf-8') as f:
raw_data = json.load(f)
pairs = []
for item in raw_data:
if isinstance(item, dict):
q = item.get("question", "") or item.get("input", "") or item.get("prompt", "")
a = item.get("answer", "") or item.get("output", "") or item.get("response", "")
if q and a:
pairs.append((q, a))
random.seed(config.seed)
random.shuffle(pairs)
split = int(len(pairs) * 0.9)
train_pairs = pairs[:split]
val_pairs = pairs[split:]
print(f"QA 数据: {len(pairs)} 条 (训练 {len(train_pairs)} + 验证 {len(val_pairs)})")
train_ds = ConversationDataset(train_pairs, vocab, config.max_length)
train_loader = DataLoader(
train_ds, batch_size=config.align_batch_size, shuffle=True,
num_workers=config.num_workers, pin_memory=torch.cuda.is_available(),
)
val_ds = ConversationDataset(val_pairs, vocab, config.max_length)
val_loader = DataLoader(
val_ds, batch_size=config.align_batch_size, shuffle=False,
num_workers=0,
)
distill_questions = []
for qpath in ['exam_distill_questions.json', 'distill_questions_v3.json', 'distill_questions_v2.json']:
fp = os.path.join(config.save_dir, qpath)
if os.path.exists(fp):
with open(fp, 'r', encoding='utf-8') as f:
distill_questions = json.load(f)
print(f"蒸馏问题: {len(distill_questions)} 道 ({qpath})")
break
total_align_steps = len(train_loader) * config.align_epochs
trainer_align = MaxTrainer(model, config, stage="align")
trainer_align.build_scheduler(total_align_steps)
best_val_loss = float('inf')
total_distill_added = 0
for epoch in range(1, config.align_epochs + 1):
train_loss = trainer_align.train_epoch(
train_loader, epoch, config.align_epochs,
stage_name="align", log_every=config.log_every
)
val_loss = trainer_align.evaluate(val_loader)
marker = " ★" if val_loss < best_val_loss else ""
if val_loss < best_val_loss:
best_val_loss = val_loss
trainer_align.save_model(
os.path.join(config.save_dir, 'xiaoli_v2_best.pth'), vocab, epoch
)
print(f" → QA Epoch {epoch}/{config.align_epochs} | "
f"Loss: {train_loss:.4f} | Val: {val_loss:.4f}{marker} | "
f"LR: {trainer_align.optimizer.param_groups[0]['lr']:.2e}")
if epoch % config.save_interval == 0:
trainer_align.save_model(
os.path.join(config.save_dir, f'llm_model_v2_resume_epoch_{epoch}.pth'),
vocab, epoch
)
if epoch % config.archive_interval == 0:
trainer_align.save_model(
os.path.join(config.save_dir, f'llm_model_v2_epoch_{epoch}.pth'),
vocab, epoch
)
resumes = sorted(glob.glob(os.path.join(config.save_dir, 'llm_model_v2_resume_*.pth')))
for old in resumes[:-config.keep_resume]:
try:
os.remove(old)
except:
pass
if config.distill_enable and distill_questions and epoch % config.distill_every == 0:
_, added = run_distillation(
model, vocab, config.device, config.max_length,
distill_questions, config.save_dir
)
total_distill_added += added
trainer_align.save_model(
os.path.join(config.save_dir, 'xiaoli_v2_final.pth'), vocab, config.align_epochs
)
for f in glob.glob(os.path.join(config.save_dir, 'llm_model_v2_resume_*.pth')):
try:
os.remove(f)
except:
pass
print(f"\n{'='*65}")
print(f" 训练完成!蒸馏新增: {total_distill_added} 条")
print(f" 最终模型: {os.path.join(config.save_dir, 'xiaoli_v2_final.pth')}")
print(f"{'='*65}")
print("\n📝 快速测试:")
chat = LLMChat(model, vocab, config.device, config.max_length)
for q in ["你好", "你是谁", "1+1等于几", "讲个笑话"]:
ans = chat.generate_response(q, max_length=30, temperature=0.5)
print(f" Q: {q}")
print(f" A: {ans}")
print()
def _download_pretrain_data(target_dir: str):
import urllib.request
samples = [
"天空是蓝色的,白云在天空中飘来飘去。小鸟在树上唱歌,花儿在阳光下开放。",
"小明是一个小学生,他每天都要去学校上课。今天老师教大家认识汉字,小明学会了'天'、'地'、'人'三个字。",
"春天来了,大地变绿了。小草从土里钻出来,树木长出了新叶子,到处都是生机勃勃的景象。",
"猫是一种常见的宠物,它们有着柔软的毛发和灵活的身体。猫咪喜欢晒太阳,也喜欢玩毛线球。",
"数学是一门重要的学科,我们生活中处处都用得到数学。比如买东西需要算钱,做菜需要称量材料。",
"学习编程可以培养逻辑思维能力,通过写代码,我们可以让计算机帮助我们完成各种任务。",
"中国有着悠久的历史和灿烂的文化,四大发明——造纸术、印刷术、火药、指南针,对世界文明做出了重要贡献。",
"保护环境人人有责,我们应该节约用水,减少使用塑料袋,做好垃圾分类,让地球变得更美好。",
"星星在夜空中闪烁,月亮有时圆有时缺。宇宙中有无数的星系,地球只是其中一颗小小的行星。",
"友谊是人生中宝贵的财富,好朋友会在你开心时和你一起笑,在你难过时给你安慰和鼓励。",
"人工智能是当今最前沿的科技领域之一,它让机器能够像人一样思考和学习,改变着我们的生活方式。",
"读书是获取知识的重要途径,通过阅读,我们可以了解世界各地的风土人情,学习前人的智慧和经验。",
"运动对身体健康非常重要,跑步、游泳、打篮球都是很好的运动方式,每天坚持运动可以让我们更有活力。",
"音乐是一种美妙的艺术形式,不同的乐器能演奏出不同的声音,组合在一起就形成了动听的旋律。",
"做饭是一门生活技能,学会做饭不仅能照顾好自己的饮食,还能给家人朋友带来美味的享受。",
"植物通过光合作用将阳光转化为能量,它们吸收二氧化碳,释放出氧气,为地球上的生命提供了重要的保障。",
"狗是人类最忠诚的朋友,它们聪明、勇敢、重情义,能够帮助人类看家护院、导盲、搜救等。",
"海洋覆盖了地球表面的百分之七十,海洋中有各种各样的生物,从微小的浮游生物到巨大的鲸鱼。",
"互联网让世界变成了一个地球村,人们可以通过网络随时随地获取信息、交流沟通、学习工作。",
"时间管理是一项重要的能力,合理安排时间可以提高工作效率,让我们有更多的时间做自己喜欢的事情。",
]
out_path = os.path.join(target_dir, "pretrain_samples.txt")
with open(out_path, 'w', encoding='utf-8') as f:
for s in samples:
f.write(s + "\n")
wiki_samples_path = os.path.join(target_dir, "wiki_samples.json")
wiki_data = [
{"text": "孔子(公元前551年—公元前479年),名丘,字仲尼,是中国古代伟大的思想家、教育家,儒家学派的创始人。"},
{"text": "太阳是太阳系的中心天体,直径为139.2万公里,表面温度约为5500摄氏度,地球上的生命离不开太阳的光和热。"},
{"text": "长城是中国古代的军事防御工程,始建于春秋战国时期,总长度超过2.1万公里,被誉为世界七大奇迹之一。"},
{"text": "DNA是脱氧核糖核酸的缩写,是生物遗传信息的载体,它决定了生物的性状和特征,呈双螺旋结构。"},
{"text": "Python是一种广泛使用的高级编程语言,由吉多·范罗苏姆于1991年首次发布,以其简洁易读的语法而闻名。"},
{"text": "水的化学式是H2O,在标准大气压下,水的沸点是100摄氏度,冰点是0摄氏度,水是生命之源。"},
{"text": "贝多芬是德国著名的作曲家和钢琴家,他创作了《命运交响曲》《月光奏鸣曲》等不朽的音乐作品。"},
{"text": "光合作用是植物、藻类和某些细菌利用光能将二氧化碳和水转化为有机物和氧气的过程。"},
{"text": "人类基因组计划于1990年启动,2003年完成,成功绘制了人类基因组的完整序列图,对医学研究具有重要意义。"},
{"text": "丝绸之路是古代连接中国与中亚、欧洲的贸易路线,不仅促进了商品交流,也推动了东西方文化的传播与融合。"},
]
with open(wiki_samples_path, 'w', encoding='utf-8') as f:
json.dump(wiki_data, f, ensure_ascii=False, indent=2)
print(f" ✅ 已生成示例预训练数据: {len(samples) + len(wiki_data)} 条")
print(f" ⚠️ 这仅供流程测试!实际训练请替换为大规模中文语料")
print(f" 💡 建议: https://huggingface.co/datasets/SkyWork/SkyPile-150B")
if __name__ == "__main__":
main()