feat(dispatcher): LLM 自动化评测落地(规则 + LLM-as-judge)+ 单测
Evaluator 此前是空桩(Score 恒返 0)且未接线。落地为真实自动化评测并接入: - 规则评测(always-on,纯函数):空输出/过短/疑似拒答/重复啰嗦各扣分 → 0–1 分 + 标签。 - LLM-as-judge(模型就绪时):让模型对(输入,输出)按相关性/准确性/完整性 1–5 打分给理由, 归一化后与规则分加权(0.4 规则 + 0.6 LLM);解析失败/无模型则回退纯规则分。 - 经注入 ready/chat 解耦 LLM 后端,便于单测(无需真实模型)。 - 接线:orchestrator 在答复产出后 `go o.evaluate(...)` 异步评分并记日志(off 热路径, 不影响响应与流式);main.go 用 pool.Ready/pool.Chat 构造 Evaluator。 测试:规则各情形(正常/空/过短/拒答/重复)、纯规则模式、LLM-judge(带围栏 JSON 解析 + 归一化 + 加权)、坏 JSON 回退 —— 全过。 至此 Harness 三件:熔断降级 ✅ · 输入护栏 ✅ · LLM 自动化评测 ✅(输出护栏待 emit 层)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,135 @@
|
||||
// Package harness 提供 LLM 自动化评测与熔断降级能力。
|
||||
package harness
|
||||
|
||||
import "context"
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Evaluator 实现 LLM 自动化评测(质量打分 / 回归对比)。
|
||||
type Evaluator struct{}
|
||||
|
||||
func NewEvaluator() *Evaluator { return &Evaluator{} }
|
||||
|
||||
// Score 对一次推理输出打分。
|
||||
func (e *Evaluator) Score(ctx context.Context, input, output string) (float64, error) {
|
||||
// TODO: LLM-as-judge / 规则评测
|
||||
return 0, nil
|
||||
// Result 是一次输出评测的结果。Overall ∈ [0,1]。
|
||||
type Result struct {
|
||||
Overall float64 // 综合分(有 LLM 评审则 0.4*规则 + 0.6*LLM,否则=规则分)
|
||||
Rule float64 // 规则分
|
||||
LLM float64 // LLM-as-judge 分(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 对一次推理输出综合打分。
|
||||
func (e *Evaluator) Score(ctx context.Context, input, output string) Result {
|
||||
rule, flags := ruleScore(output)
|
||||
res := Result{Rule: rule, Flags: flags, Overall: rule}
|
||||
if e.ready != nil && e.chat != nil && e.ready() {
|
||||
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
|
||||
}
|
||||
|
||||
// llmJudge 让模型对输出 1–5 打分并给理由,归一化到 [0.2,1]。失败返回 ok=false。
|
||||
func (e *Evaluator) llmJudge(ctx context.Context, input, output string) (float64, string, bool) {
|
||||
sys := "你是严格的回答质量评审,从相关性、准确性、完整性综合判断。"
|
||||
user := fmt.Sprintf("用户输入:%s\n模型输出:%s\n请打分。只输出 JSON:{\"score\":1到5的整数,\"reason\":\"一句话理由\"},不要任何多余文字。",
|
||||
evalTruncate(input, 500), evalTruncate(output, 1500))
|
||||
txt, err := e.chat(ctx, sys, 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
|
||||
}
|
||||
s := j.Score / 5.0
|
||||
if s > 1 {
|
||||
s = 1
|
||||
}
|
||||
return s, 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user