8.5. 单元测试与集成测试保障自进化不会引入错误

单元测试与集成测试保障自进化不会引入错误

场景:你的 Hermes Agent 已经运行了两个月,记忆库积累了 3400 条交互记录,技能列表从最初的 12 个增长到 47 个。某天你更新了“日程管理”Skill 的冲突检测逻辑,Agent 却开始在团队频道里重复发送相同的会议提醒——一个让同事血压飙升的回归 Bug。

这不是假设。Agent 系统的自进化能力(记忆更新、工具链动态加载、Prompt 自适应调整)打破了传统软件的“稳定输入→稳定输出”假设。每一次技能更新、每一次记忆压缩、每一次系统 Prompt 的微调,都可能触发连锁反应。你需要一套能在变更发生时就拦截错误的测试策略,而不是等用户来报 Bug。

本章将带你构建三层测试防线:确定性单元测试(锁死模型行为)、回放式集成测试(复现真实交互)和版本回归测试(保障 Skill 兼容性)。读完这一章,你就能让自己的 Agent 在每一次自我进化后,自动验证“我还是原来那个我”。


你需要什么

资源 说明
Hermes Agent 实例 0.14.0 及以上版本(截至 2026 年 6 月)
Python 3.11+ 环境 用于编写测试脚本
pytest ≥ 8.0 测试框架
一个已注册的 Skill 作为被测对象(本章以 weather_report 为例)
录制工具 hermes-rec Hermes 内置的交互录制命令

预计时间:90 分钟(首次搭建 45 分钟,后续添加新测试用例约 15 分钟/个)


最终成果

你将得到一个可运行的测试套件,包含:

  1. 模拟模型响应的单元测试,0.3 秒内验证逻辑分支
  2. 基于真实交互录制的回放测试,复现多轮对话中的工具调用链
  3. 跨版本的 Skill 回归测试,确保升级后的 Skill 仍能通过历史断言

为什么要做这件事?因为 Agent 的“自进化”本质上是一系列自动或半自动的代码/数据变更。没有测试防线,你就等于在高速公路上闭眼换轮胎。


步骤一:模拟模型响应进行确定性测试

Agent 系统的核心不确定性来自 LLM 的输出。要让测试可重复、瞬时完成,第一步就是把模型调用替换为固定脚本。

1.1 理解 Hermes 的模型抽象层

Hermes 通过 ModelGateway 统一调用不同模型家族。测试时,你需要注入一个 MockGateway,它不调用远程 API,而是按预设规则返回固定响应。

# Hermes 的模型调用链路(简化)
Agent.loop()
  → Skill.tool_call()
    → ModelGateway.chat_completion()  # 这里产生不确定性

1.2 编写 Mock Gateway

在项目 tests/ 目录下创建 conftest.py,注册一个可复用的 Mock:

# tests/conftest.py
import pytest
from hermes.gateway.base import ModelGateway
from hermes.messages import AssistantMessage, ToolCall

class DeterministicGateway(ModelGateway):
    """返回预设响应的网关,用于单元测试"""

    def __init__(self, response_map: dict):
        """
        response_map: {"输入关键词": 预定义响应}
        """
        self.response_map = response_map
        self.call_log = []  # 记录所有调用,用于断言

    async def chat_completion(self, messages, tools=None, **kwargs):
        last_user_msg = messages[-1].content if messages else ""
        self.call_log.append(last_user_msg)

        # 根据输入内容匹配预设响应
        for keyword, response in self.response_map.items():
            if keyword in last_user_msg:
                return response

        # 默认回退响应
        return AssistantMessage(
            content="I don't know how to respond to that.",
            tool_calls=[]
        )

@pytest.fixture
def mock_gateway():
    """提供一个可配置的 Mock Gateway 实例"""
    return DeterministicGateway(response_map={})

1.3 测试 Skill 的条件分支

weather_report Skill 为例,它有两条逻辑分支:

  • 用户提供了城市名 → 调用天气 API
  • 用户说“今天”但未指定城市 → 询问用户位置
# tests/test_weather_skill.py
import pytest
from hermes.messages import AssistantMessage, ToolCall
from hermes.skills.weather_report import WeatherReportSkill

@pytest.mark.asyncio
async def test_weather_with_city(mock_gateway):
    """测试:用户明确提供了城市名时,Skill 应调用天气 API"""

    # 配置 Mock 的响应映射
    mock_gateway.response_map = {
        "Beijing": AssistantMessage(
            content="",
            tool_calls=[
                ToolCall(
                    id="call_1",
                    name="fetch_weather",
                    arguments='{"city": "Beijing", "unit": "celsius"}'
                )
            ]
        )
    }

    skill = WeatherReportSkill(model_gateway=mock_gateway)
    result = await skill.execute(user_input="What's the weather in Beijing today?")

    # 断言:工具被正确调用
    assert len(result.tool_calls) == 1
    assert result.tool_calls[0].name == "fetch_weather"
    assert "Beijing" in result.tool_calls[0].arguments

@pytest.mark.asyncio  
async def test_weather_without_city(mock_gateway):
    """测试:用户未提供城市名时,Skill 应询问位置"""

    mock_gateway.response_map = {
        "weather today": AssistantMessage(
            content="I need to know your city before I can check the weather.",
            tool_calls=[]
        )
    }

    skill = WeatherReportSkill(model_gateway=mock_gateway)
    result = await skill.execute(user_input="What's the weather today?")

    # 断言:没有工具调用,但有追问内容
    assert len(result.tool_calls) == 0
    assert "city" in result.content.lower()

预期结果:两个测试在 0.3 秒内通过,无需任何网络调用。

注意:Mock Gateway 的 response_map 键是关键词匹配,生产环境中建议使用更精确的语义匹配或哈希映射。一个常见踩坑是:LLM 对你的同一个 Prompt 可能返回语义等价但措辞不同的响应,导致关键词匹配失败。解决方案是在录制阶段使用 Hermes 的 --capture-raw 参数保存完整响应体,而不是手动编写 Mock 数据。


步骤二:基于录制的回放测试

单元测试验证了“如果模型这么回复,逻辑是否正确”。但你还不知道:模型在真实场景下到底会怎么回复。录制回放测试填补了这个空白。

2.1 录制真实交互

使用 Hermes 内置的 hermes-rec 命令录制一次完整的交互会话:

# 启动录制模式
hermes-rec start --session test_session_001

# 在另一个终端中与 Agent 交互
hermes chat send "What's the weather in Tokyo?"
hermes chat send "And what about tomorrow?"

# 停止录制
hermes-rec stop --output ./tests/fixtures/session_001.json

录制文件的结构(简化版):

{
  "session_id": "test_session_001",
  "turns": [
    {
      "user_input": "What's the weather in Tokyo?",
      "agent_output": {
        "content": "",
        "tool_calls": [
          {
            "id": "call_1",
            "name": "fetch_weather",
            "arguments": "{\"city\": \"Tokyo\"}"
          }
        ]
      }
    },
    {
      "user_input": "And what about tomorrow?",
      "agent_output": {
        "content": "Tomorrow's forecast for Tokyo is partly cloudy, 22°C.",
        "tool_calls": []
      }
    }
  ],
  "skills_used": ["weather_report", "memory_compress"]
}

2.2 编写回放测试

# tests/test_session_playback.py
import json
import pytest
from hermes.core.agent import Agent
from hermes.replay import ReplayGateway

@pytest.fixture
def recorded_session():
    """加载录制的会话数据"""
    with open("./tests/fixtures/session_001.json") as f:
        return json.load(f)

@pytest.mark.asyncio
async def test_weather_conversation_playback(recorded_session):
    """
    回放测试:使用录制数据中的模型响应作为 Mock,
    验证 Agent 的工具调用链和状态转换是否与录制时一致。
    """

    # 从录制数据构建 ReplayGateway
    replay_gateway = ReplayGateway.from_session(recorded_session)

    agent = Agent(gateway=replay_gateway)

    for turn in recorded_session["turns"]:
        response = await agent.process(turn["user_input"])

        # 断言:工具调用列表与录制一致
        expected_calls = turn["agent_output"]["tool_calls"]
        actual_calls = [tc.dict() for tc in response.tool_calls]

        assert actual_calls == expected_calls, (
            f"工具调用不匹配!\n"
            f"期望: {expected_calls}\n"
            f"实际: {actual_calls}"
        )

        # 断言:技能列表一致
        assert set(response.skills_invoked) == set(
            recorded_session.get("skills_used", [])
        )

预期结果:测试精确复现录制时的行为。如果运行失败,说明自进化过程(如记忆压缩改变了上下文提取逻辑)引入了回归。

2.3 处理“合理偏差”

并非所有不一致都是 Bug。假设上次录制时模型回复“22°C”,这次变成“21°C”——这可能是天气 API 数据变化,而非 Agent 逻辑错误。

解决办法:在回放配置中标记语义等价断言

# tests/conftest.py 中的配置
REPLAY_TOLERANCE = {
    "temperature_float_delta": 1.0,    # 温度允许 ±1°C 偏差
    "ignore_fields": ["response_id"],   # 忽略每次生成的随机 ID
    "fuzzy_match_text": True            # 对自然语言文本用模糊匹配
}

步骤三:测试 Skill 的版本兼容性

Skill 是 Hermes 自进化的最小单元。当一个 Skill 从 v1.2 升级到 v1.3 时,你必须确认旧版本能处理的输入,新版本仍然能正确处理——这就是回归测试集的意义。

3.1 建立 Skill 回归测试集

每个 Skill 都应有一个 regression_cases.json 文件,存放在 Skill 目录下:

// skills/weather_report/regression_cases.json
{
  "skill_version": "1.3.0",
  "cases": [
    {
      "id": "WR-001",
      "description": "单城市查询,使用摄氏度",
      "input": "What's the weather in London?",
      "expected_tool": "fetch_weather",
      "expected_args_contains": ["London", "celsius"]
    },
    {
      "id": "WR-002",
      "description": "未指定城市时应询问",
      "input": "Is it going to rain?",
      "expected_tool": null,
      "expected_response_contains": ["city", "location"]
    },
    {
      "id": "WR-003",
      "description": "处理城市名的大小写变体",
      "input": "Weather in new york city",
      "expected_tool": "fetch_weather",
      "expected_args_contains": ["New York City"]
    }
  ]
}

3.2 编写版本兼容性测试

# tests/test_skill_regression.py
import json
import pytest
from pathlib import Path

SKILLS_DIR = Path("skills")

def load_regression_cases(skill_name: str):
    """加载指定 Skill 的回归测试用例"""
    case_file = SKILLS_DIR / skill_name / "regression_cases.json"
    with open(case_file) as f:
        data = json.load(f)
    return data["cases"]

def all_skill_regression_cases():
    """自动发现所有 Skill 的回归用例,生成参数化测试"""
    params = []
    for skill_dir in SKILLS_DIR.iterdir():
        if not skill_dir.is_dir():
            continue
        cases = load_regression_cases(skill_dir.name)
        for case in cases:
            params.append(pytest.param(
                skill_dir.name, case,
                id=f"{skill_dir.name}-{case['id']}"
            ))
    return params

@pytest.mark.asyncio
@pytest.mark.parametrize("skill_name,case", all_skill_regression_cases())
async def test_skill_regression(skill_name, case, mock_gateway):
    """
    回归测试:对每个 Skill 的每个历史用例,
    验证当前版本的行为是否符合预期。
    """
    # 动态加载 Skill 的当前版本
    skill_cls = load_skill_class(skill_name)
    skill = skill_cls(model_gateway=mock_gateway)

    # 配置 Mock(基于用例预期)
    if case["expected_tool"]:
        mock_gateway.response_map = {
            case["input"]: AssistantMessage(
                content="",
                tool_calls=[ToolCall(
                    id="reg_test",
                    name=case["expected_tool"],
                    arguments=",".join(case["expected_args_contains"])
                )]
            )
        }
    else:
        mock_gateway.response_map = {
            case["input"]: AssistantMessage(
                content=" ".join(case.get("expected_response_contains", [])),
                tool_calls=[]
            )
        }

    result = await skill.execute(user_input=case["input"])

    # 断言逻辑与用例定义一致
    if case["expected_tool"]:
        assert len(result.tool_calls) > 0, f"期望工具调用 {case['expected_tool']},实际无"
        assert result.tool_calls[0].name == case["expected_tool"]
        for expected_arg in case["expected_args_contains"]:
            assert expected_arg in result.tool_calls[0].arguments
    else:
        assert len(result.tool_calls) == 0
        for expected_text in case["expected_response_contains"]:
            assert expected_text in result.content.lower()

预期结果:当你升级 weather_report 到 v1.4 后运行 pytest tests/test_skill_regression.py,WR-001 到 WR-003 全部通过,说明新版本没有破坏已有功能。

踩坑记录:在 Hermes 0.13.x 版本中,load_skill_class() 的路径解析依赖 sys.path 顺序,导致某些环境下加载到旧缓存版本的 Skill。解决方案:在测试文件顶部显式调用 importlib.invalidate_caches(),并在 conftest.py 中设置环境变量 HERMES_SKILL_RELOAD=true


构建完整的测试套件

将上述三层测试组合成一个可一键运行的测试套件:

# 运行所有测试
pytest tests/ -v --tb=short

# 仅运行单元测试(不依赖录制文件,速度最快)
pytest tests/ -m unit -v

# 运行回归测试并生成覆盖率报告
pytest tests/test_skill_regression.py --cov=skills --cov-report=html

.github/workflows/test.yml 或你的 CI 配置中加入:

# CI 中的测试步骤(示例)
test:
  script:
    - pip install -r requirements-dev.txt
    - pytest tests/ -v --junitxml=test-results.xml
    - hermes test verify --threshold 95  # Hermes 内置的测试覆盖率检查

关键指标

测试层 运行时间 覆盖目标
单元测试(Mock) < 5 秒 每条逻辑分支
回放测试 < 30 秒 80% 常见交互场景
回归测试 < 2 分钟 100% Skill 公共接口

回顾

这一章你完成了三件事:

  1. 用 Mock Gateway 替代真实模型调用,让 Agent 的单元测试在 0.3 秒内确定性地验证逻辑分支
  2. hermes-rec 录制真实交互并回放,捕获模型在实际场景下的完整工具调用链
  3. 为每个 Skill 建立回归用例文件,确保版本升级不会破坏已有功能

花了多久:首次搭建约 90 分钟,后续每新增一个 Skill 只需 15 分钟编写回归用例。

现在你的 Agent 在每次自进化后都能自动验证“我还是原来那个我”。但测试失败只是定位问题的起点——当回放测试告诉你“工具调用链与录制不一致”时,你需要深入 Hermes 的日志来判断是模型输出偏移、记忆污染还是工具链异常。下一章《日志解读能力是定位问题的首要技能》将拆解 Hermes Agent 的日志格式,教你从数千行日志中快速锁定根因。

行动清单

  • [ ] 在 tests/conftest.py 中实现 DeterministicGateway
  • [ ] 为你最核心的 Skill 编写 3 个单元测试(覆盖正常路径和边界条件)
  • [ ] 用 hermes-rec 录制一次完整对话,生成回放测试
  • [ ] 为每个 Skill 创建 regression_cases.json,至少包含 2 个历史用例
  • [ ] 将 pytest 加入 CI 流程,确保每次提交都触发测试

本文章首发在 LearnKu.com 网站上。

上一篇 下一篇
讨论数量: 0
发起讨论 只看当前版本


暂无话题~