跳到主要内容

数据截至 (上游 commit 7fb95fe9048f)

评估层:一个分数是怎么算出来的

30 秒导读: LangWatch 的评估层回答一个问题——「这条 LLM 输出好不好」。它把 71 个评估器收进一张目录表,用映射规则从 trace 里取出待评内容,用前置条件决定这次到底跑不跑,然后分派到三个执行后端之一(TypeScript 进程内 / Python langevals HTTP / Go DAG 引擎),最后把 score / passed / label / details 写进 ClickHouse、记一笔成本、必要时触发告警。

本章只讲「产生分数」的路径。评估是怎么被触发的(订阅者、调度)见 事件溯源内核trace 处理管线;把 agent 放进剧本跑的仿真见 Agent 仿真


1. 这是什么(零基础也能懂)

一句话定义: 评估层是一套「打分器的运行时」——把各种打分逻辑(规则、嵌入相似度、LLM 当裁判、第三方安全 API)统一成同一个接口,然后在合适的时机、用合适的数据把它们跑起来。

解决什么问题。 假设你上线了一个 RAG 客服机器人。你能看到每条对话的输入输出(那是 数据入口 的活),但你看不到:

  • 这次回答有没有脱离检索到的资料在瞎编?
  • 用户的问题到底解决了没有?
  • 输出里有没有混进信用卡号、API Key?

这些问题的答案都是一个分数。评估层就是产分数的地方。

它能做什么。

  • 70+ 个内置评估器,覆盖质量、RAG、安全、合规等类。
  • 在线监控:每条 trace 落地后自动打分,可采样、可加前置条件省钱。
  • 护栏:同一批评估器,在业务代码里同步调用、拿 passed 当放行开关。
  • 自定义:写一段 Python(code 评估器),或者拖一张有向图(workflow 评估器)。
  • 离线实验:拿数据集批量跑,对比不同 prompt / 模型的分数。

用起来什么样。 最小的一次调用就是一个 HTTP POST:

# 一次在线评估:让 LLM 当裁判判断输出是否答了输入的问题
curl -X POST "$LW/api/evaluations/langevals/llm_boolean/evaluate" \
-H "X-Auth-Token: $API_KEY" -H "Content-Type: application/json" \
-d '{"data": {"input": "退货政策是几天?", "output": "支持 7 天无理由退货。"}}'

# 返回
# {"status":"processed","passed":true,"score":1.0,"details":"...","cost":{...}}

路由定义在 platform/app/src/server/routes/evaluations-legacy.ts:483-504(/evaluations/:evaluator/evaluate)。

一句话直觉。 把评估器想成一个纯函数:

# 示意,非源码
def evaluate(data, settings) -> Result:
# data: {"input": ..., "output": ..., "contexts": [...], ...}
# settings:该评估器的可配置项(模型、阈值、开关)
return {"status": "processed", "score": 0.87, "passed": True, "details": "..."}

整章剩下的内容,都是在讲这个纯函数外面那一圈壳:目录、映射、闸门、分派、落库。


2. 顶层全景(它大概怎么转)

四种人会来要一个分数,但它们共用同一个执行核。

谁要分数 共用的四步 分数去哪
───────────── ──────────────────────── ─────────────
① 在线监控(Monitor) ─┐
② 代码护栏(as_guardrail)├─▶ ①选型号 → ②取数据 → ③闸门 → ④执行 ─┬─▶ ClickHouse
③ DAG 里的 evaluator 节点├─▶ 目录 mapping 前置条件 执行核 ├─▶ Cost 计费
④ 离线批量实验 ─┘ └─▶ 告警匹配

部件一句话职责:

部件干什么主文件
评估器目录把生成的 + 手写的评估器合并成一张型号表platform/app/src/server/evaluations/evaluators.ts
映射层决定 trace 的哪个字段喂给评估器的哪个入参platform/app/src/server/evaluations/evaluationMappings.tsthreadMappingResolver.ts
闸门采样 + 必填字段 + 用户前置条件,不满足就跳过platform/app/src/server/evaluations/preconditions.ts
执行核按类型分派到三个后端之一platform/app/src/server/evaluations/runEvaluation.tsrunEvaluation
Python 后端内置评估器的真实算法services/langevals/(独立 FastAPI 服务)
Go DAG 引擎跑「评估器组成的有向图」services/nlpgo/app/engine/
落库分数写 ClickHouse、成本写 Postgresevent-sourcing/pipelines/evaluation-processing/ 事件管线

主线走一遍(高层)。 一条 trace 处理完 → trace 管线的评估触发订阅者查出这个项目里所有「每条消息都跑」的监控器 → 逐个过闸门 → 发 executeEvaluation 命令(默认延迟 30 秒去重)→ worker 取出、按 mapping 组装数据 → 分派执行 → 结果发一个 EvaluationReported 事件 → 投影折叠成一行 ClickHouse 记录 → 自动化管线的告警匹配订阅者看要不要发通知。

已移除:旧 BullMQ 执行链。 旧版还有一条 background/workers/evaluationsWorker.ts 的 BullMQ worker 链路(独立的 evaluationsQueuerunEvaluationJob)。整个 background/ 目录已在重构中删除,评估的在线执行只剩事件溯源一条链——触发(evaluationTrigger.subscriber)、执行(ExecuteEvaluationCommandrunEvaluation)、落库(EvaluationRunFoldProjection)全部收进事件管线,本版不再有「新旧并存」的问题。


3. 评估器目录:一个分数的「型号表」

3.1 两份目录,合并成一份

目录不是手写死的,而是两份合并:

evaluators.generated.ts ← Python 端脚本生成(70 个)
│ services/langevals/scripts/generate_evaluators_ts.py
│ 遍历 langevals 包,把每个 pydantic Settings 直接渲染成 Zod
├── merge ──▶ evaluators.ts(所有消费方唯一 import 的门面)

evaluators.native.ts ← 手写(1 个,纯 TS 执行)

合并动作只有几行,但意图明确(platform/app/src/server/evaluations/evaluators.ts:21 起):

export const evaluatorsSchema = generatedEvaluatorsSchema.extend(
nativeEvaluatorsSchemaShape,
);
export const AVAILABLE_EVALUATORS = {
...GENERATED_AVAILABLE_EVALUATORS,
...NATIVE_EVALUATOR_DEFINITIONS,
} as unknown as { [K in EvaluatorTypes]: EvaluatorDefinition<K> };

好处写在文件头注释里:导入门面而不是生成文件,意味着原生评估器自动进入 EvaluatorTypes 联合类型,于是分类映射的穷尽检查会强制给它一个 UI 分类,设置表单也会自动按合并后的 Zod schema 渲染。

生成器本身在 services/langevals/scripts/generate_evaluators_ts.py,核心是 field_annotation_to_zod 把 pydantic 注解直译成 Zod:str→z.string()Literal[...]→z.union([z.literal(..)])Zod 是源头,TS 类型靠 z.infer 反推——生成文件头明说了这条方向。

3.2 EvaluatorDefinition 的承重字段

定义结构在 evaluations/evaluators.ts:

字段含义谁在用
category多选一:quality / rag / safety / policy / custom / similarity / otherUI 分组;与 Python 端 EvalCategories 对齐(services/langevals/langevals_core/langevals_core/base_evaluator.py)
isGuardrail这个评估器适合当同步护栏吗护栏 UI 筛选;离线实验里用来剥掉无意义的 0/1 分(见 §11)
requiredFields缺了就没法跑的入参闸门第二道(checkEvaluatorRequiredFields);REST 入口的 400 校验
optionalFields有则更好的入参与 required 一起构成「允许透传」的字段白名单
settings每项带 description + default渲染设置表单;getEvaluatorDefaultSettings 取默认值
resultscore/passed/label 各自的人话解释UI 上给数字配文案

一个细节:getEvaluatorDefaultSettings(platform/app/src/server/evaluations/getEvaluator.ts:38)不直接用 Zod 里烤死的默认模型modelembeddings_model 两个键会优先取调用方传入的级联解析值,因为生成的默认值是字面量,不反映这个项目/团队实际配了什么。

3.3 型号的家族分布

按前缀统计 AVAILABLE_EVALUATORS(evaluators.generated.ts 70 项 + 原生 1 项):

家族数量靠什么算代表
langevals/*32自研:规则、嵌入余弦、LLM 当裁判exact_matchllm_booleansimilaritypairwise_compare
ragas/*26封装 ragas 库的 RAG 指标faithfulnesscontext_precisionresponse_relevancy
azure/*6调 Azure Content Safety APIcontent_safetyjailbreakprompt_injection
openai/*2调 OpenAI Moderation APIopenai/moderation
presidio/*2本地 spacy + Presidio 识别 PIIpresidio/pii_detection
lingua/*2本地统计语言识别模型lingua/language_detection
langwatch/*(原生 TS)1Node 进程内正则规则api_keys_and_secrets_detection(evaluations/evaluators.native.ts:14)

标了 isGuardrail: true 的有 15 个(生成侧 14 + 原生 1)。

3.4 除了内置,还有三种「非目录」评估器

目录之外还有三类,靠 checkType 的前缀路由:

前缀是什么执行在哪
custom/<workflowId>用户在 Studio 里画的评估工作流Go DAG 引擎(见 §10)
code/<evaluatorId>用户写的一段 Python 类Go DAG 引擎(临时三节点 DSL)
workflow存在 Evaluator 记录上、type = "workflow"custom/,workflowId 从记录里取

前缀判断的入口都很朴素,例如 platform/app/src/server/evaluators/codeEvaluator.ts:75-78:CODE_EVALUATOR_CHECK_PREFIX = "code/",isCodeEvaluatorCheckType 判前缀、codeEvaluatorIdFromCheckType 切后半段。


4. 数据从哪来:mapping 与 thread

4.1 默认映射:五个字段

评估器要的是 input / output / contexts / expected_output 这样的干净入参,而 trace 是一棵 span 树。中间靠一份 MappingState。默认那份在 platform/app/src/server/evaluations/evaluationMappings.ts:3:

评估器入参默认取自
spanstrace 的 spans
inputtrace.input
outputtrace.output
contextsRAG span 的 contexts
expected_outputmetadata 里 key 为 expected_output 的项

同文件的 migrateLegacyMappings(:39)负责把老格式("trace.first_rag_context" 这类字符串)翻译成新结构。

4.2 trace 级 vs thread 级

监控器有一个 level 字段("trace" | "thread",platform/app/prisma/schema.prisma:606-607 的注释)。thread 级评估要的是整段会话而不是单条消息,于是需要一次额外的「拉出这个 thread 的所有 trace」。

这套逻辑被多个调用方共用,所以抽成了 platform/app/src/server/evaluations/threadMappingResolver.ts,并且用回调注入 I/O:

  • hasThreadMappings(:21)——判断映射里有没有 type: "thread" 的项。注释点明它的 ?.mapping 判空是在防历史脏数据。
  • resolveThreadMappingsIntoData(:44)——把 thread 字段解析进已有的 data;拿不到 thread_id 就填空字符串而不是抛错。

混合场景也支持:一个 trace 级评估里混进了 thread 类型的映射项,执行侧会先做常规 trace 映射,再补跑一次 thread 解析(platform/app/src/server/app-layer/evaluations/evaluation-execution.service.ts:276 附近的注释)。

4.3 发给 langevals 时的「规范 6 字段 + 白名单透传」

发往 Python 后端的 body 有个讲究(platform/app/src/server/evaluations/runEvaluation.ts:514-529):6 个规范字段被强制类型转换,其余字段只有列在该评估器 required/optional 里才放行

const canonicalKeys = new Set([
"input","output","contexts","expected_contexts","expected_output","conversation",
]);
const allowedExtras = new Set([
...(evaluator.requiredFields ?? []), ...(evaluator.optionalFields ?? []),
]);

注释解释了为什么要这么绕:pairwise_compare 这类评估器有 candidate_a_id / candidate_a_output 这种非规范字段,老的「只转发规范 6 项」会把它们抹掉;但如果无脑全透传,某个非 pairwise 评估器上一个多余的映射输出就会让 Python 侧的严格 pydantic 模型返回 422。白名单是逐评估器 opt-in,不是万能通道。


5. 前置条件:省钱的关键

这是整章最值钱的一节。LLM 当裁判的评估器每次都要真花钱调模型,所以「决定不跑」比「跑得快」更重要。

5.1 三道闸,依次收窄

一条 trace 到达

①采样 Math.random() <= monitor.sample ? ── 否 ─▶ 不排队
│是
②必填 评估器 requiredFields 都有值吗? ── 否 ─▶ 不排队
│是 (contexts 要有 RAG span;expected_output 要有值)
③前置 用户配的 preconditions 全过吗? ── 否 ─▶ 不排队
│是 (AND 语义,一条不过就整体不过)

发 executeEvaluation 命令

三道闸现在统一实现在事件溯源命令侧(platform/app/src/server/event-sourcing/pipelines/evaluation-processing/commands/executeEvaluation.command.ts:必填检查 :397checkEvaluatorRequiredFields、前置检查 :435evaluatePreconditions),顺序理由写在注释里:先查必填字段,再查用户前置条件,因为前者不需要额外 I/O。

5.2 前置条件的规则表

CheckPrecondition 的形状在 platform/app/src/server/evaluations/types.ts:{ field, rule, value, key?, subkey? }。四条规则的语义在 evaluations/preconditions.ts(分派在 :75-81,各规则注释 :91/:113/:135/:157):

规则字符串语义数组语义值缺失时
is大小写不敏感全等任一元素相等不过
contains大小写不敏感子串任一元素含子串不过
not_containscontains 取反无元素含子串(没东西可含)
matches_regex正则 testJSON.stringify 再 test不过

正则那条有一道防线:safe(conditionValue)(:170)先跑 safe-regex2 拦灾难性回溯,不安全就当作无效正则返回 false 并打日志。

字段值的解析走一张注册表 PRECONDITION_FIELD_MATCHERS(platform/app/src/server/filters/precondition-matchers.ts:73);匹配不到的字段直接返回 null,由规则层决定过不过。

5.3 写入时校验:字段和规则要配对

光运行时判断不够,配置写进库之前还要交叉校验(platform/app/src/server/evaluations/preconditionValidation.ts:19):每个字段有一张允许规则表 PRECONDITION_ALLOWED_RULES,规则不在表里就报错;metadata.value 这类嵌套字段必须带 key。校验以 Zod 的 superRefine 形式挂在 schema 上。

5.4 两个 builder,一个数据结构

前置条件判断需要一份归一化的 trace 数据,但数据来源有两种,于是有两个 builder,产出同一个 PreconditionTraceData:

builder输入来源位置
buildPreconditionTraceDataFromTrace读取侧的 trace + spanspreconditions.ts:286
buildPreconditionTraceDataFromCommand事件溯源命令的 payload + spanspreconditions.ts:342

事件字段是按需拉取的:preconditionsNeedEvents(:273)只在有 events. 开头的字段时才触发一次额外查询(executeEvaluation.command.ts:418)——又一处省钱设计。


6. 执行核:runEvaluation 的分派

所有路径最终都汇到一个函数:platform/app/src/server/evaluations/runEvaluation.ts:357runEvaluation。它是整个评估层的十字路口。

runEvaluation({ evaluatorType, data, settings, ... })

├─ data.type === "custom" ?
│ ├─ "code/<id>" ─▶ runCodeEvaluator ─▶ nlpgo /studio/execute_sync
│ └─ "custom/<wfId>" ─▶ customEvaluation ─▶ nlpgo(已发布 workflow)
│ 或 "workflow"

└─ 内置类型
├─ isNativeEvaluatorType ─▶ executeNativeEvaluation(Node 进程内)
└─ 其余 ─▶ stagedLangevalsFetch ─▶ langevals HTTP

两条都汇入 ─────────▶ augmentEvaluationResult(隐私回补)

6.1 原生分支:为什么要有进程内评估器

只有一个原生评估器:langwatch/api_keys_and_secrets_detection。它复用了脱敏引擎的规则去扫每一个映射进来的字符串(platform/app/src/server/evaluations/native/apiKeysAndSecretsDetection.ts:6 的注释:"Runs the same …"):没命中就 score: 0, passed: true,命中就按规则 ID 汇总数量写进 details

调度器 executeNativeEvaluation(platform/app/src/server/evaluations/native/registry.ts:19)刻意做了两件事:镜像 HTTP 路径的耗时/状态指标,以及永不抛异常——执行器出错就变成 error 结果,让调用方按普通失败处理。

6.2 隐私回补:让脱敏不至于把泄漏洗白

这是全章最精巧的一处。augmentEvaluationResult(platform/app/src/server/evaluations/native/registry.ts:95)解决一个反直觉的问题:

摄入阶段已经把密钥替换成 [SECRET] 了,内容检测器扫过去当然一个都扫不到,于是报告「全绿」。隐私保护把安全风险洗白了。

做法是把脱敏留下的标记数回补成检测数:

情况处理
文本里有 [SECRET] 标记密钥检测器把它计回一次命中
文本里有 PII 类型标记([PHONE_NUMBER] 等)PII 检测器计回,但只算设置里仍勾选的实体
所有映射字段都空、且 trace 丢弃过内容类别直接判失败——「无法排除泄漏」
结果本来就是 error原样不动,运维故障必须保持可见

回补后的结果一律 passed: false,并在 details 里追加人话说明。适用对象写在 AUGMENT_KIND 表里(registry.ts:60):远端 Presidio 检测器 + 原生密钥检测器。

6.3 langevals 分支:环境变量、大包体、重试

发往 Python 之前有几件事要办(runEvaluation.ts):

  1. Azure 硬切换(:432-436):Azure 系评估器不再读 process.env,必须从项目的 azure_safety 模型供应商凭据里取;取不到就返回 skipped 加一句可自助修复的提示。
  2. 模型环境注入:settings 里带 model / embeddings_model 的,用 setupModelEnv 解析成一组环境变量随请求下发。
  3. 其余评估器:按 definition 的 envVars 从进程环境里挑。

包体走 stagedLangevalsFetch(platform/app/src/server/langevals/stagedFetch.ts:80)。它的存在理由很具体:SaaS 上 langevals 跑在 AWS Lambda 后面,同步请求体有硬上限。策略分三段:

包体大小行为
> 该 kind 的硬上限发网络请求之前就抛 PayloadTooLargeError(:95)
≤ staging 阈值(或未配置阈值)直接内联 POST
介于两者之间上传 S3 → 用预签名 URL 走 X-Payload-S3-URL 头(:151)→ finally 里尽力删除对象(:161)

删除那一步的注释说明了动机:暂存的 body 里带着客户 trace 数据和供应商凭据,不能留;桶生命周期规则是崩溃场景的兜底。


7. Python 后端 langevals:契约与动态路由

services/langevals/ 是个独立的 FastAPI 服务(可打成 Lambda),仓库里以工作区形式组织:langevals_core/ 定契约,evaluators/<family>/ 放实现。

7.1 结果契约:三选一的 union

services/langevals/langevals_core/langevals_core/base_evaluator.py 定义了整个平台共用的结果形状:

关键字段
EvaluationResult140score / passed / label / details / cost,都可空
EvaluationResultSkipped158只有 details
EvaluationResultError173error_type / details / traceback

TypeScript 侧的 singleEvaluationResultSchema(生成文件里)是它的镜像。这是跨语言的承重接口:Go 引擎的 evaluatorblock 结果也照它对齐。

7.2 入参白名单:靠 __init_subclass__ 强制

EvaluatorEntry(:71)只允许六个字段:conversation / input / output / contexts / expected_output / expected_contexts。强制手段是元类钩子 __init_subclass__(:85-118)——子类多声明一个字段就直接 TypeError,错误消息还写明「其他配置应该放 TSettings」。

例外通道是显式的:allow_extra=True,错误消息限定「只给形状确实不符合单输出契约的评估器(e.g. multi-candidate)」。这正好呼应了 §4.3 里 TS 侧那套白名单透传。

7.3 基类的执行语义

方法行为
evaluate抽象方法,子类实现真算法
_evaluate_entry用 tenacity 包一层重试,任何异常都转成 EvaluationResultError,不外抛
evaluate_batch线程池并行,结果槽位预填 skipped,支持取消
get_env(:247)变量不在 env_vars / 模型变量白名单里就拒绝访问

set_model_envs(:267)还处理了一件脏活:litellm 和 Azure SDK 用不同的变量名,这里做双向别名互填(AZURE_OPENAI_API_KEYAZURE_API_KEY)。

7.4 路由是运行时生成的

services/langevals/langevals/server.py:239create_evaluator_routes 对每个评估器类动态造一条路由:

  • 路径 /{module_name}/{evaluator_name}/evaluate——正好对上 TS 侧拼的 ${LANGEVALS_ENDPOINT}/${evaluatorType}/evaluate
  • 请求模型用该评估器自己的 entry_type / settings_type,并且 extra="forbid"——多余字段直接 422。
  • 处理函数每次都 清空并还原 os.environ(:134-144,启动时的快照在 :53),避免上一次请求注入的凭据污染下一次。

启动时 load_evaluator_packages() 扫描已安装的 langevals_* 包;--only <families> 参数可以只加载部分家族,对应「每个评估器家族单独打一个 Lambda」的部署模型。

7.5 几个代表性实现,看算法差异

评估器文件怎么产分数
langevals/exact_matchservices/langevals/evaluators/langevals/…/exact_match.py先按 float 比,再按 JS 宽松相等比,最后才是 trim/去标点/大小写的文本比
langevals/similarity…/similarity.pylitellm 取 embedding → 余弦相似度 → 与阈值比;超 token 上限返回 skipped
langevals/llm_boolean…/llm_boolean.pyLLM 当裁判,is_guardrail = True,超 token 上限 skipped,用 completion_cost 回填 cost
presidio/pii_detectionservices/langevals/evaluators/presidio/…/pii_detection.py本地 spacy 模型 + Presidio;preload() 多级回退加载模型
ragas/faithfulnessservices/langevals/evaluators/ragas/…/faithfulness.py封装 ragas 的 Faithfulness 指标,autodetect_dont_know 避免「我不知道」被判不忠实

8. 在线执行链路:事件溯源命令

ExecuteEvaluationCommand(platform/app/src/server/event-sourcing/pipelines/evaluation-processing/commands/executeEvaluation.command.ts:255)把整套流程写成一个命令处理器,产出恰好一个 EvaluationReported 事件。

它的 makeJobId(:281)是去重的核心:无 thread 防抖时 exec:<tenant>:<traceId>:<evaluatorId>;threadIdleTimeout > 0 且有 threadId 时换成 thread 维度——同一个 thread 的新消息会顶掉旧任务,直到会话安静下来才真跑(触发侧的延迟/去重配置见 platform/app/src/server/event-sourcing/pipelines/trace-processing/subscribers/evaluationTrigger.subscriber.ts:343-371)。

命令注册在 platform/app/src/server/event-sourcing/pipelines/evaluation-processing/pipeline.ts:128-135:serializeByAggregate: true、延迟 30 秒、带去重契约。

跳过语义分两层(executeEvaluation.command.ts:344:367),这是个容易踩的设计点:

  • 配置类跳过(监控器不存在、Azure 未配置)——发事件,让用户在 UI 上看到原因。
  • 数据类跳过(没有 thread_id、trace 报错无 IO)——不发事件,直接返回空数组。注释解释:批量重跑一堆不可评估的 trace 会产生成千上万条结果,每条都要付一次昂贵的投影读取。

投影侧 EvaluationRunFoldProjection(platform/app/src/server/event-sourcing/pipelines/evaluation-processing/projections/evaluationRun.foldProjection.ts:52)折叠事件流。handleEvaluationReported(:158)一次性写满所有字段,并把 startedAtcompletedAt 都设成同一个 occurredAt——因为 reported 是「一步到位」的终态事件。落库通过 EvaluationRunStore(projections/evaluationRun.store.ts:11)转交 repository,保留期取自租户策略。

告警侧的匹配不再是评估管线的反应器,而是 automations 管线的订阅者:evaluationAlertTriggerMatch.subscriber(platform/app/src/server/event-sourcing/pipelines/automations/subscribers/evaluationAlertTriggerMatch.subscriber.ts,在 pipelineRegistry.ts:86/:557 接线)处理「带评估条件的告警触发器」——跨管线读 trace 投影、加载该 trace 的全部评估、匹配过滤器、派发动作。旧的 evaluationEsSync ES 同步反应器已随 Elasticsearch 依赖一起整体删除(评估读取已全部走 ClickHouse,见 §9.4)。


9. 监控器与护栏:同一件事的两个名字

9.1 产品定义

FEATURE_MAP.md 写得直白:护栏 = 用代码访问的在线评估(as_guardrail=True),不是一个独立概念。落到代码,这句话是准确的——护栏和监控器共用 handleEvaluatorCall,唯一区别是最后一个布尔参数:

// platform/app/src/server/routes/evaluations-legacy.ts:505 / :544 起
// /api/evaluations/:evaluator/evaluate → handleEvaluatorCall(c, slug, false)
// /api/guardrails/:evaluator/evaluate → handleEvaluatorCall(c, slug, true)

isGuardrail 最终是 as_guardrail || params.as_guardrail(evaluations-legacy.ts:1256),即路由和 body 任一置真即可。

9.2 护栏的「失败开放」语义

护栏挡在业务主链路上,所以它自己坏掉时不能把用户请求也带塌。路由的文档注释(:550)把契约写成一句话:每个结果都带 passed,评估器 skip 或 fail 都不拦请求。结果整形阶段(:1520-1535)体现了这条:

结果状态普通评估返回护栏返回
processed原样原样,但 passed 缺失时补 true(:1535)
skipped{status: "skipped", details}额外加 passed: true(:1520)
error{status:"error", …}额外加 passed: true(:1531)
监控器被禁用——直接 {status:"skipped", passed:true}(:1263)

成本记账也分家:护栏记 CostType.GUARDRAIL,其余记 CostType.TRACE_CHECK(:1445)。

9.3 监控器数据模型

Monitor 表(platform/app/prisma/schema.prisma:589 起)是在线评估的配置载体:

字段作用
checkType评估器类型(可以是 custom/ code/ 前缀)
executionModeON_MESSAGE / AS_GUARDRAIL / MANUALLY
preconditions / sample三道闸的配置源
mappings§4 的映射状态
level + threadIdleTimeouttrace 级还是 thread 级、会话防抖秒数

服务层很薄(platform/app/src/server/app-layer/monitors/monitor.service.ts:17getEnabledOnMessageMonitors),转发给 repository。

AI 网关那侧还有一条额外约束:虚拟密钥上的护栏要求项目里至少有一个 executionMode = AS_GUARDRAIL 的启用监控器,否则报 evaluator_not_as_guardrail(platform/app/src/server/gateway/guardrail.service.ts:196)。网关细节见 AI 网关

9.4 读取侧

分数写完之后怎么读?EvaluationService(platform/app/src/server/evaluations/evaluation.service.ts:39)是个门面,文件头注释写明它只包 ClickHouse 仓库(TraceEvaluationsClickHouseRepository),旧的 Elasticsearch 实现已删干净。inputs 这类重字段由 TraceService.getEvaluationInputs(platform/app/src/server/traces/trace.service.ts:645)按需懒加载——列表查询不带它,展开单条时才拉。


10. 执行 DAG 的引擎:Go 版 nlpgo

自定义评估器(custom/ / code/ / workflow)不是一个函数,而是一张图。跑图的是 Go 服务 services/nlpgo

10.1 前端 DSL:图长什么样

platform/app/src/optimization_studio/types/dsl.ts 定义了图的类型。要点:

概念说明
ComponentTypeentry / end / signature / code / retriever / prompting_technique / custom / evaluator / http / agent / if_else 等(:56 起)
ExecutionStatusidle / waiting / running / success / error / skipped(分支没走到,零成本,:46-54)
Workflowspec_version 的完整图(:352-376)
ServerWorkflow服务端形态,多带 api_key / project_id / secrets(:436)

节点默认形态在 platform/app/src/optimization_studio/registry.ts。注意 ALLOWED_EVALUATORS(:106)——Studio 画布上只开放一部分内置评估器,不是目录全集。

优化器配置在 platform/app/src/optimization_studio/types/optimizers.ts:13 起:三个 DSPy 优化器(MIPROv2、MIPROv2ZeroShot、BootstrapFewShotWithRandomSearch),各自带最小训练集要求。

10.2 代码评估器:临时三节点图

code/ 类型没有 Workflow 记录,而是每次执行现造一张图(platform/app/src/server/evaluators/codeEvaluator.ts:98buildCodeEvaluatorDsl):

entry ──(用户声明的输入)──▶ code_evaluator ──(固定四输出)──▶ end
cls: "Code" behave_as: "evaluator"
parameters: [code] inputs: details/passed/score/label

两个细节写在注释里,都很有意思:

  1. code 节点故意声明 outputs: []。引擎只校验「声明了的输出必须出现在返回字典里」,而评估器允许只返回子集(比如只返回 passed),声明了反而会炸。
  2. end 节点扛住完整契约,四个字段固定顺序 details / passed / score / label(说理在前、结论在后,CODE_EVALUATOR_OUTPUT_FIELDS:47),与 UI 面板保持一致。

执行走 runCodeEvaluator(platform/app/src/server/evaluators/runCodeEvaluator.ts:35):组装 execute_flow 事件 → 注入凭据 → POST nlpgo /studio/execute_sync。返回值再做一次标量强制转换(coerceResultScalars,:13),把字符串 "true" / "0.8" 还原成 bool / float。

10.3 planner:把图切成层

services/nlpgo/app/engine/planner/planner.goNew 按固定顺序校验并分层:

① 重复节点 id
② 退役 / 不支持的节点类型(retiredKinds 在 :84;不支持时报
UnsupportedNodeKindError,:66,TS 侧据此回落 Python)
③ 边的端点都存在
④ 环检测(DFS 三色染色)
⑤ 可达性裁剪:从 Entry BFS;"运行到此为止" 再叠一次反向 DFS 求交
⑥ 分层拓扑排序(Kahn,按输入顺序保持稳定)

第 ⑤ 步的动机注释说得清楚:Studio 画布容忍作者还没接线的孤立节点,Python 的行为是跳过它们,Go 要保持一致。

retiredKinds 当前只剩 retriever。注释记录了 custom 曾被误列为退役、后来因为 Studio 的子工作流拖拽功能而恢复的历史。

10.4 engine:逐层并行执行

services/nlpgo/app/engine/engine.go:215Execute:

  • TraceID 为空就现生成一个——否则下游 runEvaluator 回调 LangWatch 时拿不到同一个 trace id,评估的 span 会变成孤儿 trace。
  • req.NodeID 非空表示 Studio 的「单组件手动执行」,只跑那一个节点,不遍历全部层。
  • 主循环逐层跑,任一层出错就返回。

runLayer(:263)每层开 goroutine 并发,每个节点三步:

  1. shouldSkip(runState.shouldSkip,:273)—— 分支门控:if/else 的 outputs.true / outputs.false 入边是「门」,上游 skipped/error 的数据边视为失活,全部入边失活才跳过
  2. resolveInputs(:280)—— 按边的句柄搬运上游输出。
  3. dispatch(:354)—— 按节点类型分派;prompting_technique 是装饰器,返回空 map。

nodeEmitsSpan 让 entry / end / prompting_technique 不发 span,保持 trace 树干净(services/nlpgo/app/engine/tracing.go:58)。

10.5 关键回环:evaluator 节点打回 TS

runEvaluator(engine.go:963)不在 Go 里算分数,而是 HTTP 打回 LangWatch 应用:

TS app ──execute_flow──▶ nlpgo engine ──▶ planner 分层 ──▶ evaluator 节点

POST X-Auth-Token

TS: /api/evaluations/<slug>/evaluate ──▶ runEvaluation ──▶ langevals / 原生 TS

services/nlpgo/app/engine/blocks/evaluatorblock/executor.go 的包注释解释了为什么不在进程内算:LangWatch 应用在另一个 pod,它才拥有评估器分派逻辑(保存的评估器、监控器、workflow 评估器、langevals 路由)。

两处细节值得记:

  • 信封字段永远透传。 评估器节点可以只声明 passed,但 status / details / cost 这三个「评估元数据」无论如何都会出现在输出里。注释点名这曾是 Python→Go 迁移的静默回归:过滤掉 details 会让评估器的推理理由在所有地方消失。
  • 入参强制转换只对 langevals/* 生效(evaluatorblock/coerce.go)。这些评估器的 pydantic schema 把每个字段声明成 str,收到裸 bool 会拒绝;而保存的 / workflow 评估器带自己的类型定义,强转反而会破坏校验。转换规则与 Python 的 autoparse_field_value 对齐。

10.6 流式:与 Python 对齐的 SSE 帧

services/nlpgo/app/engine/stream.goExecuteStream 输出的 StreamEvent 刻意镜像 Python 的 StudioServerEvent 判别联合。里面有一条容易忽视的对应关系:

请求类型必须发的工作流级事件Studio 里更新哪一槽
execute_flowexecution_state_changeworkflow.state.execution
execute_evaluationevaluation_state_changeworkflow.state.evaluation
execute_component不发工作流级事件只靠 component_state_change

发错族的后果很具体:引擎在网线上跑成功了,Studio 却弹「启动工作流执行超时」的 toast。

心跳(heartbeat,:223)按固定间隔发 is_alive_response 撑过中间代理超时。emit 对两类帧区别对待:心跳可丢,状态变更不可丢但可被 ctx 取消——避免消费者停摆时 goroutine 永久阻塞(:236 注释)。

10.7 批量上报:让实验页有数据

execute_evaluation 不只是跑一遍图,还要遍历数据集、逐行跑、把结果分批 POST 回 LangWatch 的批量日志路由。这块在 services/nlpgo/app/engine/evaluation.go:

  • newEvaluationReporter(:120)—— 批量上报器。
  • recordEntry(:150)—— 一行拆两处:dataset 记预测输出/耗时/成本,evaluations 记每个评估器节点的分数。
  • flush —— 无论 POST 成功与否都清空缓冲,重试不重复计数。
  • 批次体带 experiment_id(有则用),否则退回 experiment_slug = workflow_id 的双键形状,好让现有接收路由不用改。

行级 trace id 用 crypto 随机生成(newRowTraceID,:28);训练/测试划分用确定性种子(newDeterministicRand,:43),对齐 sklearn 的 random_state,保证同种子可复跑。

10.8 密钥处理

services/nlpgo/app/engine/secrets.go 三件事都做了:

函数作用
resolveSecretRefs(:27)构造请求时(不是解析时)替换 {{ secrets.NAME }},于是轮换后的值下次执行即生效;找不到的引用原样保留,让配置错误显形而不是发一个空凭据
redactSecrets(:54)出错时把已解析的密钥从错误字符串里抹掉——Go 的传输错误常把完整 URL(含 query 里的 token)嵌进 message
resolveAuthSecrets只解析凭据字段;api_key头名不是密钥,不动

代码块执行则是隔离子进程:services/nlpgo/app/engine/blocks/codeblock/runner.py 内嵌进二进制,通过 stdin 传代码和输入,并注入一个轻量 dspy 桩避免加载真包。


11. 离线实验入口:experiments-v3

在线是「一条 trace 一个分」,离线是「一个数据集 × 一组配置 = 一张分数表」。入口在 platform/app/src/server/routes/experiments-v3.ts(路由清单写在文件头 :5-10):

路由用途
POST /api/experiments/execute(:215)SSE 流式执行(UI 用)
POST /api/experiments/abort中止
POST /api/experiments/:slug/run按 slug 执行(CI/CD 用)
GET /api/experiments/runs / /runs/:runId / /runs/:runId/results列运行 / 轮询状态 / 逐行结果

执行侧在 platform/app/src/server/experiments-v3/execution/:generateCells(orchestrator.ts:156)按 scope 生成「行 × 目标」单元格,executeCell(:1181)逐个执行,runOrchestrator 带信号量限流(默认并发 10,EVAL_V3_CONCURRENCY 可调,:81)。

一个小而聪明的处理是 evaluatorScoreFilter.ts:某些评估器的 score 只会是 0 或 1,对聚合毫无意义。shouldStripScore(:34)据两条规则剥掉:显式列在 BINARY_ONLY_EVALUATORS,或者目录里 isGuardrail: true。这就是 §3.2 里 isGuardrail 的第二个用途。

Studio 侧还有一条 REST 入口:WorkflowEvaluationService.triggerEvaluation(platform/app/src/server/workflows/workflowEvaluation.service.ts:67)——CI 流水线调它,内部构造和「点 Evaluate 按钮」完全一样的 execute_evaluation 事件。版本选择规则是「最新手动提交优先,没有则回退最新自动保存」。

工作流保存时还会顺手算一次 agent 的字段映射(platform/app/src/server/workflows/auto-compute-agent-mappings.ts:98),文件头注释写明这是尽力而为、失败只记日志、绝不阻塞保存。


12. 巧妙之处(可借鉴的技术)

  1. 隐私脱敏与安全检测的冲突,靠「回补」而不是「例外」解决。 不去给检测器开脱敏白名单(那等于打洞),而是把脱敏留下的标记数当作检测数加回去。platform/app/src/server/evaluations/native/registry.ts:95

  2. 两层跳过语义。 配置类跳过要留痕给用户看,数据类跳过要静默省钱。同一个 skipped 字符串,处理方式完全相反。platform/app/src/server/event-sourcing/pipelines/evaluation-processing/commands/executeEvaluation.command.ts:344:367

  3. 按需 I/O。 事件字段只在前置条件真的引用了 events.* 时才多查一次(platform/app/src/server/evaluations/preconditions.ts:273);评估的 inputs 重字段列表查询不带、展开才拉(platform/app/src/server/traces/trace.service.ts:645)。

  4. 白名单式的字段透传。 既不是「只转发规范 6 项」(会丢 pairwise 的字段),也不是「全透传」(会 422),而是按每个评估器声明的 required + optional 放行。platform/app/src/server/evaluations/runEvaluation.ts:514-529

  5. 大包体走 S3 暂存,并在 finally 删除。 上限检查在任何网络调用之前做,失败信息可行动;暂存对象因为含凭据必须删,桶生命周期只当崩溃兜底。platform/app/src/server/langevals/stagedFetch.ts:95:161

  6. 信封与数据分离。 DAG 里评估器节点声明了什么输出是「数据契约」,而 status/details/cost 是「评估元数据」,永远透传。混淆两者曾导致评估理由在全平台消失。services/nlpgo/app/engine/engine.go:963 起的 runEvaluator

  7. 入度只数会执行的父节点。 拓扑分层遇上可达性裁剪时的经典陷阱,注释和代码都处理了。services/nlpgo/app/engine/planner/planner.golayerize


13. 边界与局限

  • Studio 画布只开放一部分评估器,不是目录里的 71 个(platform/app/src/optimization_studio/registry.ts:106)。目录全集只在监控器/护栏路径可用。
  • 告警匹配搬了家。 评估条件的告警触发现在归 automations 管线的订阅者管,读评估章节时别再往 evaluation-processing/reactors/ 找(那个目录已不存在,只剩 subscribers)。
  • Go 引擎不是全量替代:planner 遇到不支持的节点类型会报 UnsupportedNodeKindError(services/nlpgo/app/engine/planner/planner.go:66),注释说明 TS 侧据此回落 Python;Python 端旧引擎在本次克隆里未随 Go 引擎一起提供源码,回落路径的具体实现代码里看不出来。
  • 护栏是失败开放的:评估器挂掉、被跳过、监控器被禁用,统统返回 passed: true(platform/app/src/server/routes/evaluations-legacy.ts:1519-1534)。这是刻意的可用性取舍,但意味着护栏不能作为唯一的安全边界
  • 成本只对返回了 cost 的评估器计账。规则类、原生类评估器不产生成本记录。

14. 代码地图(导航索引)

主题文件路径(相对克隆根)关键符号
评估器目录门面platform/app/src/server/evaluations/evaluators.tsAVAILABLE_EVALUATORSEvaluatorDefinitionevaluatorsSchema
原生评估器定义platform/app/src/server/evaluations/evaluators.native.tsNATIVE_EVALUATOR_DEFINITIONSisNativeEvaluatorType
原生执行 + 隐私回补platform/app/src/server/evaluations/native/registry.tsexecuteNativeEvaluationaugmentEvaluationResultAUGMENT_KIND
密钥检测器platform/app/src/server/evaluations/native/apiKeysAndSecretsDetection.ts密钥规则扫描
默认设置解析platform/app/src/server/evaluations/getEvaluator.tsgetEvaluatorDefinitionsgetEvaluatorDefaultSettings
字段映射platform/app/src/server/evaluations/evaluationMappings.tsDEFAULT_MAPPINGSmigrateLegacyMappings
thread 映射platform/app/src/server/evaluations/threadMappingResolver.tshasThreadMappingsresolveThreadMappingsIntoData
前置条件platform/app/src/server/evaluations/preconditions.tsevaluatePreconditionscheckEvaluatorRequiredFieldspreconditionsNeedEvents
前置条件校验platform/app/src/server/evaluations/preconditionValidation.tsvalidatePreconditionRules
执行核platform/app/src/server/evaluations/runEvaluation.tsrunEvaluation
执行编排(app 层)platform/app/src/server/app-layer/evaluations/evaluation-execution.service.tsEvaluationExecutionServicesetupModelEnv
langevals 调用封装platform/app/src/server/langevals/stagedFetch.tsstagedLangevalsFetchPayloadTooLargeError
事件溯源命令platform/app/src/server/event-sourcing/pipelines/evaluation-processing/commands/executeEvaluation.command.tsExecuteEvaluationCommandmakeJobId
投影platform/app/src/server/event-sourcing/pipelines/evaluation-processing/projections/evaluationRun.foldProjection.tsEvaluationRunFoldProjectionhandleEvaluationReported
投影存储platform/app/src/server/event-sourcing/pipelines/evaluation-processing/projections/evaluationRun.store.tsEvaluationRunStore
管线装配platform/app/src/server/event-sourcing/pipelines/evaluation-processing/pipeline.tscreateEvaluationProcessingPipeline
告警匹配订阅者platform/app/src/server/event-sourcing/pipelines/automations/subscribers/evaluationAlertTriggerMatch.subscriber.tscreateEvaluationAlertTriggerMatchHandler
评估触发订阅者platform/app/src/server/event-sourcing/pipelines/trace-processing/subscribers/evaluationTrigger.subscriber.tscreateEvaluationTriggerSubscriber
在线/护栏 RESTplatform/app/src/server/routes/evaluations-legacy.tshandleEvaluatorCall
监控器服务platform/app/src/server/app-layer/monitors/monitor.service.tsMonitorService.getEnabledOnMessageMonitors
评估读取门面platform/app/src/server/evaluations/evaluation.service.tsEvaluationService
代码评估器 DSLplatform/app/src/server/evaluators/codeEvaluator.tsbuildCodeEvaluatorDslCODE_EVALUATOR_OUTPUT_FIELDS
代码评估器执行platform/app/src/server/evaluators/runCodeEvaluator.tsrunCodeEvaluatorcoerceResultScalars
Studio DSL 类型platform/app/src/optimization_studio/types/dsl.tsWorkflowComponentTypeExecutionStatus
节点注册表platform/app/src/optimization_studio/registry.tsMODULESALLOWED_EVALUATORS
优化器platform/app/src/optimization_studio/types/optimizers.tsOPTIMIZERS
工作流分派platform/app/src/server/workflows/runWorkflow.tsrunWorkflow
CI 触发实验platform/app/src/server/workflows/workflowEvaluation.service.tsWorkflowEvaluationService.triggerEvaluation
工作流保存时算映射platform/app/src/server/workflows/auto-compute-agent-mappings.tsautoComputeAgentMappings
实验路由platform/app/src/server/routes/experiments-v3.ts/execute/:slug/run/runs/:runId/results
实验编排platform/app/src/server/experiments-v3/execution/orchestrator.tsgenerateCellsexecuteCellrunOrchestrator
分数剥离规则platform/app/src/server/experiments-v3/execution/evaluatorScoreFilter.tsshouldStripScoreBINARY_ONLY_EVALUATORS
Python 评估契约services/langevals/langevals_core/langevals_core/base_evaluator.pyBaseEvaluatorEvaluatorEntryEvaluationResult
Python 动态路由services/langevals/langevals/server.pycreate_evaluator_routesload_evaluator_packages
TS 目录生成器services/langevals/scripts/generate_evaluators_ts.pyfield_annotation_to_zod
Go 引擎services/nlpgo/app/engine/engine.goEngine.ExecuterunLayerdispatchrunEvaluator
Go 流式services/nlpgo/app/engine/stream.goExecuteStreamheartbeat
Go 分层规划services/nlpgo/app/engine/planner/planner.goNewlayerizeUnsupportedNodeKindError
Go 批量上报services/nlpgo/app/engine/evaluation.gonewEvaluationReporterrecordEntrynewDeterministicRand
Go 评估器块services/nlpgo/app/engine/blocks/evaluatorblock/executor.gocoerce.goExecutor.ExecutecoerceData
Go 密钥处理services/nlpgo/app/engine/secrets.goresolveSecretRefsredactSecrets
Go DSL 类型services/nlpgo/app/engine/dsl/types.goComponentTypeNodeWorkflow