From 5fb6e3ffa8e76816f266ee8814bbac61edf86f59 Mon Sep 17 00:00:00 2001 From: Blizzard Date: Mon, 29 Jun 2026 16:23:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(harness):=20T0.1=20=E6=A0=A1=E5=87=86?= =?UTF-8?q?=E8=AF=84=E6=B5=8B=E8=A3=81=E5=88=A4=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E8=AE=A9=E6=81=92=E6=B8=A9=E5=99=A8=E7=9C=9F=E8=A7=A6=E5=8F=91?= =?UTF-8?q?(=E6=AD=A4=E5=89=8D=E5=85=A8=201.00=20=E7=A9=BA=E8=BD=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 诊断:旧评测 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 --- DEPTH_ROADMAP.md | 16 +++--- sundynix-dispatcher/internal/harness/eval.go | 57 ++++++++++++------- .../internal/harness/eval_test.go | 47 +++++++++++++-- 3 files changed, 86 insertions(+), 34 deletions(-) diff --git a/DEPTH_ROADMAP.md b/DEPTH_ROADMAP.md index d9a8977..fcf3b4e 100644 --- a/DEPTH_ROADMAP.md +++ b/DEPTH_ROADMAP.md @@ -15,13 +15,15 @@ ## 🥇 Tier 0 — 激活已有投入(小改动,最高回报) -### [ ] T0.1 🔴 校准评测裁判 — 让恒温器真触发 +### [x] T0.1 🔴 校准评测裁判 — 让恒温器真触发 ✅ 现状:LLM judge 恒给 0.94/1.00,从不判 poor,低分自动纠偏闭环几乎从没启动。 -- [ ] `eval.go` judge 改对抗性/rubric 打分(强制按具体维度挑毛病、默认怀疑、给扣分点) -- [ ] grounded judge 同样收紧(未被来源支持的说法必须进 Flags 并压分) -- [ ] 加校准测试:喂一批已知烂答案(空泛/跑题/拒答/未引用来源)→ 断言判 poor/warn -- [ ] 复核分级阈值(poor/warn/ok)与新分布匹配 -- 验收:已知烂答案被判 poor 并触发一次纠偏;好答案仍 ok。 +- [x] `eval.go` judge 改对抗性/rubric 打分(默认怀疑、先挑毛病再打分、给死评分基准、用满 1–5) +- [x] grounded judge 同样收紧(忠实度按编造数递减,一处编造≤2;未支持说法进 Flags) +- [x] **根因更深的数学修复**:归一 `score/5`(下限 0.2) → `(v-1)/4`(1→0),否则 0.4*rule 底使 poor 永不可达 +- [x] 校准测试:normJudge 区间 + 「流畅但跑题(judge=1)可达 poor」+ 好坏区分度 +- [x] 复核阈值(poor<0.5 / warn<0.75)与新分布匹配 +- **验收 ✅ live(deepseek)**:跑题→0.40 poor→**触发纠偏(0.40→0.55)**;截断→0.55 warn;好答案→1.00 ok。 + 校准前这三个全是 1.00→ok,恒温器从没触发。 ### [ ] T0.2 多智能体进 Studio(多智能体 v2) 现状:coordinator 后端已通(见 MULTI_AGENT.md),但画布拖不出。 @@ -87,7 +89,7 @@ | Tier | 完成 / 总 | |---|---| -| T0 激活 | 0 / 2 | +| T0 激活 | 1 / 2 | | T1 收口 | 0 / 2 | | T2 深化 | 0 / 3 | | T3 硬化 ⏸ | 0 / 5 | diff --git a/sundynix-dispatcher/internal/harness/eval.go b/sundynix-dispatcher/internal/harness/eval.go index 0588731..cbde4bd 100644 --- a/sundynix-dispatcher/internal/harness/eval.go +++ b/sundynix-dispatcher/internal/harness/eval.go @@ -106,12 +106,36 @@ func hasHeavyRepeat(s string) bool { return false } -// llmJudge 让模型对输出 1–5 打分并给理由,归一化到 [0.2,1]。失败返回 ok=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) { - 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) + 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 } @@ -122,22 +146,20 @@ func (e *Evaluator) llmJudge(ctx context.Context, input, output string) (float64 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 + 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 回答评审,重点核查回答是否严格基于检索资料、有无编造(幻觉)。" + sys := "你是严苛的 RAG 回答评审。默认怀疑:先逐条核查回答里每个关键说法能否在检索资料中找到依据,再据基准打分,不要轻易给高分。" src := evalTruncate(strings.Join(sources, "\n---\n"), 3000) user := fmt.Sprintf( "【用户问题】%s\n\n【检索资料】\n%s\n\n【模型回答】%s\n\n"+ - "请评估两项:quality=回答质量(相关/准确/完整),faithfulness=忠实度(回答是否严格基于检索资料、有无编造)。"+ - "并列出 unsupported:回答中未被检索资料支持的具体说法(无则空数组)。"+ + "评估两项,务必用满 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) @@ -153,14 +175,7 @@ func (e *Evaluator) llmJudgeGrounded(ctx context.Context, input, output string, if json.Unmarshal([]byte(evalStripFence(txt)), &j) != nil || j.Quality <= 0 || j.Faithfulness <= 0 { return 0, 0, nil, "", false } - norm := func(v float64) float64 { - s := v / 5.0 - if s > 1 { - s = 1 - } - return s - } - return norm(j.Quality), norm(j.Faithfulness), j.Unsupported, j.Reason, true + return normJudge(j.Quality), normJudge(j.Faithfulness), j.Unsupported, j.Reason, true } func evalTruncate(s string, n int) string { diff --git a/sundynix-dispatcher/internal/harness/eval_test.go b/sundynix-dispatcher/internal/harness/eval_test.go index a045011..37d2ade 100644 --- a/sundynix-dispatcher/internal/harness/eval_test.go +++ b/sundynix-dispatcher/internal/harness/eval_test.go @@ -2,6 +2,7 @@ package harness import ( "context" + "fmt" "strings" "testing" ) @@ -58,8 +59,8 @@ func TestScore_WithLLMJudge(t *testing.T) { 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.LLM < 0.74 || r.LLM > 0.76 { // 校准版归一 normJudge(4)=(4-1)/4=0.75 + t.Errorf("LLM 分应归一化为 0.75, got %.2f", r.LLM) } if r.Reason != "相关且较完整" { t.Errorf("应解析出 reason, got %q", r.Reason) @@ -70,6 +71,40 @@ func TestScore_WithLLMJudge(t *testing.T) { } } +// TestNormJudge 钉死校准归一:1→0(最低,让低质能压到 poor)、3→0.5、5→1.0,用满区间。 +func TestNormJudge(t *testing.T) { + cases := []struct { + v, want float64 + }{{1, 0}, {2, 0.25}, {3, 0.5}, {4, 0.75}, {5, 1.0}, {0, 0}, {6, 1.0}} + for _, c := range cases { + if got := normJudge(c.v); got < c.want-1e-9 || got > c.want+1e-9 { + t.Errorf("normJudge(%.0f)=%.3f, want %.3f", c.v, got, c.want) + } + } +} + +// TestScore_PoorReachable 钉死校准核心:流畅但跑题的回答(judge 给 1)也能压到 poor 区间(<0.5)。 +// 旧归一(score/5 下限 0.2 + 0.4*rule 底)会让 Overall 恒 ≥0.52、poor 永不可达 → 纠偏闭环空转。 +func TestScore_PoorReachable(t *testing.T) { + judge := func(score int) *Evaluator { + return NewEvaluator(func() bool { return true }, + func(_ context.Context, _, _ string) (string, error) { + return fmt.Sprintf(`{"score":%d,"reason":"x"}`, score), nil + }) + } + fluentOffTopic := "这是一段流畅、完整、看起来很认真的回答,但其实完全没有回答用户的问题。" + // judge 判 1(跑题)→ Overall 应落入 poor 区间(<0.5),纠偏闭环才会触发。 + if r := judge(1).Score(context.Background(), "问题", fluentOffTopic, nil); r.Overall >= 0.5 { + t.Fatalf("流畅但跑题(judge=1)应可达 poor(<0.5),got Overall=%.3f(恒温器空转的根因)", r.Overall) + } + // 区分度:好答案(judge=5)应明显高于差答案(judge=1)。 + good := judge(5).Score(context.Background(), "问题", fluentOffTopic, nil).Overall + bad := judge(1).Score(context.Background(), "问题", fluentOffTopic, nil).Overall + if good <= bad+0.3 { + t.Fatalf("好坏答案区分度不足: good=%.3f bad=%.3f", good, bad) + } +} + func TestScore_LLMJudgeBadJSONFallsBack(t *testing.T) { e := NewEvaluator( func() bool { return true }, @@ -97,11 +132,11 @@ func TestScore_GroundedFaithfulness(t *testing.T) { if !strings.Contains(gotPrompt, "检索资料") || !strings.Contains(gotPrompt, "产品支持在线协作") { t.Fatalf("judge 提示词应包含检索资料, got: %q", gotPrompt) } - if r.LLM < 0.79 || r.LLM > 0.81 { // quality 4/5 - t.Errorf("quality 应为 0.8, got %.2f", r.LLM) + if r.LLM < 0.74 || r.LLM > 0.76 { // 校准归一 quality 4 → normJudge(4)=0.75 + t.Errorf("quality 应为 0.75, got %.2f", r.LLM) } - if r.Faithful < 0.39 || r.Faithful > 0.41 { // faithfulness 2/5 - t.Errorf("忠实度应为 0.4, got %.2f", r.Faithful) + if r.Faithful < 0.24 || r.Faithful > 0.26 { // 校准归一 faithfulness 2 → normJudge(2)=0.25 + t.Errorf("忠实度应为 0.25, got %.2f", r.Faithful) } if !contains(r.Flags, "未被来源支持:该产品支持离线模式") { t.Errorf("未支持说法应进 Flags, got %v", r.Flags)