AgentEvalTool/backend/agenteval/evaluation/rules/llm_score.py

128 lines
5.0 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 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)
# 用户问题取自当前轮发送的消息sent_message而非上一轮的智能体回复。
# 旧逻辑用 dialog[-2].reply 会把「上一轮 AI 回复」误当成「用户问题」,
# 导致多轮/动态用例里评分 LLM 收到牛头不对马嘴的问答对,普遍打 0 分。
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)
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}",
)
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)