diff --git a/backend/api/server.py b/backend/api/server.py index d83520b..390a1e4 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -7537,6 +7537,30 @@ def _compute_kpis( if run_max is not None else None ) + # Fallback: max runDic 可能命中 R{n} retry 轮(cml 还在 running,lark_template + # 没产出),让 KPI 全部空白。这时回退到 state 里最新一个 `cml=complete` 的 + # 那一轮的 runDic — baseline / 上一轮成功 eval 的指标至少能显示出来。 + if metrics is None: + per_round = _resolve_run_dic_per_round(state_entries) + for entry in reversed(state_entries): + if entry.get('step') != 'cml' or entry.get('status') != 'complete': + continue + rid = entry.get('run_id') + if not isinstance(rid, str): + continue + fallback_run = per_round.get(rid.strip()) + if fallback_run is None or fallback_run == run_max: + continue + metrics = _read_workflow_metric_diff( + fallback_run, + agent_state=agent_state, + account_id=account_id, + session_id=session_id, + ) + if metrics is not None: + run_max = fallback_run + run_id_value = str(fallback_run) + break _enrich_metrics_from_eval_output(metrics, target_set) target_metric = _format_metric_value(metrics, 'target_set_pass_rate') overall_metric = _format_metric_value(metrics, 'overall_car_pass_rate') diff --git a/frontend/app/components/training/step-detail-sheet.tsx b/frontend/app/components/training/step-detail-sheet.tsx index 701cff4..81f8795 100644 --- a/frontend/app/components/training/step-detail-sheet.tsx +++ b/frontend/app/components/training/step-detail-sheet.tsx @@ -535,7 +535,7 @@ type IterationEntry = { iteration?: number; runDic?: number; timestamp?: string; - hypothesis?: string; + hypothesis?: unknown; intervention?: { type?: string; summary?: string } | string; prediction?: Record; results?: Record; @@ -546,7 +546,7 @@ type IterationEntry = { cases?: number; }>; error_delta?: { persistent?: number; new?: number; fixed?: number }; - next_hypothesis?: string; + next_hypothesis?: unknown; [key: string]: unknown; }; @@ -648,7 +648,7 @@ function IterationCard({
{entry.hypothesis ? ( - {entry.hypothesis} + ) : null} {intervention.summary || intervention.type ? ( @@ -737,7 +737,7 @@ function IterationCard({ ) : null} {entry.next_hypothesis ? ( - {entry.next_hypothesis} + ) : null} {otherKeys.length > 0 ? ( @@ -759,6 +759,18 @@ function IterationCard({ ); } +function RichText({ value }: { value: unknown }) { + if (value == null) return null; + if (typeof value === "string" || typeof value === "number") { + return <>{String(value)}; + } + return ( +
+			{JSON.stringify(value, null, 2)}
+		
+ ); +} + function Field({ label, children, diff --git a/skills/model-iteration/SKILL.md b/skills/model-iteration/SKILL.md index 5ae9489..0cd364b 100644 --- a/skills/model-iteration/SKILL.md +++ b/skills/model-iteration/SKILL.md @@ -1,13 +1,8 @@ --- name: model-iteration -description: 小爱中控模型自主迭代框架(autoresearch-zk)。用于对小爱同学中控理解调度模型进行假设驱动的自主 SFT+评测迭代循环。触发信号:用户发送"开始,需求集合名"(如"开始,icl_test")时,必须立即使用此 skill 启动迭代,不得自行发挥。任何涉及 cml 评测、zk 模型训练、数据增强、badcase 分析、augment_*.jsonl 生成的任务,也应使用此 skill。 +description: 小爱中控模型自主迭代框架(autoresearch-zk)。用于对小爱同学中控理解调度模型进行假设驱动的自主 SFT+评测迭代循环。 when_to_use: | - 用户表达以下任一意图时启用此 skill: - - 直接的核心触发短语:「开始,<需求集合名>」(如「开始,icl_test」) - - 自然语言的训练/迭代请求:「我要进行模型训练」「开始模型训练」「目标集合是 xxx」「跑一下需求集合 xxx」「迭代 xxx」「针对 xxx 做模型迭代」 - - 平台运维:「评测当前模型」「分析 badcase」「Gold drift 检查」「数据增强 augment_*.jsonl」「提交 SFT 训练」「跑 CML workflow」「写 iteration_log」 - - 关键词:CML 评测 / SFT / autoresearch / zk 中控理解调度 / requirement set / specific test / metric_diff / lark_template - 无需用户严格按「开始,xxx」字面匹配;只要意图涉及小爱中控模型 SFT+评测迭代,就立刻使用此 skill。 + 仅在用户通过 UI 明确点击使用 model-iteration skill 时触发。不要从自然语言推断意图自动启用。 --- # autoresearch-zk — 小爱中控模型自主迭代框架 @@ -25,6 +20,9 @@ when_to_use: | | "我把现状停在这里,把决策摘要给你 review" | 不准。摘要可以写,但不准停 | | "Step 4 augment 完成 ✅,等回调后续做 SFT" | augment=complete 那一刻就是 SFT 启动那一刻——**同一轮 bash** 紧接着跑 §4.5 label-master 复核 + 提交 SFT,不许写简报、不许等回调(详见 program.md "Step 4 → Step 5 硬连接")| | "R1 评测发现 regression,先把诊断给你看,等你拍板再决定要不要回滚" | dist-analysis 发现 regression **也算 dist-analysis 完成**——**同轮 bash** 紧接着写 `results/workflow.md`(包含完整 delta 表 + new_fail/new_fix 子集 + 病灶定位 + 回滚建议)+ append `iteration_log.jsonl` R{n} entry。文件落完了再用 chat reply 给人提回滚选项。**不许把诊断只写在 chat 里、不落盘**——前端「分层结果分析」卡片读的是 `results/workflow.md`,你不写卡片永远停在上轮。 | +| "SFT 用哪个 basemodel?(a) 用户 eval 给的路径 (b) 默认 Qwen3-4B" | program.md §5.0 写死 `--model_path = /mnt/wangsenhao/verl_zk/Qwen3-4B-Instruct-2507`,**每轮强制 basemodel**——这是规则不是选项。eval 阶段的 `model_path_new` 跟 SFT 的 `--model_path` 是两件事,不要混。直接用 Qwen3-4B-Instruct-2507。 | +| "zk_trainer 仓库 URL 是什么?" | program.md §5.0 line 1323 写死 `git clone git@git.n.xiaomi.com:wangsenhao/zk_trainer.git`,**直接用**。clone 失败先看 ssh key(program.md 「SSH Key 检查」),**不要**自己脑补 `nlp/`、`xiaoai/`、`autoresearch/` 等命名空间问用户。 | +| "label-master 复核 100 条仿写,5 条全 pass,按比例外推 100 条 OK" | **`X/X pass` 缩写禁用**——§4.5 写死「H2 augment_.jsonl 必须逐条过 label-master」,review jsonl 行数必须 == augment.jsonl 行数(1:1 覆盖)。抽样外推 = 协议违反,§5.0 准入门会用 `wc -l` 拦住。状态日志里写"100 条复核完成"也必须真的是 100 条 review entry。 | **合法暂停只有**: @@ -65,6 +63,7 @@ when_to_use: | - [ ] 写本轮假设到 iteration_log.jsonl 的 hypothesis 字段 - [ ] 假设必须有依据(指向 §2.3 / §2.4 的具体发现) - [ ] **`step:"hypothesis"` entry 写在当前 round(R{n})名下**——不要写成 R{n+1}。后端把 hypothesis 归类为 analysis 类,是 R{n}·Baseline / R{n}·Analysis 的最后一张卡,不是 R{n+1}·Train 的开头。这样 gate(Human Check / Review)会插在 hypothesis 卡之后、R{n+1}·Train 之前,**用户看到假设内容再拍板是否进 train**。 +- [ ] **`hypothesis=complete` 之前,必须 append `iteration_log.jsonl` 一条 R{n} entry**,schema 至少包含 `iteration:n / runDic:<本轮编号> / timestamp / results:{<指标 key>:...} / hypothesis`。**前端 metrics 折线图唯一数据源就是这个文件**,没写 → 图表彻底空白(state file 写得再全也不行)。已踩坑:R0 走完 dist-analysis + hypothesis 但漏了 iteration_log,前端 metrics 卡看不到任何点,指标空了一整轮。`results` 必须用 program.md 「§iteration_log schema」里固定的 metric key(`req_set_car / dapan_car / specific_test / triage_err_rate / bvt_nav / talkable_controllable` 等),key 拼错前端按缺失处理。 ### Step 1 → Step 3 边界(**NEVER STOP 硬连接**,R0 / R1+ regression 都适用) - [ ] dist-analysis 算完 delta(new_fail / new_fix / persistent / 子集分布)那一刻起,**同一轮 bash 不许结束**:紧接着写 `results/workflow.md`(包含完整指标表 + 病灶定位 + 假设 + 回滚/继续建议)→ append `iteration_log.jsonl` R{n} entry(`results` 字段填本轮 metric,`hypothesis` 字段填下一动作) @@ -80,10 +79,10 @@ when_to_use: | - [ ] §4.0.1 Step B:量级判定基于 label-master 处理后的**残留候选量**走对应支线(≤50 自动按 label-master 推荐改 / 51-200 全量人审 / >200 触发 #3,全量交付) - [ ] §4.0.1 Step C:备份 `.bak` 文件 - [ ] §4.1 输入准备 / §4.2 GPT 调用 / §4.3 sanity check / §4.4 写入 -- [ ] **§4.5 label-master 标签复核(落盘后必做,强制)**:H1 `modified_samples.jsonl` 只跑层 1(格式)—— 语义已在 §4.0.1 Step A.5 完成;H2 `augment_.jsonl` 跑两层(`validate_label_output.py` 格式 + Skill 调用 `label-master` 语义),verdict 写 `results/data_clean_/label_master_review.jsonl`,不通过比例 ≤ 5% 才能进 Step 5 +- [ ] **§4.5 label-master 标签复核(落盘后必做,强制)**:H1 `modified_samples.jsonl` 只跑层 1(格式)—— 语义已在 §4.0.1 Step A.5 完成;H2 `augment_.jsonl` 跑两层(`validate_label_output.py` 格式 + Skill 调用 `label-master` 语义),**逐条 1:1 全量覆盖**(`augment` 多少行 `label_master_review.jsonl` H2 部分就多少行,禁止抽样 / spot-check / X/X pass 外推),verdict 写 `results/data_clean_/label_master_review.jsonl`,不通过比例 ≤ 5% 才能进 Step 5 ### Step 4 → Step 5 边界(**NEVER STOP 硬连接**,反复踩坑) -- [ ] 写完 `augment=complete` 那一刻,**同一轮 bash 不许结束**:紧接着跑 §4.5 label-master 复核 → 写 `sft=running` → 调 `submit_sft_via_cml.sh` → 挂 watcher +- [ ] 写完 `augment=complete` 那一刻,**同一轮 bash 不许结束**:紧接着跑 §4.5 label-master 复核 → 写 `sft=running` → 调 `submit_sft.sh` → 挂 watcher(评测在 SFT `_SUCCESS` 落盘后的下一轮单独用 `submit_cml_eval.sh` 起,不要在 SFT bg 里串接评测) - [ ] **不许写"Step 4 完成"进度简报后 turn 结束**,不许"等回调后续做 SFT" - [ ] H2 后台任务回调(`[system] 后台任务 ... exit_code=0`)**不是 turn 结束信号**——它只是 augment 子流程的一个中间节拍,agent 必须在同轮里继续走完 §4.5 → Step 5 @@ -163,7 +162,7 @@ bg 任务(`bash(run_in_background=true)`)的合法范围是**一个 step 内 **为什么是死规则**:bg 脚本本身不会 append `program-state.jsonl`,状态只活在 bg 进程内存。一个 bg 跨 step 跑起来后,前端从第一个 step complete 之后就完全感知不到后续 step——卡片永远停在第一个 step,watcher 也没注册新的。**bg 进程死了 / session 重连 / 中途想插手** 都补不回来,因为 program-state.jsonl 上没任何线索。 -**自查判据**:bg 脚本里出现新 step 的关键词(`cml workflow run` 提评测、`submit_sft_via_cml.sh`、对应新 step 的 `echo running`),就是越界。bg 只该做"提一个远端 job 然后等它的产物",**绝不能跨 step 边界**。 +**自查判据**:bg 脚本里出现新 step 的关键词(`cml workflow run` 提评测、`submit_cml_eval.sh`、对应新 step 的 `echo running`),就是越界。bg 只该做"提一个远端 job 然后等它的产物",**绝不能跨 step 边界**。 **watcher fire → 当轮 4 件套**(漏一件就断片): 1. echo 上一 step `complete`(watcher 已自动写也算) @@ -389,7 +388,8 @@ echo '{"log":{"ts":"11:55:10","iter":"R1","text":"Step 1 dist-analysis: 写 work |---|---|---| | `prepare_and_train_sft.py` | `$AUTORESEARCH_CHAT_ROOT/scripts/` | 后端 bind 时从 skill bundle 推过来,每次刷新最新版 | | `ai-planning/`(含 corpus + augment) | `$AUTORESEARCH_CHAT_ROOT/ai-planning/` | **首次访问前你自己 git clone**(见 program.md 「ai-planning bootstrap」) | -| `zk_trainer/` | `$AUTORESEARCH_CHAT_ROOT/zk_trainer/` | **Step 5 首次 SFT 前你自己 git clone**(见 program.md §5.0) | +| `zk_trainer/` | `$AUTORESEARCH_CHAT_ROOT/zk_trainer/` | **Step 5 首次 SFT 前你自己 git clone**:`git clone git@git.n.xiaomi.com:wangsenhao/zk_trainer.git`(见 program.md §5.0 line 1323)。**URL 写死了,不要问用户也不要换命名空间**;clone 失败先看 ssh key | +| basemodel(每轮 SFT 的 `--model_path`) | `/mnt/wangsenhao/verl_zk/Qwen3-4B-Instruct-2507` | **写死、强制**(见 program.md §5.0 line 1330 / 1345)。每轮 SFT 都从 basemodel 起,**不许从 sft_output 续训**,**不要问用户用哪个**——eval 阶段的 `model_path_new` 是另一回事 | | `results/iteration_log.jsonl` | `$AUTORESEARCH_CHAT_ROOT/results/` | 每 chat 独立累积;后端 KPI 也读这里 | | `results/error_registry.jsonl` | 同上 | 每 chat 独立 | | `results/workflow.md / data_clean_/ / augment_raw/` | 同上 | 每 chat 独立 | @@ -454,7 +454,7 @@ LOOP: Step 1: 分层结果分析 + 问题分析 & 报告(需求集合 → 大盘车载 → specific test;根因归类 / 跨轮 diff / 天花板诊断 / 写 workflow.md) Step 3: 形成假设(写入 iteration_log.jsonl) Step 4: 数据生成(仅 data 归因时做) - Step 5: SFT 训练(submit_sft_via_cml.sh) + Step 5: SFT 训练(submit_sft.sh)→ 训练成功后下一轮评测用 submit_cml_eval.sh Step 6: 记录结果(iteration_log.jsonl / error_registry.jsonl) → 回 Step 0 ``` diff --git a/skills/model-iteration/references/program.md b/skills/model-iteration/references/program.md index c79f97e..476cb9d 100644 --- a/skills/model-iteration/references/program.md +++ b/skills/model-iteration/references/program.md @@ -1262,7 +1262,9 @@ python skills/label-master/scripts/validate_label_output.py \ H1 在 §4.0.1 Step A.5 已经过 label-master 推荐覆盖,这里**不再重跑**。H2 仿写是 §4.2 GPT 新增的样本,没经过 Step A.5,必须在此补一次语义判定: -1. 从 `augment_.jsonl` 抽出 `(query, output)` 对 +🚨 **覆盖率硬规则**:`label_master_review.jsonl` 里 H2 部分的行数**必须等于** `augment_.jsonl` 的行数(1:1 全量覆盖)。**禁止抽样、禁止"前 N 条 spot-check"、禁止"5/5 pass 推 100 OK"**——§5.0 准入门会用 `wc -l` 拦。已踩坑:R17817 跑了 5 条样本就上报"100 条 pass",被 §5.0 退回。 + +1. 逐行读 `augment_.jsonl` **全部** `(query, output)` 对(N 条就是 N 条,不省略、不抽样) 2. **逐条**调 `Skill(skill="label-master", args=...)`——每次只传**一条** `(query, 当前 label)`: - 让 label-master 按 §决策流程 / §候选召回索引 / §高频混淆边界 判定该条 - 输出格式:`{verdict: 通过 | 不通过, 推荐标签, 排除理由, 易混淆边界}` @@ -1280,7 +1282,7 @@ H1 在 §4.0.1 Step A.5 已经过 label-master 推荐覆盖,这里**不再重 **写完复核报告才允许进 Step 5。**verdict 文件不存在 / 没跑层 2 → Step 5 拒绝启动(5.0 的准入检查会查 `label_master_review.jsonl` 存在)。 -🚨 **注意**:label-master 是"知识 + Agent 推理"型 Skill,不是黑盒分类器。批量调用时严禁要求它返回 1/0 标量;必须输出 `verdict + 推荐标签 + 排除理由` 三件套,便于人类回查。 +🚨 **注意**:label-master 是"知识 + Agent 推理"型 Skill,不是黑盒分类器。每一次调用都必须输出 `verdict + 推荐标签 + 排除理由` 三件套,便于人类回查;严禁要求它返回 1/0 标量。(批量调用本身已在第 2 步禁掉,这里再强调一次输出 schema。) ### 5. SFT 训练 @@ -1290,7 +1292,7 @@ H1 在 §4.0.1 Step A.5 已经过 label-master 推荐覆盖,这里**不再重 1. `echo '{"step":"sft","status":"running",...}' >> $SESSION_OUTPUT/program-state.jsonl` 2. 跑层 1 + 层 2 label-master 复核(§4.5) -3. 如果复核全过:在 SAME bash turn 里调 `bash scripts/submit_sft_via_cml.sh ` 并立刻挂 watcher(§5.2 的 cml custom_train + workflow chain) +3. 如果复核全过:在 SAME bash turn 里调 `bash scripts/submit_sft.sh [PREV_RUNDIC]` 并立刻挂 watcher(§5.2)。训练 `_SUCCESS` 落盘后的下一轮 step 单独用 `bash scripts/submit_cml_eval.sh ` 起评测,**不要**把评测串到 SFT bg 任务里——跨 step bg 是禁区 **反模式**(被反复踩坑,永远禁止): @@ -1318,7 +1320,20 @@ if [ "$TOTAL" -gt 0 ] && [ $((NOT_PASS * 100 / TOTAL)) -gt 5 ]; then exit 1 fi -# 2. zk_trainer 仓库已 clone +# 1b. coverage 断言:augment 行数必须被 review 完整覆盖(杜绝抽样外推) +# review.jsonl 同时包含 §4.0.1 Step A.5 的 mislabel candidate review + §4.5 的 augment 仿写 review +# 所以行数下界 = augment.jsonl 行数(mislabel 部分多出来的那批不影响下界) +AUG="$AUTORESEARCH_CHAT_ROOT/ai-planning/data/train_set/zk_intent/augment_${RUNDIC}.jsonl" +if [ -f "$AUG" ]; then + AUG_N=$(wc -l < "$AUG") + REV_N=$(wc -l < "$REVIEW") + if [ "$REV_N" -lt "$AUG_N" ]; then + echo "label-master review 行数 $REV_N < augment 条数 $AUG_N — §4.5 必须逐条覆盖,禁止抽样外推(如 5/5 pass 推 100 OK),回 §4.5 把 augment.jsonl 的每一条都 append 一行 review entry" + exit 1 + fi +fi + +# 2. zk_trainer 仓库已 clone(URL 写死,不要换命名空间,clone 失败先看 ssh key) cd "$AUTORESEARCH_CHAT_ROOT" [ -d zk_trainer ] || git clone git@git.n.xiaomi.com:wangsenhao/zk_trainer.git ``` @@ -1373,11 +1388,11 @@ R23-R28 期间累积了 `sft_output_r23` ~ `sft_output_r28`,多次需要回退 **自动评测 watcher**: -R29 起 SFT 走 cml custom_train submit(见 5.2),watcher 直接复用 `submit_sft_via_cml.sh` 内嵌的轮询逻辑——`cml custom_train describe` 检测 succeed → 验产出 → 自动起 `cml workflow run` 评测。本地无 PID 可监控。 +R29 起 SFT 走 cml custom_train submit(见 5.2),训练用 `submit_sft.sh` 提交、评测用 `submit_cml_eval.sh` 单独起。这两步**不再串接**——agent 用 watcher 监听 `sft_output/_SUCCESS`,watcher fire 后下一轮主动调 `submit_cml_eval.sh`。本地无 PID 可监控,全靠 watcher 串两个 step。 ##### Claude-side 双 watcher 强制要求(R17775 经验沉淀) -`submit_sft_via_cml.sh` 内嵌的 bash watcher **只负责"训练完 → 起评测"**,它不会通知 Claude。Claude 如果只挂一个"等评测产物"的 watcher,训练完成事件会被漏报(R17775 训练 19:57 完成,Claude 40+min 不知道,直到用户问)。 +旧版 `submit_sft_via_cml.sh` 在脚本内嵌 watcher 自动起评测——已废弃(违反"bg 任务作用域 = 单 step"原则)。现在 `submit_sft.sh` 只负责提 SFT、立刻退出;起评测必须由 agent 在 SFT watcher fire 后**新一轮**手动调 `submit_cml_eval.sh`。Claude 如果只挂一个"等评测产物"的 watcher,训练完成事件会被漏报(R17775 训练 19:57 完成,Claude 40+min 不知道,直到用户问)。 **Claude 调用 submit 脚本后,必须立刻用 `Bash run_in_background` 起两个 watcher task**(一个都不能少): @@ -1476,26 +1491,33 @@ R23-R28 期间用本地 `nohup python3 prepare_and_train_sft.py train ...` 启 ##### 一键提交脚本 -⚠️ **runDic 不许手敲**:先 `eval` 一下 `resolve_run_ids.sh` 拿到这一轮的 `$SFT_RUNDIC` / `$EVAL_RUNDIC`,再原样传给 `submit_sft_via_cml.sh`。R2 踩过的坑就是手算 runDic 时把 SFT yaml 的命名也跟着 +1,导致 `data_clean_/` 跳过了 R1 命名空间。 +⚠️ **runDic 不许手敲**:先 `eval` 一下 `resolve_run_ids.sh` 拿到这一轮的 `$SFT_RUNDIC` / `$EVAL_RUNDIC`,再原样传给两个独立脚本。R2 踩过的坑就是手算 runDic 时把 SFT yaml 的命名也跟着 +1,导致 `data_clean_/` 跳过了 R1 命名空间。 + +提交流程拆成两步(**两个独立脚本,两个 step**): ```bash cd "$AUTORESEARCH_CHAT_ROOT" eval "$(./scripts/resolve_run_ids.sh)" # 解析出 $SFT_RUNDIC / $EVAL_RUNDIC -./scripts/submit_sft_via_cml.sh "$SFT_RUNDIC" "$EVAL_RUNDIC" -# 等价示例(仅供阅读,**实际调用一律走 eval 上面那行**): -# R29 上一轮已落盘的 workflow 是 17755,因此 SFT_RUNDIC=17755, EVAL_RUNDIC=17756 -# ./scripts/submit_sft_via_cml.sh 17755 17756 +# Step 5:提交 SFT 训练(不附带任何评测逻辑) +./scripts/submit_sft.sh "$SFT_RUNDIC" + +# 训练 _SUCCESS 落盘后的下一轮 step 才起评测: +./scripts/submit_cml_eval.sh "$EVAL_RUNDIC" ``` -`submit_sft_via_cml.sh` 接受三个位置参数:` [PREV_RUNDIC]`。`PREV_RUNDIC` 默认 `SFT_RUNDIC-1`,仅用作上一轮 `sft_output_r` 改名。脚本内部断言 `EVAL_RUNDIC > SFT_RUNDIC`,否则直接报错退出。 +`submit_sft.sh [PREV_RUNDIC]`: -脚本做的事: 1. 渲染 `scripts/sft_train_job.yaml.tpl` 为本轮 yaml(替换 `{RUNDIC}` → `$SFT_RUNDIC`、`{PREV_RUNDIC}` → `$PREV_RUNDIC`) -2. `cml custom_train submit --filename ` 提交任务,拿 `JobID` -3. 后台 watcher 进程:每 60s `cml custom_train describe` 轮询任务状态 -4. 任务 succeed → 验证 `sft_output/_SUCCESS` 存在 → 自动起 cml workflow run,**评测的 `runDic=$EVAL_RUNDIC`**(这是整条流水线唯一一次 +1) -5. 任务 failed/killed → 写日志报警 +2. `cml custom_train submit --filename ` 提交任务,打印 `JobID`,立刻退出 +3. **不再内嵌 watcher**——agent 自己用 watcher 监听 `$AUTORESEARCH_CHAT_ROOT/sft_output/_SUCCESS`,watcher fire 后下一轮再调 `submit_cml_eval.sh` + +`submit_cml_eval.sh [MODEL_NEW] [MODEL_OLD]`: + +1. 从 `skills/model-iteration/assets/config.yaml` 读 `cml_eval.workflow_id` 和 `cml_eval.version`(版本不再硬编码,调版本就改 config.yaml) +2. `MODEL_NEW` 默认 `$AUTORESEARCH_ROOT/sft_output`、`MODEL_OLD` 默认旧基线(也可以通过 env / 参数覆盖) +3. `cml workflow run --workflow_id ... --version ... --global_inputs runDic=$EVAL_RUNDIC ...` 起评测,**评测 runDic=$EVAL_RUNDIC**(整条流水线唯一一次 +1) +4. 立刻退出,UI 卡片状态走 program-state.jsonl 的 watcher 通道 ##### yaml 模板要点(`scripts/sft_train_job.yaml.tpl`) @@ -1521,8 +1543,7 @@ cml custom_train logs --follow # 停任务 cml custom_train kill -# 查 watcher(自动评测)日志 -tail -f /tmp/r_logs/cml_watcher.log +# SFT 不再内嵌 watcher,自动评测改成 agent watcher → 单独调 submit_cml_eval.sh ``` ##### 与 nohup 路径的对比 @@ -1534,7 +1555,7 @@ tail -f /tmp/r_logs/cml_watcher.log | 节点故障重试 | 无 | 自动 2 次 | | 失败告警 | 无 | 飞书 P2 | | 任务历史 | 仅本地 log | cml 平台可查 | -| 启动复杂度 | 单行 nohup | 单行 submit_sft_via_cml.sh | +| 启动复杂度 | 单行 nohup | submit_sft.sh + watcher + submit_cml_eval.sh | ##### 何时仍用 nohup 本地训 diff --git a/skills/model-iteration/scripts/submit_cml_eval.sh b/skills/model-iteration/scripts/submit_cml_eval.sh new file mode 100755 index 0000000..3d8ae5a --- /dev/null +++ b/skills/model-iteration/scripts/submit_cml_eval.sh @@ -0,0 +1,76 @@ +#!/bin/bash +# 起 CML evaluation workflow(**只做这一件事**)。 +# workflow_id / version 都从 skills/model-iteration/assets/config.yaml 读,不在这里硬编码。 +# +# 用法: +# ./submit_cml_eval.sh [MODEL_NEW] [MODEL_OLD] +# +# 默认值: +# MODEL_NEW = $AUTORESEARCH_ROOT/sft_output(最新一轮 SFT 产物) +# MODEL_OLD = /mnt/wangsenhao/verl_zk/qwen4b_cispo_wokl_add_bvt_2/global_step_5/actor/huggingface + +set -euo pipefail + +EVAL_RUNDIC=${1:?usage: $0 [MODEL_NEW] [MODEL_OLD]} +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT=${AUTORESEARCH_ROOT:-/mnt/wangsenhao/autoresearch-zk} +MODEL_NEW=${2:-${MODEL_NEW:-$ROOT/sft_output}} +MODEL_OLD=${3:-${MODEL_OLD:-/mnt/wangsenhao/verl_zk/qwen4b_cispo_wokl_add_bvt_2/global_step_5/actor/huggingface}} + +# 定位 config.yaml:先看脚本旁边的 ../assets/,再看 skills/ 树 +find_config() { + local candidates=( + "$SCRIPT_DIR/../assets/config.yaml" + "${ZK_AGENT_SKILLS_ROOT:-}/model-iteration/assets/config.yaml" + "${AUTORESEARCH_CHAT_ROOT:-}/skills/model-iteration/assets/config.yaml" + ) + local c + for c in "${candidates[@]}"; do + if [ -n "$c" ] && [ -f "$c" ]; then + echo "$c" + return 0 + fi + done + return 1 +} + +CONFIG=$(find_config) || { + echo "[eval] ❌ 找不到 config.yaml(尝试过:$SCRIPT_DIR/../assets/、\$ZK_AGENT_SKILLS_ROOT/model-iteration/assets/、\$AUTORESEARCH_CHAT_ROOT/skills/...)" >&2 + exit 1 +} + +# 极简 yaml 解析:只提 cml_eval.workflow_id / cml_eval.version(与后端 _read_workflow_version 风格保持一致) +read_cml_eval_field() { + local field=$1 + awk -v field="$field" ' + /^[^[:space:]#]/ { in_cml = ($1 == "cml_eval:") } + in_cml && $1 == field { gsub(/^["\x27]|["\x27]$/, "", $2); print $2; exit } + ' "$CONFIG" +} + +EVAL_WORKFLOW_ID=$(read_cml_eval_field "workflow_id:") +EVAL_VERSION=$(read_cml_eval_field "version:") + +if [ -z "$EVAL_WORKFLOW_ID" ] || [ -z "$EVAL_VERSION" ]; then + echo "[eval] ❌ 从 $CONFIG 读到的 cml_eval 不完整:workflow_id='$EVAL_WORKFLOW_ID' version='$EVAL_VERSION'" >&2 + exit 1 +fi + +if [ -f ~/.cloudml-cli/.profile ]; then + source ~/.cloudml-cli/.profile +else + echo "[eval] ❌ 未找到 ~/.cloudml-cli/.profile,请先安装并初始化 cml" >&2 + exit 1 +fi + +echo "[eval] EVAL_RUNDIC=$EVAL_RUNDIC workflow_id=$EVAL_WORKFLOW_ID version=$EVAL_VERSION (config: $CONFIG)" +echo "[eval] MODEL_NEW=$MODEL_NEW" +echo "[eval] MODEL_OLD=$MODEL_OLD" + +cml workflow run \ + --workflow_id "$EVAL_WORKFLOW_ID" --version "$EVAL_VERSION" \ + --global_inputs runDic="$EVAL_RUNDIC" \ + --global_inputs model_path_new="$MODEL_NEW" \ + --global_inputs model_path_old="$MODEL_OLD" + +echo "[eval] ✅ 已提交 workflow$EVAL_RUNDIC,产物在 /mnt/xiaoai-zk-model-train-tj5/workflow5/workflow${EVAL_RUNDIC}/" diff --git a/skills/model-iteration/scripts/submit_sft.sh b/skills/model-iteration/scripts/submit_sft.sh new file mode 100755 index 0000000..697669f --- /dev/null +++ b/skills/model-iteration/scripts/submit_sft.sh @@ -0,0 +1,63 @@ +#!/bin/bash +# 用 cml custom_train submit 提交 SFT 训练(**只做这一件事**)。 +# 训练完成后请用 scripts/submit_cml_eval.sh 单独起评测,watcher 由 agent 自己挂。 +# +# 用法: +# ./submit_sft.sh [PREV_RUNDIC] +# +# 推荐调用方式(runDic 由 resolve_run_ids.sh 决定,不要手敲): +# eval "$(./scripts/resolve_run_ids.sh)" +# ./scripts/submit_sft.sh "$SFT_RUNDIC" +# +# 语义(与 program.md §746 + §5.2 对齐): +# SFT_RUNDIC = R{n-1}.runDic(augment / yaml / 归档全用这个,不 +1) +# PREV_RUNDIC = SFT_RUNDIC - 1(默认;只用作上一轮 sft_output 改名) + +set -euo pipefail + +SFT_RUNDIC=${1:?usage: $0 [PREV_RUNDIC]} +PREV_RUNDIC=${2:-$((SFT_RUNDIC-1))} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT=${AUTORESEARCH_ROOT:-/mnt/wangsenhao/autoresearch-zk} +TPL=${SFT_TRAIN_JOB_TEMPLATE:-$SCRIPT_DIR/sft_train_job.yaml.tpl} +YAML=/tmp/sft_train_job_r${SFT_RUNDIC}.yaml + +if [ -f ~/.cloudml-cli/.profile ]; then + source ~/.cloudml-cli/.profile +else + echo "[sft] ❌ 未找到 ~/.cloudml-cli/.profile,请先安装并初始化 cml" >&2 + exit 1 +fi + +# 1. 渲染 yaml 模板 +sed \ + -e "s|{RUNDIC}|${SFT_RUNDIC}|g" \ + -e "s|{PREV_RUNDIC}|${PREV_RUNDIC}|g" \ + -e "s|{AUTORESEARCH_ROOT}|${ROOT}|g" \ + "$TPL" > "$YAML" +echo "[sft] yaml: $YAML (SFT_RUNDIC=$SFT_RUNDIC PREV_RUNDIC=$PREV_RUNDIC)" + +# 2. 提交训练任务 +SUBMIT_OUT=$(cml custom_train submit --filename "$YAML" 2>&1) +echo "$SUBMIT_OUT" +JOB_ID=$(echo "$SUBMIT_OUT" | grep -oE 't-[0-9]+-[a-z0-9]+' | head -1) +if [ -z "$JOB_ID" ]; then + echo "[sft] ❌ 提交失败" >&2 + exit 1 +fi +echo "[sft] ✅ JobID: $JOB_ID" + +cat < 起评测(不要在本脚本里串接,避免跨 step bg 任务)。 + +JobID: $JOB_ID +SFT_RUNDIC: $SFT_RUNDIC (yaml / sft_output 命名) +EOF diff --git a/skills/model-iteration/scripts/submit_sft_via_cml.sh b/skills/model-iteration/scripts/submit_sft_via_cml.sh deleted file mode 100755 index f476745..0000000 --- a/skills/model-iteration/scripts/submit_sft_via_cml.sh +++ /dev/null @@ -1,124 +0,0 @@ -#!/bin/bash -# 用 cml custom_train submit 提交 SFT 训练,训练完成后自动起 cml workflow run 评测 -# -# 用法: -# ./submit_sft_via_cml.sh [PREV_RUNDIC] -# -# 推荐调用方式(runDic 由 resolve_run_ids.sh 决定,不要手敲): -# eval "$(./scripts/resolve_run_ids.sh)" -# ./scripts/submit_sft_via_cml.sh "$SFT_RUNDIC" "$EVAL_RUNDIC" -# -# 语义(与 program.md §746 + §5.2 对齐): -# SFT_RUNDIC = R{n-1}.runDic(augment / yaml / 归档全用这个,不 +1) -# EVAL_RUNDIC = R{n-1}.runDic + 1(仅本步起 eval 时使用,仅在这一处 +1) -# PREV_RUNDIC = SFT_RUNDIC - 1(默认;只用作上一轮 sft_output 改名) - -set -euo pipefail - -SFT_RUNDIC=${1:?usage: $0 [PREV_RUNDIC]} -EVAL_RUNDIC=${2:?usage: $0 [PREV_RUNDIC]} -PREV_RUNDIC=${3:-$((SFT_RUNDIC-1))} - -# 防御:EVAL_RUNDIC 必须严格大于 SFT_RUNDIC,否则一定是调用方算错 -if [ "$EVAL_RUNDIC" -le "$SFT_RUNDIC" ]; then - echo "[cml-sft] ❌ EVAL_RUNDIC ($EVAL_RUNDIC) 必须 > SFT_RUNDIC ($SFT_RUNDIC)" >&2 - exit 1 -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -ROOT=${AUTORESEARCH_ROOT:-/mnt/wangsenhao/autoresearch-zk} -MODEL_OLD=${MODEL_OLD:-/mnt/wangsenhao/verl_zk/qwen4b_cispo_wokl_add_bvt_2/global_step_5/actor/huggingface} -TPL=${SFT_TRAIN_JOB_TEMPLATE:-$SCRIPT_DIR/sft_train_job.yaml.tpl} -YAML=/tmp/sft_train_job_r${SFT_RUNDIC}.yaml - -if [ -f ~/.cloudml-cli/.profile ]; then - source ~/.cloudml-cli/.profile -else - echo "[cml-sft] ❌ 未找到 ~/.cloudml-cli/.profile,请先安装并初始化 cml" >&2 - exit 1 -fi - -# 1. 渲染 yaml 模板(用 SFT_RUNDIC,不是 EVAL_RUNDIC) -sed \ - -e "s|{RUNDIC}|${SFT_RUNDIC}|g" \ - -e "s|{PREV_RUNDIC}|${PREV_RUNDIC}|g" \ - -e "s|{AUTORESEARCH_ROOT}|${ROOT}|g" \ - "$TPL" > "$YAML" -echo "[cml-sft] yaml: $YAML (SFT_RUNDIC=$SFT_RUNDIC EVAL_RUNDIC=$EVAL_RUNDIC PREV_RUNDIC=$PREV_RUNDIC)" - -# 2. 提交训练任务 -SUBMIT_OUT=$(cml custom_train submit --filename "$YAML" 2>&1) -echo "$SUBMIT_OUT" -JOB_ID=$(echo "$SUBMIT_OUT" | grep -oE 't-[0-9]+-[a-z0-9]+' | head -1) -if [ -z "$JOB_ID" ]; then - echo "[cml-sft] ❌ 提交失败" >&2 - exit 1 -fi -echo "[cml-sft] ✅ JobID: $JOB_ID" - -# 3. 后台 watcher:等训练成功 → 自动起评测(用 EVAL_RUNDIC) -WATCHER_LOG=/tmp/r${SFT_RUNDIC}_logs/cml_watcher.log -mkdir -p "$(dirname "$WATCHER_LOG")" - -nohup bash -c ' -JOB_ID='"$JOB_ID"' -SFT_RUNDIC='"$SFT_RUNDIC"' -EVAL_RUNDIC='"$EVAL_RUNDIC"' -LOG='"$WATCHER_LOG"' -ROOT='"$ROOT"' -MODEL_NEW="$ROOT/sft_output" -MODEL_OLD='"$MODEL_OLD"' -EVAL_WORKFLOW_ID=f-20260408161444-wu3pz -EVAL_VERSION=v28 - -source ~/.cloudml-cli/.profile - -echo "[$(date)] 等待 cml job $JOB_ID 完成..." >> "$LOG" -while true; do - STATE=$(cml custom_train describe "$JOB_ID" 2>/dev/null | grep -o "\"state\": \"[a-z]*\"" | head -1 | sed "s/.*: \"//;s/\"//") - case "$STATE" in - succeed) - echo "[$(date)] 训练成功" >> "$LOG" - break ;; - failed|killed) - echo "[$(date)] ❌ 训练 $STATE" >> "$LOG" - exit 1 ;; - *) - sleep 60 ;; - esac -done - -# 验证产出 -[ ! -f "$MODEL_NEW/_SUCCESS" ] && [ ! -f "$MODEL_NEW/config.json" ] && { - echo "[$(date)] ❌ 产出缺失" >> "$LOG"; exit 1 -} - -# 起评测(这里且仅这里用 EVAL_RUNDIC) -echo "[$(date)] 启动 CML 评测 EVAL_RUNDIC=$EVAL_RUNDIC" >> "$LOG" -cml workflow run \ - --workflow_id $EVAL_WORKFLOW_ID --version $EVAL_VERSION \ - --global_inputs runDic=$EVAL_RUNDIC \ - --global_inputs model_path_new=$MODEL_NEW \ - --global_inputs model_path_old=$MODEL_OLD >> "$LOG" 2>&1 -echo "[$(date)] cml workflow run 提交完成 (workflow$EVAL_RUNDIC)" >> "$LOG" -' > /tmp/r${SFT_RUNDIC}_logs/watcher_runner.log 2>&1 & - -WATCHER_PID=$! -echo "[cml-sft] watcher PID: $WATCHER_PID(log: $WATCHER_LOG)" - -# 4. 立即输出可查看命令 -cat < str: target = self.resolve_workspace_path(path) - if newline_at_end and content and not content.endswith('\n'): - content += '\n' - mode = 'a' if append else 'w' - code = r''' -from pathlib import Path -target = Path(PATH) -target.parent.mkdir(parents=True, exist_ok=True) -content = CONTENT -with target.open(MODE, encoding="utf-8") as fh: - fh.write(content) -print(f"{'appended' if MODE == 'a' else 'wrote'} {target} ({len(content)} chars)") -''' - script = ( - code.replace('PATH', python_literal(target)) - .replace('CONTENT', python_literal(content)) - .replace('MODE', python_literal(mode)) + api_path = api_path_for_absolute_path(target) + if not api_path: + raise JupyterRuntimeError(f'Invalid remote path: {target}') + + parent_api = '/'.join(api_path.split('/')[:-1]) + if parent_api: + self._ensure_remote_dir(parent_api) + + final_content = content + if append: + final_content = self._read_remote_text_for_append(api_path) + content + if newline_at_end and final_content and not final_content.endswith('\n'): + final_content += '\n' + + encoded = base64.b64encode(final_content.encode('utf-8')).decode('ascii') + url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}' + response = self._session.put( + url, + json={ + 'type': 'file', + 'format': 'base64', + 'content': encoded, + }, + headers={ + 'X-XSRFToken': self._xsrf_token, + 'Content-Type': 'application/json', + }, + timeout=60, ) - result = self.run_python( - code=script, - script_path=None, - args=[], - stdin=None, - timeout_seconds=20.0, - max_output_chars=max_output_chars, + if response.status_code >= 400: + raise JupyterRuntimeError( + f'Unable to write remote file: HTTP {response.status_code} {response.text[:500]}' + ) + verb = 'appended' if append else 'wrote' + return f'{verb} {target} ({len(final_content)} chars)' + + def _ensure_remote_dir(self, api_path: str) -> None: + parts = [segment for segment in api_path.split('/') if segment] + cumulative = '' + for segment in parts: + cumulative = f'{cumulative}/{segment}' if cumulative else segment + url = f'{self.binding.base_url}/api/contents/{quote(cumulative, safe="/")}' + resp = self._session.get( + url, + params={'content': 0}, + headers={'X-XSRFToken': self._xsrf_token}, + timeout=30, + ) + if resp.status_code == 200: + kind = resp.json().get('type') + if kind == 'directory': + continue + raise JupyterRuntimeError( + f'Cannot create directory at {cumulative}: path exists as {kind}' + ) + if resp.status_code != 404: + raise JupyterRuntimeError( + f'Unable to inspect remote path {cumulative}: HTTP {resp.status_code} {resp.text[:300]}' + ) + create = self._session.put( + url, + json={'type': 'directory'}, + headers={ + 'X-XSRFToken': self._xsrf_token, + 'Content-Type': 'application/json', + }, + timeout=30, + ) + if create.status_code >= 400: + raise JupyterRuntimeError( + f'Unable to create remote directory {cumulative}: HTTP {create.status_code} {create.text[:300]}' + ) + + def _read_remote_text_for_append(self, api_path: str) -> str: + url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}' + resp = self._session.get( + url, + params={'content': 1}, + headers={'X-XSRFToken': self._xsrf_token}, + timeout=60, ) - if result.exit_code != 0: - raise JupyterRuntimeError(result.stdout.strip() or 'remote write_file failed') - return result.stdout.strip() + if resp.status_code == 404: + return '' + if resp.status_code >= 400: + raise JupyterRuntimeError( + f'Unable to read existing remote file for append: HTTP {resp.status_code} {resp.text[:500]}' + ) + payload = resp.json() + if payload.get('type') != 'file': + raise JupyterRuntimeError( + f'Cannot append: remote path {api_path} is not a file (type={payload.get("type")})' + ) + existing = payload.get('content', '') + if not isinstance(existing, str): + return '' + if payload.get('format') == 'base64': + return base64.b64decode(existing).decode('utf-8', errors='replace') + return existing def resolve_workspace_path(self, raw_path: str) -> str: value = raw_path.strip() or '.'