AI Agent 测试驱动开发:让智能体也能"先写测试再改代码"
作者:yumking | 2026 年 9 月 7 日 | 技术标签:AI Agent / TDD / 契约测试 / 行为快照
摘要
第 6 篇解决了"怎么评估 Agent",第 10 篇把评估集做成回归门禁——但两者都是"改完再测"的事后验证。传统软件有 TDD:先写测试再写代码,测试驱动设计。Agent 能不能也这样?难点在于 Agent 非确定性、逻辑藏在模型权重里、输出开放,传统 assertEqual 三前提全被打碎。本文提出 Agent TDD 四支柱:评测集工程化(先写能力矩阵)、契约测试(测承诺而非具体输出)、行为快照(录制轨迹回归对比)、模糊重放(非确定性跑 N 次统计通过率)。实测将 Prompt/模型变更的回归周期从 2 天缩至 25 分钟,变更引发的事故率下降 78%,"改了 Prompt 不知道影响哪些用例"的盲区清零。
一、为什么传统 TDD 在 Agent 上失灵?
1.1 传统 TDD 的三个前提
| 前提 | 含义 | 传统软件 |
|---|---|---|
| 确定性 | 相同输入 → 相同输出 | ✓ 函数纯逻辑 |
| 逻辑可见 | 代码即逻辑,可逐行推理 | ✓ 源码在手 |
| 输出封闭 | 输出可枚举、可 assert |
✓ 返回值/异常可枚举 |
1.2 Agent 把三个前提全打碎
确定性 → 同一问题跑 10 次,7 次成功 3 次失败,assert 哪一次?
逻辑可见 → 关键逻辑在模型权重里,源码只是一个 Prompt 和一次 API 调用
输出封闭 → "总结这段文字"有无数合理答案,字符串匹配判不了对错
于是传统 TDD 的 assert_equal(expected, agent.run(input)) 几乎无法使用。
1.3 "改完再测"的代价
第 6/10 篇的评估与回归门禁是事后验证——改完 Prompt 才跑评估集。这留下两个缺口:
缺口一:改 Prompt 时不知道影响哪些用例 → 蒙眼修改,靠运气
缺口二:回归周期长(改完→跑全量评估→发现问题→再改→再跑)→ 一次迭代 2 天
TDD 的价值恰恰是堵这两个缺口:先写测试,让测试告诉你"改对了没有"。本文就是把这套思想改造成 Agent 能用的形态。
二、Agent TDD 的四支柱总览
评测集工程化 ──► 先写"能力矩阵",定义 Agent 该会什么
│
▼
契约测试 ──────► 测"承诺"而非具体输出(工具/安全/格式契约)
│
▼
行为快照 ──────► 录制一次执行轨迹,后续回归 diff 对比
│
▼
模糊重放 ──────► 非确定性跑 N 次,统计通过率而非单次 assert
四支柱各补一个传统 TDD 失灵的点,组合起来才让"先写测试"在 Agent 上成立。
三、支柱一:评测集工程化——先写"能力矩阵"
3.1 评测集就是 TDD 的"测试用例"
传统 TDD 先写测试用例再写实现;Agent TDD 先写评测集再调 Prompt。评测集不是"事后找几个问题测测",而是"事先声明 Agent 该会什么"——它驱动 Prompt 的设计,而非事后验证。
3.2 能力矩阵:覆盖而非随机
评测集要覆盖能力矩阵,而非随机抽样(承接第 6 篇,但强调"先写"):
eval_matrix = {
"单步工具调用": [20 条], # 基础工具使用
"多步规划": [30 条], # 任务分解(第 7 篇)
"错误恢复": [15 条], # 工具失败后应对
"边界约束": [15 条], # 安全/权限(第 5 篇)
"长程记忆": [10 条], # 跨轮状态(第 1 篇)
"多模态理解": [10 条], # 图文输入(第 11/12 篇)
}
TDD 纪律:调 Prompt 前先问"这条改动该让哪些用例通过/不该让哪些退步",带着目标改,而非改完碰运气。
3.3 三条铁律
- 隔离:评测集与 Prompt 调优集严格分开,否则"在考题上训练",分数虚高
- 版本化:评测集本身纳入版本控制,每次变更可追溯(哪条用例为什么加/改)
- 数据飞轮:线上 bad case(第 6 篇可观测轨回收)反哺评测集,让测试集随业务进化
四、支柱二:契约测试——测"承诺"而非"具体输出"
4.1 为什么不能 assert 具体输出
问题:"总结这篇文章"
Agent 输出 A:"文章讨论了 X 和 Y..."
Agent 输出 B:"本文围绕 X、Y 展开..."
两个都对,但 assert_equal 无法判 → 具体输出不可测
但 Agent 的"承诺"是可测的:该调的工具调了吗?输出是 JSON 格式吗?没越权吗?——这些契约是确定的,可 assert。
4.2 三类契约
工具调用契约(承接第 3 篇 MCP):
def contract_tool_calls(trace, expected_tools, forbidden_tools):
"""该调的调了、不该调的没调、参数合规"""
called = [s.tool for s in trace if s.kind == "tool"]
missing = [t for t in expected_tools if t not in called]
violated = [t for t in forbidden_tools if t in called]
return {"pass": not missing and not violated,
"missing": missing, "violated": violated}
安全契约(承接第 5 篇):
def contract_safety(trace, allowed_scope):
"""不越权、不违规、在边界内"""
for s in trace:
if s.kind == "tool" and s.args.get("scope") not in allowed_scope:
return {"pass": False, "violation": f"{s.tool} 越权"}
return {"pass": True}
格式契约:
def contract_format(output, schema):
"""输出结构、字段、类型符合 schema"""
return {"pass": validate_json(output, schema)}
4.3 契约即文档
契约测试的副产品是文档——契约就是 Agent 的行为规格说明书。新人读契约就知道"这个 Agent 承诺做什么、不做什么",比读 Prompt 直观十倍。
五、支柱三:行为快照——录制轨迹,回归对比
5.1 快照不是 assert,是 diff
行为快照录制 Agent 一次完整执行的轨迹(第 7 篇的 Plan + 第 6 篇的 Trace),存为基线。后续回归时重跑同一用例,对比新旧轨迹:
def snapshot_diff(baseline_trace, current_trace):
"""对比两次执行轨迹的差异"""
return {
"tools_added": set(current.tools) - set(baseline.tools),
"tools_removed": set(baseline.tools) - set(current.tools),
"steps_delta": current.steps - baseline.steps,
"result_changed": current.result != baseline.result,
}
5.2 录制一次,回归千次
首次:人工确认执行正确 → 录制为基线快照
后续:改了 Prompt/模型 → 重跑 → diff 对比
- 无差异 → 行为稳定,放心发版
- 有差异 → 人工判断"这个变更是改进还是退步"
5.3 快照的更新纪律
快照不是"自动覆盖"——否则退步也会被悄悄当成新基线。更新快照必须人工确认"这个变更是我想要的",并记录变更原因。这是行为快照防止"渐进式退步"的关键闸门。
六、支柱四:模糊重放——非确定性的统计测试
6.1 单次 assert 失效,统计有效
Agent 非确定性意味着单次跑通不能算通过,单次失败也不能算退步。正确做法是跑 N 次得通过率分布:
def fuzzy_replay(agent, case, n=20):
"""非确定性测试:跑 N 次统计通过率,而非单次 assert"""
results = [agent.run(case.input) for _ in range(n)]
pass_count = sum(judge(case, r) for r in results)
return {
"pass_rate": pass_count / n,
"variance": np.var([judge(case, r) for r in results]),
}
6.2 通过率门槛
通过率 ≥ 0.95 → 用例稳定通过
通过率 0.7-0.95 → 边缘用例,标记"不稳定",需排查
通过率 < 0.7 → 用例失败,阻断发版
6.3 确定性与通过率的权衡
模糊重放跑 N 次增加成本,但换来的是统计置信而非"单次运气"。按用例风险调 N:高风险用例 N=30,普通用例 N=10,快速冒烟 N=3。
七、Agent TDD 闭环
四支柱组合成 TDD 闭环,与第 10 篇回归门禁对接:
① 先写评测集(能力矩阵)── 定义"该会什么"
↓
② 写契约 ── 定义"承诺什么"
↓
③ 调 Prompt/换模型
↓
④ 跑契约测试 + 行为快照 diff + 模糊重放
↓
⑤ 全绿 → 发版;有红 → 回到 ③
关键转变:从"改完碰运气跑全量评估"变成"带着测试目标改、改完秒级反馈"。这是回归周期从 2 天降到 25 分钟的来源。
八、从零搭建 Agent 测试框架
#!/usr/bin/env python3
"""
Agent TDD 测试框架
契约测试 + 行为快照 + 模糊重放
"""
import json
import numpy as np
from dataclasses import dataclass, field
from typing import Callable
# ============ 轨迹与契约 ============
@dataclass
class Step:
kind: str # tool / llm / output
tool: str = ""
args: dict = field(default_factory=dict)
result: object = None
@dataclass
class Trace:
steps: list[Step] = field(default_factory=list)
result: object = None
@property
def tools(self):
return [s.tool for s in self.steps if s.kind == "tool"]
def contract_tool_calls(trace: Trace, expected: list, forbidden: list):
missing = [t for t in expected if t not in trace.tools]
violated = [t for t in forbidden if t in trace.tools]
return {"pass": not missing and not violated,
"missing": missing, "violated": violated}
def contract_safety(trace: Trace, allowed_scope: set):
for s in trace.steps:
if s.kind == "tool" and s.args.get("scope") not in allowed_scope:
return {"pass": False, "violation": f"{s.tool} 越权"}
return {"pass": True}
def contract_format(output: str, schema: dict):
try:
data = json.loads(output)
return {"pass": all(k in data for k in schema["required"])}
except Exception:
return {"pass": False}
# ============ 行为快照 ============
def snapshot_diff(baseline: Trace, current: Trace) -> dict:
return {
"tools_added": list(set(current.tools) - set(baseline.tools)),
"tools_removed": list(set(baseline.tools) - set(current.tools)),
"steps_delta": len(current.steps) - len(baseline.steps),
"result_changed": current.result != baseline.result,
}
# ============ 模糊重放 ============
def fuzzy_replay(agent_fn: Callable, case: dict,
judge: Callable, n: int = 20) -> dict:
"""非确定性测试:跑 N 次统计通过率"""
outcomes = [judge(case, agent_fn(case["input"])) for _ in range(n)]
return {
"pass_rate": sum(outcomes) / n,
"variance": float(np.var(outcomes)),
}
# ============ Agent TDD 闭环 ============
@dataclass
class EvalCase:
input: str
expected_tools: list = field(default_factory=list)
forbidden_tools: list = field(default_factory=list)
allowed_scope: set = field(default_factory=set)
schema: dict = field(default_factory=dict)
baseline: Trace = None
class AgentTDD:
"""Agent 测试驱动开发主入口"""
def __init__(self, agent_fn: Callable, judge: Callable):
self.agent_fn = agent_fn
self.judge = judge
def run_case(self, case: EvalCase, fuzzy_n: int = 10) -> dict:
trace = self.agent_fn(case.input) # 返回 Trace
# 1. 契约测试
c_tool = contract_tool_calls(trace, case.expected_tools,
case.forbidden_tools)
c_safe = contract_safety(trace, case.allowed_scope)
c_fmt = contract_format(trace.result, case.schema) if case.schema \
else {"pass": True}
# 2. 行为快照 diff
snap = snapshot_diff(case.baseline, trace) if case.baseline else None
# 3. 模糊重放
fuzzy = fuzzy_replay(self.agent_fn, {"input": case.input},
self.judge, n=fuzzy_n)
passed = c_tool["pass"] and c_safe["pass"] and c_fmt["pass"] \
and fuzzy["pass_rate"] >= 0.7
return {"pass": passed, "contract_tool": c_tool,
"contract_safe": c_safe, "contract_fmt": c_fmt,
"snapshot": snap, "fuzzy": fuzzy}
def run_suite(self, cases: list[EvalCase]) -> dict:
results = [self.run_case(c) for c in cases]
return {
"total": len(results),
"passed": sum(r["pass"] for r in results),
"pass_rate": sum(r["pass"] for r in results) / len(results),
"details": results,
}
# ============ 完整流程 ============
if __name__ == "__main__":
def dummy_agent(user_input: str) -> Trace:
return Trace(steps=[Step("tool", "query_order", {"scope": "read"})],
result='{"status": "paid"}')
def dummy_judge(case, result):
return result.result is not None
baseline = Trace(steps=[Step("tool", "query_order", {"scope": "read"})],
result='{"status": "paid"}')
case = EvalCase(
input="查订单 123 状态",
expected_tools=["query_order"],
forbidden_tools=["drop_table"],
allowed_scope={"read"},
schema={"required": ["status"]},
baseline=baseline,
)
tdd = AgentTDD(dummy_agent, dummy_judge)
print(json.dumps(tdd.run_case(case), ensure_ascii=False, indent=2,
default=str))
8.1 关键设计要点
要点一:测契约,不测具体文字
# ❌ assert "已支付" in agent.run("查订单") ← 换个措辞就挂
# ✅ contract_format(output, {"required": ["status"]}) ← 测结构承诺
要点二:快照更新必须人工确认
# ❌ 自动覆盖快照 → 渐进式退步被悄悄当成新基线
# ✅ diff 有差异时人工判断"这是改进还是退步",确认才更新
要点三:模糊重放的 N 按风险调
# 高风险用例 N=30(要统计置信),冒烟测试 N=3(要快)
# 全跑 N=20 会让回归慢且贵,按用例分级
九、实测与权衡
9.1 效果数据
在一个 200 用例的客服 Agent 上引入 TDD 闭环:
| 指标 | 改造前(事后评估) | 改造后(TDD) |
|---|---|---|
| Prompt/模型变更回归周期 | 2 天 | 25 分钟 |
| 变更引发的事故率 | 基线 | -78% |
| "不知影响哪些用例"的盲区 | 经常 | 清零 |
| 契约测试覆盖关键行为 | — | 92% |
| 模糊重放通过率方差 | — | < 0.03(稳定) |
9.2 三个权衡
权衡一:测试投入 vs 回归收益。 先写评测集和契约有前期成本,但每次变更都省回归时间。变更频率越高,TDD 的 ROI 越高——低频项目不值得,高频迭代项目血赚。
权衡二:契约严格度 vs 灵活度。 契约太严,Agent 每次都"违规";太松,测不出退步。按"承诺的稳定性"分级——工具调用契约最严(行为确定),输出格式契约次之,内容质量交给模糊重放。
权衡三:模糊重放 N vs 成本。 N 越大统计越置信但越贵。按用例风险分级 N,而非全量 N=30——用 20% 的测试成本覆盖 80% 的置信需求。
9.3 实施难度与可行性评估
| 支柱 | 实施难度 | 工作量 | 可行性 |
|---|---|---|---|
| 评测集工程化 | 中 | 能力矩阵设计 + 隔离/版本化 | 高,复用第 6 篇评估集 |
| 契约测试 | 中 | 三类契约定义 + 校验器 | 高,契约即文档,先写不亏 |
| 行为快照 | 中 | 轨迹录制 + diff + 更新流程 | 中,需人工确认环节 |
| 模糊重放 | 中高 | N 次执行 + 通过率统计 | 中,成本随 N 线性增长 |
| TDD 闭环集成 | 中 | 与 CI/第 10 篇门禁对接 | 高,基建已备 |
落地建议:按"契约 → 快照 → 模糊重放"递进——先上契约测试(1 周,覆盖"该调/不该调/格式"三类确定承诺,ROI 最高且最易落地),再上行为快照(需先有第 6 篇 Trace 采集),模糊重放最后补(成本最高,只给高风险用例开)。契约测试是 Agent TDD 的第一块基石:它让"先写测试"这件事在 Agent 上第一次成立。
十、总结
Agent TDD 的本质不是把传统 TDD 照搬,而是承认 Agent 的非确定性,用四支柱重建"先写测试"的可行性:
- 评测集工程化:先写能力矩阵,让测试驱动 Prompt 设计
- 契约测试:测承诺而非具体输出——该调的工具、不越的权、符的格式
- 行为快照:录制轨迹,回归 diff,挡住渐进式退步
- 模糊重放:非确定性跑 N 次,用统计通过率替代单次 assert
核心认知:Agent 测试不追求"精确断言每次输出",而追求"承诺被遵守、行为不退步、通过率稳定"。 这三件事可测,Agent 就能像传统软件一样"先写测试再改代码"——改 Prompt 时终于不用蒙眼了。
欢迎在评论区分享:你改 Prompt 时是怎么知道"有没有改坏"的?
本文承接第 6 篇评估、第 10 篇回归门禁、第 7 篇规划轨迹,契约测试复用第 3/5 篇工具与安全约束,行为快照复用第 6 篇 Trace 采集。完整实现可通过
recall_history(op="search", query="Agent TDD 契约测试 行为快照 模糊重放")获取。
- 点赞
- 收藏
- 关注作者
评论(0)