diff --git a/backend/api/server.py b/backend/api/server.py index 8713e1e..e4435a1 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -1768,11 +1768,17 @@ def create_app(state: AgentState) -> FastAPI: if isinstance(run_id, str) and run_id.strip(): rid = run_id.strip() per_round = _resolve_run_dic_per_round(state_entries) - # augment 产物的 runDic = resolve_run_ids.sh 在当轮 eval 落盘后取到的 - # max(已落盘 workflow) = 当轮 eval 自身的 workflow id,即 per_round[R{n}] - # (不是 R{n-1},脚本取的是 eval 之后的 max,此时 max 已经包含当轮 eval) + # 正确标号: R{n}·Train 和 R{n}·Analysis 共用同一 run_id, + # per_round[R{n}] 从 Analysis 的 watch/log 中解析到 runDic。 + # 兼容旧 session 错误标号: agent 每阶段递增 run_id(R7=train, R6=analysis), + # 导致 per_round[R_train] = None,需 fallback 到 R{n-1}。 if step == 'augment': - pass # 直接走下面 per_round.get(rid) 取本轮 runDic + run_dic = per_round.get(rid) + if run_dic is None: + run_index = _parse_run_index(rid) + if run_index is not None and run_index >= 1: + prev_rid = f'R{run_index - 1}' + run_dic = per_round.get(prev_rid) if run_dic is None: run_dic = per_round.get(rid) if run_dic is None: @@ -2018,7 +2024,14 @@ def create_app(state: AgentState) -> FastAPI: ) -> Response: sessions_dir = state.account_paths(account_id)['sessions'] state_path = _session_state_path(sessions_dir, session_id) + await asyncio.to_thread( + _sync_remote_program_state, state, account_id, session_id, state_path, + ) state_entries, _ = _read_program_state(state_path) + trigger = _extract_trigger_from_state(state_entries) or _extract_trigger_from_session( + sessions_dir, session_id + ) + _target_set_name = _parse_target_set(trigger, model_config=state.model_config_for(account_id)) iter_count = await asyncio.to_thread( _read_iteration_log_count, session_id, @@ -2030,6 +2043,7 @@ def create_app(state: AgentState) -> FastAPI: session_id, agent_state=state, account_id=account_id, + target_set_name=_target_set_name, ) payload = await asyncio.to_thread( _hardcoded_autoresearch_pipeline, @@ -2048,6 +2062,9 @@ def create_app(state: AgentState) -> FastAPI: session_id, ) run_active = _is_run_active(state, account_id, session_id) + state_entries = _maybe_inject_gate_fallback( + state_path, state_entries, sessions_dir, session_id, run_active, + ) _apply_program_state( payload, state_entries, @@ -2074,6 +2091,12 @@ def create_app(state: AgentState) -> FastAPI: async def event_gen(): last_signature: str | None = None last_heartbeat = time.monotonic() + last_remote_sync = 0.0 + _state_entries_init, _ = _read_program_state(state_path) + _trigger_init = _extract_trigger_from_state(_state_entries_init) or _extract_trigger_from_session( + sessions_dir, session_id + ) + _stream_target_set = _parse_target_set(_trigger_init, model_config=state.model_config_for(account_id)) refresher = asyncio.create_task( _iter_count_refresh_loop( session_id, @@ -2086,12 +2109,19 @@ def create_app(state: AgentState) -> FastAPI: session_id, agent_state=state, account_id=account_id, + target_set_name=_stream_target_set, ) ) try: while True: if await request.is_disconnected(): break + now_mono = time.monotonic() + if now_mono - last_remote_sync >= 5.0: + await asyncio.to_thread( + _sync_remote_program_state, state, account_id, session_id, state_path, + ) + last_remote_sync = now_mono state_entries, state_mtime = _read_program_state(state_path) iter_count = await asyncio.to_thread( _read_iteration_log_count, @@ -2104,6 +2134,7 @@ def create_app(state: AgentState) -> FastAPI: session_id, agent_state=state, account_id=account_id, + target_set_name=_stream_target_set, ) payload = await asyncio.to_thread( _hardcoded_autoresearch_pipeline, @@ -2121,6 +2152,9 @@ def create_app(state: AgentState) -> FastAPI: session_id, ) run_active = _is_run_active(state, account_id, session_id) + state_entries = _maybe_inject_gate_fallback( + state_path, state_entries, sessions_dir, session_id, run_active, + ) _apply_program_state( payload, state_entries, @@ -5725,11 +5759,43 @@ def _sync_remote_program_state( new_content = result.stdout or '' if not new_content.strip(): return - # Skip rewrite if local already matches remote (avoid touching mtime, which + # Preserve local-only synthetic gate entries that remote doesn't have. + local_synthetics: list[str] = [] + try: + if local_state_path.is_file(): + # Parse remote gate keys once + remote_gate_keys: set[str] = set() + for raw_line in new_content.splitlines(): + if not raw_line.strip(): + continue + try: + r = json.loads(raw_line) + if r.get('step') and not r.get('_synthetic'): + remote_gate_keys.add(f"{r.get('run_id','')}:{r.get('step','')}") + except (json.JSONDecodeError, ValueError): + pass + for line in local_state_path.read_text(encoding='utf-8').splitlines(): + if '"_synthetic"' not in line: + continue + try: + obj = json.loads(line) + if obj.get('_synthetic'): + gate_key = f"{obj.get('run_id','')}:{obj.get('step','')}" + if gate_key not in remote_gate_keys: + local_synthetics.append(line) + except (json.JSONDecodeError, AttributeError): + pass + except OSError: + pass + merged = new_content.rstrip('\n') + if local_synthetics: + merged += '\n' + '\n'.join(local_synthetics) + merged += '\n' + # Skip rewrite if local already matches (avoid touching mtime, which # SSE uses to detect changes). try: if local_state_path.is_file() and ( - local_state_path.read_text(encoding='utf-8') == new_content + local_state_path.read_text(encoding='utf-8') == merged ): return except OSError: @@ -5737,7 +5803,7 @@ def _sync_remote_program_state( try: local_state_path.parent.mkdir(parents=True, exist_ok=True) tmp = local_state_path.with_suffix('.jsonl.sync-tmp') - tmp.write_text(new_content, encoding='utf-8') + tmp.write_text(merged, encoding='utf-8') tmp.replace(local_state_path) except OSError: return @@ -6066,7 +6132,7 @@ _STEP_KIND: dict[str, str] = { 'dist-analysis': 'analysis', 'hypothesis': 'analysis', 'log': 'analysis', - 'augment': 'train', + 'augment': 'analysis', 'verify': 'train', 'sft': 'train', 'human-check': 'gate', @@ -6094,8 +6160,10 @@ _CARD_META: dict[tuple[str, str], dict[str, Any]] = { ('dist-analysis', 'baseline'): {'icon': 'trending-up', 'title': '分层结果分析 & 报告', 'subtitle': '根因归类 + workflow.md'}, ('hypothesis', 'baseline'): {'icon': 'dna', 'title': '形成假设', 'subtitle': 'iteration_log hypothesis 字段'}, ('log', 'baseline'): {'icon': 'clipboard-list', 'title': '记录迭代日志', 'subtitle': 'iteration_log.jsonl'}, + # augment — lives under previous round's analysis/baseline + ('augment', 'baseline'): {'icon': 'flask', 'title': '数据增强', 'subtitle': 'augment_.jsonl'}, + ('augment', 'analysis'): {'icon': 'flask', 'title': '数据增强', 'subtitle': 'augment_.jsonl'}, # R{n}·Train - ('augment', 'train'): {'icon': 'flask', 'title': '数据增强', 'subtitle': 'augment_.jsonl'}, ('verify', 'train'): {'icon': 'shield', 'title': '修改返回验证', 'subtitle': 'sanity + 人审'}, ('sft', 'train'): {'icon': 'graduation-cap','title': 'SFT 训练', 'subtitle': 'submit_sft_via_cml.sh'}, # R{n}·Analysis @@ -6114,16 +6182,17 @@ _CARD_ORDER: dict[tuple[str, str], int] = { ('dist-analysis', 'baseline'): 2, ('hypothesis', 'baseline'): 3, ('log', 'baseline'): 4, + ('augment', 'baseline'): 5, # train - ('augment', 'train'): 0, - ('verify', 'train'): 1, - ('sft', 'train'): 2, + ('verify', 'train'): 0, + ('sft', 'train'): 1, # analysis ('cml', 'analysis'): 0, ('gold-drift', 'analysis'): 1, ('dist-analysis', 'analysis'): 2, ('hypothesis', 'analysis'): 3, ('log', 'analysis'): 4, + ('augment', 'analysis'): 5, } _SECTION_LABELS: dict[str, str] = { @@ -6264,9 +6333,9 @@ def _derive_section_status(card_statuses: list[str]) -> str: return 'pending' if all(s == 'complete' for s in card_statuses): return 'complete' - if 'failed' in card_statuses and not any(s in ('running', 'complete') for s in card_statuses): + if 'failed' in card_statuses and not any(s in ('running', 'complete', 'waiting') for s in card_statuses): return 'failed' - if 'running' in card_statuses or 'complete' in card_statuses: + if any(s in ('running', 'complete', 'waiting') for s in card_statuses): return 'running' return 'pending' @@ -6326,6 +6395,11 @@ def _build_pipeline_items( if not run_id: continue last_non_gate_run_id = run_id + # augment belongs to the previous round's analysis/baseline section + if step == 'augment': + aug_index = _parse_run_index(run_id) + if aug_index is not None and aug_index >= 1: + run_id = f'R{aug_index - 1}' run_index = _parse_run_index(run_id) if run_index is None: continue @@ -6386,6 +6460,11 @@ def _build_pipeline_items( card['progress'] = max(0.0, min(1.0, value)) except (TypeError, ValueError): pass + if step == 'augment' and 'count' in entry: + try: + card['subtitle'] = f'{int(entry["count"])} 条新增样本' + except (TypeError, ValueError): + pass sections[section_key]['cards'].append(card) # 3. Sort cards within each section. @@ -6462,7 +6541,7 @@ def _build_pipeline_items( 'summary': entry.get('summary'), 'proposal': entry.get('proposal'), 'ask': entry.get('ask'), - '_order_key': (run_index, 99, gate_position_index.get(gate_key, 0)), + '_order_key': entry.get('ts', ''), }) # 6. Filter: drop empty/pending sections (no cards or all pending). Running @@ -6600,12 +6679,18 @@ def _compute_in_flight( progress = max(0.0, min(1.0, value)) except (TypeError, ValueError): pass + subtitle = meta['subtitle'] + if step == 'augment' and 'count' in entry: + try: + subtitle = f'{int(entry["count"])} 条新增样本' + except (TypeError, ValueError): + pass cards.append({ 'key': f'{target_run_id}:{step}', 'step': step, 'icon': meta['icon'], 'title': meta['title'], - 'subtitle': meta['subtitle'], + 'subtitle': subtitle, 'status': status, 'progress': progress, }) @@ -6623,6 +6708,85 @@ def _compute_in_flight( } +_QUESTION_PATTERN = re.compile( + r'[??]|拍板|选择.*哪|决定|确认.*吗|建议.*哪|要不要|是否|请.*选|你.*决定|三个选项|哪个方案|哪条路' +) + + +def _last_assistant_text(sessions_dir: Path, session_id: str | None) -> str | None: + if not session_id: + return None + safe_id = _safe_session_id(session_id) + if safe_id is None: + return None + path = sessions_dir / safe_id / 'session.json' + try: + data = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return None + messages = data.get('messages') + if not isinstance(messages, list): + return None + for msg in reversed(messages): + if not isinstance(msg, dict): + continue + if msg.get('role') != 'assistant': + continue + content = msg.get('content') + if isinstance(content, str) and content.strip(): + return content.strip() + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get('type') == 'text': + text = block.get('text', '').strip() + if text: + return text + return None + + +def _maybe_inject_gate_fallback( + state_path: Path, + state_entries: list[dict[str, Any]], + sessions_dir: Path, + session_id: str | None, + run_active: bool | None, +) -> list[dict[str, Any]]: + """If agent's last message asks a question but no gate is running, + append a synthetic human-review entry to program-state.jsonl.""" + if run_active is not False: + return state_entries + for entry in reversed(state_entries): + step = entry.get('step') + if step in ('human-check', 'human-review') and entry.get('status') == 'running': + return state_entries + last_text = _last_assistant_text(sessions_dir, session_id) + if not last_text: + return state_entries + if not _QUESTION_PATTERN.search(last_text[-500:]): + return state_entries + run_id = None + for entry in reversed(state_entries): + rid = entry.get('run_id') + if isinstance(rid, str) and rid.strip(): + run_id = rid.strip() + break + gate_entry: dict[str, Any] = { + 'step': 'human-review', + 'status': 'running', + 'run_id': run_id or 'R0', + 'reason': last_text[-300:], + 'ts': time.strftime('%Y-%m-%dT%H:%M:%S+08:00', time.localtime()), + '_synthetic': True, + } + try: + with open(state_path, 'a', encoding='utf-8') as f: + f.write(json.dumps(gate_entry, ensure_ascii=False) + '\n') + except OSError: + pass + state_entries.append(gate_entry) + return state_entries + + def _apply_program_state( payload: dict[str, Any], state_entries: list[dict[str, Any]], @@ -6965,6 +7129,7 @@ def _read_iteration_log_metrics_history( *, agent_state: 'AgentState | None' = None, account_id: str | None = None, + target_set_name: str | None = None, ) -> dict[str, Any] | None: """Cached read of full iteration_log.jsonl as metrics time-series.""" cache_key = _iter_count_cache_key(session_id, account_id) @@ -6972,6 +7137,7 @@ def _read_iteration_log_metrics_history( return _METRICS_HISTORY_CACHE[cache_key] value = _read_iteration_log_metrics_history_uncached( session_id, agent_state=agent_state, account_id=account_id, + target_set_name=target_set_name, ) _METRICS_HISTORY_CACHE[cache_key] = value return value @@ -6982,6 +7148,7 @@ async def _metrics_history_refresh_loop( *, agent_state: 'AgentState | None', account_id: str | None, + target_set_name: str | None = None, interval_seconds: float = 3.0, ) -> None: cache_key = _iter_count_cache_key(session_id, account_id) @@ -6992,6 +7159,7 @@ async def _metrics_history_refresh_loop( session_id, agent_state=agent_state, account_id=account_id, + target_set_name=target_set_name, ) _METRICS_HISTORY_CACHE[cache_key] = value except Exception: # noqa: BLE001 @@ -7004,6 +7172,7 @@ def _read_iteration_log_metrics_history_uncached( *, agent_state: 'AgentState | None' = None, account_id: str | None = None, + target_set_name: str | None = None, ) -> dict[str, Any] | None: """Parse iteration_log.jsonl into {'metrics': [keys], 'rounds': [...]}. @@ -7096,8 +7265,70 @@ def _read_iteration_log_metrics_history_uncached( 'values': values, }) + # Deduplicate: keep only the last entry per (iteration, runDic). + # Agent may write multiple entries for the same round (e.g. re-running + # dist-analysis); the last one has the most complete metrics. + seen_keys: dict[tuple[int, int], int] = {} + for idx, r in enumerate(rounds): + key = (r['iteration'], r['runDic']) + seen_keys[key] = idx + rounds = [rounds[i] for i in sorted(seen_keys.values())] + + # Rebuild metrics_order from surviving rounds only (dedup may have removed + # entries whose keys shouldn't appear in the chart). + metrics_order = [] + metrics_seen = set() + for r in rounds: + for k in r['values']: + if k not in metrics_seen: + metrics_seen.add(k) + metrics_order.append(k) + if not rounds: return None + + # Enrich each round with KPI-equivalent metrics from lark_template.json. + # This ensures dapan_car / specific_test / target_subset are always present + # even if the agent omitted them from iteration_log. + target_key = target_set_name if target_set_name and target_set_name != '—' else 'target_subset' + _KPI_METRIC_MAP = { + 'specific_test_pass_rate': 'specific_test', + 'overall_car_pass_rate': 'dapan_car', + 'target_set_pass_rate': target_key, + } + # Rename agent-written 'target_subset' to the actual target set name first, + # so enrichment doesn't create duplicates. + if target_key != 'target_subset': + if 'target_subset' in metrics_seen: + metrics_order = [target_key if m == 'target_subset' else m for m in metrics_order] + metrics_seen.discard('target_subset') + metrics_seen.add(target_key) + for r in rounds: + if 'target_subset' in r['values']: + r['values'][target_key] = r['values'].pop('target_subset') + + for r in rounds: + run_dic = r.get('runDic') + if not isinstance(run_dic, int) or run_dic == 0: + continue + metric_diff = _read_workflow_metric_diff( + run_dic, + agent_state=agent_state, + account_id=account_id, + session_id=session_id, + ) + if metric_diff is None: + continue + _enrich_metrics_from_eval_output(metric_diff, None) + for src_key, dst_key in _KPI_METRIC_MAP.items(): + if dst_key not in r['values']: + raw = metric_diff.get(src_key) + if isinstance(raw, (int, float)): + r['values'][dst_key] = round(raw * 100, 2) + if dst_key not in metrics_seen: + metrics_seen.add(dst_key) + metrics_order.append(dst_key) + return {'metrics': metrics_order, 'rounds': rounds} @@ -7317,7 +7548,8 @@ def _parse_target_set( ) -> str | None: """Sync, non-blocking. Reads cache only — never makes the LLM call inline. Cache is filled by the watcher scanner background loop via - _refresh_target_set_cache_blocking (which is run in a thread).""" + _refresh_target_set_cache_blocking (which is run in a thread). + Falls back to regex extraction of .csv filenames from trigger text.""" if not trigger: return None text = trigger.strip() @@ -7326,7 +7558,14 @@ def _parse_target_set( if len(text) > 4000: text = text[:4000] cache_key = hashlib.sha256(text.encode('utf-8')).hexdigest() - return _TARGET_SET_LLM_CACHE.get(cache_key) + cached = _TARGET_SET_LLM_CACHE.get(cache_key) + if cached is not None: + return cached + # Regex fallback: extract .csv filename from trigger text. + m = re.search(r'(?:(?<=[的/,,\s])|(?<=^))([^\s,,。;的中是在]+\.csv)', text) + if m: + return m.group(1) + return None def _refresh_target_set_cache_blocking( diff --git a/frontend/app/app/assistant.tsx b/frontend/app/app/assistant.tsx index 8e3c00f..e21b107 100644 --- a/frontend/app/app/assistant.tsx +++ b/frontend/app/app/assistant.tsx @@ -66,8 +66,7 @@ export const Assistant = ({ initialSessionId }: AssistantProps = {}) => { const lastSessionId = getLastSessionId(messages); const pendingWorkspaceSessionId = messages.length <= 1 ? readPendingWorkspaceSessionId() : null; - const selectedResumeSessionId = - messages.length > 1 ? selectedSessionId : null; + const selectedResumeSessionId = selectedSessionId; const outgoingSessionId = lastSessionId ?? selectedResumeSessionId ?? diff --git a/frontend/app/components/assistant-ui/thread.tsx b/frontend/app/components/assistant-ui/thread.tsx index 30601b3..0f232ac 100644 --- a/frontend/app/components/assistant-ui/thread.tsx +++ b/frontend/app/components/assistant-ui/thread.tsx @@ -1926,6 +1926,9 @@ function SkillInsertDialog() { className="min-w-0 flex-1 text-left" onClick={() => { insertComposerText(`Use the ${skill.name} skill.\n\n`); + if (skill.name === "model-iteration") { + dispatchSkillToggled({ skill: skill.name, enabled: true }); + } setOpen(false); }} > diff --git a/frontend/app/components/training/metrics-chart-panel.tsx b/frontend/app/components/training/metrics-chart-panel.tsx index ef1d465..642979e 100644 --- a/frontend/app/components/training/metrics-chart-panel.tsx +++ b/frontend/app/components/training/metrics-chart-panel.tsx @@ -28,9 +28,14 @@ const METRIC_LABELS: Record = { req_set_car: "需求集合(车载)", dapan_car: "大盘车载", specific_test: "Specific Test", + target_subset: "目标集合", + icl_test: "ICL Test", + overall: "大盘整体", triage_err_rate: "Triage 错误率", bvt_nav: "导航BVT", talkable_controllable: "可聊可控", + badcase_2025: "Badcase 2025", + autotask: "自动任务", target_subset_count: "目标子集数量", target_subset_wrong: "目标子集错误数", target_subset_correct: "目标子集正确数", diff --git a/skills/model-iteration/SKILL.md b/skills/model-iteration/SKILL.md index 614e056..657fb30 100644 --- a/skills/model-iteration/SKILL.md +++ b/skills/model-iteration/SKILL.md @@ -87,8 +87,8 @@ 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` 语义),**逐条 1:1 全量覆盖**(`augment` 多少行 `label_master_review.jsonl` H2 部分就多少行,禁止抽样 / spot-check / X/X pass 外推),verdict 写 `results/data_clean_/label_master_review.jsonl`,**不通过必须 = 0**(任何不通过必须修正后重新 review 直到全 pass 才能进 Step 5) -- [ ] **§4.5 层 2 语义复核必须调用 `Skill(skill="label-master")`,禁止正则/规则脚本替代**:层 2 的本质是"用 label-master 知识体系对每条 (query, label) 做独立语义判定"。label-master 已标记 `repeatable: true`,可在同一 run 内多次调用。**禁止**:纯正则/关键词匹配脚本、"target 都一样所以直接 pass"逻辑、批量写 pass 不看 query 内容。已踩坑:R1 agent 写了个正则脚本充当"layer 2",完全没走语义判断。 +- [ ] **§4.5 label-master 标签复核(落盘后必做,强制,H1+H2 都要)**:H1 `modified_samples.jsonl` **和** H2 `augment_.jsonl` 都必须跑两层(`validate_label_output.py` 格式 + Skill 调用 `label-master` 语义),**逐条 1:1 全量覆盖**。§4.0.1 A.5 的预审是粗筛,**不能替代落盘前的 layer2 语义复核**。verdict 写 `results/data_clean_/label_master_review.jsonl`,**不通过必须 = 0**(任何不通过必须修正后重新 review 直到全 pass 才能进 Step 5) +- [ ] **§4.5 层 2 语义复核必须通过子 agent 调用 `Skill(skill="label-master")`,禁止主 agent 直接调、禁止正则/规则脚本替代**:主 agent 把待审列表写入 `scratchpad/lm_input.jsonl`,起子 agent(`delegate_agent(prompt="...", allow_shell=true)`)逐条调 label-master,结果写 `scratchpad/lm_output.jsonl`,主 agent 读取汇总。子 agent prompt 里给**绝对路径**。**禁止**:主 agent 直接调 Skill(skill="label-master")、纯正则/关键词匹配脚本、"target 都一样所以直接 pass"逻辑、批量写 pass 不看 query 内容。 ### Step 4 → Step 5 边界(**NEVER STOP 硬连接**,反复踩坑) - [ ] 写完 `augment=complete` 那一刻,**同一轮 bash 不许结束**:紧接着跑 §4.5 label-master 复核 → 写 `sft=running` → 调 `submit_sft.sh` → 挂 watcher(评测在 SFT `_SUCCESS` 落盘后的下一轮单独用 `submit_cml_eval.sh` 起,不要在 SFT bg 里串接评测) @@ -128,17 +128,45 @@ when_to_use: | > echo '{"step":"cml","status":"complete","ts":"'$(date -Iseconds)'"}' >> "$SESSION_OUTPUT/program-state.jsonl" > ``` -### R1.5 — 推荐每条 state entry 带 run_id(让 UI 准确归位 round) +### R1.5 — 每条 state entry 必须带 run_id(让 UI 准确归位 round) -新版 UI 按 R0 / R1 / R2 切分 sections。**强烈建议**每行加 `run_id`: +新版 UI 按 R0 / R1 / R2 切分 sections。每个 R{n>=1} 同时包含 **Train** 和 **Analysis** 两个 section。**必须**每行带 `run_id`。 + +⚠️ **核心规则:run_id 只在 hypothesis=complete 之后递增。** 一个完整迭代(augment → sft → eval → analysis → hypothesis)全程使用同一个 run_id。 + +完整示例(一轮完整迭代 R1): ```bash -echo '{"step":"cml","status":"running","run_id":"R0","ts":"'$(date -Iseconds)'"}' >> $S -echo '{"step":"hypothesis","status":"complete","run_id":"R0","ts":"..."}' >> $S # hypothesis 跟当前轮(R0),不是 R1 -echo '{"step":"augment","status":"running","run_id":"R1","ts":"..."}' >> $S # augment 才是 R1·Train 起点 +# ─── R0·Baseline ─── +echo '{"step":"cml","status":"running","run_id":"R0"}' >> $S +echo '{"step":"dist-analysis","status":"complete","run_id":"R0"}' >> $S +echo '{"step":"hypothesis","status":"complete","run_id":"R0"}' >> $S +# ← hypothesis=complete → 递增 run_id + +# ─── R1·Train ─── +echo '{"step":"augment","status":"running","run_id":"R1"}' >> $S +echo '{"step":"augment","status":"complete","run_id":"R1","count":80}' >> $S +echo '{"step":"sft","status":"running","run_id":"R1"}' >> $S +echo '{"step":"sft","status":"complete","run_id":"R1"}' >> $S + +# ─── R1·Analysis(SFT 后的 eval 仍然是 R1,不是 R2!)─── +echo '{"step":"cml","status":"running","run_id":"R1"}' >> $S +echo '{"step":"cml","status":"complete","run_id":"R1"}' >> $S +echo '{"step":"dist-analysis","status":"complete","run_id":"R1"}' >> $S +echo '{"step":"hypothesis","status":"complete","run_id":"R1"}' >> $S +# ← hypothesis=complete → 递增 run_id + +# ─── R2·Train ─── +echo '{"step":"augment","status":"running","run_id":"R2"}' >> $S +# ... ``` -run_id 缺省时后端会按 `iteration_log.jsonl` 行数自动推断(analysis 类 step 含 hypothesis → R{count},train 类 step augment/verify/sft → R{count+1}),但显式写更准——尤其是并发跨轮、补写历史 entry、或要在 R0 强制注入 baseline 时。 +**错误(禁止)**:eval 后递增 run_id,导致 train 和 analysis 分属不同 round: +``` +R1: augment+sft → R2: cml+analysis+hypothesis → R3: augment+sft → R4: cml+analysis ... +``` + +**为什么重要**:后端通过 `per_round[R{n}]` 从 R{n}·Analysis 的 watch/log entry 解析 runDic,再用这个 runDic 去找 R{n}·Train 的 augment 产物文件。如果 train 和 analysis 用了不同 run_id,augment 的产物文件找不到。 ### R2 — 写入即生效,最后一行覆盖前面 @@ -417,58 +445,24 @@ echo '{"log":{"ts":"11:55:10","iter":"R1","text":"Step 1 dist-analysis: 写 work 1. **评测优先**:`"开始"` 信号的第一个动作永远是评测当前模型,绝不直接训练 2. **假设驱动**:每轮必须能回答"这轮验证了什么?学到了什么?下一轮改什么?" 3. **NEVER STOP**:全程不打断用户,循环直到达标或人类主动打断 -4. **从 basemodel 训练**:每轮 SFT 的 `--model_path` 必须是 basemodel,禁止从上轮 ckpt 继续训 -5. **复杂度优先**:分析类任务(近邻检索 / 聚类 / 相似度统计)选算法前先估数据规模。**忌**对几万行训练集用 `sklearn.fit_transform` 这类要全量物化稀疏矩阵或 embedding 矩阵的 API — 单次 600s 仍可能 timeout。**>1 万行训练集做近邻必须先粗筛后精排(倒排索引 → 每错例 50–300 候选 → 精排),禁止 O(M·N) 全量比对**——37×30k=1.1M 次朴素比较 ~3min,粗筛后 7.4k 次 ~10s。错例侧的"剪枝"只是常数优化,不能替代粗筛。详见 [references/program.md §2.3](references/program.md) -6. **大文件先切片**:>10MB 量级的远端 CSV/JSONL(如 `specific_comparison.csv`),先用 bash `awk/grep/head` 在远端切出目标子集再处理。**忌**直接 `pd.read_csv` 整体加载或把 DataFrame 当返回值穿过工具调用 — 会撞 runtime 的输出/返回大小限制(实测 99MB 直接读 → "太大了" 失败) -7. **跨脚本复用走 /tmp pickle 缓存**:同一轮 dist-analysis 里 all_train.jsonl 这种"多个脚本都要读的训练集"必须缓存——第一次读完用 `pickle.dump(rows, '/tmp/all_train_.pkl')`(pod /tmp 是本地 SSD),后续脚本以 `if path.exists(): pickle.load(path) else: 重建 + 落盘` 模式启动。倒排索引同理。**忌**每个脚本都重新 `for line in open(jsonl): json.loads(line)` 全表 — 30k 行 NFS jsonl 每次 5–15s,从本地 pickle unpickle <1s,v32 R0 5 次重读累计 ~50s 可省。脚本文件名最好写 cache key(如 `kn_search_v2_.py`),换轮换 runDic 自动失效。 -8. **stdout 即 LLM 下一轮 input,分级 print**:每个分析脚本的 stdout 会原样喂回 LLM 当下一轮上下文,每 KB 都要 prefill。**只 print 决策需要的聚合**(端点抽样总数、各 verdict 的计数、top-3 exemplars 文字),**明细落 `/tmp/__detail.json`**(每条错例的 3 近邻 + 相似度 + token overlap 等)后续要回看再 `cat` 该文件。v32 R0 实测 kn_search 一次 stdout 18KB、judge_check 4.7KB、mislabel 11.3KB,60% 是重复的 per-case 全量 dump,LLM 看完只用一句话总结——这部分 prefill cost 是直接浪费。还有:`cat > /tmp/x.py < ` 回显进 stdout(实测一次累计 1–2KB),改用 `python3 -c "open('/tmp/x.py','w').write(open('/dev/stdin').read())" <<'PY' ... PY` 或 `base64 -d` 注入,可以彻底消掉 `> > >` 噪声。 -9. **一律走 bash + 脚本文件,禁用 python_exec**:所有 python 任务把代码写到远端 `/tmp/xxx.py`,用 `bash` 跑 `python3 -u /tmp/xxx.py 2>&1 | tee /tmp/xxx.log`。**忌**用 python_exec — 它 stdout 全缓冲、turn 结束才 flush,超时被 kill 时 buffer 直接丢,故障时连 phase 时序都看不到(实测 600s/1800s 撞墙后 `tool_result` 没有任何 stdout 字段)。bash + tee 模式 stdout 实时落盘,超时也能 `tail /tmp/xxx.log` 看到死在哪。 +4. **从 basemodel 训练**:每轮 SFT 的 `--model_path` = `/mnt/wangsenhao/verl_zk/Qwen3-4B-Instruct-2507`,禁止从上轮 ckpt 续训 +5. **近邻检索先粗筛后精排**:>1 万行训练集禁止 O(M·N) 全量比对;倒排索引 → 50-300 候选 → 精排。详见 program.md §2.3 +6. **大文件先切片**:>10MB 的 CSV/JSONL 先 bash `awk/grep/head` 切子集,禁止 `pd.read_csv` 整体加载 +7. **pickle 缓存 /tmp**:训练集 + 倒排索引缓存到 pod `/tmp/`(mtime 作 key 自动失效),禁止每脚本重建 +8. **stdout 分级 print**:只 print 决策聚合(<2KB),明细落 `/tmp/__detail.json` +9. **一律 bash + 脚本文件**:`python3 -u /tmp/xxx.py 2>&1 | tee /tmp/xxx.log`,禁用 python_exec ## 达标条件 -- **需求集合(目标集)** ≥ 95%(**最高优先级,一切以此为准**) -- **大盘集(车载)** 降幅 ≤ 0.3%(次要,目标集未达 95% 前不因大盘微降而回滚/停训) -- **specific test** 降幅 ≤ 1%(次要,同上) +- **需求集合(目标集)** ≥ 95%(**最高优先级**) +- **大盘集(车载)** 降幅 ≤ 0.3%(次要) +- **specific test** 降幅 ≤ 1%(次要) -⚠️ **优先级铁律**:目标集上 95% 是主线任务。只要目标集还没到 95%,其他集合的轻微下降(大盘 ≤1%、specific ≤2%)**不构成回滚理由**,继续迭代。只有目标集已达标后,才需要关注并修复其他集合的 regression。 +⚠️ **优先级铁律**:目标集未达 95% 前,其他集合轻微下降(大盘 ≤1%、specific ≤2%)不构成回滚理由。 ## 主流程 -读取 `references/program.md` 获取完整的 Step 0-7 迭代循环规范。在任何操作前必须读取该文件。 - -每个 step 的执行模板(**强制**,配合上文「Pipeline UI 同步约定」R1): - -```text -1. echo '{"step":"","status":"running","ts":"..."}' >> $SESSION_OUTPUT/program-state.jsonl -2. (执行该 step 的实际操作 / 调用工具 / 等结果) -3. 成功: echo '{"step":"","status":"complete","ts":"..."}' >> ... - 失败: echo '{"step":"","status":"failed","error":"...","ts":"..."}' >> ... -``` - -step key 跟 Step 编号映射: - -| Step | step key | 说明 | -|------|----------|------| -| Step 0 | `cml` | CML 评测当前模型 | -| Step 0 (附带) | `gold-drift` | Gold drift 检查 | -| Step 1 | `dist-analysis` | 分层结果分析 + 问题分析 & 报告(写 workflow.md) | -| Step 3 | `hypothesis` | 形成假设(写 iteration_log) | -| Step 4 | `augment` | 数据增强 / 生成 | -| Step (干预后) | `verify` | 修改返回验证 | -| Step 5 | `sft` | SFT 训练 | -| Step 6 | `log` | 记录迭代日志 | -| 循环 | `next-round` | 回 Step 0 评测新 ckpt | - -``` -LOOP: - Step 0: CML 评测(当前模型)+ Gold drift 检查 - Step 1: 分层结果分析 + 问题分析 & 报告(需求集合 → 大盘车载 → specific test;根因归类 / 跨轮 diff / 天花板诊断 / 写 workflow.md) - Step 3: 形成假设(写入 iteration_log.jsonl) - Step 4: 数据生成(仅 data 归因时做) - Step 5: SFT 训练(submit_sft.sh)→ 训练成功后下一轮评测用 submit_cml_eval.sh - Step 6: 记录结果(iteration_log.jsonl / error_registry.jsonl) - → 回 Step 0 -``` +完整 Step 0-7 规范见 `references/program.md`,**操作前必读**。 ## 文件结构 @@ -623,31 +617,19 @@ echo '{"step":"human-check","status":"running","run_id":"R0","reason":"label-mas 实际差别在 UI 上不大(都是横向 dashed 卡),区分主要为了让用户从标题就大致知道是开始前还是结果后的检查点。 -## CML 配置(快速查阅) +## CML 配置 -```yaml -# ~/.config/cloudml/config.yaml -default_config_context: cloudml5-config -xiaomi_cloudml: - cloudml5-config: - xiaomi_auth_type: key - xiaomi_cloudml_endpoint: https://cnbj6-cloudml5.api.xiaomi.net - xiaomi_cloudml_workspace_id: 10065 -``` +见 `references/program.md` §5.2。 -## 结果文件 +## 结果文件 → 前端卡片映射 -| 文件 | 用途 | 前端卡片 | -|---|---|---| -| `results/iteration_log.jsonl` | 每轮假设/干预/判定完整记录 | hypothesis / log | -| `results/error_registry.jsonl` | 跨轮错误追踪(case_hash → 出错轮次) | log | -| `results/workflow.md` | 每轮回归分析报告 | dist-analysis | -| `output/relabel_candidates_.csv` | **阶段一**:预计修改训练集候选清单(complex 翻转候选等) | **dist-analysis(分层结果分析)** | -| `results/data_clean_/modified_samples.jsonl` | **阶段二**:确认修改最终落盘(用户审核 1/0 后实际生效的改动) | **augment(数据增强)** | -| `results/data_clean_/deleted_samples.jsonl` | 旧数据清洗存档(删除项) | — | -| `results/augment_raw/augment__raw.jsonl` | GPT-5.4 仿写原始产物 | augment | -| `ai-planning/data/train_set/zk_intent/augment_.jsonl` | 本轮合入训练集的增量(H1 重标 + H2 仿写) | augment | -| `results/gold_drift/drift_.json` | Gold drift 检测结果 | gold-drift | -| `results/label_rules.md` | 已确立的标签规则集(R1~RN) | — | - -**两阶段训练集修改的展示约定**:候选阶段(量级判定 + 人审 1/0 之前)写在 `output/relabel_candidates_.csv`,前端「分层结果分析」卡片读取展示供人审;确认阶段(人审后落盘)写在 `results/data_clean_/modified_samples.jsonl`,前端「数据增强」卡片读取展示最终改动。详见 [references/program.md §4.0.1 Step A](references/program.md)。 +| 文件 | 前端卡片 | +|---|---| +| `results/iteration_log.jsonl` | hypothesis / log | +| `results/error_registry.jsonl` | log | +| `results/workflow.md` | dist-analysis | +| `output/relabel_candidates_.csv` | dist-analysis(阶段一:候选) | +| `results/data_clean_/modified_samples.jsonl` | augment(阶段二:落盘) | +| `results/augment_raw/augment__raw.jsonl` | augment | +| `ai-planning/data/train_set/zk_intent/augment_.jsonl` | augment | +| `results/gold_drift/drift_.json` | gold-drift | diff --git a/skills/model-iteration/references/program.md b/skills/model-iteration/references/program.md index 18b08b0..ea74504 100644 --- a/skills/model-iteration/references/program.md +++ b/skills/model-iteration/references/program.md @@ -219,9 +219,11 @@ for k in prev: | 字段 | 含义 | | --- | --- | -| `origin_predict_base` | 旧模型原始输出。具体格式(是否含 `complex=` 前缀、tag 包装、自定义 class 名等)以当前评测产出为准,**分析前 head 一下实物** | -| `origin_predict_dev` | 新模型原始输出。同上,格式以当前产出为准 | -| `cleaned_predict_*` | 清洗后输出,用于 GSB 对比 | +| `origin_predict_base` | 旧模型(baseline)原始输出。具体格式(是否含 `complex=` 前缀、tag 包装、自定义 class 名等)以当前评测产出为准,**分析前 head 一下实物** | +| `origin_predict_dev` | 新模型(迭代模型)原始输出。同上,格式以当前产出为准 | +| `complex_dev` | 迭代模型的 complex 标签 | +| `complex_base` | baseline 模型的 complex 标签 | + | `label` / `code_label_base` | ground truth;`label` 为空时回退解析 `code_label_base` | #### 2.2 分析步骤 @@ -276,80 +278,11 @@ for k in prev: 3. **最终报告强制并列原文**:每条候选必须同时打印**原始 output 字符串前 80 字符**和**判别器返回值**,读者肉眼可对一遍——光报"近邻一致 N 条 / mislabel M 条"不够。 4. **零计数兜底**:mislabel 跑出 = 0 时**禁止**直接结论"训练集没问题",必须先回去验步骤 2 的端点是否全过——zero-count 默认是判别器 bug 信号、不是结论信号。 - ⚠️ **算法选型**:错例集 O(几十)、训练集 O(几万) — 不要用复杂度高、要把训练集全量物化成稀疏矩阵或 embedding 的算法(如 `sklearn.TfidfVectorizer.fit_transform` 全量 + cosine — 实测 35k 行字符 n-gram 600s 仍 timeout)。 - - ⚠️ **>1 万行训练集做近邻必须先粗筛后精排,不许直接 O(M·N) 全量比对**——37 错例 × 30k 训练 = 1.1M 次精排,朴素写法实测 ~3 min(v32 R0 case:错例侧逐条扫训练集 + 每例剪枝仍 182s,且后续每加一步 mislabel 定位都要再跑一遍)。两段式: - - 1. **粗筛(倒排索引,全表只扫一次)**:训练集一次性建 `token → set(line_idx)` 倒排表,key 用 query 的 2-gram 字符 token(30k 行 query 通常 3k–5k 唯一 token,建表 O(N) 秒级)。 - 2. **错例侧逐条取候选**:每条错例提它自己的 2-gram tokens,union 训练集对应 posting list → 通常每条得到 50–300 候选行(不是 30k)。 - 3. **精排(小集合上做精细打分)**:仅对粗筛候选跑 Jaccard / char-overlap / Levenshtein 取 top-3。 - - 预算对照(37 × 30k):朴素 O(M·N)=1.1M 次精排(~3 min);倒排粗筛后 ~37×200=7.4k 次精排(~10s)。错例侧的"轻量剪枝"只是常数优化,**不能替代粗筛**——粗筛把候选基数砍到 1/100 才是数量级提速的来源。 - - ⚠️ **训练集 + 倒排索引必须 pickle 缓存到 /tmp,不要每个脚本重建**:dist-analysis 一轮要跑 kn_search / judge_check / mislabel_list / cross_subset 等多个脚本,每个都要读 30k 行 all_train.jsonl + 重建倒排索引——v32 R0 实测 5 次重读累计 ~50s 全是浪费。pod `/tmp` 是本地 SSD,`pickle.dump` 之后所有后续脚本 unpickle <1s。模板: - - ```python - # 每个会读 all_train 的脚本头部统一这一段,cache key 用文件 mtime 自动失效 - import os, json, pickle - from pathlib import Path - ALL_TRAIN = Path(os.environ['AUTORESEARCH_CHAT_ROOT']) / 'ai-planning/data/train_set/zk_intent/all_train.jsonl' - CACHE = Path(f'/tmp/all_train_{int(ALL_TRAIN.stat().st_mtime)}.pkl') # mtime 当版本号 - IDX_CACHE = Path(f'/tmp/all_train_idx_{int(ALL_TRAIN.stat().st_mtime)}.pkl') - - if CACHE.exists(): - rows = pickle.loads(CACHE.read_bytes()) # <1s - else: - rows = [json.loads(l) for l in ALL_TRAIN.open('r', encoding='utf-8')] # 5–15s NFS - CACHE.write_bytes(pickle.dumps(rows)) - - # 倒排索引同样缓存(first script 建好就一直复用) - if IDX_CACHE.exists(): - inv = pickle.loads(IDX_CACHE.read_bytes()) - else: - inv = {} - for i, r in enumerate(rows): - q = r.get('query', '') - for tok in {q[k:k+2] for k in range(len(q)-1)}: # 2-gram - inv.setdefault(tok, set()).add(i) - IDX_CACHE.write_bytes(pickle.dumps(inv)) - ``` - - 忌:每个脚本头都重写一遍 `for line in open(jsonl): json.loads(line)`;用 `/tmp/.pkl` 而忽略 mtime(训练集中途被改不失效);把 cache 写到 NFS(白做了)。 - - ⚠️ **stdout 是下一轮 LLM 的 input,分析脚本只 print 决策聚合,明细落 `/tmp/__detail.json`**:每个分析脚本的 stdout 会原样喂回 LLM 当下一轮上下文,每 KB 都要 prefill。v32 R0 实测 kn_search 一次 stdout 18KB(37 错例 × 3 邻居 × 6 列)、judge_check 4.7KB、mislabel 11.3KB,整个 dist-analysis 累计 ~50KB 全是重复模式行。kn_search 模板(其他步骤同构): - - ```python - # 不要 print 整张 candidate × neighbors 表;只 print 三档分布 + 每档 top-3 范例 - import json, collections, os - buckets = collections.Counter() - exemplars = collections.defaultdict(list) - detail = [] # 全量明细,落盘不 print - for case in failed_cases: - neigh = topk(case, k=3) # 粗筛 → 精排已就位 - verdict = classify(case, neigh) # 'no_neighbor' / 'consistent' / 'mislabel' - buckets[verdict] += 1 - if len(exemplars[verdict]) < 3: - exemplars[verdict].append({ - 'query': case['query'][:50], - 'gold': case['gold'], - 'top_neigh': [{'q': n['query'][:50], 'label': n['label']} for n in neigh], - }) - detail.append({'case': case['idx'], 'verdict': verdict, 'neigh': neigh}) - - detail_path = f'/tmp/kn_search_{os.environ["RUN_DIC"]}_detail.json' - json.dump(detail, open(detail_path, 'w'), ensure_ascii=False) - - # 只 print 决策需要的聚合(目标 < 2KB) - print(f'[kn_search] total={len(failed_cases)} buckets={dict(buckets)} detail={detail_path}') - for v, exs in exemplars.items(): - print(f'-- top-3 of {v} --') - for e in exs: - print(json.dumps(e, ensure_ascii=False)) - ``` - - 忌:把每条错例的 k 个邻居全 print;`DataFrame.to_string()` 直接喂 stdout(自带 padding,一张表 2KB+);用 `cat > /tmp/x.py <` 回显进 stdout,一次累计 1–2KB),改 `python3 - <<'PY' ... PY` 或 `python3 -c "$(base64 -d <<<'...')"` 注入。 - - ⚠️ **大文件 I/O**:>10MB 量级的远端 CSV/JSONL(如本步要读的 `specific_comparison.csv`,常见 90+MB),**先用 bash `awk/grep/head` 在远端切目标子集再处理**。直接 `pd.read_csv` 整体加载、或把 DataFrame 当返回值穿过工具调用 → 会撞 runtime 输出/返回大小限制(实测 99MB 直接读报"太大了")。 + ⚠️ **近邻检索性能规则**(>1 万行训练集时强制): + - **禁止 O(M·N) 全量比对**——必须两段式:① 倒排索引粗筛(query 2-gram → posting list,每错例得 50-300 候选)② 仅对候选精排(Jaccard/char-overlap 取 top-3) + - **训练集 + 倒排索引 pickle 缓存到 `/tmp/`**(key 用文件 mtime 自动失效),禁止每个脚本重建 + - **stdout 只 print 聚合**(三档分布 + 每档 top-3 范例,目标 <2KB),明细落 `/tmp/__detail.json` + - **>10MB 文件先 bash 切片**(`awk/grep/head` 取子集),禁止 `pd.read_csv` 整体加载 ⚠️ **执行通道**:所有 python 一律走 `bash` + `/tmp/xxx.py` + `python3 -u … | tee /tmp/xxx.log`,禁用 `python_exec`。`python_exec` stdout 全缓冲、turn 结束才 flush,超时被 kill 时 buffer 直接丢光(实测 600s/1800s 撞墙后 `tool_result` 没有任何 stdout 字段)。bash + tee 模式实时落盘,超时也能 `tail /tmp/xxx.log` 看到死在哪个 phase。 3. **Reward 对齐检查**(全量失败 case,不抽样):用 `zk_reward_fn` 对"正确 label"和"实际输出"分别打分,验证 reward 方向是否和准确率一致。如果 reward 给错误输出的分更高 → reward 函数本身就有问题。 @@ -411,243 +344,95 @@ conflict_rate = len(conflicts) / len(query_to_golds) #### 2.6 报告模板 -> **首次评测使用下方"首次评测报告模板",后续迭代使用"迭代报告模板"。** - -##### 首次评测报告模板 - -```markdown -# workflow 基准模型评测报告 - -**版本**: v28 | **执行ID**: ... | **基准模型**: ... - -> 首次评测:新旧模型相同,本报告只记录基准模型的绝对指标和错误分布,作为后续迭代的对照基准。 - -## 总览 -- **需求集合 (<名称>)**: xxx% ⭐ -- **大盘(车载)**: xxx% -- **Specific test**: xxx% - -## 需求集合分析 -### 子集表现 -| 子集 | 准确率 | 总数 | 错误数 | 距 95% gap | - -### 失败 case 与训练数据关联 -- <子集>: N 条失败中 X 条**有近邻且 label 一致**、Y 条**无近邻**、Z 条**近邻 mislabeled** ⚠️ → 主要缺口: <分布外补数据 / SFT 学不动 / 训练集错标> - -#### 训练集错标清单(逐条,对应 Z 条 mislabeled) -| 失败 case query | 训练样本 file:line | hash | 当前 label | 应改 label | 相似度 | 违反规则 | -|---|---|---|---|---|---|---| -| | augment_.jsonl:42 |

| Agent | CT | 0.93 | R1(单 POI+多形容词) | - -> 没有 mislabeled 时这张表省略;有则进入下一轮 Step 4 的修订清单。 - -## 设备维度(只分析车载) -| 设备 | 准确率 | 总数 | 错误数 | - -全设备参考: -| 设备 | 准确率 | 总数 | 错误数 | - -## Specific Test 子集表现 -| 子集 | 准确率 | 总数 | 错误数 | - -## 基准错误画像 -### <错误 pattern>(数量) -**根因归类**: reward / data / 格式 -**典型 case**: -query: ... -label: ... -模型输出: ... -归类: 分流/语义 -训练集近邻: 有 label 一致 / 无 / ⚠️ mislabeled(hash=xxx, 相似度 0.xx, 当前=Agent / 应改=CT) - -## 初始错误分布总结 -| 根因类 | 错误数 | 占比 | 代表 pattern | - -## 下一轮假设候选(按 ROI 排序) -1. <假设>: 基于基准错误画像 ,预期收益 -2. ... -``` - -##### 迭代报告模板 +写入 `results/workflow.md`。首次评测(new=old)省略"上轮回顾"和"跨轮追踪",只记绝对指标。 ```markdown # workflow 回归分析报告 -**版本**: v27 | **执行ID**: ... | **新模型**: ... | **基线**: ... +**版本**: v28 | **执行ID**: ... | **新模型**: ... | **基线**: ... -## 上轮假设回顾(若本轮是上轮训练结果) -- **假设**: -- **干预**: - -- **预测**: <...> -- **判定**: ✅ hit / ❌ miss / 🟡 partial — <一句话说明> +## 上轮假设回顾(首次评测省略) +- **假设/干预/预测/判定**: ✅ hit / ❌ miss / 🟡 partial ## 总览 -- **需求集合 (<名称>)**: xxx%(vs 基线 ±x.xx%,vs 上轮 ±x.xx%)⭐ +- **需求集合 (<名称>)**: xxx%(vs 基线 ±x.xx%)⭐ - **大盘(车载)**: xxx%(vs 基线 ±x.xx%) - **Specific test**: xxx%(vs 基线 ±x.xx%) -## 跨轮追踪 +## 跨轮追踪(首次评测省略) | 指标 | 本轮 | 上轮 | 基线 | -|---|---|---|---| -| 需求集合 | | | | -| 持久错误数 | | | — | -| 新引入错误数 | ⚠️ | — | — | -| 修复错误数 ✅ | | — | — | ## 需求集合深度分析 ### 子集表现 -| 子集 | 基线准确率 | 新模型准确率 | 旧对新错 | 旧错新对 | 总数 | 分流错误 | 语义错误 | 变化 | - -> 准确率来自 lark_template.json(含 complex 门禁检查),B/G 数来自 CSV 的纯模型 GSB(不含 complex 门禁),两者口径不同。 +| 子集 | 基线准确率 | 新模型准确率 | 旧对新错 | 旧错新对 | 总数 | 分流错误 | 语义错误 | 距95% gap | ### 失败 case 与训练数据关联 -- <子集>: N 条失败中 X 条**有近邻且 label 一致**、Y 条**无近邻**、Z 条**近邻 mislabeled** ⚠️ → 主要缺口: <分布外补数据 / SFT 学不动 / 训练集错标> -- Reward 对齐(全量): N/N 条 reward 方向与准确率一致 +- <子集>: N 条失败中 X 条有近邻 label 一致、Y 条无近邻、Z 条近邻 mislabeled ⚠️ +- Reward 对齐(全量): N/N 条方向一致 -#### 训练集错标清单(逐条,对应 Z 条 mislabeled) +#### 训练集错标清单(Z>0 时必写) | 失败 case query | 训练样本 file:line | hash | 当前 label | 应改 label | 相似度 | 违反规则 | -|---|---|---|---|---|---|---| -| | augment_.jsonl:42 |

| Agent | CT | 0.93 | R1(单 POI+多形容词) | -> 没有 mislabeled 时这张表省略;有则 Z 条样本自动进入下一轮 Step 4 的修订清单。 - -## 设备维度(只分析车载) +## 设备维度(车载) | 设备 | 基线准确率 | 新模型准确率 | 变化 | 旧对新错 | -全设备参考(准确率来自 lark_template,含 complex 门禁检查): -| 设备 | 基线准确率 | 新模型准确率 | 变化 | 旧对新错(B) | 旧错新对(G) | - -> 注:B/G 数来自 overrall_comparison.csv 的纯模型 GSB(不含 complex 门禁),准确率来自 lark_template.json(含 complex 门禁),两者口径不同。 +> B/G 数来自 CSV 纯模型 GSB(不含 complex 门禁),准确率来自 lark_template(含 complex 门禁),口径不同。 ## Specific Test 旧对新错 Top 子集(不含需求集合) | 子集 | 旧对新错 | 总数 | 率 | 旧错新对 | 相对基线 | -## 问题模式分析 +## 问题模式分析(先需求集合,再其他 Specific Test) -> **分析顺序**:先分析本次需求迭代集合(如 icl_test)的问题,再分析其他 Specific Test 问题。 -> Specific Test 旧对新错 Top 子集表中也应去掉需求集合子集(已在需求集合深度分析中覆盖)。 - -### 一、需求集合问题分析 - -### 二、其他 Specific Test 问题分析 - -### N. <问题标题>(优先级 P0/P1/P2) -**根因归类**: reward / data / 格式 — <一句话依据> -**涉及集合**: -- `data/specific_test_set/xxx/xxx.csv`(B=x/xxx=x.xx%,G=x,相对基线 ±x.xx%) - -**错误分布**: 分流 x / 语义 x(可重叠) +### N. <问题标题>(P0/P1/P2) +**根因归类**: reward / data / 格式 +**涉及集合**: `xxx.csv`(B=x/xxx=x.xx%) **跨轮**: 持久 x / 新引入 x ⚠️ / 修复 x ✅ - -**错误类型**: -- <类型1>(x 条): 描述 -- <类型2>(x 条): 描述 - -**典型 case**(优先 new/regressed,必须包含对话历史): +**典型 case**(必须含对话历史,从 input 的 `[对话历史]` 段提取): ``` -对话历史: - 用户: <之前的对话> - 小爱: <之前的回复> - ...(多轮则列出所有轮次,无历史则写"无") -query: <用户最后一句(当前 query)> -label: xxx -旧: xxx # origin_predict_base -新: xxx # origin_predict_dev(保留 complex= 前缀) -归类: 分流/语义/两者 | 跨轮: persistent/new/regressed -训练集近邻: 有 label 一致(hash=xxx, 相似度 0.xx)/ 无 / ⚠️ mislabeled(hash=xxx, 相似度 0.xx, 当前=Agent / 应改=CT) +对话历史: ...(多轮列出,无则写"无") +query: ... | label: ... | 旧: ... | 新: ... | 归类: 分流/语义 +训练集近邻: 有/无/⚠️ mislabeled(hash, 相似度, 当前→应改) ``` - -> **重要**:对话历史从 input 字段的 `[对话历史]` 段提取,不能省略。很多错误(如短句闲聊被误判为 Agent)只有在多轮上下文中才能理解根因。 - -**结论**: 一句话根因 -**建议**: 一句话改动(对应 2.4 的动作) +**结论+建议**: 一句话 ## 优先级与下一轮假设 | 优先级 | 问题 | B 数 | 根因类 | 建议动作 | - -**下一轮假设候选**(按 ROI 排序): -1. <假设>: 基于本轮发现 ,预期收益 -2. ... ``` -#### 2.7 参考实现 +#### 2.7 参考工具函数 ```python -import re, csv, ast, json, hashlib +import re, ast, json, hashlib from pathlib import Path from collections import defaultdict def extract_query(text: str) -> str: parts = text.rsplit('用户: ', 1) - if len(parts) >= 2: - return parts[1].split('[function]', 1)[0].strip() - m = re.search(r'query:(.+?)(?:\n|context:|function:)', text) - return m.group(1).strip() if m else '<未提取到>' + return parts[1].split('[function]', 1)[0].strip() if len(parts) >= 2 else ( + re.search(r'query:(.+?)(?:\n|context:|function:)', text) or type('',(),{'group':lambda s,n:'<未提取到>'})() + ).group(1).strip() def get_label(row: dict) -> str: - if row.get('label', '').strip(): - return row['label'].strip() + if row.get('label', '').strip(): return row['label'].strip() clb = row.get('code_label_base', '').strip() - if not clb: - return '' + if not clb: return '' try: parsed = ast.literal_eval(clb) - if isinstance(parsed, list) and parsed: - return '\n '.join(parsed) - except (ValueError, SyntaxError): - pass + if isinstance(parsed, list) and parsed: return '\n '.join(parsed) + except (ValueError, SyntaxError): pass return clb.strip("[]'").replace('\\n', '\n ') -def classify(row: dict) -> tuple[bool, bool]: - """返回 (is_triage_err, is_semantic_err) - - is_triage_err 的判定无法用通用代码——agent 先 head 当前 - origin_predict_base / origin_predict_dev 实物,根据真实格式现写 parser - 抽出 base / dev 的「是否复杂」二值判断再比对。 - """ - cpb, cpd = row['cleaned_predict_base'], row['cleaned_predict_dev'] - is_triage = ... # TODO: 见 docstring,按当前评测产出格式现写 - is_semantic = cpb != cpd - return is_triage, is_semantic - def case_hash(query: str, label: str) -> str: return hashlib.md5(f'{query}|||{label}'.encode()).hexdigest()[:16] -def load_error_registry(path='results/error_registry.jsonl') -> dict: - """返回 {case_hash: [runDic where it was wrong]}""" - reg = defaultdict(list) - if Path(path).exists(): - with open(path) as f: - for line in f: - e = json.loads(line) - reg[e['case_hash']].append(e['runDic']) - return reg - def cross_iter_tag(case_h: str, last_runDic: int, registry: dict) -> str: history = registry.get(case_h, []) if last_runDic in history: return 'persistent' - if history: return 'regressed' - return 'new' - -# 主流程 -registry = load_error_registry() -with open('specific_comparison.csv', encoding='utf-8-sig') as f: - b_rows = [r for r in csv.DictReader(f) if r['纯模型GSB'] == 'B'] - -stats = defaultdict(lambda: {'triage': 0, 'semantic': 0, 'total': 0, - 'persistent': 0, 'new': 0, 'regressed': 0}) -for r in b_rows: - sc = r['sub_cate'] - q, l = extract_query(r['input']), get_label(r) - h = case_hash(q, l) - triage, semantic = classify(r) - tag = cross_iter_tag(h, last_runDic=LAST_RUN, registry=registry) - stats[sc]['total'] += 1 - stats[sc]['triage'] += int(triage) - stats[sc]['semantic'] += int(semantic) - stats[sc][tag] += 1 + return 'regressed' if history else 'new' ``` +> `classify(row)` 无法通用——agent 需先 `head` 当前 `origin_predict_*` 实物,按真实格式现写 parser 抽出 complex 二值。 + ### 3. 本轮假设(基于 Step 1 的证据) **只有证据充分时才往下走。** 在开始训练前,写入 `results/iteration_log.jsonl` 的 `hypothesis` 字段: @@ -680,6 +465,11 @@ for r in b_rows: #### 4.0 原始训练数据清洗(增强前必做) +> 🚨 **强制执行:每轮 augment 必须先跑 §4.0→§4.0.1,不得跳过直接做 H2 仿写。** +> 即使 hypothesis 归因为"训练集缺数据",也必须先逐 pattern 检索训练集确认是否存在 mislabel。只有当 §4.0.1 Step A 扫描结果 + Step A.5 label-master 预审后残留候选 = 0 时,才能判定"无需 H1 修改"并跳到 H2。**"这轮只需要加数据"不是跳过 H1 检查的合法理由**——上一轮加的新数据可能引入了新的标签冲突,必须每轮重新检索确认。 +> +> 缺少 H1 检查的 augment 视为不完整:`modified_samples.jsonl` 可以为空(代表确认无需修改),但 `data_clean_.log` 必须记录"H1 扫描完成,0 候选"的结论,否则 §5.0 准入检查拒绝启动 SFT。 + 数据增强不仅仅是加数据,还必须对原数据集中的错误/不一致标签进行清洗,否则新增数据和旧数据矛盾,模型学不好。 **清洗原则(case 驱动,逐类分析)**: @@ -744,30 +534,11 @@ with open(out_csv, 'w', encoding='utf-8-sig') as fp: for c in candidates: w.writerow(list(c)+['','']) ``` -**两阶段 UI 展示约定(强制)**: +**两阶段产物路径**: +- 阶段一(候选):`$AUTORESEARCH_CHAT_ROOT/output/relabel_candidates_.csv` → 前端"分层结果分析"卡 +- 阶段二(确认):`$AUTORESEARCH_CHAT_ROOT/results/data_clean_/modified_samples.jsonl` → 前端"数据增强"卡 -| 阶段 | 产物 | 落盘路径 | 前端卡片 | -|---|---|---|---| -| 阶段一:预计修改候选 | `relabel_candidates_.csv` | `$AUTORESEARCH_CHAT_ROOT/output/`(NFS) | **分层结果分析(dist-analysis)** | -| 阶段二:确认修改最终 | `modified_samples.jsonl` | `$AUTORESEARCH_CHAT_ROOT/results/data_clean_/` | **数据增强(augment)** | - -候选阶段(246 条等量级)的 CSV 必须写到 `output/relabel_candidates_.csv` 让分析卡片可读;确认修改阶段(用户审核后落盘的 135 条)写入 `results/data_clean_/modified_samples.jsonl` 让数据增强卡片可读。**不要混落**——把候选 CSV 写到 `/tmp/` 或 `data_clean_/` 都会让 UI 看不到。 - -> **runDic 命名语义(backend 已对齐)**:augment 类产物(`data_clean_/`、`augment_raw/augment__raw.jsonl`、`zk_intent/augment_.jsonl`、`label_master_review.jsonl`)的 `` = `$SFT_RUNDIC` = **当轮 eval 落盘后 `resolve_run_ids.sh` 取到的 max(已落盘 workflow)**。由于 resolve 是在当轮 eval 的 `lark_template.json` 落盘之后调用的,此时 max 就是当轮 eval 自身的 workflow id。Backend step-detail 端点对 augment 也直接用本轮(`per_round[R{n}]`)的 runDic 去读。其他 step(cml / dist-analysis / hypothesis / gold-drift)的产物同理按本轮自己的 runDic 命名。 -> -> ⚠️ **runDic 不许手算**:augment 落盘、SFT yaml、归档目录、cml workflow run 用的 runDic **必须**从 `scripts/resolve_run_ids.sh` 取,不准 agent 自己 `ls workflow5 | tail -1` 然后心算 +1。规则简单到没必要靠模型: -> -> ```bash -> eval "$(./scripts/resolve_run_ids.sh)" -> # 现在 shell 里有: -> # $SFT_RUNDIC = max(已落盘 workflow) = 当轮 eval 的 workflow id -> # augment 落盘 / yaml / 归档全用这个 -> # $EVAL_RUNDIC = SFT_RUNDIC + 1(仅下一轮 cml workflow run 提交时使用) -> ``` -> -> **写 augment / SFT / 归档脚本时一律用 `$SFT_RUNDIC`,不要写 `${RUNDIC}` 也不要写 `${EVAL_RUNDIC}`**。 -> -> 🚨 **调用时序(CRITICAL)**:`resolve_run_ids.sh` **只能在当轮 CML eval 的 `lark_template.json` 已落盘之后调用**。禁止在 eval 提交前或 watcher 等待期间预先调用——此时 max workflow 仍是上一轮的值,会导致 SFT_RUNDIC 偏移 -1(已踩坑:R0 workflow17823 评测中提前 resolve 得到 17822,augment 文件全部错位)。正确时序:`cml step=complete` → `resolve_run_ids.sh` → augment/SFT。每次进入 augment/SFT 步骤前**必须重新调用**,不许复用之前缓存的值。 +> **runDic 规则**:所有产物的 `` = `$SFT_RUNDIC`(从 `eval "$(./scripts/resolve_run_ids.sh)"` 取)。**只能在当轮 eval `lark_template.json` 落盘后调用**,禁止手算或提前调用。 **Step A.5:label-master 预审(量级判定之前必跑,强制)** @@ -776,9 +547,12 @@ with open(out_csv, 'w', encoding='utf-8-sig') as fp: 具体步骤: 1. 抽出每条候选的 `(file, line, query, old_label, suspected_new_label)` -2. **逐条**调 `Skill(skill="label-master", args=...)`——每次只传**一条** `(query, old_label)`(**不**给 suspected_new_label,避免锚定),让 label-master 按 §决策流程 / §候选召回索引 / §高频混淆边界 输出 → `{verdict: 通过 | 不通过, 推荐标签, 排除理由, 易混淆边界}`。 - - ⚠️ **禁止把多条 query 塞进同一次 Skill 调用**——批量调用会让 label-master 在多条之间相互锚定,分错率显著升高(已踩坑) - - 可在同一轮 assistant response 里并发多次 Skill 调用(建议 ≤ 8 路并发)以提升吞吐 +2. **用子 agent 调用 label-master**——**禁止在主 agent 上下文里直接调 Skill(skill="label-master")**,避免大量输出污染主 agent 上下文。做法: + - 主 agent 把待审列表写入 jsonl 文件(如 `scratchpad/lm_input.jsonl`,每行 `{query, old_label}`) + - 起子 agent:`delegate_agent(prompt="读取 /scratchpad/lm_input.jsonl,对每条逐一调用 Skill(skill='label-master', args='query: ... label: ...'),把结果逐行 append 到 /scratchpad/lm_output.jsonl(每行 {query, verdict, 推荐标签, 理由})。注意:每次 Skill 调用只传一条 query,禁止批量。", allow_shell=true)` + - 子 agent 完成后主 agent 读取 `lm_output.jsonl` 汇总结果 + - ⚠️ **禁止把多条 query 塞进同一次 Skill 调用**——批量调用会让 label-master 在多条之间相互锚定(已踩坑) + - ⚠️ 子 agent prompt 里必须给出**完整的 workspace 绝对路径**(`$AUTORESEARCH_CHAT_ROOT/scratchpad/...`),因为子 agent 没有父的环境变量 3. 把 label-master 的"推荐标签"**回写到** `relabel_candidates_.csv` 覆盖原 `建议新label` 列;新增列 `verdict`、`label_master_理由`,便于回查 4. 收尾时按以下规则筛 candidate list(**残留候选 = 真正进入 Step B 的列表**): - `推荐标签 == old_label`:label-master 不认同要改 → 从候选里**剔除**(这条原 label 可能本来就是对的) @@ -805,7 +579,13 @@ done 同时落归档到 `results/data_clean_/`: - `deleted_samples.jsonl`:被删样本原文 -- `modified_samples.jsonl`:被改样本(含 before/after output) +- `modified_samples.jsonl`:被改样本,**必须包含以下字段**: + - `line_idx`:原文件行号 + - `file`:原文件路径 + - `query`:**完整 query**(用 `extract_query(instruction)` 从原始 instruction 提取,**禁止截断**) + - `old_output`:修改前 output + - `new_output`:修改后 output + - 可选:`instruction_query_excerpt`(仅供人工快速浏览,允许截断,但**不得作为 §4.5 复核的 query 来源**) - `data_clean_.log`:摘要 + 影响 pattern + 样本数 **Step D:修改执行(按文件批量,避免重复读写)** @@ -828,43 +608,11 @@ for f, edits in edits_by_file.items(): with open(f, 'w') as fp: fp.writelines(rows) ``` -**Step E:修改后回归验证(强制)** +**Step E:修改后验证** — `prepare_and_train_sft.py prepare` 验数量 + 格式校验 4.1.1 + 逐条目视确认 -改完不能直接训: -1. **数据 sanity**:`prepare_and_train_sft.py prepare`,对比合并后总数(旧总数 - 删除数 = 新总数) -2. **全量人审**:所有改动条目逐条过一遍,看 query/label/instruction 符合预期 -3. **格式校验**:用 4.1.1 字段约束扫一遍改后 output +**Step F:删除 vs 修改** — 标签错但 query 有价值→修改;query 噪声重/同 pattern 已充足/跨子集 gold 矛盾→删除 -**Step F:删除 vs 修改的选择** - -| 情况 | 删除 | 修改 | -|---|---|---| -| 标签错但 query 有价值 | | ✓ | -| 标签错且 query 噪声重(ASR 含糊) | ✓ | | -| 同 pattern 已有大量正确样本 | ✓ | | -| 同 pattern 仅此一条 | | ✓(保留多样性) | -| query 跨多子集 gold 矛盾 | ✓(避免训练矛盾信号) | | - -**Step G:多轮 .bak 累积管理** - -每轮 `.before_r{N}.bak` 至少保留 5 轮: - -``` -ai-planning/data/train_set/zk_intent/ -├── augment_17729.jsonl # 当前 -├── augment_17729.jsonl.before_r24.bak # R24 改前 -├── augment_17729.jsonl.before_r25.bak # R25 改前 -└── augment_17729.jsonl.before_r28.bak # R28 改前 -``` - -回退:`cp augment_17729.jsonl.before_r{N}.bak augment_17729.jsonl` - -**反模式**(违反必查): -- ❌ `sed -i 's/ComplexTask/Agent/g'` 全文替换 -- ❌ 没备份就改文件 -- ❌ >50 条改动跳过人审 -- ❌ 修改后不验证直接训练 -- ❌ backup 文件命名含 `_valid` / `_train`(会被 prepare 脚本误读) +**Step G:.bak 管理** — 每轮 `.before_r{N}.bak` 保留≥5 轮;回退:`cp xxx.before_r{N}.bak xxx`;禁止 bak 文件含 `_valid`/`_train`(会被 prepare 误读) #### 4.1 输入 @@ -1106,122 +854,22 @@ def check_label_rules(query: str, label: str, history: list = None) -> tuple[boo 超阈值必须人介入定边界,不允许自动放行。 -#### 4.3.2 从评测集自动归纳新规则(替代旧版"新规则不明确"信号) +#### 4.3.2 从评测集自动归纳新规则 -**核心观察**:测试集 CSV 已含 gold (`code_label` / `complex`),所谓"新规则"不是 gold 不明确,而是**模型尚未学到测试集已存在的 gold 规律**。这种情况自动归纳即可,不该让人。 +测试集 CSV 已含 gold(`code_label`/`complex`),规则从错例自动归纳,不需人定。 -##### 触发时机 +**触发**:每轮 Step 1 后,对未被已知规则覆盖的错例做归纳。 -每轮 Step 1 错例分析后,对**所有未被 R1~R{N} 已知规则覆盖的错例**做自动归纳。 +**接受条件**(两个同时满足): +- `c1 ≥ 0.80`:同 pattern 错例内 gold 主流占比(防错例巧合) +- `c2 ≥ 0.85`:候选正则扫全测试集后主流 gold 占比(防过拟合错例) +- 两者推出的主流 gold 必须一致,且不与现有规则重叠 >70% -##### Confidence 的定义(两个独立指标,必须同时满足) +**归纳流程**:同 pattern 错例 ≥3 条 → 提取公共正则 → 算 c1/c2 → 过阈值则自动追加到 `results/label_rules.md`,下一轮 4.3.1 立即生效,不需人审。 -新规则候选必须通过**两层置信度检查**: - -**c1 = 错例内一致性**:候选 pattern 在错例中 gold 标注的主流占比 - -``` -c1 = max_gold_count(同 pattern 错例) / 总错例数 -``` - -例:5 条同 pattern 错例,4 条 gold=CT、1 条 gold=Agent → c1 = 4/5 = **0.8** - -**c2 = 全测试集验证准确率**:把候选正则扫**整个测试集**(含对例 + 错例),看主流 gold 占比 - -``` -c2 = 主流 gold 数 / 命中正则的全部测试 case -``` - -例:扫整个测试集,符合该 pattern 共 12 条(4 错 + 8 对),9 条 gold=CT → c2 = 9/12 = **0.75** - -**接受条件**:`c1 ≥ 0.80 AND c2 ≥ 0.85` - -为什么用两个? -- c1 防止"错例巧合":3 条错例都说 CT 但全测试集大多数对例其实是 Agent → c2 拦下 -- c2 防止"过拟合错例":仅看错例可能学到模型当前的偏差而不是真规则 -- 两个指标同时高才说明规则真实存在 - -为什么是 0.80 / 0.85 不是 1.0? -- gold 标注本身有 ~5-10% 噪声(标注员失误 + 边界 case) -- 如果要求 100%,会丢失大部分有效规则 -- 0.85 是经验阈值(参考 R28 实测:状态描述句规则 c2 ≈ 0.92,找+模糊 c2 ≈ 0.55 被拦下) - -##### 自动归纳代码 - -```python -import re -from collections import Counter - -def induce_rule(cluster_cases, all_test_rows, existing_rules): - """ - cluster_cases: 同 pattern 的错例(≥3 条) - all_test_rows: 所有测试集 rows(含对+错) - existing_rules: 已有 R1~R{N} - 返回: 候选规则 dict 或 None - """ - if len(cluster_cases) < 3: return None - - # c1: 错例内一致性 - err_gold_dist = Counter((c['gold_complex'], c['gold_tag']) for c in cluster_cases) - majority_gold, majority_count = err_gold_dist.most_common(1)[0] - c1 = majority_count / len(cluster_cases) - if c1 < 0.80: return None - - # 提取候选正则 - regex = extract_common_regex(cluster_cases) - - # c2: 全测试集验证 - matched = [r for r in all_test_rows if re.search(regex, r['query'])] - if len(matched) < 5: return None # 命中样本太少不足以判定 - full_gold_dist = Counter((r['complex_norm'], r['code_tag']) for r in matched) - full_majority, full_count = full_gold_dist.most_common(1)[0] - c2 = full_count / len(matched) - if c2 < 0.85: return None - - # 主流 gold 必须一致(c1 和 c2 推出的 gold 不能矛盾) - if majority_gold != full_majority: return None - - # 排除:是否已被现有规则覆盖 - for rule in existing_rules: - if rule.regex_overlap(regex) > 0.7: return None - - return { - 'regex': regex, - 'gold': majority_gold, - 'c1': c1, # 错例一致性 - 'c2': c2, # 全集验证 - 'support_err': len(cluster_cases), - 'support_total': len(matched), - 'anchor_cases': cluster_cases[:3], - } -``` - -##### 归纳后自动追加到规则库 - -候选规则 c1 ≥ 0.80 + c2 ≥ 0.85 + 主流 gold 一致 + 不与现有规则冲突 → 自动追加到 `results/label_rules.md` + `check_label_rules()`: - -```markdown -### R{N} 自动归纳({runDic} 轮) -**规则**:含 pattern `{regex}` → {gold} -**置信度**:c1={c1:.2f}(错例 {support_err}/{support_err_total} 一致), c2={c2:.2f}(全测试集 {full_majority}/{support_total} 一致) -**锚定 case**(列出该 pattern 在测试集命中的全部 case,不抽样): -- {case1} -- {case2} -- {case3} -- ...(共 {N} 条) -**自动检查正则**:`{regex}` -``` - -新规则立即在下一轮 4.3.1 检查中生效,**不需要人审**。 - -##### 什么时候自动归纳失败 → 才触发人介入 - -仅当以下情况自动归纳算法**得不出结论**时,才转 H-i-T-L 信号: - -- **触发信号 #2**:同结构 query 在 ≥2 个测试子集 gold 不同(自动归纳得到的规则会矛盾) -- **触发信号 #6**:同 pattern 错例 < 3 条(样本不足无法归纳)但持续多轮出现 → 让人决定是否手动定规则 - -> 这就是为什么删除了旧的"新规则不明确"信号——**99% 情况自动归纳就能解决**,1% 真矛盾的情况已被信号 #2/#6 覆盖。 +**归纳失败 → HiTL**: +- 信号 #2:同结构 query 在 ≥2 子集 gold 矛盾 +- 信号 #6:同 pattern 错例 <3 条但多轮持续出现 #### 4.4 写入训练目录 @@ -1246,76 +894,49 @@ JSONL 行里的 `sub_cate` 字段仅用于内部路由/去重,归档时丢弃 #### 4.5 label-master 标签复核(落盘后、SFT 前,**强制**) -任何写入训练目录的修改/新增样本都必须经 [label-master skill](../../label-master/SKILL.md) 复核:格式 + 语义。**没过这关不许进 Step 5。** +| 文件 | 层 1(格式) | 层 2(语义,`Skill(skill="label-master")`) | +|---|---|---| +| H1 `modified_samples.jsonl` | ✅ `validate_label_output.py --field output_after` | ✅ 逐条调 label-master(1:1 全量)— §4.0.1 A.5 是粗筛,**不能替代落盘前的 layer2** | +| H2 `augment_.jsonl` | ✅ `validate_label_output.py --field output` | ✅ 逐条调 label-master(1:1 全量) | -**复核范围**(两份必须过格式层;语义层只对 H2): +⛔ **层 2 禁止用正则/规则脚本替代**,必须通过**子 agent** 调 `Skill(skill="label-master")`(`repeatable: true`)。每次只传一条 `(query, label)`,禁止批量。**禁止主 agent 直接调用 label-master**——上下文污染会导致主 agent 后续推理质量下降。子 agent 通过文件交换结果(主 agent 写 input jsonl → 子 agent 读取并逐条调 Skill → 写 output jsonl → 主 agent 读取汇总),见 §4.0.1 Step A.5 的详细做法。 -| 文件 | 来源 | 层 1(格式) | 层 2(语义,Skill 调 label-master) | -|---|---|---|---| -| `$AUTORESEARCH_CHAT_ROOT/results/data_clean_/modified_samples.jsonl` | H1 改标(complex 翻转 / tag 改写等) | ✅ 必跑 | ❌ 已在 §4.0.1 Step A.5 完成,**不再重跑**(label-master 不会推翻自己的推荐,重跑只是浪费 token) | -| `$AUTORESEARCH_CHAT_ROOT/ai-planning/data/train_set/zk_intent/augment_.jsonl` | H2 仿写(新增训练样本,未经过 §4.0.1) | ✅ 必跑 | ✅ 必跑 | +⚠️ **层 2 必须校验完整 label(含 tag),不能只校验 complex 维度**。 -**层 1:格式校验(确定性,自动跑)** - -```bash -cd /home/mi/zk-data-agent-wsh # 或 wherever skills 包在的项目根 -# H1 改标 -python skills/label-master/scripts/validate_label_output.py \ - --file "$AUTORESEARCH_CHAT_ROOT/results/data_clean_/modified_samples.jsonl" \ - --field output_after -# H2 仿写 -python skills/label-master/scripts/validate_label_output.py \ - --file "$AUTORESEARCH_CHAT_ROOT/ai-planning/data/train_set/zk_intent/augment_.jsonl" \ - --field output +子 agent 调用 label-master 时的 args 格式:`query: <完整query> label: <完整output>`,例如: +``` +Skill(skill="label-master", args="query: 顺路再去个加油站 label: Agent(tag=\"地图导航\")") ``` -任一行 fail(tag 不存在 / Agent 包装错 / function 引用错)→ **必须修,不许直接跳过**。修完原地重跑直到全过。 +label-master 会按其决策流程判断该 query 的 **tag 归属**是否正确(如应该是"地图导航"还是"充电加油"),同时判断 **complex**(Agent vs ComplexTask)和**输出格式**。 -**层 2:语义复核(Skill 调用 label-master Agent,仅 H2)** +**禁止只传 complex=true/false 让 label-master 做二分类**——这不是 label-master 的设计用途。必须传完整的 `Agent(tag="xxx")` 或 `ComplexTask(tag="xxx")`,让 label-master 走完整的"候选召回→标签卡片→边界判定→推荐标签"流程。 -⛔ **禁止用正则/规则脚本替代 LLM 语义判断**。层 2 不是格式校验,是"该 query 在 label-master 知识体系下是否应该标成这个 label"的语义问题。**唯一合法实现方式是调用 `Skill(skill="label-master")`**(label-master 已标记 `repeatable: true`,可在同一 run 内多次调用)。已踩坑:R1 agent 写了个检查 hist 里有没有导航触发词的正则脚本充当"layer 2",完全没走语义判断,导致错误样本全量混入训练集。 +label-master 的 verdict 必须同时覆盖: +1. **tag 是否正确**:label-master 推荐的 tag 与当前 label 中的 tag 是否一致,不一致则 verdict=不通过 +2. **complex 是否正确**:Agent vs ComplexTask 是否正确 +3. **输出格式是否合规**:`Agent(tag="xxx")` / `ComplexTask(tag="xxx")` 格式是否规范 -H1 在 §4.0.1 Step A.5 已经过 label-master 推荐覆盖,这里**不再重跑**。H2 仿写是 §4.2 GPT 新增的样本,没经过 Step A.5,必须在此补一次语义判定: +`label_master_review.jsonl` 每行必须包含字段:`query`、`label`(完整 output,如 `Agent(tag="地图导航")`)、`verdict`(通过/不通过)、`recommended_label`(label-master 推荐的完整 output)、`reason`(判断依据,需说明 tag 判定理由)。 -🚨 **覆盖率硬规则**:`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 退回。 +**以下情况视为不合格 review,§5.0 准入检查拒绝启动 SFT**: +- review 行中缺少 `label` 或 `recommended_label` 字段 +- `label` 字段只含 complex=true/false 而非完整 output +- `reason` 中只提及 complex 判定而未提及 tag 归属判断 -1. 逐行读 `augment_.jsonl` **全部** `(query, output)` 对(N 条就是 N 条,不省略、不抽样) -2. **逐条**调 `Skill(skill="label-master", args=...)`——每次只传**一条** `(query, 当前 label)`: - - 让 label-master 按 §决策流程 / §候选召回索引 / §高频混淆边界 判定该条 - - 输出格式:`{verdict: 通过 | 不通过, 推荐标签, 排除理由, 易混淆边界}` - - ⚠️ **禁止把多条 query 塞进同一次 Skill 调用**——批量调用会让 label-master 在多条之间相互锚定,分错率显著升高(已踩坑) - - 可在同一轮 assistant response 里并发多次 Skill 调用(建议 ≤ 8 路并发)以提升吞吐 -3. 收集 verdict,写入 `$AUTORESEARCH_CHAT_ROOT/results/data_clean_/label_master_review.jsonl`,每行一条 verdict(只含 H2 条目) +⚠️ **H1 query 来源**:review 时传给 label-master 的 `query` **必须**取自 `modified_samples.jsonl` 的 `query` 字段(Step C 已要求写入完整 query)。**禁止**从 `instruction_query_excerpt` 提取——该字段可能被截断导致 `extract_query` 返回空值。如果 `query` 字段缺失(旧格式兼容),必须用 `line_idx` + `file` 回原始训练文件读取完整 instruction 再 `extract_query`。 -**verdict 处置规则(自动)**: - -| 比例 | 处置 | -|---|---| -| 不通过 = 0 | ✅ 全量通过,允许进入 Step 5 | -| 不通过 > 0 | ❌ **必须修正**(H2 → 修正 augment 样本后重新 review;H1 → 回滚或修正后重新 review),循环直到不通过 = 0 才能进 Step 5。禁止"自动丢弃不通过样本继续"——每条都必须过 | - -**写完复核报告才允许进 Step 5。**verdict 文件不存在 / 没跑层 2 → Step 5 拒绝启动(5.0 的准入检查会查 `label_master_review.jsonl` 存在)。 - -🚨 **注意**:label-master 是"知识 + Agent 推理"型 Skill,不是黑盒分类器。每一次调用都必须输出 `verdict + 推荐标签 + 排除理由` 三件套,便于人类回查;严禁要求它返回 1/0 标量。(批量调用本身已在第 2 步禁掉,这里再强调一次输出 schema。) +**覆盖率硬规则**:`label_master_review.jsonl` H2 行数 = `augment_.jsonl` 行数(§5.0 `wc -l` 断言会拦)。不通过 > 0 则必须修正后重新 review 直到全 pass。verdict 文件不存在 → Step 5 拒绝启动。 ### 5. SFT 训练 -🚨 **Step 4 → Step 5 硬连接(NEVER STOP,反惯性反复强调)** - -写完 `{"step":"augment","status":"complete",...}` 那一行**之后**,**同一轮 bash 不许结束、不许写"Step 4 完成"简报、不许等回调**。必须**紧接着**: - -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.sh [PREV_RUNDIC]` 并立刻挂 watcher(§5.2)。训练 `_SUCCESS` 落盘后的下一轮 step 单独用 `bash scripts/submit_cml_eval.sh ` 起评测,**不要**把评测串到 SFT bg 任务里——跨 step bg 是禁区 - -**反模式**(被反复踩坑,永远禁止): - -- ❌ 写完 `augment=complete` → 写一份漂亮的"Step 4 完成"进度简报 → turn 结束 → 等下一次触发 -- ❌ "augment 已完成,下一步等回调后续做 SFT" — 不存在等回调,augment 完成的那一刻就是 SFT 启动的那一刻 -- ❌ 把 H2 后台任务的 stdout 当成"用户消息"处理完就 turn 结束 — bg 任务回调不是 turn 结束信号,augment=complete 也不是 - -**模型本能会想停("任务段落感"),SKILL NEVER STOP 压不住这个本能**——所以这里写成硬命令清单,照清单第一条到第三条机械执行,做完才允许 turn 结束。 +🚨 **Step 4 → Step 5 硬连接**:`augment=complete` 后同一轮**紧接着**:① 写 `sft=running` ② §4.5 复核 ③ 复核全过 → `submit_sft.sh` + 挂 watcher。禁止 turn 结束、禁止写简报、禁止等回调。评测在 SFT `_SUCCESS` 落盘后的下一轮单独用 `submit_cml_eval.sh` 起,不串进 SFT bg。 +⚠️ **写 `augment=complete` 时必须附带 `"count"` 字段**,值为本轮最终写入 `augment_.jsonl` 的样本行数(经 dedup + label-master 过滤后的实际数)。示例: +```jsonl +{"step":"augment","status":"complete","run_id":"R5","count":49,"ts":"2026-05-26T21:51:32+08:00"} +``` +Pipeline panel 用此字段展示增强条数;缺失则只显示文件名。 使用 `prepare_and_train_sft.py` 完成数据组装和训练。 @@ -1346,6 +967,10 @@ if [ -f "$AUG" ]; then fi fi +# 1c. H1 检查日志必须存在(§4.0 强制要求,即使无修改也要记录扫描结论) +CLEAN_LOG="$AUTORESEARCH_CHAT_ROOT/results/data_clean_${RUNDIC}/data_clean_${RUNDIC}.log" +[ -f "$CLEAN_LOG" ] || { echo "data_clean 日志不存在,说明 §4.0 H1 检查未执行,回 §4.0"; exit 1; } + # 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 @@ -1388,93 +1013,19 @@ cd "$AUTORESEARCH_CHAT_ROOT" - ❌ `--model_path $AUTORESEARCH_CHAT_ROOT/sft_output_r17776`(从历史轮 ckpt) - ❌ 任何形式的 "增量 SFT"(除非显式声明是为了验证"从 X ckpt 继续是否更好"的对照实验,且单次性,log 要明确标记) -#### 5.0.1 训练产物备份(R23-R28 经验沉淀) - -**训练前强制**: +#### 5.0.1 训练产物备份 ```bash -# 上一轮 sft_output 改名归档(保留至少 5 轮,方便快速回退评测) [ -d sft_output ] && mv sft_output sft_output_r${PREV_RUNDIC} +rm -f "$AUTORESEARCH_CHAT_ROOT/sft_output/_SUCCESS" # 防遗留误报 ``` -R23-R28 期间累积了 `sft_output_r23` ~ `sft_output_r28`,多次需要回退到上一轮模型重评(如 gold drift 验证、副作用对照)。**没备份就丢失了一切对照能力。** +**SFT watcher 流程**(`submit_sft.sh` 提交后): +1. 写 program-state `sft=running` + watch 声明(target = `sft_output/_SUCCESS`) +2. 用 `bash(run_in_background=true, wait_for_completion=true)` 等 `_SUCCESS` 落盘 +3. Watcher fire / bg 返回后**下一轮**:写 `sft=complete` → 写 `cml=running` → 调 `submit_cml_eval.sh` 起评测 + 挂评测 watcher -**自动评测 watcher**: - -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` 在脚本内嵌 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**(一个都不能少): - -**⚠️ 先 `rm -f sft_output/_SUCCESS` 再挂 Watcher 1**(R17777 经验:上一轮遗留的 `_SUCCESS` 会让 watcher 瞬间误报 train done)。cml 容器内 `imageCommand` 的 `rm -rf sft_output` 要等到 `state=deploying→running` 之后才执行,若 watcher 只靠 `_SUCCESS` 存在与否判断,会在 deploy 阶段直接触发。 - -```bash -# 预清理:必须在挂 watcher 之前执行 -rm -f "$AUTORESEARCH_CHAT_ROOT/sft_output/_SUCCESS" - -# Watcher 1:训练阶段 —— cml state 为主判据,_SUCCESS 辅助 -# 注意:heredoc 用 'EOF' 防止外层 shell 展开,$AUTORESEARCH_CHAT_ROOT -# 在 watcher 子进程里运行时再展开(远端 bash 会自动注入这个 env)。 -cat > /tmp/wait_train_.sh <<'EOF' -#!/bin/bash -JOB_ID= -SUCCESS="$AUTORESEARCH_CHAT_ROOT/sft_output/_SUCCESS" -export PATH=$HOME/.cloudml-cli/bin:$PATH -while true; do - sleep 60 - STATE=$(cml custom_train describe "$JOB_ID" 2>/dev/null | grep -oE '"state": "[a-z]+"' | head -1 | sed 's/.*: "//;s/"//') - case "$STATE" in - succeed) - # state=succeed 后再校验 _SUCCESS 文件(有时 flush 慢,再等 30s) - [ -f "$SUCCESS" ] && { echo "TRAIN_DONE"; exit 0; } - sleep 30; [ -f "$SUCCESS" ] && { echo "TRAIN_DONE (delayed _SUCCESS)"; exit 0; } - echo "TRAIN_DONE (state=succeed, _SUCCESS missing)"; exit 0 ;; - failed|killed) echo "TRAIN_$STATE"; exit 1 ;; - running|deploying|queued|pending|"") ;; # 继续等 - esac -done -EOF - -# Watcher 2:评测阶段 —— 等 workflow/metric_diff/lark_template.json 出现 -cat > /tmp/wait_eval_.sh <<'EOF' -TARGET=/mnt/xiaoai-zk-model-train-tj5/workflow5/workflow/metric_diff/lark_template.json -while [ ! -f "$TARGET" ]; do sleep 120; done -echo "EVAL_DONE" -EOF -``` - -两个 task 都要 `run_in_background: true`。Claude 会在两个事件各触发一次 task-notification: -- Watcher 1 触发 → 进入"训练完成,评测已自动提交,等评测"阶段(通常不需要动作,报告进度即可) -- Watcher 2 触发 → 进入 Step 1/2 分析报告阶段 - -**反模式**: -- ❌ 只挂 Watcher 2(R17775 犯的错) —— 训练完到评测完之间的窗口期完全失联 -- ❌ 依赖 `tail -f /tmp/r_logs/cml_watcher.log` —— Claude 不会主动 tail -- ❌ 依赖用户看到别的信号来触发 —— 违反 "NEVER STOP" 自主循环原则 - -旧版 nohup 路径下的 watcher 模板(仅在 5.2 例外情况使用): - -```bash -#!/bin/bash -# /tmp/auto_eval_after_train.sh —— 等本地训练 PID 结束 → 自动起 cml workflow -TRAIN_PID=$1 -RUN_DIC=$2 -MODEL_NEW=$AUTORESEARCH_CHAT_ROOT/sft_output -MODEL_OLD=<基线模型> - -while kill -0 $TRAIN_PID 2>/dev/null; do sleep 60; done -sleep 30 # 等 checkpoint flush -[ ! -f "$MODEL_NEW/config.json" ] && exit 1 - -source ~/.cloudml-cli/.profile -cml workflow run --workflow_id f-... --version v28 \ - --global_inputs runDic=$RUN_DIC \ - --global_inputs model_path_new=$MODEL_NEW \ - --global_inputs model_path_old=$MODEL_OLD -``` +⚠️ 训练和评测**不串接**——`submit_sft.sh` 只提 SFT,评测在 `_SUCCESS` 后新一轮单独起。 #### 5.1 数据组装 @@ -1508,95 +1059,33 @@ python prepare_and_train_sft.py prepare --regen_from_csv --output_dir ./sft_data 必须展示:(1) 参与训练的所有文件及行数 (2) 被排除的旧轮文件及排除原因(如果有)。 -#### 5.2 启动训练(cml 任务,R29 起统一走这条路径) - -R23-R28 期间用本地 `nohup python3 prepare_and_train_sft.py train ...` 启动,会因登出/网络/会话退出而中断,并占用本地工作机 8 卡 GPU。**R29 起统一改为 cml custom_train submit 提交训练任务**,由 CloudML 调度到 `bj-nlp` 队列的 h20-96g 8 卡,本地零占用。 - -##### 一键提交脚本 - -⚠️ **runDic 不许手敲**:先 `eval` 一下 `resolve_run_ids.sh` 拿到这一轮的 `$SFT_RUNDIC` / `$EVAL_RUNDIC`,再原样传给两个独立脚本。R2 踩过的坑就是手算 runDic 时把 SFT yaml 的命名也跟着 +1,导致 `data_clean_/` 跳过了 R1 命名空间。 - -提交流程拆成两步(**两个独立脚本,两个 step**): +#### 5.2 启动训练(cml custom_train) ```bash cd "$AUTORESEARCH_CHAT_ROOT" -eval "$(./scripts/resolve_run_ids.sh)" # 解析出 $SFT_RUNDIC / $EVAL_RUNDIC - -# Step 5:提交 SFT 训练(不附带任何评测逻辑) -./scripts/submit_sft.sh "$SFT_RUNDIC" - -# 训练 _SUCCESS 落盘后的下一轮 step 才起评测: -./scripts/submit_cml_eval.sh "$EVAL_RUNDIC" +eval "$(./scripts/resolve_run_ids.sh)" +./scripts/submit_sft.sh "$SFT_RUNDIC" # 提交 SFT,立刻退出 +# _SUCCESS 落盘后下一轮: +./scripts/submit_cml_eval.sh "$EVAL_RUNDIC" # 起评测 ``` -`submit_sft.sh [PREV_RUNDIC]`: +**`submit_sft.sh`**:渲染 `sft_train_job.yaml.tpl`(替换 `{RUNDIC}`)→ `cml custom_train submit` → 打印 JobID → 退出。 +**`submit_cml_eval.sh`**:从 `config.yaml` 读 workflow_id/version → `cml workflow run` 起评测,runDic=EVAL_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**——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`) - -| 字段 | 值 | 说明 | -|---|---|---| -| `imageUrl` | `micr.cloud.mioffice.cn/wsw/large-lm:1.0.15-2` | zk_trainer 默认训练镜像(torch 2.6 + accelerate 1.7.0) | -| `queueId` | `6052` (bj-nlp) | h20-96g 资源池 | -| `resourceName` | `cloudml.ng2h20-8-8.20-199` | 8 卡 H20 96G | -| `juiceFsMountConfigs` | wangsenhao + xiaoai-zk-model-train-tj5 | 训练数据 / 产出路径 | -| `imageCommand` | 内嵌 prepare → train → mark _SUCCESS | 训练成功才落标记 | -| `retryConfig` | enableRetry: true, NodeFailure | 节点级故障自动重试 2 次 | -| `alertConfig` | FAILED + SUCCEED 飞书 P2 告警 | 异常立即知道 | - -##### 监控命令 - -```bash -# 查任务实时状态 -cml custom_train describe - -# 查训练实时日志(含 accelerate / trainer 输出) -cml custom_train logs --follow - -# 停任务 -cml custom_train kill - -# SFT 不再内嵌 watcher,自动评测改成 agent watcher → 单独调 submit_cml_eval.sh -``` - -##### 与 nohup 路径的对比 - -| 维度 | nohup(旧) | cml submit(新) | -|---|---|---| -| 本地 GPU 占用 | 8 卡满载 | 0 | -| 网络/会话中断容忍 | 中断即停 | 任务持续跑 | -| 节点故障重试 | 无 | 自动 2 次 | -| 失败告警 | 无 | 飞书 P2 | -| 任务历史 | 仅本地 log | cml 平台可查 | -| 启动复杂度 | 单行 nohup | submit_sft.sh + watcher + submit_cml_eval.sh | - -##### 何时仍用 nohup 本地训 - -只有以下情况例外使用本地训: -- cml 队列资源排队 > 30min(紧急复现验证) -- 调试新训练逻辑(频繁改代码) -- 实验性超小规模训练(< 1000 条数据,< 2 epoch) - -其余一律走 cml。 - -##### 兼容旧脚本 - -`prepare_and_train_sft.py` 不动,cml 任务的 `imageCommand` 内部仍调用它。`train` 子命令本身只负责训练,需先执行 5.1 组装数据。配置见 5.0。 +监控:`cml custom_train describe/logs/kill ` ### 6. 记录结果 每轮写入 `results/iteration_log.jsonl` 一行,schema: +> 🚨 **`results` 字段必须包含以下固定指标(每轮都要,不可遗漏)**: +> - `specific_test`:Specific Test 准确率 +> - `dapan_car`:大盘车载准确率 +> - `target_subset`:目标集合准确率(= 当前需求子集) +> - `icl_test`:ICL Test 准确率 +> +> 这些指标从 `lark_template.json` 的 metric_diff 中提取。如果某个指标在评测结果中确实不存在(如首次评测缺少某子集),写 `null` 而不是省略 key——前端 metrics 折线图依赖每轮 key 的一致性,缺 key 会导致数据点丢失。 + ```json { "iteration": 12, @@ -1609,8 +1098,9 @@ cml custom_train kill }, "prediction": {"req_set_car": 93.0, "triage_err_rate": 0.15}, "results": { - "req_set_car": 92.1, "dapan_car": 96.30, - "specific_test": 95.10, "triage_err_rate": 0.18 + "specific_test": 95.10, "dapan_car": 96.30, + "target_subset": 92.1, "icl_test": 86.11, + "triage_err_rate": 0.18 }, "verdict": "partial", "root_cause_findings": [ diff --git a/skills/model-iteration/scripts/submit_sft.sh b/skills/model-iteration/scripts/submit_sft.sh index 697669f..561eba4 100755 --- a/skills/model-iteration/scripts/submit_sft.sh +++ b/skills/model-iteration/scripts/submit_sft.sh @@ -19,7 +19,9 @@ 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} +# chat-isolated 时代 AUTORESEARCH_ROOT == AUTORESEARCH_CHAT_ROOT;优先用 chat +# root,避免 agent 没 export AUTORESEARCH_ROOT 时回退到全局共享路径(已废弃)。 +ROOT=${AUTORESEARCH_ROOT:-${AUTORESEARCH_CHAT_ROOT:-$(dirname "$SCRIPT_DIR")}} TPL=${SFT_TRAIN_JOB_TEMPLATE:-$SCRIPT_DIR/sft_train_job.yaml.tpl} YAML=/tmp/sft_train_job_r${SFT_RUNDIC}.yaml diff --git a/src/agent_runtime.py b/src/agent_runtime.py index 7db63f8..62aafbf 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -154,6 +154,119 @@ def _prepend_runtime_context(prompt: str, runtime_context: str | None) -> str: ) +def _log_child_skill_calls( + child_result: 'AgentRunResult', + child_agent: 'LocalCodingAgent', + subtask_label: str, +) -> None: + """Extract Skill tool calls from a child agent's transcript and write to a log file. + + Logs each Skill invocation with the args sent and the agent's response, + so we can audit whether sub-agents actually called skills like label-master. + """ + from .agent_types import AgentRunResult # noqa: F811 + + transcript = child_result.transcript + if not transcript: + return + log_dir = child_agent.runtime_config.session_directory / '_subagent_logs' + try: + log_dir.mkdir(parents=True, exist_ok=True) + except OSError: + return + session_id = child_result.session_id or 'unknown' + log_path = log_dir / f'{session_id}.jsonl' + + skill_calls: list[dict[str, object]] = [] + # Build a map of tool_call_id -> skill args from assistant messages with tool_calls + pending_skills: dict[str, dict[str, str]] = {} + for entry in transcript: + if not isinstance(entry, dict): + continue + role = entry.get('role') + if role == 'assistant': + tool_calls = entry.get('tool_calls') + if isinstance(tool_calls, list): + for tc in tool_calls: + if not isinstance(tc, dict): + continue + fn = tc.get('function') or {} + if not isinstance(fn, dict): + continue + if fn.get('name') == 'Skill': + tc_id = tc.get('id', '') + raw_args = fn.get('arguments', '{}') + try: + args_dict = json.loads(raw_args) if isinstance(raw_args, str) else raw_args + except (json.JSONDecodeError, TypeError): + args_dict = {'raw': raw_args} + pending_skills[tc_id] = { + 'skill': args_dict.get('skill', ''), + 'args': args_dict.get('args', ''), + } + elif role == 'tool': + tc_id = entry.get('tool_call_id', '') + if tc_id in pending_skills: + skill_info = pending_skills.pop(tc_id) + skill_calls.append({ + 'skill': skill_info['skill'], + 'args': skill_info['args'], + 'prompt_injected': entry.get('content') or '', + 'ts': datetime.now(timezone.utc).isoformat(), + }) + + # Also capture the assistant responses that follow skill injections + # by looking at assistant messages after tool messages + assistant_after_skill: list[str] = [] + saw_skill_tool = False + for entry in transcript: + if not isinstance(entry, dict): + continue + role = entry.get('role') + if role == 'tool' and entry.get('tool_call_id', '') in [ + tc.get('id', '') for tc in _all_skill_tool_call_ids(transcript) + ]: + saw_skill_tool = True + elif role == 'assistant' and saw_skill_tool: + content = entry.get('content', '') + if content: + assistant_after_skill.append(content) + saw_skill_tool = False + + # Merge assistant responses into skill_calls + for i, response_text in enumerate(assistant_after_skill): + if i < len(skill_calls): + skill_calls[i]['agent_response'] = response_text + + if not skill_calls: + return + try: + with open(log_path, 'a', encoding='utf-8') as f: + for call in skill_calls: + call['subtask_label'] = subtask_label + f.write(json.dumps(call, ensure_ascii=False) + '\n') + except OSError: + pass + + +def _all_skill_tool_call_ids(transcript: tuple[dict[str, object], ...]) -> list[dict[str, str]]: + """Collect all tool_call entries for Skill from assistant messages.""" + results = [] + for entry in transcript: + if not isinstance(entry, dict) or entry.get('role') != 'assistant': + continue + tool_calls = entry.get('tool_calls') + if not isinstance(tool_calls, list): + continue + for tc in tool_calls: + if not isinstance(tc, dict): + continue + fn = tc.get('function') or {} + if isinstance(fn, dict) and fn.get('name') == 'Skill': + results.append(tc) + return results + + @dataclass class LocalCodingAgent: model_config: ModelConfig @@ -3052,6 +3165,11 @@ class LocalCodingAgent: managed_child_index=index, managed_label=subtask_label, ) + if self.tool_context.jupyter_runtime is not None: + child_agent.tool_context = replace( + child_agent.tool_context, + jupyter_runtime=self.tool_context.jupyter_runtime, + ) if group_id is not None and child_agent.managed_agent_id is not None: self.agent_manager.register_group_child( group_id, @@ -3118,9 +3236,11 @@ class LocalCodingAgent: break continue child_result = child_agent.resume(child_prompt, stored_child_session) + _log_child_skill_calls(child_result, child_agent, subtask_label) resume_used = True else: child_result = child_agent.run(child_prompt) + _log_child_skill_calls(child_result, child_agent, subtask_label) if group_id is not None and child_agent.managed_agent_id is not None: self.agent_manager.register_group_child( group_id, diff --git a/src/agent_tools.py b/src/agent_tools.py index c77eeea..9611f3a 100644 --- a/src/agent_tools.py +++ b/src/agent_tools.py @@ -340,7 +340,7 @@ def default_tool_registry() -> dict[str, AgentTool]: {'type': 'number'}, {'type': 'integer'}, {'type': 'boolean'}, - {'type': 'array'}, + {'type': 'array', 'items': {}}, {'type': 'object'}, {'type': 'null'}, ] diff --git a/src/jupyter_runtime.py b/src/jupyter_runtime.py index 1f4697a..a51e022 100644 --- a/src/jupyter_runtime.py +++ b/src/jupyter_runtime.py @@ -268,10 +268,12 @@ class JupyterRuntimeSession: '远端 jupyter pod 未挂载 /mnt/wangsenhao 或不可写:' + (probe.stdout.strip() or probe.stderr.strip() or 'unknown error') ) + ws_output = f'{shlex.quote(self.binding.workspace_cwd)}/output' + chat_output_link = f'{shlex.quote(chat_root)}/output' result = self.run_command( ( 'mkdir -p ' - f'{shlex.quote(self.binding.workspace_cwd)}/output ' + f'{ws_output} ' f'{shlex.quote(self.binding.workspace_cwd)}/input ' f'{shlex.quote(self.binding.workspace_cwd)}/scratchpad ' f'{shlex.quote(self.binding.workspace_root)}/.runtime/uploads ' @@ -279,7 +281,12 @@ class JupyterRuntimeSession: f'{shlex.quote(self.account_runtime_root)}/python ' f'{shlex.quote(chat_root)}/results ' f'{shlex.quote(chat_root)}/sft_output ' - f'{shlex.quote(chat_root)}/scripts' + f'{shlex.quote(chat_root)}/scripts && ' + f'if [ -d {chat_output_link} ] && [ ! -L {chat_output_link} ]; then ' + f'cp -a {chat_output_link}/* {ws_output}/ 2>/dev/null; ' + f'rm -rf {chat_output_link}; ' + f'fi && ' + f'ln -sfnT {ws_output} {chat_output_link}' ), timeout_seconds=timeout_seconds, max_output_chars=4000, @@ -297,14 +304,32 @@ class JupyterRuntimeSession: if not self.skills_root: return chat_root = self.chat_workspace_root - src = f'{self.skills_root}/model-iteration/scripts/prepare_and_train_sft.py' - dst = f'{chat_root}/scripts/prepare_and_train_sft.py' - result = self.run_command( - ( + # SKILL/program §5 让 agent 直接 `bash scripts/` 调下面这些脚本, + # 不同步过去 → agent 当 "脚本不存在" 处理后会肉手写 yaml,常把 + # imageCommand 里的 `/scripts/prepare_and_train_sft.py` 写丢前缀, + # 导致训练 pod 报 "No such file or directory"。每次 bind 全量覆盖 + # 一份,跟 prepare_and_train_sft.py 同样 "刷最新版"。 + script_names = ( + 'prepare_and_train_sft.py', + 'submit_sft.sh', + 'sft_train_job.yaml.tpl', + 'submit_cml_eval.sh', + 'resolve_run_ids.sh', + ) + src_dir = f'{self.skills_root}/model-iteration/scripts' + dst_dir = f'{chat_root}/scripts' + copy_cmds = [] + for name in script_names: + src = f'{src_dir}/{name}' + dst = f'{dst_dir}/{name}' + copy_cmds.append( f'if [ -f {shlex.quote(src)} ]; then ' - f'cp {shlex.quote(src)} {shlex.quote(dst)}; ' + f'cp {shlex.quote(src)} {shlex.quote(dst)} && ' + f'chmod +x {shlex.quote(dst)} 2>/dev/null || true; ' f'fi' - ), + ) + result = self.run_command( + ' && '.join(copy_cmds), timeout_seconds=timeout_seconds, max_output_chars=2000, )