一、Skill 能被再次调用,为什么还不能说它变好了

第四篇结束时,generated-code-testing 已经写进 Skills 目录,未来会话也能发现它。现在我们想再改进这条 Skill: 让它不仅记住“先生成、再测试”,还能识别生成结果过期、错误地提交生成物、或者只跑局部测试等问题。 最直接的做法似乎是让模型把说明写得更完整,然后挑一个任务试试。新版本成功了,我们是不是就能宣布它更好?

还不能。第二次任务可能更简单,环境可能已经被上一次修好,模型也可能只是那一轮恰好选对工具。即使变化确实来自新 Skill, 它也可能只背住了刚才那个例子。要把“这次成功”变成“这个版本更可靠”,必须固定题目、固定怎样评分、固定模型与工具条件, 还要预留一组搜索过程从未见过的任务。评测数据就是这把比较尺子。

本篇先不讲 DSPy 或 GEPA 怎样改文字,只解决优化开始前的四个问题:一道题到底包含什么;题目从哪里来; train、validation、holdout 分别允许影响哪一步;当前 Hermes self-evolution 原型怎样真正读取和切分它们。 如果这四件事没有讲清,后面的“分数提高”也没有可信含义。

先给最常见疑问一个短答案。 train 会参与执行、失败反思和候选改写;validation 不直接把题目送给反思器写新文字,但它的分数会决定候选排名和下一轮从谁继续改; holdout 在搜索停止后才打开,用来比较冻结基线和最终候选。验证集会间接塑造最终 Skill,所以它不是第二份训练集,也不是完全保密的最终考试。

二、先做一次公平对比:一道评测题究竟是什么

继续用生成代码测试 Skill。我们先冻结当前 SKILL.md,称它为基线。之后无论产生多少候选, 都能回到这个相同起点。然后准备一道任务:“某个 schema 改动后,测试报告导入模块不匹配,请定位原因并验证修复。” 题目只描述用户要完成什么,不能顺手把答案提示进去。

另一边单独写评分要求:好结果必须确认生成器是否运行、检查生成文件差异、执行目标测试,并说明为什么旧生成物会造成失败。 它不是逐字标准答案,而是一份 rubric(评分说明),告诉评估器哪些行为不能漏掉。Hermes self-evolution 把这一对数据叫作 EvalExampletask_input 是给 Agent 做的题,expected_behavior 是给 metric 或裁判看的要求。

task_input
  schema 变更后,测试报告导入模块不匹配;定位原因并验证修复

expected_behavior
  - 检查生成代码是否过期
  - 运行项目真实生成命令
  - 检查生成文件差异
  - 执行目标测试并解释失败原因

这两个字段必须分开。若把“检查生成代码是否过期”直接写进用户题目,Agent 只要复述题面就可能得到高分; 我们无法判断它是否真的学会在不提示的情况下想到这一检查。源码转换为 DSPy 样本时,仍把 expected_behavior 保留在 Example 上供 metric 使用,却只把 task_input 标成程序输入:

dspy.Example(
    task_input=ex.task_input,
    expected_behavior=ex.expected_behavior,
).with_inputs("task_input")

字段定义、JSONL 持久化和转换逻辑见 EvalExample / EvalDatasetdifficultycategory 用来描述覆盖面,source 记录题目从哪里来;它们不是另一套答案。

2.1 分数告诉你“多差”,执行现场告诉你“差在哪”

接着让基线 Skill 真正完成这道题。一次“某个版本在某道题上实际执行”的运行叫 rollout,执行过程叫 trace。 假设基线只重跑测试,没有执行生成器,于是仍然失败。评估器给出低分,同时留下“没有检查生成文件是否过期”这条反馈。 分数适合比较,trace、输出和文字反馈则让优化器知道应改哪条说明。

优化器据此写出候选版,例如增加“schema 或接口变化后,先确认生成步骤与生成文件差异”。候选必须在相同任务、相同模型、相同工具和相同评分下重跑。 若新旧版本使用不同装备,分数变化就不能归因于 Skill。若只比较两段文本而不真正执行任务,我们也只知道新文字看起来更完整,不知道它能否改变行为。

要固定的东西为什么变化后会混入什么解释
基线版本所有候选从同一对象出发不知道改动相对谁产生
任务与 rubric旧版、新版面对同一难度和口径题更简单或评分更宽松
模型、工具、预算两个版本使用同样装备能力差异来自 runtime 配置
执行与 metric比较真实行为,而不是文字观感新版只写得更长、更像答案

三、题目从哪里来:三种出题人,统一一种样本

一道题能解释评测单位,却不足以启动优化。候选可能只适合这一种 schema 变化,也可能只会处理测试命令。 我们需要不同难度、不同故障类型和不同表达方式的任务,而且每道题都要提前写清 rubric。 当前 self-evolution 原型支持三条来源路径:合成任务、人工金标和真实会话。

合成任务、人工金标或经过两阶段相关性筛选的真实会话分别归一成 EvalExample,再随机切为 train、validation 和 holdout
一次 CLI 运行只选择一条来源路径;三条路径最终都必须交出同一种 EvalExample

3.1 合成任务:先让系统有题可做

没有历史数据时,SyntheticDatasetBuilder 会把完整旧 Skill 交给一个生成模型,让它输出多组 task_inputexpected_behavior、difficulty 和 category。对生成代码 Skill,它可以快速造出 schema 变化、生成文件未提交、局部测试遗漏等任务。输出解析成功后,空题目或空 rubric 会被丢弃,再随机切分。

这条路解决冷启动,却有结构性盲区:出题模型是在读旧 Skill。旧 Skill 反复强调“先跑生成器”,合成题也容易围着这句话变化; 旧 Skill 完全没想到跨平台脚本,出题模型可能一起忽略。合成数据适合快速建立第一把尺子,不适合独自证明真实使用已经改善。 生成和切分见 SyntheticDatasetBuilder.generate

3.2 人工金标:主动覆盖旧 Skill 看不见的风险

Golden set 不是要求 Agent 输出一段固定答案,而是由人直接编写或审定任务与 rubric。它的价值在于能故意加入旧 Skill 没覆盖的高风险题, 也能把“测试通过”“差异为空”“不得修改手写文件”等条件写成可审计门槛。成本更高,但它能打破“旧 Skill 决定出题范围”的闭环。

GoldenDatasetLoader 优先读取已经分好的 train.jsonlval.jsonlholdout.jsonl;如果只有一个 golden.jsonl,当前实现会先 shuffle 再按 50/25/剩余比例切分。 已经人工分好的文件更适合处理会话家族、项目来源和难度分层,避免纯随机切分把近重复题放到不同集合。

3.3 真实会话:把用户真的会问什么带进来

第三条路读取 Claude Code、GitHub Copilot 和 Hermes Agent 的历史会话。它能带回真实措辞、真实项目压力和旧 Skill 没预见的失败, 但会话消息还不是评测样本:其中有大量无关任务,旧 assistant 回答也可能正是我们想修复的错误,不能直接当金标。 因此真实会话需要先筛选,再重新生成 rubric。

来源任务怎样产生rubric 怎样产生主要风险
合成任务模型阅读旧 Skill 后生成同一次生成给出复制旧 Skill 的关注点和盲区
人工金标人编写或选择高价值任务人审定要求或可执行检查成本高,覆盖取决于设计者
真实会话从用户历史消息中筛选相关性模型依据 Skill 与上下文生成隐私、噪声和裁判偏差

四、真实会话为什么要经过两道筛选门

历史消息可能很多,逐条调用模型既慢又贵。RelevanceFilter 先用便宜的词法规则预筛:Skill 全名、名称中的长词, 或 Skill 开头 500 个字符里的关键词重叠。若预筛得到的候选太少,再从剩余消息中随机补一些,避免词法规则把表达不同但真正相关的任务全部漏掉。

第二道门才调用 LLM。它同时看 Skill 名称、前 800 个字符、用户消息以及最多 1000 字符的旧 assistant 回答,判断是否相关; 通过后重新生成 expected_behavior、difficulty 和 category。旧回答只是判断上下文,不是正确答案。 两阶段流程见 RelevanceFilter

4.1 数据清洗不只是去重,还包括隐私边界

外部 importer 在构造任务前会过滤一组常见密钥、token、认证头、私钥、数据库连接串与 password/secret 赋值模式; 任务文本最长保留 2000 字符,difficulty 归一到 easy、medium、hard。这个规则能挡住明显泄漏,却不等于完整匿名化: 仓库名、客户名、内部 URL 和业务数据仍可能出现在普通文本里。真正用于共享或长期存档的数据集还需要额外脱敏与人工审阅。

Secret pattern 与字段校验见 external_importers.py。 通过筛选的三类会话样本随后汇总,再进入统一切分入口: build_dataset_from_external

五、train、validation、holdout 到底怎样分工

现在终于可以回答最容易混淆的问题。假设留下 20 道有效题,默认 50/25/25 比例大致得到 10 道 train、5 道 validation 和 5 道 holdout。 这里没有更新模型权重:被改的是 Skill 文字。三份数据的差别不在文件格式,而在它们允许对搜索产生多大影响

train rollout 与失败反馈产生候选,validation 分数更新候选池和下一轮父候选,搜索停止后才用 holdout 比较冻结基线与最终候选
train 直接产生改写信号,validation 导航搜索,holdout 只做最终独立比较。

5.1 train:让优化器看到失败现场并提出改写

搜索先从候选池选一个父版本 P1,抽取 train 小批次,实际执行任务并保存 trace、输出、分数和反馈。 反思器阅读这些训练失败,提出改写版 P2。随后 P2 在同一小批次上复测;没有改善就立即淘汰。 train 因此提供的是直接改写信号:优化器知道哪道题失败、怎样失败,并据此写出下一版文字。

5.2 validation:不亲自写文字,但决定搜索朝哪里走

通过 train 快速检查的候选会在 validation 上执行。逐题分数用于更新候选池和 Pareto 前沿,并影响下一轮选择哪个候选作为父版本。 validation 题通常不会直接进入反思器成为新 instruction 的素材,但它的表现决定谁被保留、谁继续繁衍。 所以 validation 提供的是搜索导航信号,会间接塑造最终 Skill。

这就是为什么“训练集跑出 Skill,验证集再优化 Skill”不够准确。实际是循环接力:train 产生候选,validation 选择候选, 被选中的候选又回到下一轮 train。反复查看 validation 分数并据此调整搜索,也会对 validation 过拟合。 最新 DSPy GEPA 的 compile 契约仍明确区分两者:trainset 做 reflective updates,valset 跟踪 Pareto scores;若不提供 valset, 就复用 trainset,并明确警告这会鼓励对给定任务过拟合。见 GEPA.compile

5.3 holdout:只有搜索停止以后才允许打开

搜索选出最终候选以后,才同时让冻结基线和该候选完成 holdout 题,用相同 metric 比较平均分。 holdout 没有回流箭头:一旦你看见结果又去修改 Skill,这些题就已经参与了搜索,下一次不能再把它们当成独立证据。 需要继续改时,应把已查看任务降为开发数据,并准备新的最终留出集。

集合什么时候打开允许影响什么不能做什么
train每轮搜索rollout、反馈、反思和候选改写单独证明未见任务上的泛化
validation搜索期间反复候选排名、Pareto 与下一轮父候选充当最终独立验收
holdout最终候选冻结以后一次基线 vs 候选比较看完分数继续改,还声称它未见

六、当前 Hermes 原型实际怎样切分和读取

evolve_skill.py 的一次运行会从 goldensessiondbsynthetic 中选择一条路径, 不会自动把三类来源混在一起。sessiondb 这个名字也容易误导:当前实现会尝试汇总 Claude Code、Copilot 和 Hermes 三种会话来源, 不是只读 Hermes 的 SessionDB。

if eval_source == "golden":
    dataset = GoldenDatasetLoader.load(dataset_path)
elif eval_source == "sessiondb":
    dataset = build_dataset_from_external(
        sources=["claude-code", "copilot", "hermes"], ...
    )
elif eval_source == "synthetic":
    dataset = SyntheticDatasetBuilder(config).generate(...)

trainset = dataset.to_dspy_examples("train")
valset = dataset.to_dspy_examples("val")
optimized = optimizer.compile(baseline, trainset=trainset, valset=valset)

# 搜索结束后
holdout = dataset.to_dspy_examples("holdout")

数据分支见 evolve_skill.py, train/val 接入优化器与 holdout 最终比较见 同一执行链

6.1 随机切分不自动等于独立

当前 synthetic、未分组 golden 和 external importer 都使用 random.shuffle 后按位置切分,没有保存随机种子, 也没有按会话 ID、项目、任务家族或近重复语义分组。来自同一会话的两种改写可能一条进 train、一条进 holdout; 候选看似没见过最终题,实际上已经见过几乎相同的答案模式。

严谨运行应先做去重和 group split:同一会话、同一 issue、同一代码片段的变体必须留在同一侧;再按 category、difficulty 和 source 检查覆盖, 并保存数据版本、生成模型、rubric 审核记录与随机种子。train/validation/holdout 是权限边界,不是三个文件名本身。

6.2 很小的数据集可能留下空 holdout

构建器对 train 和 val 各使用 max(1, ...),剩余样本全部进入 holdout。只有 2 道题时,train 和 val 会各拿 1 道,holdout 为空; external importer 虽提示至少 3 道才有有意义的切分,却不会强制阻止继续运行。后续 holdout 平均分用 max(1, len(scores)) 防止除零, 这能让程序不崩,却不能创造独立证据。空 holdout 的“0 分比较”不应被解释为验收完成。

七、启动优化前,先检查这把尺子

到这里,评测数据的完整链路已经清楚:来源先被清洗并归一为同一种任务记录;题目只进入被评测程序,rubric 留给 metric; train 提供直接改写信号,validation 导航候选搜索,holdout 在搜索停止后独立比较。下面这份清单比“数据一共有多少条”更重要:

  1. 每条 task_input 是否像真实用户任务,而不是把答案写进题面?
  2. expected_behavior 是否描述可观察行为,且经过领域人员审查?
  3. 基线与候选是否使用同一模型、工具、预算和 metric?
  4. 同一会话、项目或任务家族是否被整体分到同一集合?
  5. validation 是否只导航搜索,holdout 是否从未回流到改写?
  6. holdout 是否有足够样本,而不是靠防除零逻辑制造“评测已完成”的错觉?

现在我们有了题目、尺子和三种权限,但还没有说明“谁能改 Skill 的哪一段文字”。 下一篇会先讲 DSPy 如何把固定输入输出、固定程序结构、可优化 instruction 和 metric 分开,再讲 GEPA 如何利用 train trace 反思、 用 validation 维护候选前沿,以及 Hermes 当前 SkillModule 接线究竟有没有把 Skill 文本真正暴露成可优化参数。 继续阅读:DSPy、GEPA 与 Hermes Self-Evolution

参考源码