跳到主要内容

评估器 — 四类裁判与 LLM 输出解析

本章讲什么: 上一章的 RunEvaluator 是个黑盒,本章打开它。评估器分四类,重点剖析最常用的 Prompt 评估器(用 LLM 当裁判)——它最大的难点不是调模型,而是"模型吐回来的那坨文本里,怎么稳稳抠出一个分数和理由"。

1. 先建直觉:评估器是什么

评估器(evaluator)= 一个"给(输入, 输出)打分"的函数。 输入是"这道题 + 考生的答案",输出是"一个分数 + 一段打分理由"。

难点在于"打分"这件事本身可以有很多实现方式,于是 CozeLoop 抽象出 4 类:

类型怎么打分典型场景同步/异步
Prompt把题目+答案塞进 prompt,让一个 LLM 输出分数(LLM-as-judge)"这个回答够礼貌吗"这类主观判断同步
Code跑一段用户写的 Python/JS,返回分数精确匹配、正则、JSON 字段校验同步
CustomRPC调用用户自己的打分服务已有评分系统、内场服务同步
Agent一个智能体多步推理后打分复杂、需工具的评测异步

枚举在 modules/evaluation/domain/entity/evaluator.go:51-58EvaluatorType);IsAsync():67)目前只有 Agent 类型为真。

2. 统一抽象:EvaluatorSourceService

四类评估器都实现同一个接口 EvaluatorSourceService(定义在 evaluator_source_service.go),关键方法是 Run / AsyncRun / Debug / PreHandle / ShouldIntercept。这样上层 RunEvaluator 完全不关心是哪一类——典型的策略模式按类型分发

Evaluator 实体本身是个"按类型转发"的壳:它内嵌 4 个可空的 version 指针(evaluator.go:23-26),几乎所有 getter/setter(GetVersionGetModelConfigValidateInput…)都是一个 switch e.EvaluatorType 转发到对应 version(如 evaluator.go:110 GetVersion)。这让"一个 Evaluator 在不同类型下行为不同"而调用方无需感知。

3. 重头戏:Prompt 评估器(LLM-as-judge)

3.1 它要解决的小问题

你想让 GPT 当裁判:给它"标准答案 + 实际答案",让它回 {"score": 0.8, "reason": "..."}。但 LLM 经常不老实:可能包一层 ```json、可能多说一段废话、可能 JSON 格式有瑕疵、可能 score 是字符串 "0.8"你不能假设它返回干净 JSON。

3.2 整体流程

EvaluatorSourcePromptServiceImpl.Runevaluator_source_prompt_impl.go:78)的主干:

// 示意,非源码
func Run(evaluator, input) (output, status, traceID) {
rootSpan := startTrace() // 评估器执行本身也被 trace(自观测)
evaluator.ValidateBaseInfo() // 校验配置
evaluator.ValidateInput(input) // 校验输入
renderTemplate(version, input) // 把 {{变量}} 替换成实际字段值
llmResp := chat(version) // 调 LLM
output := parseOutput(version, llmResp) // ★ 把回复抠成 score+reason
return output, Success
}

注意三个有意思的点:

  • 评估器执行自己也上报 trace:84 newEvaluatorSpan,root/model/parser 各一个 span)。也就是说评测系统是可观测系统的用户——它打的分在 trace 里能看到完整调用链。这是两子系统的第一个接触点。
  • 模板渲染renderTemplate:614)用 fasttemplate 把 prompt 里的 {{字段名}} 替换为输入字段,支持文本与多模态(processMessageContent:729)。
  • 两种解析模式:351):ParseTypeFunctionCall(让模型走 tool call,参数即结构化分数)或 ParseTypeContent(解析自由文本)。FunctionCall 更稳,但不是所有模型/配置都用。

3.3 精华:四级降级解析

这是本章最值得带走的东西。parseContentOutputevaluator_source_prompt_impl.go:373)面对"自由文本"模式,按优先级试 4 种策略,命中即停

下面这张图怎么读:从上到下是尝试顺序,越靠下越"将就",任何一级成功就返回。

LLM 原始文本

├─① parseDirectJSON ........ 直接当完整 JSON 解析(最理想)
│ └ 失败 ↓
├─② parseRepairedJSON ...... jsonrepair 修复后再解析(补逗号/引号等)
│ └ 失败 ↓
├─③ parseRegexExtractedJSON 正则从一坨文本里抠出 {…score…reason…} 片段,再解析
│ └ 失败 ↓
└─④ parseScoreWithRegex .... 正则只抠 score 数字;reason 尽力抠,抠不到就用整段文本当 reason

对应代码 :377-382 的策略数组与 :384-392 的"命中即停"循环。四个策略各自实现:

  • parseDirectJSON:400):sonic.Unmarshal 直接试。
  • parseRepairedJSON:419):先 jsonrepair.JSONRepair 再试——专治"差一个引号/逗号"。
  • parseRegexExtractedJSON:440):用一条支持 score/reason 任意顺序、score 可为字符串或数字的正则(jsonRe:371)把 JSON 片段从废话里捞出来,对每个片段先直接解析、不行再修复后解析。
  • parseScoreWithRegex:480):最兜底。先抠出 score 数字;reason 尽量抠(甚至处理"reason 值里有未转义双引号"的恶劣情况,:489-543),实在抠不到就把整段输出当 reason,保证至少有分数。

这套降级是 LLM-as-judge 生产可用的关键:模型越不听话,越往下兜底,最差也能拿到分数而不是整行评测失败。

FunctionCall 模式则走 parseFunctionCallOutput:575):从 tool call 的 arguments 里按 schema 提取 score/reason,同样先 jsonrepair

3.4 真实片段:命中即停的循环

// evaluator_source_prompt_impl.go:384-396(真实源码,节选)
for _, strategy := range strategies {
success, err := strategy(ctx, content, output)
if err != nil { return err }
if success { return nil } // 任一策略成功立即返回
}
// 全部失败才报错
return errorx.NewByCode(errno.InvalidOutputFromModelCode, ...)

一句话:容错解析 = 有序策略链 + 命中即停 + 最末兜底

4. Code 评估器:在沙箱里跑用户代码

Code 评估器让用户写一段 Python/JS 返回分数。安全是核心问题——绝不能在主进程里 eval 用户代码

CozeLoop 的做法(evaluator_source_code_impl.go):把代码交给 runtime.RunCode(调用点 :317),运行时是 HTTP-FaaS 模式——通过环境变量 COZE_LOOP_PYTHON_FAAS_URL / COZE_LOOP_JS_FAAS_URL 把代码 POST 给独立的 FaaS 服务执行(见 infra/runtime/README.md,路由由 http_faas_runtime.go 实现)。

沙箱配置(entity.SandboxConfig,README 示例)限定内存、超时、最大输出、是否联网:

// 示意,非源码:沙箱约束(语义取自 SandboxConfig)
SandboxConfig{
MemoryLimit: 256, // MB
TimeoutLimit: 30 * time.Second,
MaxOutputSize: 2 * 1024 * 1024, // 2MB
NetworkEnabled: false, // 默认禁网
}

创建评估器时还会做语法预校验:用 10s 超时跑一段语法检查代码(evaluator_source_code_impl.go:880 Python、:922 JS),提前拦住写错的脚本。

设计取舍:把执行外移到独立 FaaS 服务(进程/容器隔离),而不是在 Go 进程内嵌 Wasm/Pyodide——README 的"精简后架构"明确删掉了本地 deno/pyodide 实现,只保留 HTTP-FaaS。优点是隔离强、可独立扩缩;代价是多一跳网络、依赖部署额外服务(release 镜像里有 python-faas,见根 ARCHITECTURE.md)。

5. 评估器记录:打分的产物

每次打分产出一条 EvaluatorRecordevaluator_record.go:6),它把输入、输出、状态、trace_id 全记下来:

// 示意,非源码:抓关键字段
type EvaluatorRecord struct {
EvaluatorVersionID int64
TraceID string // 这次打分的 trace(可跳去可观测看链路)
EvaluatorInputData *EvaluatorInputData // 喂进去的字段
EvaluatorOutputData *EvaluatorOutputData // score + reason + usage + 可选 Correction
Status EvaluatorRunStatus
}

EvaluatorResult.Correctionevaluator_record.go:54 Correction)就是上一章"人工修正分优先"的载体;EvaluatorOutputData 还带 EvaluatorUsage(token 消耗)和 ExtraOutput(可附 HTML/Markdown 详情)。TraceID 字段是评测↔可观测的又一接触点。

6. 边界与坑

  • Prompt 评估器不支持 AsyncRun/AsyncDebug(evaluator_source_prompt_impl.go:678:682 直接返回错误)——异步只属于 Agent 类型。
  • 四级解析仍可能全败(模型输出完全不含数字),此时整条评测标记失败并记 InvalidOutputFromModelCode:394-396)。
  • Code 评估器强依赖外部 FaaS 服务可达;FaaS 不通则 Code 类评测不可用。

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

主题文件符号
评估器实体 / 类型转发modules/evaluation/domain/entity/evaluator.goEvaluatorEvaluatorTypeIsAsyncGetVersion
Prompt 评估器执行modules/evaluation/domain/service/evaluator_source_prompt_impl.goEvaluatorSourcePromptServiceImpl.RunchatrenderTemplate
四级降级解析同上parseContentOutputparseDirectJSONparseRepairedJSONparseRegexExtractedJSONparseScoreWithRegexparseFunctionCallOutput
Code 评估器modules/evaluation/domain/service/evaluator_source_code_impl.goEvaluatorSourceCodeServiceImpl.Run(调用 runtime.RunCode
沙箱运行时modules/evaluation/infra/runtime/http_faas_runtime.gofactory.gomanager.goSandboxConfig
评估器记录modules/evaluation/domain/entity/evaluator_record.goEvaluatorRecordEvaluatorOutputDataCorrection

下一章切换到可观测子系统:trace 怎么从 SDK 一路落到 ClickHouse。