diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7b74b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,76 @@ +# 缓存和日志 +__pycache__/ +*.pyc +*.log +*.pth +*.pth.* +/tmp/ + +# 中间数据(旧版临时文件) +mega_interim_*.json +essay_interim_*.json +checkpoints_v2/ +high_temp_outputs/ +old_ckpts/ +backup_old_arch/ + +# 旧版数据(不再使用) +xiaoli_mega_*.json +xiaoli_multiturn_memory*.json +xiaoli_offline_distill*.json +xiaoli_personality*.json +xiaoli_safety*.json +xiaoli_sassy*.json +xiaoli_short_cot*.json +xiaoli_cot*.json +xiaoli_distill*.json +xiaoli_diverse*.json +xiaoli_drowning*.json +xiaoli_english*.json +xiaoli_final*.json +xiaoli_girl*.json +xiaoli_knowledge*.json +xiaoli_math*.json +xiaoli_merged_data.json +xiaoli_body*.json +xiaoli_child*.json +xiaoli_cute*.json +xiaoli_complete*.json +xiaoli_data_optimized*.json +xiaoli_riddle*.json +xiaoli_final*.json + +# 旧版脚本(不再使用) +generate_math*.py +generate_mimo*.py +generate_m3ke*.py +generate_mega*.py +generate_memory*.py +generate_essays*.py +generate_cot*.py +generate_more_data*.py +generate_offline*.py +gen_*.py +distill_*.py +analyze_*.py +merge_and_retrain*.sh +run_all_batches*.sh +watchdog*.py +test_epoch*.py +test_ood*.py + +# 旧版配置文件 +cot_*.json +memory_long*.json +multiturn_batch_*.json +real_multiturn*.json +mega_questions*.json +math_questions*.json +essay_questions*.json +cot_candidates*.json +model_test_results*.json +online_distill_report*.json +batch_runner*.log +mega_*.log +mimo_*.log +knowledge_*.log \ No newline at end of file diff --git a/api_correction.py b/api_correction.py new file mode 100644 index 0000000..09b2572 --- /dev/null +++ b/api_correction.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +API生成模块 — Astron 5并发,稳定不超限 +""" +import json, requests, time, os, threading +from concurrent.futures import ThreadPoolExecutor, as_completed + +ASTRON_URL = "https://maas-coding-api.cn-huabei-1.xf-yun.com/v2/chat/completions" +ASTRON_KEY = "658beba1beb0c911ed00672019bbf7e5:MjNmYWRkMWUyZjAxYTM3ZGE2ODI1YzFh" +ASTRON_MODEL = "astron-code-latest" + +SYSTEM_PROMPT = """你是一个名叫小狸的8岁中国小女孩。 + +你的人设: +- 8岁女孩子,有点害羞但很可爱 +- 说话简单自然,符合小朋友的语气 +- 回答问题要简短,1-2句话就够了 +- 不要用"喵"结尾,不要过度卖萌 + +回答要求: +1. 语气自然,像真实的小朋友说话 +2. 不懂就说"不知道" +3. 回答简洁但要说清楚,不要讲一半就停""" + + +def call_astron(q, temperature=0.85): + 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": "user", "content": q} + ], + "temperature": temperature, + "max_tokens": 8192, + } + resp = requests.post(ASTRON_URL, headers=headers, json=payload, timeout=60) + if resp.status_code == 200: + content = resp.json()['choices'][0]['message']['content'].strip() + if content: + return content + else: + time.sleep(1) + except Exception: + time.sleep(2) + return None + + +def batch_generate(questions, temperature=0.85, max_workers=4): + """Astron 4并发 — 明确限制,稳定可靠""" + total = len(questions) + results = [] + + def call(q): + ans = call_astron(q, temperature) + if ans: + return q, ans + return None + + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {executor.submit(call, item): i for i, item in enumerate(questions)} + done = 0 + t0 = time.time() + for f in as_completed(futures): + done += 1 + r = f.result() + if r: + results.append({"question": r[0], "answer": r[1]}) + if done % 20 == 0 or done == total: + 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_generate_multi_temp(questions, *args, **kwargs): + """兼容占位:训练脚本导入用,已弃用""" + print(" [API] batch_generate_multi_temp已弃用,使用batch_generate") + return [] \ No newline at end of file diff --git a/chat.html b/chat.html new file mode 100644 index 0000000..db06c0f --- /dev/null +++ b/chat.html @@ -0,0 +1,284 @@ + + + + + +小狸 v1.2 聊天 + + + +
+
+
🦊
+

小狸 v1.2

+

V2架构 · 21M参数 · 新训练启动 🔥

+
+ +
+ + +
+ + + 0.15 + 🗑️ 清空 +
+ +
+ + +
+
按回车发送 · 建议温度 0.1~0.2
+
+ + + + \ No newline at end of file diff --git a/daily_data_gen.py b/daily_data_gen.py new file mode 100644 index 0000000..1d4f173 --- /dev/null +++ b/daily_data_gen.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +每日数据生成 — 每天12:00跑 +用 Astron 生成大量小狸风格问答数据,追加到训练集(不触发训练) +""" +import sys, os, json, time, random + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', buffering=1) + +from api_correction import batch_generate + +MODEL_DIR = os.path.dirname(os.path.abspath(__file__)) +MAX_WORKERS = 5 # 5并发 +BATCH_SIZE = 500 + +# ===== 种子话题(每天随机选一批让小狸聊) ===== +SEED_TOPICS = [ + "幼儿园", "学校", "朋友", "玩具", "动画片", "宠物", "零食", "天气", + "节日", "生日", "爸爸妈妈", "老师", "作业", "画画", "唱歌", "跳舞", + "游乐园", "动物园", "海洋馆", "公园", "滑滑梯", "秋千", "积木", "拼图", + "自行车", "游泳", "跳绳", "踢球", "冰淇淋", "蛋糕", "水果", "蔬菜", + "彩虹", "星星", "月亮", "太阳", "花", "草", "树", "小动物", + "蝴蝶", "小鸟", "小鱼", "乌龟", "兔子", "猫", "狗", "恐龙", + "过年", "压岁钱", "红包", "新衣服", "放鞭炮", "灯笼", "元宵", + "圣诞老人", "礼物", "糖果", "巧克力", "饼干", "牛奶", "早餐", + "为什么天空是蓝的", "为什么树叶会掉", "为什么有影子", "为什么会下雨", + "为什么会有白天黑夜", "为什么海水是咸的", "为什么会打雷", + "1+1等于几", "数数", "认字", "背诗", "唱歌", "讲故事", + "礼貌", "分享", "排队", "帮助别人", "说谢谢", "对不起", + "害怕", "开心", "难过", "生气", "勇敢", "害羞", +] + +def generate_questions_from_topics(topics, count=500): + """用话题种子生成自然提问""" + questions = [] + for topic in topics[:count]: + patterns = [ + f"小狸,你喜欢{topic}吗?", + f"给我讲讲{topic}吧", + f"{topic}是什么呀?", + f"你最喜欢什么样的{topic}?", + f"关于{topic}你知道什么?", + f"小狸,{topic}好玩吗?", + f"能不能教我关于{topic}的事情?", + f"你上次玩{topic}是什么时候?", + f"大家为什么都喜欢{topic}呀?", + f"小狸,你在{topic}上有什么有趣的事吗?", + ] + q = random.choice(patterns) + questions.append(q) + return questions + +def generate_natural_questions(count=200): + """生成自然闲聊问题(固定一批高质量问题)""" + base_questions = [ + "小狸你好呀", "今天开心吗", "你几岁了", "你叫什么名字", + "你吃饭了吗", "我们一起玩吧", "你在干嘛呢", "你今天做了什么", + "你喜欢什么颜色", "你最好的朋友是谁", "你最喜欢什么动物", + "你最喜欢吃什么", "你最喜欢什么水果", "你最爱看什么动画片", + "可以给我讲个故事吗", "唱首歌给我听", "教我背一首诗吧", + "你会画画吗", "我们来玩猜谜语", "你家里有谁呀", + "你的房间是什么样子的", "你书包里有什么", "你最喜欢上什么课", + "今天天气真好", "外面下雨了", "好冷啊", "好热啊", + "我有点不开心", "我今天好开心", "我害怕打雷", + "你睡午觉了吗", "晚安", "早安", "快来", + "我想吃冰淇淋", "我想去游乐场", "我想养一只小猫", + "为什么雪是白色的", "为什么天是蓝的", "为什么花是香的", + "月亮上有人吗", "星星为什么会眨眼睛", "大海有多大", + "你会数数吗", "1+1等于几", "100+200等于多少", + "我喜欢你小狸", "你好可爱", "我们做朋友吧", + "你今天真好看", "谢谢你", "你真好", + "对不起", "我不小心摔倒了", "我被人欺负了", + "怎么办呀", "帮帮我", "我该怎么做", + "什么是爱", "什么是勇敢", "什么是善良", + "长大是什么感觉", "为什么要上学", "为什么要听话", + "你会一直陪着我吗", "你想去什么地方玩", + ] + return random.sample(base_questions, min(count, len(base_questions))) + +def main(): + print("=" * 60) + print("📅 每日数据生成") + print(f"时间: {time.strftime('%Y-%m-%d %H:%M:%S')}") + print("=" * 60) + + # 1. 混合生成问题 + random.seed(int(time.time())) + n_topic_qs = random.randint(300, 500) # 话题衍生问题 + n_natural_qs = random.randint(200, 300) # 自然闲聊问题 + + random.shuffle(SEED_TOPICS) + topic_questions = generate_questions_from_topics(SEED_TOPICS, n_topic_qs) + natural_questions = generate_natural_questions(n_natural_qs) + + all_questions = topic_questions + natural_questions + random.shuffle(all_questions) + + print(f"\n准备生成 {len(all_questions)} 条问答 (话题衍生: {len(topic_questions)}, 自然: {len(natural_questions)})") + + # 2. 用不同温度分批生成,增加多样性 + TEMPS = [0.85, 0.9, 0.95, 1.0] + all_results = [] + existing = set() + + # 加载已有数据用于去重 + train_path = os.path.join(MODEL_DIR, 'xiaoli_train_v2.json') + with open(train_path, encoding='utf-8') as f: + cur_train = json.load(f) + for it in cur_train: + key = (it.get('question','')[:100], it.get('answer','')[:100]) + existing.add(key) + print(f"已有训练集: {len(cur_train)} 条") + + # 分批+变温度 + total_batches = (len(all_questions) + BATCH_SIZE - 1) // BATCH_SIZE + t_start = time.time() + + for batch_idx in range(total_batches): + batch_qs = all_questions[batch_idx * BATCH_SIZE : (batch_idx + 1) * BATCH_SIZE] + temp = random.choice(TEMPS) + + print(f"\n--- 批次 {batch_idx + 1}/{total_batches} ({len(batch_qs)}条, temp={temp}) ---") + + t0 = time.time() + results = batch_generate(batch_qs, temperature=temp, max_workers=MAX_WORKERS) + elapsed = time.time() - t0 + + new_in_batch = 0 + for r in results: + key = (r['question'][:100], r['answer'][:100]) + if key not in existing: + existing.add(key) + all_results.append(r) + new_in_batch += 1 + + total_elapsed = time.time() - t_start + rate = len(all_results) / max(total_elapsed, 1) * 60 + print(f" 本批新增 {new_in_batch} 条 | 累计 {len(all_results)} 条 | {rate:.0f}条/分") + + if not all_results: + print("\n⚠️ 没有生成新数据") + return + + # 3. 追加到训练集 + print(f"\n追加 {len(all_results)} 条到训练集...") + for tgt in ['xiaoli_train_v2.json', 'xiaoli_merged_data_v2.json']: + tp = os.path.join(MODEL_DIR, tgt) + with open(tp, encoding='utf-8') as f: + cur = json.load(f) + + ex = set((it.get('question','')[:100], it.get('answer','')[:100]) for it in cur) + to_add = [] + for r in all_results: + key = (r['question'][:100], r['answer'][:100]) + if key not in ex: + ex.add(key) + to_add.append({'question': r['question'], 'answer': r['answer']}) + + cur.extend(to_add) + with open(tp, 'w', encoding='utf-8') as f: + json.dump(cur, f, ensure_ascii=False, indent=2) + + if 'train' in tgt: + print(f" ✅ 训练集 +{len(to_add)} 条 → {len(cur)} 条") + else: + print(f" ✅ 合并集 +{len(to_add)} 条 → {len(cur)} 条") + + # 4. 保存今日生成日志 + today = time.strftime('%Y-%m-%d') + log_path = os.path.join(MODEL_DIR, f'daily_gen_{today}.json') + with open(log_path, 'w', encoding='utf-8') as f: + json.dump(all_results, f, ensure_ascii=False, indent=2) + + elapsed_total = time.time() - t_start + print(f"\n{'='*60}") + print(f"✅ 今日生成完成! {len(all_results)} 条新数据") + print(f"用时: {elapsed_total/60:.1f} 分钟") + print(f"日志: daily_gen_{today}.json") + print(f"{'='*60}") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/distill_questions_v2.json b/distill_questions_v2.json new file mode 100644 index 0000000..8043495 --- /dev/null +++ b/distill_questions_v2.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5154511dddc7a03fcad94b7273bf7ab23ff9ccfe16957058b6a18eed608bf3b +size 4919 diff --git a/distill_questions_v3.json b/distill_questions_v3.json new file mode 100644 index 0000000..9a63e93 --- /dev/null +++ b/distill_questions_v3.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c74ebfca559a906d6cbd2d77eae0752ab72add28fca9b8de47d165fadd00e419 +size 30616 diff --git a/exam_distill_questions.json b/exam_distill_questions.json new file mode 100644 index 0000000..4011fe9 --- /dev/null +++ b/exam_distill_questions.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59f8dabcf5cbfc960810e99e51d8777a238e2ad4798405e8dd1c7bb7e5050a93 +size 2686585 diff --git a/generate_batch.py b/generate_batch.py new file mode 100644 index 0000000..3ef9cce --- /dev/null +++ b/generate_batch.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +单批生成 — 从mega_questions.json取指定batch生成并追加 +用法: python3 generate_batch.py +""" +import sys, os, json, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', buffering=1) + +from api_correction import batch_generate + +model_dir = os.path.dirname(os.path.abspath(__file__)) +batch_num = int(sys.argv[1]) +batch_size = int(sys.argv[2]) + +# 加载问题列表 +with open(os.path.join(model_dir, 'mega_questions.json'), 'r', encoding='utf-8') as f: + mega = json.load(f) + +# 计算本批范围 +start = (batch_num - 1) * batch_size +end = min(start + batch_size, len(mega)) +chunk = [item['question'] for item in mega[start:end]] + +if not chunk: + print(f"批{batch_num}: 无数据") + sys.exit(0) + +print(f"批 {batch_num}: {len(chunk)}题 (序号{start}-{end})") +t0 = time.time() + +results = batch_generate(chunk, temperature=0.85) +dt = time.time() - t0 + +# 保存中间文件 +interim_path = os.path.join(model_dir, f'mega_interim_{batch_num}.json') +with open(interim_path, 'w', encoding='utf-8') as f: + json.dump(results, f, ensure_ascii=False, indent=2) + +# 追加到训练集 +for tgt in ['xiaoli_train_v2.json', 'xiaoli_merged_data_v2.json']: + tgt_path = os.path.join(model_dir, tgt) + if not os.path.exists(tgt_path): + continue + with open(tgt_path, 'r', encoding='utf-8') as f: + cur = json.load(f) + existing = set((item.get("question",""), item.get("answer","")) for item in cur) + truly_new = [item for item in results if (item["question"], item["answer"]) not in existing] + if truly_new: + cur.extend(truly_new) + with open(tgt_path, 'w', encoding='utf-8') as f: + json.dump(cur, f, ensure_ascii=False, indent=2) + if tgt == 'xiaoli_train_v2.json': + print(f" ✅ 追加训练集: +{len(truly_new)}条, 现有{len(cur)}条") + +print(f"批{batch_num}完成: +{len(results)}条 ({dt:.0f}s)") \ No newline at end of file diff --git a/generate_diverse.py b/generate_diverse.py new file mode 100644 index 0000000..a784b6d --- /dev/null +++ b/generate_diverse.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""高温度多样化生成 — 每个问题跑 N 次,获得多种回答""" +import sys, os, json, random, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', buffering=1) +from api_correction import batch_generate_astron as batch_generate + +MODEL_DIR = os.path.dirname(os.path.abspath(__file__)) + +# 载入全部问题 +with open(os.path.join(MODEL_DIR, 'mega_questions.json')) as f: + mega = json.load(f) +total_q = len(mega) +print(f"mega_questions 共 {total_q} 条") + +# 温度配置:逐渐升高,产出多样化 +TEMP_CONFIG = [0.9, 1.1, 1.3, 1.5] # 跑4轮 +all_new_results = [] + +for round_idx, temp in enumerate(TEMP_CONFIG): + print(f"\n{'='*60}") + print(f"第{round_idx+1}轮: 温度={temp}") + print(f"{'='*60}") + + questions = [item['question'] for item in mega] + + t0 = time.time() + results = batch_generate(questions, temperature=temp) + elapsed = time.time() - t0 + + # 标注来源 + for r in results: + r['temperature'] = temp + r['round'] = round_idx + 1 + + all_new_results.extend(results) + print(f"轮{round_idx+1} 完成: {len(results)}条 ({elapsed:.0f}s)") + print(f"累计: {len(all_new_results)}条") + +# 去重(问题+回答完全重复才去) +print(f"\n总体去重中...") +seen = set() +unique_results = [] +for r in all_new_results: + key = (r['question'][:100], r['answer'][:100]) + if key not in seen: + seen.add(key) + unique_results.append(r) + +print(f"去重前: {len(all_new_results)}条 → 去重后: {len(unique_results)}条") + +# 追加到训练集 +for tgt in ['xiaoli_train_v2.json', 'xiaoli_merged_data_v2.json']: + tp = os.path.join(MODEL_DIR, tgt) + with open(tp, encoding='utf-8') as f: + cur = json.load(f) + + ex = set((it.get("question","")[:100], it.get("answer","")[:100]) for it in cur) + to_add = [] + for r in unique_results: + key = (r['question'][:100], r['answer'][:100]) + if key not in ex: + ex.add(key) + to_add.append({"question": r['question'], "answer": r['answer']}) + + cur.extend(to_add) + with open(tp, 'w', encoding='utf-8') as f: + json.dump(cur, f, ensure_ascii=False, indent=2) + if 'train' in tgt: + print(f"✅ 训练集 +{len(to_add)}条 → {len(cur)}条") + +print(f"\n🎉 全部完成!") +print(f"总数: {len(cur)}条") \ No newline at end of file diff --git a/llm_chat_improved.py b/llm_chat_improved.py index ace8095..e9ae4c4 100644 --- a/llm_chat_improved.py +++ b/llm_chat_improved.py @@ -35,6 +35,7 @@ class CharTokenizer: self.char_to_id = self.special_tokens.copy() self.id_to_char = {v: k for k, v in self.char_to_id.items()} self.next_id = len(self.special_tokens) + self._sorted_vocab_cache = None # encode缓存用 def build_vocab(self, texts: List[str], min_freq: int = 1) -> Dict[str, int]: """从文本构建词汇表""" @@ -53,6 +54,13 @@ class CharTokenizer: self.id_to_char[self.next_id] = char self.next_id += 1 + # 清除encode缓存(vocab已变化) + self._sorted_vocab_cache = None + # 清除多字符token缓存 + for attr in ['_multi_trie', '_multi_keys', '_single_map']: + if hasattr(self, attr): + delattr(self, attr) + return self.char_to_id def _split_chars(self, text: str) -> List[str]: @@ -71,18 +79,38 @@ class CharTokenizer: return result def encode(self, text: str) -> List[int]: - """将文本编码为ID序列""" + """将文本编码为ID序列 - 高效版""" if not text: return [self.special_tokens['']] - chars = self._split_chars(text) ids = [] - for char in chars: - if char in self.char_to_id: - ids.append(self.char_to_id[char]) - else: - # 未知字符用 UNK token - ids.append(self.special_tokens['']) + i = 0 + n = len(text) + + # 构建多字符token的前缀映射(仅一次) + if not hasattr(self, '_multi_trie'): + self._multi_trie = {} + for token in self.char_to_id: + if len(token) > 1: + self._multi_trie[token] = self.char_to_id[token] + # 按长度降序排序,优先匹配长token + self._multi_keys = sorted(self._multi_trie.keys(), key=len, reverse=True) + self._single_map = {c: tid for c, tid in self.char_to_id.items() if len(c) == 1} + + while i < n: + matched = False + # 优先匹配多字符token(特殊标记) + for token in self._multi_keys: + if text.startswith(token, i): + ids.append(self._multi_trie[token]) + i += len(token) + matched = True + break + if not matched: + # 单字符直接用dict查 + c = text[i] + ids.append(self._single_map.get(c, self.special_tokens[''])) + i += 1 return ids @@ -92,8 +120,8 @@ class CharTokenizer: for tid in ids: if tid in self.id_to_char: char = self.id_to_char[tid] - if char not in self.special_tokens: - chars.append(char) + # 保留特殊token以便后续处理,但这里先不过滤 + chars.append(char) return ''.join(chars) @@ -512,7 +540,7 @@ class LLMModel(nn.Module): class ConversationDataset(Dataset): """对话数据集""" - def __init__(self, conversations: List[Tuple[str, str]], vocab: dict, max_length: int = 128): + def __init__(self, conversations: List[Tuple[str, str]], vocab: dict, max_length: int = 256): self.conversations = conversations self.vocab = vocab self.max_length = max_length @@ -582,7 +610,7 @@ class LLMTrainer: self.model.train() total_loss = 0 accumulation_steps = config.get('gradient_accumulation_steps', 1) if config else 1 - log_interval = max(1, len(dataloader) // 10) # 每10%打印一次 + log_interval = max(1, len(dataloader) // 100) # 每1%打印一次 for batch_idx, batch in enumerate(dataloader): input_ids = batch['input_ids'].to(self.device, non_blocking=True) @@ -637,6 +665,27 @@ class LLMTrainer: self.scheduler.step() return total_loss / len(dataloader) + def evaluate(self, dataloader: DataLoader, config: dict = None): + """在验证集上计算loss(无梯度,不更新权重)""" + self.model.eval() + total_loss = 0 + accumulation_steps = config.get('gradient_accumulation_steps', 1) if config else 1 + + with torch.no_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) + outputs = self.model(input_ids, mask) + outputs = outputs.reshape(-1, outputs.size(-1)) + tgt = target_ids.reshape(-1) + + loss = self.criterion(outputs, tgt) + total_loss += loss.item() + + return total_loss / len(dataloader) + def save_model(self, filepath: str, vocab: dict, epoch: int = None): save_dict = { 'model_state_dict': self.model.state_dict(), @@ -677,7 +726,7 @@ class LLMTrainer: class LLMChat: """LLM对话""" - def __init__(self, model: nn.Module, vocab: dict, device: str = 'cpu', max_length: int = 128, + def __init__(self, model: nn.Module, vocab: dict, device: str = 'cpu', max_length: int = 256, show_thinking: bool = True): self.model = model.to(device) self.model.eval() @@ -692,14 +741,30 @@ class LLMChat: # 同步全局tokenizer的vocab(关键!) tokenizer_instance.char_to_id = vocab tokenizer_instance.id_to_char = {v: k for k, v in vocab.items()} + # 清除encode缓存,避免用旧的cache导致错位 + tokenizer_instance._sorted_vocab_cache = None + for attr in ['_multi_trie', '_multi_keys', '_single_map']: + if hasattr(tokenizer_instance, attr): + delattr(tokenizer_instance, attr) def generate_response(self, question: str, max_length: int = 100, temperature: float = 0.8, - include_thinking: bool = False, use_beam_search: bool = False): - """生成回答""" + include_thinking: bool = False, use_beam_search: bool = False, + history: list = None): + """生成回答 + history: 可选,历史对话列表 [(user_q, assistant_a), ...] + """ self.model.eval() - # 使用与训练数据一致的格式: 问题 - input_text = f"{question}" + # 构建输入(支持多轮上下文) + if history: + parts = [] + for hq, ha in history: + parts.append(hq) + parts.append(ha) + parts.append(question) + input_text = f"{''.join(parts)}" + else: + input_text = f"{question}" input_ids = tokenizer_instance.encode(input_text) diff --git a/regenerate_all_high_temp.py b/regenerate_all_high_temp.py new file mode 100644 index 0000000..cdcb91d --- /dev/null +++ b/regenerate_all_high_temp.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +高温多样化生成 — 把训练集全部问题用 Astron 高温度重答一次 +温度=1.2,追求多样化回答,丰富训练数据 +""" +import sys, os, json, time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', buffering=1) + +from api_correction import batch_generate + +MODEL_DIR = os.path.dirname(os.path.abspath(__file__)) +TEMPERATURE = 1.0 # 高温度(上限1.0),产出多样化回答 +BATCH_SIZE = 500 # 每批 500 条,分批保存防丢 +MAX_WORKERS = 5 # Astron 5并发 + +def extract_questions(data): + """从训练数据中提取所有问题""" + questions = [] + for item in data: + if isinstance(item, dict): + q = item.get('question', '') or item.get('input', '') + if q: + questions.append(q) + return questions + +def main(): + # 1. 加载训练数据 + train_path = os.path.join(MODEL_DIR, 'xiaoli_train_v2.json') + print(f"加载训练数据: {train_path}") + with open(train_path, encoding='utf-8') as f: + train_data = json.load(f) + + questions = extract_questions(train_data) + total = len(questions) + print(f"提取到 {total} 条问题,温度={TEMPERATURE},{MAX_WORKERS}并发") + + # 2. 创建输出目录 + out_dir = os.path.join(MODEL_DIR, 'high_temp_outputs') + os.makedirs(out_dir, exist_ok=True) + + # 3. 分批处理 + all_results = [] + existing = set() # 去重用 + + # 检查是否有已完成的中间结果 + interim_files = sorted([f for f in os.listdir(out_dir) if f.startswith('batch_') and f.endswith('.json')]) + if interim_files: + for fname in interim_files: + with open(os.path.join(out_dir, fname), encoding='utf-8') as f: + batch_data = json.load(f) + for r in batch_data: + key = (r['question'][:100], r['answer'][:100]) + if key not in existing: + existing.add(key) + all_results.append(r) + print(f"发现已有中间结果: {len(all_results)} 条(来自 {len(interim_files)} 个文件)") + # 跳过已处理的 question + processed_questions = set(r['question'] for r in all_results) + remaining = [q for q in questions if q not in processed_questions] + print(f"已处理 {len(processed_questions)} 条,剩余 {len(remaining)} 条") + questions = remaining + + total_batches = (len(questions) + BATCH_SIZE - 1) // BATCH_SIZE + t_start = time.time() + + for batch_idx in range(total_batches): + batch_qs = questions[batch_idx * BATCH_SIZE : (batch_idx + 1) * BATCH_SIZE] + print(f"\n{'='*60}") + print(f"批次 {batch_idx + 1}/{total_batches} ({len(batch_qs)}条, 温度{TEMPERATURE})") + print(f"{'='*60}") + + t0 = time.time() + results = batch_generate(batch_qs, temperature=TEMPERATURE, max_workers=MAX_WORKERS) + elapsed = time.time() - t0 + + # 标注入参温度 + for r in results: + r['temperature'] = TEMPERATURE + + # 去重后加入总集 + new_in_batch = 0 + for r in results: + key = (r['question'][:100], r['answer'][:100]) + if key not in existing: + existing.add(key) + all_results.append(r) + new_in_batch += 1 + + # 立即保存本批结果 + batch_file = os.path.join(out_dir, f'batch_{batch_idx + 1:04d}.json') + with open(batch_file, 'w', encoding='utf-8') as f: + json.dump(results, f, ensure_ascii=False, indent=2) + + # 追加保存总进度 + progress_file = os.path.join(out_dir, 'all_progress.json') + with open(progress_file, 'w', encoding='utf-8') as f: + json.dump(all_results, f, ensure_ascii=False, indent=2) + + total_elapsed = time.time() - t_start + rate = len(all_results) / max(total_elapsed, 1) * 60 + eta_remaining = (total - len(all_results)) / max(rate, 0.1) / 60 if rate > 0 else 0 + print(f" 本批新增 {new_in_batch} 条 (去重后) | 累计 {len(all_results)} 条 | " + f"{rate:.0f}条/分 | 已用 {total_elapsed/60:.1f}分 | 预计剩余 {eta_remaining:.1f}分") + + # 4. 去重统计 + print(f"\n{'='*60}") + print(f"全部生成完成!") + print(f"生成结果: {len(all_results)} 条 (去重后)") + print(f"用时: {(time.time() - t_start)/60:.1f} 分钟") + + # 5. 追加到训练集 + for tgt in ['xiaoli_train_v2.json', 'xiaoli_merged_data_v2.json']: + tp = os.path.join(MODEL_DIR, tgt) + print(f"\n追加到 {tgt}...") + with open(tp, encoding='utf-8') as f: + cur = json.load(f) + + # 去重 + ex = set((it.get('question', '')[:100], it.get('answer', '')[:100]) for it in cur) + to_add = [] + for r in all_results: + key = (r['question'][:100], r['answer'][:100]) + if key not in ex: + ex.add(key) + to_add.append({'question': r['question'], 'answer': r['answer']}) + + cur.extend(to_add) + with open(tp, 'w', encoding='utf-8') as f: + json.dump(cur, f, ensure_ascii=False, indent=2) + + if 'train' in tgt: + print(f" ✅ 训练集 +{len(to_add)} 条 → {len(cur)} 条") + else: + print(f" ✅ 合并集 +{len(to_add)} 条 → {len(cur)} 条") + + print(f"\n{'='*60}") + print(f"全部完成! 新增 {len(all_results)} 条多样化回答") + print(f"{'='*60}") + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/train_v2.py b/train_v2.py new file mode 100644 index 0000000..94435e8 --- /dev/null +++ b/train_v2.py @@ -0,0 +1,495 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +小狸完整数据集训练 - 支持断点续训 + batch=4防OOM +""" +import sys, os +sys.path.insert(0, '/workspace/xiaoli-llm-chat') +sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', buffering=1) +sys.stderr = os.fdopen(sys.stderr.fileno(), 'w', buffering=1) + +import torch +import torch.nn as nn +import json +import time +import glob +import random +from torch.utils.data import Dataset, DataLoader + +from llm_chat_improved import ( + CharTokenizer, build_vocabulary, LLMModel, LLMChat, + ConversationDataset, LLMTrainer, tokenizer_instance +) +from api_correction import batch_convert, batch_generate_multi_temp + +def dict_to_tuple(data_list): + result = [] + for item in data_list: + if isinstance(item, dict): + q = item.get("question", "") or item.get("input", "") or item.get("prompt", "") or item.get("instruction", "") + a = item.get("answer", "") or item.get("output", "") or item.get("response", "") + if q and a: + result.append((q, a)) + elif isinstance(item, (list, tuple)) and len(item) >= 2: + result.append((str(item[0]), str(item[1]))) + return result + +def load_multiturn_data(path): + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + single_turn = [] + for item in data: + if isinstance(item, dict) and "conversations" in item: + convs = item["conversations"] + for i in range(0, len(convs) - 1, 2): + if i + 1 < len(convs): + c1, c2 = convs[i], convs[i+1] + if isinstance(c1, dict): + h = c1.get("value", "") or c1.get("content", "") + a = c2.get("value", "") or c2.get("content", "") + elif isinstance(c1, list) and len(c1) >= 2: + h, a = str(c1[1]), str(c2[1]) + else: + continue + if h and a: + single_turn.append((h, a)) + return single_turn + +def find_latest_checkpoint(model_dir): + """查找最新的checkpoint,返回 (路径, epoch数) 或 (None, 0)""" + checkpoints = glob.glob(os.path.join(model_dir, 'llm_model_v2_resume_epoch_*.pth')) + if not checkpoints: + checkpoints = glob.glob(os.path.join(model_dir, 'llm_model_v2_epoch_*.pth')) + if not checkpoints: + return None, 0 + best_path = None + best_epoch = 0 + for path in checkpoints: + import re + nums = [int(s) for s in re.findall(r'\d+', os.path.basename(path).replace('.pth',''))] + if nums: + epoch = max(nums) + if epoch > best_epoch: + best_epoch = epoch + best_path = path + return best_path, best_epoch + +def migrate_weights(old_sd, new_sd_init, old_d_model, new_d_model, + old_n_layers, new_n_layers, old_d_ff, new_d_ff, + old_vocab_size, new_vocab_size): + """权重扩展迁移:将旧模型权重扩展到新架构 + 支持 d_model / n_layers / d_ff / vocab_size 全部变化 + """ + import torch + new_sd = {} + old_keys = list(old_sd.keys()) + new_keys = list(new_sd_init.keys()) + + for key in new_keys: + if 'freq' in key: + # RoPE频率表,新模型自动生成,跳过 + new_sd[key] = new_sd_init[key].clone() + continue + + # 解析层索引(如 transformer_blocks.3.attention.wq.weight) + old_key = key + new_w = new_sd_init[key].clone() + old_w_shape = None + + if key in old_sd: + old_w = old_sd[key] + old_w_shape = old_w.shape + new_shape = new_w.shape + + # 1. 严格匹配 = 直接复制 + if old_w.shape == new_shape: + new_w = old_w.clone() + # 2. 二维权重(线性层 + embedding) + elif old_w.dim() == 2: + o0, o1 = old_w.shape + n0, n1 = new_shape + # embedding.weight 和 fc_out.weight: 词汇表维度变化 + if 'embedding' in key and old_vocab_size != new_vocab_size: + # 词汇表扩展:复制旧词嵌入,新词随机 + min_v = min(old_vocab_size, new_vocab_size) + min_d = min(o1, n1) + new_w[:min_v, :min_d] = old_w[:min_v, :min_d] + print(f" 迁移 {key}: ({o0},{o1})→({n0},{n1}) vocab扩展") + elif 'fc_out' in key and old_vocab_size != new_vocab_size: + min_vocab = min(old_vocab_size, new_vocab_size) + min_feat = min(o1, n1) + new_w[:min_vocab, :min_feat] = old_w[:min_vocab, :min_feat] + print(f" 迁移 {key}: ({o0},{o1})→({n0},{n1}) vocab扩展") + elif o0 <= n0 and o1 <= n1: + # 权重扩展(old 是 new 的子集,放在左上角) + new_w[:o0, :o1] = old_w + if o0 < n0 or o1 < n1: + print(f" 扩展 {key}: ({o0},{o1})→({n0},{n1})") + elif n0 <= o0 and n1 <= o1: + # 权重缩小(new 是 old 的子集,取左上角) + new_w = old_w[:n0, :n1].clone() + print(f" 缩小 {key}: ({o0},{o1})→({n0},{n1})") + else: + # 特殊形状 → 尽量复制重合部分 + min0 = min(o0, n0) + min1 = min(o1, n1) + new_w[:min0, :min1] = old_w[:min0, :min1] + print(f" 部分迁移 {key}: ({o0},{o1})→({n0},{n1})") + # 3. 一维权重(LayerNorm, bias等) + elif old_w.dim() == 1: + o0 = old_w.shape[0] + n0 = new_w.shape[0] + min_d = min(o0, n0) + new_w[:min_d] = old_w[:min_d] + if o0 != n0: + print(f" 迁移 {key}: ({o0})→({n0}) norm扩展") + else: + new_w = old_w.clone() + else: + # 旧模型中没有这个key(可能是不存在的层) + # 检查是否是额外的新层 + # 如 transformer_blocks.6.xxx → 旧模型只有0-5 + new_sd[key] = new_w # 保持随机初始化 + continue + + new_sd[key] = new_w + + return new_sd + + +def main(): + print("=" * 60) + print("小狸训练 - 从头训练版 v2 (13.2M架构)") + print("=" * 60) + + device = 'cpu' + model_dir = '/workspace/xiaoli-llm-chat' + + # 加载数据 - 从合并文件加载 + merged_path = os.path.join(model_dir, 'xiaoli_merged_data_v2.json') + if not os.path.exists(merged_path): + print("❌ 未找到合并数据文件 xiaoli_merged_data.json") + print("请先运行合并脚本") + return + + with open(merged_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + unique = dict_to_tuple(data) + print(f"加载合并数据: {len(unique)}条") + + # ====== 划分训练/验证集(保存到独立文件,避免问题重复问题 ====== + val_split = 0.1 + train_path = os.path.join(model_dir, 'xiaoli_train_v2.json') + val_path = os.path.join(model_dir, 'xiaoli_valid_v2.json') + + if os.path.exists(train_path) and os.path.exists(val_path): + # 从独立文件加载(精确的position-based划分) + with open(train_path, 'r', encoding='utf-8') as f: + train_set = dict_to_tuple(json.load(f)) + with open(val_path, 'r', encoding='utf-8') as f: + val_set = dict_to_tuple(json.load(f)) + # 检查是否有新数据(来自外部脚本) + train_qs = set(q for q, _ in train_set) + val_qs = set(q for q, _ in val_set) + new_entries = [(q, a) for q, a in unique if q not in train_qs and q not in val_qs] + if new_entries: + train_set.extend(new_entries) + with open(train_path, 'w', encoding='utf-8') as f: + json.dump([{"question": q, "answer": a} for q, a in train_set], f, ensure_ascii=False, indent=2) + print(f" 新增外部数据: {len(new_entries)}条 → 加入训练集") + print(f" 训练集: {len(train_set)}条 | 验证集: {len(val_set)}条 (来自文件)") + else: + # 首次划分:随机打乱后按位置切分 + shuffled = unique.copy() + random.seed(42) + random.shuffle(shuffled) + split_idx = int(len(shuffled) * val_split) + val_set = shuffled[:split_idx] + train_set = shuffled[split_idx:] + # 保存两份独立文件 + with open(train_path, 'w', encoding='utf-8') as f: + json.dump([{"question": q, "answer": a} for q, a in train_set], f, ensure_ascii=False, indent=2) + with open(val_path, 'w', encoding='utf-8') as f: + json.dump([{"question": q, "answer": a} for q, a in val_set], f, ensure_ascii=False, indent=2) + print(f" 首次划分: 训练集{len(train_set)}条 + 验证集{len(val_set)}条 (已保存)") + + print(f" 训练集: {len(train_set)}条 | 验证集: {len(val_set)}条") + + # ====== 加载固定vocab ====== + vocab_path = os.path.join(model_dir, 'vocab_v2.json') + if not os.path.exists(vocab_path): + print("❌ 未找到 vocab_v2.json,请先运行固定vocab生成脚本") + return + with open(vocab_path, 'r', encoding='utf-8') as f: + vocab = json.load(f) + print(f"词汇表: {len(vocab)} (固定, 来自 vocab_v2.json)") + # 同步tokenizer(新字符映射为UNK) + tokenizer_instance.char_to_id = vocab.copy() + tokenizer_instance.id_to_char = {v: k for k, v in vocab.items()} + tokenizer_instance._sorted_vocab_cache = None + for attr in ['_multi_trie', '_multi_keys', '_single_map']: + if hasattr(tokenizer_instance, attr): + delattr(tokenizer_instance, attr) + + # ====== 加载蒸馏问题(v3 + 真题) ====== + distill_q_path = os.path.join(model_dir, 'exam_distill_questions.json') + if not os.path.exists(distill_q_path): + distill_q_path = os.path.join(model_dir, 'distill_questions_v3.json') + if not os.path.exists(distill_q_path): + distill_q_path = os.path.join(model_dir, 'distill_questions_v2.json') + if os.path.exists(distill_q_path): + with open(distill_q_path, 'r', encoding='utf-8') as f: + distill_questions = json.load(f) + print(f"蒸馏问题: {len(distill_questions)}道") + else: + distill_questions = [] + print("蒸馏问题: 无") + + config = { + 'batch_size': 4, + 'gradient_accumulation_steps': 2, + 'epochs': 200, + 'd_model': 320, 'n_heads': 8, 'n_layers': 6, + 'd_ff': 1536, 'max_length': 256, 'dropout': 0.1, + 'use_amp': False, + } + + 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=False, use_thinking=False, use_moe=False, + num_experts=1, top_k=1, + ) + total_params = sum(p.numel() for p in model.parameters()) + print(f"参数量: {total_params:,} ({total_params/1e6:.2f}M)") + + # 训练集/验证集 DataLoader + train_dataset = ConversationDataset(train_set, vocab, config['max_length']) + train_loader = DataLoader(train_dataset, batch_size=config['batch_size'], + shuffle=True, num_workers=0) + print(f"训练批次: {len(train_loader)}") + + val_dataset = ConversationDataset(val_set, vocab, config['max_length']) + val_loader = DataLoader(val_dataset, batch_size=config['batch_size'], + shuffle=False, num_workers=0) + print(f"验证批次: {len(val_loader)}") + + # 检查断点 + ckpt_path, start_epoch = find_latest_checkpoint(model_dir) + if ckpt_path and start_epoch > 0: + print(f"\n发现断点: epoch {start_epoch} ({ckpt_path})") + checkpoint = torch.load(ckpt_path, map_location=device) + old_cfg = checkpoint.get('model_config', {}) + old_d_model = old_cfg.get('d_model', 256) + old_n_layers = old_cfg.get('n_layers', 6) + old_d_ff = old_cfg.get('d_ff', 1024) + + # 检测架构变化,自动做权重扩展迁移 + new_sd_init = model.state_dict() # 随机初始化的新权重 + old_sd = checkpoint['model_state_dict'] + old_vocab_size = old_cfg.get('vocab_size', 0) + new_vocab_size = len(vocab) + + # 如果架构没变化,直接加载 + if (old_d_model == config['d_model'] and old_n_layers == config['n_layers'] + and old_d_ff == config['d_ff'] and old_vocab_size == new_vocab_size): + model.load_state_dict(old_sd) + print("✅ 架构一致,直接加载权重") + else: + print(f" 架构变化: d_model={old_d_model}→{config['d_model']}, " + f"layers={old_n_layers}→{config['n_layers']}, " + f"d_ff={old_d_ff}→{config['d_ff']}, " + f"vocab={old_vocab_size}→{new_vocab_size}") + print(" 执行权重扩展迁移(宽度扩展 + 深度复制)...") + new_sd = migrate_weights(old_sd, new_sd_init, old_d_model, config['d_model'], + old_n_layers, config['n_layers'], + old_d_ff, config['d_ff'], + old_vocab_size, new_vocab_size) + model.load_state_dict(new_sd) + print(" ✅ 权重扩展迁移完成!") + + # 记录旧checkpoint已使用,从下一epoch开始 + start_epoch = start_epoch + + # 尝试加载优化器(仅当架构vocab完全一致时) + if (old_d_model == config['d_model'] and old_n_layers == config['n_layers'] + and old_d_ff == config['d_ff'] and old_vocab_size == new_vocab_size + and 'optimizer_state_dict' in checkpoint): + try: + trainer = LLMTrainer(model, device, use_amp=config['use_amp']) + # 前向一步恢复优化器(不做完整epoch,太慢) + batch = next(iter(train_loader)) + input_ids = batch['input_ids'].to(device) + target_ids = batch['target_ids'].to(device) + mask = model.generate_mask(input_ids.size(1)).to(device) + outputs = model(input_ids, mask) + loss = trainer.criterion(outputs.reshape(-1, outputs.size(-1)), target_ids.reshape(-1)) + loss.backward() + trainer.optimizer.step() + trainer.optimizer.zero_grad() + # 加载优化器状态 + trainer.optimizer.load_state_dict(checkpoint['optimizer_state_dict']) + print("优化器状态已加载(1步热身)") + except Exception as e: + print(f"优化器加载失败(将使用新的): {e}") + + print(f"\n开始训练: epoch {start_epoch + 1} -> {config['epochs']}") + print("=" * 60) + start = time.time() + + # 确保trainer已创建(可能因为架构变化没有在提前创建) + if 'trainer' not in dir() or trainer is None: + trainer = LLMTrainer(model, device, use_amp=config['use_amp']) + + best_val_loss = float('inf') + total_distill_added = 0 + for epoch in range(start_epoch + 1, config['epochs'] + 1): + # ====== 每轮从train_path重建dataloader(蒸馏数据已追加至此)====== + with open(train_path, 'r', encoding='utf-8') as f: + epoch_train = dict_to_tuple(json.load(f)) + print(f"\n 训练数据: {len(epoch_train)}条 (含{total_distill_added}条蒸馏新增)") + + train_dataset = ConversationDataset(epoch_train, vocab, config['max_length']) + train_loader = DataLoader(train_dataset, batch_size=config['batch_size'], + shuffle=True, num_workers=0) + + # ====== 训练 ====== + t0 = time.time() + avg_loss = trainer.train_epoch(train_loader, epoch, vocab, config) + dt = time.time() - t0 + + # ====== 验证 ====== + t1 = time.time() + val_loss = trainer.evaluate(val_loader, config) + dt_val = time.time() - t1 + + # 打印train/val loss + marker = "" + if val_loss < best_val_loss: + best_val_loss = val_loss + marker = " ★" + best_path = os.path.join(model_dir, 'xiaoli_v2_best.pth') + trainer.save_model(best_path, vocab, epoch) + print(f"Epoch {epoch}/{config['epochs']} | Train Loss: {avg_loss:.4f} | Val Loss: {val_loss:.4f}{marker} | {dt:.0f}s+{dt_val:.0f}s") + + # 每轮保存断点 + resume_path = os.path.join(model_dir, f'llm_model_v2_resume_epoch_{epoch}.pth') + trainer.save_model(resume_path, vocab, epoch) + if epoch % 20 == 0: + save_path = os.path.join(model_dir, f'llm_model_v2_epoch_{epoch}.pth') + trainer.save_model(save_path, vocab, epoch) + print(f" 已保存正式checkpoint: epoch {epoch}") + resume_files = sorted(glob.glob(os.path.join(model_dir, 'llm_model_v2_resume_*.pth'))) + for old in resume_files[:-5]: + try: os.remove(old) + except: pass + + # ====== 每轮末尾:在线蒸馏 ====== + if distill_questions: + distill_t0 = time.time() + try: + print(f" 📡 在线蒸馏 ({len(distill_questions)}题 × 多种方式)...") + chat = LLMChat(model, vocab, device, config['max_length']) + all_new_pairs = [] + + # ====== Pass 1: 模型生成(temperature=0.15) → API转换 ====== + print(" [P1] 模型生成中...") + model_answers = [] + for i, q in enumerate(distill_questions): + ans = chat.generate_response(q, max_length=40, temperature=0.15) + model_answers.append((q, ans)) + if (i + 1) % 100 == 0: + print(f" P1: {i+1}/{len(distill_questions)}") + p1_gen = time.time() + print(f" [P1] 生成完毕 ({(p1_gen-distill_t0):.0f}s),API转换中...") + + def progress(done, total): + if done % 50 == 0 or done == total: + print(f" API: {done}/{total}", end="\r") + + p1_results = batch_convert(model_answers, progress) + print(f" API: {len(model_answers)}/{len(model_answers)}") + all_new_pairs.extend(p1_results) + print(f" [P1] +{len(p1_results)}条") + + # ====== Pass 2: 模型高温生成(temperature=0.7) → 更多多样性 ====== + print(f" [P2] 模型高温生成中 (temp=0.7)...") + model_answers2 = [] + for i, q in enumerate(distill_questions): + ans = chat.generate_response(q, max_length=40, temperature=0.7) + model_answers2.append((q, ans)) + if (i + 1) % 100 == 0: + print(f" P2: {i+1}/{len(distill_questions)}") + p2_gen = time.time() + print(f" [P2] 生成完毕 ({(p2_gen-p1_gen):.0f}s),API转换中...") + + p2_results = batch_convert(model_answers2, progress) + print(f" API: {len(model_answers2)}/{len(model_answers2)}") + all_new_pairs.extend(p2_results) + print(f" [P2] +{len(p2_results)}条") + + # ====== Pass 3: API直接生成多样回答(temp=1.0) ====== + print(f" [P3] API直接生成 (temp=1.0, 全部直答)...") + p3_t0 = time.time() + p3_results = batch_generate_multi_temp( + distill_questions, + temperatures=[1.0], + max_workers=8 + ) + all_new_pairs.extend(p3_results) + print(f" [P3] +{len(p3_results)}条") + + # ====== 合并去重后追加 ====== + if all_new_pairs: + with open(train_path, 'r', encoding='utf-8') as f: + cur_train = json.load(f) + existing_pairs = set((item.get("question",""), item.get("answer","")) for item in cur_train) + truly_new = [item for item in all_new_pairs + if (item["question"], item["answer"]) not in existing_pairs] + + 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) + # 同步到merged + 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) + total_distill_added += len(truly_new) + distill_total = time.time() - distill_t0 + print(f" ✅ 蒸馏完成: +{len(truly_new)}条 (新增{len(set(q for q,a in [(i['question'],i['answer']) for i in truly_new]))}道独特问题) | 总耗时{distill_total:.0f}s") + else: + print(f" ℹ️ 蒸馏完成: 本轮无新内容(全部已存在)({time.time()-distill_t0:.0f}s)") + else: + print(f" ℹ️ 蒸馏完成: 无有效转换 ({time.time()-distill_t0:.0f}s)") + except Exception as e: + print(f" ⚠️ 蒸馏异常: {e}") + + # 保存最终模型 + trainer.save_model(os.path.join(model_dir, 'xiaoli_v2_final.pth'), vocab, config['epochs']) + + # 清理所有resume文件 + for f in glob.glob(os.path.join(model_dir, 'llm_model_v2_resume_*.pth')): + try: + os.remove(f) + except: + pass + + total = time.time() - start + print(f"\n训练完成!总耗时: {total/60:.1f}分钟") + + # 测试 + print("\n测试:") + chat = LLMChat(model, vocab, device, config['max_length']) + for q in ["你好", "你是谁", "你叫什么名字", "你好可爱", "我喜欢你", "1+1等于几"]: + r = chat.generate_response(q, max_length=60, temperature=0.8) + print(f" Q: {q}") + print(f" A: {r}\n") + +if __name__ == "__main__": + main() diff --git a/vocab_v2.json b/vocab_v2.json new file mode 100644 index 0000000..3de7706 --- /dev/null +++ b/vocab_v2.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efabe0696ef9fcd64685f70a62bd55c97ca2b8efa69bca26df35177ac6774330 +size 41185 diff --git a/web_server.py b/web_server.py new file mode 100644 index 0000000..b5b8976 --- /dev/null +++ b/web_server.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""小狸v1.2 Web聊天服务器 - 当前训练中的模型""" +import sys, os, json, time +sys.path.insert(0, '/workspace/xiaoli-llm-chat') +import torch +from flask import Flask, request, jsonify, send_from_directory +from llm_chat_improved import LLMModel, LLMChat, build_vocabulary + +print("=" * 50) +print(" 小狸 v1.2 Web 服务加载中...") +print("=" * 50) + +device = 'cpu' +model_dir = '/workspace/xiaoli-llm-chat' + +# 构建当前vocab +with open(f'{model_dir}/xiaoli_merged_data_v2.json', 'r', encoding='utf-8') as f: + all_data = json.load(f) +pairs = [] +for d in all_data: + q = d.get('question','') or d.get('input','') or d.get('prompt','') or '' + a = d.get('answer','') or d.get('output','') or d.get('response','') or '' + if q and a: + pairs.append((q, a)) +vocab = build_vocabulary(pairs) +print(f"词汇表: {len(vocab)} | 数据: {len(all_data)}条") + +# 加载模型 (epoch 50 = 干净epoch 41 + vocab扩展) +ckpt_path = f'{model_dir}/llm_model_v2_resume_epoch_50.pth' +print(f"加载: {ckpt_path}") +ckpt = torch.load(ckpt_path, map_location=device) +old_vocab_size = ckpt.get('model_config', {}).get('vocab_size', 0) +cfg = ckpt['model_config'] +print(f"断点vocab: {old_vocab_size}") + +model = LLMModel( + vocab_size=len(vocab), + d_model=cfg['d_model'], n_heads=cfg['n_heads'], + n_layers=cfg['n_layers'], d_ff=cfg['d_ff'], + max_len=cfg['max_len'], dropout=cfg.get('dropout', 0.1), + use_memory=False, use_thinking=False, use_moe=False, + num_experts=1, top_k=1, +) + +# 迁移权重 +old_sd = ckpt['model_state_dict'] +new_sd = model.state_dict() +if old_vocab_size != len(vocab): + for key in new_sd: + if key in old_sd: + w = old_sd[key] + if w.shape == new_sd[key].shape: + new_sd[key] = w.clone() + elif len(w.shape) == 2 and w.shape[0] == old_vocab_size: + new_sd[key][:old_vocab_size] = w + elif 'freq' in key: + pass + else: + try: new_sd[key] = w.clone() + except: pass + model.load_state_dict(new_sd) +else: + model.load_state_dict(old_sd) + +model.to(device) +model.eval() +chat = LLMChat(model, vocab, device, max_length=cfg['max_len']) +print(f"✅ 模型加载完成! | voc={len(vocab)} | max_len={cfg['max_len']}") + +app = Flask(__name__) + +@app.route('/') +def index(): + return send_from_directory(model_dir, 'chat.html') + +@app.route('/api/chat', methods=['POST']) +def api_chat(): + data = request.get_json() + question = data.get('question', '').strip() + temp = float(data.get('temperature', 0.2)) + max_len = int(data.get('max_length', 100)) + history = data.get('history', None) + + if not question: + return jsonify({'error': '问题不能为空'}) + + # 如果有history,转换为对话格式 + history_pairs = None + if history and isinstance(history, list) and len(history) > 0: + history_pairs = [(h['q'], h['a']) for h in history] + + try: + t0 = time.time() + answer = chat.generate_response(question, max_length=max_len, temperature=temp, history=history_pairs) + elapsed = time.time() - t0 + return jsonify({ + 'question': question, + 'answer': answer, + 'time': f'{elapsed:.1f}s', + 'temperature': temp, + }) + except Exception as e: + return jsonify({'error': str(e)}) + +@app.route('/api/info') +def model_info(): + return jsonify({ + 'name': '小狸 v1.2', + 'version': 'epoch 52 (架构扩展: 7.9M→21.3M, max_len=256)', + 'vocab_size': len(vocab), + 'max_length': cfg['max_len'], + 'd_model': cfg['d_model'], + 'layers': cfg['n_layers'], + 'heads': cfg['n_heads'], + 'd_ff': cfg['d_ff'], + 'data_size': f'{len(all_data):,}条', + 'training': '新架构训练中 epoch 1→200', + 'features': ['多轮对话(1,694条)', '长文本(758条)', '我不知道(1,140条)'], + }) + +if __name__ == '__main__': + print("\n" + "=" * 50) + print(" 小狸 v1.2 聊天服务已启动!") + print(" 访问地址: http://0.0.0.0:5000") + print(" 建议温度: 0.1 ~ 0.2") + print("=" * 50) + app.run(host='0.0.0.0', port=5000, debug=False, threaded=True) \ No newline at end of file diff --git a/xiaoli_merged_data_v2.json b/xiaoli_merged_data_v2.json new file mode 100644 index 0000000..f936b6c --- /dev/null +++ b/xiaoli_merged_data_v2.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b117303df258c2296699ad987c2e5f9ef141e9adef2b693723c2a41511a85d4d +size 12616207 diff --git a/xiaoli_train_v2.json b/xiaoli_train_v2.json new file mode 100644 index 0000000..bc32cd6 --- /dev/null +++ b/xiaoli_train_v2.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfa8970a4a176f23451002081afec16c909b6c8889b42819d698bc2e44e06aeb +size 12290087 diff --git a/xiaoli_valid_v2.json b/xiaoli_valid_v2.json new file mode 100644 index 0000000..8b55843 --- /dev/null +++ b/xiaoli_valid_v2.json @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c445541e8aaefe41ef3223ec0f83988acf5e282a52f34d13f8560e551615a2d2 +size 327442