feat(harness): 低分自动纠偏 —— poor 触发评语驱动重生成,取更优者(测温计→恒温器)

评测闭环延伸出自愈:当自动评测判定输出为 poor(综合<0.5),dispatcher 在热路径外
自动用「原问题+初版回答+评审短板(flags/评语)」(有来源则连来源一并喂回、要求严格基于来源)
让模型重写,重评后仅当新分严格更高才采纳(绝不退步);采纳的修订版落会话历史,
保证多轮上下文用的是好答案而非被判低分的初版。评测终值带 corrected 标记经 NATS→网关落库。

- maxRefineRounds=1:poor 稀少,1 轮重写+重评够用,防成本失控
- canRefine 门控:模型就绪且熔断未开才纠偏,避免后端抖时雪上加霜
- 单 goroutine 串 评测→纠偏→落历史,杜绝原两 goroutine 对答案版本的竞态
- 契约 EvalEvent / Eval 表 / upsert / GET /tasks/:id/eval 均加 corrected 字段
- refine_test.go:采纳更优 / 不退步 / 非低分不触发 三测;live 验证好答案不误触发

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-25 16:22:45 +08:00
parent 10f08ffb14
commit 93e9d3b195
8 changed files with 227 additions and 34 deletions
@@ -193,37 +193,118 @@ func (o *Orchestrator) Handle(ctx context.Context, t *contract.Task) error {
o.breaker.Report(true)
o.finishStatus(t.ID, nil)
// 写回阶段:离开热路径、异步落历史 + (TODO)抽取记忆。
go o.memorize(t, answer)
// 自动化评测:离开热路径,对本轮输出打分并记录(规则 + LLM-as-judge + RAG 忠实度)
go o.evaluate(t, dsl.Compile(t.Graph).Query, answer, refs)
// 写回阶段(离热路径,单 goroutine 串起评测与落历史):
// 先评测(低分则自动纠偏重生成、取更优者),再把最终采纳的答案落会话历史 —— 保证
// 多轮上下文用的是纠偏后的好答案,而非被判低分的初版
go func() {
query := dsl.Compile(t.Graph).Query
final := o.evaluate(t, query, answer, refs)
o.memorize(t, final)
}()
return nil
}
// maxRefineRounds 是低分自动纠偏的最大重生成轮数(防成本失控/死循环)。
// 每轮 = 1 次重写 + 1 次重评;poor 本就稀少,1 轮足以覆盖大多数偶发劣化。
const maxRefineRounds = 1
// evaluate 异步对一次输出做自动化评测并记录评分(off 热路径,不影响响应)。
// 低分(poor)时触发"评语驱动"的自动纠偏:重生成更优答案、重评、取更优者(恒温器闭环)。
// sources 为本轮检索来源:非空时额外评忠实度(幻觉检测)。
func (o *Orchestrator) evaluate(t *contract.Task, input, output string, sources []string) {
// 返回最终采纳的答案(纠偏成功则为修订版,否则为原文)——供调用方落会话历史。
func (o *Orchestrator) evaluate(t *contract.Task, input, output string, sources []string) string {
if o.eval == nil {
return
return output
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
r := o.eval.Score(ctx, input, output, sources)
level := evalLevel(r)
log.Printf("[eval] task %s 综合 %.2f(规则 %.2f / LLM %.2f / 忠实 %.2f,来源 %dlevel=%s flags=%v %s",
t.ID, r.Overall, r.Rule, r.LLM, r.Faithful, len(sources), level, r.Flags, r.Reason)
if level == contract.EvalPoor { // 低质量告警(可观测 + 后续可挂报警)
slog.Warn("eval poor quality", "task_id", t.ID, "overall", r.Overall, "faithful", r.Faithful, "flags", r.Flags)
// 低分自动纠偏:把"问题 + 初版回答 + 评审短板"交给模型重写,重评后仅采纳更优者(不退步)。
corrected := false
for round := 0; level == contract.EvalPoor && round < maxRefineRounds && o.canRefine(); round++ {
newOut, newR, ok := o.refine(ctx, t, input, output, sources, r)
if !ok {
break // 重写失败或未改好:保留当前最优,停止
}
prevOverall := r.Overall
output, r, level, corrected = newOut, newR, evalLevel(newR), true
log.Printf("[eval] task %s 自动纠偏第 %d 轮采纳:综合 %.2f→%.2f level=%s", t.ID, round+1, prevOverall, r.Overall, level)
}
// 闭环:评测结果落库(供 UI 查询 / 质量趋势 / 门控)。
if level == contract.EvalPoor { // 纠偏后仍低质量:告警(可观测 + 后续可挂报警)
slog.Warn("eval poor quality", "task_id", t.ID, "overall", r.Overall, "faithful", r.Faithful, "flags", r.Flags, "corrected", corrected)
}
// 闭环:评测结果(纠偏后终值)落库(供 UI 查询 / 质量趋势 / 门控)。
if o.evalSink != nil {
if err := o.evalSink.PublishEval(&contract.EvalEvent{
TaskID: t.ID, Overall: r.Overall, Rule: r.Rule, LLM: r.LLM, Faithful: r.Faithful,
Level: level, Flags: r.Flags, Reason: r.Reason, Sources: len(sources), TS: time.Now().UnixMilli(),
Level: level, Flags: r.Flags, Reason: r.Reason, Sources: len(sources), Corrected: corrected,
TS: time.Now().UnixMilli(),
}); err != nil {
log.Printf("[eval] 回写评测结果失败 task=%s: %v", t.ID, err)
}
}
return output
}
// canRefine 报告是否具备自动纠偏条件:模型就绪且熔断未开(重写要再打一次模型,避雪上加霜)。
func (o *Orchestrator) canRefine() bool {
return o.pool != nil && o.pool.Ready() && (o.breaker == nil || o.breaker.Allow())
}
// refine 对低分输出做一次"评语驱动"的重生成:把原问题 + 初版回答 + 评测短板(flags/评语)
// 交给模型重写,再重新评分。仅当新分严格更高时采纳(ok=true);否则保留原文(不退步)。
// 有检索来源时把来源一并喂回,要求严格基于来源作答(同时压低幻觉)。
func (o *Orchestrator) refine(ctx context.Context, t *contract.Task, input, output string, sources []string, prev harness.Result) (string, harness.Result, bool) {
tr := o.tracer(t.ID)
end := tr.span("refine", "system", "低分自动纠偏")
critique := strings.Join(prev.Flags, "")
if prev.Reason != "" {
if critique != "" {
critique += ""
}
critique += "评语:" + prev.Reason
}
if critique == "" {
critique = "整体质量不足(相关性/准确性/完整性欠佳)"
}
sys := "你是严谨的回答修订专家。下面给你一个用户问题、一份质量不足的初版回答、以及评审指出的问题。请针对这些问题重写一份更准确、相关、完整的回答。"
srcBlock := ""
if len(sources) > 0 {
srcBlock = "\n\n【可参考的检索资料(严格基于它作答,不要编造资料外的内容)】\n" +
truncate(strings.Join(sources, "\n---\n"), 3000)
}
user := fmt.Sprintf("【用户问题】%s\n\n【初版回答(质量不足)】%s\n\n【评审指出的问题】%s%s\n\n请直接输出修订后的回答正文,不要解释你做了哪些修改。",
truncate(input, 1000), truncate(output, 2000), critique, srcBlock)
revised, err := o.pool.Chat(ctx, []llm.ChatMessage{
{Role: "system", Content: sys},
{Role: "user", Content: user},
})
if err != nil || strings.TrimSpace(revised) == "" {
end("重写失败", errOrEmpty(err))
return output, prev, false
}
newR := o.eval.Score(ctx, input, revised, sources)
if newR.Overall <= prev.Overall {
end(fmt.Sprintf("重写未提升(%.2f≤%.2f),保留原版", newR.Overall, prev.Overall), nil)
return output, prev, false
}
end(fmt.Sprintf("已采纳修订:综合 %.2f→%.2f", prev.Overall, newR.Overall), nil)
return revised, newR, true
}
// errOrEmpty 把空输出也表达成一个错误,供 exec error 事件着色。
func errOrEmpty(err error) error {
if err != nil {
return err
}
return errors.New("空输出")
}
// evalLevel 据综合分 + 忠实度把评测分级(闭环门控/告警用)。