Files
liqiang b119135836
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
2026-08-20 13:12:50 +00:00

139 lines
5.3 KiB
Python

"""
replay.py —— 回归测试重放框架
输入:Agent 生成的回归测试用例(结构化 JSON),引用轨迹 ID 与交互轮次。
过程:从原始轨迹取出该任务的输入,喂给被测系统(sut.run_task)重放,
在重放产生的新轨迹上求值断言,给出 通过/失败。
一个测试用例的结构(Agent 需按此 DSL 生成):
{
"test_id": "RT-001",
"trajectory_id": "T-1001", # 引用的问题轨迹
"focus_turn": 3, # 关键交互轮次(问题所在)
"description": "退款前必须先做资格校验",
"assertion": {"type": "step_present", "params": {"tool": "verify_refund_eligibility"}}
}
支持的断言类型(replay 框架内置,可被自动求值):
- step_present params.tool 某工具在轨迹中必须出现(如强制前置校验)
- tool_succeeds params.tool 某工具最终必须成功、且不得出现连续失败后误报成功
- latency_under params.tool, threshold_ms 某工具单次延迟必须低于阈值
- final_status_is params.value 任务最终状态必须等于给定值
"""
import json
import os
from typing import Dict, Any, List, Tuple
import sut
_DATA = os.path.join(os.path.dirname(__file__), "data", "trajectories.jsonl")
def load_trajectories(path: str = _DATA) -> Dict[str, Dict[str, Any]]:
"""读取生产轨迹集合,按 trajectory_id 索引。"""
out = {}
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
t = json.loads(line)
out[t["trajectory_id"]] = t
return out
# ---------------- 断言求值器 ----------------
def _tool_turns(traj: Dict[str, Any], tool: str) -> List[Dict[str, Any]]:
return [t for t in traj.get("turns", []) if t.get("tool") == tool]
def _eval_assertion(assertion: Dict[str, Any], traj: Dict[str, Any]) -> Tuple[bool, str]:
a_type = assertion.get("type")
params = assertion.get("params", {})
if a_type == "step_present":
tool = params.get("tool") or params.get("step")
ok = len(_tool_turns(traj, tool)) > 0
return ok, f"工具 {tool} {'出现' if ok else '缺失'}"
if a_type == "tool_succeeds":
tool = params.get("tool")
calls = _tool_turns(traj, tool)
if not calls:
return False, f"{tool} 未被调用"
n_err = sum(1 for c in calls if c.get("status") == "error")
last_ok = calls[-1].get("status") == "success"
# 修复标准:最终成功,且不存在"多次失败后仍误报成功"(>=2 次失败视为未正确处理)
ok = last_ok and n_err < 2
return ok, f"{tool} 调用 {len(calls)} 次, 失败 {n_err} 次, 末次{'成功' if last_ok else '失败'}"
if a_type == "latency_under":
tool = params.get("tool")
thr = params.get("threshold_ms") or params.get("threshold")
if thr is None:
return False, "latency_under 断言缺失阈值设置"
try:
thr = float(thr)
except (TypeError, ValueError):
return False, f"latency_under 阈值非法: {thr!r}"
calls = _tool_turns(traj, tool)
if not calls:
return False, f"{tool} 未被调用"
latencies = []
for c in calls:
lat = c.get("latency_ms")
if lat is None:
lat = 0
try:
latencies.append(float(lat))
except (TypeError, ValueError):
latencies.append(0.0)
worst = max(latencies) if latencies else 0.0
ok = worst < thr
return ok, f"{tool} 最大延迟 {worst}ms, 阈值 {thr}ms"
if a_type == "final_status_is":
want = params.get("value")
ok = traj.get("final_status") == want
return ok, f"final_status={traj.get('final_status')}, 期望={want}"
return False, f"未知断言类型: {a_type}"
def run_test_case(tc: Dict[str, Any], trajectories: Dict[str, Any],
fixed: bool) -> Dict[str, Any]:
"""对单条测试用例:取原始轨迹输入 -> 重放被测系统 -> 求值断言。"""
tid = tc.get("trajectory_id")
src = trajectories.get(tid)
if src is None:
return {"test_id": tc.get("test_id"), "passed": False,
"detail": f"引用的轨迹 {tid} 不存在"}
replayed = sut.run_task(src["task_input"], fixed=fixed)
passed, detail = _eval_assertion(tc.get("assertion", {}), replayed)
return {
"test_id": tc.get("test_id"),
"trajectory_id": tid,
"focus_turn": tc.get("focus_turn"),
"passed": passed,
"detail": detail,
"replay_mode": "fixed" if fixed else "buggy",
}
def run_suite(test_cases: List[Dict[str, Any]], fixed: bool,
path: str = _DATA) -> List[Dict[str, Any]]:
"""跑完整套测试用例,返回结果列表。"""
trajectories = load_trajectories(path)
results = []
for tc in test_cases:
try:
results.append(run_test_case(tc, trajectories, fixed))
except Exception as e: # 单条用例出错不影响整套
results.append({"test_id": tc.get("test_id"), "passed": False,
"detail": f"用例执行异常: {e}",
"replay_mode": "fixed" if fixed else "buggy"})
return results