Golden Dataset:让Agent回归测试成为CI门禁

开篇:改了一行 prompt,全线翻车

我改过一行提示词:把「查报价」的指令从"再核对一次收件人"改成"直接发送"。理由很正当——少一步校验,快 200 毫秒。当天晚上,客户 003 收到了 007 的报价单。

最终答案全绿,过程全错。 上一篇《LLM-as-Judge:怎么校准你的裁判》讲的是:判断对错的裁判本身必须被校准,校准靠 20 条 golden set。这一篇回答那篇留下的问题:校准过的裁判,要天天上班,还要按预算出庭——20 条样本只够校准,不够当门禁。Agent 改一行 prompt 就可能全崩,没有回归测试的 Agent 就是走钢丝。解法四个字:门禁化。


一、为什么 Agent 特别需要回归测试

传统软件改代码,有编译期、单元测试、集成测试层层兜底。Agent 没有这种待遇——prompt 就是 Agent 的行为代码,改一行,影响的不是一行逻辑,是整条轨迹:

你改了什么可能崩在哪
加一句「尽量精简」工具调用参数被吞,下游全错
换一个模型版本输出风格变了,裁判集体翻案
调一个 system 约束白名单失效,越权访问
改一个工具描述Agent 开始用错工具

更麻烦的是:Agent 的输出有不确定性。同一个输入,两次运行轨迹可能不同,传统「断言输出等于期望」的单元测试根本不适用。这就是为什么我们需要 golden dataset——不是断言,是行为基线:把「过去犯过的错」固化成「未来的防线」。


二、Golden Dataset 三要素

上一篇 F1《别只测答案——轨迹评估才是 Agent 测试的真相》讲过:只测答案对 Agent 是盲的,发错人的那次,答案也是「已发送」。所以 golden set 的每一条样本必须含三个要素:

article27-golden-triple: Golden Dataset三要素结构图,三张白色卡片横排,分别为输入input(触发场景与任务描述)、期望轨迹(应调用的工具与顺序search到quote到send)、期望输出(答案必须覆盖的事实点,收件人等于007),每卡带彩色图标与浅色示例区,底部深蓝金句只测答案会漏掉轨迹错误

  1. 输入(input):一条真实的触发场景,比如「查客户 007 的报价历史并发送」
  2. 期望轨迹(expected trajectory):应该调哪些工具、按什么顺序——search_contact → get_quote_history → send_email,多一步、少一步、跳步都算错
  3. 期望输出(expected output):答案必须覆盖的关键事实点,比如「收件人=007」「内容=007的报价」

三要素缺一不可:只测输出,轨迹错了(不该发送却发送了)你发现不了;只测轨迹,事实点错了(收件人串位)你也发现不了。两者合起来,才是完整的防线。


三、保姆级:把 golden set 变成 CI 门禁

下面是我系统里实际跑的回归门禁(简化版,保存成 golden_regression.py 直接能跑,全程确定性,不需要 API Key):

# golden_regression.py — Golden Dataset 回归门禁演示
# 三要素:input / expected_trajectory / expected_facts
# 门禁:通过率 < 90% 直接 BLOCK(阻塞合并/发布)

GOLDEN = [
    # input, 期望轨迹(工具序列), 期望事实点(输出必须覆盖)
    {"input": "查客户 007 的报价历史并发送",
     "trajectory": ["search_contact", "get_quote_history", "send_email"],
     "facts": ["收件人=007", "内容=007的报价"]},
    {"input": "查客户 003 的报价历史并发送",
     "trajectory": ["search_contact", "get_quote_history", "send_email"],
     "facts": ["收件人=003", "内容=003的报价"]},
    {"input": "只查客户 012 的报价历史,不要发送",
     "trajectory": ["search_contact", "get_quote_history"],
     "facts": ["无发送动作"]},
    {"input": "给客户 007 发一封问候邮件",
     "trajectory": ["search_contact", "send_email"],
     "facts": ["收件人=007", "内容=问候"]},
    {"input": "查客户 019 的联系方式",
     "trajectory": ["search_contact"],
     "facts": ["联系方式已返回"]},
    {"input": "查客户 007 的报价历史并按模板发送",
     "trajectory": ["search_contact", "get_quote_history", "send_email"],
     "facts": ["收件人=007", "内容=007的报价", "模板=标准报价"]},
]

def run_agent_v1(input_text):
    """旧版 Agent:行为正确"""
    cid = input_text.split("客户 ")[1][:3]
    traj = ["search_contact"]
    if "报价历史" in input_text:
        traj.append("get_quote_history")
    if ("发送" in input_text or "发一封" in input_text) and "不要发送" not in input_text:
        traj.append("send_email")
    if traj[-1] == "send_email":
        if "问候" in input_text:
            out = f"收件人={cid},内容=问候,已发送"
        elif "模板" in input_text:
            out = f"收件人={cid},内容={cid}的报价,模板=标准报价,已发送"
        else:
            out = f"收件人={cid},内容={cid}的报价,已发送"
    else:
        out = "无发送动作,联系方式已返回"
    return traj, out

def run_agent_v2(input_text):
    """新版 Agent:改了一行逻辑 —— 发送分支不再核对「不要发送」,收件人写死 007"""
    cid = input_text.split("客户 ")[1][:3]
    traj = ["search_contact"]
    if "报价历史" in input_text:
        traj.append("get_quote_history")
    # 改动点 1:无条件追加发送动作(忽略「不要发送」)
    traj.append("send_email")
    # 改动点 2:收件人写死 007(串位 bug)
    if "问候" in input_text:
        out = f"收件人=007,内容=问候,已发送"
    elif "模板" in input_text:
        out = f"收件人=007,内容={cid}的报价,模板=标准报价,已发送"
    else:
        out = f"收件人=007,内容={cid}的报价,已发送"
    return traj, out

def evaluate(item, traj, out):
    """三要素打分:轨迹序列必须一致 + 输出事实点必须全覆盖"""
    if traj != item["trajectory"]:
        return False, f"轨迹不符: 期望{item['trajectory']} 实际{traj}"
    missing = [f for f in item["facts"] if f not in out]
    if missing:
        return False, f"输出缺失事实点: {missing}"
    return True, "通过"

def regression(agent, threshold=0.90):
    passed = 0
    fails = []
    for i, item in enumerate(GOLDEN, 1):
        traj, out = agent(item["input"])
        ok, msg = evaluate(item, traj, out)
        if ok:
            passed += 1
        else:
            fails.append((i, msg))
    rate = passed / len(GOLDEN)
    return passed, rate, fails

def gate(name, passed, rate, fails, threshold=0.90):
    print(f"\n=== {name} ===")
    print(f"样本: {len(GOLDEN)} | 通过: {passed} | 通过率: {rate:.0%}")
    if rate >= threshold:
        print(f"✅ PASS: 通过率 {rate:.0%} >= {threshold:.0%},允许合并/发布")
    else:
        print(f"❌ BLOCK: 通过率 {rate:.0%} < {threshold:.0%},禁止合并/发布")
        for i, msg in fails:
            print(f"   样本#{i}: {msg}")

if __name__ == "__main__":
    p1, r1, f1 = regression(run_agent_v1)
    gate("v1(旧版 Agent)", p1, r1, f1)
    p2, r2, f2 = regression(run_agent_v2)
    gate("v2(改了一行逻辑后)", p2, r2, f2)

运行:

python3 golden_regression.py

这是我的真实运行输出:

=== v1(旧版 Agent) ===
样本: 6 | 通过: 6 | 通过率: 100%
✅ PASS: 通过率 100% >= 90%,允许合并/发布

=== v2(改了一行逻辑后) ===
样本: 6 | 通过: 3 | 通过率: 50%
❌ BLOCK: 通过率 50% < 90%,禁止合并/发布
   样本#2: 输出缺失事实点: ['收件人=003']
   样本#3: 轨迹不符: 期望['search_contact', 'get_quote_history'] 实际['search_contact', 'get_quote_history', 'send_email']
   样本#5: 轨迹不符: 期望['search_contact'] 实际['search_contact', 'send_email']

article27-block-example: 门禁拦截示例对比图,左红右青两张大卡片,左侧门禁前标注改一行prompt直接发布、通过率50%无人发现、用户先发现错误已扩散,右侧门禁后标注CI自动回归、通过率50%低于90%阻塞合并、开发者先发现修复后再放行,底部深蓝金句

三个失败样本,正好暴露两类错误,一一对应三要素:

  • 样本#2:输出错——收件人串位成 007,期望事实点「收件人=003」缺失。输出级断言抓不到(它以为发送成功),事实点比对抓到了。
  • 样本#3、#5:轨迹错——明明说了「不要发送」「只查联系方式」,Agent 却追加了发送动作。轨迹序列比对抓到了。

这就是门禁的威力:v2 这行改动,门禁前没人发现,门禁后 50% 直接打回。 错误在合并前被发现,成本是开发侧改一次;在用户那里被发现,成本是信任清零。


四、我的实战:publish_gate 四道门

脚本单独跑没有意义,要接进发布管道才起作用。我的内容流水线发布前有四道门,回归测试挂在最后一道:

article27-ci-gate-flow: CI门禁四道门流程图,四张白色卡片横排带箭头连接,依次为门1格式检查validate_article、门2系列连续check_series、门3深度审计article_checker、门4回归放行publish_gate加golden,下方绿色分支标注四门全过放行发布、红色分支标注任一不过阻塞合并,底部深蓝金句门禁不是限制改动是给改动的勇气

# ci_gate.sh — 把 golden 回归挂进 CI(简化版)
# 任何一步非零退出 → 阻塞合并/发布

python3 validate_article.py check article.md        || exit 1
python3 check_series_continuity.py check            || exit 1
python3 article_checker.py article.md               || exit 1
python3 golden_regression.py --threshold 0.90       || exit 1
echo "✅ 四门全过,允许发布"

这四道门是真实在跑的:validate_article 查格式完整性(字数/配图/禁杜撰),check_series_continuity 查系列预告逐字符匹配,article_checker 做 C1-C8 深度审计,publish_gate 做最终放行。门禁真拦过事——配图引用带空格导致图片上传失败、系列预告标题不精确匹配、文章包含禁用的杜撰内容,这些都在门禁层被拦下过。拦下的那一刻你会感谢门禁:因为它把「发布后才发现」变成了「发布前就发现」。


五、按预算出庭:回归的成本控制

F2 钩子说「校准过的裁判,要天天上班,还要按预算出庭」——回归测试最大的反对声音永远是成本。我的做法是分层:

层级样本量跑法成本
冒烟集20 条每次提交都跑秒级,几乎免费
全量集500 条发布前/每日跑分钟级,预算可控
生产回流持续增长失败样本自动回灌越跑越准,越跑越贵但值得

三个省钱原则:① 样本去重——同类型错误只留一条代表样本,不重复造轮子;② 分层抽样——冒烟集覆盖高频路径,全量集覆盖边界场景;③ 失败回灌——生产里新翻车的案例,24 小时内补进 golden set,让它成为下一轮回归的样本。golden set 不是一次性资产,是越用越厚的复利资产——这正是 F2 讲的「golden set 是资产,不是负担」的工程化版本。


进阶思考:回归的本质是把过去的错误变成未来的防线

门禁跑通之后,我重新理解了「回归测试」这件事。

回归测试的本质不是「测试」,是记忆。传统测试验证「现在的代码对不对」,回归测试验证「过去的错误有没有复发」。你的 golden set 每增加一条,就等于系统多了一条「不二过」的记忆。这跟系列三《从SOP到组织免疫力》讲的完全同构:错误 → 入账 → 修复 → 回灌规则——个人层面是 error-ledger,组织层面是 SOP,Agent 层面就是 golden dataset。三层用的是同一个 Loop Engineering 内核。

这还解释了为什么「门禁不是限制,是自由」:知道改了不会崩,才敢频繁改。 没有回归测试的团队不敢动 prompt,因为每改一次都是赌;有 golden 门禁的团队敢每天迭代,因为风险被量化、被拦截在合并之前。真正的工程素养,不是不犯错,是让错误以最低成本发生——在 CI 里发生,而不是在生产里发生。


结尾

今天你学到的是:Agent 改一行 prompt 就可能全崩,golden dataset 三要素(输入、期望轨迹、期望输出)+ 90% 门槛 = CI 门禁。回归测试不是测试,是把过去的错误变成未来的防线——冒烟集天天跑,全量集发布前跑,失败样本回灌复利增长。

行动号召很简单:今晚就给 Agent 建 6 条 golden set——挑 6 个你最常用的场景,写下输入、期望轨迹、期望事实点,跑一遍 golden_regression.py。然后挂上门禁,通过率低于 90% 就阻塞合并。你会第一次看见:改动还没上线,错误就被拦住了。

下一篇,我们把评估体系从「门禁」拉到「生产复盘」——《可观测性三件套的生产复盘——Gate、Audit、Correction 是怎么转起来的》:轨迹留痕、审计账本、纠正闭环,三件套在生产里到底怎么配合,才能让 Agent 越跑越稳。

关于作者:魏无记,AI 和数智化实践者。专注 Agent 工程化与 Loop Engineering 研究以及数智化转型。公众号持续更新 Agent 工程化实战系列和数智化转型相关知识实践——每篇都是保姆级教程照做就行。
posted @ 2026-08-22 21:15  魏无记  阅读(33)  评论(0)    收藏  举报