## 现象
多轮/动态用例的 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>
105 lines
3.9 KiB
Python
105 lines
3.9 KiB
Python
"""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)
|