深入 ‘Unit Testing for LLMs’:如何为非确定性的 Agent 编写有效的断言与评估脚本?

尊敬的各位同仁,下午好!

今天,我们齐聚一堂,共同探讨一个当前AI领域的热点且极具挑战性的话题:如何为非确定性的LLM Agent编写有效的单元测试与评估脚本。随着大型语言模型(LLMs)能力的飞速发展,它们已经不再仅仅是简单的文本生成器,而是演变为能够感知、推理、规划并执行复杂任务的智能代理(Agents)。这些Agent能够与环境交互,调用外部工具,甚至拥有记忆和学习能力。然而,这种强大的能力也带来了一个棘手的问题:它们固有的非确定性,使得传统的软件测试方法显得力不从心。

作为一名编程专家,我深知测试在软件开发生命周期中的核心地位。它不仅是质量的保证,更是迭代和创新的基石。但面对LLM Agent,我们必须承认,以往的“输入X,输出Y”的精确匹配断言已经不再适用。我们不能期望一个智能Agent在每次运行时都给出完全相同的响应,即使输入完全一致。这并非缺陷,而是其类人智能的体现。

那么,如何在这种充满“不确定性”的环境中,依然能够建立起一套严谨、高效且可信赖的测试体系呢?这正是我们今天讲座的核心目标。我们将深入剖析非确定性的来源,批判性地审视传统测试方法的局限,并重点介绍一系列专为LLM Agent设计的、拥抱非确定性的断言策略与评估脚本编写实践。


I. LLM Agent 测试的范式转变:为何传统方法失效?

在传统软件工程中,单元测试的核心原则是确定性 (Determinism)。给定相同的输入,一个函数或模块应该总是产生相同的输出。我们编写断言 assert_equals(func(input), expected_output),以精确匹配来验证功能正确性。这种方法对于大多数确定性算法、数据结构或业务逻辑非常有效。

然而,LLM Agent的出现彻底打破了这一范式。它们引入了非确定性 (Non-Determinism),这使得传统测试方法面临巨大挑战:

  1. 模型的随机性: LLM内部的采样机制(如温度、top-p、top-k参数)导致即使在相同的输入下,模型也可能生成不同的文本。
  2. 上下文依赖: Agent的决策和响应往往取决于其历史对话、内部状态(记忆)和外部环境信息。这意味着相同的用户查询,在不同的会话阶段或外部条件下,会产生不同的合理响应。
  3. 工具调用与外部世界: Agent可能需要调用外部API或工具,这些工具本身可能是非确定性的(如实时数据查询),或其可用性和响应速度会影响Agent的最终行为。
  4. 意图理解的模糊性: 用户输入本身就可能存在多义性、模糊性或不完整性,Agent需要进行解释和推断,这其中包含主观判断。

因此,对LLM Agent进行测试,我们不能再奢望100%精确的输出匹配。我们需要一种新的思维模式:从验证精确值转向验证属性和行为的正确性。我们不再问“它是否输出了X”,而是问“它是否表现出Y的特性”、“它是否实现了Z的目标”、“它的输出是否满足W的约束”。


II. 理解 LLM Agent 的非确定性来源

为了更好地设计测试策略,我们首先要深入理解LLM Agent非确定性的具体来源。

A. LLM 模型本身的非确定性

  • 采样策略 (Sampling Strategies):
    • 温度 (Temperature): 控制生成文本的随机性。高温度会使输出更具创造性和多样性,但也更不可预测;低温度则使输出更集中和确定。
    • Top-P (Nucleus Sampling): 从概率累积和达到P的最小词汇集中采样。
    • Top-K Sampling: 只从概率最高的K个词汇中采样。
    • 随机种子 (Random Seed): 许多LLM库和API允许设置随机种子,但这通常只能在一定程度上提高可复现性,并不能完全消除非确定性,尤其是在分布式或多线程环境中。

B. 外部工具与 API 的非确定性

  • 实时数据: Agent可能调用天气API、股票行情API等,这些数据是实时变化的,导致Agent的响应也随之变化。
  • 服务可用性与延迟: 外部服务的网络延迟、故障或不同的响应时间可能影响Agent的决策流程。
  • 非幂等操作: 如果Agent调用了会改变外部状态的工具(如创建订单、发送邮件),每次调用都会产生不同的副作用。

C. 用户输入与上下文的非确定性

  • 意图模糊与多义性: 用户可能使用不明确的语言,Agent需要根据上下文进行推断,不同的推断可能导致不同的合理响应。
  • 对话历史与内部记忆: Agent的决策受到此前对话轮次、内部存储的记忆或知识库的影响。相同的查询,在不同的对话历史背景下,可能导致完全不同的行为。
  • 环境变化: Agent可能根据其所处的环境(如时间、地点、用户偏好等)调整其行为。

D. Agent 内部逻辑的非确定性

  • 决策路径: 复杂的Agent可能包含多条决策路径,在面对边缘情况或模糊输入时,不同的路径选择都可能是合理的。
  • RAG (Retrieval Augmented Generation) 系统的检索结果: 如果Agent依赖检索增强生成,检索到的文档可能因索引更新、查询变化或检索算法的随机性而有所不同,进而影响生成内容。

理解这些来源是至关重要的,因为它指导我们如何设计更具鲁棒性的测试。我们不能试图“消除”非确定性,而应该“管理”和“拥抱”它。


III. 传统测试方法的局限性与误区

在深入探讨新策略之前,我们有必要明确传统测试方法在LLM Agent测试中的具体局限性。

A. 精确匹配断言的失效

  • 问题: assert_equals(agent.run(input), expected_exact_string) 几乎总是会失败。即使是微小的语法差异、词汇选择或语气的变化,都会导致断言失败,但这并不意味着Agent的功能是错误的。
  • 误区: 试图通过设置 temperature=0 来强制Agent输出确定性结果。虽然这在一定程度上可以减少随机性,但:
    1. 并不能完全消除所有非确定性来源(如外部工具、RAG检索)。
    2. 它剥夺了LLM的创造性和灵活性,使其行为与真实世界应用中的表现不符,测试结果缺乏代表性。

B. 单一输入-单一输出测试的不足

  • 问题: 传统的单元测试往往侧重于给定一个输入,验证一个输出。但LLM Agent的行为通常是一个多步骤的序列,涉及工具调用、状态更新和多轮对话。仅仅验证最终输出,可能会忽略中间步骤中的错误或次优决策。
  • 误区: 仅关注Agent的最终响应,而忽视了其决策过程。例如,一个Agent可能最终给出了一个看似正确的答案,但却是通过错误的工具调用或不合理的推理路径得出的。

C. 覆盖率指标的重新思考

  • 问题: 传统的代码覆盖率(如行覆盖率、分支覆盖率)在LLM Agent中变得不那么有意义。Agent的核心逻辑往往内嵌在LLM的黑箱中,我们无法直接测量“模型代码”的覆盖率。
  • 误区: 盲目追求高代码覆盖率,而忽略了对Agent行为和效果的覆盖。我们更应该关注的是“行为覆盖率”或“用例覆盖率”,即Agent在各种典型和边缘场景下是否表现出期望的行为。

IV. 核心策略:拥抱非确定性,构建弹性断言

既然传统方法不适用,我们就需要一套全新的策略来构建弹性 (Resilient) 断言,这些断言能够容忍合理的变动,同时又能捕捉到真正的错误。

我们将聚焦于以下四种核心策略:基于属性的断言、基于行为的断言、基于范围/模糊匹配的断言,以及引入评估指标作为断言。

A. 基于属性的断言 (Property-Based Assertions)

概念: 这种断言不关心Agent输出的精确文本,而是验证输出是否满足一系列预定义的属性 (Properties)约束 (Constraints)。只要输出符合这些属性,无论具体措辞如何,都被认为是成功的。

常见属性类型:

  1. 结构性属性:

    • 格式: 输出是否为有效的JSON、XML、Markdown等特定格式。
    • 字段存在性: JSON对象中是否包含特定键,并且其值类型正确。
    • 嵌套结构: 复杂的结构是否按预期组织。
    • 示例: Agent生成了一个旅行计划,我们期望它是一个JSON对象,包含 destination, dates, activities 等字段。
  2. 内容性属性:

    • 关键词/短语存在性: 输出中是否包含必要的关键词或短语,或不包含禁用词。
    • 主题相关性: 输出内容是否与输入主题高度相关。
    • 情感倾向: 输出是积极、消极还是中立(例如,客服Agent的回复不应带有负面情绪)。
    • 信息完整性: 是否包含了所有关键信息点。
    • 事实准确性: 输出的事实性信息是否与已知事实一致(这通常需要RAG或外部知识库验证)。
    • 示例: 用户询问“Python最新版本”,Agent回复中应包含“Python”和“版本号”等信息,且版本号应是当前最新的。
  3. 约束性属性:

    • 长度限制: 输出文本是否在特定长度范围内。
    • 数值范围: 如果输出包含数字,它们是否在合理范围内。
    • 语法正确性: 输出的SQL、代码片段等是否符合语法规范。
    • 安全性: 输出是否包含敏感信息泄露、SQL注入风险、不当言论等。

代码示例:使用 Pydantic 进行结构验证

假设我们的Agent被设计用于生成结构化的数据,例如,一个电影推荐Agent。

from pydantic import BaseModel, Field, ValidationError
from typing import List, Optional

# 定义预期的输出结构
class MovieRecommendation(BaseModel):
    title: str = Field(..., description="电影名称")
    genre: List[str] = Field(..., description="电影类型,可以是多个")
    year: int = Field(..., ge=1900, le=2100, description="上映年份")
    rating: float = Field(..., ge=0.0, le=10.0, description="IMDb评分")
    plot_summary: str = Field(..., min_length=50, description="电影剧情简介")
    director: Optional[str] = Field(None, description="导演姓名")

# 模拟一个LLM Agent的输出
def mock_llm_agent_response_json_str(prompt: str) -> str:
    if "喜剧电影" in prompt:
        return """
        {
            "title": "The Grand Budapest Hotel",
            "genre": ["Comedy", "Adventure", "Drama"],
            "year": 2014,
            "rating": 8.1,
            "plot_summary": "The adventures of Gustave H, a legendary concierge at a famous hotel from the interwar period, and Zero Moustafa, the lobby boy who becomes his most trusted friend.",
            "director": "Wes Anderson"
        }
        """
    elif "科幻" in prompt:
        return """
        {
            "title": "Dune",
            "genre": ["Sci-Fi", "Adventure", "Drama"],
            "year": 2021,
            "rating": 8.0,
            "plot_summary": "A noble family becomes embroiled in a war for control over the desert planet Arrakis.",
            "director": "Denis Villeneuve"
        }
        """
    elif "错误格式" in prompt:
        return """
        {
            "movie_name": "Inception",
            "release_year": "2010"
        }
        """
    else:
        return """
        {
            "title": "Parasite",
            "genre": ["Thriller", "Drama"],
            "year": 2019,
            "rating": 8.5,
            "plot_summary": "Greed and class discrimination threaten the newly formed symbiotic relationship between the wealthy Park family and the destitute Kim clan.",
            "director": "Bong Joon-ho"
        }
        """

import json

def test_movie_recommendation_structure():
    prompts = [
        "推荐一部喜剧电影。",
        "我想看一部科幻大片。",
        "随便推荐一部电影。"
    ]

    for prompt in prompts:
        print(f"n--- 测试 Prompt: {prompt} ---")
        raw_output_str = mock_llm_agent_response_json_str(prompt)
        print(f"原始输出: {raw_output_str}")

        try:
            # 尝试解析为JSON
            output_data = json.loads(raw_output_str)
            # 使用Pydantic模型进行验证
            recommendation = MovieRecommendation(**output_data)
            print("Pydantic 验证成功!")
            print(f"验证后的数据: {recommendation.model_dump_json(indent=2)}")
            # 进一步断言具体属性值(可选,但推荐结合使用)
            assert recommendation.title is not None
            assert len(recommendation.genre) > 0
            assert 1900 <= recommendation.year <= 2100
            assert 0.0 <= recommendation.rating <= 10.0
            assert len(recommendation.plot_summary) >= 50
            print("基本属性值断言通过。")

        except json.JSONDecodeError as e:
            print(f"错误: LLM输出不是有效的JSON格式。{e}")
            assert False, "LLM输出应为有效JSON"
        except ValidationError as e:
            print(f"错误: LLM输出结构不符合预期模型。{e}")
            assert False, "LLM输出结构不符合MovieRecommendation模型"
        except AssertionError as e:
            print(f"错误: 特定属性值不符合预期。{e}")
            assert False, "特定属性值断言失败"

    # 测试一个预期会失败的案例
    print("n--- 测试错误格式 Prompt: 错误格式 ---")
    raw_output_str_error = mock_llm_agent_response_json_str("错误格式")
    print(f"原始输出: {raw_output_str_error}")
    try:
        output_data_error = json.loads(raw_output_str_error)
        MovieRecommendation(**output_data_error)
        assert False, "预期此格式应验证失败,但却成功了!"
    except (json.JSONDecodeError, ValidationError) as e:
        print(f"成功捕获预期错误: {type(e).__name__} - {e}")
        assert True # 预期失败,所以捕获到异常即为成功

# 运行测试
test_movie_recommendation_structure()

自定义内容验证函数

import re

def assert_contains_keywords(text: str, keywords: List[str], all_required: bool = True):
    """
    断言文本是否包含所有或任一关键词。
    """
    found_keywords = [kw for kw in keywords if kw.lower() in text.lower()]
    if all_required:
        assert len(found_keywords) == len(keywords), 
            f"文本未包含所有必需关键词。缺失: {set(keywords) - set(found_keywords)}"
    else:
        assert len(found_keywords) > 0, 
            f"文本未包含任何必需关键词。预期包含任一: {keywords}"
    print(f"关键词断言通过。包含: {found_keywords}")

def assert_no_forbidden_words(text: str, forbidden_words: List[str]):
    """
    断言文本不包含任何禁用词。
    """
    found_forbidden = [fw for fw in forbidden_words if fw.lower() in text.lower()]
    assert len(found_forbidden) == 0, 
        f"文本包含禁用词: {found_forbidden}"
    print("禁用词断言通过。")

def assert_sentiment_positive(text: str):
    """
    模拟一个情感分析,断言文本情感为正面。
    在实际应用中,这里会集成一个真正的情感分析模型。
    """
    # 极简模拟:检查正面词汇
    positive_indicators = ["很棒", "推荐", "喜欢", "满意", "好评", "出色"]
    if any(indicator in text for indicator in positive_indicators):
        print("情感断言通过:文本倾向正面。")
        return True
    else:
        # 更复杂的逻辑会调用实际的NLP模型
        print("情感断言失败:文本不倾向正面。")
        return False

def test_content_properties():
    # 模拟Agent回复
    response_positive = "感谢您的反馈,很高兴您对我们的服务感到满意,我们会继续努力提供更出色的体验。"
    response_negative = "我很抱歉,您的请求似乎没有被正确处理,这让人非常不满意。"
    response_neutral = "今天的会议安排在下午三点,地点是会议室A。"
    response_with_forbidden = "请注意,此处严禁讨论政治话题。"

    # 测试正面回复
    print("n--- 测试正面回复 ---")
    assert_contains_keywords(response_positive, ["反馈", "满意", "服务"], all_required=True)
    assert_no_forbidden_words(response_positive, ["抱歉", "失败", "严禁"])
    assert assert_sentiment_positive(response_positive)

    # 测试负面回复(预期情感断言失败)
    print("n--- 测试负面回复 ---")
    assert_contains_keywords(response_negative, ["抱歉", "不满意"], all_required=True)
    assert_no_forbidden_words(response_negative, ["出色", "喜欢"])
    # 预期失败的断言,需要特殊处理,或者设计为返回布尔值
    assert not assert_sentiment_positive(response_negative), "预期负面情感,但却被识别为正面"

    # 测试包含禁用词
    print("n--- 测试包含禁用词 ---")
    try:
        assert_no_forbidden_words(response_with_forbidden, ["政治"])
        assert False, "预期包含禁用词的断言失败,但却通过了"
    except AssertionError as e:
        print(f"成功捕获预期错误: {e}")

# 运行测试
test_content_properties()

B. 基于行为的断言 (Behavior-Based Assertions)

概念: 对于Agent而言,其“行为”不仅仅是最终的文本输出,更包括其内部的决策过程、工具调用序列、对外部状态的改变以及内部记忆的更新。基于行为的断言关注Agent是否在特定条件下执行了预期的动作 (Actions)决策 (Decisions)

关键方面:

  1. 工具调用 (Tool Calls):

    • 是否调用了正确的工具? (例如,查询天气时调用天气API,而不是日历API)
    • 工具调用的参数是否正确? (例如,查询北京天气时,城市参数是否为“北京”)
    • 工具调用序列是否合理? (例如,先检索信息,再总结,最后回答)
    • 是否避免了不必要的工具调用?
  2. 决策路径 (Decision Paths):

    • 意图识别: 在多意图或模糊输入下,Agent是否正确识别了用户意图。
    • 分支选择: 在有条件逻辑(如根据用户偏好推荐不同内容)时,Agent是否选择了正确的逻辑分支。
    • 拒绝与澄清: 当遇到无法处理、需要更多信息或违反策略的请求时,Agent是否能正确拒绝或寻求澄清。
  3. 状态变更 (State Changes):

    • 内部记忆: Agent是否正确更新了其内部的对话历史、用户偏好或任务状态。
    • 外部系统状态: 如果Agent执行了如创建任务、更新数据库等操作,这些外部系统是否按预期发生了变化。

代码示例:模拟工具与断言 tool_calls

为了测试Agent的工具调用行为,我们需要模拟LLM的响应和外部工具,以便完全控制环境并确保可复现性。

from unittest.mock import MagicMock, patch
from typing import List, Dict, Any, Callable

# 模拟一个简单的LLM Agent框架
class MyLLMAgent:
    def __init__(self, llm_model: Callable, tools: Dict[str, Callable]):
        self.llm_model = llm_model
        self.tools = tools

    def run(self, user_query: str, chat_history: List[str] = None) -> Dict[str, Any]:
        # 简化Agent逻辑:假设LLM直接输出工具调用指令或最终回复
        # 实际Agent会有一个推理循环,这里直接模拟LLM输出
        context = {"user_query": user_query, "chat_history": chat_history or []}
        llm_output = self.llm_model(context) # LLM根据上下文决定行为

        if "tool_call" in llm_output:
            tool_name = llm_output["tool_call"]["name"]
            tool_args = llm_output["tool_call"]["args"]
            if tool_name in self.tools:
                print(f"Agent 调用工具: {tool_name} with args: {tool_args}")
                tool_result = self.tools[tool_name](**tool_args)
                return {"action": "tool_call", "tool_name": tool_name, "tool_args": tool_args, "tool_result": tool_result}
            else:
                return {"action": "error", "message": f"未知工具: {tool_name}"}
        else:
            print(f"Agent 直接回复: {llm_output['response']}")
            return {"action": "respond", "response": llm_output["response"]}

# 模拟外部工具
def get_current_weather(city: str, unit: str = "celsius") -> str:
    print(f"[Tool] Querying weather for {city} in {unit}...")
    if city.lower() == "beijing":
        return f"Beijing: 25°{unit.upper()}, Sunny"
    elif city.lower() == "new york":
        return f"New York: 70°{unit.upper()}, Cloudy"
    else:
        return f"Weather data not available for {city}"

def send_email(recipient: str, subject: str, body: str) -> str:
    print(f"[Tool] Sending email to {recipient} with subject '{subject}'...")
    return f"Email sent to {recipient}"

# 注册工具
available_tools = {
    "get_current_weather": get_current_weather,
    "send_email": send_email
}

# 测试 Agent 的工具调用行为
def test_agent_tool_calling():
    # 使用patch装饰器模拟LLM的输出
    # 第一次模拟:LLM决定调用天气工具
    with patch.object(MyLLMAgent, 'llm_model', return_value={
        "tool_call": {
            "name": "get_current_weather",
            "args": {"city": "beijing", "unit": "celsius"}
        }
    }) as mock_llm_weather:
        print("n--- 测试天气查询 ---")
        agent = MyLLMAgent(mock_llm_weather, available_tools)
        result = agent.run("北京天气怎么样?")

        # 断言LLM是否被调用,以及参数是否正确
        mock_llm_weather.assert_called_once()
        # 断言Agent的输出是否是工具调用类型
        assert result["action"] == "tool_call"
        assert result["tool_name"] == "get_current_weather"
        assert result["tool_args"] == {"city": "beijing", "unit": "celsius"}
        assert "Sunny" in result["tool_result"]
        print("天气查询工具调用测试通过。")

    # 第二次模拟:LLM决定调用发送邮件工具
    with patch.object(MyLLMAgent, 'llm_model', return_value={
        "tool_call": {
            "name": "send_email",
            "args": {"recipient": "[email protected]", "subject": "Meeting", "body": "Please join the meeting."}
        }
    }) as mock_llm_email:
        print("n--- 测试发送邮件 ---")
        agent = MyLLMAgent(mock_llm_email, available_tools)
        result = agent.run("给[email protected]发邮件,主题是会议,内容是请加入会议。")

        mock_llm_email.assert_called_once()
        assert result["action"] == "tool_call"
        assert result["tool_name"] == "send_email"
        assert result["tool_args"]["recipient"] == "[email protected]"
        assert "Email sent" in result["tool_result"]
        print("发送邮件工具调用测试通过。")

    # 第三次模拟:LLM决定直接回复
    with patch.object(MyLLMAgent, 'llm_model', return_value={
        "response": "我无法执行这个操作,因为我没有发送邮件的权限。"
    }) as mock_llm_respond:
        print("n--- 测试直接回复 ---")
        agent = MyLLMAgent(mock_llm_respond, available_tools)
        result = agent.run("请给我发送一封邮件,但我没有提供收件人。") # 模拟LLM识别到缺少参数并拒绝

        mock_llm_respond.assert_called_once()
        assert result["action"] == "respond"
        assert "无法执行" in result["response"]
        print("直接回复测试通过。")

    # 模拟Agent内部状态变更(例如记忆更新)
    class MyAgentWithMemory(MyLLMAgent):
        def __init__(self, llm_model: Callable, tools: Dict[str, Callable]):
            super().__init__(llm_model, tools)
            self.memory = {}

        def run(self, user_query: str, chat_history: List[str] = None) -> Dict[str, Any]:
            # 模拟LLM不仅决定行为,还决定记忆更新
            llm_output = self.llm_model({"user_query": user_query, "chat_history": chat_history or [], "current_memory": self.memory})

            if "update_memory" in llm_output:
                self.memory.update(llm_output["update_memory"])
                print(f"Agent 更新记忆: {llm_output['update_memory']}")

            if "tool_call" in llm_output:
                # ... (同上)
                tool_name = llm_output["tool_call"]["name"]
                tool_args = llm_output["tool_call"]["args"]
                if tool_name in self.tools:
                    tool_result = self.tools[tool_name](**tool_args)
                    return {"action": "tool_call", "tool_name": tool_name, "tool_args": tool_args, "tool_result": tool_result, "memory": self.memory}
                else:
                    return {"action": "error", "message": f"未知工具: {tool_name}", "memory": self.memory}
            else:
                return {"action": "respond", "response": llm_output["response"], "memory": self.memory}

    with patch.object(MyAgentWithMemory, 'llm_model', return_value={
        "update_memory": {"user_name": "Alice", "preferred_city": "London"},
        "response": "好的,Alice,我已经记住了您的偏好。"
    }) as mock_llm_memory:
        print("n--- 测试记忆更新 ---")
        agent_with_memory = MyAgentWithMemory(mock_llm_memory, available_tools)
        result = agent_with_memory.run("我的名字是Alice,我喜欢伦敦。")

        mock_llm_memory.assert_called_once()
        assert result["action"] == "respond"
        assert "Alice" in result["response"]
        assert agent_with_memory.memory == {"user_name": "Alice", "preferred_city": "London"}
        print("记忆更新测试通过。")

test_agent_tool_calling()

C. 基于范围/模糊匹配的断言 (Range/Fuzzy Matching Assertions)

概念: 这种断言策略允许输出在一定程度上偏离精确的预期值,只要它在可接受的“模糊范围”内。这对于那些具有多种表达方式但语义相同或数值允许小幅波动的输出特别有用。

技术手段:

  1. 语义相似度:

    • 使用嵌入模型(如Sentence-BERT、OpenAI embeddings)将文本转换为向量。
    • 计算预期输出和实际输出向量之间的余弦相似度。
    • 设定一个相似度阈值,高于该阈值则认为匹配成功。
    • 适用场景: 回答问题、总结文本、生成创意内容。
  2. 关键词/短语集合匹配:

    • 定义一组可接受的关键词或短语集合。
    • 只要Agent的输出包含了这个集合中的足够多的元素(例如,达到某个百分比),就认为是成功的。
    • 适用场景: 信息提取、多答案选择题。
  3. 正则表达式 (Regular Expressions):

    • 匹配输出文本的模式而非固定字符串。
    • 适用场景: 提取特定格式的数据(如日期、电话号码、URL)、验证结构化文本的局部模式。

代码示例:语义相似度与正则表达式

from sentence_transformers import SentenceTransformer, util
import re

# 加载一个预训练的Sentence-BERT模型
# 第一次运行可能需要下载模型,可以替换为本地路径或更轻量级模型
model = SentenceTransformer('all-MiniLM-L6-v2')

def assert_semantic_similarity(actual_text: str, expected_text: str, threshold: float = 0.7):
    """
    断言两个文本的语义相似度高于给定阈值。
    """
    embeddings1 = model.encode(actual_text, convert_to_tensor=True)
    embeddings2 = model.encode(expected_text, convert_to_tensor=True)
    cosine_similarity = util.cos_sim(embeddings1, embeddings2).item()
    print(f"文本1: '{actual_text}'")
    print(f"文本2: '{expected_text}'")
    print(f"语义相似度: {cosine_similarity:.4f} (阈值: {threshold:.4f})")
    assert cosine_similarity >= threshold, 
        f"语义相似度 {cosine_similarity:.4f} 低于阈值 {threshold:.4f}"
    print("语义相似度断言通过。")
    return cosine_similarity

def assert_regex_match(text: str, pattern: str):
    """
    断言文本匹配给定的正则表达式。
    """
    match = re.search(pattern, text)
    assert match is not None, f"文本 '{text}' 未匹配正则表达式 '{pattern}'"
    print(f"正则表达式断言通过。匹配内容: {match.group(0)}")
    return match

def test_fuzzy_assertions():
    # 模拟Agent的回复
    agent_response1 = "Python是一种非常流行的编程语言,广泛应用于机器学习和Web开发。"
    agent_response2 = "Python是数据科学和网络编程领域的主力语言。"
    agent_response3 = "Java是一种面向对象的语言。"
    agent_response4 = "会议时间是2023年10月26日下午3点。"
    agent_response5 = "我的电话号码是138-0000-1234。"

    # --- 语义相似度测试 ---
    print("n--- 语义相似度测试 ---")
    expected_answer = "Python是一种多用途的编程语言,在AI和Web领域应用广泛。"
    assert_semantic_similarity(agent_response1, expected_answer, threshold=0.75) # 预期通过
    assert_semantic_similarity(agent_response2, expected_answer, threshold=0.75) # 预期通过

    try:
        assert_semantic_similarity(agent_response3, expected_answer, threshold=0.75) # 预期失败
        assert False, "预期Java与Python语义不相似,但却通过了"
    except AssertionError as e:
        print(f"成功捕获预期错误: {e}")

    # --- 正则表达式测试 ---
    print("n--- 正则表达式测试 ---")
    # 匹配日期时间
    date_pattern = r"d{4}年d{1,2}月d{1,2}日(上午|下午|晚上)?d{1,2}点"
    assert_regex_match(agent_response4, date_pattern)

    # 匹配电话号码
    phone_pattern = r"1[3-9]d{9}|(?:[0-9]{3}-){2}[0-9]{4}"
    assert_regex_match(agent_response5, phone_pattern)

    # 预期不匹配的正则表达式
    try:
        assert_regex_match(agent_response1, r"Java")
        assert False, "预期Python文本不匹配Java模式,但却通过了"
    except AssertionError as e:
        print(f"成功捕获预期错误: {e}")

test_fuzzy_assertions()

D. 引入评估指标 (Evaluation Metrics) 作为断言

概念: 将更高级的、量化的评估指标 (Evaluation Metrics) 转化为断言阈值。这特别适用于评估Agent的整体质量,例如在信息检索、内容生成或对话系统中的表现。

常见指标:

  1. RAG评估指标:

    • 上下文相关性 (Context Relevance): 检索到的信息是否与用户查询相关。
    • 忠实性 (Faithfulness): 生成的答案是否完全基于检索到的上下文,没有幻觉。
    • 答案相关性 (Answer Relevance): 生成的答案是否直接回答了用户查询。
    • 答案准确性 (Answer Accuracy): 生成的答案是否正确。
    • 示例: 使用RAGAS这样的框架,断言 faithfulness_score > 0.8
  2. 任务完成率: Agent是否成功完成给定任务的百分比。

  3. 特定领域指标:

    • 代码生成: 可执行性、正确性。
    • 摘要: 抽象度、信息覆盖率。
    • 翻译: BLEU、ROUGE分数。
  4. 用户满意度(通过代理指标): 例如,用户是否需要多次澄清、Agent的回复是否被采纳等。

代码示例:集成 RAGAS 评估

RAGAS是一个专门用于评估RAG系统质量的框架。我们可以将其评估结果作为断言。

# 假设您已安装 ragas: pip install ragas
from ragas.metrics import faithfulness, answer_relevance, context_recall
from ragas import evaluate
import pandas as pd
from datasets import Dataset

# 模拟一个RAG Agent的输出
class MockRAGAgent:
    def __init__(self, knowledge_base: Dict[str, str]):
        self.knowledge_base = knowledge_base

    def retrieve_and_generate(self, question: str) -> Dict[str, Any]:
        # 模拟检索:根据关键词匹配
        retrieved_context = []
        for doc_id, content in self.knowledge_base.items():
            if any(keyword.lower() in content.lower() for keyword in question.split()):
                retrieved_context.append(content)

        # 模拟生成:简单总结或直接引用
        if "Python" in question and retrieved_context:
            answer = f"根据检索到的信息,{question} 相关的答案是:Python是一种流行的编程语言,具有广泛的应用,如AI和Web开发。它以其简洁的语法和强大的库生态系统而闻名。"
        elif "Java" in question and retrieved_context:
             answer = f"根据检索到的信息,{question} 相关的答案是:Java是一种面向对象的编程语言,广泛应用于企业级应用开发。它以其跨平台特性和健壮性而著称。"
        else:
            answer = "我无法找到相关信息来回答这个问题。"

        return {
            "question": question,
            "answer": answer,
            "contexts": retrieved_context,
            "ground_truth": "Python是一种高级编程语言,以其简洁明了的语法和丰富的库而受到欢迎。广泛用于Web开发、数据科学、人工智能和自动化。", # 假设有真实答案
        }

# 模拟知识库
mock_kb = {
    "doc1": "Python是一种高级编程语言,以其简洁明了的语法和丰富的库而受到欢迎。广泛用于Web开发、数据科学、人工智能和自动化。",
    "doc2": "Java是一种面向对象的编程语言,广泛应用于企业级应用开发。它以其跨平台特性和健壮性而著称。",
    "doc3": "C++是一种通用编程语言,支持多编程范式,常用于系统编程、游戏开发和高性能计算。"
}

def test_rag_agent_quality():
    agent = MockRAGAgent(mock_kb)

    # 定义测试用例
    test_cases = [
        {"question": "Python有什么特点和用途?", "ground_truth": "Python是一种高级编程语言,以其简洁明了的语法和丰富的库而受到欢迎。广泛用于Web开发、数据科学、人工智能和自动化。"},
        {"question": "Java主要用于哪些领域?", "ground_truth": "Java是一种面向对象的编程语言,广泛应用于企业级应用开发。它以其跨平台特性和健壮性而著称。"},
        {"question": "C++的应用场景是什么?", "ground_truth": "C++是一种通用编程语言,支持多编程范式,常用于系统编程、游戏开发和高性能计算。"}
    ]

    # 收集Agent的输出
    agent_outputs = []
    for case in test_cases:
        output = agent.retrieve_and_generate(case["question"])
        output["ground_truth"] = case["ground_truth"] # 添加ground_truth用于评估
        agent_outputs.append(output)

    # 将数据转换为Dataset格式
    data_df = pd.DataFrame(agent_outputs)
    dataset = Dataset.from_pandas(data_df)

    # 定义评估指标
    metrics = [faithfulness, answer_relevance, context_recall]

    print("n--- RAG Agent 质量评估 ---")
    # 执行评估
    results = evaluate(
        dataset,
        metrics=metrics,
        llm=None, # RAGAS 默认使用 OpenAI LLM 进行评估,这里可以替换为自定义或模拟
        embeddings=None # RAGAS 默认使用 OpenAI embeddings
    )

    print("n评估结果:")
    print(results)
    # print(results.to_pandas()) # 可以查看每个样本的详细分数

    # 将评估结果转化为断言
    # 注意:这些阈值需要根据实际项目和期望的质量水平进行调整
    # RAGAS 默认使用 LLM 来进行评估,这本身也引入了一定非确定性。
    # 在单元测试中,我们可能需要更确定的评估方式,或对 RAGAS 的 LLM 部分进行模拟。
    # 这里我们假设 RAGAS LLM/embedding 已经被配置为确定性模式或我们接受其内部非确定性。

    assert results["faithfulness"] > 0.8, f"忠实性分数过低: {results['faithfulness']}"
    assert results["answer_relevance"] > 0.7, f"答案相关性分数过低: {results['answer_relevance']}"
    assert results["context_recall"] > 0.9, f"上下文召回率分数过低: {results['context_recall']}"

    print("nRAG Agent 质量断言通过!")

# 运行测试 (请确保ragas库已安装)
# test_rag_agent_quality()

注意: RAGAS这类框架在内部也可能依赖LLM进行评估,这又引入了一层非确定性。在严格的单元测试环境中,我们可能需要对RAGAS内部的LLM调用进行模拟,或者在集成测试中使用。在上述示例中,为了演示概念,我们假设其评估结果是相对稳定的。


V. 构建有效的测试框架与实践

除了上述断言策略,还需要一套健全的测试框架和实践来支持LLM Agent的测试。

A. 测试用例的设计

  • 多样化的输入:
    • 典型用例 (Happy Path): 覆盖Agent的预期核心功能。
    • 边缘情况 (Edge Cases): 空输入、超长输入、特殊字符、数字边界、时间边界。
    • 对抗性样本 (Adversarial Examples): 模糊意图、误导性信息、注入攻击(Prompt Injection)。
    • 多种表达方式: 对于相同意图,使用不同的措辞、句式、语言风格。
    • 不完整/含糊输入: 测试Agent的澄清能力。
  • 预设上下文: 模拟不同的对话历史、用户偏好、系统状态,以测试Agent在不同上下文下的行为。
  • 预期行为描述: 为每个测试用例清晰定义期望的属性和行为,而非精确输出。这可以是一个结构化的JSON,描述预期的工具调用、参数、关键词、情感等。

B. 隔离与模拟 (Isolation and Mocking)

隔离是单元测试的黄金法则。对于LLM Agent,这意味着:

  1. 模拟 LLM API:

    • 使用 unittest.mockpytest-mock 来替换对真实LLM API(如OpenAI API)的调用。
    • 模拟的LLM可以返回预定义的响应,或者根据输入动态生成响应。这确保了测试的可复现性,并避免了API调用的成本和延迟。
    • 示例: mock_openai_chat_completion.return_value = {"choices": [{"message": {"content": "Expected response."}}]}
  2. 模拟外部工具:

    • 将Agent调用的所有外部工具(数据库、第三方API、文件系统等)替换为模拟对象。
    • 模拟工具可以返回预定义的结果,抛出特定错误,或记录被调用的情况。
    • 这有助于测试Agent在不同工具响应(成功、失败、慢响应)下的行为。

代码示例:使用 pytest-mock 进行更高级的模拟

import pytest
from unittest.mock import MagicMock
from typing import Dict, Any

# 假设Agent的LLM调用通过一个名为 'llm_client' 的模块进行
# 例如: from my_agent_lib import llm_client
# mock_llm_client.chat.completions.create(...)

# 模拟一个LLM客户端
class MockLLMClient:
    def chat(self):
        return self
    def completions(self):
        return self
    def create(self, **kwargs):
        # 模拟LLM的响应结构
        prompt = kwargs.get('messages', [])[-1]['content']
        if "天气" in prompt:
            return MagicMock(
                choices=[MagicMock(
                    message=MagicMock(content="{"tool_call": {"name": "get_current_weather", "args": {"city": "beijing"}}}")
                )]
            )
        elif "邮件" in prompt:
             return MagicMock(
                choices=[MagicMock(
                    message=MagicMock(content="{"tool_call": {"name": "send_email", "args": {"recipient": "[email protected]", "subject": "Hello", "body": "Test"}}}")
                )]
            )
        else:
            return MagicMock(
                choices=[MagicMock(
                    message=MagicMock(content="{"response": "我是一个通用回复。"}")
                )]
            )

# 假设我们的Agent在运行时会导入并使用这个llm_client
# 为了演示,我们直接在Agent中传入模拟的llm_client
class MyAgentWithMocks:
    def __init__(self, llm_client: Any, tools: Dict[str, Callable]):
        self.llm_client = llm_client
        self.tools = tools

    def run(self, user_query: str) -> Dict[str, Any]:
        messages = [{"role": "user", "content": user_query}]
        # 模拟调用LLM API
        llm_response = self.llm_client.chat().completions().create(messages=messages, temperature=0.7)
        content = llm_response.choices[0].message.content

        # 尝试解析LLM的输出,看是否是工具调用
        try:
            parsed_content = json.loads(content)
            if "tool_call" in parsed_content:
                tool_name = parsed_content["tool_call"]["name"]
                tool_args = parsed_content["tool_call"]["args"]
                if tool_name in self.tools:
                    print(f"Agent 调用工具: {tool_name} with args: {tool_args}")
                    tool_result = self.tools[tool_name](**tool_args)
                    return {"action": "tool_call", "tool_name": tool_name, "tool_args": tool_args, "tool_result": tool_result}
                else:
                    return {"action": "error", "message": f"未知工具: {tool_name}"}
            else:
                return {"action": "respond", "response": parsed_content.get("response", content)}
        except json.JSONDecodeError:
            return {"action": "respond", "response": content}

# 外部工具(真实或模拟)
def mock_get_weather(city: str) -> str:
    return f"Weather in {city}: Sunny"

def mock_send_email(recipient: str, subject: str, body: str) -> str:
    return f"Email to {recipient} sent."

@pytest.fixture
def mock_tools():
    return {
        "get_current_weather": mock_get_weather,
        "send_email": mock_send_email
    }

def test_agent_with_mocked_llm(mocker, mock_tools):
    # 注入模拟的LLM客户端
    mock_llm_client_instance = MockLLMClient()
    agent = MyAgentWithMocks(mock_llm_client_instance, mock_tools)

    # 测试天气查询
    print("n--- Mocked LLM: 测试天气查询 ---")
    result = agent.run("北京天气怎么样?")
    assert result["action"] == "tool_call"
    assert result["tool_name"] == "get_current_weather"
    assert result["tool_args"]["city"] == "beijing"
    assert "Sunny" in result["tool_result"]
    print("天气查询测试通过。")

    # 测试发送邮件
    print("n--- Mocked LLM: 测试发送邮件 ---")
    result = agent.run("给[email protected]发邮件。")
    assert result["action"] == "tool_call"
    assert result["tool_name"] == "send_email"
    assert result["tool_args"]["recipient"] == "[email protected]"
    assert "sent" in result["tool_result"]
    print("发送邮件测试通过。")

    # 测试普通回复
    print("n--- Mocked LLM: 测试普通回复 ---")
    result = agent.run("你好。")
    assert result["action"] == "respond"
    assert "通用回复" in result["response"]
    print("普通回复测试通过。")

# 要运行这个测试,您需要安装 pytest 和 pytest-mock:
# pip install pytest pytest-mock
# 然后在命令行运行: pytest your_test_file.py

C. 可复现性与种子管理

尽管我们拥抱非确定性,但在测试环境中,尽可能地提高可复现性仍然非常重要,以便于调试和回归测试。

  • LLM 模型种子: 如果LLM API允许,设置 seed 参数。同时将 temperature 设置为较低的值(例如0或0.1),以减少生成文本的随机性。
  • 随机库种子: 对于Python自身的随机模块,使用 random.seed()
  • 外部随机性: 如果Agent依赖外部实时数据,在测试时应使用模拟数据或固定的快照数据。
import random
import numpy as np
import os

def set_deterministic_env(seed: int = 42):
    """
    尝试设置环境以提高可复现性。
    注意:这不能保证100%的可复现性,尤其是在分布式系统或底层模型中。
    """
    random.seed(seed)
    np.random.seed(seed)
    # Python hash seed
    os.environ['PYTHONHASHSEED'] = str(seed)
    # PyTorch/TensorFlow等深度学习框架也需要设置
    try:
        import torch
        torch.manual_seed(seed)
        torch.cuda.manual_seed(seed)
        torch.backends.cudnn.deterministic = True
        torch.backends.cudnn.benchmark = False
    except ImportError:
        pass
    try:
        import tensorflow as tf
        tf.random.set_seed(seed)
    except ImportError:
        pass

    # 对于LLM API,需要在API调用时传递 seed 参数
    # 例如:openai.ChatCompletion.create(..., seed=seed, temperature=0)
    print(f"环境随机种子已设置为: {seed}")

# 在测试开始前调用
# set_deterministic_env(123)

D. 持续集成与监控

将LLM Agent测试集成到CI/CD流程中至关重要。

  • 自动化运行: 每次代码提交后自动运行测试。
  • 结果趋势分析: 由于非确定性,单个测试失败可能不意味着严重问题。我们更应该关注测试失败的趋势、某些评估指标的下降。例如,如果语义相似度分数从平均0.9骤降到0.6,则可能存在问题。
  • 阈值警报: 设置评估指标的阈值,当低于阈值时触发警报。
  • A/B测试与灰度发布: 对于LLM Agent,A/B测试和灰度发布是验证其在真实世界中表现的有效手段。

E. 人类在环 (Human-in-the-Loop) 评估

对于LLM Agent,自动化测试虽然强大,但仍有其局限性,尤其是在评估主观质量(如流畅度、创造力、情感共鸣)时。

  • 自动化作为筛选: 自动化测试可以快速捕捉结构性错误、行为偏差和低质量输出。
  • 人类作为最终把关: 对于通过自动化测试的样本,特别是关键功能或新功能,引入人类专家进行少量、高质量的评估。
  • 结合流程:
    1. 大规模自动化测试运行。
    2. 根据自动化测试结果(如低分样本、失败样本)筛选出需要人工复核的样本。
    3. 人类标注员对这些样本进行详细评估和反馈。
    4. 将人工反馈整合回测试用例或Agent优化流程。

VI. 实践案例:一个多步骤 Agent 的测试

让我们以一个更复杂的Agent为例:一个旅行规划Agent。用户可以告诉它目的地、日期、偏好(如预算、活动类型),Agent会规划行程,包括查找航班/酒店、推荐活动,并最终生成一个结构化的旅行计划。

这个Agent的行为是高度非确定性的,因为航班和酒店价格是实时变化的,活动推荐可能有很多合理选项。

Agent核心功能点:

  1. 意图识别: 识别用户是想规划旅行、查询信息还是修改计划。
  2. 信息提取: 从用户输入中提取目的地、日期、预算、偏好等关键实体。
  3. 工具调用:
    • search_flights(origin, destination, dates)
    • search_hotels(destination, dates, budget)
    • recommend_activities(destination, preferences)
  4. 规划逻辑: 根据用户偏好和工具结果,合理安排行程。
  5. 最终输出: 生成一个结构化、可读的旅行计划。

测试分解与断言策略:

测试点 输入示例 期望行为/属性 断言策略
意图识别鲁棒性 "帮我规划去日本的行程"
"东京有什么好玩的"
"修改一下我上次去巴黎的计划"
正确识别为“规划旅行”
“查询活动”
“修改计划”
行为断言: assert agent.get_intent(input) == "plan_trip"
模糊匹配: 语义相似度匹配意图类别
实体提取准确性 "下个月去伦敦玩一周,预算5000英镑,想看博物馆" destination='London', dates='next_month', duration='1 week', budget='5000 GBP', preferences='museums' 属性断言 (Pydantic): 提取结果符合预期结构和类型,数值在合理范围。
工具选择与参数提取 "去东京的机票多少钱?" 调用 search_flights,参数 destination='Tokyo' 行为断言 (Mocking): mock_llm_client.assert_called_with_tool("search_flights", {"destination": "Tokyo"})
规划逻辑合理性 "去巴黎玩5天,想住便宜点的酒店,去埃菲尔铁塔" 1. 调用 search_flightssearch_hotels
2. 酒店搜索参数包含 budget='economy'
3. 推荐活动包含“埃菲尔铁塔”
行为断言: 检查工具调用序列与参数。
属性断言: 最终计划包含所有关键信息且逻辑连贯。
语义相似度: 计划内容与用户偏好高度相关。
最终输出格式 "请给我一个详细的东京旅行计划。" 输出为结构化JSON(包含日期、活动、酒店、航班建议等字段) 属性断言 (Pydantic): assert TravelPlanSchema.model_validate_json(agent_output)
内容相关性与忠实性 (基于检索到的航班/酒店/活动信息) 旅行计划中的信息必须与检索结果一致,不虚构。 评估指标 (RAGAS): assert faithfulness_score > 0.8
关键词断言: 计划中包含检索到的航班号、酒店名称等。
拒绝不当请求 "请帮我预订一个去月球的航班。" 拒绝请求,并说明原因。 属性断言: assert "无法预订" in agent_outputassert agent.action == "reject_request"
多轮对话能力 "我想去纽约" -> "什么时候去?" -> "下个月" Agent能够理解上下文,并逐步收集信息。 行为断言: 检查Agent是否在正确时机请求缺失信息,并成功填充槽位。

示例代码片段 (使用 Pytest 和 Mocks):

import pytest
from unittest.mock import MagicMock
import json
from pydantic import BaseModel, Field
from typing import List, Dict, Any, Optional
from sentence_transformers import SentenceTransformer, util

# --- 1. 定义Agent的预期输出结构 ---
class FlightInfo(BaseModel):
    airline: str
    flight_number: str
    departure_time: str
    arrival_time: str
    price: str

class HotelInfo(BaseModel):
    name: str
    address: str
    price_per_night: str
    rating: float

class ActivityInfo(BaseModel):
    name: str
    description: str
    location: str

class DailyPlan(BaseModel):
    date: str
    activities: List[ActivityInfo]
    notes: Optional[str] = None

class TravelPlan(BaseModel):
    destination: str
    dates: str
    total_budget: Optional[str] = None
    flights: Optional[FlightInfo] = None
    hotel: Optional[HotelInfo] = None
    daily_plan: List[DailyPlan]

# --- 2. 模拟LLM客户端和外部工具 ---
# 模拟LLMClient,使其根据输入返回预设的工具调用或响应
class MockLLMClient:
    def chat(self): return self
    def completions(self): return self
    def create(self, messages, **kwargs):
        prompt = messages[-1]['content']
        if "日本" in prompt and "规划" in prompt:
            return MagicMock(
                choices=[MagicMock(
                    message=MagicMock(content=json.dumps({"intent": "plan_trip", "destination": "Japan", "duration": "week"}))
                )]
            )
        elif "东京" in prompt and "机票" in prompt:
             return MagicMock(
                choices=[MagicMock(
                    message=MagicMock(content=json.dumps({"tool_call": {"name": "search_flights", "args": {"destination": "Tokyo", "dates": "next week"}}}))
                )]
            )
        elif "月球" in prompt:
            return MagicMock(
                choices=[MagicMock(
                    message=MagicMock(content=json.dumps({"action": "reject_request", "reason": "无法预订前往月球的航班。"}))
                )]
            )
        elif "东京旅行计划" in prompt:
            # 模拟生成一个符合TravelPlan结构的JSON
            return MagicMock(
                choices=[MagicMock(
                    message=MagicMock(content=json.dumps({
                        "destination": "Tokyo",
                        "dates": "2024年7月1日-7月7日",
                        "total_budget": "5000 USD",
                        "flights": {"airline": "JAL", "flight_number": "JL123", "departure_time": "08:00", "arrival_time": "16:00", "price": "800 USD"},
                        "hotel": {"name": "Tokyo Hilton", "address": "Shinjuku", "price_per_night": "200 USD", "rating": 8.5},
                        "daily_plan": [
                            {"date": "2024-07-01", "activities": [{"name": "Check-in Hotel", "description": "入住酒店并休息", "location": "Shinjuku"}]},
                            {"date": "2024-07-02", "activities": [{"name": "浅草寺", "description": "参观古老的寺庙", "location": "Asakusa"}, {"name": "东京晴空塔", "description": "登塔俯瞰城市", "location": "Sumida"}]}
                        ]
                    }))
                )]
            )
        else:
            return MagicMock(
                choices=[MagicMock(
                    message=MagicMock(content=json.dumps({"response": "我没有理解您的请求,请问您想做什么?"}))
                )]
            )

def mock_search_flights(destination: str, dates: str) -> Dict[str, Any]:
    print(f"[Tool] Searching flights for {destination} on {dates}")
    if destination == "Tokyo":
        return {"status": "success", "flights": [{"airline": "JAL", "price": "800 USD"}]}
    return {"status": "fail", "message": "No flights found"}

def mock_search_hotels(destination: str, dates: str, budget: str) -> Dict[str, Any]:
    print(f"[Tool] Searching hotels for {destination} on {dates} with budget {budget}")
    if destination == "Tokyo" and budget == "economy":
        return {"status": "success", "hotels": [{"name": "Capsule Hotel", "price": "50 USD/night"}]}
    return {"status": "fail", "message": "No hotels found"}

def mock_recommend_activities(destination: str, preferences: str) -> Dict[str, Any]:
    print(f"[Tool] Recommending activities for {destination} with preferences {preferences}")
    if destination == "Tokyo" and "museums" in preferences:
        return {"status": "success", "activities": ["Tokyo National Museum", "Ghibli Museum"]}
    return {"status": "fail", "message": "No activities found"}

# --- 3. Agent 实现 (简化版) ---
class TravelAgent:
    def __init__(self, llm_client: Any, tools: Dict[str, Callable]):
        self.llm_client = llm_client
        self.tools = tools
        self.conversation_history = []
        self.slots = {} # 用于多轮对话

    def _call_llm(self, messages: List[Dict[str, str]]) -> Dict[str, Any]:
        response = self.llm_client.chat().completions().create(messages=messages, temperature=0.0) # 强制低温度
        return json.loads(response.choices[0].message.content)

    def run(self, user_query: str) -> Dict[str, Any]:
        self.conversation_history.append({"role": "user", "content": user_query})

        # 模拟LLM的思考过程:意图识别 -> 信息提取/工具调用 -> 规划/生成
        llm_decision = self._call_llm(self.conversation_history)

        if "intent" in llm_decision:
            if llm_decision["intent"] == "plan_trip":
                self.slots.update(llm_decision)
                if all(k in self.slots for k in ["destination", "duration"]): # 简化判断
                    # 模拟工具调用和规划逻辑
                    flights = self.tools["search_flights"](self.slots["destination"], "next week")
                    hotels = self.tools["search_hotels"](self.slots["destination"], "next week", "economy")
                    activities = self.tools["recommend_activities"](self.slots["destination"], "general")

                    # 模拟生成最终旅行计划
                    plan_messages = self.conversation_history + [{"role": "system", "content": f"基于这些信息:航班{flights}, 酒店{hotels}, 活动{activities},生成一个东京旅行计划。"}]
                    final_plan_json = self._call_llm(plan_messages) # 再次调用LLM生成结构化计划
                    return {"action": "plan_generated", "plan": final_plan_json}
                else:
                    return {"action": "ask_for_more_info", "message": "请告诉我目的地和大致时长。"}
            # 其他意图省略
        elif "tool_call" in llm_decision:
            tool_name = llm_decision["tool_call"]["name"]
            tool_args = llm_decision["tool_call"]["args"]
            if tool_name in self.tools:
                tool_result = self.tools[tool_name](**tool_args)
                return {"action": "tool_executed", "tool_name": tool_name, "tool_result": tool_result}
            else:
                return {"action": "error", "message": f"未知工具: {tool_name}"}
        elif "action" in llm_decision and llm_decision["action"] == "reject_request":
            return llm_decision
        else:
            return {"action": "respond", "response": llm_decision.get("response", "未知错误")}

# --- 4. Pytest 测试用例 ---
@pytest.fixture
def travel_agent(mocker):
    # 注入模拟的LLM客户端和工具
    mock_llm_client_instance = MockLLMClient()
    mock_tools = {
        "search_flights": mock_search_flights,
        "search_hotels": mock_search_hotels,
        "recommend_activities": mock_recommend_activities,
    }
    return TravelAgent(mock_llm_client_instance, mock_tools)

# Sentence-BERT模型用于语义相似度
sbert_model = SentenceTransformer('all-MiniLM-L6-v2')

def assert_semantic_similarity(actual_text: str, expected_text: str, threshold: float = 0.7):
    embeddings1 = sbert_model.encode(actual_text, convert_to_tensor=True)
    embeddings2 = sbert_model.encode(expected_text, convert_to_tensor=True)
    cosine_similarity = util.cos_sim(embeddings1, embeddings2).item()
    assert cosine_similarity >= threshold, f"语义相似度 {cosine_similarity:.4f} 低于阈值 {threshold:.4f}"

# 测试意图识别
def test_intent_recognition(travel_agent):
    print("n--- 测试意图识别 ---")
    result = travel_agent.run("帮我规划去日本的行程。")
    assert result["action"] == "ask_for_more_info" # 模拟Agent在初期需要更多信息
    assert "目的地" in result["message"] and "时长" in result["message"]
    print("意图识别(需补全信息)测试通过。")

# 测试工具调用
def test_tool_calling(travel_agent):
    print("n--- 测试工具调用 ---")
    result = travel_agent.run("东京的机票多少钱?")
    assert result["action"] == "tool_executed"
    assert result["tool_name"] == "search_flights"
    assert "Tokyo" in result["tool_result"]["flights"][0]["airline"] or "JAL" in result["tool_result"]["flights"][0]["airline"] # 模糊匹配航班信息
    print("工具调用测试通过。")

# 测试旅行计划生成与结构验证
def test_travel_plan_generation_and_structure(travel_agent):
    print("n--- 测试旅行计划生成与结构验证 ---")
    # 模拟一个完整的规划流程,确保最终生成的计划符合Pydantic模型
    travel_agent.slots = {"destination": "Tokyo", "duration": "7 days"} # 预填充槽位以跳过信息收集
    result = travel_agent.run("请给我一个详细的东京旅行计划。")

    assert result["action"] == "plan_generated"
    plan_data = result["plan"]

    try:
        validated_plan = TravelPlan(**plan_data)
        print("旅行计划结构验证成功!")
        # 进一步检查内容属性
        assert validated_plan.destination == "Tokyo"
        assert len(validated_plan.daily_plan) > 0
        assert "JAL" in validated_plan.flights.airline # 特定值断言
        assert_semantic_similarity(validated_plan.daily_plan[0].activities[0].name, "入住酒店", threshold=0.7)
        print("旅行计划内容断言通过。")
    except ValidationError as e:
        pytest.fail(f"旅行计划结构不符合预期: {e}")
    except AssertionError as e:
        pytest.fail(f"旅行计划内容断言失败: {e}")

# 测试拒绝不当请求
def test_reject_invalid_request(travel_agent):
    print("n--- 测试拒绝不当请求 ---")
    result = travel_agent.run("请帮我预订一个去月球的航班。")
    assert result["action"] == "reject_request"
    assert "月球" in result["reason"]
    print("拒绝不当请求测试通过。")

# 要运行这些测试,您需要安装 pytest: pip install pytest
# 然后在命令行运行: pytest your_test_file.py

VII. 展望未来,持续演进

为非确定性LLM Agent编写有效的断言与评估脚本,是一项持续演进的工作。我们必须认识到,LLM Agent的本质决定了其行为的复杂性和多样性。传统的确定性测试思维已经不再适用,我们需要采纳并融合多种策略:基于属性的验证确保输出的格式和关键内容正确;基于行为的断言确保Agent的决策路径和工具调用符合预期;基于模糊匹配的断言则允许合理的语义变动;而引入评估指标则提供了从宏观层面衡量Agent质量的手段。

在实践中,这通常意味着构建一个多层次的测试金字塔:底层是大量基于模拟和属性的单元测试,确保Agent核心组件的健壮性;中层是针对复杂场景和多轮对话的集成测试,验证Agent端到端的行为;顶层则是结合人工评估和A/B测试的系统级评估,确保Agent在真实用户场景下的表现和用户满意度。

未来的方向将包括更智能的测试用例生成(例如,使用LLM自身生成对抗性测试用例)、更精细的评估指标、以及将人类反馈更紧密地集成到自动化测试循环中,形成一个高效的反馈闭环。最终,我们的目标是构建一个弹性、可信赖的测试体系,它能够与LLM Agent的智能一同成长,为AI应用的可靠性和高质量保驾护航。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注