目录
- [1. 为什么 AI Agent 的测试这么难](#1. 为什么 AI Agent 的测试这么难)
- [2. OpenClaw 架构与测试切入点](#2. OpenClaw 架构与测试切入点)
- [3. 测试金字塔与分层策略](#3. 测试金字塔与分层策略)
- [4. 单元测试:纯逻辑与工具函数](#4. 单元测试:纯逻辑与工具函数)
- [4.1 准备测试环境](#4.1 准备测试环境)
- [4.2 测一个 Skill 的纯逻辑](#4.2 测一个 Skill 的纯逻辑)
- [4.3 测状态机与路由](#4.3 测状态机与路由)
- [5. 集成测试:Mock LLM 与消息渠道](#5. 集成测试:Mock LLM 与消息渠道)
- [5.1 为什么要 Mock LLM](#5.1 为什么要 Mock LLM)
- [5.2 测 Agent 的提示词组装与工具调用](#5.2 测 Agent 的提示词组装与工具调用)
- [5.3 Mock 消息渠道](#5.3 Mock 消息渠道)
- [6. E2E 测试:真实模型的端到端验证](#6. E2E 测试:真实模型的端到端验证)
- [7. AI 评测体系:从断言到评估器](#7. AI 评测体系:从断言到评估器)
- [7.1 双层评估策略](#7.1 双层评估策略)
- [7.2 规则评估器实现](#7.2 规则评估器实现)
- [7.3 LLM-as-judge 评估器](#7.3 LLM-as-judge 评估器)
- [8. CI/CD 落地:GitHub Actions 实战](#8. CI/CD 落地:GitHub Actions 实战)
- [9. 实践经验与避坑指南](#9. 实践经验与避坑指南)
- [10. 总结](#10. 总结)
1. 为什么 AI Agent 的测试这么难
传统软件的测试思路建立在「确定性」之上:给定输入,输出可预期,断言写得明明白白。而 OpenClaw 这类 AI Agent 项目从根上打破了这条假设------同一个问题,模型可能给出语义相同但措辞完全不同的回答;同一个工具调用,有时会被判定为「值得执行」,有时又会被跳过。
更麻烦的是,AI Agent 的代码往往是一条「模型 + 工具 + 编排逻辑」的混合链路:
- 模型输出不确定:非结构化文本、可变长度、格式漂移;
- 工具调用依赖环境:消息渠道、外部 API、文件系统、浏览器;
- 编排逻辑与模型耦合:提示词、tool schema、路由规则交织在一起,很难单独测;
- 回归成本高:跑一次真实模型的 E2E 又慢又贵,改一行提示词就可能全盘重测。
OpenClaw 就是一个典型样本:TypeScript 编写的个人 AI 助手框架,Gateway 负责消息收发,Channels 对接 WhatsApp、Telegram、Discord 等渠道,Agents 处理对话决策,Skills 承载可复用的能力模块。它的每一层都有不同的确定性特征,测试策略必须分层设计,而不是用一把尺子量到底。
这篇文章就以 OpenClaw 为靶子,从零搭一套可落地的 AI Agent 自动化测试体系:先拆架构找测试切入点,再按金字塔分层,最后用 GitHub Actions 串起完整流水线。
2. OpenClaw 架构与测试切入点
在写第一条测试之前,先回答一个问题:这段代码里,哪些是确定的,哪些是不确定的?
OpenClaw 的核心架构大致可以概括为五层:
| 层 | 职责 | 确定性 | 测试重点 |
|---|---|---|---|
| Gateway | WebSocket 服务、会话管理、消息路由 | 高 | 生命周期、路由逻辑、状态机 |
| Channels | 对接外部消息平台 | 中 | 消息收发、事件解析、重连 |
| Agents | 主 agent / 子 agent 的对话决策 | 低 | 提示词组装、tool schema、决策流程 |
| Skills | 可挂载的能力模块 | 高 | 纯业务逻辑、输入输出契约 |
| Memory / Models | 会话记忆、模型适配 | 中 | 序列化、provider 抽象、fallback |
从这个表里能看出一个清晰的规律:越靠近 Skills 和 Gateway 的代码越确定,越值得做细粒度的白盒测试;越靠近 Agents 和 Models 的代码越不确定,越需要 mock 和评测体系来兜底。
所以测试切入点优先落在三处:
- Skills 是天然的单元测试对象------它们是独立模块,有明确的输入输出,不依赖模型;
- Agent 的决策链路用 Mock LLM 测------把模型换成可编程的 stub,验证提示词组装、tool schema 和路由逻辑;
- 消息渠道用集成测试测------用本地 mock server 模拟 WhatsApp / Telegram 的 Webhook 回调。
至于真实模型的行为,交给最后一层「评测体系」用评估器来打分,而不是用传统断言硬卡。
3. 测试金字塔与分层策略
套用经典的测试金字塔,AI Agent 项目的分层策略应该是这样的:
#mermaid-svg-jwMLDcnR7YTo3p2U{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jwMLDcnR7YTo3p2U .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jwMLDcnR7YTo3p2U .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jwMLDcnR7YTo3p2U .error-icon{fill:#552222;}#mermaid-svg-jwMLDcnR7YTo3p2U .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jwMLDcnR7YTo3p2U .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jwMLDcnR7YTo3p2U .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jwMLDcnR7YTo3p2U .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jwMLDcnR7YTo3p2U .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jwMLDcnR7YTo3p2U .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jwMLDcnR7YTo3p2U .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jwMLDcnR7YTo3p2U .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jwMLDcnR7YTo3p2U .marker.cross{stroke:#333333;}#mermaid-svg-jwMLDcnR7YTo3p2U svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jwMLDcnR7YTo3p2U p{margin:0;}#mermaid-svg-jwMLDcnR7YTo3p2U .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-jwMLDcnR7YTo3p2U .cluster-label text{fill:#333;}#mermaid-svg-jwMLDcnR7YTo3p2U .cluster-label span{color:#333;}#mermaid-svg-jwMLDcnR7YTo3p2U .cluster-label span p{background-color:transparent;}#mermaid-svg-jwMLDcnR7YTo3p2U .label text,#mermaid-svg-jwMLDcnR7YTo3p2U span{fill:#333;color:#333;}#mermaid-svg-jwMLDcnR7YTo3p2U .node rect,#mermaid-svg-jwMLDcnR7YTo3p2U .node circle,#mermaid-svg-jwMLDcnR7YTo3p2U .node ellipse,#mermaid-svg-jwMLDcnR7YTo3p2U .node polygon,#mermaid-svg-jwMLDcnR7YTo3p2U .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-jwMLDcnR7YTo3p2U .rough-node .label text,#mermaid-svg-jwMLDcnR7YTo3p2U .node .label text,#mermaid-svg-jwMLDcnR7YTo3p2U .image-shape .label,#mermaid-svg-jwMLDcnR7YTo3p2U .icon-shape .label{text-anchor:middle;}#mermaid-svg-jwMLDcnR7YTo3p2U .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-jwMLDcnR7YTo3p2U .rough-node .label,#mermaid-svg-jwMLDcnR7YTo3p2U .node .label,#mermaid-svg-jwMLDcnR7YTo3p2U .image-shape .label,#mermaid-svg-jwMLDcnR7YTo3p2U .icon-shape .label{text-align:center;}#mermaid-svg-jwMLDcnR7YTo3p2U .node.clickable{cursor:pointer;}#mermaid-svg-jwMLDcnR7YTo3p2U .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-jwMLDcnR7YTo3p2U .arrowheadPath{fill:#333333;}#mermaid-svg-jwMLDcnR7YTo3p2U .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-jwMLDcnR7YTo3p2U .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-jwMLDcnR7YTo3p2U .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jwMLDcnR7YTo3p2U .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-jwMLDcnR7YTo3p2U .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jwMLDcnR7YTo3p2U .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-jwMLDcnR7YTo3p2U .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-jwMLDcnR7YTo3p2U .cluster text{fill:#333;}#mermaid-svg-jwMLDcnR7YTo3p2U .cluster span{color:#333;}#mermaid-svg-jwMLDcnR7YTo3p2U div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-jwMLDcnR7YTo3p2U .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-jwMLDcnR7YTo3p2U rect.text{fill:none;stroke-width:0;}#mermaid-svg-jwMLDcnR7YTo3p2U .icon-shape,#mermaid-svg-jwMLDcnR7YTo3p2U .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-jwMLDcnR7YTo3p2U .icon-shape p,#mermaid-svg-jwMLDcnR7YTo3p2U .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-jwMLDcnR7YTo3p2U .icon-shape .label rect,#mermaid-svg-jwMLDcnR7YTo3p2U .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-jwMLDcnR7YTo3p2U .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-jwMLDcnR7YTo3p2U .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-jwMLDcnR7YTo3p2U :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} E2E 测试:真实模型 + 测试渠道
集成测试:Mock LLM + Mock Channel
单元测试:Skills / 纯逻辑 / 工具函数
评测体系:评估器打分 LLM-as-judge
数量上呈倒金字塔:单元测试最多、最快、最稳定;E2E 最少、最慢、最贵。评测体系横向贯穿,作为对「模型输出质量」的专项保障。
每一层的核心手段:
- 单元测试:Vitest + 纯函数断言,覆盖 Skills、工具函数、状态转换;
- 集成测试:用 stub 替换 LLM provider,用 mock server 替换渠道,验证 Agent 编排链路;
- E2E 测试:Playwright 驱动一个真实的 WebChat 渠道,连真实模型,做冒烟验证;
- 评测体系:把对话结果送进评估器,用规则打分 + LLM-as-judge 双重判定。
接下来按从下往上的顺序,每一层给出可运行的代码。
4. 单元测试:纯逻辑与工具函数
4.1 准备测试环境
OpenClaw 是 TypeScript 项目,测试栈用 Vitest 最顺滑。先装依赖:
bash
npm install -D vitest @vitest/coverage-v8 typescript tsx
在 vitest.config.ts 里配置:
typescript
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: ["src/**/*.test.ts", "src/mocks/**"],
},
},
});
4.2 测一个 Skill 的纯逻辑
假设 OpenClaw 里有一个做日程解析的 Skill,核心函数是 parseReminder,输入自然语言,输出结构化时间。这类函数应该是纯的、确定的,完全可以白盒测试:
typescript
// src/skills/reminder/parse.ts
export interface ReminderResult {
text: string;
dueAt: Date | null;
confidence: number;
}
const TIME_PATTERNS: Array<{ regex: RegExp; parse: (m: RegExpMatchArray) => Date }> = [
{
regex: /(\d{1,2}):(\d{2})/,
parse: (m) => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate(), Number(m[1]), Number(m[2]));
},
},
{
regex: /明天\s*(\d{1,2})[点时]/,
parse: (m) => {
const d = new Date();
d.setDate(d.getDate() + 1);
d.setHours(Number(m[1]), 0, 0, 0);
return d;
},
},
];
export function parseReminder(input: string): ReminderResult {
for (const pattern of TIME_PATTERNS) {
const match = input.match(pattern.regex);
if (match) {
return {
text: input,
dueAt: pattern.parse(match),
confidence: 0.9,
};
}
}
return { text: input, dueAt: null, confidence: 0.1 };
}
对应的测试:
typescript
// src/skills/reminder/parse.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { parseReminder } from "./parse";
describe("parseReminder", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-13T10:00:00"));
});
afterEach(() => {
vi.useRealTimers();
});
it("解析 HH:mm 格式的时间", () => {
const result = parseReminder("提醒我 15:30 开会");
expect(result.dueAt).toBeInstanceOf(Date);
expect(result.dueAt?.getHours()).toBe(15);
expect(result.dueAt?.getMinutes()).toBe(30);
expect(result.confidence).toBeGreaterThan(0.8);
});
it("解析"明天 N 点"的相对时间", () => {
const result = parseReminder("明天 9点 交周报");
expect(result.dueAt?.getDate()).toBe(14);
expect(result.dueAt?.getHours()).toBe(9);
});
it("无法解析时返回空时间与低置信度", () => {
const result = parseReminder("帮我写篇周报");
expect(result.dueAt).toBeNull();
expect(result.confidence).toBeLessThan(0.5);
});
});
这里的重点是用 vi.useFakeTimers 固定时间,让「明天」这类相对时间可被断言。单元测试的核心纪律是:把一切依赖注入和时钟、随机数都控制住,让被测代码变成纯函数。
4.3 测状态机与路由
OpenClaw 的 Gateway 中往往有一段消息路由状态机,决定消息发给主 agent 还是子 agent。这类逻辑同样可以抽成纯函数来测:
typescript
// src/gateway/router.ts
export type RouteTarget = "main-agent" | "sub-agent" | "ignore";
export function routeMessage(input: {
fromChannel: string;
text: string;
mentionsBot: boolean;
isDirectMessage: boolean;
}): RouteTarget {
if (!input.mentionsBot && !input.isDirectMessage) return "ignore";
if (input.isDirectMessage && input.fromChannel === "webchat") return "main-agent";
if (/^@\w+/.test(input.text)) return "sub-agent";
return "main-agent";
}
测试:
typescript
// src/gateway/router.test.ts
import { describe, it, expect } from "vitest";
import { routeMessage } from "./router";
describe("routeMessage", () => {
it("群聊中未 @ 机器人时忽略", () => {
expect(
routeMessage({ fromChannel: "whatsapp", text: "大家好", mentionsBot: false, isDirectMessage: false })
).toBe("ignore");
});
it("私聊消息路由到主 agent", () => {
expect(
routeMessage({ fromChannel: "webchat", text: "你好", mentionsBot: true, isDirectMessage: true })
).toBe("main-agent");
});
it("显式 @ 子 agent 时路由到子 agent", () => {
expect(
routeMessage({ fromChannel: "discord", text: "@scheduler 帮我排时间", mentionsBot: true, isDirectMessage: false })
).toBe("sub-agent");
});
});
5. 集成测试:Mock LLM 与消息渠道
5.1 为什么要 Mock LLM
单元测试只能覆盖纯逻辑,但 Agent 编排链路里最值钱的部分------提示词组装、tool schema 注入、多轮对话状态、模型失败后的 fallback------是必须连上「模型」才能跑起来的。直接连真实模型又太慢太贵,所以这里的标准做法是:把 LLM provider 抽象成接口,测试时注入一个可编程的 stub。
先定义一个 provider 抽象:
typescript
// src/llm/provider.ts
export interface LLMRequest {
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
tools?: ToolSchema[];
temperature?: number;
}
export interface LLMResponse {
content: string;
toolCalls?: Array<{ name: string; arguments: Record<string, unknown> }>;
usage: { inputTokens: number; outputTokens: number };
}
export interface LLMProvider {
complete(req: LLMRequest): Promise<LLMResponse>;
}
一个可编程的 Stub:
typescript
// src/test-utils/stub-llm.ts
import { LLMProvider, LLMRequest, LLMResponse } from "../llm/provider";
export class StubLLM implements LLMProvider {
private queue: LLMResponse[] = [];
public requests: LLMRequest[] = [];
/** 按调用顺序依次返回预设响应 */
enqueue(response: Partial<LLMResponse>) {
this.queue.push({
content: response.content ?? "",
toolCalls: response.toolCalls,
usage: response.usage ?? { inputTokens: 0, outputTokens: 0 },
});
return this;
}
async complete(req: LLMRequest): Promise<LLMResponse> {
this.requests.push(req);
const next = this.queue.shift();
if (!next) throw new Error("StubLLM: 响应队列已空,请确认调用次数与预设一致");
return next;
}
}
这个 stub 的价值在于它既能按预设返回 ,又能记录每次真实的请求内容,后者才是断言的关键------我们测的不是模型输出,而是「你的代码给模型喂了什么」。
5.2 测 Agent 的提示词组装与工具调用
假设 OpenClaw 的 Agent 处理流程大致是:组装 system prompt → 注入 tools → 调用 LLM → 若返回 tool call 则执行工具 → 把结果回填继续对话。我们测试这段编排、不测模型本身:
typescript
// src/agents/main-agent.ts
import { LLMProvider } from "../llm/provider";
export interface AgentContext {
userName: string;
userMessage: string;
history?: Array<{ role: string; content: string }>;
}
export class MainAgent {
constructor(private llm: LLMProvider, private tools: Tool[]) {}
async run(ctx: AgentContext) {
const systemPrompt = `你是 ${ctx.userName} 的个人 AI 助手,请用中文简洁回答。`;
const messages = [
{ role: "system" as const, content: systemPrompt },
...(ctx.history ?? []),
{ role: "user" as const, content: ctx.userMessage },
];
const response = await this.llm.complete({
messages,
tools: this.tools.map((t) => t.schema),
});
if (response.toolCalls) {
for (const call of response.toolCalls) {
const tool = this.tools.find((t) => t.schema.name === call.name);
if (tool) await tool.execute(call.arguments);
}
}
return response.content;
}
}
对应测试:
typescript
// src/agents/main-agent.test.ts
import { describe, it, expect, vi } from "vitest";
import { StubLLM } from "../test-utils/stub-llm";
import { MainAgent } from "./main-agent";
import { Tool } from "../tools/types";
describe("MainAgent", () => {
it("组装正确的 system prompt 并携带用户历史", async () => {
const stub = new StubLLM();
stub.enqueue({ content: "好的,已为你处理。" });
const agent = new MainAgent(stub, []);
await agent.run({
userName: "小明",
userMessage: "明天天气怎么样",
history: [{ role: "user", content: "之前问过你周末安排" }],
});
const req = stub.requests[0];
expect(req.messages[0].role).toBe("system");
expect(req.messages[0].content).toContain("小明");
expect(req.messages).toHaveLength(3);
expect(req.messages[2].content).toBe("明天天气怎么样");
});
it("LLM 返回 tool call 时执行对应工具", async () => {
const stub = new StubLLM();
stub.enqueue({
content: "",
toolCalls: [{ name: "send_email", arguments: { to: "boss@corp.com", body: "周报已发" } }],
});
const sendEmail = vi.fn().mockResolvedValue(undefined);
const tool: Tool = {
schema: { name: "send_email", description: "发送邮件" },
execute: sendEmail,
};
const agent = new MainAgent(stub, [tool]);
await agent.run({ userName: "小明", userMessage: "帮我发封邮件" });
expect(sendEmail).toHaveBeenCalledTimes(1);
expect(sendEmail).toHaveBeenCalledWith({ to: "boss@corp.com", body: "周报已发" });
});
it("LLM 抛错时向上传播,不做吞异常", async () => {
const stub = new StubLLM();
stub.enqueue({ content: "" });
const brokenLLM = {
complete: vi.fn().mockRejectedValue(new Error("provider timeout")),
};
const agent = new MainAgent(brokenLLM as any, []);
await expect(agent.run({ userName: "小明", userMessage: "hi" })).rejects.toThrow("provider timeout");
});
});
这三条测试分别覆盖了提示词正确性 、工具调用分发 、异常传播。注意最后一条:Agent 层的职责是编排,不是重试,重试应该有独立的 retry 中间件来处理,这样才能分层测试。
5.3 Mock 消息渠道
渠道测试的难点在于外部平台。不要真的去连 WhatsApp,而是在本地起一个 mock server,模拟平台的 Webhook 推送:
typescript
// src/test-utils/mock-channel-server.ts
import { createServer, Server } from "node:http";
export interface WebhookPayload {
channel: string;
from: string;
text: string;
}
export class MockChannelServer {
private server: Server;
public received: WebhookPayload[] = [];
constructor(private port = 18888) {
this.server = createServer((req, res) => {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
try {
this.received.push(JSON.parse(body));
} catch {
// ignore malformed payload
}
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true }));
});
});
}
async start() {
await new Promise<void>((resolve) => this.server.listen(this.port, resolve));
return this;
}
async stop() {
await new Promise<void>((resolve) => this.server.close(() => resolve()));
}
url() {
return `http://127.0.0.1:${this.port}`;
}
}
测试渠道 handler:
typescript
// src/channels/webhook-handler.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { MockChannelServer } from "../test-utils/mock-channel-server";
async function deliverWebhook(url: string, payload: unknown) {
await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
}
describe("Webhook handler", () => {
let server: MockChannelServer;
beforeAll(async () => {
server = await new MockChannelServer().start();
});
afterAll(async () => {
await server.stop();
});
it("接收并解析 WhatsApp 渠道的 Webhook", async () => {
await deliverWebhook(server.url(), {
channel: "whatsapp",
from: "+8613800138000",
text: "你好",
});
expect(server.received).toHaveLength(1);
expect(server.received[0].channel).toBe("whatsapp");
expect(server.received[0].text).toBe("你好");
});
});
这套集成测试能在毫秒级跑完,完全不依赖外部网络,是 CI 里的主力。
6. E2E 测试:真实模型的端到端验证
E2E 层要回答一个其他层都答不了的问题:真实模型接到真实渠道后,整条链路能不能真正跑通? 因为它是唯一会暴露「stub 和真实模型行为不一致」的层级,所以必须保留,但量要控制到最小。
OpenClaw 自带了 WebChat 渠道,它有一个浏览器界面,这让 E2E 测试变得可行:用 Playwright 打开本地 Gateway 的 WebChat 页面,发送消息,等待机器人回复。
typescript
// e2e/webchat.e2e.ts
import { test, expect } from "@playwright/test";
const GATEWAY_URL = process.env.E2E_GATEWAY_URL ?? "http://127.0.0.1:3210";
test.describe("WebChat E2E 冒烟", () => {
test("发送消息后能收到非空回复", async ({ page }) => {
await page.goto(GATEWAY_URL);
await page.waitForLoadState("networkidle");
const input = page.locator('[data-testid="chat-input"]');
await input.fill("请用一句话介绍你自己");
await input.press("Enter");
const reply = page.locator('[data-testid="message-assistant"]');
await expect(reply.first()).toBeVisible({ timeout: 60_000 });
const text = await reply.first().innerText();
expect(text.trim().length).toBeGreaterThan(0);
});
test("连续对话能保持上下文", async ({ page }) => {
await page.goto(GATEWAY_URL);
const input = page.locator('[data-testid="chat-input"]');
await input.fill("我叫小明");
await input.press("Enter");
await page.locator('[data-testid="message-assistant"]').first().waitFor({ timeout: 60_000 });
await input.fill("我叫什么名字?");
await input.press("Enter");
await page.locator('[data-testid="message-assistant"]').nth(1).waitFor({ timeout: 60_000 });
const text = await page.locator('[data-testid="message-assistant"]').nth(1).innerText();
expect(text).toContain("小明");
});
});
E2E 层的纪律:
- 只做冒烟,不做全量回归;
- 超时放宽,真实模型响应可能要几十秒;
- 用环境变量控制开关,默认在 PR 里只跑「非模型」的快速测试,真实模型 E2E 放在夜间流水线或手动触发;
- 断言要宽松:只断言「有回复」「包含关键信息」,不断言精确措辞。精确措辞的活儿交给评测层。
Playwright 配置:
typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
timeout: 120_000,
retries: 1,
use: {
baseURL: process.env.E2E_GATEWAY_URL ?? "http://127.0.0.1:3210",
headless: true,
},
projects: [
{
name: "chromium",
use: { browserName: "chromium" },
},
],
});
7. AI 评测体系:从断言到评估器
这是 AI Agent 测试体系里最特殊、也最容易被忽略的一层。前面三层测的是「代码对不对」,这一层测的是「模型输出好不好」。传统断言在这里失效,需要引入评估器(Evaluator)。
7.1 双层评估策略
#mermaid-svg-DOU0hse9MYSffTJh{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DOU0hse9MYSffTJh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DOU0hse9MYSffTJh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DOU0hse9MYSffTJh .error-icon{fill:#552222;}#mermaid-svg-DOU0hse9MYSffTJh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DOU0hse9MYSffTJh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DOU0hse9MYSffTJh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DOU0hse9MYSffTJh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DOU0hse9MYSffTJh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DOU0hse9MYSffTJh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DOU0hse9MYSffTJh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DOU0hse9MYSffTJh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DOU0hse9MYSffTJh .marker.cross{stroke:#333333;}#mermaid-svg-DOU0hse9MYSffTJh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DOU0hse9MYSffTJh p{margin:0;}#mermaid-svg-DOU0hse9MYSffTJh .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DOU0hse9MYSffTJh .cluster-label text{fill:#333;}#mermaid-svg-DOU0hse9MYSffTJh .cluster-label span{color:#333;}#mermaid-svg-DOU0hse9MYSffTJh .cluster-label span p{background-color:transparent;}#mermaid-svg-DOU0hse9MYSffTJh .label text,#mermaid-svg-DOU0hse9MYSffTJh span{fill:#333;color:#333;}#mermaid-svg-DOU0hse9MYSffTJh .node rect,#mermaid-svg-DOU0hse9MYSffTJh .node circle,#mermaid-svg-DOU0hse9MYSffTJh .node ellipse,#mermaid-svg-DOU0hse9MYSffTJh .node polygon,#mermaid-svg-DOU0hse9MYSffTJh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DOU0hse9MYSffTJh .rough-node .label text,#mermaid-svg-DOU0hse9MYSffTJh .node .label text,#mermaid-svg-DOU0hse9MYSffTJh .image-shape .label,#mermaid-svg-DOU0hse9MYSffTJh .icon-shape .label{text-anchor:middle;}#mermaid-svg-DOU0hse9MYSffTJh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DOU0hse9MYSffTJh .rough-node .label,#mermaid-svg-DOU0hse9MYSffTJh .node .label,#mermaid-svg-DOU0hse9MYSffTJh .image-shape .label,#mermaid-svg-DOU0hse9MYSffTJh .icon-shape .label{text-align:center;}#mermaid-svg-DOU0hse9MYSffTJh .node.clickable{cursor:pointer;}#mermaid-svg-DOU0hse9MYSffTJh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DOU0hse9MYSffTJh .arrowheadPath{fill:#333333;}#mermaid-svg-DOU0hse9MYSffTJh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DOU0hse9MYSffTJh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DOU0hse9MYSffTJh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DOU0hse9MYSffTJh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DOU0hse9MYSffTJh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DOU0hse9MYSffTJh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DOU0hse9MYSffTJh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DOU0hse9MYSffTJh .cluster text{fill:#333;}#mermaid-svg-DOU0hse9MYSffTJh .cluster span{color:#333;}#mermaid-svg-DOU0hse9MYSffTJh div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DOU0hse9MYSffTJh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DOU0hse9MYSffTJh rect.text{fill:none;stroke-width:0;}#mermaid-svg-DOU0hse9MYSffTJh .icon-shape,#mermaid-svg-DOU0hse9MYSffTJh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DOU0hse9MYSffTJh .icon-shape p,#mermaid-svg-DOU0hse9MYSffTJh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DOU0hse9MYSffTJh .icon-shape .label rect,#mermaid-svg-DOU0hse9MYSffTJh .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DOU0hse9MYSffTJh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DOU0hse9MYSffTJh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DOU0hse9MYSffTJh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Agent 输出
规则评估器(确定性)
LLM-as-judge 评估器(语义)
打分合并
低于阈值 → 进入回归分析
- 规则评估器:检查硬性约束------格式、长度、是否包含禁用词、tool call 参数是否合法、有没有输出泄漏的系统提示词。它是确定性的,可以给硬指标。
- LLM-as-judge:用另一个模型当裁判,评估语义质量------回答是否切题、是否礼貌、步骤是否清晰。它是不确定的,所以只能给软指标,并且要和规则评估器结合使用。
7.2 规则评估器实现
typescript
// src/eval/rules.ts
export interface RuleEvaluation {
passed: boolean;
score: number; // 0-1
failures: string[];
}
export function evaluateByRules(output: string): RuleEvaluation {
const failures: string[] = [];
let score = 1;
// 规则 1:非空
if (output.trim().length === 0) {
failures.push("输出为空");
score -= 0.5;
}
// 规则 2:长度不超过硬上限
if (output.length > 4000) {
failures.push("输出过长");
score -= 0.3;
}
// 规则 3:不泄漏系统提示词
const bannedPhrases = ["system prompt", "你是 AI 助手", "<system>", "instructions:"];
for (const phrase of bannedPhrases) {
if (output.toLowerCase().includes(phrase)) {
failures.push(`疑似泄漏提示词片段:${phrase}`);
score -= 0.4;
}
}
// 规则 4:若包含代码块,必须闭合
const fences = (output.match(/```/g) ?? []).length;
if (fences % 2 !== 0) {
failures.push("代码块未闭合");
score -= 0.3;
}
return {
passed: failures.length === 0,
score: Math.max(0, score),
failures,
};
}
测试它(注意:评估器本身也要有测试,否则就是「不确定的东西测不确定的东西」):
typescript
// src/eval/rules.test.ts
import { describe, it, expect } from "vitest";
import { evaluateByRules } from "./rules";
describe("evaluateByRules", () => {
it("正常输出全部通过", () => {
const result = evaluateByRules("你好,我是你的助手,有什么可以帮你?");
expect(result.passed).toBe(true);
expect(result.score).toBe(1);
});
it("检测到提示词泄漏", () => {
const result = evaluateByRules("我的系统提示词是:<system> you are a helpful assistant");
expect(result.passed).toBe(false);
expect(result.failures.some((f) => f.includes("泄漏"))).toBe(true);
});
it("检测到未闭合代码块", () => {
const result = evaluateByRules("下面是代码:\n```typescript\nconst a = 1;");
expect(result.passed).toBe(false);
expect(result.failures.some((f) => f.includes("闭合"))).toBe(true);
});
});
7.3 LLM-as-judge 评估器
typescript
// src/eval/judge.ts
import { LLMProvider } from "../llm/provider";
export interface JudgeResult {
score: number; // 0-10
reasoning: string;
}
const JUDGE_PROMPT = `你是一个严格的评测裁判。请根据以下标准给 AI 助手的回答打分(0-10 分):
1. 是否准确回答了用户问题(4 分)
2. 是否简洁、无冗余信息(2 分)
3. 语气是否礼貌、专业(2 分)
4. 若涉及代码,是否正确可运行(2 分)
只输出 JSON:{"score": 数字, "reasoning": "一句话理由"}`;
export class LLMJudge {
constructor(private llm: LLMProvider) {}
async evaluate(question: string, answer: string): Promise<JudgeResult> {
const response = await this.llm.complete({
messages: [
{ role: "system", content: JUDGE_PROMPT },
{ role: "user", content: `用户问题:${question}\n\nAI 回答:${answer}` },
],
temperature: 0,
});
try {
const parsed = JSON.parse(response.content);
return {
score: Math.max(0, Math.min(10, Number(parsed.score))),
reasoning: String(parsed.reasoning ?? ""),
};
} catch {
return { score: 0, reasoning: `裁判输出无法解析:${response.content.slice(0, 100)}` };
}
}
}
关键点:temperature: 0 让裁判输出尽量稳定,同时裁判的输出被 JSON 硬约束,解析失败时直接判 0 分,避免「裁判说胡话」污染结果。
评测结果落盘,形成一份可追踪的报告:
typescript
// src/eval/report.ts
import { writeFileSync } from "node:fs";
export interface EvalCase {
id: string;
question: string;
answer: string;
ruleScore: number;
ruleFailures: string[];
judgeScore?: number;
judgeReasoning?: string;
passed: boolean;
}
export function writeEvalReport(cases: EvalCase[], path = "eval-report.json") {
const summary = {
total: cases.length,
passed: cases.filter((c) => c.passed).length,
avgRuleScore: cases.reduce((sum, c) => sum + c.ruleScore, 0) / cases.length,
avgJudgeScore:
cases.filter((c) => c.judgeScore !== undefined).reduce((sum, c) => sum + (c.judgeScore ?? 0), 0) /
Math.max(1, cases.filter((c) => c.judgeScore !== undefined).length),
cases,
};
writeFileSync(path, JSON.stringify(summary, null, 2));
return summary;
}
8. CI/CD 落地:GitHub Actions 实战
把上面四层串起来,用 GitHub Actions 搭建一条分层流水线。核心思路是快慢分离:PR 上只跑快测试(单元 + 集成 + 规则评测),真实模型的 E2E 和 LLM-as-judge 放到夜间任务,避免 PR 被昂贵的模型调用拖垮。
yaml
# .github/workflows/test.yml
name: Test Pipeline
on:
pull_request:
push:
branches: [main]
schedule:
# 每天 UTC 时间凌晨 2 点跑一次完整流水线(含真实模型)
- cron: "0 2 * * *"
workflow_dispatch:
jobs:
fast-tests:
name: 单元 + 集成 + 规则评测
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: 单元测试 + 覆盖率
run: npm run test:unit -- --coverage
- name: 集成测试
run: npm run test:integration
- name: 规则评测
run: npm run eval:rules
- name: 上传覆盖率报告
uses: codecov/codecov-action@v4
with:
files: coverage/coverage-final.json
e2e-model:
name: E2E + LLM-as-judge(真实模型)
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: 启动 Gateway
run: |
npm run gateway &
sleep 15
- name: 安装 Playwright 浏览器
run: npx playwright install --with-deps chromium
- name: E2E 测试
run: npm run test:e2e
env:
E2E_GATEWAY_URL: http://127.0.0.1:3210
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- name: LLM-as-judge 评测
run: npm run eval:judge
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
- name: 上传评测报告
uses: actions/upload-artifact@v4
with:
name: eval-report
path: eval-report.json
package.json 里的脚本:
json
{
"scripts": {
"test:unit": "vitest run src --exclude spec",
"test:integration": "vitest run src/channels src/agents",
"test:e2e": "playwright test",
"eval:rules": "tsx src/eval/run-rules.ts",
"eval:judge": "tsx src/eval/run-judge.ts"
}
}
这套配置落地后,日常开发只等几分钟,真实模型回归在夜里自动跑,评测报告作为 artifact 留存,方便回溯「哪次提示词改动导致评分下降」。
9. 实践经验与避坑指南
坑 1:用真实模型写单元测试
最常见的错误是把真实 LLM 塞进单元测试里,结果测试又慢又 flaky,团队最后被迫关掉 CI。原则很简单:凡是能 mock 的,一律 mock;真实模型只在 E2E 和评测层出现。
坑 2:把非确定性输出硬编码成断言
expect(reply).toBe("你好,我是你的助手") 这类断言必挂。正确做法是断言语义特征(包含关键词)、结构特征(JSON 可解析)、或交给评估器打分。
坑 3:评估器没有自己的测试
LLM-as-judge 本身就是个 LLM 应用,它自己的行为也不确定。规则评估器必须写成纯函数并配测试;裁判 prompt 也要定期用「金标准数据集」回归,防止悄悄退化。
坑 4:忽略 tool call 的参数校验
模型返回的 tool call 参数经常缺字段、类型错。在生产代码里永远不要直接信任模型输出,要过一层 zod 之类的 schema 校验。测试里也要覆盖「参数非法」这条路径:
typescript
import { z } from "zod";
const SendEmailArgs = z.object({
to: z.string().email(),
body: z.string().min(1),
});
// 在 tool.execute 之前校验,校验失败走重试或降级
const parsed = SendEmailArgs.safeParse(call.arguments);
if (!parsed.success) {
// 把校验错误回填给模型,让它重新生成
return { error: parsed.error.message };
}
坑 5:评测数据不隔离
评测案例要放在独立仓库目录(eval/cases/),和测试代码分开管理。每次修改提示词后,用同一批评测案例跑分,对比历史报告,才能判断改动是变好还是变坏。
建议的落地节奏
- 第一周:给 Skills 和纯逻辑补单元测试,把覆盖率拉到 60% 以上;
- 第二周:引入 StubLLM,给 Agent 编排链路写集成测试;
- 第三周:搭规则评估器,先跑通「硬性约束」的自动检查;
- 第四周:加 Playwright E2E 冒烟 + LLM-as-judge,接进夜间流水线。
10. 总结
AI Agent 的测试不是传统测试的简单延伸,它要求我们重新审视「什么是可断言的」。把这套体系落地的关键,可以归纳成四句话:
- 把不确定性隔离出去:纯逻辑、Schema、路由这些确定的部分,用单元测试白盒覆盖,覆盖得越狠越好;
- 把模型变成可替换的依赖:通过 LLM provider 抽象 + Stub,让 Agent 编排链路可以脱离真实模型快速验证;
- 把「好不好」交给评估器:规则评估器管硬指标,LLM-as-judge 管软指标,两者结合才能兼顾稳定与语义;
- 用 CI 把快慢分开:PR 只跑快测试,真实模型回归放夜间,评测报告留痕可回溯。
测试策略从来没有「一劳永逸」,尤其是面对 AI Agent 这种还在快速演进的技术形态。先把这套分层框架搭起来,再随着项目演化逐步填充每一层的用例,比一开始追求完美覆盖要实际得多。