Day 12 / 共 20 天 · 第 3 周 数据与评测

评测集 + 评估器:题库 与 裁判

昨天认识了通用的 Dataset。今天进入评测专属的两个角色:评测集(EvaluationSet)——专门给评测用的题库,和 评估器(Evaluator)——给 AI 的回答打分的"自动裁判"。它们都在 backend/modules/evaluation 下,是 Day 13 实验能跑起来的两大前提。

📍 你在整门课的位置 · 第 3 周 数据与评测(共 4 周 · 20 天)
D11 Dataset D12 评测集+评估器 D13 实验主链路 D14 Trace 观测 D15 跨模块组装图
L01

评测集 和 昨天的 Dataset 是什么关系

🤔 痛点:为什么不直接复用 Dataset,还要再造一个"评测集"? 打开 backend/modules/evaluation/domain/entity/evaluation_set.go 你会看到一个眼熟的结构——这不是巧合。
💡 本质:EvaluationSet 是 Dataset 的"评测视角别名 + 领域扩展" 看真实代码(evaluation_set.go:12-28):
type EvaluationSet struct {
	ID                   int64                 `json:"id,omitempty"`
	SpaceID              int64                 `json:"space_id,omitempty"`
	Name                 string                `json:"name,omitempty"`
	Status               DatasetStatus         `json:"status,omitempty"`   // 注意:类型名还叫 DatasetStatus
	Spec                 *DatasetSpec          `json:"spec,omitempty"`     // 还叫 DatasetSpec
	Features             *DatasetFeatures      `json:"features,omitempty"`
	ItemCount            int64                 `json:"item_count,omitempty"`
	ChangeUncommitted    bool                  `json:"change_uncommitted,omitempty"`
	EvaluationSetVersion *EvaluationSetVersion `json:"evaluation_set_version,omitempty"`
	LatestVersion        string                `json:"latest_version,omitempty"`
	...
}
生活类比 昨天的 Dataset 是"通用练习册"模板,今天的 EvaluationSet 就是"专门用来出评测题的那一本"——字段几乎照搬(DatasetStatus/DatasetSpec 这些类型名都直接沿用),但它活在 modules/evaluation 这个独立的领域模块里,有自己的 evaluation_set_app.go、自己的表、自己的版本和条目管理逻辑。可以理解成"评测域"对"数据域"概念的一次复刻和专属化,两者代码是分开维护的,不是同一张表。
为什么不共享同一套代码? 因为 DDD 里"模块间不直接互调"(AGENTS.md 里的核心约束之一)——evaluation 模块要用到"数据集"这个概念,但不能直接 import data 模块的 domain 包,所以选择在自己的域里定义一套结构相近但独立演进的实体。真正跨模块协作时,会通过 Day 15 要讲的"本地 RPC 客户端"(lodataset)来调用,而不是共享代码。
L02

什么是评估器:自动打分的"裁判"

🤔 痛点:AI 回答得好不好,谁说了算? 假设你跑了 1000 条测试,得到 1000 条 AI 回复——你不可能一条条人肉看。评估器(Evaluator)就是代替人工给每条回复打分的"自动裁判"。
💡 本质:评估器 = 一个"输入是(问题+AI回答),输出是分数"的函数 不管评估器内部实现多复杂,对外都是同一个契约:喂给它"这道题是什么、期望答案是什么、AI 实际答了什么",它吐出一个分数(或对/错)加一段理由。这正是为什么它能被"插"进任意实验流程——它只关心输入输出契约,不关心你在评测什么业务。
生活类比:改卷老师 评估器就像"改卷老师"。有的改卷老师是"按标准答案比对"(代码评估器:写死规则,比如答案是否包含关键词);有的是"请另一位更权威的老师帮忙看"(Prompt 评估器:让另一个大模型来判断好不好);还有的是"打电话问外部专家"(Custom RPC:调用你自己写的评分服务)。Coze Loop 内置了这几种"改卷老师",你也能自己接一个。
你可能听过的说法在 Coze Loop 里对应
LLM-as-a-JudgePrompt 类型评估器(EvaluatorTypePrompt)——用一个大模型当裁判
规则打分 / 关键词匹配Code 类型评估器(EvaluatorTypeCode)——跑一段代码逻辑
接第三方评分服务CustomRPC 类型评估器——调用你自己的 HTTP/RPC 服务
让另一个 Agent 来评Agent 类型评估器(EvaluatorTypeAgent)——异步,跑得比较久
L03

Evaluator 实体拆解:一个类型,四种"身体"

核心实体在 backend/modules/evaluation/domain/entity/evaluator.go:6-27

type Evaluator struct {
	ID            int64
	SpaceID       int64
	Name          string
	EvaluatorType EvaluatorType   // Prompt / Code / CustomRPC / Agent
	LatestVersion string
	Builtin       bool            // 是否是官方预置评估器
	BoxType       EvaluatorBoxType // 白盒(可看到实现细节) / 黑盒
	SourceType    EvaluatorSourceType

	PromptEvaluatorVersion    *PromptEvaluatorVersion    // 四种类型二选一携带对应版本
	CodeEvaluatorVersion      *CodeEvaluatorVersion
	CustomRPCEvaluatorVersion *CustomRPCEvaluatorVersion
	AgentEvaluatorVersion     *AgentEvaluatorVersion
}
💡 设计手法:一个结构体、四个"可选身体" 注意这四个 XxxEvaluatorVersion 字段都是指针,同一时刻只有和 EvaluatorType 对应的那个不为空。GetEvaluatorVersionID()evaluator.go:85-107)这类方法内部用 switch e.EvaluatorType 分发到对应字段——这是 Go 里"轻量级多态"的常见写法(没有接口继承,靠一个 tag 字段 + switch 分支模拟"多种子类型")。

以 Prompt 类型评估器的"身体"为例,evaluator_version_prompt.go:17-34

type PromptEvaluatorVersion struct {
	EvaluatorID        int64
	Version            string
	InputSchemas       []*ArgsSchema  // 这个 Prompt 评估器需要哪些输入变量
	MessageList        []*Message     // 评分用的 Prompt 模板本身
	ModelConfig        *ModelConfig   // 用哪个模型来当裁判、温度多少
	Tools              []*Tool
	ReceiveChatHistory *bool
	ParseType          ParseType      // 怎么从模型输出里解析出分数
}
一句话理解 Prompt 评估器 它本质就是"另一个 Prompt 调用"——只不过这个 Prompt 的任务是"给上一次调用的结果打分",用的还是 Day 4-10 会讲的 Prompt 模块的能力(消息列表、模型配置、工具、解析类型全都照搬)。评测系统里"裁判"和"被评的模型"用的是同一套底层能力,只是角色不同。
L04

四种评估器类型,怎么选

Prompt(EvaluatorTypePrompt=1)

用大模型当裁判,最灵活,能评估"语义是否正确""是否礼貌"这类主观维度。同步执行,等模型返回。

Code(EvaluatorTypeCode=2)

跑一段确定性代码逻辑(比如正则匹配、JSON 字段比对)。速度快、成本低、结果稳定,适合客观维度。

CustomRPC(EvaluatorTypeCustomRPC=3)

调用你自己部署的评分服务(HTTP/RPC)。适合已有专有评分模型/业务规则的团队。

Agent(EvaluatorTypeAgent=4)

调用一个完整的 Agent 来评估,可能涉及多轮工具调用,耗时更长——所以是异步的。

⚠️ 关键细节:IsAsync() evaluator.go:67-69func (e *Evaluator) IsAsync() bool { return e.EvaluatorType == EvaluatorTypeAgent }——只有 Agent 类型评估器被标记为异步。这意味着实验(Day 13)在编排评分流程时,遇到 Agent 评估器不能"调用完立刻等结果",得走"提交任务 → 后台跑 → 回调/轮询拿结果"的路子,这也是为什么 Day 13 的实验大量依赖 RocketMQ 消息队列。
L05

Run vs Debug:两条调用评估器的路

backend/modules/evaluation/application/evaluator_app.go 里有两个容易混淆的入口:

RunEvaluator(第 1087 行)正式运行:先查评估器版本,鉴权(预置评估器免鉴权),调用 evaluatorService.RunEvaluator 落库一条 EvaluatorRecord
DebugEvaluator(第 1144 行)调试运行:多一步权益检查 CheckEvaluatorBenefit(用量/额度限制),CustomRPC/Agent 类型还要额外检查"内容可写"权限
💡 为什么要拆成两个接口而不是一个加参数? Run 是"正式评测流程里被实验调用"的路径,追求快、少做无关检查;Debug 是"用户在页面上试跑一下看效果对不对"的路径,需要做用量控制(避免有人无限调试消耗模型额度)、需要检查内容是否可编辑。把"生产路径"和"调试/预览路径"分成两个接口,是接口设计里常见的"读写分离思想"的变体——职责不同,检查也不同,硬塞一个接口反而互相牵制。

👶 小白问:预置评估器为什么免鉴权?

👨‍🏫 老师:Builtin(预置)评估器是官方内置的公共资源,所有空间都能用,不存在"谁有权限用"的问题——鉴权检查的是"这是不是你自己创建的私有评估器",公共资源自然跳过这一步(evaluator_app.go:1095-1106if !evaluatorDO.Builtin 就是这个判断)。

L06

真实路由与数据库表

评估器相关路由横跨 v1/v2/v3——正是"接口经历过多次演进"的痕迹(backend/api/handler/coze/loop/apis/evaluator_service.go):

POST  /api/evaluation/v2/evaluator/create        # 创建
PUT   /api/evaluation/v2/evaluator/update_draft   # 更新草稿
POST  /api/evaluation/v2/evaluator/:evaluator_id/submit  # 提交版本
POST  /api/evaluation/v2/evaluator/run            # 正式运行 → RunEvaluator
POST  /api/evaluation/v2/evaluator/debug          # 调试运行 → DebugEvaluator
POST  /api/evaluationv3/evaluators/list           # v3:新版列表接口
POST  /api/evaluation/v1/evaluators_versions/:evaluator_version_id/async_run   # 异步运行(Agent 类型走这里)
POST  /api/evaluation/v1/evaluators/async_debug   # 异步调试
注意到 v1 的 async_run / async_debug 专门为异步评估器(Agent 类型)开的路由,和 v2 的同步 run/debug 分开——接口层面就已经体现了 L04 讲的"同步 vs 异步"的区分。

核心表 .../mysql-init/init-sql/evaluator.sql(节选):

CREATE TABLE IF NOT EXISTS `evaluator` (
    `id`              bigint unsigned NOT NULL,
    `space_id`        bigint unsigned NOT NULL,
    `evaluator_type`  int unsigned    NOT NULL COMMENT '评估器类型',
    `draft_submitted` tinyint(1)      DEFAULT '0' COMMENT '草稿是否已提交',
    `latest_version`  varchar(128)    NOT NULL DEFAULT '',
    `builtin`         int unsigned    NOT NULL DEFAULT '2' COMMENT '是否预置,1:是;2:否',
    `box_type`        int unsigned    NOT NULL DEFAULT '1' COMMENT '黑白盒类型',
    ...
    KEY `idx_space_id_evaluator_type` (`space_id`, `evaluator_type`)
) ENGINE = InnoDB;
草稿 vs 已提交版本 draft_submitted 字段呼应了 Day 11 学的"未提交改动"概念——评估器同样支持"先编辑草稿、调试满意了再提交成正式版本",实验只能引用已提交的版本号,这样实验结果才可复现(引用的评分逻辑不会因为你后来改草稿而悄悄变化)。
L07

今日小结 + 动手 + 预告

🧠 今天你应该能回答

  • EvaluationSet 和 Dataset 是同一份代码吗?为什么类型名还叫 DatasetStatus?
  • 评估器本质是什么函数?四种类型分别怎么选?
  • 哪种类型的评估器是异步的?为什么?
  • Run 和 Debug 两个接口为什么要分开,各自多做了什么检查?
  • 为什么实验只能引用"已提交"的评估器版本?
🎵 记忆口诀题库分家不分心(EvaluationSet 独立建模)、裁判身体四选一(Prompt/Code/RPC/Agent)、正式调试两条路(Run/Debug)、异步只服 Agent」——这些概念会在 Day 13 被"实验"这个更大的编排层串联起来跑。

✋ 动手 5 分钟(可选)

# 1. 对比 EvaluationSet 和 Dataset 的字段有多相似
diff <(grep -A30 "type EvaluationSet struct" backend/modules/evaluation/domain/entity/evaluation_set.go) \
     <(grep -A30 "type Dataset struct" backend/modules/data/domain/dataset/entity/dataset.go)

# 2. 看四种评估器类型的完整枚举定义
grep -n "EvaluatorType" backend/modules/evaluation/domain/entity/evaluator.go | head -20

# 3. 找出所有评估器相关路由,感受 v1/v2/v3 的演进痕迹
grep -n "@router" backend/api/handler/coze/loop/apis/evaluator_service.go

# 4. 看 IsAsync 的真实判断逻辑
grep -n "IsAsync" -A3 backend/modules/evaluation/domain/entity/evaluator.go
明天预告 · Day 13:题库有了、裁判也有了,谁来把"每一条测试数据 × 被测对象 × 每个裁判"排列组合、跑完、汇总成一份实验报告?我们去读 experiment_app.go 和背后一串 RocketMQ 消费者,看"实验"这台大机器怎么转起来。
← 上一天 Day 11 下一天 · 实验主链路 →