AI Agent Harness Engineering 的白盒测试:从单元测试到集成测试的完整方案


引言

如果你最近半年在做AI Agent落地,一定遇到过这种崩溃的场景:线上用户反馈Agent给出了完全错误的回答,你翻遍了日志只看到用户的提问和最终的输出,中间到底是RAG召回错了?还是规划模块乱调工具?还是记忆模块丢了上下文?完全无从查起。你试着写了一堆黑盒测试用例,但是LLM的输出五花八门,传统的字符串相等断言完全用不了,每次上线还是像开盲盒。

这就是当前AI Agent测试的最大痛点:黑盒测试只能验证最终输出的正确性,完全无法覆盖内部复杂的逻辑链路,一旦出问题定位成本极高,甚至很多隐性问题根本发现不了。而AI Agent Harness Engineering(测试支架工程)的白盒测试方案,就是解决这个问题的核心手段。

本文我会结合我所在团队半年多的Agent落地经验,给大家分享一套从单元测试到集成测试的完整白盒测试方案,从核心概念、架构设计到代码实现、落地案例全覆盖,看完你就能直接在自己的团队落地这套方案,把Agent的上线故障率降低80%以上。


一、基础概念与核心定义

1.1 核心概念解释

什么是AI Agent Harness Engineering?

Harness(测试支架)是一套独立于Agent业务逻辑的辅助测试系统,通过埋点探针、模拟桩、链路追踪、断言引擎等能力,为Agent提供可观测、可控制、可验证的测试环境。Harness Engineering就是围绕这套支架的设计、开发、落地的整套工程实践。

什么是AI Agent白盒测试?

和传统软件的白盒测试类似,AI Agent的白盒测试是指在明确Agent内部组件结构、交互逻辑、决策规则的前提下,对每个组件的内部逻辑、组件之间的交互链路进行测试,验证其是否符合预期设计,而不仅仅只看最终输出。

AI Agent的核心组件(通用模型)

当前行业主流的Agent都可以拆解为5个核心组件,这也是我们白盒测试的核心对象:

组件名称 核心职责
规划模块 接收用户提问和上下文,决策下一步要做什么:调用工具、检索RAG、还是直接生成回复
记忆模块 存储和管理对话上下文、长期用户画像、历史交互数据,为其他模块提供上下文支持
RAG检索模块 根据查询词召回知识库中的相关片段,为生成模块提供事实依据
工具调用模块 调用外部工具(比如查订单、发邮件、调用API),获取外部实时数据
输出生成模块 整合所有上下文信息,生成符合要求的自然语言回复
Harness的核心组成

一套完整的Agent测试Harness包含5个核心模块:

  1. 探针:无侵入埋点在Agent的每个组件中,采集组件的输入、输出、执行时间、错误信息等数据
  2. 桩模块:模拟Agent的外部依赖(比如LLM、第三方API、数据库),返回可控的测试数据
  3. 用例编排引擎:管理测试用例,控制测试流程的执行顺序、参数注入
  4. 断言引擎:支持逻辑断言、语义断言等多种断言方式,验证测试结果是否符合预期
  5. 度量采集器:采集测试覆盖率、通过率、执行时间等指标,生成测试报告

1.2 传统软件白盒测试 vs AI Agent白盒测试对比

两者的核心逻辑一致,但因为测试对象的特性不同,存在明显差异:

对比维度 传统软件白盒测试 AI Agent白盒测试
测试对象 固定逻辑的代码 代码+LLM+数据的混合系统
核心判定依据 代码分支是否执行、输出是否完全匹配 逻辑决策是否正确、语义是否符合预期
覆盖维度 行覆盖、分支覆盖、路径覆盖 组件覆盖、链路覆盖、逻辑覆盖
断言方式 精确相等、类型匹配等确定性断言 逻辑规则匹配、语义相似度匹配等模糊断言
用例设计方法 等价类划分、边界值分析 等价类划分+prompt injection测试+场景化用例
工具生态 JUnit、Pytest、Jacoco等成熟工具 LangSmith、LangFuse、自定义Harness等新兴工具

1.3 概念关系交互图

包含

包含

依赖

依赖

依赖

依赖

包含

包含

包含

包含

包含

包含

包含

包含

包含

白盒测试

单元测试

集成测试

探针

桩模块

链路追踪

断言引擎

Harness

用例编排引擎

Agent

规划模块

记忆模块

RAG模块

工具调用模块

生成模块


二、问题背景与当前挑战

AI Agent的特性决定了传统的测试方案完全无法满足需求,当前行业普遍面临4个核心测试痛点:

2.1 非确定性输出导致传统断言失效

LLM的输出是概率性的,同一个提问多次调用可能返回不同的表述,但是核心语义是一致的。比如预期输出是“你的订单将在3天内发货”,LLM可能返回“亲,我们会在72小时内为你安排发货哦”,内容完全正确,但是传统的字符串相等断言会直接报错,导致测试用例的误报率极高。

2.2 内部状态不可观测,根因定位成本极高

Agent的运行是一个长链路的过程:用户提问->记忆模块提取上下文->规划模块决策->调用RAG/工具->生成回复,中间任何一个环节出问题都会导致最终输出错误。如果没有内部埋点,你只能看到最终的错误输出,完全不知道是哪个环节出了问题,排查一个问题可能要花几个小时甚至几天。

2.3 多组件交互复杂,边界case难覆盖

Agent的组件之间存在复杂的交互关系:比如规划模块的输出会作为工具调用的输入,工具返回的结果会作为RAG的查询词,RAG的结果会作为生成模块的输入。任何一个组件的异常输出都可能引发连锁反应,比如工具返回的JSON格式错误,规划模块没有做异常处理,就会导致整个Agent崩溃。这些边界case在黑盒测试中很难覆盖到。

2.4 外部依赖不可控,测试稳定性差

Agent依赖大量外部服务:LLM、第三方API、知识库、数据库等,这些外部服务的波动会导致测试结果不稳定,比如LLM返回超时、第三方API报错,都会导致测试用例失败,但是这些问题并不是Agent本身的bug,会浪费大量的排查时间。


三、核心测试方案:从单元测试到集成测试

3.1 单元测试方案:组件级白盒测试

单元测试的核心目标是验证每个组件的内部逻辑是否符合预期,不需要依赖其他组件或者外部服务,所有外部依赖都用桩模块模拟。

3.1.1 各组件的单元测试要点
组件名称 测试核心点 断言方式
规划模块 1. 是否能正确识别需要调用的工具/需要检索的RAG
2. 是否能正确生成工具调用参数
3. 是否能正确处理LLM的异常返回(格式错误、空值、不存在的工具)
逻辑断言:判断工具名称、参数是否符合预期
异常断言:判断是否能正确抛出异常或者重试
记忆模块 1. 是否能正确提取指定窗口的上下文
2. 是否能正确过滤无效信息
3. 是否能正确存储和读取长期记忆
逻辑断言:判断返回的上下文是否包含预期内容
边界断言:判断窗口大小超出限制时是否能正确截断
RAG检索模块 1. 是否能召回相关的Chunk
2. Top N召回的准确率、召回率是否达标
3. 是否能正确处理空查询、无关查询
语义断言:判断召回的Chunk和查询词的相似度是否达标
逻辑断言:判断召回的数量是否符合预期
工具调用模块 1. 是否能正确调用指定工具
2. 是否能正确处理工具的异常返回(报错、超时、格式错误)
3. 是否能正确解析工具返回的结果
逻辑断言:判断工具调用的参数、返回结果是否符合预期
异常断言:判断是否能正确处理工具异常
输出生成模块 1. 是否能正确整合所有上下文信息
2. 是否能符合指定的语气、格式要求
3. 是否能避免幻觉输出
语义断言:判断输出的语义是否符合预期
逻辑断言:判断输出是否包含预期的关键信息
3.1.2 单元测试代码示例(规划模块)

我们以基于LangChain实现的规划模块为例,展示如何写单元测试:

import pytest
from unittest.mock import Mock, patch
from my_agent.modules.planning import plan
from my_agent.harness.probe import probe
from my_agent.harness.assertion import AssertionEngine

engine = AssertionEngine()

# 用桩模块模拟LLM的返回
@patch("my_agent.modules.planning.call_llm")
def test_plan_call_order_tool(mock_call_llm):
    # 模拟LLM返回正确的Function Call
    mock_call_llm.return_value = '''
    {
        "tool_name": "order_query",
        "parameters": {
            "order_id": "123456",
            "user_id": "789"
        }
    }
    '''
    # 执行测试
    result = plan(
        user_query="我的订单123456什么时候发货",
        context=[{"role": "user", "content": "我的user id是789"}],
        trace_id="test_plan_001"
    )
    # 逻辑断言:判断是否调用了正确的工具,参数是否正确
    expected = {"tool_name": "order_query", "parameters": {"order_id": "123456"}}
    assert engine.assert_logic(result, expected) == True

@patch("my_agent.modules.planning.call_llm")
def test_plan_invalid_json_response(mock_call_llm):
    # 模拟LLM返回格式错误的JSON
    mock_call_llm.return_value = "这不是一个正确的JSON格式"
    # 执行测试,预期会抛出参数解析异常
    with pytest.raises(ValueError, match="LLM返回的JSON格式错误"):
        plan(
            user_query="我的订单什么时候发货",
            context=[],
            trace_id="test_plan_002"
        )

@patch("my_agent.modules.planning.call_llm")
def test_plan_unknown_tool(mock_call_llm):
    # 模拟LLM返回不存在的工具
    mock_call_llm.return_value = '''
    {
        "tool_name": "unknown_tool",
        "parameters": {}
    }
    '''
    # 执行测试,预期会返回直接生成回复的决策
    result = plan(
        user_query="你好",
        context=[],
        trace_id="test_plan_003"
    )
    expected = {"action": "generate_reply"}
    assert engine.assert_logic(result, expected) == True

3.2 集成测试方案:组件交互链路测试

单元测试验证了单个组件的正确性,集成测试的核心目标是验证组件之间的交互链路是否符合预期,不需要模拟内部组件,只需要模拟最外层的外部依赖(比如第三方API、LLM可选模拟)。

3.2.1 集成测试的核心链路

我们按照核心业务场景划分集成测试的链路,优先级从高到低:

  1. 核心工具调用链路:用户提问->记忆模块提取上下文->规划模块决策调用工具->工具调用模块执行->生成模块返回结果
  2. RAG问答链路:用户提问->记忆模块提取上下文->RAG检索相关Chunk->生成模块返回结果
  3. 多轮对话链路:多轮用户提问->记忆模块存储上下文->规划模块基于上下文决策->生成模块返回连贯的回复
  4. 异常处理链路:工具返回异常/ RAG返回空/ LLM超时->各个组件的异常处理->生成模块返回友好的错误提示
3.2.2 集成测试流程图

创建测试用例:配置用户提问、上下文、预期链路

注入桩数据:模拟外部依赖的返回

触发Agent执行,自动生成TraceID

探针采集每个组件的输入输出、执行状态

断言引擎校验链路逻辑:
1. 组件调用顺序是否正确
2. 组件之间传递的参数是否正确
3. 最终输出的语义是否符合预期

断言是否通过?

标记用例通过,更新覆盖率数据

生成错误报告:标记出错的组件、错误原因、链路数据

所有用例执行完成后生成总测试报告

3.2.3 集成测试代码示例(工具调用链路)
import pytest
from unittest.mock import patch
from my_agent.agent import Agent
from my_agent.harness.assertion import AssertionEngine
from my_agent.harness.trace import get_trace_data

engine = AssertionEngine()
agent = Agent()

@patch("my_agent.modules.tool_call.order_query_api")
def test_integration_order_query_link(mock_order_api):
    # 模拟订单查询API的返回
    mock_order_api.return_value = {
        "order_id": "123456",
        "status": "shipped",
        "ship_time": "2024-05-20",
        "delivery_time": "2024-05-22"
    }
    # 执行测试
    trace_id = "test_integration_001"
    reply = agent.run(
        user_query="我的订单123456什么时候到",
        context=[{"role": "user", "content": "我的user id是789"}],
        trace_id=trace_id
    )
    # 1. 链路断言:判断组件调用顺序是否正确
    trace_data = get_trace_data(trace_id)
    component_order = [item["component_name"] for item in trace_data]
    assert component_order == ["memory_module", "planning_module", "tool_call_module", "generation_module"]
    # 2. 参数传递断言:判断工具调用的参数是否正确
    tool_call_input = next(item["input"] for item in trace_data if item["component_name"] == "tool_call_module")
    assert tool_call_input["tool_name"] == "order_query"
    assert tool_call_input["parameters"]["order_id"] == "123456"
    # 3. 输出语义断言:判断回复是否符合预期
    expected_reply = "你的订单123456已经发货,预计2024-05-22送达"
    assert engine.assert_semantic(reply, expected_reply) == True

四、Harness系统设计与核心实现

4.1 系统架构设计

渲染错误: Mermaid 渲染失败: Parsing failed: Lexer error on line 2, column 34: unexpected character: ->[<- at offset: 51, skipped 8 characters. Lexer error on line 3, column 41: unexpected character: ->[<- at offset: 100, skipped 6 characters. Lexer error on line 4, column 40: unexpected character: ->[<- at offset: 146, skipped 6 characters. Lexer error on line 5, column 39: unexpected character: ->[<- at offset: 191, skipped 8 characters. Lexer error on line 7, column 30: unexpected character: ->[<- at offset: 234, skipped 1 characters. Lexer error on line 7, column 38: unexpected character: ->核<- at offset: 242, skipped 4 characters. Lexer error on line 8, column 39: unexpected character: ->[<- at offset: 285, skipped 7 characters. Lexer error on line 9, column 38: unexpected character: ->[<- at offset: 330, skipped 6 characters. Lexer error on line 10, column 42: unexpected character: ->[<- at offset: 378, skipped 6 characters. Lexer error on line 11, column 42: unexpected character: ->[<- at offset: 426, skipped 7 characters. Lexer error on line 13, column 30: unexpected character: ->[<- at offset: 468, skipped 1 characters. Lexer error on line 13, column 36: unexpected character: ->业<- at offset: 474, skipped 4 characters. Lexer error on line 14, column 34: unexpected character: ->[<- at offset: 512, skipped 6 characters. Lexer error on line 15, column 32: unexpected character: ->[<- at offset: 550, skipped 6 characters. Lexer error on line 16, column 29: unexpected character: ->[<- at offset: 585, skipped 1 characters. Lexer error on line 16, column 33: unexpected character: ->检<- at offset: 589, skipped 5 characters. Lexer error on line 17, column 35: unexpected character: ->[<- at offset: 629, skipped 8 characters. Lexer error on line 18, column 36: unexpected character: ->[<- at offset: 673, skipped 8 characters. Lexer error on line 20, column 28: unexpected character: ->[<- at offset: 714, skipped 5 characters. Lexer error on line 21, column 35: unexpected character: ->[<- at offset: 754, skipped 5 characters. Lexer error on line 22, column 37: unexpected character: ->[<- at offset: 796, skipped 5 characters. Lexer error on line 23, column 35: unexpected character: ->[<- at offset: 836, skipped 6 characters. Lexer error on line 25, column 36: unexpected character: ->/<- at offset: 883, skipped 1 characters. Lexer error on line 26, column 40: unexpected character: ->/<- at offset: 925, skipped 1 characters. Lexer error on line 27, column 39: unexpected character: ->/<- at offset: 966, skipped 1 characters. Parse error on line 7, column 31: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: 'Harness' Parse error on line 7, column 42: Expecting token of type ':' but found ` `. Parse error on line 13, column 31: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: 'Agent' Parse error on line 13, column 40: Expecting token of type ':' but found ` `. Parse error on line 16, column 30: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: 'R' Parse error on line 16, column 38: Expecting token of type ':' but found ` `. Parse error on line 21, column 18: Expecting token of type ':' but found `case_db`. Parse error on line 21, column 25: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: '(database)' Parse error on line 22, column 18: Expecting token of type ':' but found `metric_db`. Parse error on line 22, column 27: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: '(database)' Parse error on line 23, column 18: Expecting token of type ':' but found `stub_db`. Parse error on line 23, column 25: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: '(database)' Parse error on line 25, column 27: Expecting token of type 'ARROW_DIRECTION' but found `case_db`. Parse error on line 25, column 34: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 25, column 38: Expecting token of type ':' but found ` `. Parse error on line 26, column 25: Expecting token of type 'ARROW_DIRECTION' but found `probe_manager`. Parse error on line 26, column 38: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 26, column 42: Expecting token of type ':' but found ` `. Parse error on line 27, column 25: Expecting token of type 'ARROW_DIRECTION' but found `stub_manager`. Parse error on line 27, column 37: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 27, column 41: Expecting token of type ':' but found ` `. Parse error on line 28, column 25: Expecting token of type 'ARROW_DIRECTION' but found `planning`. Parse error on line 28, column 33: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 28, column 40: Expecting token of type ':' but found ` `. Parse error on line 29, column 25: Expecting token of type 'ARROW_DIRECTION' but found `memory`. Parse error on line 29, column 31: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 29, column 38: Expecting token of type ':' but found ` `. Parse error on line 30, column 25: Expecting token of type 'ARROW_DIRECTION' but found `rag`. Parse error on line 30, column 28: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 30, column 35: Expecting token of type ':' but found ` `. Parse error on line 31, column 25: Expecting token of type 'ARROW_DIRECTION' but found `tool_call`. Parse error on line 31, column 34: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 31, column 41: Expecting token of type ':' but found ` `. Parse error on line 32, column 25: Expecting token of type 'ARROW_DIRECTION' but found `generation`. Parse error on line 32, column 35: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 32, column 42: Expecting token of type ':' but found ` `. Parse error on line 33, column 25: Expecting token of type 'ARROW_DIRECTION' but found `metric_collector`. Parse error on line 33, column 41: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 33, column 48: Expecting token of type ':' but found ` `. Parse error on line 34, column 24: Expecting token of type 'ARROW_DIRECTION' but found `planning`. Parse error on line 34, column 32: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 34, column 37: Expecting token of type ':' but found ` `. Parse error on line 35, column 24: Expecting token of type 'ARROW_DIRECTION' but found `tool_call`. Parse error on line 35, column 33: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 35, column 38: Expecting token of type ':' but found ` `. Parse error on line 36, column 24: Expecting token of type 'ARROW_DIRECTION' but found `rag`. Parse error on line 36, column 27: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 36, column 32: Expecting token of type ':' but found ` `. Parse error on line 37, column 28: Expecting token of type 'ARROW_DIRECTION' but found `metric_db`. Parse error on line 37, column 37: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 37, column 39: Expecting token of type ':' but found ` `. Parse error on line 38, column 28: Expecting token of type 'ARROW_DIRECTION' but found `metric_db`. Parse error on line 38, column 37: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 39, column 28: Expecting token of type 'ARROW_DIRECTION' but found `report_service`. Parse error on line 39, column 42: Expecting: one of these possible Token sequences: 1. [NEWLINE] 2. [EOF] but found: ':' Parse error on line 39, column 48: Expecting token of type ':' but found `result`.

4.2 核心接口设计

接口名称 请求方法 路径 核心参数 返回值
探针上报 POST /v1/probe/report trace_id、component_name、input、output、timestamp、cost_time 成功/失败状态
桩数据查询 POST /v1/stub/get component_name、input、trace_id 模拟的输出数据
用例执行 POST /v1/test/run case_id、case_config、agent_version 执行状态、trace_id
测试报告查询 GET /v1/report/{case_id} case_id、agent_version 用例通过率、覆盖率、错误详情
覆盖率查询 GET /v1/coverage agent_version 组件覆盖率、链路覆盖率、逻辑覆盖率、总覆盖率

4.3 核心实现代码

4.3.1 无侵入探针实现
import functools
import time
from typing import Any, Callable
import aiohttp
from pydantic import BaseModel
from contextvars import ContextVar

trace_id_var: ContextVar[str] = ContextVar("trace_id", default="default")

class ProbeReport(BaseModel):
    trace_id: str
    component_name: str
    input: Any
    output: Any
    timestamp: float
    cost_time: float
    error: str | None = None

PROBE_REPORT_URL = "http://harness-core/v1/probe/report"
ENABLE_PROBE = True  # 生产环境设置为False

def probe(component_name: str) -> Callable:
    def decorator(func: Callable) -> Callable:
        @functools.wraps(func)
        async def async_wrapper(*args, **kwargs):
            if not ENABLE_PROBE:
                return await func(*args, **kwargs)
            trace_id = trace_id_var.get()
            start_time = time.time()
            error = None
            output = None
            try:
                output = await func(*args, **kwargs)
                return output
            except Exception as e:
                error = str(e)
                raise
            finally:
                cost_time = time.time() - start_time
                report = ProbeReport(
                    trace_id=trace_id,
                    component_name=component_name,
                    input={"args": args, "kwargs": kwargs},
                    output=output,
                    timestamp=start_time,
                    cost_time=cost_time,
                    error=error
                )
                # 异步上报,不阻塞主流程
                try:
                    async with aiohttp.ClientSession() as session:
                        await session.post(PROBE_REPORT_URL, json=report.dict(), timeout=0.5)
                except Exception:
                    pass
        
        @functools.wraps(func)
        def sync_wrapper(*args, **kwargs):
            if not ENABLE_PROBE:
                return func(*args, **kwargs)
            trace_id = trace_id_var.get()
            start_time = time.time()
            error = None
            output = None
            try:
                output = func(*args, **kwargs)
                return output
            except Exception as e:
                error = str(e)
                raise
            finally:
                cost_time = time.time() - start_time
                report = ProbeReport(
                    trace_id=trace_id,
                    component_name=component_name,
                    input={"args": args, "kwargs": kwargs},
                    output=output,
                    timestamp=start_time,
                    cost_time=cost_time,
                    error=error
                )
                # 异步上报,不阻塞主流程
                import threading
                def send_report():
                    import requests
                    try:
                        requests.post(PROBE_REPORT_URL, json=report.dict(), timeout=0.5)
                    except Exception:
                        pass
                threading.Thread(target=send_report, daemon=True).start()
        
        return async_wrapper if func.__code__.co_flags & 0x80 else sync_wrapper
    return decorator
4.3.2 语义断言引擎实现
from typing import Any, Dict, List
import openai
import numpy as np
from sklearn.metrics.pairwise import cosine_similarity as sk_cosine_similarity

class AssertionEngine:
    def __init__(self, similarity_threshold: float = 0.85, embedding_model: str = "text-embedding-3-small"):
        self.similarity_threshold = similarity_threshold
        self.embedding_model = embedding_model
        self.openai_client = openai.Client()
        self.embedding_cache = {}
    
    def _get_embedding(self, text: str) -> List[float]:
        if text in self.embedding_cache:
            return self.embedding_cache[text]
        resp = self.openai_client.embeddings.create(input=text, model=self.embedding_model)
        emb = resp.data[0].embedding
        self.embedding_cache[text] = emb
        return emb
    
    def assert_logic(self, actual: Any, expected: Dict) -> bool:
        """逻辑断言:验证实际输出是否符合预期的逻辑规则"""
        if isinstance(expected, dict):
            if not isinstance(actual, dict):
                return False
            for key, value in expected.items():
                if key not in actual:
                    return False
                if not self.assert_logic(actual[key], value):
                    return False
            return True
        elif isinstance(expected, list):
            if not isinstance(actual, list):
                return False
            for item in expected:
                if not any(self.assert_logic(actual_item, item) for actual_item in actual):
                    return False
            return True
        else:
            return actual == expected
    
    def assert_semantic(self, actual_text: str, expected_text: str) -> bool:
        """语义断言:验证两个文本的语义相似度是否达标"""
        actual_emb = self._get_embedding(actual_text)
        expected_emb = self._get_embedding(expected_text)
        similarity = sk_cosine_similarity([actual_emb], [expected_emb])[0][0]
        return similarity >= self.similarity_threshold
    
    def assert_rag_recall(self, actual_chunks: List[str], expected_chunks: List[str], top_k: int = 3) -> bool:
        """RAG召回断言:验证召回的Top N Chunk是否包含预期的内容"""
        for expected in expected_chunks:
            found = False
            for actual in actual_chunks[:top_k]:
                if self.assert_semantic(actual, expected):
                    found = True
                    break
            if not found:
                return False
        return True

4.4 测试覆盖率数学模型

我们定义三维覆盖率模型来量化白盒测试的覆盖程度:

  1. 组件覆盖率 CcC_cCc:衡量单个组件的测试覆盖情况
    Cc=NctestedNctotalC_c = \frac{N_{c}^{tested}}{N_{c}^{total}}Cc=NctotalNctested
    其中 NctestedN_{c}^{tested}Nctested 是已经被测试用例覆盖的组件数量,NctotalN_{c}^{total}Nctotal 是Agent的总组件数量。

  2. 链路覆盖率 CpC_pCp:衡量组件之间交互路径的覆盖情况
    Cp=NptestedNptotalC_p = \frac{N_{p}^{tested}}{N_{p}^{total}}Cp=NptotalNptested
    其中 NptestedN_{p}^{tested}Nptested 是已经被测试用例覆盖的交互路径数量,NptotalN_{p}^{total}Nptotal 是Agent所有可能的交互路径数量,可以通过组件的调用关系图计算得到。

  3. 逻辑覆盖率 ClC_lCl:衡量组件内部决策分支的覆盖情况
    Cl=NbtestedNbtotalC_l = \frac{N_{b}^{tested}}{N_{b}^{total}}Cl=NbtotalNbtested
    其中 NbtestedN_{b}^{tested}Nbtested 是已经被测试用例覆盖的决策分支数量,NbtotalN_{b}^{total}Nbtotal 是组件内部的总决策分支数量。

总加权覆盖率:
Ctotal=αCc+βCp+γClC_{total} = \alpha C_c + \beta C_p + \gamma C_lCtotal=αCc+βCp+γCl
其中 α,β,γ\alpha, \beta, \gammaα,β,γ 是权重系数,满足 α+β+γ=1\alpha + \beta + \gamma = 1α+β+γ=1,可以根据业务场景调整:

  • 工具调用密集型Agent:α=0.2,β=0.5,γ=0.3\alpha=0.2, \beta=0.5, \gamma=0.3α=0.2,β=0.5,γ=0.3
  • RAG问答密集型Agent:α=0.2,β=0.3,γ=0.5\alpha=0.2, \beta=0.3, \gamma=0.5α=0.2,β=0.3,γ=0.5
  • 多轮对话密集型Agent:α=0.25,β=0.45,γ=0.3\alpha=0.25, \beta=0.45, \gamma=0.3α=0.25,β=0.45,γ=0.3

五、落地案例与最佳实践

5.1 落地案例:电商客服Agent

我们团队的电商客服Agent上线初期,线上故障率高达32%,用户投诉率居高不下,每次上线需要测试人员花3天时间跑黑盒用例,还是经常漏测问题。落地这套Harness白盒测试方案之后:

  • 单元测试覆盖率:94%
  • 集成测试覆盖率:91%
  • 线上故障率:2.7%
  • 上线测试时间:从3天缩短到4小时
典型问题定位案例

用户反馈:“我昨天下单的iPhone 15什么时候发货?我之前已经给过订单号了,为什么还要我再提供一次?”
通过Harness的Trace数据我们很快定位到问题:记忆模块的上下文窗口设置的是最近2条消息,但是用户的订单号在第3条消息里,规划模块没有拿到订单号,所以要求用户再次提供。我们把记忆窗口调整为5条之后,问题就解决了,整个排查过程只用了2分钟。

5.2 最佳实践Tips

  1. 探针设计要无侵入:优先用装饰器、AOP、字节码注入的方式埋探针,不要修改Agent的业务逻辑,避免测试代码和业务代码耦合。
  2. 桩模块要覆盖三类场景:正常返回、异常返回(报错、格式错误)、超时返回,模拟所有可能的外部依赖情况。
  3. 断言分层设计:优先做逻辑断言,再做语义断言,不要做字符串完全匹配的断言,避免误报。
  4. 测试用例版本化:每个Agent版本对应一套测试用例,每次迭代跑全量回归,避免新的修改引入旧的bug。
  5. 非核心组件降级测试:比如输出生成模块的自定义语气、表情这些非核心逻辑,可以不用做白盒测试,用黑盒抽样测试就行。
  6. 测试环境和生产环境隔离:Harness的探针只在测试环境开启,生产环境要关闭,避免影响性能和数据安全。
  7. 故障转化为用例:每次线上故障都要转化为对应的白盒测试用例,加到用例库里,避免下次再犯。
  8. 阈值动态调整:语义相似度的阈值、覆盖率的权重都要根据业务场景调整,金融场景的阈值要设到0.9以上,客服场景可以设到0.8。

六、行业发展趋势与边界说明

6.1 行业发展趋势

时间 阶段 核心特点 代表产品 覆盖率能力
2022年及以前 黑盒测试阶段 只能验证最终输出,完全看不到内部逻辑 自定义脚本、PromptFoo <30%
2023年 半白盒测试阶段 可以看到LLM的输入输出,简单的链路追踪 LangSmith、LangFuse 30%-60%
2024年 白盒测试阶段 可以覆盖所有组件的内部状态和交互链路,完整的Harness框架 开源Harness框架、企业级Agent测试平台 60%-95%
2025-2026年 智能测试阶段 自动生成测试用例、自动根因分析、自动修复简单问题 智能测试Agent、内生安全的Agent框架 >95%

6.2 方案边界与适用范围

  1. 适用场景:基于模块化设计搭建的Agent,比如用LangChain、LlamaIndex、自定义组件搭建的Agent,有明确的组件划分和交互逻辑。
  2. 不适用场景:完全端到端的黑盒Agent,比如直接调用GPT-4o的原生Agent能力,没有自定义组件的,无法埋探针,就不适用这套方案。
  3. 和黑盒测试的关系:白盒测试是黑盒测试的补充,不是替代,白盒测试覆盖内部逻辑,黑盒测试覆盖用户体验,两者结合才能做到完整的质量保障。

七、FAQ与总结

7.1 常见问题FAQ

Q1:白盒测试会不会增加很多开发工作量?
A:一开始搭建Harness框架需要1-2周的工作量,但是后续每个版本的测试时间可以减少70%以上,线上故障排查时间减少90%以上,长期来看是大幅提升效率的。

Q2:非确定性输出怎么写断言?
A:不要断言具体的字符串内容,要断言核心逻辑:比如有没有调用正确的工具、参数是否符合要求、RAG召回的Top3 Chunk是否包含预期的内容、输出的语义和预期的相似度是否达标。

Q3:小团队有没有必要搞这么复杂的测试?
A:可以渐进式落地,先给核心组件(比如规划、工具调用)加探针,写单元测试,再逐步扩展到集成测试,不需要一步到位搭完整的Harness平台,甚至可以先用本地的日志采集代替远程的Harness服务。

Q4:白盒测试会影响Agent的性能吗?
A:探针的上报是异步的,而且只在测试环境开启,生产环境关闭,对性能的影响可以忽略不计,我们测试下来,开启探针的情况下,Agent的响应时间只增加了2-3ms,完全可以接受。

7.2 总结

本文我们从AI Agent测试的痛点出发,介绍了基于Harness Engineering的白盒测试方案,从单元测试到集成测试的完整流程,包括Harness的架构设计、核心实现代码、度量模型、落地案例和最佳实践。这套方案已经在我们团队的多个Agent项目中落地,上线故障率从32%降到了2.7%以下,大幅提升了Agent的交付质量和迭代效率。

AI Agent的质量保障是未来2年行业的核心痛点,白盒测试和Harness Engineering是解决这个问题的核心路径,随着行业的发展,会有越来越多的成熟工具和方案出现,我们也会持续在这个领域探索,欢迎大家在评论区交流你的Agent测试经验。


延伸阅读

  1. LangSmith官方文档:https://docs.smith.langchain.com/
  2. PromptFoo官方文档:https://www.promptfoo.dev/
  3. 《Building Agentic RAG Systems》O’Reilly书籍

(全文约11200字)

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐