OpenClaw 测试策略实战:AI Agent 自动化测试体系搭建与落地

目录

  • [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 和评测体系来兜底。

所以测试切入点优先落在三处:

  1. Skills 是天然的单元测试对象------它们是独立模块,有明确的输入输出,不依赖模型;
  2. Agent 的决策链路用 Mock LLM 测------把模型换成可编程的 stub,验证提示词组装、tool schema 和路由逻辑;
  3. 消息渠道用集成测试测------用本地 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/),和测试代码分开管理。每次修改提示词后,用同一批评测案例跑分,对比历史报告,才能判断改动是变好还是变坏。

建议的落地节奏

  1. 第一周:给 Skills 和纯逻辑补单元测试,把覆盖率拉到 60% 以上;
  2. 第二周:引入 StubLLM,给 Agent 编排链路写集成测试;
  3. 第三周:搭规则评估器,先跑通「硬性约束」的自动检查;
  4. 第四周:加 Playwright E2E 冒烟 + LLM-as-judge,接进夜间流水线。

10. 总结

AI Agent 的测试不是传统测试的简单延伸,它要求我们重新审视「什么是可断言的」。把这套体系落地的关键,可以归纳成四句话:

  • 把不确定性隔离出去:纯逻辑、Schema、路由这些确定的部分,用单元测试白盒覆盖,覆盖得越狠越好;
  • 把模型变成可替换的依赖:通过 LLM provider 抽象 + Stub,让 Agent 编排链路可以脱离真实模型快速验证;
  • 把「好不好」交给评估器:规则评估器管硬指标,LLM-as-judge 管软指标,两者结合才能兼顾稳定与语义;
  • 用 CI 把快慢分开:PR 只跑快测试,真实模型回归放夜间,评测报告留痕可回溯。

测试策略从来没有「一劳永逸」,尤其是面对 AI Agent 这种还在快速演进的技术形态。先把这套分层框架搭起来,再随着项目演化逐步填充每一层的用例,比一开始追求完美覆盖要实际得多。

相关推荐
糖果店的幽灵1 小时前
Codex官网前端可抄吗?从模仿到创新的技术实践指南
前端·人工智能
CypressTel1 小时前
Meta发布面向本地运行的开放权重模型——赛柏特AI快讯
人工智能
熊猫钓鱼>_>1 小时前
Seedance 2.0 技术深度解析:重构AI视频生成的世界模型新范式
人工智能·笔记·ai·重构·音视频·变革·sedence2.0
弈语道破AI1 小时前
3D渲染不再熬时间!即梦 Seedance 2.5 具备3D白模渲染功能的AI视频生成工具
人工智能·3d·音视频
laboratory agent开发1 小时前
企业AI Agent开发中的接口契约校验:外部接口格式变更如何避免解析失败
人工智能
不爱土豆唯爱马铃薯2 小时前
我用MonkeyCode给科研生活做了个塔罗牌占卜
人工智能
吨吨ai2 小时前
ChatGPT Plus / Pro 用户的 Codex 进阶实战:从 CLI 配置到 Agent 工作流、多文件重构与用量控制的完整指南
chatgpt·重构
m0_617493942 小时前
OpenCV cv2.circle() 坐标类型错误排查与修复指南
人工智能·opencv·计算机视觉