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:
Blizzard
2026-06-17 15:32:02 +08:00
parent e63632adf5
commit 3ae009db38
5 changed files with 247 additions and 17 deletions
+2 -2
View File
@@ -54,7 +54,7 @@
- [x] 报告专用编排(规划 → 分章并行 → 汇聚 → 存源) - [x] 报告专用编排(规划 → 分章并行 → 汇聚 → 存源)
- [x] 会话历史写回 - [x] 会话历史写回
- [x] Harness 熔断降级中心(真三态状态机 Closed/Open/HalfOpen + 单测含 -race;熔断时回流提示并收尾流,不静默丢弃) - [x] Harness 熔断降级中心(真三态状态机 Closed/Open/HalfOpen + 单测含 -race;熔断时回流提示并收尾流,不静默丢弃)
- [ ] Harness LLM 自动化评测( - [x] Harness LLM 自动化评测(规则检查 + LLM-as-judge,异步 off 热路径评分记录 + 单测
- [ ] 长期偏好记忆抽取(LLM 抽取 → 去重 → memory_upsertTODO - [ ] 长期偏好记忆抽取(LLM 抽取 → 去重 → memory_upsertTODO
## 第 5 层 · MCP TOOLS ## 第 5 层 · MCP TOOLS
@@ -90,7 +90,7 @@
- [ ] **真实登录 / 鉴权 / 会话**(替掉裸 `X-User-ID`,最影响"能否交付他人用") - [ ] **真实登录 / 鉴权 / 会话**(替掉裸 `X-User-ID`,最影响"能否交付他人用")
- [ ] **代码解释器 + 安全沙箱**mcp-py 核心能力,目前全桩) - [ ] **代码解释器 + 安全沙箱**mcp-py 核心能力,目前全桩)
- [ ] **Harness 余下**:输出护栏(dispatcher token 发射层)· LLM 自动化评测(熔断降级 ✅、输入护栏 ✅ 已完成) - [ ] **Harness 余下**:输出护栏(dispatcher token 发射层)(熔断降级 ✅、输入护栏 ✅、LLM 自动化评测 ✅ 已完成)
- [ ] **长期记忆抽取** + external_api 工具 - [ ] **长期记忆抽取** + external_api 工具
- [ ] **计费 / 商业化**真实实现 - [ ] **计费 / 商业化**真实实现
- [ ] 微服务化拆分(Morph B)—— 现为 Monolith First**按设计如此,非缺陷** - [ ] 微服务化拆分(Morph B)—— 现为 Monolith First**按设计如此,非缺陷**
+5 -1
View File
@@ -20,6 +20,10 @@ func main() {
pool := llm.NewPool() // LLM Pool: vLLM / Ollama 集群 pool := llm.NewPool() // LLM Pool: vLLM / Ollama 集群
breaker := harness.NewCircuitBreaker() // Harness: 熔断降级中心 breaker := harness.NewCircuitBreaker() // Harness: 熔断降级中心
// Harness: LLM 自动化评测(规则 + LLM-as-judge,模型就绪时启用)。
eval := harness.NewEvaluator(pool.Ready, func(ctx context.Context, sys, user string) (string, error) {
return pool.Chat(ctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: user}})
})
sub := dnats.MustConnect(natsURL) sub := dnats.MustConnect(natsURL)
defer sub.Close() defer sub.Close()
@@ -37,7 +41,7 @@ func main() {
} }
// sub 同时作为 Token 回流出口(TokenSink)、MCP 工具调用出口(ToolCaller)与执行事件出口(ExecSink)。 // sub 同时作为 Token 回流出口(TokenSink)、MCP 工具调用出口(ToolCaller)与执行事件出口(ExecSink)。
orch, err := eino.NewOrchestrator(pool, breaker, sub, sub, sub) orch, err := eino.NewOrchestrator(pool, breaker, eval, sub, sub, sub)
if err != nil { if err != nil {
log.Fatalf("[dispatcher] build eino graph: %v", err) log.Fatalf("[dispatcher] build eino graph: %v", err)
} }
@@ -34,15 +34,16 @@ const toolCallTimeout = 3 * time.Second
type Orchestrator struct { type Orchestrator struct {
pool *llm.Pool pool *llm.Pool
breaker *harness.CircuitBreaker breaker *harness.CircuitBreaker
eval *harness.Evaluator
sink TokenSink sink TokenSink
tools ToolCaller tools ToolCaller
exec ExecSink exec ExecSink
} }
// NewOrchestrator 持有依赖;图按任务的 DSL 在 Handle 内动态编译。 // NewOrchestrator 持有依赖;图按任务的 DSL 在 Handle 内动态编译。
// exec 为执行可视化事件出口(可为 nil,则不发轨迹事件)。 // exec 为执行可视化事件出口(可为 nil,则不发轨迹事件);eval 为自动化评测(可为 nil)
func NewOrchestrator(pool *llm.Pool, breaker *harness.CircuitBreaker, sink TokenSink, tools ToolCaller, exec ExecSink) (*Orchestrator, error) { func NewOrchestrator(pool *llm.Pool, breaker *harness.CircuitBreaker, eval *harness.Evaluator, sink TokenSink, tools ToolCaller, exec ExecSink) (*Orchestrator, error) {
return &Orchestrator{pool: pool, breaker: breaker, sink: sink, tools: tools, exec: exec}, nil return &Orchestrator{pool: pool, breaker: breaker, eval: eval, sink: sink, tools: tools, exec: exec}, nil
} }
// Handle 消费一个任务:按 DSL 编译 Eino 图并执行,把 Token 流回流到 sundynix.streams.<id>。 // Handle 消费一个任务:按 DSL 编译 Eino 图并执行,把 Token 流回流到 sundynix.streams.<id>。
@@ -83,9 +84,23 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
// 写回阶段:离开热路径、异步落历史 + (TODO)抽取记忆。 // 写回阶段:离开热路径、异步落历史 + (TODO)抽取记忆。
go o.memorize(t, answer) go o.memorize(t, answer)
// 自动化评测:离开热路径,对本轮输出打分并记录(规则 + LLM-as-judge)。
go o.evaluate(t, dsl.Compile(t.Graph).Query, answer)
return nil return nil
} }
// evaluate 异步对一次输出做自动化评测并记录评分(off 热路径,不影响响应)。
func (o *Orchestrator) evaluate(t *contract.Task, input, output string) {
if o.eval == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
r := o.eval.Score(ctx, input, output)
log.Printf("[eval] task %s 综合 %.2f(规则 %.2f / LLM %.2fflags=%v %s",
t.ID, r.Overall, r.Rule, r.LLM, r.Flags, r.Reason)
}
// fetchMemory 经 MCP memory_get 工具召回用户常驻画像。 // fetchMemory 经 MCP memory_get 工具召回用户常驻画像。
// 工具不可用/超时/无 user_id 时返回空串,降级为无记忆推理(不阻断主流程)。 // 工具不可用/超时/无 user_id 时返回空串,降级为无记忆推理(不阻断主流程)。
func (o *Orchestrator) fetchMemory(ctx context.Context, userID, _ string) string { func (o *Orchestrator) fetchMemory(ctx context.Context, userID, _ string) string {
+131 -11
View File
@@ -1,15 +1,135 @@
// Package harness 提供 LLM 自动化评测与熔断降级能力。
package harness package harness
import "context" import (
"context"
"encoding/json"
"fmt"
"strings"
)
// Evaluator 实现 LLM 自动化评测(质量打分 / 回归对比) // Result 是一次输出评测的结果。Overall ∈ [0,1]
type Evaluator struct{} type Result struct {
Overall float64 // 综合分(有 LLM 评审则 0.4*规则 + 0.6*LLM,否则=规则分)
func NewEvaluator() *Evaluator { return &Evaluator{} } Rule float64 // 规则分
LLM float64 // LLM-as-judge 分(0 表示未评/失败)
// Score 对一次推理输出打分。 Flags []string // 命中的规则问题
func (e *Evaluator) Score(ctx context.Context, input, output string) (float64, error) { Reason string // LLM 评语
// TODO: LLM-as-judge / 规则评测 }
return 0, nil
// 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)
} }
@@ -0,0 +1,91 @@
package harness
import (
"context"
"strings"
"testing"
)
func TestRuleScore(t *testing.T) {
cases := []struct {
name string
output string
wantMax float64 // 期望分 ≤ 此值
wantMin float64 // 期望分 ≥ 此值
wantFlag string // 期望命中的标签(空=不校验)
}{
{"正常", "杭州是浙江省会,历史悠久,有西湖等名胜,是著名的旅游与电商之城。", 1.0, 1.0, ""},
{"空", " ", 0, 0, "空输出"},
{"过短", "好的", 0.7, 0, "输出过短"},
{"拒答", "抱歉,我无法回答这个问题,因为信息不足,请谅解理解支持。", 0.8, 0, "疑似拒答/错误"},
}
for _, c := range cases {
s, flags := ruleScore(c.output)
if s > c.wantMax+1e-9 || s < c.wantMin-1e-9 {
t.Errorf("%s: 分 %.2f 不在 [%.2f,%.2f]", c.name, s, c.wantMin, c.wantMax)
}
if c.wantFlag != "" && !contains(flags, c.wantFlag) {
t.Errorf("%s: 期望命中标签 %q, got %v", c.name, c.wantFlag, flags)
}
}
}
func TestRuleScore_HeavyRepeat(t *testing.T) {
out := strings.Repeat("这是一句会重复很多次的废话\n", 4)
_, flags := ruleScore(out)
if !contains(flags, "重复啰嗦") {
t.Errorf("应命中重复啰嗦, got %v", flags)
}
}
func TestScore_RuleOnly(t *testing.T) {
e := NewEvaluator(nil, nil) // 无 LLM → 仅规则
r := e.Score(context.Background(), "问题", "一段质量不错的较完整回答内容,长度足够,没有任何问题。")
if r.LLM != 0 || r.Overall != r.Rule {
t.Errorf("无 LLM 时 Overall 应等于规则分, got overall=%.2f rule=%.2f llm=%.2f", r.Overall, r.Rule, r.LLM)
}
}
func TestScore_WithLLMJudge(t *testing.T) {
// 注入假评审:返回带围栏的 JSON,验证解析 + 归一化 + 综合权重。
e := NewEvaluator(
func() bool { return true },
func(ctx context.Context, sys, user string) (string, error) {
return "```json\n{\"score\":4,\"reason\":\"相关且较完整\"}\n```", nil
},
)
r := e.Score(context.Background(), "介绍杭州", "杭州是浙江省会,西湖闻名,历史与现代交融,电商发达。")
if r.LLM <= 0 {
t.Fatalf("应有 LLM 分, got %.2f", r.LLM)
}
if r.LLM < 0.79 || r.LLM > 0.81 { // 4/5 = 0.8
t.Errorf("LLM 分应归一化为 0.8, got %.2f", r.LLM)
}
if r.Reason != "相关且较完整" {
t.Errorf("应解析出 reason, got %q", r.Reason)
}
want := 0.4*r.Rule + 0.6*r.LLM
if r.Overall < want-1e-9 || r.Overall > want+1e-9 {
t.Errorf("综合分应为 0.4*规则+0.6*LLM=%.3f, got %.3f", want, r.Overall)
}
}
func TestScore_LLMJudgeBadJSONFallsBack(t *testing.T) {
e := NewEvaluator(
func() bool { return true },
func(ctx context.Context, sys, user string) (string, error) { return "我觉得还行吧", nil },
)
r := e.Score(context.Background(), "q", "一段足够长且正常的回答内容用于评测。")
if r.LLM != 0 || r.Overall != r.Rule {
t.Errorf("LLM 返回非 JSON 应回退到规则分, got overall=%.2f llm=%.2f", r.Overall, r.LLM)
}
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}