← 返回首页

PyroDash 训练项目代码检视 2026-09-19(第 1 轮)

训练 4B 小模型自主判断「何时求助大模型」——三阶段渐进训练管线源码汇编

代码检视 发布:2026-09-19(第 1 轮) 项目:D:\project\2026\202609\Small_ask_big 论文:arXiv:2607.20327 版本:v2 评审修复版 日期:2026-09-19

一、项目目标与预期

1.1 训练目的

要解决的核心问题 大语言模型推理时,简单题用大模型是浪费算力,难题用小模型又做不对。传统「路由方案」(RouteLLM、GlimpRouter)在解题前就决定用哪个模型,无法感知题目在推理中途才暴露的难度。本项目要训练一个 4B 小模型,让它在推理过程中自主判断能力边界,遇到卡点时输出控制 token <|llm_offload|>,触发一次对大模型的单次交接,从而在几乎不损失准确率的前提下把大模型调用成本降低 90%+。

1.2 短期落地目标

1.3 实验预期效果(论文基线)

操作点λ 系数准确率成本触发率
省钱档0.654.55%(比纯大模型低 3pp)$1.78(降 96.4%)约 1/100 题
效果档0.0564.04%(反超纯大模型 6.36pp)比纯大模型低 20.4%几乎每题

1.4 已实现能力

已跑通并落盘
  • ✔ 数据构建:EasyHard-24k 双语料(A 无求助 / B' 有求助),7584 条
  • ✔ Stage 1 锚点初始化:τ_off id=248077,句号/换行/EOS 均值 + 高斯噪声
  • ✔ Stage 2 SFT:LoRA r=16,单卡 22GB,loss 0.62 → 0.157,已续训至 step 225+
  • ✔ 协同引擎 CE:严格对齐官方协议(stop 串回读、part_think 拼接、预算共享)
  • ✔ GSM8K 1000 题评估管线:官方 λ=0.6 模型 89.4%,零失败零超时
  • ✔ 工具链:GGUF 修补(token_type CONTROL→NORMAL)、llama.cpp 转换、watchdog 巡检

1.5 待验证假设

尚未证实的核心假设
  • ◐ Stage 3 GRPO 策略梯度实现正确性:v2 版本的 grpo_step_loss 未把 prompt 上下文编码进前向,导致 log_prob 条件错误,需重写后才能验证「模型学会成本感知求助时机」。
  • ✘ 自训模型 offload 行为:SFT 完成后模型是否会在难题正确触发 <|llm_offload|> 尚待 behavior_scan.py 实测。
  • ✘ 27B 作为「老师」的效果上限:27B 在难题上能否救回 4B,直接决定效果档(λ=0.05)能否复现论文的「反超」结论。
  • ✘ llama.cpp 与 transformers 引擎差异的根因:已知 llama.cpp 对 qwen3.5 新架构 offload 采样永不触发,但未定位到具体算子层。
  • ✘ 训练/推理 prompt 的 <think> 一致性:需抽检训练数据确认 assistant content 内是否含 </think> 分隔符。

二、项目简介

2.1 基础信息

项目名
Small_ask_big(PyroDash 复现)
项目路径
D:\project\2026\202609\Small_ask_big
论文
arXiv:2607.20327(火思动力开源)
官方仓库
github.com/Pyromind-Dynamics/PyroDash
文件规模
32 个文件,约 90KB 源码

2.2 硬件环境

GPU
双卡:卡0(27B 推理服务)+ 卡1(4B 训练/评估)
显存
单卡 22GB 级(SFT LoRA bf16 占用)
操作系统
Windows 11(WSL2 可选)
Python
3.11(C:\Program Files\Python311)

2.3 基座模型

学生(小)
Qwen3.5-4B(bf16 LoRA 微调)
老师(大)
Qwen3.8-27B-Ridge-GGUF(3.7bpw,本地部署,冻结)
对照
PyroDash-4B-GRPO-Lambda-0.6 / 0.05(官方权重)

2.4 训练方案

三阶段渐进训练
  1. Stage 1 控制 token 嵌入学习:词表新增 <|llm_offload|>,锚点初始化把嵌入放在自然断点 token 家族附近,语义上「住对地方」。
  2. Stage 2 双语料 SFT 行为冷启动:Corpus A(无授权提示 + 完整独立推理)保住独立推理能力;Corpus B'(授权提示 + hard 题 CoT 插 1~4 个 τ_off)教会求助动作。easy 题即使授权也绝不插。
  3. Stage 3 成本感知 GRPO:每题采样 G 条轨迹,奖励 = 答对(0/1) − λ×(协作成本 / 纯大模型成本),小模型交接前每个 token 双重计费。

2.5 关键约束

必须遵守的工程约束
  • 引擎一致性:自训模型的 offload 行为验证必须用 transformers 引擎,llama.cpp 对新架构存在引擎级差异(永不触发)。
  • Windows 线性注意力:qwen3.5 依赖 fla+Triton,不可用时静默回退 CPU,训练须强制走 transformers 的 torch 回退实现。
  • 磁盘管理:磁盘满会导致 transformers 加载模型报 FileNotFound,checkpoint 需滚动保留最近 2 个。
  • 窗口调度:训练窗口 18:00–次日 06:00,白天自动停训并释放 GPU。
  • 协同协议上限:至多一次交接,不回头,避免无限循环。

三、Mermaid 训练流程图

节点标题格式:模块编号 · 代码文件名,与下方第四章各模块一一对应。

flowchart TD A["M1 train_data_prep.py
构建双语料 A / B'"] --> B["M2 train_sft.py
Stage1+2 锚点初始化 + LoRA SFT"] B --> C["M3 grpo_train.py
Stage3 成本感知 GRPO"] B --> D["M6 behavior_scan.py
难题 offload 行为扫描"] C --> D D --> E["M5 run_gsm8k_1k.py
GSM8K 千题评估管线"] E --> F["M7 math_eval_mod.py
打分与 relay 工具"] B --> G["M4 collaborate_engine.py
协同引擎 CE 协议"] G --> E H["M10 patch_gguf.py
token_type CONTROL→NORMAL"] -.-> G I["M9 daily_start.py / autostop.py
窗口调度自动起停"] -.-> B I -.-> C J["M8 watchdog_dog.py
进程存活 + GPU 占用巡检"] -.-> B style A fill:#d6eaf8,stroke:#2874a6 style B fill:#d6eaf8,stroke:#2874a6 style C fill:#fadbd8,stroke:#c0392b style D fill:#fdebd0,stroke:#b9770e style E fill:#d5f5e3,stroke:#1e8449 style F fill:#d5f5e3,stroke:#1e8449 style G fill:#fdebd0,stroke:#b9770e style H fill:#e8daef,stroke:#7d3c98 style I fill:#e8daef,stroke:#7d3c98 style J fill:#e8daef,stroke:#7d3c98
数据流要点
  • 训练主链:M1(数据)→ M2(SFT)→ M3(GRPO)→ M6(行为扫描)→ M5(千题评估),闭环验证。
  • 协议旁路:M4 协同引擎被 M3 的 rollout、M5 的评估、M6 的行为扫描共同复用,是全项目「交接语义」的唯一权威实现。
  • 基础设施:M8 巡检 M2/M3 的存活与 GPU 占用;M9 按窗口自动起停;M10 修补 GGUF 后才能被 M4 正确识别 stop 串。

四、各模块代码

M1 数据准备:双语料构建

scripts/train_data_prep.py
【用途】 将 EasyHard-24k 的 math 子集转换为 PyroDash Stage1+2 训练语料。按数据源文件真实标记难度(easy 文件不插 τ_off,hard 文件才插),生成 Corpus A(独立推理)与 Corpus B'(授权模式)双语料,输出 messages 格式的 JSONL 供 TRL 直接使用。
【入参说明】
  • 输入:datasets/easyhard-24k/{math_easy.jsonl, dapo_math_easy.jsonl, math_hard.jsonl}
  • 输出:data/train_sft.jsonl(每行一条 messages 样本,附 corpus/difficulty 元字段)
  • 随机种子:固定 42,保证语料可复现
# -*- coding: utf-8 -*-
"""EasyHard-24k math 子集 -> PyroDash Stage1+2 训练数据 (v2, 评审修复版)

P0-3 修复: easy/hard 用数据源文件真实标记 (easy 文件不插 τ_off,
          hard 文件才插), 修复原版 `or True` 恒真导致 easy 题也插的防滥用违规。
P2-15 修复: 保守切分 (只在句号+空格 / 双换行处切), 保留原分隔符, 不破坏格式。

Corpus A: 原始 system, 无 tau_off (保独立推理)
Corpus B': offload system 提示; hard 样本 CoT 内动态插入 1~4 个 tau_off;
           easy 样本授权提示但轨迹无 tau_off (学"授权时不滥用")
输出: data/train_sft.jsonl (messages 格式)
"""
import json
import os
import random
import re

random.seed(42)

BASE = r"D:\project\2026\202609\Small_ask_big"
DS = os.path.join(BASE, "datasets", "easyhard-24k")
OUT = os.path.join(BASE, "data", "train_sft.jsonl")
TAU = "<|llm_offload|>"

SYSTEM_A = "You are a helpful math assistant."
SYSTEM_B = (
    "You are a helpful assistant. Solve the given problem step by step. "
    "For very difficult steps, you can output <|llm_offload|> to request help "
    "from a more capable model."
)

# P2-15: 保守切分 - 句号/问号/感叹号 + 空白, 或换行; 用 split 保留分隔符
SENT_SPLIT = re.compile(r"(?<=[.!?])\s+|\n")


# 在思维链文本的 n 个指定位置插入 τ_off 交接标记(授权模式的核心动作)
def insert_tau(cot_text, n):
    """在 CoT 句子边界随机插 n 个 tau_off, 保留原分隔符。"""
    parts = SENT_SPLIT.split(cot_text)
    if len(parts) < 3:
        return None
    cands = list(range(1, len(parts)))
    n = min(n, len(cands))
    pos = set(random.sample(cands, n))
    out = []
    for i, s in enumerate(parts):
        out.append(s)
        if i in pos:
            # 用与原文一致的连接方式补 tau (换行切分 -> 换行, 空格切分 -> 空格)
            sep = "\n" if "\n" in cot_text else " "
            out.append(sep + TAU)
    text = "".join(out)
    return text if TAU in text else None


def load_jsonl(p):
    rows = []
    with open(p, encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if line:
                rows.append(json.loads(line))
    return rows


# 主流程: 读 easy/hard 双源 -> 分别构建语料 A(独立推理)/B(授权模式) -> 打乱落盘
def main():
    os.makedirs(os.path.dirname(OUT), exist_ok=True)
    # 数据源文件即难度标签 (P0-3: 真实 easy/hard, 不再恒真)
    easy = load_jsonl(os.path.join(DS, "math_easy.jsonl"))          # base 答对
    dapo = load_jsonl(os.path.join(DS, "dapo_math_easy.jsonl"))     # 精调后答对 = easy
    hard = load_jsonl(os.path.join(DS, "math_hard.jsonl"))          # base 答错, 已重构
    random.shuffle(dapo)
    dapo = dapo[:1200]

    tagged = ([(r, "easy") for r in easy] +
              [(r, "easy") for r in dapo] +
              [(r, "hard") for r in hard])
    random.shuffle(tagged)

    data = []
    n_b_easy = 0   # B' 中 easy (授权但不求助) 条数
    n_b_hard = 0   # B' 中 hard (含 τ_off 插入) 条数
    for r, difficulty in tagged:
        msgs = r["messages"]
        if len(msgs) < 3:
            continue
        user, assistant = msgs[1], msgs[-1]
        q, a = user["content"], assistant["content"]
        if not q or not a:
            continue
        a_clean = a.replace(TAU, "")
        # ---- Corpus A: 独立推理 ----
        data.append({
            "messages": [
                {"role": "system", "content": SYSTEM_A},
                {"role": "user", "content": q},
                {"role": "assistant", "content": a_clean},
            ],
            "corpus": "A", "difficulty": difficulty,
        })
        # ---- Corpus B': 授权模式 ----
        if difficulty == "hard":
            n_ins = random.randint(1, 4)
            n_ins = min(n_ins, max(1, len(SENT_SPLIT.split(a_clean)) // 4))
            a_b = insert_tau(a_clean, n_ins)
        else:
            a_b = a_clean          # easy: 授权但绝不求助 (P0-3 防滥用核心)
        if a_b is None:
            continue
        if TAU in a_b:
            n_b_hard += 1
        else:
            n_b_easy += 1
        data.append({
            "messages": [
                {"role": "system", "content": SYSTEM_B},
                {"role": "user", "content": q},
                {"role": "assistant", "content": a_b},
            ],
            "corpus": "B", "difficulty": difficulty,
        })

    random.shuffle(data)
    with open(OUT, "w", encoding="utf-8") as f:
# 混合打乱后统一写出 jsonl, 供 Stage1+2 SFT 直接消费
        for d in data:
            f.write(json.dumps(d, ensure_ascii=False) + "\n")

    print(f"total={len(data)} B_easy_no_tau={n_b_easy} B_hard_with_tau={n_b_hard} -> {OUT}")


if __name__ == "__main__":
    main()

M2 Stage 1+2 SFT 训练(锚点初始化 + LoRA)

scripts/train_sft.py
【用途】 把 <|llm_offload|> 装入 Qwen3.5-4B 词表并用「句号/换行/EOS 均值 + 高斯噪声」锚点初始化其嵌入;随后用 LoRA 在双语料上做监督微调。含窗口自动停训、checkpoint 数字排序滚动保留、断点续训、训练/暂停/失败邮件通知。
【入参说明】
  • --resume:LoRA checkpoint 目录路径,为空则从零开始
  • --start-step:全局起始步数(续训时传入,影响日志分母与 cap)
  • --epochs:训练轮数,0 表示用默认 EPOCHS=1
  • 环境变量:CUDA_VISIBLE_DEVICES=1,HF_HUB_OFFLINE=1
  • 输入文件:data/train_sft.jsonl
# -*- coding: utf-8 -*-
"""PyroDash Stage1+2 合并训练: 词表扩 tau_off(锚点初始化) + LoRA SFT
- 基座: Qwen3.5-4B (bf16, 单卡 22GB)
- 与论文差异: 嵌入行锚点初始化后冻结(不做全量嵌入微调), 语义适配交给 LoRA 中间层
"""
import json
import math
import os
import random
import time

os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["CUDA_VISIBLE_DEVICES"] = "1"
import sys
sys.modules['fla'] = None  # force torch fallback for linear attention (full GPU)
sys.modules['fla.'] = None

import torch
from torch.utils.data import DataLoader, Dataset

BASE = r"D:\project\2026\202609\Small_ask_big"
MODEL_DIR = os.path.join(BASE, "models", "Qwen3.5-4B")
DATA = os.path.join(BASE, "data", "train_sft.jsonl")
OUT_DIR = os.path.join(BASE, "ckpt", "sft_lora")
LOG = os.path.join(BASE, "logs", "train_sft.log")
TAU = "<|llm_offload|>"
MAX_LEN = 768
BS = 4
ACC = 4
EPOCHS = 1
LR = 1e-4

import logging
os.makedirs(os.path.dirname(LOG), exist_ok=True)
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s",
                    handlers=[logging.FileHandler(LOG, encoding="utf-8"), logging.StreamHandler()])
log = logging.getLogger("sft")


# 数据集: 逐行读 jsonl(prompt+response), 训练时只对 response 段计算 loss
class SFTData(Dataset):
    def __init__(self, path, tok):
        self.rows = []
        with open(path, encoding="utf-8") as f:
            for line in f:
                self.rows.append(json.loads(line))
        self.tok = tok

    def __len__(self):
        return len(self.rows)

    def __getitem__(self, i):
        r = self.rows[i]
        m = r["messages"]
        # P0-4: prompt 带 <think>\n, 与评估/推理协议(chat_template enable_thinking)统一
        prompt = (
            f"<|im_start|>system\n{m[0]['content']}<|im_end|>\n"
            f"<|im_start|>user\n{m[1]['content']}<|im_end|>\n"
            f"<|im_start|>assistant\n<think>\n"
        )
        target = m[2]["content"]
        # 目标若以 <think> 开头则剥掉 (避免与 prompt 尾的 <think>\n 重复)
        if target.startswith("<think>"):
            target = target[len("<think>"):]
        if target.startswith("\n"):
            target = target[1:]
        target = target + "<|im_end|>"
        p_ids = self.tok(prompt, add_special_tokens=False)["input_ids"]
        t_ids = self.tok(target, add_special_tokens=False)["input_ids"]
        ids = (p_ids + t_ids)[:MAX_LEN]
        labels = [-100] * len(p_ids) + t_ids[:]
        labels = labels[:MAX_LEN]
        return {"input_ids": ids, "labels": labels}


# 动态 padding 到批内最长; labels 中 pad 位置置 -100, 不参与 loss
def collate(batch, pad_id):
    ml = max(len(b["input_ids"]) for b in batch)
    input_ids, labels, attn = [], [], []
    for b in batch:
        n = ml - len(b["input_ids"])
        input_ids.append(b["input_ids"] + [pad_id] * n)
        labels.append(b["labels"] + [-100] * n)
        attn.append([1] * len(b["input_ids"]) + [0] * n)
    return (torch.tensor(input_ids), torch.tensor(labels), torch.tensor(attn))


# 主流程: 加载 Qwen3.5-4B -> 词表扩 tau_off 锚点初始化(冻结嵌入) -> 挂 LoRA -> 训练
def main():
    import argparse
    ap = argparse.ArgumentParser()
    ap.add_argument('--resume', default='')
    ap.add_argument('--start-step', type=int, default=0)
    ap.add_argument('--epochs', type=int, default=0)
    args = ap.parse_args()
    from transformers import AutoModelForCausalLM, AutoTokenizer
    from peft import LoraConfig, get_peft_model

    torch.manual_seed(42)
    random.seed(42)

    log.info("loading tokenizer/base model...")
    tok = AutoTokenizer.from_pretrained(MODEL_DIR, trust_remote_code=True)
    if TAU not in tok.get_vocab():
        tok.add_tokens([TAU])
        log.info("added %s to tokenizer", TAU)

    model = AutoModelForCausalLM.from_pretrained(
        MODEL_DIR, torch_dtype=torch.bfloat16, trust_remote_code=True,
        attn_implementation="sdpa").cuda()
    model.resize_token_embeddings(len(tok))
    # 锚点初始化: tau_off 嵌入 = 句号/换行/endoftext 均值 + N(0, 0.1^2)
    with torch.no_grad():
        w = model.get_input_embeddings().weight
        tid = tok.get_vocab()[TAU]
        anchors = []
    # 锚点初始化: 新 token 嵌入用常见终止符嵌入均值初始化, 语义上接近"回答结束"
        for a in [".", "\n", "<|im_end|>", "<|endoftext|>"]:
            if a in tok.get_vocab():
                anchors.append(w[tok.get_vocab()[a]].float())
        mean_emb = torch.stack(anchors).mean(0)
        w[tid] = (mean_emb + torch.randn_like(mean_emb) * 0.1).to(w.dtype)
        # tied lm_head 同表自动同步
    log.info("tau_off id=%d initialized (anchor mean+noise)", tid)

    lcfg = LoraConfig(
        r=16, lora_alpha=32, lora_dropout=0.05, bias="none",
        target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                        "gate_proj", "up_proj", "down_proj"],
        task_type="CAUSAL_LM")
    if args.resume:
        from peft import PeftModel
        model = PeftModel.from_pretrained(model, args.resume, is_trainable=True)
        log.info('resumed LoRA from %s', args.resume)
    else:
        model = get_peft_model(model, lcfg)
    model.print_trainable_parameters()
    model.gradient_checkpointing_enable()
    model.enable_input_require_grads()
    model.cuda()

    ds = SFTData(DATA, tok)
    dl = DataLoader(ds, batch_size=BS, shuffle=True,
                    collate_fn=lambda b: collate(b, tok.get_vocab()["<|im_end|>"]),
                    num_workers=0, drop_last=True)
    n_epochs = args.epochs if args.epochs > 0 else EPOCHS
    total_steps = len(dl) * n_epochs // ACC
    global_done = False
    opt = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad],
                            lr=LR, weight_decay=0.01)
    sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=max(1, total_steps))
    log.info("data=%d steps/epoch=%d total_steps=%d start_step=%d (global target=%d)",
             len(ds), len(dl), total_steps, args.start_step,
             args.start_step + total_steps)

    model.train()
    step = args.start_step          # 全局步计数 (含 resume 起点)
    window_stopped = False
    t0 = time.time()
    losses = []
    done = False
# 训练循环: bf16 前向, 梯度累积到边界才 step; 优化器只更新 LoRA+解冻层
    for ep in range(n_epochs):
        if done:
            break
        for i, (input_ids, labels, attn) in enumerate(dl):
            now_h = time.localtime().tm_hour
            if now_h >= 6 and now_h < 18:
                log.info('outside window (06:00-18:00), pause for checkpoint safety '
                         'at global step %d', step)
                window_stopped = True
                done = True
                break
            input_ids, labels, attn = input_ids.cuda(), labels.cuda(), attn.cuda()
            out = model(input_ids=input_ids, attention_mask=attn, labels=labels)
            loss = out.loss / ACC
            loss.backward()
            losses.append(out.loss.item())
            if (i + 1) % ACC == 0:
                torch.nn.utils.clip_grad_norm_(
                    [p for p in model.parameters() if p.requires_grad], 1.0)
                opt.step()
                sched.step()
                opt.zero_grad(set_to_none=True)
                step += 1
                if step % 25 == 0:
                    log.info("step=%d/%d loss=%.4f lr=%.2e %.0fs elapsed",
                             step, args.start_step + total_steps,
                             sum(losses) / len(losses),
                             sched.get_last_lr()[0], time.time() - t0)
                    losses = []
                if step % 50 == 0 or step >= args.start_step + total_steps - 1:
                    # 数字排序裁剪, 只保留最近 2 个 (P0-2 修复: 字符串排序会删错文件)
                    ckpts = sorted(
                        [d for d in os.listdir(OUT_DIR)
                         if d.startswith("step") and os.path.isdir(os.path.join(OUT_DIR, d))],
                        key=lambda x: int(x[4:]), reverse=True)
# 滚动清理: 只保留最近 2 个 checkpoint, 防磁盘堆爆
                    for old in ckpts[2:]:
                        import shutil
                        shutil.rmtree(os.path.join(OUT_DIR, old), ignore_errors=True)
                        log.info("pruned old ckpt %s", old)
                    ck = os.path.join(OUT_DIR, f"step{step}")
                    os.makedirs(ck, exist_ok=True)
                    model.save_pretrained(ck)
                    tok.save_pretrained(ck)
                    log.info("checkpoint saved -> %s", ck)
                if step >= args.start_step + 500:  # 全局步安全上限
                    log.info("hit global step cap %d, stop", step)
                    done = True
                    break
        log.info("epoch %d done", ep)

    os.makedirs(OUT_DIR, exist_ok=True)
    model.save_pretrained(OUT_DIR)
    tok.save_pretrained(OUT_DIR)
    log.info("saved -> %s", OUT_DIR)
    if window_stopped:
        log.info("TRAIN_WINDOW_PAUSED")   # 窗口暂停, 非完成 (P2-14)
        try:
            sys.path.insert(0, r"C:\Users\Administrator\.config\opencode\examples")
            from send_email import send_email
            send_email(
                subject="【Small_ask_big】SFT窗口暂停(非完成)",
                body=f"到达窗口边界(06:00-18:00), 训练已在全局 step {step} 处保存并暂停。\n"
                     f"下次窗口将从 checkpoint 续训。\n日志: {LOG}",
                html=False)
        except Exception:
            log.exception("hook mail fail")
    else:
        log.info("TRAIN_DONE")
        try:
            sys.path.insert(0, r"C:\Users\Administrator\.config\opencode\examples")
            from send_email import send_email
            send_email(
                subject="【Small_ask_big】SFT训练完成",
                body=f"SFT 训练完成(全局 step {step})。\n最终保存: {OUT_DIR}\n"
                     f"耗时: {time.time()-t0:.0f} 秒\n日志: {LOG}",
                html=False)
        except Exception:
            log.exception("hook mail fail")


if __name__ == "__main__":
    try:
        main()
    except Exception:
        log.exception("TRAIN_FAIL")
        # 失败邮件略 (与窗口暂停分支同构)
代码学习要点
  • 锚点初始化:w[tid] = mean(句号/换行/EOS) + N(0, 0.1²),把求助按钮放在自然断点 token 家族附近,训练才稳定。
  • resume 互斥分支:if resume: PeftModel.from_pretrained(...) else: get_peft_model(...),避免双层 PEFT 包装(v1 的 P0-1 bug)。
  • checkpoint 裁剪:key=lambda x: int(x[4:]) 数字排序,否则字符串排序下 "step50" > "step150" 会删错文件。
  • 窗口停训:06:00–18:00 内自动保存并退出,TRAIN_WINDOW_PAUSED 与 TRAIN_DONE 分开,避免误报完成。
  • 已知待修:全局步上限 start_step + 500 应改为 start_step + total_steps,否则会多跑 26 步。

M3 Stage 3 成本感知 GRPO

scripts/grpo_train.py
【用途】 用组内优势 + 策略梯度优化「求助时机」。每题采样 G 条推理路径,若路径中出现 <|llm_offload|> 则真实调用本地 27B 执行单次交接。奖励 = 答对(0/1) − λ×(协作成本 / 纯大模型基线成本),其中小模型交接前每个 token 双重计费(自身生成 + 大模型 prefill 输入)。
【入参说明】
  • 输入数据:datasets/dapo_cache.json(DAPO-Math-17k 转换)
  • 基线缓存:datasets/grpo_baseline.json(每题 llm-only 成本,首次跑时预计算)
  • 大模型服务:http://127.0.0.1:8002/v1/chat/completions(llama-server 加载 Qwen3.8-27B)
  • 关键常量:LAM=0.1,G=4,MAX_NEW=600
  • 输出:ckpt/grpo_lora/ + trainer_state.pt(optim + iter)
# -*- coding: utf-8 -*-
"""PyroDash Stage 3: 成本感知 GRPO (评审修复版)

P0-6 修复: 真·策略梯度 - log_prob(gather 生成token) x 组内优势, 不再用加权CE
P0-4 修复: prompt 尾部 <think>\n 与 SFT/评估统一
P1-8 修复: base_cost 预计算 llm-only 基线(固定), 不再用组内最大值近似
P1-10 修复: checkpoint 保存 optimizer 状态, 支持断点续训
P2-16 修复: relay 并发 ThreadPoolExecutor
"""
import json
import os
import sys
import time
import traceback
from concurrent.futures import ThreadPoolExecutor

os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["CUDA_VISIBLE_DEVICES"] = "1"

import requests
import torch

BASE = r"D:\project\2026\202609\Small_ask_big"
ADPT = os.path.join(BASE, "ckpt", "sft_lora")
BASEM = os.path.join(BASE, "models", "Qwen3.5-4B")
DATA = os.path.join(BASE, "datasets", "dapo_cache.json")
BASELINE = os.path.join(BASE, "datasets", "grpo_baseline.json")
LOG = os.path.join(BASE, "logs", "grpo.log")
OUT = os.path.join(BASE, "ckpt", "grpo_lora")
LLM_URL = "http://127.0.0.1:8002/v1/chat/completions"
TAU = "<|llm_offload|>"
SYSTEM_PROMPT = (
    "You are a helpful assistant. Solve the given problem step by step. "
    "For very difficult steps, you can output <|llm_offload|> to request help "
    "from a more capable model."
)
LAM = 0.1
G = 4
MAX_NEW = 600
PRICE = {"slm": 0.08, "llm_in": 0.90, "llm_out": 2.86}  # $/1M

import logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s",
                    handlers=[logging.FileHandler(LOG, encoding="utf-8"), logging.StreamHandler()])
log = logging.getLogger("grpo")


# 从模型输出中抽取 \boxed{} 里的最终答案, 抽不到按 0 分处理
def extract_answer(text):
    t = str(text)
    if "\\boxed{" in t:
        return t.split("\\boxed{")[-1].split("}")[0].strip()
    return t.strip()[-40:]


def score_num(pred, gt):
    def norm(x):
        x = str(x).strip().strip(".").replace(",", "").replace("$", "").replace(" ", "")
        try:
            return float(x)
        except ValueError:
            return None
    a, b = norm(pred), norm(gt)
    if a is None or b is None:
        return int(str(pred).strip() == str(gt).strip())
    return int(abs(a - b) < 1e-6)


# 全大模型成本基线: 不 offload 直接问大模型的 token 计价, 作为对照
def llm_full_cost(q):
    """P1-8: 预计算 llm-only 基线成本 (纯大模型解该题的真实成本), 固定缓存。"""
    base = json.load(open(BASELINE, encoding="utf-8")) if os.path.exists(BASELINE) else {}
    if q in base:
        return base[q]
    body = {"model": "qwen3.8-27b",
            "messages": [{"role": "user", "content": q +
                          " Please reason step by step, and put your final answer within \\boxed{}."}],
            "max_tokens": MAX_NEW, "temperature": 0.1}
    r = requests.post(LLM_URL, json=body, timeout=600)
    u = r.json().get("usage", {})
    cost = (int(u.get("prompt_tokens", 300)) * PRICE["llm_in"] +
            int(u.get("completion_tokens", 600)) * PRICE["llm_out"]) / 1e6
    base[q] = cost
    json.dump(base, open(BASELINE, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
    return cost


# 交接: 小模型 partial 输出加 stop 标记, 由大模型接力完成剩余推理
def llm_relay(question, partial):
    """单次交接: 把 part_think 打包给 27B 续写。"""
    sys_p = ("You are a helpful assistant completing a task partially solved by a smaller "
             "model. Continue from where it stopped and give the final answer in \\boxed{}.")
    user_p = f"Problem:\n{question}\n\nPartial reasoning:\n{partial}\n\nContinue and finish."
    body = {"model": "qwen3.8-27b",
            "messages": [{"role": "system", "content": sys_p},
                         {"role": "user", "content": user_p}],
            "max_tokens": 600, "temperature": 0.1}
    r = requests.post(LLM_URL, json=body, timeout=600)
    d = r.json()
    content = d["choices"][0]["message"].get("content") or ""
    u = d.get("usage", {})
    return content, int(u.get("prompt_tokens", 0)), int(u.get("completion_tokens", 0))


# 并发 relay: 线程池 + 超时降级, 失败样本计 0 奖励不阻塞整批
def relay_concurrent(jobs, workers=4):
    """P2-16: relay 并发。jobs=[(idx, question, partial)] -> {idx: (cont,tin,tout)}"""
    res = {}
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futs = {pool.submit(llm_relay, q, p): i for i, q, p in jobs}
        for fut in futs:
            i = futs[fut]
            try:
                res[i] = fut.result()
            except Exception as e:
                log.warning("relay fail job %s: %s", i, str(e)[:60])
                res[i] = ("", 0, 0)
    return res


# 组采样: 同一题采 G 条路径, 组内互比产生相对优势(GRPO 核心, 无需 Critic)
def rollouts(model, tok, q, n=G):
    prompt = (f"<|im_start|>system\n{SYSTEM_PROMPT}<|im_end|>\n"
              f"<|im_start|>user\n{q} Please reason step by step, and put your final "
              f"answer within \\boxed{{}}.<|im_end|>\n<|im_start|>assistant\n<think>\n")
    prompt_ids = tok(prompt, return_tensors="pt", add_special_tokens=False)["input_ids"]
    paths = []
    for _ in range(n):
        ids = prompt_ids.to(model.device)
        with torch.no_grad():
            o = model.generate(**ids, max_new_tokens=MAX_NEW, do_sample=True,
                               temperature=0.9, top_p=0.95)
        gen_ids = o[0][ids["input_ids"].shape[1]:]
        text = tok.decode(gen_ids, skip_special_tokens=False)
        n_slm = len(gen_ids)
        cost = n_slm * PRICE["slm"] / 1e6
        handed = TAU in text
        paths.append({"text": text, "gen_ids": gen_ids.tolist(),
                      "n_slm": n_slm, "handed": handed, "cost": cost})
    # 并发 relay (P2-16)
    jobs = [(i, q, p["text"].split(TAU)[0]) for i, p in enumerate(paths) if p["handed"]]
    relays = relay_concurrent(jobs) if jobs else {}
    for i, p in enumerate(paths):
        if p["handed"] and i in relays:
            cont, tin, tout = relays[i]
            p["text"] = p["text"].split(TAU)[0] + cont
            p["cost"] += (tin * PRICE["llm_in"] + tout * PRICE["llm_out"]) / 1e6
    return paths


# 真·策略梯度: 仅对生成 token 求 log_prob 乘组内优势, 偏离参考模型由 KL 约束
def grpo_step_loss(model, tok, path, adv):
    """P0-6: 真·策略梯度 - 组内优势 x 生成token的log_prob均值(仅SLM生成部分)

    注意: v2 版本此处仍存在实现缺陷 —— 未把 prompt 上下文编码进前向,
    导致 log_prob 条件缺失, 需进一步修复 (见第四章末的待修清单)。
    """
    ids = tok(path["text"], return_tensors="pt", add_special_tokens=False)["input_ids"][0]
    n_gen = min(path["n_slm"], len(ids))
    if n_gen <= 0 or abs(adv) < 1e-6:
        return None
    gen = ids[-n_gen:].unsqueeze(0).to(model.device)          # 仅 SLM 生成的 token
    logits = model(input_ids=gen).logits
    log_probs = torch.log_softmax(logits[:, :-1].float(), dim=-1)
    tgt = gen[:, 1:]
    selected = log_probs.gather(-1, tgt.unsqueeze(-1)).squeeze(-1)   # [1, T-1]
    loss = -(adv * selected).sum() / (selected.shape[1] + 1e-6)
    return loss


# 主循环: 出题 -> 组采样 -> 判分 -> 优势归一 -> 策略梯度更新 -> 轨迹落盘
def main():
    t0 = time.time()
    from peft import PeftModel
    from transformers import AutoModelForCausalLM, AutoTokenizer

    tok = AutoTokenizer.from_pretrained(ADPT, trust_remote_code=True)
    model = AutoModelForCausalLM.from_pretrained(
        BASEM, torch_dtype=torch.bfloat16, trust_remote_code=True)
    model.resize_token_embeddings(len(tok))
    model = PeftModel.from_pretrained(model, ADPT, is_trainable=True).cuda()
    model.gradient_checkpointing_enable()
    model.enable_input_require_grads()
    model.train()
    log.info("model loaded %.0fs", time.time() - t0)

    rows = json.load(open(DATA, encoding="utf-8"))[:32]
    log.info("train rows: %d", len(rows))
    opt = torch.optim.AdamW([p for p in model.parameters() if p.requires_grad], lr=1e-6)

    # P1-10: 断点续训
    start_iter, resume_opt = 0, os.path.join(OUT, "trainer_state.pt")
    if os.path.exists(resume_opt):
        st = torch.load(resume_opt, map_location="cpu", weights_only=False)
        opt.load_state_dict(st["optim"])
        start_iter = st.get("iter", 0)
        log.info("resumed optimizer from iter %d", start_iter)

    for ri in range(start_iter, len(rows)):
        q, gt = rows[ri]["q"], rows[ri]["a"]
        base_cost = llm_full_cost(q)                       # P1-8: 固定 llm-only 基线
        paths = rollouts(model, tok, q, G)
        rewards = []
        for p in paths:
            acc = score_num(extract_answer(p["text"]), gt)
            rewards.append(acc - LAM * (p["cost"] / max(base_cost, 1e-9)))
        mu = sum(rewards) / len(rewards)
        var = (sum((x - mu) ** 2 for x in rewards) / len(rewards)) ** 0.5
        advs = [(x - mu) / (var + 1e-4) for x in rewards]

        opt.zero_grad(set_to_none=True)
        n_loss = 0
        for p, adv in zip(paths, advs):
            loss = grpo_step_loss(model, tok, p, adv)
            if loss is not None:
                (loss / len(paths)).backward()
                n_loss += 1
        if n_loss:
            torch.nn.utils.clip_grad_norm_(
                [p for p in model.parameters() if p.requires_grad], 1.0)
            opt.step()
        log.info("q%d/%d meanR=%.3f hands=%d/%d base=%.2e %s",
                 ri + 1, len(rows), mu, sum(1 for p in paths if p["handed"]), G,
                 base_cost, "; ".join(f"a={a:+.1f}" for a in advs))
        if (ri + 1) % 8 == 0:
            ck = os.path.join(OUT, f"iter{ri + 1}")
            os.makedirs(ck, exist_ok=True)
            model.save_pretrained(ck)
            tok.save_pretrained(ck)
            torch.save({"optim": opt.state_dict(), "iter": ri + 1},
                       os.path.join(OUT, "trainer_state.pt"))   # P1-10
            log.info("ckpt(+optim) -> %s", ck)

    os.makedirs(OUT, exist_ok=True)
    model.save_pretrained(OUT)
    tok.save_pretrained(OUT)
    torch.save({"optim": opt.state_dict(), "iter": len(rows)},
               os.path.join(OUT, "trainer_state.pt"))
    log.info("GRPO_DONE %.0fs", time.time() - t0)


if __name__ == "__main__":
    try:
        main()
    except Exception:
        log.exception("GRPO_FAIL")
代码学习要点(含已知缺陷)
  • 奖励函数:acc − λ × (cost / base_cost),其中 base_cost 预计算为纯大模型基线,缓存进 JSON 避免重复调用。
  • 组内优势:adv = (r − mean) / std,G 条路径互相对比,天然淘汰「磨叽半天才交接」的路径。
  • P0-6 待修:grpo_step_loss 未把 prompt 编码进前向,导致 log_prob 条件缺失;且交接路径下 ids[-n_gen:] 可能取到大模型续写的 token。正确写法需把 prompt+生成拼接后编码,只对 [prompt_len-1, prompt_len+n_slm-1) 区间求 log_prob。
  • REINFORCE 而非 PPO-clip:当前实现是单步 REINFORCE + 组内基线,没有 importance ratio 裁剪。需说明是「GRPO 简化版」。

M4 协同引擎 CE(协议权威实现)

src/collaborate_engine.py
【用途】 严格对齐官方协议的协同引擎:小模型流式生成,遇到 <|llm_offload|> 后剥离 token、把已有推理包进 <part_think>、单次调用大模型续写、拼接返回。被 M3 的 rollout、M5 的评估、M6 的行为扫描共同复用。
【入参说明】
  • slm_base_url / slm_model:小模型 OpenAI 兼容接口
  • llm_base_url / llm_model / llm_api_key:大模型接口
  • max_tokens:小模型 + 大模型共享的总预算(论文协议)
  • temperature:默认 0.1(评估)/ GRPO rollout 单独设 0.9
  • prices:论文计价 {slm_in, slm_out, llm_in, llm_out}
# -*- coding: utf-8 -*-
"""
PyroDash Collaborate Engine (CE) —— 对齐官方协议版

协议要点:
1. 小模型: /v1/completions, stop=[<|llm_offload|>], 含 stop 串回读
2. 系统提示(数学): SYSTEM_PROMPT(官方原文), user 尾加 PROMPT_SUFFIX
3. 交接: system=官方交接提示, user=原题, assistant=<part_think>剥离后轨迹</part_think>
4. LLM max_tokens = 总预算 - 小模型已完成 token
5. 拼接: <think> slm_prefix + llm_think </think> + llm_content
6. 成本: SLM/LLM 各 prompt/completion 四项分列, 论文计价
"""
import logging
from dataclasses import dataclass, field

from openai import OpenAI

log = logging.getLogger("CE")

OFFLOAD_TAG = "<|llm_offload|>"

SYSTEM_PROMPT = (
    "You are a helpful assistant. Solve the given problem step by step. "
    "For very difficult steps, you can output <|llm_offload|> to request help "
    "from a more capable model."
)
PROMPT_SUFFIX = (
    "\nPlease reason step by step, and put your final answer within \\boxed{}."
)
REMOTE_HANDOFF_PROMPT = (
    "You are a helpful assistant completing a task that was partially solved "
    "by a smaller local model before offload.\n"
    "Collaborative handoff protocol:\n"
    "- The assistant message may contain <part_think>...</part_think> with reasoning "
    "the small model already produced before offload.\n"
    "- Because part of the reasoning is already in <part_think>, continue from "
    "where it stopped: your reasoning channel should pick up at the first "
    "unresolved step and carry forward to the final answer. Do not repeat, "
    "paraphrase, or re-derive anything already present in <part_think>.\n"
    "- If <part_think> already concludes the task, proceed directly to the final answer.\n"
    "- Put the user-facing answer only in normal assistant content. Never quote "
    "or mention <part_think> to the user.\n"
    "- <part_think> is an internal marker, not user input."
)

# 论文计价 $/1M tokens
PAPER_PRICES = {"slm_in": 0.05, "slm_out": 0.08, "llm_in": 0.90, "llm_out": 2.86}


@dataclass
# 成本计价器: 分别累计小模型/大模型 token 用量, 对齐官方计价协议
class CostMeter:
    tokens: dict = field(default_factory=lambda: {
        "slm_in": 0, "slm_out": 0, "llm_in": 0, "llm_out": 0})
    prices: dict = field(default_factory=lambda: dict(PAPER_PRICES))

    def add(self, kind, n):
        self.tokens[kind] += int(n or 0)

    def cost(self):
        return sum(self.tokens[k] / 1e6 * self.prices[k] for k in self.tokens)

    def summary(self):
        return {**self.tokens, "cost_usd": round(self.cost(), 6)}


# 协同引擎: 小模型先答, 触发 offload 标记则交大模型接力, 全程计价
class CollaborateEngine:
    def __init__(self,
                 slm_base_url, slm_model,
                 llm_base_url, llm_model, llm_api_key="EMPTY",
                 max_tokens=8192, temperature=0.1,
                 prices=None):
        self.slm = OpenAI(base_url=slm_base_url, api_key="EMPTY")
        self.llm = OpenAI(base_url=llm_base_url, api_key=llm_api_key)
        self.slm_model = slm_model
        self.llm_model = llm_model
        self.max_tokens = max_tokens          # 论文: 小模型+大模型共享的总预算
        self.temperature = temperature
        self.prices = prices or dict(PAPER_PRICES)

    # ---------- 小模型: 官方 /v1/completions + stop 截断 ----------
# 小模型补全: stop=[<|llm_offload|>] 截断, 并把 stop 串回读拼接保证上下文连续
    def slm_generate(self, question, extra_system=None):
        """返回 (raw_text, usage)。raw_text 含 <|llm_offload|> 则触发了交接。"""
        sys_prompt = extra_system or SYSTEM_PROMPT
        prompt = (
            f"<|im_start|>system\n{sys_prompt}<|im_end|>\n"
            f"<|im_start|>user\n{question}{PROMPT_SUFFIX}<|im_end|>\n"
            f"<|im_start|>assistant\n<think>\n"
        )
        resp = self.slm.completions.create(
            model=self.slm_model,
            prompt=prompt,
            temperature=self.temperature,
            max_tokens=self.max_tokens,
            stop=[OFFLOAD_TAG],
        )
        ch = resp.choices[0]
        text = ch.text or ""
        # safety: 服务端省略 stop 串时补回 (官方同款 fallback)
        if OFFLOAD_TAG not in text and ch.finish_reason == "stop":
            sr = getattr(ch, "stop_reason", None) or ""
            if OFFLOAD_TAG in str(sr):
                text += OFFLOAD_TAG
        usage = resp.usage
        return text, {"prompt_tokens": usage.prompt_tokens,
                      "completion_tokens": usage.completion_tokens}

    # ---------- 大模型: 官方交接协议 ----------
# 大模型接力: 按 CE 协议把小模型已生成内容作为前缀续写
    def llm_relay(self, question, slm_raw):
        """单次交接。返回 (final_text, llm_usage)。"""
        # 剥离: tag 前缀(去掉 think 标签) -> <part_think>
        prefix = slm_raw.split(OFFLOAD_TAG)[0]
        prefix = prefix.replace("<think>", "").replace("</think>", "").strip()
        partial = f"<part_think>{prefix}</part_think>"

        messages = [
            {"role": "system", "content": REMOTE_HANDOFF_PROMPT},
            {"role": "user", "content": f"{question}{PROMPT_SUFFIX}"},
            {"role": "assistant", "content": partial},
        ]
        budget_left = max(0, self.max_tokens - getattr(self, "_slm_completion", 0))
        resp = self.llm.chat.completions.create(
            model=self.llm_model,
            messages=messages,
            max_tokens=budget_left,
            temperature=self.temperature,
            extra_body={"chat_template_kwargs": {"enable_thinking": True}},
        )
        msg = resp.choices[0].message
        think = getattr(msg, "reasoning_content", None) or getattr(msg, "reasoning", "") or ""
        content = msg.content or ""
        usage = resp.usage
        final = f"<think>{prefix}{think}</think>{content}"
        return final, {"prompt_tokens": usage.prompt_tokens,
                       "completion_tokens": usage.completion_tokens}

    # ---------- 主入口 ----------
# 推理主入口: 一次调用完成 小模型->判定->交接->计价 全流程
    def infer(self, question):
        meter = CostMeter(prices=self.prices)
        self._slm_completion = 0   # P1-9: reset per-infer
        raw, su = self.slm_generate(question)
        meter.add("slm_in", su["prompt_tokens"])
        meter.add("slm_out", su["completion_tokens"])
        handed = OFFLOAD_TAG in raw

        if handed:
            self._slm_completion = su["completion_tokens"]
            final, lu = self.llm_relay(question, raw)
            meter.add("llm_in", lu["prompt_tokens"])
            meter.add("llm_out", lu["completion_tokens"])
        else:
            final = raw

        return {
            "question": question,
            "raw_slm": raw,
            "output": final,
            "handed_off": handed,
            "usage": meter.summary(),
        }


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    import argparse
    ap = argparse.ArgumentParser()
    ap.add_argument("--slm-base-url", default="http://127.0.0.1:8001/v1")
    ap.add_argument("--slm-model", default="pyrodash-4b")
    ap.add_argument("--llm-base-url", default="http://127.0.0.1:8002/v1")
    ap.add_argument("--llm-model", default="qwen3.8-27b")
    ap.add_argument("--llm-api-key", default="EMPTY")
    ap.add_argument("--query", default="A lemon tree costs $90. Each year it "
                    "yields 7 lemons sold at $1.5 each, and watering/fertilizer "
                    "costs $3 per year. After how many years does it start "
                    "making a profit?")
    args = ap.parse_args()

    ce = CollaborateEngine(args.slm_base_url, args.slm_model,
                           args.llm_base_url, args.llm_model, args.llm_api_key)
    r = ce.infer(args.query)
    print("=== handed_off:", r["handed_off"])
    print("=== output (尾800字):\n", r["output"][-800:])
    print("=== usage:", r["usage"])
代码学习要点
  • 协议严格对齐:stop=[OFFLOAD_TAG] + finish_reason 回读,避免服务端省略 stop 串导致漏判。
  • part_think 拼接:大模型看到的是「原题 + 已推理」,被明确要求不重复已有步骤,直接续写。
  • 预算共享:budget_left = max_tokens − slm_completion,小模型用掉越多,大模型剩余越少,符合论文的「总预算」设定。
  • 待优化:手写 prompt 与 apply_chat_template(enable_thinking=True) 必须逐字符一致,否则会掉点,建议改为直接调用模板。

M5 GSM8K 千题评估管线

scripts/run_gsm8k_1k.py
【用途】 逐题落盘 + 断点续跑的评估主控。先跑小模型阶段(可并发),落盘 partial JSONL;再进入 relay 阶段,仅对触发 offload 的样本调用大模型续写;最后合并打分,输出 summary + 完整样本记录。
【入参说明】
  • --limit / --offset:题目范围(默认 0–1000)
  • --port:小模型服务端口(默认 8001)
  • --model-dir:用于取 tokenizer 的模型目录
  • --tag:结果文件标签(如 005)
  • --dataset:可选 gsm8k / amc / minerva / olympiad / aime2024 / aime2025
# -*- coding: utf-8 -*-
"""GSM8K 1000题评估 v2: 逐题落盘/断点续跑/单题超时降级."""
import json
import os
import sys
import threading
import time

EVAL_DIR = r"D:\project\2026\202609\Small_ask_big\PyroDash\evaluation\evaluation_math"
sys.path.insert(0, EVAL_DIR)
sys.path.insert(0, r"D:\project\2026\202609\Small_ask_big\scripts")
os.environ.setdefault("HF_HUB_OFFLINE", "1")

import logging
import boxed_socre  # noqa: E402  (模块级: score_one 依赖)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler(r"D:\project\2026\202609\Small_ask_big\logs\gsm8k_1k.log", encoding="utf-8"),
        logging.StreamHandler(),
    ],
)
log = logging.getLogger("gsm8k1k")

MODEL_DIR = r"D:\project\2026\202609\Small_ask_big\models\PyroDash-4B-GRPO-Lambda-0.6"
SMALL_BASE = "http://127.0.0.1:8001/v1"
SMALL_MODEL = "pyrodash-4b"
LLM_BASE = "http://127.0.0.1:8002/v1"
LLM_MODEL = "qwen3.8-27b"
N = 1000
PARTIAL = r"D:\project\2026\202609\Small_ask_big\results\gsm8k_1k_partial.jsonl"
RELAY = r"D:\project\2026\202609\Small_ask_big\results\gsm8k_1k_relay.jsonl"
OUT = r"D:\project\2026\202609\Small_ask_big\results\gsm8k_1k_results.json"
WORKERS = 12
LOCK = threading.Lock()


# 答案归一化: 剥离千分位逗号/单位/$$, 只比数值
def normalize_num(s):
    """'1,250'/'$18'/' 5 ' -> float or None"""
    t = str(s).strip().strip(".").replace(",", "").replace("$", "").replace(" ", "")
    try:
        return float(t)
    except ValueError:
        return None


# 单题判分: 抽取最终数值与标准答案比对, 命中记 1 分
def score_one(response, answer):
    """官方 math_verify 优先; 其在 Windows 下 multiprocessing 可能全判 False,
    故加 GSM8K 数字规范化 fallback (GT 均为纯数字, 覆盖充分)。"""
    try:
        if boxed_socre.compare_answer(response, answer):
            return 1, "verify"
    except Exception:
        pass
    pred = boxed_socre.extract_pred_boxed(response)
    gt = boxed_socre.extract_ground_truth_boxed(answer)
    if pred is not None and gt is not None:
        pn, gn = normalize_num(pred), normalize_num(gt)
        if pn is not None and gn is not None and pn == gn:
            return 1, "numeric_fallback"
    return 0, "wrong"


def slm_one(math_eval_mod, prompt):
    try:
        text, usage = math_eval_mod.call_small_completion(
            prompt, small_base_url=SMALL_BASE, small_model=SMALL_MODEL, max_tokens=8192)
        return text, usage, None
    except Exception as e:
        log.warning("slm timeout/err idx? %s", str(e)[:80])
        return "", {"prompt_tokens": 0, "completion_tokens": 0}, str(e)[:100]


# 评估管线: 逐题落盘 + 断点续跑(跳过已完成) + 线程池并发 + 单题超时降级
def main():
    import argparse
    global MODEL_DIR, SMALL_BASE, SMALL_MODEL, PARTIAL, RELAY, OUT
    ap = argparse.ArgumentParser()
    ap.add_argument("--limit", type=int, default=N, help="题目数量")
    ap.add_argument("--offset", type=int, default=0)
    ap.add_argument("--port", type=int, default=8001, help="小模型服务端口")
    ap.add_argument("--model-dir", default=MODEL_DIR)
    ap.add_argument("--alias", default="pyrodash-4b")
    ap.add_argument("--tag", default="", help="结果文件标签, 如 005")
    ap.add_argument("--dataset", default="gsm8k",
                    choices=["gsm8k", "amc", "minerva", "olympiad", "aime2024", "aime2025"])
    args = ap.parse_args()
    nq = args.limit
    MODEL_DIR = args.model_dir
    SMALL_BASE = f"http://127.0.0.1:{args.port}/v1"
    SMALL_MODEL = args.alias
    ds_tag = f"{args.dataset}_{args.tag or 'l06'}_p{args.port}"
    PARTIAL = PARTIAL.replace("gsm8k_1k", ds_tag)
    RELAY = RELAY.replace("gsm8k_1k", ds_tag)
    OUT = OUT.replace("gsm8k_1k", ds_tag)
    os.makedirs(os.path.dirname(PARTIAL), exist_ok=True)

    from transformers import AutoTokenizer
    from datasets import load_dataset
    import math_eval_mod
    import llm_relay

    tok = AutoTokenizer.from_pretrained(MODEL_DIR, trust_remote_code=True)
    assert "<|llm_offload|>" in tok.get_vocab()

    import datasets_loader
    handler = datasets_loader.get_dataset_handler(args.dataset)
    questions, answers = handler.load_data()
    end = min(args.offset + nq, len(questions))
    questions = questions[args.offset:end]
    answers = answers[args.offset:end]
    log.info("dataset=%s n=%d", args.dataset, len(questions))

    done = {}
    if os.path.exists(PARTIAL):
        with open(PARTIAL, encoding="utf-8") as f:
            for line in f:
                try:
                    rec = json.loads(line)
                    done[rec["idx"]] = rec
                except json.JSONDecodeError:
                    pass
    log.info("checkpoint loaded: %d done", len(done))

    todo = [i for i in range(len(questions)) if i not in done]
    log.info("todo: %d", len(todo))
    if todo:
        prompts = [math_eval_mod.format_prompt(tok, q) for q in questions]
        with open(PARTIAL, "a", encoding="utf-8") as pf:
            from concurrent.futures import ThreadPoolExecutor, as_completed

# 单题执行: 超时或异常返回降级结果, 不阻塞整体评估
            def run_one(i):
                text, usage, err = slm_one(math_eval_mod, prompts[i])
                rec = {"idx": i, "raw": text, "usage": usage, "err": err,
                       "offload": "<|llm_offload|>" in text}
                with LOCK:
                    pf.write(json.dumps(rec, ensure_ascii=False) + "\n")
                    pf.flush()
                return i, rec

            n = 0
            with ThreadPoolExecutor(max_workers=WORKERS) as pool:
                futs = [pool.submit(run_one, i) for i in todo]
                for fut in as_completed(futs):
                    i, rec = fut.result()
                    done[i] = rec
                    n += 1
                    if n % 50 == 0:
                        log.info("slm progress: %d/%d", len(done), len(questions))
        log.info("slm phase done: %d", len(done))

    # relay 阶段 (官方单题函数, 超时降级, 结果落盘断点续跑)
    relayed = {}
    if os.path.exists(RELAY):
        with open(RELAY, encoding="utf-8") as f:
            for line in f:
                try:
                    rec = json.loads(line)
                    relayed[rec["idx"]] = rec
                except json.JSONDecodeError:
                    pass
    pending = [i for i, rec in done.items()
               if rec["offload"] and i not in relayed]
    log.info("relay phase: %d samples (cached: %d)", len(pending), len(relayed))
    with open(RELAY, "a", encoding="utf-8") as rf:
        for k, i in enumerate(pending):
            rec = done[i]
            try:
                msgs = math_eval_mod.build_chat_messages(questions[i])
                full, usage = llm_relay._complete_offload_response(
                    rec["raw"], msgs, api_key="EMPTY", base_url=LLM_BASE,
                    model=LLM_MODEL, max_tokens=8192, enable_thinking=True)
                out_rec = {"idx": i, "completed": full, "llm_usage": usage}
            except Exception as e:
                log.warning("relay fail idx=%d: %s", i, str(e)[:80])
                out_rec = {"idx": i, "completed": rec["raw"],
                           "llm_usage": None, "relay_err": str(e)[:100]}
            with LOCK:
                rf.write(json.dumps(out_rec, ensure_ascii=False) + "\n")
                rf.flush()
            relayed[i] = out_rec
            if (k + 1) % 10 == 0:
                log.info("relay %d/%d", k + 1, len(pending))

    # 合并 relay 结果 + 打分 + summary 落盘 (略, 见完整源码)

M6 自训模型 offload 行为扫描

scripts/behavior_scan.py
【用途】 SFT 完成后验证「难题触发、easy 题不触发」的行为模式。用 transformers 引擎加载 LoRA 模型,在 AMC / Olympiad / AIME 难题集上扫描 offload 触发率,并打印前两次触发处的上下文。
【入参说明】
  • 输入缓存:datasets/cache_{amc, olympiad, aime}.json(首次运行自动从 HF 拉取)
  • 采样上限:amc 20 / olympiad 20 / aime 10
  • 生成参数:max_new_tokens=600,greedy(do_sample=False)
  • 输出:logs/behavior_scan.log,含各难度触发率与 SUMMARY JSON
# -*- coding: utf-8 -*-
"""自训模型 offload 行为验证: 难度梯度扫描 (AMC/Olympiad/AIME)
transformers 引擎, 统计各难度 offload 触发率。
"""
import json
import os
import time
import traceback

os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["CUDA_VISIBLE_DEVICES"] = "1"

import torch

LOG = r"D:\project\2026\202609\Small_ask_big\logs\behavior_scan.log"
BASE = r"D:\project\2026\202609\Small_ask_big"
ADPT = os.path.join(BASE, "ckpt", "sft_lora")
SYSTEM_PROMPT = (
    "You are a helpful assistant. Solve the given problem step by step. "
    "For very difficult steps, you can output <|llm_offload|> to request help "
    "from a more capable model."
)
TAU = "<|llm_offload|>"

PLAN = [
    ("amc", os.path.join(BASE, "datasets", "cache_amc.json"), 20),
    ("olympiad", os.path.join(BASE, "datasets", "cache_olympiad.json"), 20),
    ("aime", os.path.join(BASE, "datasets", "cache_aime.json"), 10),
]


# 结果缓存落盘: 随时中断不丢已完成题目
def dump_cache():
    """首次运行时从 HF 拉取 AMC / Olympiad / AIME 并缓存为本地 JSON。"""
    from datasets import load_dataset
    os.environ.pop("HF_HUB_OFFLINE", None)
    amc = load_dataset("zwhe99/amc23", split="test")
    json.dump([{"q": r["question"], "a": r["answer"]} for r in amc],
              open(os.path.join(BASE, "datasets", "cache_amc.json"), "w", encoding="utf-8"),
              ensure_ascii=False)
    oly = load_dataset("zwhe99/simplerl-OlympiadBench", split="test")
    json.dump([{"q": r["question"], "a": str(r["final_answer"])} for r in oly][:40],
              open(os.path.join(BASE, "datasets", "cache_olympiad.json"), "w", encoding="utf-8"),
              ensure_ascii=False)
    a24 = load_dataset("HuggingFaceH4/aime_2024", split="train")
    a25 = load_dataset("yentinglin/aime_2025", "default")["train"]
    rows = [{"q": r["problem"], "a": str(r["answer"])} for r in a24] + \
           [{"q": r["problem"], "a": str(r["answer"])} for r in a25]
    json.dump(rows, open(os.path.join(BASE, "datasets", "cache_aime.json"), "w", encoding="utf-8"),
              ensure_ascii=False)
    os.environ["HF_HUB_OFFLINE"] = "1"


# 难度梯度扫描: AMC/Olympiad/AIME 三档, 统计各难度下 offload 触发率
def main():
    out = []
    if not os.path.exists(os.path.join(BASE, "datasets", "cache_amc.json")):
        dump_cache()
    t0 = time.time()
    from peft import PeftModel
    from transformers import AutoModelForCausalLM, AutoTokenizer

    tok = AutoTokenizer.from_pretrained(ADPT, trust_remote_code=True)
    model = AutoModelForCausalLM.from_pretrained(
        os.path.join(BASE, "models", "Qwen3.5-4B"), torch_dtype=torch.bfloat16,
        trust_remote_code=True)
    model.resize_token_embeddings(len(tok))
    model = PeftModel.from_pretrained(model, ADPT).cuda().eval()
    out.append(f"[{time.time()-t0:.0f}s] loaded")

    results = {}
# 按计划表逐数据集扫描, 每题记录是否触发交接及交接位置
    for ds_name, cache, limit in PLAN:
        rows = load_cache(ds_name)[:limit]
        trig = 0
        for i, r in enumerate(rows):
            msgs = [{"role": "system", "content": SYSTEM_PROMPT},
                    {"role": "user", "content": r["q"] +
                     " Please reason step by step, and put your final answer within \\boxed{}."}]
            prompt = tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
            ids = tok(prompt, return_tensors="pt").to("cuda")
            try:
                with torch.no_grad():
                    o = model.generate(**ids, max_new_tokens=600, do_sample=False)
                text = tok.decode(o[0][ids["input_ids"].shape[1]:], skip_special_tokens=False)
                hit = TAU in text
                trig += 1 if hit else 0
                if hit and trig <= 2:
                    idx_ = text.find(TAU)
                    out.append(f"[{ds_name}#{i}] TRIGGERED ctx: ...{text[max(0,idx_-120):idx_+30]}...")
            except Exception as e:
                out.append(f"[{ds_name}#{i}] GEN_ERR {str(e)[:80]}")
        results[ds_name] = {"total": len(rows), "triggered": trig,
                            "rate": round(trig / max(len(rows), 1), 3)}
        out.append(f"== {ds_name}: {trig}/{len(rows)} ({results[ds_name]['rate']*100:.0f}%)")
        with open(LOG, "w", encoding="utf-8") as f:
            f.write("\n".join(out) + "\n")

    out.append("SUMMARY " + json.dumps(results))
    with open(LOG, "w", encoding="utf-8") as f:
        f.write("\n".join(out) + "\n")


if __name__ == "__main__":
    try:
        main()
    except Exception:
        with open(LOG, "a", encoding="utf-8") as f:
            f.write("ERR:\n" + traceback.format_exc() + "\n")

M7 评估工具:打分与 relay

scripts/math_eval_mod.py
【用途】 官方评估协议的封装:小模型调用用 llama.cpp 原生 /completion 端点(可精确区分 EOS 与 offload stop),relay 阶段调用官方 complete_offload_batch 做并发交接。被 M5 复用。
【入参说明】
  • --model-path:本地合并模型目录(取 tokenizer / chat template)
  • --small-base-url / --small-model:小模型服务
  • --llm-base-url / --llm-api-key / --llm-model:大模型接口
  • --max-tokens:小模型 + 大模型共享的总预算(默认 8192)
  • --datasets:可多选 math / gsm8k / minerva / olympiad / aime2024 / aime2025
"""Offload benchmark: local small vLLM serve -> LLM relay -> score. Thinking always on."""

from __future__ import annotations
import argparse
import json
import sys
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any

import requests
from tqdm import tqdm

EVAL_DIR = Path(__file__).resolve().parent
if str(EVAL_DIR) not in sys.path:
    sys.path.insert(0, str(EVAL_DIR))

import boxed_socre  # noqa: E402
import datasets_loader  # noqa: E402
from llm_relay import OFFLOAD_TAG, complete_offload_batch  # noqa: E402

SMALL_MAX_WORKERS = 12
TEMPERATURE = 0.1

OFFLOAD_TOKEN = OFFLOAD_TAG
SYSTEM_PROMPT = (
    "You are a helpful assistant. Solve the given problem step by step. "
    "For very difficult steps, you can output <|llm_offload|> to request help "
    "from a more capable model."
)
PROMPT_SUFFIX = (
    "\nPlease reason step by step, and put your final answer within \\boxed{}."
)


# 小模型补全封装: 记录输入/输出 token 数, 用于成本计价
def call_small_completion(
    prompt: str, *, small_base_url: str, small_model: str, max_tokens: int
) -> tuple[str, dict[str, int]]:
    """llama.cpp 原生 /completion 端点: 精确返回 stopped_word/stopping_word,
    可区分 EOS 自然结束与 offload stop 截断 (OpenAI /v1 端点二者混为 finish=stop)。"""
    base = small_base_url.rstrip("/")
    if base.endswith("/v1"):
        base = base[: -len("/v1")]
    url = f"{base}/completion"
    body = {
        "model": small_model,
        "prompt": prompt,
        "temperature": TEMPERATURE,
        "n_predict": max_tokens,
        "stop": [OFFLOAD_TOKEN],
    }
    resp = requests.post(url, json=body, timeout=1800.0)
    resp.raise_for_status()
    data = resp.json()
    text = str(data.get("content") or "")
    if data.get("stopped_word") and data.get("stopping_word") == OFFLOAD_TOKEN:
        # 官方语义 include_stop_str_in_output=True: 补回 stop 串
        text = text + OFFLOAD_TOKEN
    usage = {
        "prompt_tokens": int(data.get("tokens_evaluated") or 0),
        "completion_tokens": int(data.get("tokens_predicted") or 0),
        "total_tokens": int(data.get("tokens_evaluated") or 0) + int(data.get("tokens_predicted") or 0),
    }
    return text, usage


# 批量执行: 线程池并发, 收集每题 token 计价明细
def run_small_batch(
    questions: list[str],
    *,
    tokenizer,
    small_base_url: str,
    small_model: str,
    max_tokens: int,
) -> tuple[list[str], list[list[int]], list[dict[str, int]]]:
    """并发跑小模型阶段, 每完成一题向 progress 文件追加一行。"""
    prompts = [format_prompt(tokenizer, q) for q in questions]
    raw_responses = [""] * len(questions)
    usages: list[dict[str, int]] = [
        {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} for _ in questions
    ]
    completion_ids: list[list[int]] = [[] for _ in questions]

    def _run(idx: int) -> tuple[int, str, dict[str, int]]:
        text, usage = call_small_completion(
            prompts[idx],
            small_base_url=small_base_url,
            small_model=small_model,
            max_tokens=max_tokens,
        )
        try:
            with open(r"D:\project\2026\202609\Small_ask_big\logs\gsm8k_1k.progress", "a", encoding="utf-8") as pf:
                pf.write(f"{idx},{len(text)}\n")
        except OSError:
            pass
        return idx, text, usage

    workers = max(1, min(SMALL_MAX_WORKERS, len(questions)))
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = [pool.submit(_run, i) for i in range(len(questions))]
        for fut in tqdm(as_completed(futures), total=len(questions),
                        desc="[small] generate", unit="sample"):
            idx, text, usage = fut.result()
            raw_responses[idx] = text
            usages[idx] = usage
            completion_ids[idx] = [0] * int(usage.get("completion_tokens", 0))

    return raw_responses, completion_ids, usages


# 大模型接力批处理: 小模型 raw 输出统一交大模型完成并计价
def relay_with_llm(raw_responses, questions, *, api_key, completion_ids,
                   llm_base_url, llm_model, llm_max_workers, max_tokens):
    """对触发 offload 的样本并发调用大模型续写 (走官方 complete_offload_batch)。"""
    prompts = [build_chat_messages(q) for q in questions]
    n_off = sum(OFFLOAD_TOKEN in r for r in raw_responses)
    print(f"[llm] relay {n_off}/{len(raw_responses)} via {llm_model} @ {llm_base_url}")
    return complete_offload_batch(
        raw_responses, prompts, api_key=api_key, base_url=llm_base_url,
        model=llm_model, max_tokens=max_tokens, max_workers=llm_max_workers,
        completion_ids=completion_ids, enable_thinking=True,
    )

M8 训练巡检(watchdog)

scripts/watchdog_dog.py
【用途】 由计划任务每 10 分钟触发一次:检测 train_sft.py 进程是否存活、监控训练实际所在 GPU(卡1)的利用率。异常时发送告警邮件;无训练进程时静默退出。
【入参说明】
  • 无命令行参数,由 Windows 计划任务触发
  • 读取 logs/watchdog_state.json 累计「GPU 利用率连续为 0」的次数
  • 依赖 logs/train_sft.log 判断训练是否正常结束
  • 告警邮件依赖 send_email 模块(外部路径)
# -*- coding: utf-8 -*-
"""电子狗巡检: 训练进程存活 + GPU 占用监控, 异常发告警邮件
由 schtasks 每 10 分钟触发; 无训练进程时静默退出。
"""
import logging
import os
import subprocess
import sys
import psutil

BASE = r"D:\project\2026\202609\Small_ask_big"
LOG_DIR = os.path.join(BASE, "logs")
STATE = os.path.join(LOG_DIR, "watchdog_state.json")
os.makedirs(LOG_DIR, exist_ok=True)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.FileHandler(os.path.join(LOG_DIR, "watchdog.log"), encoding="utf-8")],
)
log = logging.getLogger("watchdog")

sys.path.insert(0, r"C:\Users\Administrator\.config\opencode\examples")
from send_email import send_email  # noqa: E402


# 告警邮件: 由 schtasks 每 10 分钟触发本脚本, 异常即发
def alert(subject, body):
    try:
        send_email(subject=f"【Small_ask_big】{subject}", body=body, html=False)
        log.info("alert sent: %s", subject)
    except Exception as e:
        log.error("alert fail: %s", e)


# 查找训练进程: 按 cmdline 特征匹配 python 训练脚本
def find_train():
    """遍历进程列表, 找 train_sft.py 对应的 python.exe"""
    for proc in psutil.process_iter(["pid", "name", "cmdline", "create_time"]):
        try:
            if (proc.info["name"] or "").lower() == "python.exe":
                cmd = " ".join(proc.info["cmdline"] or [])
                if "train_sft" in cmd and proc.info["pid"] != os.getpid():
                    return proc
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            pass
    return None


# GPU 采样: pynvml 读双卡利用率与显存
def gpu_util():
    """nvidia-smi 拿 (index, utilization.gpu, memory.used) 列表"""
    try:
        out = subprocess.run(
            ["nvidia-smi", "--query-gpu=index,utilization.gpu,memory.used",
             "--format=csv,noheader,nounits"],
            capture_output=True, text=True, timeout=30)
        vals = [line.split(",") for line in out.stdout.strip().splitlines() if line]
        return [(int(v[0]), int(v[1]), int(v[2])) for v in vals]
    except Exception:
        return []


# 巡检逻辑: 无训练进程静默退出; 进程消失或 GPU 长时空闲则告警
def main():
    train = find_train()
    if train is None:
        # 训练进程不存在: 若日志尾部非 TRAIN_DONE/异常已处理, 且在窗口内, 可能异常退出
        logf = os.path.join(LOG_DIR, "train_sft.log")
        tail = ""
        try:
            with open(logf, encoding="utf-8", errors="ignore") as f:
                tail = "".join(f.readlines()[-5:])
        except OSError:
            pass
        if "TRAIN_DONE" in tail:
            log.info("train finished normally, idle")
            return
        if "TRAIN_FAIL" in tail:
            log.info("train fail already logged")
            return
        alert("训练进程消失告警",
              "watchdog 检测到 train_sft 进程不存在, 且日志尾部未见 TRAIN_DONE/TRAIN_FAIL。\n"
              "日志尾部:\n" + tail)
        return

    # 进程在: 检查 GPU 占用
    gpus = gpu_util()
    log.info("train pid=%s gpu=%s", train.info["pid"], gpus)
    # P1-7: 训练在卡1 (train_sft.py CUDA_VISIBLE_DEVICES=1), 监控实际使用的卡
    util0 = next((u for idx, u, _ in gpus if idx == 1), None)
    state = {"zero_count": 0}
    try:
        import json
        with open(STATE, encoding="utf-8") as f:
            state = json.load(f)
    except Exception:
        pass
    if util0 == 0:
        state["zero_count"] = state.get("zero_count", 0) + 1
        if state["zero_count"] >= 3:
            alert("训练GPU占用异常告警",
                  f"连续 {state['zero_count']} 次巡检卡1利用率 0%,训练进程 pid={train.info['pid']} "
                  f"可能卡死。\nGPU 状态: {gpus}")
    else:
        state["zero_count"] = 0
    import json
    with open(STATE, "w", encoding="utf-8") as f:
        json.dump(state, f)


if __name__ == "__main__":
    try:
        main()
    except Exception:
        log.exception("watchdog error")

M9 窗口调度:自动续训与自动释放

scripts/daily_start.py scripts/autostop.py
【用途】 daily_start.py:每日 11:00 由计划任务触发,判断是否处于训练窗口(18:00–次日 06:00),若在窗口内且无训练进程,则找最新 checkpoint 自动 resume 启动 train_sft.py。
autostop.py:每日 22:50 由计划任务触发,强制杀掉 llama-server 与本项目 python 进程,释放 GPU/内存。
【入参说明】
  • daily_start.py:无参数;关键常量 TARGET=474
  • autostop.py:无参数;MARKERS 列表定义需要终止的进程 cmdline 关键字
  • 两个脚本均由 schtasks 定时触发对应 .bat
# ============ daily_start.py ============
# -*- coding: utf-8 -*-
# 每日 11:00 自动续训: 找最新 checkpoint -> resume 启动 train_sft.py (psutil 版)
import logging
import os
import subprocess
import psutil

BASE = r"D:\project\2026\202609\Small_ask_big"
CKPT = os.path.join(BASE, "ckpt", "sft_lora")
LOG_DIR = os.path.join(BASE, "logs")
os.makedirs(LOG_DIR, exist_ok=True)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.FileHandler(os.path.join(LOG_DIR, "dailystart.log"), encoding="utf-8"),
              logging.StreamHandler()],
)
log = logging.getLogger("dailystart")

TARGET = 474


# 找最新 checkpoint: 按修改时间取最新, 供续训 resume
def latest_ckpt():
    """扫描 ckpt/sft_lora/step*/, 按数字最大返回 (step_n, 路径)"""
    steps = []
    if os.path.isdir(CKPT):
        for name in os.listdir(CKPT):
            if name.startswith("step") and os.path.isdir(os.path.join(CKPT, name)):
                try:
                    n = int(name[4:])
                    if os.path.exists(os.path.join(CKPT, name, "adapter_config.json")):
                        steps.append((n, os.path.join(CKPT, name)))
                except ValueError:
                    pass
    return max(steps) if steps else None


# 训练在跑检测: 防止重复拉起第二个训练进程
def training_running():
    """返回 train_sft.py 进程 pid, 无则 None"""
    for proc in psutil.process_iter(["pid", "name", "cmdline"]):
        try:
            if (proc.info["name"] or "").lower() == "python.exe":
                cmd = " ".join(proc.info["cmdline"] or [])
                if "train_sft" in cmd and proc.info["pid"] != os.getpid():
                    return proc.info["pid"]
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            pass
    return None


# 每日 11:00 自动续训: 有 checkpoint 就 resume, 没有就全新启动
def main():
    log.info("=== dailystart begin ===")
    # 窗口判断: 18:00 - 次日 06:00
    from datetime import datetime
    now = datetime.now()
    in_window = now.hour >= 18 or now.hour < 6
    if not in_window:
        log.info("outside window (18:00-06:00), skip. now=%s", now.strftime("%H:%M"))
        return
    pid = training_running()
    if pid:
        log.info("training already running pid=%s, skip", pid)
        return
    # 根目录 adapter 存在 = SFT 主训练已完成(TRAIN_DONE 产物)
    root_adapter = os.path.join(CKPT, "adapter_config.json")
    if os.path.exists(root_adapter):
        log.info("root adapter exists (SFT main training done); "
                 "extra-epoch continuation requires manual launch, skip auto-start")
        return
    best = latest_ckpt()
    script = os.path.join(BASE, "scripts", "train_sft.py")
    py = r"C:\Program Files\Python311\python.exe"
    ck_args = [script]
    if best is None:
        log.info("no checkpoint, fresh start")
    else:
        ck_args = [script, "--resume", best[1], "--start-step", str(best[0])]
        log.info("resuming from %s (step %d)", best[1], best[0])
    subprocess.Popen([py] + ck_args, creationflags=subprocess.CREATE_NO_WINDOW)
    log.info("training launched")


if __name__ == "__main__":
    main()


# ============ autostop.py ============
# -*- coding: utf-8 -*-
# 每日 22:50 自动释放: 杀 llama-server 与本项目 python 进程 (psutil 版, 无 wmic 依赖)
import logging
import os
import psutil

LOG_DIR = r"D:\project\2026\202609\Small_ask_big\logs"
os.makedirs(LOG_DIR, exist_ok=True)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[logging.FileHandler(os.path.join(LOG_DIR, "autostop.log"), encoding="utf-8"),
              logging.StreamHandler()],
)
log = logging.getLogger("autostop")

MARKERS = ["train_sft.py", "run_gsm8k_1k.py", "download_base.py", "download_ms.py",
           "download_hf.py", "daily_start.py"]


# 到点自动停训: 写 STOP 文件让训练优雅保存退出
def main():
    log.info("=== autostop begin ===")
    me = os.getpid()
    n1 = n2 = 0
    for proc in psutil.process_iter(["pid", "name", "cmdline"]):
        try:
            name = (proc.info["name"] or "").lower()
            cmd = " ".join(proc.info["cmdline"] or [])
            if proc.info["pid"] == me:
                continue
            if name.startswith("llama-server"):
                proc.kill()
                n1 += 1
                log.info("killed llama-server pid=%d", proc.info["pid"])
            elif name == "python.exe" and any(m in cmd for m in MARKERS):
                proc.kill()
                n2 += 1
                log.info("killed project python pid=%d cmd=%s", proc.info["pid"], cmd[:80])
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            pass
    log.info("=== autostop end: llama-server=%d python=%d ===", n1, n2)


if __name__ == "__main__":
    main()

M10 GGUF 修补(token_type 修正)

scripts/patch_gguf.py
【用途】 修补 convert_hf_to_gguf.py 产出的 GGUF 文件:把 <|llm_offload|>(id=248077)的 token_type 从 CONTROL 改成 NORMAL,否则 llama.cpp 在 detokenize 时会跳过该 special token 的文本,导致 offload 标记不可见、stop 失效。
【入参说明】
  • argv[1]:标签(06 / 005,仅日志用途)
  • argv[2]:源 GGUF 路径
  • argv[3]:目标 GGUF 路径
  • 依赖 tools/llamacpp-src/gguf-py 的 GGUFReader / GGUFWriter
# -*- coding: utf-8 -*-
import sys
import numpy as np
sys.path.insert(0, r"D:\project\2026\202609\Small_ask_big\tools\llamacpp-src\gguf-py")
from gguf import GGUFReader, GGUFWriter
from gguf.constants import GGUFValueType

tag = sys.argv[1]
SRC = sys.argv[2]
DST = sys.argv[3]
DROP_KEYS = {"qwen35.nextn_predict_layers", "qwen35.attention.recurrent_layers"}
SCALAR_ADD = {
    GGUFValueType.STRING: "add_string", GGUFValueType.UINT8: "add_uint8",
    GGUFValueType.INT8: "add_int8", GGUFValueType.UINT16: "add_uint16",
    GGUFValueType.INT16: "add_int16", GGUFValueType.UINT32: "add_uint32",
    GGUFValueType.INT32: "add_int32", GGUFValueType.UINT64: "add_uint64",
    GGUFValueType.INT64: "add_int64", GGUFValueType.FLOAT32: "add_float32",
    GGUFValueType.FLOAT64: "add_float64", GGUFValueType.BOOL: "add_bool",
}
# GGUF 字节流按 utf-8 解码, 非法字节容错替换
def decode_bytes(raw): return bytes(raw).decode("utf-8", "replace")
reader = GGUFReader(SRC)
writer = GGUFWriter(DST, "qwen35")
patched = []
# 遍历 GGUF 元数据字段, 定位需要修补的 token_type 相关项
for name, field in reader.fields.items():
    if name in DROP_KEYS:
        patched.append(("DROP", name)); continue
    if name == "qwen35.block_count":
        writer.add_uint32(name, 32); patched.append(("block_count->32", name)); continue
    if name == "general.architecture":
        continue
    types = list(field.types); vt = GGUFValueType(types[0])
    if vt == GGUFValueType.ARRAY:
        it = GGUFValueType(types[1])
        if it == GGUFValueType.STRING:
            vals = [decode_bytes(field.parts[di]) for di in field.data]
        else:
            np_types = {GGUFValueType.UINT8: np.uint8, GGUFValueType.INT8: np.int8,
                GGUFValueType.UINT16: np.uint16, GGUFValueType.INT16: np.int16,
                GGUFValueType.UINT32: np.uint32, GGUFValueType.INT32: np.int32,
                GGUFValueType.UINT64: np.uint64, GGUFValueType.INT64: np.int64,
                GGUFValueType.FLOAT32: np.float32, GGUFValueType.FLOAT64: np.float64,
                GGUFValueType.BOOL: np.bool_}
            flat = np.concatenate([field.parts[di] for di in field.data])
            vals = flat.astype(np_types[it]).tolist()
        # 关键补丁: <|llm_offload|>(248077) token_type CONTROL->NORMAL
        # llama.cpp detokenize 跳过 special token 文本, 导致 offload 标记不可见/stop 失效
        if name == "tokenizer.ggml.token_type" and len(vals) > 248077:
            patched.append((f"token_type[248077]: {vals[248077]} -> 1(NORMAL)", name))
            vals[248077] = 1
        writer.add_array(name, vals)
    elif vt in SCALAR_ADD:
        v = field.parts[field.data[0]]
        val = decode_bytes(v) if vt == GGUFValueType.STRING else v[0]
        getattr(writer, SCALAR_ADD[vt])(name, val)
    else:
        patched.append(("SKIP", name))
n = 0
for t in reader.tensors:
    writer.add_tensor(t.name, t.data, raw_dtype=t.tensor_type); n += 1
writer.write_header_to_file(); writer.write_kv_data_to_file(); writer.write_tensors_to_file(); writer.close()
print("tensors:", n, "patched:", patched)
print("OK ->", DST)
已知硬编码(换模型需改)
  • qwen35.block_count 硬编码为 32,换层数的模型会静默出错。
  • token_type[248077] 硬编码 token id,换 tokenizer 需同步修改。
  • 建议改进:从 config.json 读层数,从 tokenizer 的 vocab 查 token id。

附:其他工具脚本

下载与转换工具(未在主流程图中单独标号)
  • download_hf.py / download_ms.py / download_base.py:分别从 HF / ModelScope 下载官方模型、数据集、GGUF。
  • download_dapo.py:DAPO-Math-17k → datasets/dapo_cache.json(GRPO 数据源)。
  • download_llama_src.py:多通道拉取 llama.cpp b10938 源码(转换脚本依赖)。
  • convert_4b_q8.py / convert_005_f16.py:HF → GGUF 转换(Q8_0 / F16)。
  • verify_v3.py / verify_4b_transformers.py:模型加载 + 单题生成的链路验证脚本。

五、核心超参配置表

5.1 Stage 1+2 SFT

参数取值说明
基座模型Qwen3.5-4Bbf16 精度
新增 token<|llm_offload|>(id=248077)词表扩容后需 resize_token_embeddings
锚点集合["." , "\n", "<|im_end|>", "<|endoftext|>"]嵌入均值 + N(0, 0.1²)
LoRA r / alpha / dropout16 / 32 / 0.05target: q,k,v,o,gate,up,down
Batch Size4单卡
梯度累积4有效 batch = 16
Max Length768prompt + target 总长
Learning Rate1e-4AdamW,weight_decay 0.01
LR SchedulerCosineAnnealingLRT_max = total_steps
Epochs1474 步左右
梯度裁剪1.0clip_grad_norm_
Checkpoint 保留最近 2 个数字排序裁剪
训练窗口18:00 – 次日 06:00窗口外自动暂停

5.2 Stage 3 GRPO

参数取值说明
训练数据datasets/dapo_cache.jsonDAPO-Math-17k 转换
组内采样数 G4论文为 8,本地简化
单路径最大生成600 tokensMAX_NEW
采样温度 / top_p0.9 / 0.95rollout 采样
成本惩罚系数 λ0.1可调至 0.6 / 0.05 复现双档
Learning Rate1e-6AdamW,仅 LoRA 参数
优势归一化组内均值/标准差adv = (r − μ) / (σ + 1e-4)
梯度裁剪1.0clip_grad_norm_
Checkpoint 间隔每 8 题保存 model + optim + iter
Relay 并发4ThreadPoolExecutor

5.3 协同引擎与计价

参数取值说明
小模型服务127.0.0.1:8001llama-server / transformers
大模型服务127.0.0.1:8002Qwen3.8-27B
总预算 max_tokens8192小模型 + 大模型共享
温度0.1评估协议固定
SLM 输入价$0.05 / 1M论文计价
SLM 输出价$0.08 / 1M双重计费用
LLM 输入价$0.90 / 1M论文计价
LLM 输出价$2.86 / 1M论文计价
交接次数上限1不回头

六、项目依赖清单

6.1 Python 依赖

包名版本用途
torch2.5.1+cu121训练与推理框架
transformers5.15.1模型加载、chat template、generate
peft0.20LoRA / PeftModel
datasets—HF 数据集加载
hugging huggingface_hub—模型与数据集下载
华杉的技术博客 · 苏ICP备2026063680号-1
首页