Files
sundynix-agentix/sundynix-dispatcher/internal/harness/eval.go
T
Blizzard 5fb6e3ffa8 fix(harness): T0.1 校准评测裁判 —— 让恒温器真触发(此前全 1.00 空转)
诊断:旧评测 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>
2026-06-29 16:23:30 +08:00

200 lines
7.8 KiB
Go
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.
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 把 15 的 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"+
"评估两项,务必用满 15 区间:\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)
}