5fb6e3ffa8
诊断:旧评测 LLM judge 恒给 0.94/1.00、从不判 poor → 低分自动纠偏闭环几乎从没 启动。根因两层: 1. 提示词软:只说"严格"但无评分基准、不强制挑毛病 → 模型恒锚定 4–5。 2. 数学更致命:归一 score/5 下限 0.2,叠加无来源 Overall=0.4*rule+0.6*s 的 0.4*rule≈0.4 底 → Overall 恒 ≥0.52、poor(<0.5)对"流畅但跑题/错误"永不可达。 修复: - judge 提示词改对抗性+rubric:默认怀疑、先点缺陷再打分、给死 1–5 评分基准、 要求用满区间;grounded judge 忠实度按编造说法递减(一处编造≤2)。 - 归一 score/5 → normJudge=(v-1)/4(1→0),让低质能压到 poor 触发纠偏。 - 测试:normJudge 区间 + 流畅跑题(judge=1)可达 poor + 好坏区分度;更新两处旧 断言(4→0.75, 2→0.25)。 live 验证(deepseek):跑题→0.40 poor→自动纠偏(0.40→0.55);截断→0.55 warn; 好答案→1.00 ok。校准前三者全 1.00。评测+纠偏的投入由"摆设"变"在用"。 DEPTH_ROADMAP T0.1 ✅。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
200 lines
7.8 KiB
Go
200 lines
7.8 KiB
Go
package harness
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// Result 是一次输出评测的结果。各分 ∈ [0,1]。
|
||
type Result struct {
|
||
Overall float64 // 综合分(见 Score 的加权)
|
||
Rule float64 // 规则分
|
||
LLM float64 // LLM-as-judge 质量分(0=未评/失败)
|
||
Faithful float64 // RAG 忠实度分(有检索来源时才评;0=未评/无来源)
|
||
Flags []string // 命中的问题(规则 + 未被来源支持的说法)
|
||
Reason string // LLM 评语
|
||
}
|
||
|
||
// Evaluator 实现 LLM 自动化评测:规则检查(快、always-on)+ LLM-as-judge(模型就绪时)。
|
||
// 通过注入 ready/chat 解耦具体 LLM 后端,便于单测。
|
||
type Evaluator struct {
|
||
ready func() bool
|
||
chat func(ctx context.Context, sys, user string) (string, error)
|
||
}
|
||
|
||
// NewEvaluator 注入"模型是否就绪"与"对话"两个能力;二者为 nil 时仅做规则评测。
|
||
func NewEvaluator(ready func() bool, chat func(ctx context.Context, sys, user string) (string, error)) *Evaluator {
|
||
return &Evaluator{ready: ready, chat: chat}
|
||
}
|
||
|
||
// Score 对一次推理输出综合打分。sources 为检索到的资料(RAG 来源):非空时额外评忠实度
|
||
// (回答是否基于来源、有无编造),并把"未被来源支持的说法"加进 Flags。
|
||
func (e *Evaluator) Score(ctx context.Context, input, output string, sources []string) Result {
|
||
rule, flags := ruleScore(output)
|
||
res := Result{Rule: rule, Flags: flags, Overall: rule}
|
||
if e.ready == nil || e.chat == nil || !e.ready() {
|
||
return res
|
||
}
|
||
if len(sources) > 0 {
|
||
// RAG 路径:一次 judge 同时评质量 + 忠实度 + 列未支持说法。
|
||
if q, f, unsupported, reason, ok := e.llmJudgeGrounded(ctx, input, output, sources); ok {
|
||
res.LLM, res.Faithful, res.Reason = q, f, reason
|
||
res.Overall = 0.3*rule + 0.35*q + 0.35*f
|
||
for _, u := range unsupported {
|
||
res.Flags = append(res.Flags, "未被来源支持:"+u)
|
||
}
|
||
}
|
||
return res
|
||
}
|
||
// 无来源:仅评质量(相关/准确/完整)。
|
||
if s, reason, ok := e.llmJudge(ctx, input, output); ok {
|
||
res.LLM = s
|
||
res.Reason = reason
|
||
res.Overall = 0.4*rule + 0.6*s
|
||
}
|
||
return res
|
||
}
|
||
|
||
// refusalMarkers 是疑似拒答/出错的特征串(小写匹配)。
|
||
var refusalMarkers = []string{"抱歉,我无法", "我无法回答", "无法完成", "撰写失败", "本章撰写失败", "i cannot", "i'm unable", "error:"}
|
||
|
||
// ruleScore 纯规则评测:空输出 / 过短 / 疑似拒答 / 重复啰嗦各扣分。返回分与命中标签。
|
||
func ruleScore(output string) (float64, []string) {
|
||
out := strings.TrimSpace(output)
|
||
if out == "" {
|
||
return 0, []string{"空输出"}
|
||
}
|
||
score := 1.0
|
||
var flags []string
|
||
if len([]rune(out)) < 10 {
|
||
score -= 0.4
|
||
flags = append(flags, "输出过短")
|
||
}
|
||
low := strings.ToLower(out)
|
||
for _, m := range refusalMarkers {
|
||
if strings.Contains(low, strings.ToLower(m)) {
|
||
score -= 0.3
|
||
flags = append(flags, "疑似拒答/错误")
|
||
break
|
||
}
|
||
}
|
||
if hasHeavyRepeat(out) {
|
||
score -= 0.3
|
||
flags = append(flags, "重复啰嗦")
|
||
}
|
||
if score < 0 {
|
||
score = 0
|
||
}
|
||
return score, flags
|
||
}
|
||
|
||
// hasHeavyRepeat 判断是否有非空行重复 ≥3 次(粗略的啰嗦/复读检测)。
|
||
func hasHeavyRepeat(s string) bool {
|
||
counts := map[string]int{}
|
||
for _, line := range strings.Split(s, "\n") {
|
||
line = strings.TrimSpace(line)
|
||
if len([]rune(line)) < 4 {
|
||
continue
|
||
}
|
||
counts[line]++
|
||
if counts[line] >= 3 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 评测提示词(校准版):默认怀疑、先挑毛病再打分、给死评分基准并要求用满 1–5 区间。
|
||
// 旧版只说"严格"但无基准 → 模型恒锚定 4–5,恒温器从不触发。
|
||
const (
|
||
evalQualitySys = "你是严苛的回答质量评审。默认怀疑:先找出回答的具体缺陷(跑题/不准确/不完整/空泛套话/啰嗦),再据此打分。" +
|
||
"评分要吝啬——不要因为回答看起来认真或篇幅长就给高分。"
|
||
evalQualityRubric = "评分基准(务必用满 1–5 区间,5 分极少给):\n" +
|
||
"5=准确、完整、切题,几乎无可挑剔\n4=好,但有可改进的小处\n3=基本可用但平庸/有遗漏/偏空泛\n" +
|
||
"2=有明显缺陷(部分跑题/不准确/敷衍)\n1=跑题/答非所问/明显错误"
|
||
)
|
||
|
||
// normJudge 把 1–5 的 judge 评分归一到 [0,1]:(v-1)/4。
|
||
// 关键校准:最低分 1 → 0(而非旧的 0.2)。否则 0.4*rule 的底(流畅输出 rule≈1)叠加 0.2 的分下限,
|
||
// 会让无来源 Overall 恒 ≥0.52、poor(<0.5) 永不可达 —— "流畅但跑题/错误"也压不到纠偏区间。
|
||
func normJudge(v float64) float64 {
|
||
s := (v - 1) / 4
|
||
if s < 0 {
|
||
s = 0
|
||
}
|
||
if s > 1 {
|
||
s = 1
|
||
}
|
||
return s
|
||
}
|
||
|
||
// llmJudge 让模型对输出按基准 1–5 打分并给理由,归一化到 [0,1]。失败返回 ok=false。
|
||
func (e *Evaluator) llmJudge(ctx context.Context, input, output string) (float64, string, bool) {
|
||
user := fmt.Sprintf("【用户输入】%s\n\n【模型输出】%s\n\n%s\n\n"+
|
||
"只输出 JSON:{\"score\":1到5整数,\"reason\":\"先点最主要的缺陷(没有就说无明显缺陷)再给结论,一句话\"},不要多余文字。",
|
||
evalTruncate(input, 500), evalTruncate(output, 1500), evalQualityRubric)
|
||
txt, err := e.chat(ctx, evalQualitySys, user)
|
||
if err != nil {
|
||
return 0, "", false
|
||
}
|
||
var j struct {
|
||
Score float64 `json:"score"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
if json.Unmarshal([]byte(evalStripFence(txt)), &j) != nil || j.Score <= 0 {
|
||
return 0, "", false
|
||
}
|
||
return normJudge(j.Score), j.Reason, true
|
||
}
|
||
|
||
// llmJudgeGrounded 给 judge 同时喂用户问题、检索资料、模型回答,一次返回:
|
||
// quality 质量分、faithful 忠实度分(均归一到 [0,1])、unsupported 未被资料支持的说法、reason 评语。
|
||
func (e *Evaluator) llmJudgeGrounded(ctx context.Context, input, output string, sources []string) (quality, faithful float64, unsupported []string, reason string, ok bool) {
|
||
sys := "你是严苛的 RAG 回答评审。默认怀疑:先逐条核查回答里每个关键说法能否在检索资料中找到依据,再据基准打分,不要轻易给高分。"
|
||
src := evalTruncate(strings.Join(sources, "\n---\n"), 3000)
|
||
user := fmt.Sprintf(
|
||
"【用户问题】%s\n\n【检索资料】\n%s\n\n【模型回答】%s\n\n"+
|
||
"评估两项,务必用满 1–5 区间:\n"+
|
||
"quality(质量):5=准确完整切题;3=平庸/有遗漏;1=跑题或错误。\n"+
|
||
"faithfulness(忠实度):5=每个关键说法都有资料依据;分数随无依据/编造的说法增多而降;只要有一处明显编造,faithfulness 不得高于 2。\n"+
|
||
"unsupported:列出回答中未被检索资料支持的具体说法(无则空数组)。\n"+
|
||
"只输出 JSON:{\"quality\":1到5整数,\"faithfulness\":1到5整数,\"unsupported\":[\"...\"],\"reason\":\"一句话\"},不要多余文字。",
|
||
evalTruncate(input, 400), src, evalTruncate(output, 1500))
|
||
txt, err := e.chat(ctx, sys, user)
|
||
if err != nil {
|
||
return 0, 0, nil, "", false
|
||
}
|
||
var j struct {
|
||
Quality float64 `json:"quality"`
|
||
Faithfulness float64 `json:"faithfulness"`
|
||
Unsupported []string `json:"unsupported"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
if json.Unmarshal([]byte(evalStripFence(txt)), &j) != nil || j.Quality <= 0 || j.Faithfulness <= 0 {
|
||
return 0, 0, nil, "", false
|
||
}
|
||
return normJudge(j.Quality), normJudge(j.Faithfulness), j.Unsupported, j.Reason, true
|
||
}
|
||
|
||
func evalTruncate(s string, n int) string {
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n]) + "…"
|
||
}
|
||
|
||
// evalStripFence 去掉模型可能包裹的 ```json … ``` 围栏。
|
||
func evalStripFence(s string) string {
|
||
s = strings.TrimSpace(s)
|
||
if strings.HasPrefix(s, "```") {
|
||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||
s = s[i+1:]
|
||
}
|
||
s = strings.TrimSuffix(strings.TrimSpace(s), "```")
|
||
}
|
||
return strings.TrimSpace(s)
|
||
}
|