传统软件测试建立在一条铁律上:给定输入 X,必然得到输出 Y。AI Agent 从根上打破了这条铁律------同样的 prompt、同样的温度参数,两次运行可能给出不同的措辞、不同的工具调用链,甚至不同的结论。于是团队普遍陷入两个极端:要么只靠肉眼点检("我_觉得_新 prompt 更好"),要么把 LLM 输出当普通字符串做精确断言,得到一坨 flaky 到没法用的测试。问题不在测试这件事本身,而在于把确定性软件的测试范式直接套到了非确定性系统上。
最近在 GitHub 上看到一个叫 agentbench 的开源项目(TypeScript,v0.5.1),定位就是"AI Agent 的 Jest":用 Replay 把非确定性抹掉、用工具调用断言抓住确定性事实、用 LLM-as-Judge 兜住语义质量,三层测试金字塔各司其职,全部接进 CI 当门禁。这篇文章不写安利,只拆它的核心设计:非确定性怎么处理、Replay 的底层机制、断言 DSL 长什么样、以及我在实际配置里看到的那些坑。
非确定性从哪来:比 temperature 更隐蔽的三个来源
大多数人以为把 temperature 设成 0 就能拿到确定性输出,实际上至少还有三个来源:
- 浮点非确定性 :GPU 上的并行归约浮点加法不满足结合律,CUDA kernel 调度每次可能不同,A100 和 H100 算出的结果都可能有细微差异------
temperature: 0只是贪心解码,不代表位级确定。 - Provider 侧悄悄变化 :模型名没变,但服务端可能换量化方案、改 batch 调度、上投机解码(speculative decoding),甚至无公告地 A/B 分流流量。
gpt-4o上个月的输出分布和这个月就不一样。 - 工具调用抖动 :同一句 query,这次触发
search_docs,下次触发lookup_kb;参数从"refund policy"变成"return policy";单次调用变成并行调用。
所以正确策略不是"消除非确定性"(除非你自己控制推理集群,否则做不到),而是分层消化它------每一层在合适的抽象级别上把不确定性处理掉:
Layer 1: Replay → 彻底消除(录一次,确定性回放)
Layer 2: 工具断言 → 断言客观 trace 事实(工具调用是结构化的)
Layer 3: 模糊输出断言 → contains / regex / schema,不做精确匹配
Layer 4: 评分断言 → LLM Judge 打分,用容差区间而非二元判断
Layer 5: 批量回放 → 跑 N 次,用均值/标准差度量方差
核心机制一:Tracer 拦截 + ExecutionTrace
Replay 的前提是"录得下来"。agentbench 的 Tracer 直接包住你的 LLM SDK 调用,拦截并记录六类数据:LLM 请求全文(messages/tools/temperature/max_tokens)、完整响应体(content/tool_calls/finish_reason/usage)、每次工具调用(名称/参数/结果或错误)、流式 SSE 分片(含首 token 延迟)、每步耗时、以及按模型单价算出的成本。
简化后的核心逻辑长这样:
const result = await tracer.traceLLMCall(
'openai', // provider
'gpt-4o', // model
{ messages: [...], tools: [searchDocsTool], temperature: 0.7 },
() => openai.chat.completions.create({ /* 真实调用 */ }),
(response) => ({ // 提取响应的回调
content: response.choices[0].message.content,
toolCalls: response.choices[0].message.tool_calls,
finishReason: response.choices[0].finish_reason,
usage: response.usage,
}),
)
每次调用产出一个带顺序号的 TraceStep,全部 step 拼成一条 ExecutionTrace 落盘:
{
"id": "trace_run_abc123",
"steps": [
{ "sequence": 1, "type": "llm_call", "llmModel": "gpt-4o",
"llmResponse": { "content": "Let me search...", "toolCalls": [...],
"usage": { "promptTokens": 150, "completionTokens": 80 } },
"duration": 1200, "cost": 0.00115, "status": "success" },
{ "sequence": 2, "type": "tool_call", "toolName": "search_docs",
"toolRequest": { "arguments": { "query": "refund policy" } },
"duration": 250, "status": "success" }
],
"metadata": { "agentName": "customer-support", "runtime": "Node.js v22.0.0" }
}
存储格式是项目内的 .agentbench/snapshots/<project>/<snapshot-id>/ 目录,snapshot.json(Agent 完整配置:system prompt、模型、温度、工具定义)、trace.json、metrics.json 三件套。这保证了一个 run 可以被完整复现------这是后面所有 diff、bisect、跨模型对比的地基。
核心机制二:断言 DSL------测行为,不测措辞
Replay 解决"跑不跑得起来",断言解决"对不对"。agentbench 的断言是链式 DSL,22 个 matcher 分七类:工具、token、延迟、输出、评分、状态、复合。最有价值的设计是把工具调用当作一等断言对象 ------工具调用是 trace 里的结构化客观事实,LLM 说 "refund" 还是 "return" 无所谓,但调没调 search_docs、参数对不对,是确定性的:
const result = await expect(runResult)
.status().toBeCompleted() // Agent 正常跑完
.tool("search_docs").toBeCalled() // 调用了正确工具
.tool("search_docs").toBeCalledWith({ // 参数正确
query: "refund policy"
})
.tool("delete_customer_data").not.toBeCalled() // 没碰危险工具
.output().toContain("30 days") // 输出含关键事实
.tokens().toBeLessThan(4096) // token 预算内
.latency().toBeLessThan(5000) // 5 秒内
.score("correctness").toBeGreaterThan(7) // 语义质量达标
.run()
if (!result.allPassed) process.exit(1)
质量维度一共 8 个:correctness(事实准确性)、faithfulness(忠于来源,防幻觉)、safety、relevance、completeness、reasoning、conciseness、tool_usage。评分断言底层是 Hybrid Judge------规则评估器(exact_match/contains/regex/json_schema/tool_called 等 14 种)+ LLM Judge 组合,可配置 rule_first / llm_first / parallel 三种投票策略。
三层测试金字塔:什么时候跑什么
| 层 | 测什么 | 典型断言 | 执行节奏 | 成本画像 |
|---|---|---|---|---|
| L1 单步断言 | 做没做、花没花超 | tool_called、output.contains、tokens/latency 阈值 | 每次 PR,blocking | 2-10s/条实时;Replay 约 0.1s/条,$0 |
| L2 流程断言 | 推理路径、多轮对话、状态转换 | 工具调用顺序、跨模型一致性、批量稳定性 | 每次 push / Prompt 变更后 | 15-60s/条实时;Replay 约 0.3s/条 |
| L3 质量断言 | 语义正确性、安全性 | score(correctness) > 7、score(safety) > 9 | 发版候选 / 每日夜间 | 最慢最贵,但最接近真实质量 |
实际数据:20 条 L1 用例实时跑约 40-200 秒、花费 0.01-0.05;切到 Replay 模式约 2 秒、0.00。L2 的 10 条多轮用例实时 3-10 分钟、0.05-$0.20,Replay 3 秒。这就是 Replay 存在的全部意义:LLM 调用只在录制时付一次费,之后 CI 里跑几百上千遍都是零成本。
三层金字塔的 CI 落地
name: AgentBench Tests
on:
push:
branches: [main]
paths: ['src/agent/**', 'tests/**', 'agentbench.config.ts', 'datasets/**']
pull_request:
branches: [main]
paths: ['src/agent/**', 'tests/**', 'agentbench.config.ts', 'datasets/**']
jobs:
layer1-fast: # 每次 PR 必过,阻断合并
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Layer 1 单步断言
run: agentbench test --ci --suite "smoke" --fail-on-regression
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
safety-tests: # 独立 job、独立超时、更高阈值
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: 安全测试(对抗性输入)
run: agentbench test --suite "安全测试" --ci --fail-on-regression
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
两个细节值得抄:一是 paths 过滤,只跑 agent 相关文件变更,文档 PR 不烧 CI 分钟;二是 --fail-on-regression,不只看"这次过没过",还跟基线对比 token/成本/延迟/评分有没有回退。
踩坑清单:给 Agent 写测试最容易犯的五个错
- 对 LLM 输出做精确匹配 。
toEqual('我们提供30天退款服务')必挂------LLM 可能输出"退款在30天内处理"。改用toContain('30天')或toMatchRegex(/(refund|return).*(30|thirty)\s*days/i)。 - 只测输出,不测工具调用 。输出对了不代表路径对------Agent 可能没查知识库、纯靠模型记忆答对。把
tool('search_knowledge_base').toBeCalled()和输出断言一起写。 - 阈值拍脑袋 。
latency().toBeLessThan(300)对一次真实 LLM 调用完全不现实。先跑基线,再按 p95 设阈值。 - 跨模型对比不控制变量 。
{model:'gpt-4o', temperature:0.7}vs{model:'claude-sonnet-4-5', temperature:0}------差异是模型还是参数引起的?说不清。统一temperature: 0。 - 每次迭代都实时跑 。改断言逻辑不需要重新调 LLM。工作流应该是:
--record录一次 →--replay秒级迭代断言 → 最后实时跑一遍确认模型行为。
和现有工具怎么分工
| 工具 | 类别 | 定位 | 和 agentbench 的差异 |
|---|---|---|---|
| LangSmith | 可观测性 | 看发生了什么 | 负责 debug;agentbench 负责 assert + CI 门禁 |
| DeepEval | LLM 评测 | 评输出文本质量 | 只评输出;agentbench 测整个 Agent(工具链+状态+多轮) |
| Promptfoo | Prompt 测试 | 对比 prompt 变体 | 测 prompt;agentbench 测 prompt+工具+编排+状态 |
| Jest/Vitest | 单测 | 确定性代码 | 要求确定性输出;Agent 的非确定性需要 LLM-aware 断言 |
结论:不是替代关系。LangSmith 留着查线上 trace,Promptfoo 做 prompt 选型对比,agentbench 在 CI 里当闸门------用 LangSmith 调试,用 agentbench 拦截。
度量方差:批量回放与 A/B 实验
单次通过/失败解决不了"稳不稳定"的问题。比如"同一问题跑 10 次成功率多少",这才是 Agent 团队真正关心的指标。agentbench 的批量回放把同一输入跑 N 次再聚合统计------注意它不是简单数通过率,而是把 judge 分数、token 数、延迟当随机变量处理:
import { buildBatchReplay, aggregateReplayResults } from '@agentbench/core'
const batchConfig = buildBatchReplay(config, 50, { parallel: true })
const results = await agentbench.replay(batchConfig)
const agg = aggregateReplayResults(results)
// agg: { correctness: { mean: 8.2, stddev: 0.6, p95: 9.0 },
// tokens: { mean: 1847, stddev: 210 }, ... }
改 prompt 前后各跑一批,就能上统计检验:t-test 看均值差异是否显著、bootstrap 算置信区间、Cohen's d 看效应量大小。之前"我_觉得_新 prompt 更好"的体感判断,变成"correctness 均值 7.2 → 9.1(+26%),p < 0.01,d = 1.3(大效应)"的可量化结论。这是 A/B 实验引擎做的事,也是"工程化"和"玄学调 prompt"的分水岭。
进阶方向
金字塔和 Replay 是地基,往上还有三块值得研究:A/B 实验引擎 (t-test + bootstrap 置信区间 + Cohen's d 效应量,改 prompt 不再靠感觉);4D 覆盖率 (prompt / workflow / tool / edge-case 四个维度衡量"测够了没");跨模型 Replay(用 GPT-4o 录制的 trace 对 Claude 回放,模型迁移时第一时间暴露行为漂移)。给 Agent 上测试这件事,从"没有工具"到"工具齐了",中间隔着的就是这套把非确定性分层消化的设计------这比任何单点工具都更值得抄进自己的测试体系。