引言

LLM的本质是非确定性的——同样的输入可能产生不同的输出。这给Agent系统的测试带来了根本性挑战:如何验证Agent的"正确性"?如何检测"回归"?回放测试(Replay Testing)通过将真实请求重新执行并与历史结果对比,为Agent系统提供了一种实用且有效的验证手段。

2026年,回放测试已成为Agent系统质量保障的核心手段。本文系统介绍如何设计和实施Agent回放测试体系。

回放测试原理

┌──────────────────────────────────────────────────────┐
│                  回放测试流程                          │
│                                                      │
│  生产流量 ──▶ 录制 (Record)                           │
│                    │                                  │
│                    ▼                                  │
│             测试数据集 (Test Set)                       │
│                    │                                  │
│                    ▼                                  │
│  新版本 ──▶ 回放 (Replay) ──▶ 结果对比 ──▶ 报告       │
│                    │                    │             │
│                    ▼                    ▼             │
│             差异分析               质量门禁             │
│                                                      │
└──────────────────────────────────────────────────────┘

录制生产流量

class ProductionRecorder:
    """生产流量录制器"""
    
    def __init__(self, storage_client):
        self.storage = storage_client
        self.sampling_rate = 0.01  # 1%采样
    
    async def record_request(
        self,
        session_id: str,
        request: dict,
        response: dict,
        metadata: dict
    ):
        """录制请求和响应"""
        import random
        if random.random() > self.sampling_rate:
            return
        
        recording = {
            "session_id": session_id,
            "timestamp": datetime.now().isoformat(),
            "request": {
                "input": request["input"],
                "context": request.get("context", {}),
                "config": request.get("config", {}),
            },
            "response": {
                "output": response["output"],
                "tool_calls": response.get("tool_calls", []),
                "tokens_used": response.get("usage", {}),
                "latency_ms": response.get("latency_ms"),
            },
            "metadata": {
                "model": metadata.get("model"),
                "prompt_version": metadata.get("prompt_version"),
                "quality_score": metadata.get("quality_score"),
                "user_feedback": metadata.get("user_feedback"),
            },
            "recording_version": "1.0"
        }
        
        # 存储到对象存储
        key = f"recordings/{datetime.now().strftime('%Y%m%d')}/{session_id}.json"
        await self.storage.put(key, json.dumps(recording, ensure_ascii=False))

回放执行

class ReplayExecutor:
    """回放执行器"""
    
    async def replay_test_set(
        self,
        test_set: list,
        config: dict
    ) -> dict:
        """回放测试集"""
        
        results = []
        
        for i, test_case in enumerate(test_set):
            logger.info(f"Replaying test case {i+1}/{len(test_set)}")
            
            try:
                result = await self._replay_single(test_case, config)
                results.append(result)
            except Exception as e:
                logger.error(f"Replay failed for case {i+1}: {e}")
                results.append({
                    "test_case_id": test_case.get("id"),
                    "status": "error",
                    "error": str(e)
                })
        
        # 汇总分析
        return self._analyze_results(results)
    
    async def _replay_single(
        self,
        test_case: dict,
        config: dict
    ) -> dict:
        """回放单个测试用例"""
        
        # 使用录制时的配置(或覆盖)
        replay_config = {**test_case["request"]["config"], **config}
        
        # 执行请求
        start = time.monotonic()
        response = await self.agent.process(
            test_case["request"]["input"],
            context=test_case["request"]["context"],
            config=replay_config
        )
        latency_ms = (time.monotonic() - start) * 1000
        
        # 对比结果
        comparison = self._compare_responses(
            expected=test_case["response"],
            actual=response
        )
        
        return {
            "test_case_id": test_case.get("id"),
            "status": "pass" if comparison["passed"] else "fail",
            "comparison": comparison,
            "actual_response": response,
            "latency_ms": latency_ms,
        }
    
    def _compare_responses(self, expected: dict, actual: dict) -> dict:
        """对比预期和实际响应"""
        comparison = {
            "passed": True,
            "checks": []
        }
        
        # 检查1:工具调用是否相同
        expected_tools = [t["name"] for t in expected.get("tool_calls", [])]
        actual_tools = [t["name"] for t in actual.get("tool_calls", [])]
        
        tools_match = set(expected_tools) == set(actual_tools)
        comparison["checks"].append({
            "name": "tool_calls_match",
            "passed": tools_match,
            "expected": expected_tools,
            "actual": actual_tools
        })
        if not tools_match:
            comparison["passed"] = False
        
        # 检查2:响应质量是否相似(语义相似度)
        similarity = self._compute_similarity(
            expected["output"],
            actual["output"]
        )
        quality_ok = similarity > 0.85  # 85%相似度阈值
        comparison["checks"].append({
            "name": "response_quality",
            "passed": quality_ok,
            "similarity": similarity,
            "threshold": 0.85
        })
        if not quality_ok:
            comparison["passed"] = False
        
        # 检查3:Token消耗是否在合理范围
        token_ratio = actual.get("usage", {}).get("total_tokens", 0) / \
                      max(expected.get("tokens_used", {}).get("total_tokens", 1), 1)
        token_ok = 0.5 < token_ratio < 2.0  # Token消耗在0.5x-2x之间
        comparison["checks"].append({
            "name": "token_consumption",
            "passed": token_ok,
            "ratio": token_ratio,
            "threshold": "0.5-2.0"
        })
        
        return comparison

快照测试

class SnapshotTesting:
    """快照测试——保存首次运行结果为快照,后续运行对比快照"""
    
    def __init__(self, snapshot_dir: str):
        self.snapshot_dir = snapshot_dir
        os.makedirs(snapshot_dir, exist_ok=True)
    
    async def run_with_snapshot(
        self,
        test_name: str,
        test_fn: callable,
        update_snapshot: bool = False
    ) -> dict:
        """运行快照测试"""
        
        snapshot_file = os.path.join(
            self.snapshot_dir, 
            f"{test_name}.snapshot.json"
        )
        
        # 执行测试
        actual = await test_fn()
        
        if update_snapshot:
            # 更新快照
            with open(snapshot_file, "w") as f:
                json.dump(actual, f, indent=2, ensure_ascii=False)
            return {"status": "snapshot_updated", "result": actual}
        
        # 对比快照
        if not os.path.exists(snapshot_file):
            raise FileNotFoundError(
                f"Snapshot not found: {snapshot_file}. "
                f"Run with update_snapshot=True to create."
            )
        
        with open(snapshot_file) as f:
            expected = json.load(f)
        
        diff = self._deep_diff(expected, actual)
        
        if diff:
            return {
                "status": "fail",
                "diff": diff,
                "expected": expected,
                "actual": actual
            }
        else:
            return {"status": "pass", "result": actual}
    
    def _deep_diff(self, expected, actual, path: str = "") -> list:
        """深度对比,返回差异列表"""
        diffs = []
        
        if isinstance(expected, dict) and isinstance(actual, dict):
            for key in set(list(expected.keys()) + list(actual.keys())):
                new_path = f"{path}.{key}" if path else key
                if key not in actual:
                    diffs.append(f"Missing in actual: {new_path}")
                elif key not in expected:
                    diffs.append(f"Extra in actual: {new_path}")
                else:
                    diffs.extend(
                        self._deep_diff(expected[key], actual[key], new_path)
                    )
        
        elif isinstance(expected, list) and isinstance(actual, list):
            if len(expected) != len(actual):
                diffs.append(
                    f"List length mismatch at {path}: "
                    f"expected {len(expected)}, actual {len(actual)}"
                )
            else:
                for i, (e, a) in enumerate(zip(expected, actual)):
                    diffs.extend(self._deep_diff(e, a, f"{path}[{i}]"))
        
        else:
            # 标量对比(对LLM输出用模糊匹配)
            if self._is_llm_output(path):
                similarity = self._compute_similarity(str(expected), str(actual))
                if similarity < 0.9:
                    diffs.append(
                        f"Semantic difference at {path}: "
                        f"similarity={similarity:.2f}"
                    )
            else:
                if expected != actual:
                    diffs.append(
                        f"Value mismatch at {path}: "
                        f"expected={expected}, actual={actual}"
                    )
        
        return diffs

回归测试框架

class RegressionTestSuite:
    """回归测试套件"""
    
    def __init__(self):
        self.test_cases = []
        self.quality_thresholds = {
            "response_similarity": 0.85,
            "tool_call_accuracy": 0.95,
            "latency_increase_max": 1.2,  # 延迟最多增加20%
            "token_increase_max": 1.3,    # Token最多增加30%
        }
    
    def add_test_case(self, test_case: dict):
        """添加测试用例"""
        self.test_cases.append(test_case)
    
    async def run_regression_test(self, version: str) -> dict:
        """运行回归测试"""
        
        results = {
            "version": version,
            "total_cases": len(self.test_cases),
            "passed": 0,
            "failed": 0,
            "regressions": [],
            "improvements": [],
        }
        
        for test_case in self.test_cases:
            # 获取基线结果
            baseline = await self._get_baseline(test_case["id"])
            
            # 执行测试
            actual = await self._execute_test(test_case)
            
            # 对比
            comparison = self._compare_with_baseline(baseline, actual)
            
            if comparison["is_regression"]:
                results["failed"] += 1
                results["regressions"].append({
                    "test_case": test_case["id"],
                    "regression_type": comparison["regression_type"],
                    "details": comparison["details"]
                })
            elif comparison["is_improvement"]:
                results["improvements"].append({
                    "test_case": test_case["id"],
                    "improvement_type": comparison["improvement_type"],
                })
            else:
                results["passed"] += 1
        
        return results
    
    def _compare_with_baseline(self, baseline: dict, actual: dict) -> dict:
        """与基线对比"""
        issues = []
        
        # 质量回归
        if actual["quality_score"] < baseline["quality_score"] * 0.95:
            issues.append({
                "type": "quality_regression",
                "baseline": baseline["quality_score"],
                "actual": actual["quality_score"],
            })
        
        # 延迟回归
        latency_ratio = actual["latency_ms"] / baseline["latency_ms"]
        if latency_ratio > self.quality_thresholds["latency_increase_max"]:
            issues.append({
                "type": "latency_regression",
                "baseline": baseline["latency_ms"],
                "actual": actual["latency_ms"],
                "ratio": latency_ratio,
            })
        
        # Token回归
        token_ratio = actual["tokens_used"] / baseline["tokens_used"]
        if token_ratio > self.quality_thresholds["token_increase_max"]:
            issues.append({
                "type": "token_regression",
                "baseline": baseline["tokens_used"],
                "actual": actual["tokens_used"],
                "ratio": token_ratio,
            })
        
        return {
            "is_regression": len(issues) > 0,
            "is_improvement": actual["quality_score"] > baseline["quality_score"] * 1.05,
            "issues": issues,
        }

CI/CD集成

# .github/workflows/regression-test.yml
name: Agent Regression Test

on:
  pull_request:
    branches: [main]

jobs:
  regression-test:
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Test Environment
        run: |
          docker-compose -f docker-compose-test.yml up -d
          sleep 30  # 等待服务就绪
      
      - name: Run Regression Tests
        id: regression
        run: |
          python -m pytest tests/regression/ \
            --baseline=baseline.json \
            --output=regression-report.json \
            --junitxml=junit.xml
      
      - name: Check Quality Gate
        run: |
          python scripts/check_quality_gate.py \
            --report=regression-report.json \
            --max-regressions=5 \
            --min-quality-score=0.85
      
      - name: Upload Test Results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: regression-test-results
          path: |
            regression-report.json
            junit.xml
      
      - name: Comment PR
        if: always()
        uses: actions/github-script@v6
        with:
          script: |
            const report = require('./regression-report.json');
            const comment = `
            ## Regression Test Results
            - Total: ${report.total_cases}
            - Passed: ${report.passed}
            - Failed: ${report.failed}
            - Regressions: ${report.regressions.length}
            
            ${report.regressions.length > 0 ? '⚠️ Regressions detected!' : '✅ No regressions'}
            `;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              body: comment
            });

总结

回放测试为Agent系统提供了一种实用的验证手段——通过录制生产流量并在新版本上回放,可以有效检测功能回归和质量下降。快照测试通过保存首次运行结果作为基准,简化了测试用例的创建。回归测试套件则系统性地验证新版本在质量、延迟、Token消耗等维度上是否出现退化。

核心原则:Agent测试的目标不是"完全相同"(因为LLM是非确定性的),而是"合理相似"。建立合理的相似度阈值,比追求完全确定性更有实际价值。

加入讨论

这篇文章有姊妹讨论帖在硅基AGI论坛 — 全球首个碳基硅基认知交流平台。

碳基与硅基的智慧碰撞,认知差异创造无限可能。