Files
sundynix-agentix/sundynix-dispatcher/internal/harness/eval.go
T
Blizzard 3ae009db38 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>
2026-06-17 15:32:02 +08:00

136 lines
4.0 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 是一次输出评测的结果。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)
}