跳到主要内容

数据截至 (上游 commit 36c7a7f6eca6)

LLM judge 与对齐:从一句自然语言指令到会读 trace 的评审员

30 秒导读: 04 章里,evaluate() 把每条 trace 丢给一个"判分器"就算完事;这一章拆开那个黑盒。MLflow 的判分器叫 judge——你写一句中文/英文的评分标准,它被编译成一次带结构化输出约束的 LLM 调用;如果标准里写了 {{ trace }},它会变成一个能调用 list_spans / search_trace_regex 等工具、自己去 trace 里翻证据的小 agent;最后,judge.align(traces) 能拿一批带人类标注的历史 trace,反过来改写这个 judge,让它的判断向人靠拢。


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

一句话定义: judge = 一段自然语言的评分标准 + 一台按它打分的 LLM + 一层保证输出能被程序读的约束。

它解决什么问题。 评估 GenAI 应用最难的部分不是跑用例,是判对错。"这个回答够不够专业""agent 是不是绕了远路"这类标准,写不成 assert,只能请人看——而人看不动上万条 trace。

它给谁用。 已经在用 MLflow 埋点(见 01 章)、想给线上/线下 trace 批量打质量分的人。

用起来什么样。 一个最小的真实调用:

from mlflow.genai.judges import make_judge
from typing import Literal

quality = make_judge(
name="response_quality",
# 标准就是这一句话;{{ inputs }} / {{ outputs }} 是占位符
instructions="判断 {{ outputs }} 是否正确回答了 {{ inputs }} 里的问题。",
model="openai:/gpt-4",
feedback_value_type=Literal["yes", "no"], # 打分只能是这两个值之一
)

feedback = quality(inputs={"question": "法国首都?"}, outputs="巴黎")
print(feedback.value, feedback.rationale) # -> "yes", "回答与问题一致……"

上面这段的骨架抄自 mlflow/genai/judges/make_judge.py:188-212 的官方 docstring 示例,参数含义见同文件 make_judge 签名(make_judge.py:113)。

一句话直觉。 把 judge 想成"可执行的评分细则":细则本身是散文,但 MLflow 在它两侧各加了一道闸——进来的一侧把 trace 里的数据填进占位符,出去的一侧强制模型只能吐出 {result, rationale} 这样一个能入库的 JSON。散文因此变成了函数。

judge 在这个仓库里有四种形态,本章重点是后三种:

形态长什么样入口
内置 judge预置好的英文提示词模板 + 固定字段mlflow/genai/judges/builtin.pyprompts/*.py
指令 judge(字段模式)你写一句话,用 {{ inputs }} 等占位符make_judge(...)
agentic judge(trace 模式)你写的话里出现 {{ trace }},judge 自己去查同上,靠模板变量切换
记忆增强 judge对齐之后产生的、挂着"指南 + 相似案例"的 judgejudge.align(traces)

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

这节给一张总图,后面每节各展开其中一块。

怎么读这张图: 从左到右是一次判分的主线;下方那条虚线回路是"对齐",它不参与判分,而是离线生产一个新的 judge

你写的一句话 编译期 运行期
┌──────────────┐ ┌────────────────────┐ ┌──────────────────────────┐
│ instructions │ → │ make_judge │ → │ InstructionsJudge.__call__│
│ "……{{trace}}"│ │ ① 校验模板变量 │ │ ① 决定:字段模式/trace模式│
│ + value_type │ │ ② 校验取值类型 │ │ ② 组 system+user 两条消息 │
└──────────────┘ │ ③ 定 aggregations │ │ ③ 生成 response_format │
└────────────────────┘ └───────────┬──────────────┘

┌──────────────────────────────────┐
│ adapter 选路(三选一,按序命中) │
│ Databricks / Gateway / LiteLLM │
└───────────┬──────────────────────┘

┌───────────────────────────────────────┐
│ 工具循环(仅 trace 模式) │
│ LLM 要工具 → 注册表执行 → 结果回灌 →… │
└───────────┬───────────────────────────┘

┌─────────────┐
│ Feedback │ value / rationale / metadata
└─────────────┘

- - - - - - - - - - 对齐回路(离线) - - - - - - - - - - - - - - - - - -
带人类标注的历史 trace → AlignmentOptimizer.align(judge, traces) → 新 judge

部件职责:

部件干什么在哪个文件
Judgejudge 的抽象基类,本身是一种 Scorermlflow/genai/judges/base.py:53
make_judge声明式工厂:校验 + 造 InstructionsJudgemlflow/genai/judges/make_judge.py:113
InstructionsJudge执行内核:拼 prompt、调模型、解析成 Feedbackmlflow/genai/judges/instructions_judge/__init__.py:61
提示词骨架judge 的系统提示词模板(字段版 / trace 版)mlflow/genai/judges/instructions_judge/constants.py:9-66
judge 工具集给 judge 查 trace 用的六个函数mlflow/genai/judges/tools/
adapter 层把"调模型"这件事按 provider 分流mlflow/genai/judges/adapters/
optimizer 层用人类标注反过来改 judgemlflow/genai/judges/optimizers/

3. Judge 抽象:一种「会自我改进」的 Scorer

它要解决的小问题: 04 章的 harness 只认识 Scorer。judge 想被 harness 调度,就必须先是个 Scorer;但它又比普通 scorer 多两样东西——一句可读的指令一条自我改进的通道

于是 Judge(Scorer)base.py:53 只加了四个抽象成员:

成员类型干什么
instructions属性这个 judge 在评什么,纯文本
feedback_value_type属性打分值的类型(str / int / Literal[...] …)
get_input_fields()方法我需要哪些输入字段
get_output_fields()类方法我会产出哪些输出字段

字段本身是个三元组 JudgeField(name, description, value_type)base.py:41-50)。它看着朴素,作用却很关键:同一份字段定义要同时喂给三个地方——组 prompt 时的字段说明、结构化输出的 pydantic 模型、以及对齐时的 DSPy signature。一份定义、三处消费,这是本章反复出现的模式。

输出字段是全局固定的两个:result(评分)和 rationale(理由)。两条描述文案抽在 mlflow/genai/judges/constants.py:100-101_RESULT_FIELD_DESCRIPTION / _RATIONALE_FIELD_DESCRIPTION),基类实现在 base.py:89-104

两个 constants.py 别搞混。 本章其余地方单写 constants.py 时,一律指 mlflow/genai/judges/instructions_judge/constants.py(那里只有提示词骨架,共 67 行);上面这两条字段描述在上一层mlflow/genai/judges/constants.py。凡是跨这两个文件的引用,本章都写全路径。

align 是 judge 相对普通 scorer 的唯一增量能力,签名很短(base.py:107):

def align(self, traces, optimizer=None) -> Judge:
if self.is_session_level_scorer: # 多轮 judge 暂不支持
raise NotImplementedError(...)
if optimizer is None:
optimizer = get_default_optimizer() # 默认 MemAlign
return optimizer.align(self, traces)

三个细节值得记住:

  • 它返回新 judge,不改自己——对齐是纯函数,原 judge 原样可用(base.py:137)。
  • session 级 judge 被明确拒绝base.py:132-133),即指令里写了 {{ conversation }} 的那种。
  • 默认优化器是 MemAlign,由 get_default_optimizer() 硬编码返回(mlflow/genai/judges/utils/__init__.py:44-53)。

优化器一侧的契约同样只有一个方法:AlignmentOptimizer.align(judge, traces) -> Judgebase.py:19-38)。整个第 8 节讲的三套算法,都是这一个方法的不同实现。


4. 声明式构造:make_judge 做的三件事

它要解决的小问题: 用户只想写一句话,但系统需要知道"这句话要什么输入、会产什么输出、能不能存进数据库"。make_judge 就是把散文体检一遍、再翻译成配置的地方。

4.1 模板变量:五个白名单,外加一条互斥规则

judge 指令里能出现的占位符只有五个,写死在 instructions_judge/__init__.py:74-80

变量含义判分时从哪来
{{ inputs }}被评对象的输入调用参数,或从 trace 根 span 抽
{{ outputs }}被评对象的输出调用参数,或从 trace 根 span 抽
{{ expectations }}期望值 / ground truth调用参数,或从 trace 的 expectation 断言抽
{{ conversation }}多轮会话历史从同 session 的一组 trace 抽
{{ trace }}整条 trace不做替换,改为开启工具调用

三条规则,全部在构造期就报错,不留到运行时:

  1. 至少要有一个变量。 一句不含占位符的指令没有输入,直接拒(instructions_judge/__init__.py:687-692)。
  2. 不许有自定义变量。 模板里出现白名单之外的 {{ foo }} 一律拒(instructions_judge/__init__.py:202-209)。
  3. {{ conversation }} 只能和 {{ expectations }} 共存。 它和 inputs / outputs / trace 互斥(instructions_judge/__init__.py:694-702)——因为多轮会话的输入输出概念本来就和单轮不一样。

一个复用得很省事的小技巧:变量提取没有自己写正则,而是**借了 prompt registry 的 PromptVersion**当"一次性解析器"用(instructions_judge/__init__.py:183-187,注释里写明了动机)。顺序则另用 PROMPT_TEMPLATE_VARIABLE_PATTERNmlflow/prompt/constants.py:24)扫一遍去重保序(instructions_judge/__init__.py:190-196)——这个顺序决定了后面 user 消息里字段的排列顺序。

4.2 取值类型:一张白名单,挡住 pydantic 模型

_validate_feedback_value_typemake_judge.py:25)逐个形态放行,其余一律抛异常:

允许的类型例子校验位置
四种原语str / int / float / boolmake_judge.py:40-42
可空原语float | NoneOptional[int]make_judge.py:10-2245-46
Literal[...]Literal["good", "bad"](成员必须是原语)make_judge.py:49-62
dict[str, 原语]dict[str, float](键必须是 strmake_judge.py:65-84
list[原语]list[str]make_judge.py:87-99

为什么这么严? 因为这个类型最终要落进 Feedback.value,而 assessment 的 protobuf 值类型 PbValueType 只认这些。白名单直接从 get_args(PbValueType) 取(make_judge.py:40),所以序列化层一改,校验层自动跟上。兜底的错误信息也明说了"不支持 pydantic BaseModel"(make_judge.py:104-109)。

4.3 类型怎么变成"结构化输出约束"

这是最漂亮的一步,只有十行(instructions_judge/__init__.py:655-665):

def _create_response_format_model(self):
fields = {}
for field in self.get_output_fields(): # result + rationale
fields[field.name] = (field.value_type, pydantic.Field(description=field.description))
return pydantic.create_model("ResponseFormat", **fields) # 动态造一个 pydantic 模型

也就是说:你声明的 feedback_value_type 被动态编译成一个 pydantic 模型,再作为 response_format 透传给 LLMinstructions_judge/__init__.py:637644)。Literal["yes","no"] 到了 OpenAI 那边就是 enum 约束,模型想编别的值都编不出来。

同一个类型还顺手决定了聚合方式:数值/布尔类型默认聚合 mean,分类/字符串类型默认不聚合(make_judge.py:280)——因为对 "yes"/"no" 求平均没有意义。

一个容易踩的差异:InstructionsJudge 支持 generate_rationale_first(先写理由再给分,instructions_judge/__init__.py:406-410),但 make_judge 没有暴露这个参数(对比 make_judge.py:282-294)。想要"先思考后打分"的顺序,目前只能直接构造 InstructionsJudge


5. 执行内核:一次 __call__ 里发生了什么

InstructionsJudge.__call__instructions_judge/__init__.py:489)是本章最长的一段代码,但它的结构其实是一条直线。

__call__(inputs, outputs, expectations, trace, session)

├─① 类型校验 …………………… _validate_parameter_types :533
├─② 补数据:trace 在手就从 trace 里抽 inputs/outputs/expectations :539-554
├─③ 补数据:session 在手就抽 conversation :556-565
├─④ 决定模式:字段模式 or trace 模式(含降级) :570-618
├─⑤ 组消息:system(含指令+输出格式) + user(含字段值) :627-635
├─⑥ 造约束:response_format = pydantic 动态模型 :637
├─⑦ 发车:invoke_judge_model(…, trace=trace if 是trace模式) :639-649
└─⑧ 回填:把原始指令写进 feedback.metadata["guideline"] :652

5.1 模式分岔:以及那个"救 OTel trace"的降级

正常情况下模式由模板变量决定:写了 {{ trace }} 就是 trace 模式(agentic),否则是字段模式。

但有一类 trace 天生取不到根 span 的输入输出——注释点名了 Google ADK、LangChain JS、Vercel AI SDK 这些纯 OTel 来源的 trace(instructions_judge/__init__.py:567-569)。这时候字段模式会因为 inputs is None 直接失败。

MLflow 的处理是自动降级到 trace 模式instructions_judge/__init__.py:570-589):既然从根 span 抽不到,那就让 judge 自己拿工具去 trace 里找。降级后必填校验也换了一套更宽松的(instructions_judge/__init__.py:593-610)——inputs/outputs 交给工具去发现,但 expectations/session 仍然必须由调用方给。

这是个很值得借鉴的取舍:能力更强的那条路径,同时也是失败时的兜底路径。

5.2 prompt 组装:两条消息,各管一半

系统消息由 _build_system_message 产出(instructions_judge/__init__.py:371),两种模式用两个不同骨架:

模式骨架常量大致内容
字段模式INSTRUCTIONS_JUDGE_SYSTEM_PROMPT通用开场白 + Your task: {{instructions}} + 输出格式说明
trace 模式INSTRUCTIONS_JUDGE_TRACE_PROMPT_TEMPLATE通用开场白 + 什么是 trace/span + 怎么用工具 + 五步方法论 + 输出格式 + 指令

两者共用同一句开场白 JUDGE_BASE_PROMPTinstructions_judge/constants.py:9-11)。输出格式说明由 add_output_format_instructions 拼接,逐字段渲染成 - result (Literal['yes','no']): 评分mlflow/genai/judges/utils/prompt_utils.py:42-68,类型渲染在同文件 format_type:33)。

为什么 trace 模式要把类型也写进散文里? 代码注释给了答案(instructions_judge/__init__.py:376-377):有些模型不支持"工具调用 + 结构化输出"同时开启,散文里那份类型说明是它们唯一的线索。

用户消息只放字段值,且明确跳过 traceinstructions_judge/__init__.py:422-424),每行形如 inputs: {...},值统一 JSON 化并对失败兜底(_safe_json_dumpsinstructions_judge/__init__.py:482-487)。

纯 trace 模式下字段值是空的,于是有了这段看着啰嗦、其实是血泪的兜底文案(instructions_judge/__init__.py:438-446):

return "\n".join(user_message_parts) if user_message_parts else (
"Use the tools to inspect the trace and return the JSON rating per the system "
"message. This message and your tool calls in this chat are not the input or "
"response being judged. The trace lives only behind the tools."
)

它同时解决两个问题:其一,Anthropic 等 provider 不接受"只有 system 消息"或空内容的请求;其二,也是更隐蔽的——如果不明确指路,judge 会把当前这段对话本身当成被评对象来打分。同一个警告在系统提示词里也重复了一次(instructions_judge/constants.py:36-38:"Important: do not grade this conversation.")。

5.3 解析:JSON → Feedback

模型回话之后的落地在 adapter 里(以 LiteLLM 为例,mlflow/genai/judges/adapters/litellm_adapter.py:618-648):

  1. _strip_markdown_code_blocks 先剥掉模型爱加的 markdown 围栏,连"前面一段废话 + 后面一个 json 块"这种也能捞出来(mlflow/genai/judges/utils/parsing_utils.py:6-40)。
  2. json.loads 失败就报错,不做二次猜测(litellm_adapter.py:620-625)。
  3. 组装 Feedbackvalue=response_dict["result"]rationale_sanitize_justification 洗掉 "Let's think step by step. " 这个前缀(parsing_utils.py:43-44)、source_type=LLM_JUDGEsource_id=model_urilitellm_adapter.py:639-646)。
  4. token 数与费用挂进 metadatalitellm_adapter.py:628-636)。

最后回到 judge:把原始指令塞进 feedback.metadata["guideline"]instructions_judge/__init__.py:652),这样 UI 上每个分数旁边都能显示"当时用的是哪条标准"。

5.4 顺带一提:内置 judge 走的是老路

prompts/ 下有 20 个 judge prompt 模块(correctness.pygroundedness.pysafety.pyuser_frustration.py 等,另加一个 __init__.py,所以目录里数出来是 21 个 .py)。每个模块都导出一个大写的 *_PROMPT 模板常量和一个 *_ASSESSMENT_NAME / *_FEEDBACK_NAME 常量;其中 10 个额外提供 get_prompt() 负责把字段填进模板,另外 10 个只给常量、由调用方自己 format。

它们的风格和 make_judge 完全不同——把输出格式直接写死在提示词的 JSON 示例里,而不是靠 response_format

{
"rationale": "……Start each rationale with `Let's think step by step`",
"result": "yes|no"
}

mlflow/genai/judges/prompts/correctness.py:17-24

这也解释了前面那个 _sanitize_justification:它专门用来擦掉这些老模板要求模型加的固定前缀。内置 judge 由 builtin.py 里的 is_correctbuiltin.py:237)/ is_grounded:334)等函数调用,同样走 invoke_judge_model,只是 use_case 标成 USE_CASE_BUILTIN_JUDGEmlflow/genai/judges/builtin.py:134-138)。


6. agentic judge:{{ trace }} 让评审员自己去查

它要解决的小问题: 一条真实 agent trace 动辄几十上百个 span、几十万 token,塞不进上下文;而且 judge 大部分时候只需要其中很小一块。

思路: 不给它 trace,给它查 trace 的工具。系统提示词里明说了这件事——占位符 {{ trace }} 不会被替换,要看真数据必须调工具(instructions_judge/constants.py:28-34)。

6.1 工具清单

工具是一等公民:抽象类 JudgeTool 只要求三样——nameget_definition()(OpenAI function calling 格式的定义)、invoke(trace, **kwargs)mlflow/genai/judges/tools/base.py:15-52)。

默认注册表里有六个(mlflow/genai/judges/tools/registry.py:141-147):

工具名干什么实现类
get_trace_info拿 trace 的元数据/整体信息tools/get_trace_info.py:19
get_root_span拿根 span 的输入输出(可挑属性、可分页)tools/get_root_span.py:16
get_span按 span_id 拿单个 span 的完整内容tools/get_span.py:17
list_spans列出所有 span 的元数据(名字/类型/耗时/属性名)tools/list_spans.py:53
search_trace_regex在整条 trace 的 JSON 上跑正则,返回命中和上下文tools/search_trace_regex.py:37
get_span_performance_and_timing_report生成耗时/性能报告tools/get_span_performance_and_timing_report.py:46

这套工具的设计有一条清晰的主张:先看目录,再翻正文。 list_spans 只返回属性不返回属性值(工具描述原文:does not fetch full span contenttools/list_spans.py:69-75);get_root_span 的参数说明则直接教模型"先调 list_spans 看有哪些属性,再挑相关的取"(tools/get_root_span.py:47-51)。分页、max_content_length 也都在参数里备好了。

search_trace_regex 的实现朴素得可爱——把整条 trace to_json() 之后跑 re.finditer,每个命中带前后各 100 字符的上下文(tools/search_trace_regex.py:124-137_create_regex_match:144)。在 trace 的 JSON 文本上做全文检索,比逐 span 遍历省事得多。

两个"实现了但没上桌"的工具。 SearchTracesTool(跨 trace 搜同实验,tools/search_traces.py:107)和 GetTracesInSession(取同 session 的其它 trace,tools/get_traces_in_session.py:19)在 tools/__init__.py:22 被导出,但没有出现在 registry.py:140-145 的注册列表里;它们的名字也带下划线前缀 _search_traces / _get_traces_in_sessiontools/constants.py:20-21)。也就是说,默认 judge 拿不到这两个工具,需要调用方自己 register_judge_tool()

6.2 工具循环

循环体在各 adapter 里各写了一份,结构一致,以 LiteLLM 版为例(adapters/litellm_adapter.py:224_invoke_litellm_and_handle_tools):

┌──────────────────────────────────────────────┐
│ tools = 全部注册工具的定义(仅当 trace≠None)│ :282-285
└───────────────┬──────────────────────────────┘

┌────────► 调模型(带 tools + response_format) :315
│ │
│ ├─ 没有 tool_calls ?→ 返回正文,收工 :369
│ └─ 有 tool_calls:
│ 注册表逐个执行 → 结果转成 role=tool 消息 → 追加 :395
│ │
└──────────────┘ iteration_count += 1,超过 30 次就抛错 :305-312

循环里有三处"扛住现实"的分支,都值得抄:

遇到什么怎么办位置
上下文超长丢掉最早的一对 assistant tool_call + 对应 tool 回复,然后重来litellm_adapter.py:355-363;裁剪逻辑 utils/tool_calling_utils.py:210-248
模型不支持 response_format(或不支持它和工具同开)把该模型标记进能力缓存,关掉结构化输出重试一次litellm_adapter.py:368-380
工具自己抛异常不中断循环,把 Error: ... 当作工具返回值回灌给模型utils/tool_calling_utils.py:71-80

迭代上限是 MLFLOW_JUDGE_MAX_ITERATIONS,默认 30mlflow/environment_variables.py:1624)。超限时的报错文案很有意思,它把锅指向模型能力而不是配置:"这通常说明模型不够强,考虑换个更聪明的模型"utils/tool_calling_utils.py:36-44)。

还有一个小而妙的点:如果打开了 MLFLOW_GENAI_EVAL_ENABLE_SCORER_TRACING,注册表会给工具调用本身套一层 mlflow.traceregistry.py:69-73)——judge 查 trace 的过程,自己也变成一条 trace。


7. 模型接入层:三个 adapter,按序试

它要解决的小问题: openai:/gpt-4、Databricks 托管评审员、企业内网网关、以及"LiteLLM 支持的其它一切"——判分逻辑不该关心这些差别。

抽象契约只有两个方法(adapters/base_adapter.py:100-170):类方法 is_applicable(model_uri, prompt) 说"我能不能接",实例方法 _invoke(input_params) 干活;基类的 invoke 是个薄壳,只负责给 Databricks 系模型补一层成功/失败遥测(base_adapter.py:119-166)。

选路是顺序命中,没有注册表也没有优先级数字(adapters/utils.py:53-66):

顺序Adapter什么时候命中依据
1DatabricksManagedJudgeAdaptermodel_uri 恰好等于 "databricks"databricks_managed_judge_adapter.py:371-377
2GatewayAdapterprovider 是 MLflow 网关原生支持的,或 endpoints/gateway(但 endpoints 不收消息列表)gateway_adapter.py:333-348
3LiteLLMAdapter只要装了 litellm 就接——事实上的兜底litellm_adapter.py:579-584

三家都落空才抛 No suitable adapter foundadapters/utils.py:63-66)。

参数打包成一个 AdapterInvocationInput dataclass(base_adapter.py:23-72),其中 model_provider / model_name懒解析属性——真用到时才去调 _parse_model_uri,避免为了判个路就把 mlflow.metrics 那条重依赖链拉进来(同样的顾虑在 adapters/utils.py:44-46 写得更明白)。

各家的差别一句话说完:

  • Databricks 托管版:走 databricks-agents 的 chat completions,agentic 循环用固定的 gpt-oss-120bconstants.py:2databricks_managed_judge_adapter.py:233-312)。
  • Gateway 版:自己发 HTTP,带重试与退避(adapters/utils.py:82-146),并会在请求前主动估算是否要裁剪上下文(gateway_adapter.py:171)。
  • LiteLLM 版:功能最全,base_url / extra_headers 只有它真正支持——Databricks 系一律拒绝,理由是端点由工作区配置决定(litellm_adapter.py:596-603databricks_managed_judge_adapter.py:379-385)。

一个小瑕疵:InstructionsJudgeuse_case 无条件写成 USE_CASE_AGENTIC_JUDGEinstructions_judge/__init__.py:645),哪怕这次判分走的是字段模式、根本没开工具。这个值只用于 Databricks 侧的用量归类,不影响判分结果。


8. 对齐:用人类标注反过来改 judge

这是本章最有借鉴价值的部分。

它要解决的小问题: 你写的评分标准和你心里的标准,永远差一截。人工标 50 条之后你会发现——不是标准写错了,是标准没写全:那些你觉得"不言自明"的偏好,模型并不知道。

思路: 既然人已经标过了,就让机器从标注里把没写出来的规则挖出来,补进 judge。

MLflow 给了两条路线,共享同一套数据准备,但产出物完全不同:

DSPy 路线(SIMBA / GEPA)MemAlign 路线(默认)
产出物一个新的 InstructionsJudge指令被改写一个 MemoryAugmentedJudge原指令不动,外挂记忆
学到的东西存在哪指令正文里(可能还附几个 few-shot 例子)语义记忆(指南列表)+ 情景记忆(带向量的历史案例)
判分时的开销和普通 judge 一样多一次 embedding 检索
最少标注量10 条(optimizers/dspy.py:44无硬性下限
额外依赖dspydspy + embedding 模型 + jinja2
增量更新每次全量重跑按 trace 指纹跳过未变的(见 8.4)

8.1 共同的第一步:把带标注的 trace 变成训练样本

trace_to_dspy_example(trace, judge)optimizers/dspy_utils.py:384)是两条路线共用的入口,它做四件事:

  1. 按 judge 的需求抽字段:judge 要 inputs 就抽 request,要 outputs 就抽 response,缺了就警告并返回空列表dspy_utils.py:415-429)——静默丢弃而非报错。
  2. 只认人类标注:按名字匹配、且 source_type == HUMAN 的 assessment 才算数(dspy_utils.py:433-441)。LLM judge 自己打的分不能当训练信号,否则就是自我强化。
  3. 消解标注冲突_resolve_assessment_conflictsdspy_utils.py:363-381):同一条 trace 上多个人给了不同标签时,按票数多的一组胜出,票数打平则看谁的标注更新。落败的那组会被丢弃并打日志列出细节(dspy_utils.py:456-465)。
  4. 打上溯源标记:每个样本挂 _trace_id / _assessment_id / _last_update_time_msdspy_utils.py:493-495)。这三个私有属性看着不起眼,却是 8.4 节增量对齐的全部基础。

8.2 DSPy 路线:把 judge 本身当成"被优化的程序"

DSPy 是个提示词优化框架,它优化的对象是"程序"。所以第一步要把 judge 伪装成 DSPy 程序。

create_dspy_signature(judge) 把 judge 的输入/输出字段翻译成 DSPy signature,指令原文直接当 signature 的 instructionsdspy_utils.py:505-539)——这正是后面被优化器改写的那段文本。

真正的巧思在 CustomPredictoptimizers/dspy.py:131-173)。它继承 dspy.Predict,但 forward 不走 DSPy 的推理,而是:

def forward(self, *args, **kwargs):
# 用「当前这一版 signature 指令」现造一个 judge
created_judge = create_judge_from_dspy_program(optimized_program=self, original_judge=...)
feedback = created_judge(**judge_kwargs) # 真正跑一次 MLflow judge
return dspy.Prediction(result=feedback.value, rationale=feedback.rationale)

意义在于:优化器以为自己在调 DSPy 模块,实际每一步都在跑真实的 MLflow judge。 于是优化过程中的每一次评估都是端到端真实的,不存在"优化时用一套、上线时用另一套"的偏差。附带好处是两个模型可以分开——判分用 judge 自己的模型,优化用 optimizer 的模型(optimizers/dspy.py:204-206dspy.py:222)。

目标函数简单到几乎不像话(agreement_metricdspy_utils.py:541-561):把期望标签和预测标签都 .lower().strip()相等就是 1,不等就是 0。对齐的定义就是"和人标得一样",没有偏序、没有部分得分。

两个具体算法只是换了个 DSPy 优化器:

优化器算法关键参数位置
SIMBAAlignmentOptimizerdspy.SIMBA,bootstrap 聚合bsize 默认取最小样本数 10、seed=42optimizers/simba.py:20:105
GEPAAlignmentOptimizerdspy.GEPA,遗传 + 帕累托 + 反思max_metric_calls 默认 = 样本数 × 4optimizers/gepa.py:23:116:128

优化完还要把结果翻译回 judge_create_judge_from_dspy_programoptimizers/dspy.py:93-125),两步收尾都是防御性的:

  • append_input_fields_section:DSPy 改写指令时可能把 {{ inputs }} 这类占位符改没了,于是检查一遍,缺了就在末尾补一行 Inputs for assessment: {{ inputs }}, {{ outputs }}dspy_utils.py:563-595)。
  • format_demos_as_examples:SIMBA 会往 program.demos 里塞成功案例,这里把它们渲染成 Here are some examples of good assessments: 开头的文本,前置到指令上(dspy_utils.py:597-648optimizers/dspy.py:115-118)。

换句话说,DSPy 路线的产出物仍然是一句(更长的)自然语言指令——可读、可 diff、可以手工再改

8.3 MemAlign 路线:给 judge 装两种记忆

这是 align() 的默认实现,思路和改写指令完全不同:原指令一个字不动,改为在判分时动态注入上下文。

MemoryAugmentedJudge.__call__

┌───────────────────────┴────────────────────────┐
↓ ↓
语义记忆(全量注入) 情景记忆(检索注入)
guidelines: 从历史标注里蒸馏出的规则 把当前输入 embed 一下,
例:"该用户认为回答超过 3 句就算啰嗦" 取最相似的 k=5 条历史标注案例
└───────────────────────┬────────────────────────┘

扩展后的 signature(多两个输入字段)→ dspy.Predict → Feedback

语义记忆怎么来的。 distill_guidelinesoptimizers/memalign/utils.py:402)把历史标注分批喂给一个"反思模型",让它写出这个用户的偏好规则。蒸馏提示词(optimizers/memalign/prompts.py:1-51)有三处设计值得注意:

  • 明确规定规则可以是"用户的偏好/用户的事实信念/关于用户的其它知识",并且不要求泛化——"规则不需要通用,反而应该专属于这个用户"。
  • 把已有规则一并传进去,要求新规则互补不重复,覆盖不了就返回空列表。
  • 强制每条规则带 source_trace_ids,标明它是从哪几条标注里提炼的。

分批用的是贪心装箱(_create_batchesutils.py:177),按 token 数打包,每批最多 50 条(utils.py:51:232),批间并发跑(ThreadPoolExecutorutils.py:425-426)。线程数由 MLFLOW_GENAI_OPTIMIZE_MAX_WORKERS 控制,默认 8——这个默认值写在 distill_guidelines 的 docstring 里(utils.py:337)。回包解析时会过滤空规则、重复规则、以及 source_trace_ids 解析不出来的规则(_parse_batch_responseutils.py:249-306)。

情景记忆怎么用的。dspy.Embedder 把历史案例建成向量索引(optimizer.py:583-611),默认 embedding 模型 openai:/text-embedding-3-small、维度 512、取 top 5(utils.py:156-157optimizer.py:160-162)。

检索的"查询字段"不是随便选的,而是按固定优先级挑第一个 judge 真的用到的字段:inputs > outputs > expectations > conversation > traceutils.py:56get_query_field:59)。trace 对象则只取 request/response 来做 embedding 文本,理由写在注释里——其它属性大小无界value_to_embedding_textutils.py:310-317)。

两种记忆怎么进 prompt。 create_extended_signature 往 signature 头部 prepend 两个输入字段 example_judgementsguidelinesutils.py:491-499)。这两个字段的描述里各埋了一句相同的约束(prompts.py:54-77):

你的输出绝不能直接提到这些指南/例子的存在,而要把学到的东西融进你的推理里。

这一句是防"judge 说漏嘴"——不然理由栏会变成"根据指南第 3 条……",而用户根本不知道有什么指南。

8.4 增量对齐:指纹、刷新、以及"不许用空标注撤回"

重复调 align() 是常态(今天标 20 条、明天又标 20 条)。MemAlign 为此设计了一套按 trace 的变更检测(optimizer.py:694-810)。

核心是指纹:一条 trace 的指纹 = 它所有已解析样本的 (assessment_id, last_update_time_ms) 组成的 frozenset(_generate_fingerprintoptimizer.py:87-101)。新指纹和记忆里的旧指纹一比,就知道这条 trace 是新的、变了、还是没动。

分类逻辑(optimizer.py:752-770):

情况处理
旧指纹为空、新指纹非空新增,进待学列表
两个指纹都有且不等刷新:先把旧的从记忆里剔掉,再学新的
两个指纹相等跳过:不调 LLM、不重建索引
新指纹为空、旧指纹非空报错:这是想用空标注撤回,请改用 unalign()
两个指纹都为空报错:这条 trace 压根没有人类标注

后两种为什么要"吵闹地失败"而不是静默跳过?注释解释得很直白(optimizer.py:756-762):混在一批有效 trace 里的话,静默跳过会掩盖用户的真实 bug(传错了 trace id、忘了标注)。

撤回走 unalign(traces)optimizer.py:466)。它的规则里有一条关于指南保留的判断很讲究(_apply_unalign_inplaceoptimizer.py:540-561):

只有当一条指南的全部来源 trace 都被移除时才删掉它;只要还剩一个来源就保留。

理由写在注释里——一条跨多条 trace 归纳出的规则,聚合了每条来源的信号,撤掉其中一条并不能否定其余来源提供的证据。这就是 source_trace_ids 这个字段存在的全部意义。

另外,因为 DSPy 对象里含线程锁没法 pickle,MemoryAugmentedJudge 重写了序列化:只存 trace id 列表,不存向量索引model_dumpoptimizer.py:350-368),首次使用时再按 id 把 trace 拉回来重建(_lazy_init / _reconstruct_episodic_memoryoptimizer.py:442-464)。有 trace 找不回来也不报错,只警告"将以部分记忆运行"(optimizer.py:431-438)。

8.5 这一节可以带走的四条

  1. 只信人类标注,且冲突要有确定性的消解规则(多数 → 最新),不能让训练信号里混进模型自己的判断(dspy_utils.py:363-381:416)。
  2. 让优化器直接驱动真实被优化对象,别做一个"仿真版"(optimizers/dspy.py:143-171)。
  3. 学到的知识要标明出处,否则无法做选择性遗忘(utils.py:147-150optimizer.py:550-561)。
  4. 注入的上下文要禁止被复述,学到的东西应该体现在结论里而不是理由里(prompts.py:57-63)。

9. 呼应:优化 judge 和优化 prompt 是同一套思路

mlflow.genai.optimize_promptsmlflow/genai/optimize/optimize.py:70)解决的是另一个方向的问题——不是"改评分标准",而是"改被评的那个 prompt"。但两边骨架几乎重合:

环节优化 judge优化 prompt
被优化的文本judge 的 instructionsprompt registry 里的模板
目标函数agreement_metric:和人类标注是否一致(dspy_utils.py:541scorers 组合出的分(optimize.py:283,scorer 可以就是 judge)
训练数据带人类标注的 tracetrain_data 数据集
反思模型reflection_lmoptimizers/memalign/optimizer.py:159reflection_modeloptimizers/gepa_optimizer.py:99
算法SIMBA / GEPA / MemAlignMetaPrompt(单轮) / GEPA(多轮)
产出新 Judge 对象新注册的 prompt 版本(optimize.py:266-277register_prompt 调用)

两个 prompt 侧优化器的分工:

  • MetaPromptOptimizeroptimizers/metaprompt_optimizer.py:92单轮改写。没有训练数据时走零样本模式,只按一份写死的"提示词工程九条最佳实践"清单改(META_PROMPT_TEMPLATEmetaprompt_optimizer.py:20-48);有数据时先跑一遍基线评估,把结果当例子塞进 meta prompt(_optimize_few_shot:289)。改完必须通过模板变量保全校验_validate_template_variables:413)——和 8.2 节 append_input_fields_section 防的是同一类事故:优化器把占位符改丢了。
  • GepaPromptOptimizeroptimizers/gepa_optimizer.py:23多轮迭代:变异、反思、帕累托选优,和 judge 侧的 GEPAAlignmentOptimizer 是同一个上游算法。

一句话概括这种对称:judge 和 prompt 都是"可执行的自然语言",都能用同一套"跑一遍 → 打分 → 反思 → 改写"的循环来改进;区别只在打分器是谁。


10. 边界与局限(诚实)

局限说明依据
多轮 judge 不能对齐指令里有 {{ conversation }} 的 judge 调 align() 直接 NotImplementedErrorbase.py:132-133
对齐至少要 10 条标注(DSPy 路线)少于这个数直接报错,让你"再标一些"optimizers/dspy.py:44:229-235
对齐目标只有"标签一致"没有部分得分、没有序关系;对 int 评分类 judge 来说,差 1 分和差 4 分等价dspy_utils.py:541-561
judge 打分靠模型能力兜底agentic 模式跑满 30 轮工具调用会直接失败,官方建议是换更强的模型utils/tool_calling_utils.py:36-44
结构化输出会静默降级模型不支持 response_format 时会关掉重试,此时输出格式只靠散文约束litellm_adapter.py:368-380
跨 trace / 跨 session 工具默认不可用_search_traces_get_traces_in_session 已实现但未注册tools/registry.py:141-147
记忆增强 judge 依赖 trace 还在序列化只存 trace id;trace 被删则记忆残缺,只警告不报错optimizer.py:421-440
蒸馏出的指南没有质量闸门只做去重和来源校验,不校验规则本身是否互相矛盾memalign/utils.py:322-380_parse_batch_response

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

主题文件路径符号名
Judge 抽象 / 对齐入口mlflow/genai/judges/base.pyJudgeJudgeFieldAlignmentOptimizerJudge.align
输出字段描述常量mlflow/genai/judges/constants.py_RESULT_FIELD_DESCRIPTION_RATIONALE_FIELD_DESCRIPTION
声明式构造与类型校验mlflow/genai/judges/make_judge.pymake_judge_validate_feedback_value_type_is_optional_pb_value_type
执行内核mlflow/genai/judges/instructions_judge/__init__.pyInstructionsJudge.__call___build_system_message_build_user_message_create_response_format_model_validate_instructions_template
提示词骨架mlflow/genai/judges/instructions_judge/constants.pyJUDGE_BASE_PROMPTINSTRUCTIONS_JUDGE_SYSTEM_PROMPTINSTRUCTIONS_JUDGE_TRACE_PROMPT_TEMPLATE
输出格式与类型渲染mlflow/genai/judges/utils/prompt_utils.pyadd_output_format_instructionsformat_typeformat_prompt
回包清洗mlflow/genai/judges/utils/parsing_utils.py_strip_markdown_code_blocks_sanitize_justification
调用统一入口mlflow/genai/judges/utils/invocation_utils.pyinvoke_judge_modelget_chat_completions_with_structured_output
工具循环公共件mlflow/genai/judges/utils/tool_calling_utils.py_process_tool_calls_raise_iteration_limit_exceeded_remove_oldest_tool_call_pair
工具抽象与注册表mlflow/genai/judges/tools/base.pytools/registry.pyJudgeToolJudgeToolRegistryregister_judge_toollist_judge_tools
具体工具mlflow/genai/judges/tools/GetRootSpanToolGetSpanToolListSpansToolSearchTraceRegexToolGetSpanPerformanceAndTimingReportToolSearchTracesToolGetTracesInSession
内置 judge 与 prompt 模板mlflow/genai/judges/builtin.pyjudges/prompts/is_correctis_groundedCORRECTNESS_PROMPTget_prompt
Adapter 契约与选路mlflow/genai/judges/adapters/base_adapter.pyadapters/utils.pyBaseJudgeAdapterAdapterInvocationInputget_adaptersend_chat_request
三个 adaptermlflow/genai/judges/adapters/LiteLLMAdapterGatewayAdapterDatabricksManagedJudgeAdapter_invoke_litellm_and_handle_tools_run_databricks_agentic_loop
对齐:数据准备mlflow/genai/judges/optimizers/dspy_utils.pytrace_to_dspy_example_resolve_assessment_conflictscreate_dspy_signatureagreement_metricappend_input_fields_sectionformat_demos_as_examples
对齐:DSPy 骨架mlflow/genai/judges/optimizers/dspy.pyDSPyAlignmentOptimizer_get_dspy_program_from_judge_create_judge_from_dspy_program
对齐:两个算法mlflow/genai/judges/optimizers/simba.pyoptimizers/gepa.pySIMBAAlignmentOptimizerGEPAAlignmentOptimizer
对齐:默认路线mlflow/genai/judges/optimizers/memalign/optimizer.pyMemAlignOptimizer.alignMemoryAugmentedJudgeunalign_generate_fingerprint_apply_unalign_inplace
对齐:记忆机制mlflow/genai/judges/optimizers/memalign/utils.pymemalign/prompts.pydistill_guidelinesretrieve_relevant_examplescreate_extended_signatureGuidelineDISTILLATION_PROMPT_TEMPLATE
prompt 侧优化mlflow/genai/optimize/optimize.pyoptimize/optimizers/optimize_promptsMetaPromptOptimizerGepaPromptOptimizerMETA_PROMPT_TEMPLATE

继续读: judge 怎么被批量调度、和其它 scorer 怎么并发跑,见 04 章 评估引擎;judge 读的那些 span 是怎么产生和落盘的,见 02 章 追踪运行时;打完的 Feedback 怎么入库、怎么在生产上持续跑,见 06 章 落库、查询与生产监控