跳到主要内容

评测子系统 — 实验执行引擎

本章讲什么: 一次 experiment(实验)从被提交到出分,中间到底发生了什么。重点是"执行引擎"那条主线:调度器怎么把一个实验拆成成千上万个小执行单元,每个单元怎么跑 target、跑 evaluators,分数怎么加权聚合。

1. 先建直觉:实验 = 一张大表逐格填充

把一次实验想成一张二维表:

  • = 评测集里的每个 item(一条数据),item 里又可能有多个 turn(多轮对话的每一轮)。
  • = 实验绑定的每个 evaluator(评分员)。
  • 每格 = 这个 evaluator 对"这一行的(输入, target 输出)"打出的分。

实验要做的事,就是把这张表逐格填满,然后把每行的格子加权成一个"行分",再把所有行分聚合成"实验分"。

这张表很大(数据集动辄几千行),所以执行是异步、并发、可中断、可重试的——这正是本章所有复杂度的来源。

2. 核心数据模型:Experiment 聚合根

Experiment 是评测的聚合根,一个实例 = 一次评测的全部配置 + 运行态:

// 示意,非源码:只保留最能说明问题的字段
type Experiment struct {
ID int64
EvalSetID int64 // 用哪个评测集
TargetID int64 // 被测对象(可为空:仅记录型)
Evaluators []*Evaluator // 一组评分员
EvalConf *EvaluationConfiguration // 字段映射、并发数、重试数…
Status ExptStatus // 状态机:Pending→Processing→Success/Failed/Terminated
ExptType ExptType // Offline(离线批量) 还是 Online(在线/仅记录)
Stats *ExptStats // 跑了多少、成功多少、失败多少
}

真实定义在 modules/evaluation/domain/entity/expt.go:150type Experiment struct)。几个关键点:

  • 状态机枚举在 expt.go:56-74ExptStatus_Pending(2) → Processing(3) → Success(11)/Failed(12)/Terminated(13)/SystemTerminated(14),外加流式的 Draining(21)Terminating(15)。判定函数 IsExptFinished / IsExptFinishingexpt_run.go:74-80
  • EvalConf 里最值得注意的是 Connectorexpt.go:268):它用 FieldAdapter / FieldConfexpt.go:355-363)描述"评测集的哪个字段喂给 target 的哪个入参"、"target 的哪个输出字段喂给 evaluator"。这套字段映射是把异构数据对齐的关键。
  • AsyncExec()expt.go:217):若 target 或某个 evaluator 是异步的(如 WebAgent、Agent 评估器),整条执行走异步回报路径。

3. 执行的最小单元:一个 turn 怎么被评

抛开调度,先看最里层——对单独一行(一个 turn)打分。这是 DefaultExptTurnEvaluationImpl.Eval,逻辑清爽得像教科书:

// 示意,非源码:抓主干
func Eval(etec *ExptTurnEvalCtx) *ExptTurnRunResult {
targetResult := CallTarget(etec) // ① 跑被测对象,拿输出
if 失败或需中止 { return }
evalResults := CallEvaluators(etec, targetResult) // ② 每个评估器并发打分
return 装好 target+evaluators 结果
}

真实实现 modules/evaluation/domain/service/expt_run_item_turn_impl.go:66Eval)。重点看两步:

① CallTarget —— 拿被测输出

CallTargetexpt_run_item_turn_impl.go:105)先判断要不要跳过skipTargetNode(同文件 :148)在三种情况下跳过——没绑 target、实验是 ExptType_Online、target 是"仅记录型"。这对应实体里 EvalTargetType.IsRecordOnlyType()target.go:85,那些 *Online 类型)。"仅记录型"是个巧妙设计:在线评测时被测对象的输出早已存在于 trace 里,不需要重新执行,只记录类型即可。

真正执行时(callTarget:193),它按 target 类型用 FieldConf 把评测集字段映射成 target 入参(buildEvalSetFields:700),同步对象走 ExecuteTarget,异步对象(WebAgent 等)走 AsyncExecuteTarget 并把上下文存进 evalAsyncRepo 等回报(:288-309)。

② CallEvaluators —— 并发打分

CallEvaluators:312)的精华在并发与正确性:

// 示意,非源码:每个评估器一个 goroutine,受并发池限流
pool := goroutine.NewPool(evaluatorsConf.GetEvaluatorConcurNum()) // 默认 3
for _, ev := range expt.Evaluators {
inputData := buildEvaluatorInputData(...) // 按字段映射组装输入
inputCopy := deepCopyEvaluatorInputData(inputData) // 关键:深拷贝
pool.Add(func() error {
record := evaluatorService.RunEvaluator(baseRunReq) // 真正打分
recordMap.Store(ev.versionID, record)
return err
})
}
pool.ExecAll()

真实在 callEvaluators:381)。两个容易踩的坑、它都处理了

  • 闭包捕获:438-443):循环变量必须先复制到局部,否则 goroutine 读到最后一次迭代的值。
  • 深拷贝输入deepCopyEvaluatorInputData:655):多个 evaluator 并发时,保存记录会就地裁剪大字段(content_omitted),若共享同一个 *Content 指针,先跑完的 evaluator 会污染没跑的。所以每个 evaluator 拿一份深拷贝。这条注释(:440-441)是真实踩坑的结晶。

此外还支持评估器劫持ShouldInterceptEvaluator:446):某些情况下根据输入前置判断直接给结果、跳过真实执行;以及异步评估器asyncCallEvaluator:524):调用后把上下文存 evalAsyncRepo,等结果回报。

4. 调度器:把一个实验拆成无数个 turn

上一节是"一行怎么评"。但谁来决定"评哪些行、按什么顺序、失败了怎么办"?答案是调度器,它是 MQ 事件驱动的。

6 种运行模式

实验不是只有"从头跑"一种姿势。ExptRunModeexpt_run.go:21-37)定义了 6 种:

模式含义
EvaluationModeSubmit创建后首次提交全量跑
EvaluationModeTrialRun试运行(跑少量条目预览)
EvaluationModeFailRetry失败后重试所有失败项
EvaluationModeAppend追加模式(评测集新增了数据)
EvaluationModeRetryAll重跑全部
EvaluationModeRetryItems重跑指定 item

工厂 DefaultSchedulerModeFactory.NewSchedulerModeexpt_run_scheduler_mode_impl.go:110)按 mode 分发到不同 exec 实现。TrialRun 直接内嵌复用 Submit:147 ExptTrialRunExec 组合 *ExptSubmitExec),是典型的"小变体靠组合复用"。

调度一轮在做什么

ExptSubmitExec 为例,调度器的方法签名就是它的职责清单(expt_run_scheduler_mode_impl.go):

方法干什么
ExptStart (:498)实验开跑:建 item/turn 结果行、置 Processing
ScanEvalItems (:693)扫出本轮要提交 / 未完成 / 已完成的 item
ExptEnd (:697)收尾:判断是否还要下一 tick
NextTick (:709)触发下一轮调度(继续消费剩余 item)
PublishResult (:714)把 turn 的评估器结果引用发出去

这是一个**分轮推进(tick)**的模型:每轮扫一批待跑 item 派发执行,跑完判断有没有剩的,有就发 NextTick 继续,直到全部终态。中间用幂等键(idem)和分布式锁(mutex)防重复执行。事件载体是 ExptScheduleEvent,经 RocketMQ 流转(key expt_scheduler_event_rmq,见 modules/evaluation/infra/mq/rocket/conf.go:16)。

建实验 ──发 ScheduleEvent──▶ consumer

┌──────────────▼───────────────┐
│ 调度器一个 tick: │
│ ScanEvalItems 扫待跑 item │
│ 并发执行各 item 的各 turn │ ←── 第3节的 Eval
│ ExptEnd 判断是否还有剩 │
└──────────────┬───────────────┘
还有剩?──是──▶ NextTick(再发事件) ──┐
│ │
否 └──(回到 tick)

实验置 Success/Failed/Terminated

5. 出分:行分加权 + 实验聚合

每行多个 evaluator 各打了一分,怎么合成一个"行分"?看 IEvaluatorScoreCalculator.CalculateWeightedScoreexpt_score_calculator.go:25)。

它有两条路(:42):

  1. HTTP hook 路:若配了 ExptTurnScoreHookConfexpt_run.go:124),就把本行各评估器分数组成 /score/case 请求,调外部 HTTP 接口算聚合分。注意 callCaseScoreHook:87)的细节注释:该接口即使逻辑异常也返回 200,必须同时检查响应体 error 字段:112)。
  2. 本地加权路calculateWeightedScore:202)。规则很实在:
    • 没配权重 → 简单平均(:211-242)。
    • 配了权重 → Σ(score×weight)/Σweight,权重 ≤0 的评估器不参与分子分母(:269-273)。
    • 取分时优先用人工修正分Correction.Score)再用原始分(effectiveEvaluatorScore:190),让人工纠偏能覆盖模型打分。
// 示意,非源码:本地加权的本质
weightedRowScore = Σ(evaluatorScore_i × weight_i) / Σweight_i
// 缺省权重时退化为简单平均;优先取 Correction(人工修正)分

行分之上再做实验级聚合(均值/分布等),由 expt_result_aggr_impl.go 负责(本章不展开,见代码地图)。

6. 巧妙之处(本章带走的精华)

  • 仅记录型 target(*Online:在线评测的被测输出已在 trace 里,跳过执行只记录类型,避免重复跑 Agent。target.go:85 IsRecordOnlyType
  • 并发评估器的深拷贝防污染:大字段就地裁剪 + 指针共享是隐蔽 bug,深拷贝输入根治。expt_run_item_turn_impl.go:655
  • 分轮 tick + 幂等 + 锁:让一个超大实验可以被切成多次消费、可中断、可重试、不重复。expt_run_scheduler_mode_impl.go
  • 行分计算可外接 HTTP hook:把"多评估器如何合一"开放给业务自定义,本地加权只是兜底。expt_score_calculator.go:42
  • 人工修正分优先:评分链路允许人 override 模型,且修正分参与聚合。expt_score_calculator.go:190

7. 代码地图(本章导航)

主题文件符号
实验聚合根 / 状态modules/evaluation/domain/entity/expt.goExperimentExptStatus_*EvaluationConfigurationFieldConf
运行模式 / item 状态modules/evaluation/domain/entity/expt_run.goExptRunModeItemRunStateIsExptFinished
单 turn 执行modules/evaluation/domain/service/expt_run_item_turn_impl.goDefaultExptTurnEvaluationImpl.EvalCallTargetCallEvaluatorsdeepCopyEvaluatorInputData
调度器/模式工厂modules/evaluation/domain/service/expt_run_scheduler_mode_impl.goNewSchedulerModeExptSubmitExec.ScanEvalItemsExptStartNextTick
行分计算modules/evaluation/domain/service/expt_score_calculator.goCalculateWeightedScorecalculateWeightedScoreeffectiveEvaluatorScore
实验聚合modules/evaluation/domain/service/expt_result_aggr_impl.go(聚合实现,按符号 grep)
应用层入口modules/evaluation/application/experiment_app.goIExperimentApplicationNewExperimentApplication

下一章进入评估器内部:四类评分员,以及 LLM 当裁判时如何稳健地解析它那不靠谱的输出。