第 22 章:用评测驱动 Prompt 与 Agent 优化
一次回归评审中,团队展示了“新 Prompt 提升 12 个百分点”。进一步追问后才发现:失败样本被逐条读过,Prompt 根据这些样本修改了七轮,最后仍在同一组样本上报告最好的一轮;与此同时,Agent CLI、模型别名和并发数也发生了变化。这个结果能说明新系统在那组已见样本上得分更高,却不能把差异归因于 Prompt,更不能证明它会泛化到未见任务。
本章把评测从发布前的裁决工具变成开发循环的一部分:先提出可证伪的失败假设,再在隔离的数据分区上改变 Prompt、工具或 Agent 循环,最后用受控实验和一次性测试集决定是否发布。我们还会拆解 Harbor Cookbook 的 GEPA 配方,明确自动优化器能做什么、不能替你做什么。
本章所有 Harbor 接口固定为 v0.18.0,提交 527d50deb63a5d279e8c20593c18a2cbc7f61f9e;该版本要求 Python 3.12 或更高。1 Cookbook 则固定为提交 e093c9a860b988d9d74901010ddddb9c7f124f92。二者是两份独立版本锁。
读完本章,你应该能够:
- 把 trajectory 中的症状改写为可检验的失败假设;
- 设计 development、validation、test 与 regression 四类数据,并阻断测试集泄露;
- 用 Harbor Job 对 Prompt 或 Agent 改动做受控比较;
- 同时衡量质量、错误、token、时间和成本,而不把单一 Reward 当作全部目标;
- 审计 GEPA 配方的输入、输出、预算与集成边界。
22.1 优化对象不是一个字符串
Prompt 常被当作最容易修改的旋钮,但一个 Agent 的行为来自一整套运行系统:
行为 = 模型与 Provider
+ Prompt / instruction / Skill
+ 工具定义、权限和返回内容
+ Agent 循环、终止条件与上下文管理
+ Environment 状态和外部服务
因此第一步不是“让模型写一个更好的 Prompt”,而是为失败建立竞争假设。假设应同时写出可观察证据和可能推翻它的结果。
| 观察到的症状 | 可检验假设 | 最小改动 | 反证 |
|---|---|---|---|
| Agent 没检查服务状态就改配置 | Prompt 缺少先取证约束 | 只增加“修改前记录状态” | 新旧 Prompt 的取证率相同 |
| 已调用诊断工具却误读字段 | 工具 schema/错误模型含糊 | 只改工具描述与结构化返回 | 调用参数正确但结论仍错 |
| 修复成功后继续操作并破坏状态 | 循环缺少完成检查 | 增加确定性终止谓词 | 破坏发生在终止谓词满足前 |
| 多轮后遗忘初始约束 | 上下文管理丢失关键状态 | 增加状态摘要或固定记忆槽 | 轨迹显示约束仍在有效上下文 |
这张表强迫我们区分三类改动。Prompt 改动改变模型看到的文字;工具改动改变可执行动作或 observation;循环改动改变什么时候调用模型、怎样保存状态、何时停止。若一次提交同时改三类,失败可能消失,但因果归属也随之消失。
注意:trajectory 适合生成假设,不会自动证明原因。看到“先执行 A、后失败 B”只说明时间顺序;模型服务漂移、任务差异、缓存、网络和随机采样都可能是共同原因。
22.2 四类数据,各自只有一种职责
Prompt 优化是适应性分析:下一版改动由上一版结果决定。反复读取同一 holdout 的分数会让开发过程逐渐适应该集合;这是测试复用导致过拟合的经典风险。2 工程上最可靠的控制不是给文件起名叫 test,而是限制谁能读到什么,以及哪类决策允许使用它。
本章把贯穿项目 system-config-benchmark 划为四类:
| 分区 | 谁可以使用 | 允许的动作 | 禁止的动作 |
|---|---|---|---|
| development | 开发者和优化器 | 阅读 instruction、trajectory、Verifier 反馈;产生改动 | 用于最终泛化结论 |
| validation | 候选选择器 | 比较候选、调预算、决定停止 | 把逐题答案或完整 trajectory 回灌给下一轮 |
| test | 独立发布流程 | 对冻结候选只运行一次,生成最终报告 | 根据结果再修改本次候选并仍称同一次测试 |
| regression | 日常 CI | 检查已修复故障是否复发 | 代替未见 test,证明广泛泛化 |
Regression 可以包含历史真实事故和已经见过的失败。它的价值是“不退步”,而不是“没见过”。一条 test 一旦被开发者阅读、被优化器查询,或其汇总分数被反复用于候选选择,就应登记为已暴露,下一次正式发布换用未暴露任务。任务之间还要按来源、模板和基础镜像分组切分;仅随机拆分同一任务的轻微变体,可能让近重复内容跨区。
建议把分区清单放进独立清单库,只把 development 和 validation 的 Task 引用交给优化进程。发布身份使用 Task/Dataset 的固定摘要,而不是移动标签。测试执行账户只接受冻结候选的内容摘要,并返回预先约定的聚合指标。即使采用更复杂的 reusable holdout 技术,也不能把“可以多次查询”误写成“无限次、无代价地读取详细测试反馈”。
一个可审计的状态机如下:
失败归因 ──> development 修改 ──> validation 选择
^ │
└──────── 未达门槛/预算未尽 ───────┘
│ 冻结候选摘要
v
regression 门禁
│
v
test 一次性发布评测
22.3 先建立基线,再改变一个因素
Harbor v0.18.0 的 JobConfig 可以固定 n_attempts、并发、重试、Environment、Agent、Dataset 和 Artifact 等运行输入。3 Installed Agent 的 prompt_template_path 由 Jinja2 渲染,模板必须引用 {{ instruction }},未定义变量会在 StrictUndefined 下失败;Codex 等 wrapper 在 run() 前应用这一模板。4
本章用两个独立 Job 比较 candidate-a.j2 与 candidate-b.j2。不要把同名 Agent、同名 Model、同一 Dataset 的两个 Prompt 塞进一个 Job 后只看汇总:v0.18.0 的 Job 分组键只包含 Agent 名、Model 名和 Dataset 名,不包含 kwargs,两个 Prompt 的统计会合并。5
先保存 configs/ch22-candidate-a.yaml。下面的 provider/model-id 是教学性占位符,静态预检不会联系模型;真实运行前必须替换为账户实际可用的精确 Model ID,并固定第三方 Agent 版本。
job_name: ch22-candidate-a
jobs_dir: jobs/prompt-optimization
n_attempts: 3
n_concurrent_trials: 1
retry:
max_retries: 0
environment:
type: docker
agents:
- name: codex
model_name: provider/model-id
n_concurrent: 1
override_timeout_sec: 900
kwargs:
prompt_template_path: prompts/candidate-a.j2
datasets:
- path: datasets/system-config-benchmark-development
task_names:
- diagnose-python-service
- repair-python-service
candidate-a.j2 是最小基线,不包含具体任务答案:
先检查当前状态并保存证据,再实施最小修复;修复后运行任务内可用的验证。
{{ instruction }}
复制配置为 ch22-candidate-b.yaml,只改 Job 名和模板路径。候选 B 可以加入“若验证失败,先解释观察与预期的差异,再决定下一步”,但不要同时增加 MCP 工具、修改 timeout 和升级 Agent。
22.3.1 无模型、无 Docker 的配置预检
在安装 Harbor v0.18.0 的环境中,从配套项目根目录运行:
python - <<'PY'
from pathlib import Path
import yaml
from harbor.models.job.config import JobConfig
for name in ("a", "b"):
path = Path(f"configs/ch22-candidate-{name}.yaml")
job = JobConfig.model_validate(yaml.safe_load(path.read_text()))
assert job.n_attempts == 3
assert job.n_concurrent_trials == 1
assert job.retry.max_retries == 0
assert len(job.agents) == 1
assert job.agents[0].kwargs["prompt_template_path"].endswith(
f"candidate-{name}.j2"
)
assert len(job.datasets[0].task_names) == 2
print("ch22 job configs: ok")
PY
这项预检只验证 Pydantic schema 和本章实验不变量;它不检查 Dataset/Prompt 路径是否存在,不安装 Agent,也不证明 Docker、凭据或 Model 可用。正式运行前再执行 harbor run --config ... --print-config,保存解析结果。然后在同一 Harbor 版本、同一 Dataset 摘要、同一 Agent/Model 版本和相同外部限制下运行 A、B;交替运行顺序,避免 A 总在上午、B 总在限流高峰。Harbor 的全局并发上限是 n_concurrent_trials,Agent 的 n_concurrent 是其下的子限制,不能超过前者。6
NIST 的实验设计指南把重复、随机化和区组用于处理随机误差与干扰因素。7 对本章,Task 是自然区组:A、B 应在相同 Task 和相同计划尝试数上比较;运行批次、时段或 Provider 配额窗口可以作为额外区组。若只观察“昨天 A、今天 B”,最多报告关联,不能写“B 导致提升”。
三个尝试只是教学性流程,不足以保证统计功效。正式实验应在看结果前固定:主要 Reward 键、成功阈值、计划次数、失败计入规则、停止条件、允许的比较次数与预算。认证错误、超时和 Reward 缺失不能在看到结果后静默删除;本章将其计入计划分母并单列类型。max_retries=0 是为了保留首轮失败语义,而不是建议所有生产 Job 都关闭重试。
22.3.2 候选台账与配对分析
每个候选都应有不可变身份。最简单的做法是对 Prompt 原始字节计算 SHA-256,并在台账中记录 candidate_id、父候选、改动假设、模板摘要、Job 配置摘要、Harbor/Cookbook/Agent 版本、创建者和创建时间。换行符或模板编码不同都会产生新身份;不要在 Job 启动后原地编辑同一文件。结果记录再引用候选摘要和 Trial 路径,这样才不会把两份同名 candidate-b.j2 混为一谈。
比较时先按 Task 配对,而不是把全部 Trial 扔进两个总平均数。对 Task t,分别计算 A、B 在预定尝试上的主要指标,再得到 d_t = score_B,t - score_A,t;报告全部 d_t、任务类别和失败类型,然后才给跨 Task 汇总。配对减少的是 Task 难度差异造成的噪声,不会消除运行时段或模型服务漂移。若 A、B 的计划 Trial 数不同,先按事前缺失政策补齐分母或把实验标记为不完整,不能只保留恰好成功返回的配对。
候选台账还应记录每次 validation 查询。只保存最终最好分数会隐藏搜索规模:从两个候选中挑最高分,与从两百个自适应候选中挑最高分,选择偏差风险不同。停止规则可以是“达到固定候选数”“耗尽 metric-call 预算”或“连续若干提案没有达到事前最小改善”;不能在看到一次偶然高分后重写规则为“已收敛”。
22.4 从 trajectory 到最小改动
每次失败审计使用同一顺序,避免被一句“模型不够聪明”终止调查:
- 基础设施:Environment 是否构建、Agent 是否安装、凭据和网络是否有效;
- 任务与判定:instruction 是否可解,Verifier 是否误判或可被投机;
- 观察:关键证据是否真的出现在 Agent 可见的 observation 中;
- 决策:Agent 是否基于已有证据选择了错误动作;
- 执行:工具参数、退出码、文件和服务状态是否符合预期;
- 终止:成功后是否停止,失败后是否恢复或重复无效动作。
将结论写成结构化记录,而不是在 Prompt 文件旁留一句注释:
hypothesis_id: H-017
observed_on: [diagnose-python-service]
evidence:
- "trajectory step 6 在未读取 health endpoint 前修改端口"
candidate_change: "Prompt 增加修改前健康检查"
expected_effect: "development 中 precheck_present 从 0 变为 1"
disconfirming_result: "仍无 precheck,或 precheck 已存在但错误不变"
scope: prompt_only
这里的 step 和指标名是教学性格式,不是本章实测输出。实际引用应包含 Trial 路径、trajectory 摘要以及证据的 SHA-256,敏感 observation 先脱敏。
22.4.1 Prompt、工具还是循环
选择最靠近根因、改动面最小的层:
- Agent 不知道成功条件或漏掉稳定程序时,先改 Prompt;
- Agent 知道该查什么,却只能解析易变的人类文本时,改工具 schema、错误码或结构化 observation;
- Agent 反复调用同一失败工具、无法回滚或成功后不停手时,改循环状态、重试策略和终止谓词。
不要把工具输出里的标准答案复制进 Prompt,也不要让 Verifier 反馈直接暴露隐藏断言。可以把“缺少输出文件”转化为一般规则“提交前检查要求的输出路径”,不能把 /workspace/expected-secret.json 这样的隐藏路径写回候选。开发反馈必须经过泄露审查后才能进入自动优化器的 side information。
22.5 trajectory 效率是约束,不是捷径
满分但使用四倍工具调用、两倍时间和不可审计的 shell 操作,未必是更好的工程候选。Harbor 的 AgentContext 可以记录输入、缓存、输出 token 和 cost_usd,这些字段都是可选值;TrialResult 还保存 Trial 与各阶段时间。8 None 表示未观测,不能按 0 参与平均。
建议为每个候选记录:
质量:主 Reward、分维度 Reward、成功率、异常率
效率:Agent 阶段时间、工具调用数、输入/输出 token、已知成本
稳健:每类 Task 的最差表现、回归失败数、重复间波动
安全:越权工具调用、外网尝试、敏感信息命中、Verifier gaming
“每成功一次的已知成本”可写为 已报告总成本 / 成功数,但成功数为 0 时应为未定义;有 Trial 缺成本时,名称必须保留“已知”二字。工具调用数要从 ATIF 或受控日志计算,而不是用文本行数近似。
多目标不必强行压成一个权重和。更易审计的发布规则是分层门禁:
- 任何安全或泄露门禁失败,拒绝;
- regression 不允许新增确定性失败;
- validation 主质量指标不得低于事前非劣阈值;
- 在通过前三项的候选中,再比较成本、时间或工具调用;
- test 只评估最终冻结候选,不参与重新排序。
若使用 Pareto 前沿,也只是说“在已观察目标上不存在全面占优的候选”,不是说前沿上的每个候选都适合发布。权重、阈值或字典序必须在看 test 前确定,并保存选择理由。
22.6 Harbor Cookbook 的 GEPA 配方
GEPA(Genetic-Pareto)原始工作把 trajectory、工具结果和反馈用于自然语言反思,提出 Prompt 更新,并利用候选在不同样本上的表现进行 Pareto 式选择。论文报告的是其指定任务、模型与预算下的实验结果,不应外推为本项目的预期收益。9
锁定 Cookbook 的配方使用 GEPA optimize_anything API,把待优化对象定义为包裹 Task instruction 的 Prompt 模板。其 evaluate(candidate, example) 会运行一个 Harbor Trial,返回标量 Reward,并把 Verifier 输出、Agent 日志文本和异常作为 side information;虽然字典键名写作 Agent Trajectory,实际内容来自 agent/{agent_name}.txt,不是 ATIF trajectory.json。1011 当前 GEPA 官方 API 也把入口描述为“seed candidate + evaluator + dataset/valset”,并把候选提出、反思和选择交给优化引擎。12
实际调用链是:
GEPA candidate
│ 写 prompt_template.txt:candidate + {{ instruction }}
v
Harbor TrialConfig ──> TrialQueue ──> Agent ──> Verifier
^ │
└──── reward + 截断后的 Agent 日志/错误 ──┘
│
v
GEPA 下一轮反思
这不是 Harbor v0.18.0 内置的 harbor optimize 命令。配方直接构造 TrialConfig,用 TrialQueue(n_concurrent=...) 调度;队列的 submit() 返回 coroutine,真正的并发上限由 semaphore 控制。13 临时目录中的 Prompt 通过 AgentConfig.kwargs.prompt_template_path 注入,Trial 完成后只读取最多 3,000 字符的 Agent/Verifier 文本,随后删除整个临时目录。11 因此完整 trajectory 是否可供复盘,取决于你是否在删除前额外持久化经过脱敏的证据。
配方从 Registry 下载 medagentbench 版本 1.0,按 Task 名最后的数字分层。其注释约定每类索引 1~20 进 train、21~30 进 val;实际条件是 idx <= 20 进 train,其余全部进 val。--max-val 只截取排序后的 val 前缀。14 optimize_anything 同时收到 dataset=train 与 valset=val,最终写出 result.best_candidate,并从 val_aggregate_scores[result.best_idx] 报告 best val score。10 配方没有第三个隐藏 test 分区,所以它适合演示优化和候选选择,不能单独提供最终未见集结论。
还有三个复现边界需要在运行前修正:
- Cookbook 脚本头部把
harbor写为未固定依赖,并从 GEPA Git 的命名分支安装,而不是不可变提交;本书的正式实验必须把 Harbor 固定为 v0.18.0/精确提交,并把 GEPA 固定为经验证的发布版本或提交。15 - README 声称在特定 val set、Agent 和模型上出现过一组收益数字,但本章没有运行 Docker、模型或该实验;这些数字只能视为 Cookbook 作者报告,不能抄成我们的实测或预期。16
- 配方把
OPENAI_API_KEY、ANTHROPIC_API_KEY中存在的值传给 Agent,并要求外部模型完成 Agent 行动与反思。运行前要限制预算和并发,避免把含患者信息或凭据的 trajectory 发给未获准的反思服务。11
22.6.1 把配方改造成可发布实验
不要先运行默认的 100 次评估预算。先做以下审计:
- 固定 Harbor、Cookbook、GEPA、Agent CLI、Model ID、Dataset/Task 摘要和容器镜像;
- 把 Task 分配表作为版本化输入,加入“分区无交集”和“近重复不跨区”测试;
- 用 Oracle/确定性夹具验证 Reward,再用 2~3 个 development Task 做付费 smoke;
- 将
max_metric_calls、候选提案数、反思模型预算、Harbor 并发与总费用都设上限;Cookbook 确实把--max-evals映射到EngineConfig.max_metric_calls,把--max-iterations映射到候选提案上限。17 - 只把经过脱敏、不会暴露隐藏答案的 side information 交给反思模型;
- 保存每个候选的文本摘要、父候选、开发分、validation 查询次数、异常和成本;
- 根据 validation 选出一个候选并冻结摘要,然后在独立 regression 与 test Job 上评估。
自动优化器扩大的是搜索吞吐量,也扩大了泄露、过拟合和成本风险。它不会修复有漏洞的 Verifier,不会证明 Dataset 代表生产分布,也不会自动把前后差异变成因果效应。
22.7 失败模式与排查
22.7.1 “validation 一直涨,test 不涨”
先检查候选是否查询 validation 过多、分区是否有模板近重复、最好轮次是否由同一 validation 选择。不要再读 test 的逐题轨迹来“快速修复”;那会把 test 变成新的 development。正确动作是冻结本次失败报告,把已暴露 test 降级为 regression,并为下一版本建立新的未暴露 test。
22.7.2 “Reward 涨了,但事故处理更差”
检查 Reward 是否只覆盖最终文件,遗漏证据质量、安全和恢复步骤;检查 Prompt 是否学会 Verifier gaming。加入错误答案与投机答案回归,先加固确定性 Verifier,再继续优化。若评价标准发生变化,应重跑所有候选,不能把旧 Reward 与新 Reward 直接拼接。
22.7.3 “自动优化后成本失控”
区分外层候选评估和内层 Agent rollout。一次 evaluate 可能包含完整容器、Agent 模型调用与 Verifier;反思模型又是额外成本。查看实际 metric call 数、失败重试、并发、token 和账单,不要只数“迭代”。先降低 development 子采样和并发,用确定性小任务检查 plumbing,再扩大预算。
22.7.4 “新 Prompt 赢了”但实验不可归因
核对 Agent CLI、模型完整标识、工具、超时、网络、Task 摘要和运行时段。只要除 Prompt 外还有变化,就把结论写成“系统 B 在该协议下与更高得分相关”。重新建立只改变 Prompt 的配对实验后,才有资格讨论 Prompt 的效应;即使如此,结论范围仍受 Dataset 和运行条件限制。
22.8 验收清单
本章实践通过需要同时满足:
- A、B 两份 Job 都能被 v0.18.0
JobConfig解析,且除候选身份外的实验字段一致; - development、validation、test、regression 的 Task 摘要清单无交集,近重复检查有记录;
- 每个改动关联失败假设、证据、反证条件和唯一改动层;
- 异常、超时和缺失 Reward 有事前处理政策;
- 质量、效率、成本与安全指标中的缺失值保持缺失;
- 自动优化预算、依赖提交、候选谱系和 side information 脱敏可审计;
- 冻结候选只查询一次未暴露 test,若据此修改则启动新的发布周期。
22.9 本章小结
- 评测驱动优化从失败假设开始,不从改写 Prompt 开始。
- Prompt、工具和循环是不同实验因素;一次只改一层,结论才可解释。
- development 用于学习,validation 用于选择,test 用于一次性发布结论,regression 用于防止旧故障复发。
- Harbor 提供可复现的 Trial/Job、Reward、trajectory 与使用量证据,但不会自动阻止泄露或建立因果关系。
- GEPA Cookbook 展示的是外部优化器调用 Harbor Trial 的配方,不是 v0.18.0 内置优化命令,也没有替你保留隐藏 test。
- 多目标门禁比事后调权重更易审计;
None不是零,上游报告不是本地实测。
22.10 练习
- 从一个失败 Trial 写出两个相互竞争的假设;为每个假设给出最小改动和反证结果。
- 为贯穿项目生成四分区清单,加入精确重复、规范化文本摘要和基础镜像摘要检查;解释仍可能漏掉哪类近重复。
- 把两个 Prompt Job 扩展到三个 Task、五次尝试;预先写出随机/区组运行计划、失败计入规则和预算停止线,但不要填教学性结果。
- 修改 GEPA 配方设计,使完整 trajectory 在删除临时目录前经过脱敏并按候选摘要归档;列出绝不能发给反思模型的字段。
- 设计一个发布门禁:质量非劣、安全零新增、regression 全通过,并在候选通过后最小化已知成本。说明为何它不等同于把四个指标简单加权。