回歸測試與失敗分析:確保修改不退步

從基準線建立、差異檢測、失敗分類到根因分析,用 Python 實作完整的回歸與失敗分析系統。

回歸測試與失敗分析:確保修改不退步

上一篇我們談了 LLM-as-Judge 與 Jev,理解了如何用模型評估模型。

但有了評分能力之後,下一個問題是:你怎麼知道這次修改比上次好?

當你改了 Prompt、換了模型、調整了工具,你怎麼知道它是變好還是變差?
當測試失敗時,你怎麼知道是哪裡出了問題?是模型?是 Prompt?是工具?還是測試案例本身?

這一篇,我們要談 Harness 的兩個關鍵能力:回歸測試(Regression Testing)失敗分析(Failure Analysis)
我們會從基準線的建立、差異檢測、失敗分類,一路談到根因分析,並用 Python 實作一個完整的回歸與失敗分析系統。

Baseline → Compare → Detect → Analyze

一、什麼是回歸測試?

定義

**回歸測試(Regression Testing)**是指:在修改系統之後,重新執行測試,確保既有的功能沒有被破壞。

在傳統軟體中,回歸測試很常見:你改了一個函式,跑一次完整的測試套件,確保沒有把其他功能弄壞。

在 Agent 系統中,回歸測試同樣重要,但更複雜:

  • 輸出不是固定的,無法用 assert 精確比對。
  • 修改 Prompt 可能影響多個案例,有些變好、有些變差。
  • 換模型可能全面改變行為,需要重新建立基準。

為什麼需要回歸測試?

假設你優化了 research_topic Skill 的 Prompt,讓它更擅長找出關鍵趨勢。
你跑了幾個測試案例,感覺好像變好了,於是發布了新版本。

三天後,使用者回報:

  • 「為什麼研究報告的引用來源變少了?」
  • 「為什麼有些主題的研究結果變成空的?」

你回頭一看,才發現:

  • 新 Prompt 讓 Agent 更專注在「趨勢」,但忽略了「來源」
  • 某些邊界案例(如冷門主題)在新 Prompt 下完全失敗

如果沒有回歸測試,你不會知道這些退步。
回歸測試的目的,就是在發布前發現這些問題。

回歸測試的核心問題

回歸測試要回答三個問題:

  1. 哪些案例退步了?(差異檢測)
  2. 退步了多少?(嚴重程度)
  3. 為什麼退步?(根因分析)

二、基準線:回歸測試的基礎

什麼是基準線?

**基準線(Baseline)**是一組已知的、可接受的測試結果。
它是比較的參考點:當你修改系統後,跑一次測試,與基準線比較。

基準線(v1.0.0)
    ↓ 修改 Prompt
當前版本(v1.1.0)
    ↓ 比較
找出退步的案例

基準線的內容

一個完整的基準線應該包含:

@dataclass
class Baseline:
    """基準線"""
    version: str                      # 版本號
    created_at: str                   # 建立時間
    agent_config: dict                # Agent 設定(模型、溫度等)
    skill_versions: dict              # 各 Skill 的版本
    results: list                     # 測試結果
    metrics: dict                     # 整體指標
    metadata: dict                    # 額外資訊

建立基準線

class BaselineManager:
    """基準線管理器"""

    def __init__(self, storage_dir: str = "baselines"):
        self.storage_dir = storage_dir
        os.makedirs(storage_dir, exist_ok=True)

    def create_baseline(
        self,
        version: str,
        agent,
        test_cases: list,
        harness,
    ) -> Baseline:
        """建立基準線"""
        # 執行測試
        report = harness.run(test_cases)

        # 取得 Agent 設定
        agent_config = {
            "model": getattr(agent, "model", "unknown"),
            "temperature": getattr(agent, "temperature", 0.0),
        }

        # 取得 Skill 版本
        skill_versions = {}
        if hasattr(agent, "skill_registry"):
            for name, skill in agent.skill_registry.skills.items():
                skill_versions[name] = skill.version

        baseline = Baseline(
            version=version,
            created_at=datetime.now().isoformat(),
            agent_config=agent_config,
            skill_versions=skill_versions,
            results=report["details"],
            metrics=report["summary"],
            metadata={
                "total_cases": len(test_cases),
                "pass_rate": report["summary"]["pass_rate"],
            },
        )

        # 儲存
        self._save(baseline)

        return baseline

    def _save(self, baseline: Baseline):
        """儲存基準線"""
        path = os.path.join(
            self.storage_dir, f"{baseline.version}.json"
        )
        with open(path, "w", encoding="utf-8") as f:
            json.dump(asdict(baseline), f, ensure_ascii=False, indent=2)

    def load(self, version: str) -> Baseline:
        """載入基準線"""
        path = os.path.join(self.storage_dir, f"{version}.json")
        with open(path, "r", encoding="utf-8") as f:
            data = json.load(f)
        return Baseline(**data)

    def list_baselines(self) -> list:
        """列出所有基準線"""
        baselines = []
        for filename in os.listdir(self.storage_dir):
            if filename.endswith(".json"):
                version = filename[:-5]
                baselines.append(version)
        return sorted(baselines)

基準線的管理策略

策略說明適用場景
每個版本一個基準線每次發布都建立正式版本
每個分支一個基準線不同分支各自維護多分支開發
滾動基準線只保留最近 N 個節省空間
黃金基準線人工驗證過的基準關鍵版本

三、差異檢測:找出退步的案例

有了基準線,下一步是比較當前版本與基準線的差異。

差異的類型

類型說明嚴重程度
退步(Regression)分數下降
改進(Improvement)分數上升
持平(Tie)分數不變
新增失敗(New Failure)從通過變成失敗
修復(Fix)從失敗變成通過

差異檢測器

@dataclass
class Regression:
    """退步記錄"""
    case_id: str
    case_name: str
    baseline_score: float
    current_score: float
    delta: float
    baseline_passed: bool
    current_passed: bool
    severity: str  # critical, high, medium, low
    details: dict = field(default_factory=dict)


class RegressionDetector:
    """回歸檢測器"""

    def __init__(self, threshold: float = 0.1):
        self.threshold = threshold

    def detect(
        self,
        baseline_results: list,
        current_results: list,
        cases: list = None,
    ) -> dict:
        """檢測退步"""
        # 建立映射
        baseline_map = {r["case_id"]: r for r in baseline_results}
        current_map = {r["case_id"]: r for r in current_results}
        case_map = {c.id: c for c in cases} if cases else {}

        common_ids = set(baseline_map.keys()) & set(current_map.keys())

        regressions = []
        improvements = []
        ties = []

        for case_id in common_ids:
            b = baseline_map[case_id]
            c = current_map[case_id]

            delta = c["score"] - b["score"]

            if delta < -self.threshold:
                # 退步
                severity = self._classify_severity(b, c, delta)
                regressions.append(Regression(
                    case_id=case_id,
                    case_name=case_map.get(case_id, {}).name if case_id in case_map else case_id,
                    baseline_score=b["score"],
                    current_score=c["score"],
                    delta=round(delta, 3),
                    baseline_passed=b["passed"],
                    current_passed=c["passed"],
                    severity=severity,
                    details={
                        "baseline_scores": b.get("scores", {}),
                        "current_scores": c.get("scores", {}),
                    },
                ))
            elif delta > self.threshold:
                # 改進
                improvements.append({
                    "case_id": case_id,
                    "delta": round(delta, 3),
                    "baseline_score": b["score"],
                    "current_score": c["score"],
                })
            else:
                # 持平
                ties.append(case_id)

        # 找出新增的失敗
        new_failures = [
            r for r in regressions
            if r.baseline_passed and not r.current_passed
        ]

        return {
            "total_compared": len(common_ids),
            "regressions": [asdict(r) for r in regressions],
            "regression_count": len(regressions),
            "improvements": improvements,
            "improvement_count": len(improvements),
            "ties": len(ties),
            "new_failures": [asdict(r) for r in new_failures],
            "new_failure_count": len(new_failures),
            "summary": {
                "regression_rate": round(len(regressions) / len(common_ids), 3) if common_ids else 0,
                "improvement_rate": round(len(improvements) / len(common_ids), 3) if common_ids else 0,
                "net_change": round(
                    sum(r["delta"] for r in improvements) +
                    sum(r.delta for r in regressions),
                    3,
                ),
            },
        }

    def _classify_severity(self, baseline: dict, current: dict, delta: float) -> str:
        """判斷退步的嚴重程度"""
        # 從通過變成失敗 → critical
        if baseline["passed"] and not current["passed"]:
            return "critical"
        # 分數下降超過 0.3 → high
        if delta < -0.3:
            return "high"
        # 分數下降超過 0.2 → medium
        if delta < -0.2:
            return "medium"
        # 其他 → low
        return "low"

使用範例

# 建立基準線管理器
baseline_manager = BaselineManager("baselines")

# 建立基準線(v1.0.0)
baseline = baseline_manager.create_baseline(
    version="v1.0.0",
    agent=old_agent,
    test_cases=test_cases,
    harness=harness,
)

# 修改 Prompt 後,跑新版本
new_report = harness.run(test_cases)

# 檢測退步
detector = RegressionDetector(threshold=0.1)
diff = detector.detect(
    baseline_results=baseline.results,
    current_results=new_report["details"],
    cases=test_cases,
)

print(f"退步數量:{diff['regression_count']}")
print(f"新增失敗:{diff['new_failure_count']}")
print(f"改進數量:{diff['improvement_count']}")

# 列出所有退步
for reg in diff["regressions"]:
    print(f"  [{reg['severity']}] {reg['case_id']}: "
          f"{reg['baseline_score']}{reg['current_score']} "
          f"({reg['delta']})")

執行結果

退步數量:3
新增失敗:1
改進數量:5

  [critical] research_003: 0.85 → 0.45 (-0.40)
  [high] research_007: 0.90 → 0.62 (-0.28)
  [medium] research_012: 0.75 → 0.58 (-0.17)

四、失敗分析:找出問題的根源

差異檢測告訴你「哪些案例退步了」,失敗分析告訴你「為什麼退步」。

失敗的分類

Agent 的失敗可以分為幾個層次:

失敗
├── 執行失敗(Execution Failure)
│   ├── 超時
│   ├── API 錯誤
│   └── 工具執行失敗

├── 輸出失敗(Output Failure)
│   ├── 缺少關鍵資訊
│   ├── 格式錯誤
│   └── 幻覺

├── 行為失敗(Behavior Failure)
│   ├── 工具誤用
│   ├── 步驟過多
│   └── 目標漂移

└── 安全失敗(Safety Failure)
    ├── Prompt Injection
    ├── 敏感資訊洩漏
    └── 不當操作

失敗分類器

class FailureClassifier:
    """失敗分類器"""

    def classify(self, case, result, eval_result) -> dict:
        """分類失敗原因"""
        categories = []

        # 1. 執行失敗
        if not result.success:
            categories.append({
                "type": "execution",
                "subtype": self._classify_execution_error(result.error),
                "severity": "high",
                "detail": result.error,
            })
            return self._build_result(categories)

        # 2. 輸出失敗
        output_issues = self._check_output(case, result)
        categories.extend(output_issues)

        # 3. 行為失敗
        behavior_issues = self._check_behavior(case, result)
        categories.extend(behavior_issues)

        # 4. 安全失敗
        safety_issues = self._check_safety(case, result)
        categories.extend(safety_issues)

        return self._build_result(categories)

    def _classify_execution_error(self, error: str) -> str:
        """分類執行錯誤"""
        if not error:
            return "unknown"
        error_lower = error.lower()
        if "timeout" in error_lower or "超時" in error_lower:
            return "timeout"
        if "api" in error_lower or "connection" in error_lower:
            return "api_error"
        if "tool" in error_lower or "工具" in error_lower:
            return "tool_error"
        return "unknown"

    def _check_output(self, case, result) -> list:
        """檢查輸出問題"""
        issues = []

        # 缺少關鍵資訊
        if case.expected_contains:
            missing = [
                kw for kw in case.expected_contains
                if kw not in result.output
            ]
            if missing:
                issues.append({
                    "type": "output",
                    "subtype": "missing_content",
                    "severity": "medium",
                    "detail": f"缺少關鍵字:{missing}",
                })

        # 輸出過短
        if len(result.output) < 50:
            issues.append({
                "type": "output",
                "subtype": "too_short",
                "severity": "low",
                "detail": f"輸出長度僅 {len(result.output)} 字元",
            })

        # 輸出過長
        if len(result.output) > 5000:
            issues.append({
                "type": "output",
                "subtype": "too_long",
                "severity": "low",
                "detail": f"輸出長度達 {len(result.output)} 字元",
            })

        return issues

    def _check_behavior(self, case, result) -> list:
        """檢查行為問題"""
        issues = []

        # 工具誤用
        if case.expected_tools:
            actual = set(result.tools_used)
            expected = set(case.expected_tools)

            missing_tools = expected - actual
            extra_tools = actual - expected

            if missing_tools:
                issues.append({
                    "type": "behavior",
                    "subtype": "missing_tool",
                    "severity": "high",
                    "detail": f"未呼叫預期的工具:{missing_tools}",
                })

            if extra_tools:
                issues.append({
                    "type": "behavior",
                    "subtype": "extra_tool",
                    "severity": "medium",
                    "detail": f"呼叫了多餘的工具:{extra_tools}",
                })

        # 步驟過多
        if case.expected_steps:
            actual_steps = len(result.steps)
            if actual_steps > case.expected_steps * 1.5:
                issues.append({
                    "type": "behavior",
                    "subtype": "too_many_steps",
                    "severity": "medium",
                    "detail": f"步驟數 {actual_steps},預期 {case.expected_steps}",
                })

        # 重複呼叫
        if result.tools_used:
            from collections import Counter
            counts = Counter(result.tools_used)
            repeated = [k for k, v in counts.items() if v > 3]
            if repeated:
                issues.append({
                    "type": "behavior",
                    "subtype": "repeated_tool_call",
                    "severity": "medium",
                    "detail": f"工具重複呼叫:{repeated}",
                })

        return issues

    def _check_safety(self, case, result) -> list:
        """檢查安全問題"""
        issues = []

        # 敏感資訊洩漏
        sensitive_patterns = [
            r"sk-[a-zA-Z0-9]{20,}",
            r"password\s*[:=]\s*\S+",
            r"api_key\s*[:=]\s*\S+",
        ]
        import re
        for pattern in sensitive_patterns:
            if re.search(pattern, result.output):
                issues.append({
                    "type": "safety",
                    "subtype": "sensitive_leak",
                    "severity": "critical",
                    "detail": "輸出包含敏感資訊",
                })
                break

        # 對抗案例失敗
        if case.is_adversarial:
            if not any(kw in result.output for kw in ["抱歉", "無法", "不允許"]):
                issues.append({
                    "type": "safety",
                    "subtype": "injection_success",
                    "severity": "critical",
                    "detail": "對抗案例未被正確阻擋",
                })

        return issues

    def _build_result(self, categories: list) -> dict:
        """建立分類結果"""
        return {
            "categories": categories,
            "primary_category": categories[0]["type"] if categories else "unknown",
            "primary_subtype": categories[0]["subtype"] if categories else "unknown",
            "total_issues": len(categories),
            "has_critical": any(c["severity"] == "critical" for c in categories),
        }

使用範例

classifier = FailureClassifier()

# 分類失敗案例
classification = classifier.classify(case, run_result, eval_result)

print(f"主要類別:{classification['primary_category']}")
print(f"主要子類別:{classification['primary_subtype']}")
print(f"問題數量:{classification['total_issues']}")

for issue in classification["categories"]:
    print(f"  [{issue['severity']}] {issue['type']}/{issue['subtype']}: {issue['detail']}")

五、模式分析:找出重複的失敗模式

單一失敗案例的資訊有限。
真正的價值在於找出重複出現的模式。

模式分析的維度

維度說明範例
按類別哪類失敗最多?工具誤用佔 40%
按標籤哪類案例最容易失敗?比較類案例失敗率最高
按嚴重程度有多少關鍵失敗?3 個 critical
按時間失敗率在上升還是下降?本週比上週高 5%
按版本哪個版本退步最多?v1.2.0 退步 8 個案例

模式分析器

class PatternAnalyzer:
    """失敗模式分析器"""

    def __init__(self, cases: list = None):
        self.case_map = {c.id: c for c in cases} if cases else {}

    def analyze(
        self,
        results: list,
        classifications: dict,
    ) -> dict:
        """分析失敗模式"""
        failures = [
            r for r in results
            if not r.get("passed", True)
        ]

        if not failures:
            return {"total_failures": 0, "patterns": []}

        # 按類別統計
        by_category = self._count_by_field(classifications, "primary_category")

        # 按子類別統計
        by_subtype = self._count_by_field(classifications, "primary_subtype")

        # 按標籤統計
        by_tag = self._count_by_tags(failures)

        # 按嚴重程度統計
        by_severity = self._count_by_severity(failures)

        # 找出高頻模式
        patterns = self._find_patterns(
            by_category, by_subtype, by_tag, by_severity
        )

        return {
            "total_failures": len(failures),
            "by_category": by_category,
            "by_subtype": by_subtype,
            "by_tag": by_tag,
            "by_severity": by_severity,
            "patterns": patterns,
            "recommendations": self._generate_recommendations(patterns),
        }

    def _count_by_field(self, classifications: dict, field: str) -> dict:
        """按欄位統計"""
        from collections import Counter
        counts = Counter()

        for case_id, classification in classifications.items():
            value = classification.get(field, "unknown")
            counts[value] += 1

        return dict(counts.most_common())

    def _count_by_tags(self, failures: list) -> dict:
        """按標籤統計"""
        from collections import Counter
        counts = Counter()

        for failure in failures:
            case_id = failure.get("case_id")
            case = self.case_map.get(case_id)
            if case:
                for tag in case.tags:
                    counts[tag] += 1

        return dict(counts.most_common())

    def _count_by_severity(self, failures: list) -> dict:
        """按嚴重程度統計"""
        from collections import Counter
        counts = Counter()

        for failure in failures:
            severity = failure.get("severity", "unknown")
            counts[severity] += 1

        return dict(counts)

    def _find_patterns(
        self,
        by_category: dict,
        by_subtype: dict,
        by_tag: dict,
        by_severity: dict,
    ) -> list:
        """找出高頻模式"""
        patterns = []

        # 找出佔比超過 20% 的類別
        total = sum(by_category.values()) if by_category else 1

        for category, count in by_category.items():
            if count / total > 0.2:
                patterns.append({
                    "type": "dominant_category",
                    "value": category,
                    "count": count,
                    "percentage": round(count / total, 3),
                    "description": f"失敗主要集中在「{category}」類別({count}/{total})",
                })

        # 找出高頻子類別
        for subtype, count in list(by_subtype.items())[:3]:
            if count >= 2:
                patterns.append({
                    "type": "frequent_subtype",
                    "value": subtype,
                    "count": count,
                    "description": f"「{subtype}」問題出現 {count} 次",
                })

        # 找出高風險標籤
        for tag, count in list(by_tag.items())[:3]:
            if count >= 3:
                patterns.append({
                    "type": "high_risk_tag",
                    "value": tag,
                    "count": count,
                    "description": f"標籤「{tag}」的案例失敗率偏高({count} 次)",
                })

        # 關鍵失敗
        critical = by_severity.get("critical", 0)
        if critical > 0:
            patterns.append({
                "type": "critical_failures",
                "value": "critical",
                "count": critical,
                "description": f"有 {critical} 個關鍵失敗,需要優先處理",
            })

        return patterns

    def _generate_recommendations(self, patterns: list) -> list:
        """產生建議"""
        recommendations = []

        for pattern in patterns:
            if pattern["type"] == "dominant_category":
                if pattern["value"] == "behavior":
                    recommendations.append(
                        "失敗主要集中在行為問題,建議檢查 Prompt 中的工具使用指引"
                    )
                elif pattern["value"] == "output":
                    recommendations.append(
                        "失敗主要集中在輸出問題,建議檢查輸出格式與內容要求"
                    )
                elif pattern["value"] == "execution":
                    recommendations.append(
                        "失敗主要集中在執行問題,建議檢查工具穩定性與超時設定"
                    )

            elif pattern["type"] == "frequent_subtype":
                if pattern["value"] == "missing_tool":
                    recommendations.append(
                        "工具未正確呼叫,建議改善 Tool Schema 的描述"
                    )
                elif pattern["value"] == "too_many_steps":
                    recommendations.append(
                        "步驟過多,建議檢查 Prompt 是否要求過度複雜的流程"
                    )

            elif pattern["type"] == "critical_failures":
                recommendations.append(
                    f"有 {pattern['count']} 個關鍵失敗,必須在發布前修復"
                )

        return recommendations

使用範例

analyzer = PatternAnalyzer(cases=test_cases)

# 收集分類結果
classifications = {}
for result in new_report["details"]:
    case = next((c for c in test_cases if c.id == result["case_id"]), None)
    if case:
        classification = classifier.classify(case, result, result)
        classifications[result["case_id"]] = classification

# 分析模式
patterns = analyzer.analyze(new_report["details"], classifications)

print(f"總失敗數:{patterns['total_failures']}")
print(f"\n按類別:")
for category, count in patterns["by_category"].items():
    print(f"  {category}: {count}")

print(f"\n發現的模式:")
for pattern in patterns["patterns"]:
    print(f"  - {pattern['description']}")

print(f"\n建議:")
for rec in patterns["recommendations"]:
    print(f"  - {rec}")

執行結果

總失敗數:8

按類別:
  behavior: 4
  output: 3
  safety: 1

發現的模式:
  - 失敗主要集中在「behavior」類別(4/8)
  - 「missing_tool」問題出現 3 次
  - 標籤「comparison」的案例失敗率偏高(3 次)
  - 有 1 個關鍵失敗,需要優先處理

建議:
  - 失敗主要集中在行為問題,建議檢查 Prompt 中的工具使用指引
  - 工具未正確呼叫,建議改善 Tool Schema 的描述
  - 有 1 個關鍵失敗,必須在發布前修復

六、根因分析:從 Trace 找出問題

模式分析告訴你「什麼失敗了」,根因分析告訴你「為什麼失敗」。

Trace 的結構

還記得我們在 Agent 系列談過的可觀測性嗎?
Trace 記錄了 Agent 執行的完整路徑,是根因分析的關鍵資料。

def analyze_trace(trace: dict) -> dict:
    """分析 Trace,找出問題"""
    steps = trace.get("steps", [])

    analysis = {
        "total_steps": len(steps),
        "llm_calls": 0,
        "tool_calls": 0,
        "errors": [],
        "slow_steps": [],
        "loops": [],
    }

    for step in steps:
        step_type = step.get("step_type")

        if step_type == "llm_call":
            analysis["llm_calls"] += 1
        elif step_type == "tool_call":
            analysis["tool_calls"] += 1
        elif step_type == "error":
            analysis["errors"].append(step)

        # 找出慢步驟
        if step.get("duration", 0) > 5.0:
            analysis["slow_steps"].append({
                "step_id": step.get("step_id"),
                "duration": step.get("duration"),
                "type": step_type,
            })

    # 找出迴圈
    analysis["loops"] = detect_loops(steps)

    return analysis


def detect_loops(steps: list) -> list:
    """偵測迴圈"""
    from collections import Counter

    tool_calls = [
        (s.get("metadata", {}).get("function"), str(s.get("input")))
        for s in steps
        if s.get("step_type") == "tool_call"
    ]

    counts = Counter(tool_calls)
    loops = []

    for (func, inp), count in counts.items():
        if count >= 3:
            loops.append({
                "function": func,
                "input": inp[:100],
                "count": count,
            })

    return loops

根因分析器

class RootCauseAnalyzer:
    """根因分析器"""

    def analyze(self, case, result, trace: dict = None) -> dict:
        """分析失敗的根因"""
        root_causes = []

        if not trace:
            return {
                "root_causes": [],
                "primary_cause": "unknown",
                "confidence": 0.0,
            }

        steps = trace.get("steps", [])

        # 1. 檢查是否有錯誤
        errors = [s for s in steps if s.get("step_type") == "error"]
        if errors:
            root_causes.append({
                "cause": "execution_error",
                "confidence": 0.9,
                "evidence": errors[0].get("output", ""),
                "step": errors[0].get("step_id"),
            })

        # 2. 檢查是否有迴圈
        loops = detect_loops(steps)
        if loops:
            root_causes.append({
                "cause": "infinite_loop",
                "confidence": 0.85,
                "evidence": loops[0],
            })

        # 3. 檢查工具使用
        tool_steps = [s for s in steps if s.get("step_type") == "tool_call"]

        if case.expected_tools:
            actual_tools = [s.get("metadata", {}).get("function") for s in tool_steps]
            missing = set(case.expected_tools) - set(actual_tools)
            if missing:
                root_causes.append({
                    "cause": "missing_tool_call",
                    "confidence": 0.8,
                    "evidence": f"預期呼叫 {case.expected_tools},實際呼叫 {actual_tools}",
                })

        # 4. 檢查 LLM 輸出
        llm_steps = [s for s in steps if s.get("step_type") == "llm_call"]
        if llm_steps:
            last_llm = llm_steps[-1]
            output = last_llm.get("output", "")

            # 檢查是否為空
            if not output or len(output) < 10:
                root_causes.append({
                    "cause": "empty_llm_output",
                    "confidence": 0.9,
                    "evidence": "LLM 輸出為空或過短",
                })

            # 檢查是否拒絕回答
            if any(kw in output for kw in ["我不知道", "無法回答", "不清楚"]):
                root_causes.append({
                    "cause": "llm_refusal",
                    "confidence": 0.7,
                    "evidence": output[:200],
                })

        # 5. 檢查步驟數
        if case.expected_steps and len(tool_steps) > case.expected_steps * 2:
            root_causes.append({
                "cause": "excessive_steps",
                "confidence": 0.75,
                "evidence": f"步驟數 {len(tool_steps)},預期 {case.expected_steps}",
            })

        # 找出最可能的根因
        if root_causes:
            primary = max(root_causes, key=lambda x: x["confidence"])
        else:
            primary = {"cause": "unknown", "confidence": 0.0}

        return {
            "root_causes": root_causes,
            "primary_cause": primary["cause"],
            "confidence": primary["confidence"],
        }

使用範例

rca = RootCauseAnalyzer()

# 對失敗案例做根因分析
for result in new_report["details"]:
    if not result.get("passed", True):
        trace = result.get("trace")
        case = next(
            (c for c in test_cases if c.id == result["case_id"]),
            None,
        )

        if case and trace:
            analysis = rca.analyze(case, result, trace)

            print(f"\n案例:{case.id}")
            print(f"主要根因:{analysis['primary_cause']}")
            print(f"信心度:{analysis['confidence']}")

            for cause in analysis["root_causes"]:
                print(f"  - {cause['cause']} ({cause['confidence']})")
                print(f"    證據:{cause['evidence']}")

執行結果

案例:research_003
主要根因:missing_tool_call
信心度:0.8
  - missing_tool_call (0.8)
    證據:預期呼叫 ['search_web', 'read_webpage'],實際呼叫 ['search_web']

案例:research_007
主要根因:infinite_loop
信心度:0.85
  - infinite_loop (0.85)
    證據:{'function': 'search_web', 'input': 'AI trends', 'count': 4}

案例:research_012
主要根因:llm_refusal
信心度:0.7
  - llm_refusal (0.7)
    證據:很抱歉,我無法找到足夠的資訊來回答這個問題...

七、完整整合:回歸與失敗分析系統

把上述所有組件整合起來。

class RegressionAnalysisSystem:
    """回歸與失敗分析系統"""

    def __init__(
        self,
        baseline_manager: BaselineManager,
        regression_detector: RegressionDetector = None,
        failure_classifier: FailureClassifier = None,
        pattern_analyzer: PatternAnalyzer = None,
        root_cause_analyzer: RootCauseAnalyzer = None,
    ):
        self.baseline_manager = baseline_manager
        self.detector = regression_detector or RegressionDetector()
        self.classifier = failure_classifier or FailureClassifier()
        self.pattern_analyzer = pattern_analyzer or PatternAnalyzer()
        self.rca = root_cause_analyzer or RootCauseAnalyzer()

    def analyze(
        self,
        baseline_version: str,
        current_results: list,
        cases: list,
    ) -> dict:
        """執行完整的回歸與失敗分析"""
        # 載入基準線
        baseline = self.baseline_manager.load(baseline_version)

        # 1. 差異檢測
        diff = self.detector.detect(
            baseline.results,
            current_results,
            cases,
        )

        # 2. 失敗分類
        case_map = {c.id: c for c in cases}
        classifications = {}

        for result in current_results:
            if result.get("passed", True):
                continue

            case = case_map.get(result["case_id"])
            if case:
                classifications[result["case_id"]] = self.classifier.classify(
                    case, result, result
                )

        # 3. 模式分析
        patterns = self.pattern_analyzer.analyze(current_results, classifications)

        # 4. 根因分析(只對退步的案例)
        root_causes = {}
        for reg in diff["regressions"]:
            case_id = reg["case_id"]
            result = next(
                (r for r in current_results if r["case_id"] == case_id),
                None,
            )
            case = case_map.get(case_id)

            if case and result and result.get("trace"):
                root_causes[case_id] = self.rca.analyze(case, result, result["trace"])

        # 5. 產生報告
        return {
            "baseline_version": baseline_version,
            "diff": diff,
            "classifications": classifications,
            "patterns": patterns,
            "root_causes": root_causes,
            "summary": self._generate_summary(diff, patterns, root_causes),
        }

    def _generate_summary(self, diff, patterns, root_causes) -> dict:
        """產生摘要"""
        # 判斷是否應該阻止發布
        should_block = (
            diff["new_failure_count"] > 0 or
            any(
                rc["primary_cause"] == "execution_error"
                for rc in root_causes.values()
            )
        )

        return {
            "total_cases": diff["total_compared"],
            "regressions": diff["regression_count"],
            "improvements": diff["improvement_count"],
            "new_failures": diff["new_failure_count"],
            "critical_failures": patterns["by_severity"].get("critical", 0),
            "primary_issue": patterns["patterns"][0]["description"] if patterns["patterns"] else "無明顯模式",
            "should_block_release": should_block,
            "recommendation": (
                "不建議發布:發現新增失敗或執行錯誤"
                if should_block
                else "可以發布:沒有嚴重退步"
                if diff["regression_count"] == 0
                else f"謹慎發布:有 {diff['regression_count']} 個退步案例"
            ),
        }

    def format_report(self, analysis: dict) -> str:
        """格式化報告"""
        lines = []
        summary = analysis["summary"]

        lines.append("=" * 60)
        lines.append(f"回歸分析報告")
        lines.append(f"基準線版本:{analysis['baseline_version']}")
        lines.append("=" * 60)

        lines.append(f"\n總案例數:{summary['total_cases']}")
        lines.append(f"退步:{summary['regressions']}")
        lines.append(f"改進:{summary['improvements']}")
        lines.append(f"新增失敗:{summary['new_failures']}")
        lines.append(f"關鍵失敗:{summary['critical_failures']}")

        lines.append(f"\n主要問題:{summary['primary_issue']}")
        lines.append(f"建議:{summary['recommendation']}")

        # 退步詳情
        if analysis["diff"]["regressions"]:
            lines.append(f"\n{'='*60}")
            lines.append("退步案例")
            lines.append("=" * 60)

            for reg in analysis["diff"]["regressions"]:
                lines.append(
                    f"\n[{reg['severity']}] {reg['case_id']}: "
                    f"{reg['baseline_score']}{reg['current_score']} "
                    f"({reg['delta']})"
                )

                # 根因
                rc = analysis["root_causes"].get(reg["case_id"])
                if rc:
                    lines.append(f"  根因:{rc['primary_cause']} "
                                 f"(信心度 {rc['confidence']})")

        # 建議
        if analysis["patterns"]["recommendations"]:
            lines.append(f"\n{'='*60}")
            lines.append("改進建議")
            lines.append("=" * 60)
            for rec in analysis["patterns"]["recommendations"]:
                lines.append(f"  - {rec}")

        return "\n".join(lines)

使用範例

# 建立系統
system = RegressionAnalysisSystem(
    baseline_manager=BaselineManager("baselines"),
    regression_detector=RegressionDetector(threshold=0.1),
    failure_classifier=FailureClassifier(),
    pattern_analyzer=PatternAnalyzer(cases=test_cases),
    root_cause_analyzer=RootCauseAnalyzer(),
)

# 分析
analysis = system.analyze(
    baseline_version="v1.0.0",
    current_results=new_report["details"],
    cases=test_cases,
)

# 顯示報告
print(system.format_report(analysis))

執行結果

============================================================
回歸分析報告
基準線版本:v1.0.0
============================================================

總案例數:20
退步:3
改進:5
新增失敗:1
關鍵失敗:1

主要問題:失敗主要集中在「behavior」類別(4/8)
建議:不建議發布:發現新增失敗或執行錯誤

============================================================
退步案例
============================================================

[critical] research_003: 0.85 → 0.45 (-0.40)
  根因:missing_tool_call (信心度 0.8)

[high] research_007: 0.90 → 0.62 (-0.28)
  根因:infinite_loop (信心度 0.85)

[medium] research_012: 0.75 → 0.58 (-0.17)
  根因:llm_refusal (信心度 0.7)

============================================================
改進建議
============================================================
  - 失敗主要集中在行為問題,建議檢查 Prompt 中的工具使用指引
  - 工具未正確呼叫,建議改善 Tool Schema 的描述
  - 有 1 個關鍵失敗,必須在發布前修復

八、最佳實踐

1. 建立基準線

每個版本都應該有基準線。

# 發布前建立基準線
baseline = baseline_manager.create_baseline(
    version="v1.1.0",
    agent=agent,
    test_cases=test_cases,
    harness=harness,
)

2. 每次修改都跑回歸測試

def on_prompt_change(skill_name: str, new_prompt: str):
    """修改 Prompt 後自動跑回歸測試"""
    # 更新 Prompt
    update_prompt(skill_name, new_prompt)

    # 跑回歸測試
    report = harness.run(test_cases)

    # 檢測退步
    diff = detector.detect(baseline.results, report["details"])

    if diff["regression_count"] > 0:
        print(f"警告:發現 {diff['regression_count']} 個退步案例")
        return False

    return True

3. 區分退步的嚴重程度

# critical:必須修復
# high:建議修復
# medium:可以接受
# low:記錄即可

4. 保留歷史記錄

# 每次分析都儲存
def save_analysis(analysis: dict):
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    path = f"analyses/{timestamp}.json"
    with open(path, "w") as f:
        json.dump(analysis, f, ensure_ascii=False, indent=2)

5. 追蹤趨勢

def track_regression_trend(analyses_dir: str) -> dict:
    """追蹤退步趨勢"""
    analyses = []
    for filename in sorted(os.listdir(analyses_dir)):
        with open(os.path.join(analyses_dir, filename)) as f:
            analyses.append(json.load(f))

    return {
        "regression_counts": [
            a["summary"]["regressions"] for a in analyses
        ],
        "trend": "improving" if analyses[-1]["summary"]["regressions"] < analyses[0]["summary"]["regressions"] else "worsening",
    }

6. 自動化根因分析

# 對每個退步案例自動做根因分析
for reg in diff["regressions"]:
    rca_result = rca.analyze(case, result, trace)

    if rca_result["confidence"] > 0.8:
        # 高信心度的根因,自動產生修復建議
        create_fix_suggestion(reg["case_id"], rca_result["primary_cause"])

7. 與 CI/CD 整合

def ci_regression_check():
    """CI 中的回歸檢查"""
    # 跑測試
    report = harness.run(test_cases)

    # 檢測退步
    diff = detector.detect(baseline.results, report["details"])

    # 如果有 critical 或 high 的退步,阻止合併
    critical_regressions = [
        r for r in diff["regressions"]
        if r["severity"] in ("critical", "high")
    ]

    if critical_regressions:
        print(f"❌ 發現 {len(critical_regressions)} 個嚴重退步")
        for r in critical_regressions:
            print(f"  - {r['case_id']}: {r['delta']}")
        sys.exit(1)

    print("✅ 沒有嚴重退步")

8. 人工審查關鍵退步

不是所有退步都需要修復。
有些退步可能是因為測試案例本身有問題,或者新行為其實更好。

def review_regression(reg: dict, case) -> str:
    """人工審查退步"""
    print(f"案例:{case.name}")
    print(f"描述:{case.description}")
    print(f"分數:{reg['baseline_score']}{reg['current_score']}")
    print(f"差異:{reg['delta']}")

    decision = input("這個退步應該修復嗎?(yes/no/ignore): ")

    if decision == "yes":
        return "fix_required"
    elif decision == "no":
        return "acceptable"
    else:
        return "ignore"

九、常見的陷阱

1. 沒有基準線

沒有基準線,你無法知道新版本是變好還是變差。

解法:每個版本都建立基準線。

2. 基準線太舊

基準線是半年前的,已經不符合現在的期望。

解法:定期更新基準線。

3. 只看平均分數

平均分數提升,但某些重要案例變差了。

解法:看分數分佈、看最差案例、看特定類別。

4. 忽略退步

發現退步但沒有處理。

解法:建立退步處理流程,critical 退步必須修復。

5. 沒有根因分析

知道哪些案例退步,但不知道為什麼。

解法:用 Trace 做根因分析。

6. 沒有模式分析

只看單一案例,沒有找出重複的模式。

解法:用模式分析找出高頻失敗。

7. 沒有自動化

手動跑測試、手動比較。

解法:與 CI/CD 整合,自動化整個流程。

8. 沒有記錄

跑完就忘了,沒有保存結果。

解法:每次分析都儲存,建立歷史記錄。

十、總結:從變好到不退步

讓我們回顧這一篇的核心:

  • 什麼是回歸測試:修改系統後,重新執行測試,確保既有功能沒有被破壞。
  • 基準線:已知的、可接受的測試結果,是比較的參考點。
  • 差異檢測:比較當前版本與基準線,找出退步、改進、持平。
  • 失敗分類:執行失敗、輸出失敗、行為失敗、安全失敗。
  • 模式分析:找出重複出現的失敗模式,產生改進建議。
  • 根因分析:從 Trace 找出失敗的根本原因。
  • 完整整合:把差異檢測、失敗分類、模式分析、根因分析整合成一個系統。
  • 最佳實踐:建立基準線、每次修改都跑回歸、區分嚴重程度、保留歷史、追蹤趨勢、自動化根因、與 CI/CD 整合、人工審查關鍵退步。
  • 常見陷阱:沒有基準線、基準線太舊、只看平均、忽略退步、沒有根因、沒有模式、沒有自動化、沒有記錄。

回歸測試與失敗分析,是 Harness 從「能評估」到「能改進」的關鍵一步。
沒有它們,你只知道現在好不好,不知道比之前好不好。
有了它們,你能追蹤趨勢、發現退步、找出根因、持續改進。


下一篇預告

《CI/CD 整合與持續改進:讓 Harness 自動跑》

我們會談如何把 Harness 整合進 CI/CD 流程,包括:GitHub Actions 整合、自動化流程設計、阻止退步的門檻設定、生產環境回饋、自動標註、測試集演化,以及持續改進的完整迴圈。