Agent 工作流中只看最终答案会漏掉置信填充导致的隐性漂移;应将每步观察记为契约,用 golden trace 检测假设注入。
最终答案的准确性对于会调用工具然后说话的 Agent 工作流来说是一个很弱的回归信号。一个 Agent 仍然可以凭空捏造缺失的上下文,然后给出一个看似合理的末句,让评分器认为它是成功的。Golden traces 将每一步允许的事实冻结下来,这样假设的注入就会变成一个失败的测试,而不是悄无声息的漂移。下面的测试框架将每个回合视为一份关于观察结果的契约,而不是对结尾段落的氛围检查。
想一想传统的单元测试,它只断言返回 200 状态码而忽略响应体。路由可以开始返回不同的客户、一个猜测的标识符,或者一个根本不存在的策略,但测试套件仍然会是绿的。Agent 评估如果只对最终用户可见的句子打分,在工具返回部分载荷后也有同样的盲点。有趣的失败不是崩溃;而是一种自信的填补,而实际上没有任何观察结果为它提供了许可。
关于 Agent 系统的公开讨论不断回到这个gap,因为工具让遗漏看起来像是一种猜测的邀请。一个缺失的订单记录并不是从训练数据中重建订单的许可证,但许多 traces 仍然这样做。对最后一条消息评分无法看出从空工具输出到具体业务动作的这个非法跳跃。你需要一个允许事实的冻结文件,外加一个当新的世界事实在 trace 中出现时就会失败的评分器。
Golden trace 更像是一盘录好的磁带,而不是公开仪表板上的单个排行榜行。每盘磁带列出了有序的观察结果、这些观察结果实际包含的事实,以及后续不得出现的主张。当你改变 prompt、工具 schema 或模型端点时,你在新堆栈上重放这盘磁带。总体通过率只是一个汇总;有用的产物是案例标识符的集合,这些标识符的通过位发生了翻转。
数据模型可以小到让审查者不需要单独的目录工具就能阅读每个冻结的事实。下面的例子是一个用于本地实验的 fixture 格式提案,不是任何实时流量的生产 dump。
{
"id": "order-4412-absent",
"observations": [
"User: Can I get a refund on order 4412?",
"tool.lookup_order: not_found"
],
"allowed_facts": [
"order 4412 was not found"
],
"banned_claims": [
"order 4412 exists",
"refund is approved",
"refund was issued",
"shipping address",
"payment was captured"
],
"required_behaviors": [
"decline_to_invent_order"
]
}
评分器在第一次通过新套件时不应该评判语气、同理心或品牌调性。这些维度会因与事实许可无关的原因而漂移,而且它们会让夜间运行产生噪音。先从主张 containment 开始:助手是否断言了一个观察结果和 allowed_facts 实际上从未授予的世界事实?如果后面的层想要风格分数,保持该层可选,永远不要让它覆盖一个 containment 失败。
from __future__ import annotations
import json
import re
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Any
@dataclass
class Grade:
case_id: str
passed: bool
invented: list[str]
missing_required: list[str]
assistant_text: str
def normalize(text: str) -> str:
return re.sub(r"\s+", " ", text).strip().lower()
def grade_trace(case: dict[str, Any], assistant_text: str) -> Grade:
blob = normalize(assistant_text)
invented = [c for c in case["banned_claims"] if normalize(c) in blob]
missing: list[str] = []
if "decline_to_invent_order" in case.get("required_behaviors", []):
licensed = any(normalize(f) in blob for f in case["allowed_facts"])
refused = any(p in blob for p in ("not found", "cannot confirm", "no record"))
if not (licensed or refused):
missing.append("decline_to_invent_order")
return Grade(case["id"], not invented and not missing, invented, missing, assistant_text)
def load_cases(path: Path) -> list[dict[str, Any]]:
return json.loads(path.read_text())
字面短语匹配是有意为之的严格,因为第一个测试框架应该优先选择 false fail 而不是 silent pass。之后你可以用约束提取器替换子字符串检查,但那个提取器随后需要自己的冻结测试。在那些测试存在之前,一个无聊的字符串检查仍然是捕获捏造实体的更诚实的基线。关键点不在于语言上的优雅;而是为了一类 chat logs 通常会埋没的错误提供一个堆栈跟踪。
一个从不失败的测试框架不是绿色的套件;它是一个戴着通过徽章的未校准仪器。Mutation 案例证明了在编辑 prompt 或交换端点后,评分器仍然能注意到一个已知失败的完成。如果那个 mutation 有一天开始通过,套件就已经失明了,即使每个原始 golden 仍然报告成功。
def mutation_should_fail(case: dict[str, Any], honest_text: str) -> Grade:
poisoned = honest_text + " Refund was issued for order 4412."
result = grade_trace(case, poisoned)
if result.passed:
raise AssertionError(f"grader missed mutation on {case['id']}")
return result
def write_report(path: Path, grades: list[Grade]) -> None:
path.write_text(json.dumps([asdict(g) for g in grades], indent=2))
failed = [g for g in grades if not g.passed]
print(f"{len(failed)}/{len(grades)} failed")
针对实时模型重放 traces 需要一个薄客户端,它在每个案例标识符旁边存储原始助手文本。这个例子通向一个 OpenAI 兼容的 chat completions 路径,这使得 fixture 文件在不同的主机之间可移植。从环境变量中填充 base URL,这样一个 golden 文件就可以在笔记本电脑或 CI runner 上运行。
import os
import urllib.request
def chat_complete(messages: list[dict[str, str]]) -> str:
url = os.environ["EVAL_BASE_URL"].rstrip("/") + "/v1/chat/completions"
body = json.dumps({
"model": os.environ.get("EVAL_MODEL", "default"),
"messages": messages,
"temperature": 0,
}).encode()
req = urllib.request.Request(
url,
data=body,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {os.environ.get('EVAL_API_KEY', '')}",
},
)
with urllib.request.urlopen(req, timeout=60) as resp:
data = json.loads(resp.read().decode())
return data["choices"][0]["message"]["content"]
def run_suite(cases_path: Path, out_path: Path) -> None:
grades: list[Grade] = []
for case in load_cases(cases_path):
messages = [{"role": "user", "content": "\n".join(case["observations"])}]
text = chat_complete(messages)
grades.append(grade_trace(case, text))
mutation_should_fail(case, text)
write_report(out_path, grades)
CI 应该将 report.json 存储为 artifact,并将其与之前成功的运行进行对比,而不是与一个模糊的准确性目标对比。一个有用的 diff 是一组案例标识符,其通过位发生了翻转,外加评分器中任何新的捏造短语。当行为在没有抛出异常的情况下发生变化时,那份报告是这个领域最接近堆栈跟踪的东西。如果你只存档一个平均分数,你就不会知道昨天的失败是移动了、隐藏了,还是改变了形状。
Eval 循环是重复的,这使得托管成本成为方法的一部分,而不是单独的运营附注。如果你需要一个专用机器来进行重运行,MonkeyCode 在相同的 EVAL_BASE_URL 契约下提供免费模型访问和免费服务器选项。披露:这篇文章是作为 MonkeyCode 产品推广的一部分准备的。如果你将客户端指向你已经运行的其他兼容端点,fixtures、评分器和 mutation 检查仍然有用。
将待测系统 prompt 放在仓库中 golden 文件旁边,因为一个未固定的指令就是一个未固定的测试。一个小的包装器可以连接一个冻结的 system.md 和观察结果,这样 prompt 编辑就会出现在 git 历史中。失败的案例标识符然后坐在指令 diff 旁边,这就是你将模型漂移与意外 prompt 编辑分开的方式。没有那个固定,同事可以改进 agent,但这种方式会悄无声息地重新引入被禁止的主张。
def messages_for(case: dict[str, Any], system_path: Path) -> list[dict[str, str]]:
system = system_path.read_text().strip()
user = "\n".join(case["observations"])
return [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
Flip 检测应该是第二个程序,而不是对仪表板颜色或周平均数的定性感觉。比较两个报告文件,只打印 deltas,因为在 prompt 更改期间,稳定的通过不是新闻。下面的函数故意做得很小,这样它可以在 pull-request 作业中运行,而不需要额外的服务或队列。
def flips(old: list[dict[str, Any]], new: list[dict[str, Any]]) -> list[str]:
prior = {row["case_id"]: row["passed"] for row in old}
lines: list[str] = []
for row in new:
was = prior.get(row["case_id"])
now = row["passed"]
if was is None:
lines.append(f"NEW {row['case_id']} passed={now}")
elif was != now:
direction = "regressed" if was and not now else "unexpected_pass"
lines.append(f"{direction} {row['case_id']}")
return lines
这种方法有局限性,应该在团队冻结数百个选择不当的短语之前设置门槛。短语匹配会标记一个拒绝——即为了否认而引用了被禁止的主张——所以quoted否认需要一个狭窄的 allowlist。它不会捕获措辞避免冻结措辞但仍然断言相同非法世界事实的改写。这就是为什么每个被禁止列表应该包含几种表面形式,也是为什么这是对开放式写作的错误工具。
没有确定性工具载荷的团队不应该从这里开始,因为没有什么可以诚实冻结到 allowed facts 中去。高风险的医疗、法律或金融 Agent 仍然需要在测试框架返回通过后由人工审查者审查 trace。该套件只是让审查者的队列变小了;它不认证 Agent 的安全性、公平性或监管适合性。如果你的成功指标是公开的排行榜分数,golden traces 看起来会是保守的,因为它们会惩罚无执照的有帮助行为。
实际推广是三个冻结文件和一个 CI diff:system.md、goldens.json 和之前的 report.json。在每次 prompt 或工具 schema 更改时运行套件,并在该 diff 中的任何回归 flip 上使构建失败。将意外通过视为可疑而不是免费收益,因为被禁止的主张可能已被重新措辞以脱离匹配器。当 mutation 检查失败时,停止调优 agent,先修复评分器,因为一个盲目的评分器会隐藏假设注入。
如果你已经保留 traces,从最近的日志中挖掘工具返回空但助手仍然命名了具体实体的回合。那些回合成为第一个 golden 集合,这比关于虚构策略的综合 trivia 更有价值。然后每个案例添加两个 mutation,这样套件可以在你信任绿色运行之前主动失败。这个序列比发布分数慢,但这是你如何在 agent 再次开始假设时获得堆栈跟踪的方法。