介绍如何构建输入+轨迹+输出三层结构的 golden dataset,并配置 90% 通过率门禁拦截 prompt 变更引发的 agent 回归问题,提供可直接复制的 demo 脚本。
痛点:你的 agent 昨天还好好的。今天你在 prompt 里改了一行代码,一切就全崩了——而你根本不知道是哪处改动惹的祸。传统的单元测试无法适用,因为 agent 输出是非确定性的。你将学到:如何构建包含三个要素的 golden dataset(输入、预期轨迹、预期输出),并将其接入 CI 作为回归门禁,在通过率低于 90% 时阻断合并——附带一个无需 API key 的可复制、确定性演示脚本。
我曾在 prompt 中改了一行指令:把"check quote"的指令从"verify the recipient again"改为"send directly"。理由很充分——减少一个验证步骤,速度提升 200 毫秒。但当晚,客户 003 收到了客户 007 的报价单。
最终答案全绿,流程全错。
在上一篇文章《LLM-as-Judge:如何校准你的裁判》中,我们认识到裁判本身必须经过校准,而校准依赖于一个 20 条案例的 golden set。本文回答了那篇文章留下的一个问题:一个经过校准的裁判必须每天上班、出现在法庭上——预算是有限的。20 个样本足够做校准,但不足以做门禁。Agent 可能因为一行 prompt 改动而崩溃;没有回归测试的 agent 就像走钢丝。四字解决方案:gate it。
传统软件有编译时检查、单元测试和集成测试作为分层安全网。Agent 什么都没有——prompt 就是 agent 的行为代码。改一行,影响的不是一行逻辑,而是整个轨迹:
更糟的是,agent 输出是非确定性的。同一个输入在两次运行中可能产生不同的轨迹,所以经典的"assert output equals expectation"单元测试根本不适用。这就是为什么我们需要 golden dataset——不是断言,而是行为基线:把"过去犯的错误"变成"未来的防御线"。
在 F1《停止测试最终答案:轨迹评估才是 Agent 质量的真相》中,我们已经看到,仅测试答案对 agent 来说是盲目的——在那个失误中,答案实际上是"已发送"。所以 golden set 中的每个样本必须包含三个要素:

Input:一个真实的触发场景,例如"查询客户 007 的报价历史并发送"
Expected trajectory:应该调用的工具及其顺序——search_contact → get_quote_history → send_email。多一步、少一步或跳步都算错误
Expected output:答案必须覆盖的关键事实,例如"recipient=007"、"content=quote for 007"
三者缺一不可:只测输出,轨迹错误(不该发却发了)不会被发现;只测轨迹,事实错误(收件人混淆)不会被发现。三者结合,形成完整防御。
以下是我的系统中实际运行的回归门禁(已简化——保存为 golden_regression.py,完全确定性,无需 API key):
# golden_regression.py — Golden Dataset regression gate demo
# three elements: input / expected_trajectory / expected_facts
# gate: pass rate < 90% blocks the merge/release
GOLDEN = [
# input, expected trajectory (tool sequence), expected facts (must be covered in output)
{"input": "Look up customer 007's quote history and send it",
"trajectory": ["search_contact", "get_quote_history", "send_email"],
"facts": ["recipient=007", "content=007's quote"]},
{"input": "Look up customer 003's quote history and send it",
"trajectory": ["search_contact", "get_quote_history", "send_email"],
"facts": ["recipient=003", "content=003's quote"]},
{"input": "Only look up customer 012's quote history, do not send",
"trajectory": ["search_contact", "get_quote_history"],
"facts": ["no send action"]},
{"input": "Send customer 007 a greeting email",
"trajectory": ["search_contact", "send_email"],
"facts": ["recipient=007", "content=greeting"]},
{"input": "Look up customer 019's contact info",
"trajectory": ["search_contact"],
"facts": ["contact info returned"]},
{"input": "Look up customer 007's quote history and send with template",
"trajectory": ["search_contact", "get_quote_history", "send_email"],
"facts": ["recipient=007", "content=007's quote", "template=standard quote"]},
]
def run_agent_v1(input_text):
"""Legacy agent: correct behavior"""
cid = input_text.split("customer ")[1][:3]
traj = ["search_contact"]
if "quote history" in input_text:
traj.append("get_quote_history")
if ("send" in input_text or "Send" in input_text) and "do not send" not in input_text:
traj.append("send_email")
if traj[-1] == "send_email":
if "greeting" in input_text:
out = f"recipient={cid}, content=greeting, sent"
elif "template" in input_text:
out = f"recipient={cid}, content={cid}'s quote, template=standard quote, sent"
else:
out = f"recipient={cid}, content={cid}'s quote, sent"
else:
out = "no send action, contact info returned"
return traj, out
def run_agent_v2(input_text):
"""New agent: one line of logic changed — the send branch no longer respects 'do not send', and the recipient is hard-coded to 007"""
cid = input_text.split("customer ")[1][:3]
traj = ["search_contact"]
if "quote history" in input_text:
traj.append("get_quote_history")
# change point 1: unconditionally append the send action (ignoring "do not send")
traj.append("send_email")
# change point 2: recipient hard-coded to 007 (mix-up bug)
if "greeting" in input_text:
out = f"recipient=007, content=greeting, sent"
elif "template" in input_text:
out = f"recipient=007, content={cid}'s quote, template=standard quote, sent"
else:
out = f"recipient=007, content={cid}'s quote, sent"
return traj, out
def evaluate(item, traj, out):
"""Score against the three elements: trajectory must match exactly + all expected facts must be covered"""
if traj != item["trajectory"]:
return False, f"trajectory mismatch: expected {item['trajectory']} got {traj}"
missing = [f for f in item["facts"] if f not in out]
if missing:
return False, f"output missing facts: {missing}"
return True, "pass"
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"Cases: {len(GOLDEN)} | Passed: {passed} | Pass rate: {rate:.0%}")
if rate >= threshold:
print(f"PASS: pass rate {rate:.0%} >= {threshold:.0%}, merge/release allowed")
else:
print(f"BLOCK: pass rate {rate:.0%} < {threshold:.0%}, merge/release blocked")
for i, msg in fails:
print(f" case#{i}: {msg}")
if __name__ == "__main__":
passed_v1, rate_v1, fails_v1 = regression(run_agent_v1)
gate("v1 (legacy agent)", passed_v1, rate_v1, fails_v1)
passed_v2, rate_v2, fails_v2 = regression(run_agent_v2)
gate("v2 (after a one-line logic change)", passed_v2, rate_v2, fails_v2)
python3 golden_regression.py
以下是我的实际运行输出:
=== v1 (legacy agent) ===
Cases: 6 | Passed: 6 | Pass rate: 100%
PASS: pass rate 100% >= 90%, merge/release allowed
=== v2 (after a one-line logic change) ===
Cases: 6 | Passed: 3 | Pass rate: 50%
BLOCK: pass rate 50% < 90%, merge/release blocked
case#2: output missing facts: ['recipient=003']
case#3: trajectory mismatch: expected ['search_contact', 'get_quote_history'] got ['search_contact', 'get_quote_history', 'send_email']
case#5: trajectory mismatch: expected ['search_contact'] got ['search_contact', 'send_email']

这三个失败案例精准暴露了两类错误,与三个要素一一对应:
case#2:输出错误——收件人被混淆成了 007,所以预期的 fact"recipient=003"缺失。输出级断言无法捕获(它认为发送成功了);fact 逐点对比可以捕获。
case#3 和 case#5:轨迹错误——输入明确说了"不要发送"/"只查询联系信息",但 agent 仍然追加了发送动作。轨迹序列对比可以捕获。
这就是门禁的力量:那一行 v2 改动在门禁前无人知晓;门禁后则被放大到 50% 的失败率。错误在合并前被捕获,修复成本在开发侧;在用户侧被捕获,代价是信任归零。
单独运行的脚本没有意义,只有接入发布流程才有价值。我的内容流水线在发布前有四道门禁,回归测试挂在最后一道:

# ci_gate.sh — hook golden regression into CI (simplified)
# any step exiting non-zero blocks the merge/release
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 "all four gates passed, release allowed"
这四道门禁真实运行:validate_article 检查格式完整性(长度、图片、无臆造);check_series_continuity 逐字符检查系列衔接;article_checker 运行 C1-C8 深度审计;publish_gate 给出最终 go/no-go。
门禁确实拦截过问题——图片引用带多余空格导致上传失败、系列 hook 标题不精确匹配、包含臆造内容的文章。都在门禁层被捕获了。被门禁拦截的那一刻你会感谢它:它把"发布后才发现"变成了"发布前就发现"。
F2 说过校准后的裁判必须"每天都来上班、在预算内上庭"——对回归测试最大的反对声永远是成本。以下是我的分层方案:
三大成本节约原则:① 样本去重——每个错误类只保留一个代表性案例,不要重复造轮子;② 分层采样——烟雾集覆盖高频路径,完整集覆盖边界情况;③ 失败回流——线上新失败的案例在 24 小时内加入 golden set,成为下一轮的比对样本。Golden set 不是一次性资产,而是随使用不断累积的复利资产——这就是 F2"golden set 是资产而非负担"的工程版本。
门禁运行后,我重新理解了"回归测试"的本质。
回归测试不是"测试",而是记忆。传统测试验证当前代码是否正确;回归测试验证过去的错误是否重现。Golden set 中每增加一个案例,就是给系统增加一条"绝不再犯"的记忆。这与系列 3 的《从 SOP 到免疫》完全一致:错误→台账→修复→反馈到规则。个人层面是错误台账,组织层面是 SOP,agent 层面是 golden dataset。三者都运行在同一套 Loop Engineering 核心上。
这也解释了"门禁不是限制,而是自由":当你知道改动不会出问题,就敢频繁改动。缺少回归测试的团队不敢碰 prompt,因为每次改动都是赌博;有 golden gate 的团队每天迭代,因为风险被量化、在合并前就被拦截。真正的工程成熟度不是从不犯错,而是让错误发生在最低成本的地方:在 CI,不是在生产。
今天你学到:Agent 可能因为一行 prompt 改动而崩溃,golden dataset 三要素(输入、预期轨迹、预期输出)+ 90% 阈值 = CI gate。回归测试不是测试——它是把过去的错误变成未来的防御:烟雾集在每次提交时运行,完整集在发布前运行,失败样本回流实现复利增长。
行动号召很简单:今晚就为你的 agent 建立一个 6 案例的 golden set——挑选你最常用的 6 个场景,写下输入、预期轨迹和预期事实,然后运行 golden_regression.py。接着把它接入门禁,在通过率低于 90% 时阻断合并。第一次你会看到:改动根本没来得及发布,错误就已经被捕获了。
下一步,我们把评估系统从"门禁"拉到"生产回顾"——《可观测性三件套的生产回顾:Gate、Audit、Correction 如何协同运转》:轨迹追踪、审计台账、纠正环——三者如何在生产中真正协作,让 agent 随时间越来越稳定。
关于作者:Wu Ji(无记)——AI 与数字化实践者,专注于 Agent 工程、Loop Engineering 和数字化转型。实用、手把手教程——跟着做就能用。