AgentEvalTool/backend/agenteval/evaluation/rules/llm_score.py
sinohqb c4962ddadf fix(llm_score): 修复多轮用例 question 提取错位导致普遍打 0 分
## 现象
多轮/动态用例的 llm_score 规则几乎全部返回 0.0/10 失败,即便被测对象
回复质量很高(实测 400+ 字的专业医疗回复也是 0 分)。

## 根因
evaluate() 里 question 提取逻辑错误:
    if len(dialog) >= 2:
        question_text = extract_reply_text(dialog[-2].reply)  # BUG
dialog[-2].reply 是「上一轮智能体的回复」,被误当成「用户问题」。于是评分
LLM 收到的问答对是:
  - "用户问题" = 上一轮 AI 回复
  - "智能体回复" = 当前轮 AI 回复
两段都是 AI 说的话、互不相关,评分 LLM 判定牛头不对马嘴 → 打 0 分。

单轮用例因 len(dialog)<2 走 sent_message 提取(正确),故不受影响;
问题只在多轮/dynamic 用例爆发。

## 修复
- question 始终取当前轮 last_turn.sent_message(用户实际发送的问题),
  移除错误的 dialog[-2].reply 分支
- reason 增加评分 LLM 自己的理由(parsed["reason"]),便于未来诊断
- 新增回归测试:多轮场景验证 question 来自 last sent_message 而非 prev reply

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 16:15:07 +08:00

105 lines
3.9 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))
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_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 = (
"你是一位严格的智能客服质量评估专家。请根据用户问题和智能体回复,"
f"按照以下标准打分0-10分10分最高{criteria}\n"
'只输出一个 JSON 对象:{"score": number, "reason": "简短说明"}'
)
user_prompt = f"用户问题:{question}\n智能体回复:{reply}"
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 返回内容为空"
parsed = parse_json_from_llm_text(content)
score = float(parsed["score"])
reason = parsed.get("reason", "")
return max(0.0, min(10.0, score)), reason
except Exception as exc:
return None, str(exc)