AI Agent 测试驱动开发:让智能体也能"先写测试再改代码"

举报
yd_288476769 发表于 2026/09/07 12:34:43 2026/09/07
【摘要】 作者:yumking | 2026 年 9 月 7 日 | 技术标签:AI Agent / TDD / 契约测试 / 行为快照 摘要第 6 篇解决了"怎么评估 Agent",第 10 篇把评估集做成回归门禁——但两者都是"改完再测"的事后验证。传统软件有 TDD:先写测试再写代码,测试驱动设计。Agent 能不能也这样?难点在于 Agent 非确定性、逻辑藏在模型权重里、输出开放,传统 as...

作者: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 三条铁律

  1. 隔离:评测集与 Prompt 调优集严格分开,否则"在考题上训练",分数虚高
  2. 版本化:评测集本身纳入版本控制,每次变更可追溯(哪条用例为什么加/改)
  3. 数据飞轮:线上 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 契约测试 行为快照 模糊重放") 获取。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。