回歸測試與失敗分析:確保修改不退步
從基準線建立、差異檢測、失敗分類到根因分析,用 Python 實作完整的回歸與失敗分析系統。
回歸測試與失敗分析:確保修改不退步
上一篇我們談了 LLM-as-Judge 與 Jev,理解了如何用模型評估模型。
但有了評分能力之後,下一個問題是:你怎麼知道這次修改比上次好?
當你改了 Prompt、換了模型、調整了工具,你怎麼知道它是變好還是變差?
當測試失敗時,你怎麼知道是哪裡出了問題?是模型?是 Prompt?是工具?還是測試案例本身?
這一篇,我們要談 Harness 的兩個關鍵能力:回歸測試(Regression Testing)與失敗分析(Failure Analysis)。
我們會從基準線的建立、差異檢測、失敗分類,一路談到根因分析,並用 Python 實作一個完整的回歸與失敗分析系統。
一、什麼是回歸測試?
定義
**回歸測試(Regression Testing)**是指:在修改系統之後,重新執行測試,確保既有的功能沒有被破壞。
在傳統軟體中,回歸測試很常見:你改了一個函式,跑一次完整的測試套件,確保沒有把其他功能弄壞。
在 Agent 系統中,回歸測試同樣重要,但更複雜:
- 輸出不是固定的,無法用
assert精確比對。 - 修改 Prompt 可能影響多個案例,有些變好、有些變差。
- 換模型可能全面改變行為,需要重新建立基準。
為什麼需要回歸測試?
假設你優化了 research_topic Skill 的 Prompt,讓它更擅長找出關鍵趨勢。
你跑了幾個測試案例,感覺好像變好了,於是發布了新版本。
三天後,使用者回報:
- 「為什麼研究報告的引用來源變少了?」
- 「為什麼有些主題的研究結果變成空的?」
你回頭一看,才發現:
- 新 Prompt 讓 Agent 更專注在「趨勢」,但忽略了「來源」
- 某些邊界案例(如冷門主題)在新 Prompt 下完全失敗
如果沒有回歸測試,你不會知道這些退步。
回歸測試的目的,就是在發布前發現這些問題。
回歸測試的核心問題
回歸測試要回答三個問題:
- 哪些案例退步了?(差異檢測)
- 退步了多少?(嚴重程度)
- 為什麼退步?(根因分析)
二、基準線:回歸測試的基礎
什麼是基準線?
**基準線(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 整合、自動化流程設計、阻止退步的門檻設定、生產環境回饋、自動標註、測試集演化,以及持續改進的完整迴圈。