Files
group_xinghuo_jinrong/scripts/eval/evaluate_agent.py
T

439 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Agent 评测脚本:执行单轮和多轮评测集,输出 JSON + Markdown 报告。
用法:
python scripts/eval/evaluate_agent.py --dataset docs/开发文档/51-Agent单轮评测集.md
python scripts/eval/evaluate_agent.py --dataset docs/开发文档/52-Agent多轮场景评测集.md
python scripts/eval/evaluate_agent.py --all
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
@dataclass
class EvalResult:
"""单条评测结果"""
case_id: str
agent_type: str
user_input: str
expected_tool: str
expected_params: dict[str, Any]
actual_tool: str | None
actual_params: dict[str, Any]
actual_response: str
tool_match: bool
param_match: bool
passed: bool
error: str | None = None
class AgentEvaluator:
"""Agent 评测器"""
def __init__(self, base_url: str = "http://127.0.0.1:8000"):
self.base_url = base_url
self.results: list[EvalResult] = []
def _login(self, username: str, password: str) -> str:
"""登录获取 token"""
import httpx
resp = httpx.post(
f"{self.base_url}/api/v1/auth/login",
json={"username": username, "password": password},
)
resp.raise_for_status()
return resp.json()["data"]["access_token"]
def _chat(self, token: str, message: str, agent_type: str) -> dict:
"""调用 chat API"""
import httpx
resp = httpx.post(
f"{self.base_url}/api/v1/chat",
headers={
"Authorization": f"Bearer {token}",
"X-Agent-Type": agent_type,
"Content-Type": "application/json",
},
json={"message": message},
timeout=30.0,
)
resp.raise_for_status()
return resp.json()["data"]
def parse_single_turn_cases(self, md_path: Path) -> list[dict]:
"""解析单轮评测集 Markdown"""
content = md_path.read_text(encoding="utf-8")
cases = []
# 匹配表格行:| ID | 用户输入 | 预期工具 | 预期参数 | 预期回复要点 |
# 或:| ID | 用户输入 | 预期工具 | 预期回复要点 |
table_pattern = re.compile(
r'\|\s*([A-Z]+-\d+)\s*\|\s*(.+?)\s*\|\s*(\w+)\s*\|\s*(.+?)\s*\|'
)
agent_type_map = {
"C": "customer",
"A": "advisor",
"AN": "analyst",
"X": "customer", # 通用场景默认用 customer
}
for match in table_pattern.finditer(content):
case_id = match.group(1)
user_input = match.group(2).strip()
expected_tool = match.group(3).strip()
rest = match.group(4).strip()
# 尝试解析参数
expected_params = {}
if rest.startswith("{"):
try:
expected_params = json.loads(rest)
rest = ""
except json.JSONDecodeError:
pass
agent_prefix = case_id.split("-")[0]
agent_type = agent_type_map.get(agent_prefix, "customer")
cases.append({
"case_id": case_id,
"agent_type": agent_type,
"user_input": user_input,
"expected_tool": expected_tool if expected_tool != "无" else None,
"expected_params": expected_params,
"notes": rest,
})
return cases
def parse_multi_turn_cases(self, md_path: Path) -> list[dict]:
"""解析多轮场景评测集 Markdown"""
content = md_path.read_text(encoding="utf-8")
scenarios = []
# 按场景分割
scenario_pattern = re.compile(
r'### 场景 ([A-Z]+-M\d+):(.+?)\n.*?```text\n(.*?)```',
re.DOTALL
)
agent_type_map = {
"C": "customer",
"A": "advisor",
"AN": "analyst",
"X": "customer",
}
for match in scenario_pattern.finditer(content):
scenario_id = match.group(1)
scenario_name = match.group(2).strip()
scenario_content = match.group(3)
# 解析轮次
turns = []
turn_pattern = re.compile(
r'轮次 (\d+):\n\s*用户:(.+?)\n\s*预期:(.+?)(?:\n\s*验证点:(.+?))?(?=\n\n轮次|\Z)',
re.DOTALL
)
for turn_match in turn_pattern.finditer(scenario_content):
turn_num = int(turn_match.group(1))
user_input = turn_match.group(2).strip()
expected = turn_match.group(3).strip()
checkpoint = turn_match.group(4).strip() if turn_match.group(4) else ""
# 特殊处理:[模拟会话中断]
if user_input.startswith("["):
user_input = user_input.split("]")[-1].strip()
turns.append({
"turn": turn_num,
"user_input": user_input,
"expected": expected,
"checkpoint": checkpoint,
})
agent_prefix = scenario_id.split("-")[0]
agent_type = agent_type_map.get(agent_prefix, "customer")
scenarios.append({
"scenario_id": scenario_id,
"scenario_name": scenario_name,
"agent_type": agent_type,
"turns": turns,
})
return scenarios
def evaluate_single_turn(self, cases: list[dict], token: str) -> list[EvalResult]:
"""执行单轮评测"""
results = []
for case in cases:
try:
response = self._chat(token, case["user_input"], case["agent_type"])
actual_tool = response.get("tool_calls", [{}])[0].get("tool_name") if response.get("tool_calls") else None
actual_params = response.get("tool_calls", [{}])[0].get("tool_params", {}) if response.get("tool_calls") else {}
tool_match = actual_tool == case["expected_tool"]
param_match = True
if case["expected_params"]:
param_match = all(
actual_params.get(k) == v
for k, v in case["expected_params"].items()
)
results.append(EvalResult(
case_id=case["case_id"],
agent_type=case["agent_type"],
user_input=case["user_input"],
expected_tool=case["expected_tool"] or "",
expected_params=case["expected_params"],
actual_tool=actual_tool,
actual_params=actual_params,
actual_response=response.get("response", ""),
tool_match=tool_match,
param_match=param_match,
passed=tool_match and param_match,
))
except Exception as e:
results.append(EvalResult(
case_id=case["case_id"],
agent_type=case["agent_type"],
user_input=case["user_input"],
expected_tool=case["expected_tool"] or "",
expected_params=case["expected_params"],
actual_tool=None,
actual_params={},
actual_response="",
tool_match=False,
param_match=False,
passed=False,
error=str(e),
))
return results
def evaluate_multi_turn(self, scenarios: list[dict], token: str) -> list[EvalResult]:
"""执行多轮场景评测"""
results = []
for scenario in scenarios:
scenario_results = []
for turn in scenario["turns"]:
try:
response = self._chat(token, turn["user_input"], scenario["agent_type"])
# 简单验证:检查响应非空且无错误
has_response = bool(response.get("response"))
no_error = "error" not in response.get("response", "").lower()
scenario_results.append(EvalResult(
case_id=f"{scenario['scenario_id']}-T{turn['turn']}",
agent_type=scenario["agent_type"],
user_input=turn["user_input"],
expected_tool=turn["expected"][:50], # 截取前50字符
expected_params={},
actual_tool=None,
actual_params={},
actual_response=response.get("response", ""),
tool_match=has_response,
param_match=no_error,
passed=has_response and no_error,
))
except Exception as e:
scenario_results.append(EvalResult(
case_id=f"{scenario['scenario_id']}-T{turn['turn']}",
agent_type=scenario["agent_type"],
user_input=turn["user_input"],
expected_tool=turn["expected"][:50],
expected_params={},
actual_tool=None,
actual_params={},
actual_response="",
tool_match=False,
param_match=False,
passed=False,
error=str(e),
))
results.extend(scenario_results)
return results
def generate_report(self, results: list[EvalResult], output_path: Path) -> None:
"""生成评测报告"""
total = len(results)
passed = sum(1 for r in results if r.passed)
tool_correct = sum(1 for r in results if r.tool_match)
param_correct = sum(1 for r in results if r.param_match)
# 按 Agent 统计
by_agent = {}
for r in results:
if r.agent_type not in by_agent:
by_agent[r.agent_type] = {"total": 0, "passed": 0}
by_agent[r.agent_type]["total"] += 1
if r.passed:
by_agent[r.agent_type]["passed"] += 1
report = {
"timestamp": datetime.now().isoformat(),
"summary": {
"total": total,
"passed": passed,
"failed": total - passed,
"pass_rate": round(passed / total, 4) if total > 0 else 0,
"tool_accuracy": round(tool_correct / total, 4) if total > 0 else 0,
"param_accuracy": round(param_correct / total, 4) if total > 0 else 0,
},
"by_agent": by_agent,
"failed_cases": [
{
"case_id": r.case_id,
"user_input": r.user_input,
"expected_tool": r.expected_tool,
"actual_tool": r.actual_tool,
"error": r.error,
}
for r in results if not r.passed
],
}
# 保存 JSON
json_path = output_path.with_suffix(".json")
json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
# 生成 Markdown
md_lines = [
"# Agent 评测报告",
"",
f"**评测时间**:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"",
"## 总体统计",
"",
f"- **总用例数**:{total}",
f"- **通过数**:{passed}",
f"- **失败数**:{total - passed}",
f"- **通过率**:{report['summary']['pass_rate']:.2%}",
f"- **工具准确率**:{report['summary']['tool_accuracy']:.2%}",
f"- **参数准确率**:{report['summary']['param_accuracy']:.2%}",
"",
"## 按 Agent 统计",
"",
]
for agent, stats in by_agent.items():
rate = stats["passed"] / stats["total"] if stats["total"] > 0 else 0
md_lines.append(f"- **{agent}**:{stats['passed']}/{stats['total']} ({rate:.2%})")
md_lines.extend([
"",
"## 失败用例",
"",
])
for r in results:
if not r.passed:
md_lines.extend([
f"### {r.case_id}",
"",
f"- **输入**:{r.user_input}",
f"- **预期工具**:{r.expected_tool}",
f"- **实际工具**:{r.actual_tool or 'None'}",
])
if r.error:
md_lines.append(f"- **错误**:{r.error}")
md_lines.append("")
output_path.write_text("\n".join(md_lines), encoding="utf-8")
print(f"报告已生成:{output_path}")
print(f"JSON 报告:{json_path}")
def main():
parser = argparse.ArgumentParser(description="Agent 评测脚本")
parser.add_argument("--dataset", type=Path, help="评测集 Markdown 文件路径")
parser.add_argument("--all", action="store_true", help="运行所有评测集")
parser.add_argument("--base-url", default="http://127.0.0.1:8000", help="API 基础 URL")
parser.add_argument("--output", type=Path, help="报告输出路径")
args = parser.parse_args()
evaluator = AgentEvaluator(args.base_url)
# 登录获取 token
print("正在登录...")
try:
token = evaluator._login("advisor_test", "advisor_test")
except Exception as e:
print(f"登录失败:{e}")
print("请确保服务已启动:uvicorn app.main:app --reload")
sys.exit(1)
all_results = []
if args.all or args.dataset:
datasets = []
if args.all:
datasets = [
Path("docs/开发文档/51-Agent单轮评测集.md"),
Path("docs/开发文档/52-Agent多轮场景评测集.md"),
]
else:
datasets = [args.dataset]
for dataset_path in datasets:
if not dataset_path.exists():
print(f"评测集不存在:{dataset_path}")
continue
print(f"\n正在评测:{dataset_path}")
if "单轮" in dataset_path.name:
cases = evaluator.parse_single_turn_cases(dataset_path)
print(f"解析到 {len(cases)} 条单轮用例")
results = evaluator.evaluate_single_turn(cases, token)
else:
scenarios = evaluator.parse_multi_turn_cases(dataset_path)
print(f"解析到 {len(scenarios)} 个多轮场景")
results = evaluator.evaluate_multi_turn(scenarios, token)
all_results.extend(results)
print(f"完成:{sum(1 for r in results if r.passed)}/{len(results)} 通过")
if all_results:
output_path = args.output or Path("docs/评测报告/agent_eval_report.md")
output_path.parent.mkdir(parents=True, exist_ok=True)
evaluator.generate_report(all_results, output_path)
# 输出摘要
total = len(all_results)
passed = sum(1 for r in all_results if r.passed)
print(f"\n总计:{passed}/{total} 通过 ({passed/total:.2%})")
# 判断是否达标
if passed / total >= 0.9:
print("✓ 达到通过标准(≥90%)")
sys.exit(0)
else:
print("✗ 未达到通过标准(<90%)")
sys.exit(1)
else:
print("未执行任何评测")
sys.exit(0)
if __name__ == "__main__":
main()