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 分钟/个)
最终成果
你将得到一个可运行的测试套件,包含:
- 模拟模型响应的单元测试,0.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 公共接口 |
回顾
这一章你完成了三件事:
- 用 Mock Gateway 替代真实模型调用,让 Agent 的单元测试在 0.3 秒内确定性地验证逻辑分支
- 用
hermes-rec录制真实交互并回放,捕获模型在实际场景下的完整工具调用链 - 为每个 Skill 建立回归用例文件,确保版本升级不会破坏已有功能
花了多久:首次搭建约 90 分钟,后续每新增一个 Skill 只需 15 分钟编写回归用例。
现在你的 Agent 在每次自进化后都能自动验证“我还是原来那个我”。但测试失败只是定位问题的起点——当回放测试告诉你“工具调用链与录制不一致”时,你需要深入 Hermes 的日志来判断是模型输出偏移、记忆污染还是工具链异常。下一章《日志解读能力是定位问题的首要技能》将拆解 Hermes Agent 的日志格式,教你从数千行日志中快速锁定根因。
行动清单:
- [ ] 在
tests/conftest.py中实现DeterministicGateway - [ ] 为你最核心的 Skill 编写 3 个单元测试(覆盖正常路径和边界条件)
- [ ] 用
hermes-rec录制一次完整对话,生成回放测试 - [ ] 为每个 Skill 创建
regression_cases.json,至少包含 2 个历史用例 - [ ] 将
pytest加入 CI 流程,确保每次提交都触发测试
Hermes Agent 系统设计与工程落地
关于 LearnKu