"""慢更新动量生成 — epoch 末为单个 skill 产出新的动量指导。 对标 SkillOpt 的 slow_update 机制:拿上一 epoch 末与当前 epoch 末两版 skill, 在固定样本上各跑一遍得到纵向对比(comparison_pairs),反思上一轮动量指导是否奏效、 本轮正文改动是改善还是漂移,据此重写动量指导。新指导经 patch 引擎的 replace_momentum 写回 skill 的 momentum 受保护区,作为下一轮进化的方向锚。 从 TRM4 core/harness/momentum.py(156 行)迁移 + async 化。 """ from __future__ import annotations from typing import TYPE_CHECKING, Any from loguru import logger from core.evolution.diagnose import extract_json_from_response if TYPE_CHECKING: from pathlib import Path from core.protocols import LLMProvider # ========================================================================= # 四类纵向对比类别名(单一真源) # ========================================================================= IMPROVED = "improved" # 错→对 REGRESSED = "regressed" # 对→错 PERSISTENT_FAIL = "persistent_fail" # 错→错 STABLE_SUCCESS = "stable_success" # 对→对 # 类别名 → 展示标题,列表顺序即展示顺序。 # 回退(REGRESSED)刻意排在改善(IMPROVED)之前——它是最该警惕的伤害信号。 _CATEGORY_LABELS: tuple[tuple[str, str], ...] = ( (REGRESSED, "从对变错(回退,最高优先级)"), (PERSISTENT_FAIL, "始终答错(持续失败)"), (IMPROVED, "从错变对(改善)"), (STABLE_SUCCESS, "始终答对(稳定成功)"), ) # ========================================================================= # 辅助函数 # ========================================================================= def _categorize_pair(pair: dict[str, Any]) -> str: """按两版正误派生纵向对比类别。 用键值的真值(bool(...))表示该题在两版上各自的正误:缺 correct_prev/ correct_curr 键时直接抛 KeyError 向上传播——这是上游数据损坏(不是裁判语义 歧义),静默当 False 会伪造 persistent_fail 证据、污染动量指导,故不掩盖。 参数: pair: 单个纵向对比对,须含 correct_prev/correct_curr 两键。 返回: 四个类别命名常量之一:IMPROVED(错→对)/REGRESSED(对→错)/ PERSISTENT_FAIL(错→错)/STABLE_SUCCESS(对→对)。 异常: KeyError: 缺 correct_prev 或 correct_curr 键时。 """ correct_prev = bool(pair["correct_prev"]) correct_curr = bool(pair["correct_curr"]) if not correct_prev and correct_curr: return IMPROVED if correct_prev and not correct_curr: return REGRESSED if not correct_prev and not correct_curr: return PERSISTENT_FAIL return STABLE_SUCCESS def _format_comparison_pairs(comparison_pairs: list[dict[str, Any]]) -> str: """将纵向对比对格式化为裁判可读文本,按 _CATEGORY_LABELS 分组与排序。 参数: comparison_pairs: 每个 dict 含 question/prev_prediction/curr_prediction/ correct_prev/correct_curr 字段,描述一道固定样本上两版的成对结果。 返回: 可读的纵向对比文本;空列表返回占位说明。 异常: KeyError: 任一 pair 缺 correct_prev/correct_curr 键时;不掩盖的理由见 _categorize_pair docstring。 """ if not comparison_pairs: return "(本轮无可用纵向对比样本)" grouped: dict[str, list[dict[str, Any]]] = {key: [] for key, _ in _CATEGORY_LABELS} for pair in comparison_pairs: grouped[_categorize_pair(pair)].append(pair) lines: list[str] = [f"固定样本总数:{len(comparison_pairs)}"] for key, label in _CATEGORY_LABELS: entries = grouped[key] lines.append(f"\n### {label}({len(entries)} 题)") if not entries: lines.append("(无)") continue for pair in entries: lines.append( f"- 题目:{pair.get('question', '')}\n" f" 上版预测:{pair.get('prev_prediction', '')} | " f"当前版预测:{pair.get('curr_prediction', '')}" ) return "\n".join(lines) # ========================================================================= # 入口 # ========================================================================= async def run_slow_momentum( llm: LLMProvider, diagnose_prompts_dir: Path, skill_content: str, prev_skill: str, prev_guidance: str, comparison_pairs: list[dict[str, Any]], ) -> str: """为单个 skill 生成新的慢更新动量指导。 参数: llm: LLM 端口(async chat)。 diagnose_prompts_dir: 诊断 prompt 目录(根 prompts/,slow_momentum.md 在此)。 skill_content: 当前版 skill 正文。 prev_skill: 上一版 skill 正文。 prev_guidance: 上一轮写下的动量指导。 comparison_pairs: 固定样本上两版 rollout 的成对结果(含 question/ prev_prediction/curr_prediction/correct_prev/correct_curr)。 返回: 新的动量指导文本;解析失败时保留 prev_guidance。 关键实现细节: - _format_comparison_pairs 刻意置于 try 块之外(prompt 构造阶段):它对每个 pair 取 correct_prev/correct_curr,缺键抛 KeyError 直接向上传播,不被下方 针对裁判语义歧义的 except ValueError 吞掉。 - 解析失败保留上轮指导:extract_json_from_response 抛 ValueError、缺 slow_update_content 字段、或该字段非 str,均视为语义解析失败,返回 prev_guidance(判不准时保守保留上轮指导,对标 diagnose 的保护性 fallback)。 - P5 边界:仅捕 ValueError 这一语义歧义;llm.chat 的基础设施失败 (网络/API 异常)刻意不捕,向上传播,绝不用默认值掩盖。 """ system_prompt = (diagnose_prompts_dir / "slow_momentum.md").read_text(encoding="utf-8") # _format_comparison_pairs 刻意置于下方 try 块之外(prompt 构造阶段):它对每个 # pair 取 correct_prev/correct_curr,缺键抛 KeyError 直接向上传播,不被下方针对 # 裁判语义歧义的 except ValueError 吞掉。异常类型选 KeyError(非 ValueError), # 即便位置疏忽落入 try 也不会被误吞。 user_prompt = ( f"## 上一版 skill 正文\n{prev_skill}\n\n" f"## 当前版 skill 正文\n{skill_content}\n\n" f"## 上一轮的动量指导\n{prev_guidance}\n\n" f"## 固定样本纵向对比(上版 vs 当前版)\n" f"{_format_comparison_pairs(comparison_pairs)}" ) response = await llm.chat( [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}, ] ) raw = response.content try: parsed = extract_json_from_response(raw) new_guidance = parsed.get("slow_update_content") if not isinstance(new_guidance, str): raise ValueError("slow_update_content 字段缺失或非字符串") except ValueError: logger.warning("慢更新动量解析失败,保留上轮动量指导") return prev_guidance return new_guidance