AgentEvalTool/backend/agenteval/evaluation/rules/llm_score.py
sinohqb c1a3cdbaa9
All checks were successful
CI / test (pull_request) Successful in 4m5s
fix(rules): 修复代码审查发现的三处规则层缺陷
- response_time: 仅配置 max_ms 时恢复 v0.3 评分语义(最后一轮评分 + 超限线性惩罚),扩展指标共存时才用均值评分
- safety: 移除 moderation API 的黑名单命中跳过守卫,两层安全检查独立执行、发现均上报
- llm_score: 多维度评分添加 Semaphore 并发上限(5),防止维度数多时无限扇出模型请求
2026-08-25 18:13:11 +08:00

186 lines
7.1 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.

"""LLM-based scoring evaluation rule."""
import asyncio
import json
import httpx
from agenteval.evaluation.rules.base import EvalRule, RuleResult, register_rule
from agenteval.models import Case, Turn
from agenteval.utils.llm import extract_content_from_llm_response, extract_reply_text, parse_json_from_llm_text
@register_rule
class LlmScoreRule(EvalRule):
"""Use an external LLM to score reply quality against criteria."""
name = "llm_score"
async def evaluate(self, case: Case, dialog: list[Turn]) -> RuleResult:
if not dialog:
return RuleResult(passed=False, reason="无回复记录")
last_turn = dialog[-1]
reply_text = extract_reply_text(last_turn.reply)
question_text = ""
if last_turn.sent_message:
body = last_turn.sent_message.get("msgBody", "")
if isinstance(body, dict):
question_text = body.get("content", "")
else:
try:
question_text = json.loads(body).get("content", "")
except Exception:
question_text = str(body)
dimensions = self.params.get("dimensions")
if dimensions:
return await self._evaluate_dimensions(question_text, reply_text, dimensions)
criteria = self.params.get("criteria", "")
min_score = float(self.params.get("min_score", 7))
if self.model_config and self.gateway:
score, reason = await self._call_gateway(question_text, reply_text, criteria)
else:
api_url = self.params.get("api_url")
api_key = self.params.get("api_key")
model = self.params.get("model", "gpt-4o-mini")
if not api_url:
return RuleResult(passed=False, reason="LLM 评分规则未绑定评估模型(兼容配置缺少 api_url")
score, reason = await self._call_llm(api_url, api_key, model, question_text, reply_text, criteria)
if score is None:
return RuleResult(passed=False, reason=f"LLM 评分失败: {reason}")
passed = score >= min_score
verdict = "通过" if passed else "未通过"
detail = f"{reason}" if reason else ""
return RuleResult(
passed=passed,
score=score / 10.0,
reason=f"LLM 评分 {score}/10{verdict} (阈值 {min_score}){detail}",
)
_MAX_CONCURRENT_DIMENSIONS = 5
async def _evaluate_dimensions(
self, question: str, reply: str, dimensions: list[dict]
) -> RuleResult:
semaphore = asyncio.Semaphore(self._MAX_CONCURRENT_DIMENSIONS)
async def bounded(dim: dict) -> tuple[float | None, str]:
async with semaphore:
return await self._evaluate_one_dimension(question, reply, dim)
results = await asyncio.gather(*(bounded(dim) for dim in dimensions))
dimension_scores = {}
dimension_reasons = []
all_passed = True
for dim, (score, reason) in zip(dimensions, results):
dim_name = dim.get("name", "unknown")
min_score = float(dim.get("min_score", 7))
if score is None:
all_passed = False
dimension_scores[dim_name] = None
dimension_reasons.append(f"{dim_name}: 评分失败 ({reason})")
else:
passed = score >= min_score
if not passed:
all_passed = False
dimension_scores[dim_name] = score
dimension_reasons.append(f"{dim_name}: {score}/10")
valid_scores = [s for s in dimension_scores.values() if s is not None]
avg_score = sum(valid_scores) / len(valid_scores) if valid_scores else 0
verdict = "通过" if all_passed else "未通过"
reasons_str = "".join(dimension_reasons)
return RuleResult(
passed=all_passed,
score=avg_score / 10.0,
reason=f"多维度 LLM 评分 {avg_score:.1f}/10{verdict}{reasons_str}",
details={"dimensions": dimension_scores},
)
async def _evaluate_one_dimension(
self, question: str, reply: str, dimension: dict
) -> tuple[float | None, str]:
criteria = dimension.get("criteria", "")
if self.model_config and self.gateway:
return await self._call_gateway(question, reply, criteria)
api_url = self.params.get("api_url")
api_key = self.params.get("api_key")
model = self.params.get("model", "gpt-4o-mini")
if not api_url:
return None, "未绑定评估模型"
return await self._call_llm(api_url, api_key, model, question, reply, criteria)
async def _call_gateway(self, question: str, reply: str, criteria: str) -> tuple[float | None, str]:
system_prompt, user_prompt = self._prompts(question, reply, criteria)
try:
content = await self.gateway.chat(
self.model_config,
[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.2,
)
return self._parse_score(content)
except Exception as exc:
return None, str(exc)
@staticmethod
def _prompts(question: str, reply: str, criteria: str) -> tuple[str, str]:
system_prompt = (
"你是一位严格的智能客服质量评估专家。请根据用户问题和智能体回复,"
f"按照以下标准打分0-10分10分最高{criteria}\n"
'只输出一个 JSON 对象:{"score": number, "reason": "简短说明"}'
)
return system_prompt, f"用户问题:{question}\n智能体回复:{reply}"
@staticmethod
def _parse_score(content: str) -> tuple[float, str]:
parsed = parse_json_from_llm_text(content)
score = float(parsed["score"])
return max(0.0, min(10.0, score)), parsed.get("reason", "")
async def _call_llm(
self,
api_url: str,
api_key: str | None,
model: str,
question: str,
reply: str,
criteria: str,
) -> tuple[float | None, str]:
"""Call the configured LLM API and parse a numeric score between 0 and 10."""
system_prompt, user_prompt = self._prompts(question, reply, criteria)
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": 0.2,
}
try:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(api_url, headers=headers, json=payload)
resp.raise_for_status()
content = extract_content_from_llm_response(resp.json())
if not content:
return None, "LLM 返回内容为空"
return self._parse_score(content)
except Exception as exc:
return None, str(exc)