0%

从手写 SKILL.md 到自动训练:用 SkillOpt 优化一个代码评审技能

写 SKILL.md 的过程通常是这样:翻几个官方技能的写法,把团队经验归纳成 checklist,调几轮措辞,觉得”差不多”就发布了。这份技能到底让 agent 的评审能力变好了多少?写坏了会不会反而拖累表现?没人说得清。我在上一篇 Android Skills 实践里提过,android/skills 仓库的 22 个官方 SKILL.md 都有明确的工作流与约束,但”怎么验证自己写的技能真的有效”这个问题,当时没有答案。

Microsoft Research 今年 5 月开源的 SkillOpt 给了一个工程化的回答:把 SKILL.md 当作可训练参数,用一套复刻深度学习训练纪律的循环去优化它——有 epoch、有学习率、有验证集门控,但完全不碰模型权重。论文在 6 个基准、7 个模型、3 种执行方式组合评测的 52 个格子上全部取得最优或并列最优(直连模式 7 模型 × 6 基准共 42 格,Codex 与 Claude Code 两个 agent 环境下各评 GPT-5.5 的 5 个基准共 10 格),在 GPT-5.5 直连模式下把六基准平均分从 58.8 拉到 82.3(arXiv:2605.23904,2026-05-25 v2)。

本文基于 2026-09-21 核验的资料:论文 arXiv:2605.23904(v2)、GitHub 仓库 microsoft/SkillOpt(v0.2.0,2026-07-02 发布,MIT 协议)、Microsoft Research 官方博客(2026-07-24,检索于 2026-09-21)。前两节讲清方法与论文数据,后三节给出把代码评审任务接入 SkillOpt 的完整方案——论文的六个基准里没有代码评审,这一段是本文自己补上的。

Key Takeaways

  • SkillOpt 把技能文档当作冻结 agent 的可训练外部状态,训练循环复刻深度学习:rollout 对应前向传播、分组反思对应反向传播、编辑预算对应学习率、验证门控对应验证集早停,全程不改模型权重。
  • 论文在 52 个(模型 × 基准 × 环境)评测格上全部最优或并列最优,GPT-5.5 直连平均提升 +23.5 分;最终产物只有 3791,995 token,由 14 条被接受的编辑构成。
  • 接入自有任务(如代码评审)需要自建四件套:数据加载器、带确定性 scorer 的 rollout、EnvAdapter、YAML 配置,参照官方示例约 200 行代码。
  • 成本集中在训练期:论文中每提升 1 分需 0.6M~46.4M token;部署期零额外调用。没有标注数据时,可从历史 fix commit 反推样本,或用 SkillOpt-Sleep 从真实会话回放(注意隐私边界)。

一、SkillOpt 是什么:把 SKILL.md 当权重训练

论文对现有技能来源的批评很直接:手工编写靠经验、一次性 LLM 生成无反馈修正、松散的自修订循环则”看起来在改进,实际可能越改越差”——三种方式都没有深度学习优化器那种可复现的纪律。SkillOpt 的做法是把技能文档当成冻结模型的外部可训练状态:目标模型 M 保持不动,执行环境 h 对任务 x 产生轨迹 τ 和分数 r ∈ [0,1],优化器模型(一个单独的、通常更强的 LLM)负责改写技能文档。

训练前先把数据分成 train / selection / test 三份(论文默认 4:1:5,split_seed=42 固定随机种子):train 用来产生经验,selection 用来决定编辑是否被接受,test 只在最终报告时使用。整个循环长这样:

SkillOpt 训练循环 目标模型带当前技能执行一批任务(rollout),优化器对失败与成功轨迹分组反思(reflect),合并出 add/delete/replace 编辑补丁(aggregate),按编辑预算截取(select),应用到技能文档(update),最后在 selection 划分上做验证门控(gate):分数严格变好才接受并更新 best_skill.md,被拒绝的编辑进入 rejected-edit buffer 作为负反馈。每个 epoch 边界执行 slow update 与 meta skill。 Rollout 目标模型执行任务 Reflect 失败/成功分组反思 Aggregate 合并去重补丁 Select 按编辑预算截取 Update 应用到技能文档 Gate 验证门控 best_skill.md selection 分数严格变好才接受 rejected-edit buffer 本轮被拒编辑 → 负反馈回反思 Epoch 边界:Slow Update + Meta Skill 每轮抽 20 题对比上轮/本轮技能,写入快编辑不可覆盖的受保护区;Meta Skill(接受/拒绝规律总结)只进优化器上下文,不随产物部署 编辑预算 Lₜ(文本学习率):默认每步最多 4 条 add/delete/replace 编辑,cosine 衰减到 2;支持 constant / linear / cosine / autonomous 调度。 反思细节:失败组提补规则,成功组保行为;每组最多 3 轮精炼,层级合并时失败修正优先。
Figure 1. SkillOpt 训练循环。图片来源:根据 arXiv:2605.23904 Figure 2 与官方文档重绘。

几个关键机制的对应关系,官方文档给了一张直白的对照表:

深度学习 SkillOpt
模型权重 技能文档(Markdown)
前向传播 Rollout:目标模型执行任务
损失 / 梯度 Reflect:优化器从轨迹产出编辑补丁
梯度裁剪 编辑选择:learning_rate = 每步最多编辑条数
SGD 一步 补丁应用到技能文档
验证集 在 selection 划分上的门控评估
学习率调度 lr_scheduler:cosine / linear / constant
Epoch 多轮训练 + slow update 与 meta skill

真正把这套循环和”让 LLM 自己改自己的 prompt”区分开的,是两个设计。验证门控:候选技能只有在 selection 划分上严格超过当前分数才会被接受——平局也算失败,部署产物永远不会悄悄变差。rejected-edit buffer:被拒的编辑不是扔掉,而是连同”它导致分数降了多少”一起记进本轮的负反馈池,后面的反思轮次会看到这些失败尝试,避免重复踩坑。论文的评价是:看似合理的文字诊断也可能伤害实际模型,门控把反思从”无条件自我编辑”变成了 propose-and-test 优化。

二、论文数字:提升多大,代价几何

直接看主结果。GPT-5.5 直连模式下,六个基准从”无技能”到”SkillOpt 训练后”:

GPT-5.5 直连:六基准提升 无技能基线(灰)与 SkillOpt(绿)在 SearchQA 77.7→87.3、SpreadsheetBench 41.8→80.7、OfficeQA 33.1→72.1、DocVQA 78.8→91.2、LiveMathematicianBench 37.6→66.9、ALFWorld 83.6→95.5 上的对比,六基准平均 58.8→82.3。 0 25 50 75 100 77.7 87.3 41.8 80.7 33.1 72.1 78.8 91.2 37.6 66.9 83.6 95.5 SearchQA Spreadsheet OfficeQA DocVQA LiveMath ALFWorld 无技能基线 SkillOpt 六基准平均:58.8 → 82.3(+23.5);程序性任务提升最大(OfficeQA +39.0,SpreadsheetBench +38.9)
Figure 2. GPT-5.5 直连模式六基准对比。数据来源:arXiv:2605.23904 Table 1(held-out test split,2026-05-25 v2)。

把论文的结论压缩成几句话:

  • 全面性:6 个基准(SearchQA、SpreadsheetBench、OfficeQA、DocVQA、LiveMathematicianBench、ALFWorld)、7 个目标模型(GPT-5.5 到 Qwen3.5-4B)、3 种执行模式(直连、Codex CLI、Claude Code CLI)组合出 52 个评测格——直连模式评满 7×6=42 格,两个 agent 环境各评 GPT-5.5 的 5 个基准(ALFWorld 需要持久环境交互,不进 harness 模式)共 10 格——全部最优或并列最优;对手包括人写技能、一次性 LLM 生成、Trace2Skill、TextGrad、GEPA、EvoSkill 六类基线(Table 1)。GPT-5.5 直连下,SkillOpt 比一个”每格都挑最强基线”的 oracle 还高 +5.4 分(82.3 vs 76.9,Table 1)。
  • 环境增益:GPT-5.5 在 Codex 智能体循环内平均 +24.8 分,在 Claude Code 内 +19.1 分(Table 1)。论文的 Codex/Claude Code harness 会把当前技能渲染成每个任务的 SKILL.md 放进工作区,并回读执行轨迹摘要参与反思——和你日常把技能喂给 coding agent 的方式是同一件事。
  • 小模型受益更明显:七个直连模型的平均增益为 GPT-5.5 +23.5、GPT-5.4 +12.7、GPT-5.4-mini +15.4、GPT-5.4-nano +26.7、GPT-5.2 +16.6、Qwen3.5-4B +19.2、Qwen3.6-35B-A3B +9.1(Table 1,总体均值约 +17.6)。Qwen3.5-4B 在 SpreadsheetBench 上从 9.3 提到 23.9(×2.6)——一份几百 token 的技能文档,能补上小模型权重里缺的程序性知识。
  • 产物小得反直觉:六个基准的最终 best_skill.md 在 3791,995 token 之间(中位约 920),且只由 14 条(中位 2.5)被门控接受的编辑构成。LiveMathematicianBench 的 +29.3 分和 OfficeQA 的 +39.0 分,各自来自一条编辑。作为对照基线的专家手写技能有 145516 token,在直连模式下每一行都被 SkillOpt 击败——注意区分口径:下文成本表里 16516 的初始 token 是 SkillOpt 自己的种子技能,可以短到一行。
  • 消融证明机制有效(Table 2 / Table 3):去掉 meta skill 和 slow update,SpreadsheetBench 从 77.5 跌到 55.0(-22.5,Table 3);去掉 rejected-edit buffer,三个消融基准分别 -1.6/-4.6/-2.4(Table 3);把编辑预算从 1 扫到 16 都比”无预算自由改写”强(Table 3,无预算行为在三个消融基准上为 84.6/75.7/57.3;Table 2 是预算与调度的扫描)。换句话说,有界更新和门控本身就在起作用,而不是某组精调的超参。

成本方面(Table 6,GPT-5.5 学生 + GPT-5.5 优化器):

基准 初始 token 最终 token 接受编辑数 训练 token 每提升 1 分
SearchQA 16 857 4 213.8M 37.9M
SpreadsheetBench 224 1,995 4 21.4M 0.6M
OfficeQA 145 883 1 20.8M 1.1M
DocVQA 81 959 3 188.2M 46.4M
LiveMathematicianBench 154 379 1 23.2M 3.6M
ALFWorld 516 1,321 2 59.3M 15.9M

规律很清楚:轨迹短、任务程序性强的基准便宜(每分 0.6M~3.6M token),轨迹长或多模态的基准贵一个量级(SearchQA 37.9M/分、DocVQA 46.4M/分)。这些钱都花在训练期——部署后的 best_skill.md 对目标模型零额外调用。这对设计自有任务的启示放到第四节讲。

另外两个值得记住的迁移实验(Table 4):在 Codex 里训出来的 SpreadsheetBench 技能拿到 Claude Code 里跑,从 22.1 拉到 81.8(+59.7),甚至略高于 Claude Code 原生训练的 80.4——说明学到的是”先看工作簿结构再动手”这类程序性纪律,不是某个 harness 的命令配方;在 GPT-5.4 上训的技能迁移到 GPT-5.4-mini,保留了 82% 的域内增益(Table 4)。技能产物是一次训练、多处复用的。

最后看看训练出来的是什么东西。论文 Figure 4 摘录了几条 verbatim 规则,比如 SpreadsheetBench 学到:”先检查工作簿结构与公式,再把求值后的静态值写入完整目标区间,而不是依赖 Excel 重算”;OfficeQA 学到:”把 oracle 解析页作为一手证据,锁定表格/日期/单位上下文,按要求的舍入精度输出,不带多余标签”。都是程序性纪律——不点名任何具体题目、文件或实体,更像一个老手工程师泡了一天任务后写下的经验帖,只不过它们是被优化器从失败轨迹里挖出来、再逐条过验证门的。

三、接入自有任务:SkillOpt 的四件套契约

SkillOpt 内置的六个基准离代码评审都远,但官方扩展文档把接入自定义任务的路径写得很清楚:实现一个 benchmark 约需要四个文件(官方叫 docfaithful 的教学示例约 200 行),仓库里还有 officeqa、swebench(main 分支注册表里已有,比论文六基准更贴代码场景)等完整实现可以抄。

四件套的分工:

  1. SplitDataLoader 子类:把 train/val/test 目录里的样本读成统一的 dict 列表;
  2. rollout 辅助函数:让目标模型带着当前技能执行任务、打分并持久化对话——scorer 就住在这里,框架没有单独的 evaluate() 方法;
  3. EnvAdapter 子类:把前两者接进训练生命周期(build_train_env / build_eval_env / rollout / get_task_types 四个抽象方法);
  4. YAML 配置:声明环境名、种子技能路径和训练超参。
代码评审任务 × SkillOpt 数据流 代码评审样本(diff + 缺陷标注)按 4:1:5 划分;rollout 让 agent 带种子技能评审 diff 并产出 conversation.json 与 hard/soft 分数;确定性 scorer 在 rollout 内部完成打分;反思阶段消费对话与分数产出不超过编辑预算的候选编辑;验证门控只接受 selection 分数严格变好的候选,导出 best_skill.md 并部署为 SKILL.md。 样本库 每条 = diff + 缺陷标注 历史 fix commit / 注入缺陷 建议 100+ 条起步 三划分 train : val : test = 4 : 1 : 5 split_seed=42 data/codereview_split/{train,val,test} rollout:agent 评审 diff 种子技能作为 system prompt,diff 作为 user predictions/<id>/conversation.json 必须落盘 返回 id / hard / soft 三个必填字段 确定性 scorer(在 rollout 内) 命中 = 定位到锚点 且 类别正确 hard = 全部真缺陷命中;soft = 召回率 反思 → 候选编辑 失败组补规则,成功组保行为 输出 ≤ Lₜ 条 add/delete/replace 验证门控 → best_skill.md val 分数严格变好才接受(平局即拒绝) 部署为 .claude/skills/code-review/SKILL.md 拒绝 → rejected buffer 负反馈
Figure 3. 代码评审任务接入 SkillOpt 的数据流。作者根据官方 new-benchmark 指南整理。

其中最难的不是代码,是数据集和 scorer 的设计。代码评审的样本就是”一段 diff + 它已知的问题清单”,三个靠谱来源:

  • 仓库自己的 fix commit:把修复提交 diff 掉依赖部分,缺陷标注就是 commit message 和修复内容反推出来的”修复前有什么问题”。自家的历史 bug 天然贴近真实评审场景。
  • 注入缺陷:在干净代码上按类别(空指针、并发、资源泄漏、生命周期泄漏)注入已知缺陷,标注完全可控。
  • 公开数据集:CodeReviewer、SWE-bench 类数据的评审子集,拿来冷启动。

规模上,论文的程序性基准用了几十到几百条训练样本,个人团队从 100~200 条起步是现实的。scorer 必须确定性、可复现——官方文档原话:”Noisy scoring kills the optimizer. Spend time on run_batch’s scoring before you spend time on prompts.”(噪声评分会杀死优化器,先把打分做扎实再琢磨提示词。)对代码评审,”命中”可以定义为:评审意见同时包含缺陷的锚点(函数名/变量名等可定位字符串)和类别(空指针/并发/资源泄漏……);hard 分数要求全部真缺陷命中,soft 分数用召回率给部分分。误报惩罚(评审意见里的假缺陷扣分)可以加进 soft,但第一版建议保持简单,先让循环跑通。

四、三步搭起来:codereview 环境完整代码

第 1 步:安装与目录

1
2
3
4
5
6
7
git clone https://github.com/microsoft/SkillOpt.git
cd SkillOpt
python -m pip install -e ".[searchqa]" # 研究引擎按 checkout 安装
cp .env.example .env # 配 AZURE_OPENAI_ENDPOINT 或 OPENAI_API_KEY / Anthropic 等

mkdir -p skillopt/envs/codereview/skills
mkdir -p data/codereview_split/{train,val,test}

后端配置注意一点:claude_chat 后端实际是拉起 claude -p 命令行,不是直连 Anthropic API;如果你的 provider 兼容 OpenAI Chat 协议,优先用内置的 openai_compatible 后端,不用写任何集成代码。

第 2 步:数据加载器与 rollout(scorer 在这里)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# skillopt/envs/codereview/dataloader.py
from __future__ import annotations

import json
from pathlib import Path

from skillopt.datasets.base import SplitDataLoader


def _normalize(raw: dict) -> dict:
"""每个样本 = 一段 diff + 已知缺陷清单(ground truth)。"""
return {
"id": str(raw["uid"]),
"diff": raw["diff"],
"defects": raw["defects"], # [{"anchor": "onResume", "type": "内存泄漏", "desc": "..."}]
"task_type": raw.get("category", "codereview"),
}


class CodeReviewDataLoader(SplitDataLoader):
"""从 split 目录的 JSON 文件加载代码评审样本。"""

def load_split_items(self, split_path: str) -> list[dict]:
json_files = sorted(Path(split_path).glob("*.json"))
if not json_files:
raise FileNotFoundError(f"No .json file found in {split_path}")
items: list[dict] = []
for path in json_files:
with path.open(encoding="utf-8") as f:
items.extend(_normalize(item) for item in json.load(f))
return items
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# skillopt/envs/codereview/rollout.py
from __future__ import annotations

import json
import os
from pathlib import Path

from skillopt.model import chat_target


def _hit(review_text: str, defect: dict) -> bool:
"""命中 = 同时定位到锚点(函数/变量名)并给出正确的缺陷类别。"""
text = (review_text or "").lower()
return defect["anchor"].lower() in text and defect["type"].lower() in text


def _score(review_text: str, defects: list[dict]) -> tuple[int, float]:
"""hard:全部真缺陷被命中才记 1;soft:召回率作部分分。
这是整条链路最重要的函数——噪声评分会杀死优化器。"""
total = max(len(defects), 1)
hits = sum(_hit(review_text, d) for d in defects)
return int(hits == len(defects)), hits / total


def _rollout_one(item: dict, skill_content: str, *, prediction_dir: Path,
max_completion_tokens: int) -> dict:
system = skill_content
user = (
"请评审以下代码变更,逐条指出缺陷,每条包含位置、类别与修复建议:\n\n"
f"{item['diff']}\n\n评审意见:"
)
prediction, _usage = chat_target(
system=system, user=user,
max_completion_tokens=max_completion_tokens,
)
hard, soft = _score(prediction, item.get("defects", []))

task_dir = prediction_dir / str(item["id"])
task_dir.mkdir(parents=True, exist_ok=True)
conversation = [
{"role": "system", "content": system},
{"role": "user", "content": user},
{"role": "assistant", "content": prediction},
]
(task_dir / "conversation.json").write_text(
json.dumps(conversation, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return {
"id": str(item["id"]),
"hard": hard,
"soft": soft,
"predicted_answer": prediction,
"diff": item.get("diff", ""),
"defects": item.get("defects", []),
"task_type": item.get("task_type", "codereview"),
"target_system_prompt": system,
"target_user_prompt": user,
"n_turns": 1,
}


def run_batch(*, items: list[dict], skill_content: str, out_root: str,
workers: int = 4, max_completion_tokens: int = 4096) -> list[dict]:
os.makedirs(out_root, exist_ok=True)
prediction_dir = Path(out_root, "predictions")
results = [
_rollout_one(item, skill_content, prediction_dir=prediction_dir,
max_completion_tokens=max_completion_tokens)
for item in items
]
Path(out_root, "rollouts.json").write_text(
json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
return results

两个官方强调过的坑写进代码注释了:conversation.json 必须持久化(共享反思阶段按 predictions/<id>/conversation.json 读取,缺了就跳过该样本——训练反复报 skip_no_patches 十有八九是它);走 chat_target 而不是裸调 API,这样后端切换对适配器透明。

第 3 步:Adapter、注册与配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
# skillopt/envs/codereview/adapter.py
from __future__ import annotations

from skillopt.datasets.base import BatchSpec
from skillopt.envs.base import EnvAdapter
from skillopt.envs.codereview.dataloader import CodeReviewDataLoader
from skillopt.envs.codereview.rollout import run_batch


class CodeReviewAdapter(EnvAdapter):
def __init__(self, split_dir: str = "", data_path: str = "",
split_mode: str = "split_dir", split_ratio: str = "4:1:5",
split_seed: int = 42, split_output_dir: str = "",
workers: int = 4, analyst_workers: int = 4,
failure_only: bool = False, minibatch_size: int = 8,
edit_budget: int = 4, seed: int = 42, limit: int = 0,
max_completion_tokens: int = 4096) -> None:
self.workers = workers
self.max_completion_tokens = int(max_completion_tokens)
self.dataloader = CodeReviewDataLoader(
split_dir=split_dir, data_path=data_path, split_mode=split_mode,
split_ratio=split_ratio, split_seed=split_seed,
split_output_dir=split_output_dir, seed=seed, limit=limit,
)

def setup(self, cfg: dict) -> None:
super().setup(cfg)
self.dataloader.setup(cfg)

def get_dataloader(self):
return self.dataloader

def build_env_from_batch(self, batch: BatchSpec, **kwargs):
return list(batch.payload or [])

def build_train_env(self, batch_size: int, seed: int, **kwargs):
batch = self.dataloader.build_train_batch(
batch_size=batch_size, seed=seed, **kwargs)
return self.build_env_from_batch(batch, **kwargs)

def build_eval_env(self, env_num: int, split: str, seed: int, **kwargs):
batch = self.dataloader.build_eval_batch(
env_num=env_num, split=split, seed=seed, **kwargs)
return self.build_env_from_batch(batch, **kwargs)

def rollout(self, env_manager, skill_content: str, out_dir: str,
**kwargs) -> list[dict]:
return run_batch(items=list(env_manager), skill_content=skill_content,
out_root=out_dir, workers=self.workers,
max_completion_tokens=self.max_completion_tokens)

# reflect() 继承自 EnvAdapter,用官方默认的分组反思即可
def get_task_types(self) -> list[str]:
return ["codereview"]

在 scripts/train.py 的 _register_builtins() 里加四行(scripts/eval_only.py 同样处理),否则会报 ValueError: Unknown environment 'codereview':

1
2
3
4
5
try:
from skillopt.envs.codereview.adapter import CodeReviewAdapter
_ENV_REGISTRY["codereview"] = CodeReviewAdapter
except ImportError:
pass
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# configs/codereview/default.yaml
_base_: ../_base_/default.yaml # 注意:是字符串,不是列表

model:
reasoning_effort: medium

train:
batch_size: 16
accumulation: 1
num_epochs: 4

gradient:
minibatch_size: 8
merge_batch_size: 8

optimizer:
learning_rate: 4

env:
name: codereview
skill_init: skillopt/envs/codereview/skills/initial.md # 手写种子技能
split_mode: split_dir
split_dir: data/codereview_split
workers: 4
max_completion_tokens: 4096
limit: 0

种子技能就是你现在手写的那份 SKILL.md 正文,比如:

1
2
3
4
5
6
7
8
9
# 代码评审技能(手写种子版)

你是一名严格的代码评审员。对给定 diff:

1. 先理解整体变更意图,再逐文件检查。
2. 重点:生命周期泄漏(Receiver/Listener/Handler 未注销)、主线程 IO、
并发可见性、空指针边界、资源未关闭。
3. 每条意见必须包含位置(函数/变量名)、缺陷类别、修复建议。
4. 不确定的不要报——误报比漏报更浪费人时。

训练与评估:

1
2
3
4
5
6
7
8
9
10
11
12
# 调试期先小步快跑:batch_size=4、只取 10 条样本
python scripts/train.py --config configs/codereview/default.yaml \
--out_root outputs/codereview_v1 \
--cfg-options train.batch_size=4 env.limit=10

# 正式训练
python scripts/train.py --config configs/codereview/default.yaml \
--out_root outputs/codereview_v1

# 评估训练产物(valid_unseen 即 held-out test 划分)
python scripts/eval_only.py --config configs/codereview/default.yaml \
--skill outputs/codereview_v1/best_skill.md --split valid_unseen

跑起来之后,输出目录的每个文件都有用途:best_skill.md 是部署产物;history.json 记录每一步的门控决策;skills/skill_vXXXX.md 是技能快照;steps/step_XXXX/ 里有 edit_apply_report.json,每条编辑的接受/跳过状态都在,事后可审计;runtime_state.json 支持断点续跑(重跑同一命令会从上次中断的 step 继续)。想看训练曲线可以起 WebUI:python -m skillopt_webui.app(Gradio,默认 7860 端口)。

成本预估:按论文的成本表倒推,代码评审 rollout 是单轮短轨迹(类似 OfficeQA 的 1.1M token/分),100 条样本 × 4 epoch × 每步一次 val 评估,整体量级在千万级 token 以内,训练期费用可控。超出预算时优先砍 epoch 数和 rollout batch,别砍 scorer 质量。

五、拿到 best_skill.md 之后

先读产物再部署。 打开 history.json 和 edit_apply_report.json,看优化器提了哪些编辑、被门控拒了哪些、为什么。论文的经验是大量提案会被拒(六个基准最终只接受 1~4 条),被拒的提案恰恰是”听起来合理但实际伤分”的改动——这份负反馈清单本身就是免费的质量工程材料,值得抄进团队评审规范。

部署就是包一层 frontmatter。 把 best_skill.md 正文放进项目的 .claude/skills/code-review/SKILL.md:

1
2
3
4
5
6
---
name: code-review
description: 严格评审代码变更,按位置+类别+修复建议输出意见。当用户请求 review 代码或 diff 时使用。
---

(best_skill.md 的正文原样粘贴在这里)

这与论文里 Claude Code harness 的用法一致——它就是把当前技能渲染成 SKILL.md 放进任务工作区。上次说过的 harness 选择问题(Codex 与 DeepSeek harness 对比)在这里同样成立,而且论文给了底气:同一份 best_skill.md 在 Codex 和 Claude Code 之间迁移是正收益的(SpreadsheetBench 跨 harness 迁移 +59.7/+43.6),你的技能资产不用绑定单一工具链。

让训练变成日常而非一次性项目。 v0.2.0 新增的 SkillOpt-Sleep 把这套纪律搬到了部署侧:夜间”睡眠循环”读取你本地 Claude Code / Codex / Copilot / Cursor 的历史会话,挖掘反复出现的任务,用你自己的 API 预算回放,然后反思、有界编辑、过 held-out 验证门控,产出待你人工采纳的技能/记忆更新提案(harvest → mine → replay → consolidate → stage)。这基本就是”从真实使用中自动训练技能”的完整闭环,对个人开发者是最省力的入口。两点提醒:它是 preview 状态,接口会变;数据边界要清楚——真实后端会把会话摘录发给所选 provider,敏感项目先走”harvest 落盘 → 人工审查脱敏 → 标记 "reviewed": true 再回放”的流程。

六、工程判断:什么时候值得训

不是所有技能都值得进训练循环。三个条件都满足再动手:

  1. 有确定性 scorer。代码评审有”缺陷是否被指出”这样的硬信号;纯主观的文风类技能没有,硬训只会把噪声当梯度。
  2. 失败模式是程序性的。论文里提升最大的基准(SpreadsheetBench +38.9、OfficeQA +39.0)共性是:模型缺的是”先看结构再写值””按格式输出”这类纪律。如果你的 agent 评审老是漏掉并发问题、格式总是不符合团队规范,这是可训练的;如果问题是”业务理解不到位”,技能文档解决不了。
  3. 样本够、轨迹短。百条量级样本、单轮或浅多轮任务成本可控;长轨迹任务(论文里 SearchQA 每分 37.9M token)先把轨迹砍短再训。

反过来说,手写不是被淘汰,而是换了角色:手写版做种子(skill_init),训练循环在它基础上做有界增强,验证门控保证不回退——最差情况也只是回到你的手写版。论文对 learned rules 的定性观察(程序性、非实例化、像老手写的经验帖)说明这门”训练”补的正是手写最难补的那部分:从大量真实失败里归纳、且经过数据验证的纪律。

对团队,我的建议是三步走:先用 SkillOpt-Sleep 对现有会话做一次免费诊断(mock 后端不花钱,还能看到它想提什么编辑);再从 fix commit 攒 100 条代码评审样本跑一个小规模训练(batch_size=4 起步);最后把 best_skill.md 进 code review,拿一个季度的真实评审数据回填样本库,进入下一轮训练。这套”基线—回归—回填”的循环,和《AI 生成的单元测试:70/20/10 测试金字塔怎么落地》里对测试套件的做法是同一个思路:先有可测量的基线,再谈优化。技能从此从”文档”变成”有版本、有回归验证、可回滚的工程资产”。

FAQ

SkillOpt 会修改模型权重吗?

不会。目标模型全程冻结,优化只发生在技能文档上。部署产物是一个几百到两千 token 的 Markdown 文件,推理时零额外模型调用——这也是它区别于微调(LoRA/全参)的根本点:不占 GPU 训练资源,产物可读、可审计、可跨模型复用。

它和 TextGrad、GEPA、DSPy 这类提示词优化有什么区别?

论文的实验设计刻意做了隔离:目标模型、执行环境、评分器全部固定,只换适配方法。TextGrad/GEPA 优化的是”某次调用的 prompt”,没有持久的技能产物,也没有 held-out 门控——它们在论文里是被 SkillOpt 全面超过的基线。DSPy 走声明式管线编译路线,解决的问题不同。一句话:SkillOpt 优化的是可部署、可迁移的技能文件,并把深度学习的训练纪律(编辑预算、验证门控、负反馈、slow update)带进了文本空间。

没有标注好的缺陷清单,还能训代码评审技能吗?

能,但必须有可验证信号,否则退化为噪声优化。三条路:从仓库历史 fix commit 自动反推标注(修复 diff 就是答案);按类别注入缺陷自造标注;或用 SkillOpt-Sleep 从真实会话回放并人工审核任务集。核心原则不变——scorer 要确定性,哪怕粗(命中/未命中二值)也比模糊的”评审质量打分”有效。

优化器用多大的模型?训练贵不贵?

优化器只在训练期运行,不进部署,所以”有更强的就用更强的”——论文 Table 5 显示强优化器在所有测试格上增益更大,目标模型自任优化器只能恢复 56%74% 的增益。成本参考 Table 6:短轨迹程序性任务每提升 1 分约 0.6M3.6M token,长轨迹或多模态任务 15M~46M token。个人场景把 rollout batch 和 epoch 压小(batch_size=4、epochs=2),总开销可以压到百万 token 量级。

结语

SkillOpt 把一个一直靠手感的事情变成了工程问题:技能文档 = 可训练参数,训练 = 有门控的文本空间优化,产物 = 小到可以通读的 Markdown。对每天在给 agent 写 SKILL.md 的工程师,值得动手的顺序是:选一个有硬信号的任务(代码评审就很合适)→ 攒 100 条样本 → 按 docfaithful 的四件套契约写 200 行适配代码 → 用 batch_size=4 跑第一次训练。当你第一次看到 history.json 里”提案被拒、分数不回退”的记录时,你会意识到技能维护这件事已经有了回归测试。

项目地址:microsoft/SkillOpt(MIT,PyPI 包名 skillopt),论文:arXiv:2605.23904,官方博客:SkillOpt: Agent skills as trainable parameters。关于 SKILL.md 本身的写法约定,见上一篇:Android CLI 与 Android Skills 最佳实践。