跳到主要内容

数据截至 (上游 commit 0261ea4f33d4)

生产闭环:在线评估规则、Python 沙箱与 prompt 自动优化

30 秒导读: 前一章 04-evaluation-engine.md 讲的是「你主动跑一次实验」。这一章讲不用你动手的那两块:线上流量自己被抽样打分(在线评估),以及 prompt 自己被搜索改写(optimizer SDK)。两块合起来把 Opik 从「观测 + 离线评估」变成一个闭环


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

1.1 离线评估解决不了什么

离线评估要你准备三样东西:一个 dataset、一个 task、一组 metric,然后跑一次 experiment。

线上没有这三样。线上只有源源不断到达的真实 trace,而且你事先不知道哪一条会出问题。

于是有了两条新链路:

闭环线一句话谁触发结果落在哪
在线评估对刚落库的 trace / span / thread 按规则抽样打分数据到达时后端自动触发该条 trace 的 feedback score
prompt 自动优化拿 dataset + metric 反复搜索更好的 prompt 文本你在 SDK 里调一次 optimize_prompt一个 OptimizationResult + 一串被追踪的 experiment

1.2 在线评估:一句话定义

在生产项目上挂一条规则,让后端对满足条件的一部分流量自动打分。

规则由四件事描述(存在 MySQL 里,见 §5):

  • 打给谁:哪个 project、哪种粒度(trace / span / thread)。
  • 筛哪些:一组过滤条件(比如 error_type is not empty)。
  • 抽多少:一个 0~1 的采样率。
  • 怎么打:要么是一段 LLM-as-judge 的 prompt 模板,要么是一段用户自己写的 Python 指标代码

1.3 prompt 自动优化:一句话定义

给定一个初始 prompt、一个 dataset、一个 metric,自动搜出分更高的 prompt。

用起来大概是这样:

# 示意,非源码:opik_optimizer 的典型调用形状
optimizer = MetaPromptOptimizer(model="openai/gpt-4o-mini")
result = optimizer.optimize_prompt(
prompt=my_prompt, # 初始 ChatPrompt
dataset=my_dataset, # Opik dataset
metric=levenshtein, # (dataset_item, llm_output) -> float
max_trials=10, # 最多评 10 轮候选
)
print(result.score, result.prompt)

重点看 max_trials:整个优化过程就是「生成候选 → 评分 → 留下最好的」这个循环,max_trials 是预算闸门。

1.4 两个直觉

  • 在线评估 = 生产线抽检。 不可能每件产品都拆开检,所以定一条抽检规则(哪条产线、抽多少、怎么判),检出的结论贴回那件产品身上。
  • optimizer = 自动调参,只不过调的是文字。 传统调参搜的是学习率,这里搜的是 prompt 的措辞、示例、甚至 temperature。

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

2.1 在线评估的一条链路

怎么读这张图:从左到右是一条 trace 的旅程;虚线右边是异步的,采样端把消息丢进 Redis 就返回,不等打分结果。

┌──────────────┐ EventBus ┌──────────────┐
│ ① 数据落库 │ ─────────────► │ ② 采样器 │
│ trace/span │ TracesCreated │ 查规则+三道闸门│
│ 写入 ClickHouse│ └──────┬───────┘
└──────────────┘ │ 通过的消息

═══════════ Redis Stream ═══════════
每种规则类型一条独立的流(6 条)
════════════════╤══════════════════

┌──────────────────┐
│ ③ scorer 消费者 │
│ 全非阻塞 Reactor │
└────────┬─────────┘
┌────────────┴────────────┐
▼ ▼
┌───────────────┐ ┌──────────────────┐
│ ④a LLM 裁判 │ │ ④b Python 沙箱 │
│ 渲染模板→调模型│ │ 一次性容器跑代码 │
└───────┬───────┘ └────────┬─────────┘
└────────────┬────────────┘

┌──────────────────┐
│ ⑤ 回写 feedback │
│ score + 用户日志 │
└──────────────────┘

2.2 部件一句话职责

部件干什么在哪个文件
OnlineScoringSampler监听 trace 批量落库事件,查规则、过三道闸门、发消息apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/OnlineScoringSampler.java
OnlineScoringSpanSamplerspan 粒度的同款采样器同目录 OnlineScoringSpanSampler.java
TraceThreadOnlineScoringSamplerListener会话(thread)粒度的同款采样器同目录 TraceThreadOnlineScoringSamplerListener.java
OnlineScorePublisher把消息写进对应类型的 Redis stream,并打点apps/opik-backend/src/main/java/com/comet/opik/domain/evaluators/OnlineScorePublisher.java
BaseRedisSubscriber通用的 Redis Stream 消费管道(读取/重试/ack)apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/BaseRedisSubscriber.java
OnlineScoringBaseScorer在上面那层之上加「打分 + 存 feedback score」的骨架同目录 OnlineScoringBaseScorer.java
OnlineScoringEngine模板渲染、变量取值、把模型返回的 JSON 变成 feedback score同目录 OnlineScoringEngine.java
PythonEvaluatorService后端到 Python 服务的 HTTP 客户端(带重试)apps/opik-backend/src/main/java/com/comet/opik/domain/evaluators/python/PythonEvaluatorService.java
opik-python-backendFlask 服务,收代码+数据,调度沙箱apps/opik-python-backend/src/opik_backend/evaluator.py
opik-sandbox-executor-python真正执行用户代码的一次性容器镜像apps/opik-sandbox-executor-python/scoring_runner.py
opik_optimizerprompt 自动优化 SDKsdks/opik_optimizer/src/opik_optimizer/base_optimizer.py

2.3 optimizer 的自举循环

怎么读这张图:外圈是优化算法的循环,内圈那一步产生的 trace 又被 Opik 自己记下来——所以优化过程本身也可观测、可计费。

optimize_prompt()


┌─────────────────┐
│ 建 OptimizationContext │ ←── 同时在 Opik 服务端建一个 optimization 记录
└────────┬────────┘

┌─────────────────┐
│ 算 baseline 分 │
└────────┬────────┘

┌──────────────────────────────────┐
│ run_optimization()(算法自己写) │
│ ┌───────────────────────────┐ │
│ │ 生成候选 prompt │ │
│ │ ↓ │ │
│ │ self.evaluate(context, …) │──┼──► 跑成一次 experiment(type=trial)
│ │ ↓ │ │ 每次 LLM 调用都被 @track 记 span
│ │ 记 trial、比最佳、查停止 │ │ 计数器/花费累加回 optimizer
│ └───────────┬───────────────┘ │
│ 循环直到 should_stop │
└────────┬─────────────────────────┘

收尾:写回 optimization 状态 = completed / error

3. 在线评估(一):采样端怎么决定「这条要不要打分」

这节讲从数据落库到消息进 Redis 之间发生了什么。

3.1 触发点:两个事件,一个共同前提

OnlineScoringSampler 订阅了两个进程内事件(Guava EventBus):

  • onTracesCreatedOnlineScoringSampler.java:144)——SDK 批量上报后触发。
  • onTracesUpdatedOnlineScoringSampler.java:166)——PATCH 更新后触发,专门补上「先 POST 开头、后 PATCH 收尾」这种写法。

共同前提是只评完整的 trace:创建路径先过滤 endTime != nullOnlineScoringSampler.java:148-150),更新路径则要求本次更新带上了 endTime:166-169)。理由很直白——半条 trace 只有 input 没有 output,打出来的分没有意义。

onTracesUpdated 走的是「事件只带 traceId,再回查 ClickHouse」的路子(:178-183),代码注释自己承认了多副本集群下可能读到还没复制过去的副本,只是概率极低。

3.2 谁有资格被评:来源判定

不是所有 trace 都平等(OnlineScoringSampler.java:217-229):

trace 来源规则怎么应用
SDK 上报(Source.isLoggingSource所有规则都适用
非 SDK(playground / experiment / optimization)只有 metadata 里带 selected_rule_ids 才评,且只跑被点名的那几条规则

这条设计避免了一个很容易踩的坑:optimizer 跑优化时会产生海量 trace,如果它们也自动触发在线评估规则,成本会失控。

3.3 三道闸门

对每条候选 trace × 每条规则,shouldSampleTraceOnlineScoringSampler.java:341)依次问三个问题,任何一个不过就跳过并记一条用户可见日志:

trace × rule


┌──────────────┐ 否 ┌─────────────────────┐
│ 规则启用吗? │ ───► │ skipped_disabled │
└──────┬───────┘ └─────────────────────┘
│ 是

┌──────────────┐ 否 ┌─────────────────────┐
│ 过滤条件命中?│ ───► │ skipped_filter │
└──────┬───────┘ └─────────────────────┘
│ 是

┌──────────────────────┐ 否 ┌───────────────┐
│ random() < samplingRate│ ───►│ skipped_sampling│
└──────┬───────────────┘ └───────────────┘
│ 是

进队列(decision = sampled)

采样那一步用的是 SecureRandom.getInstanceStrong()OnlineScoringSampler.java:100),判据是 secureRandom.nextFloat() >= evaluator.getSamplingRate() 则跳过(:343)。四种决策各自打到同一个 OpenTelemetry 计数器上,标签是 workspace + 规则类型 + 决策(:103-106:71-74),所以运维能直接看到「有多少流量被哪一道闸门挡掉了」。

一个容易看漏的细节: sampled 这个决策不是在摇号成功时记的,而是在消息真的发出去时记的(:259:272),注释明确说了这是为了让指标反映「实际入队量」而不是「摇号通过量」。

3.4 发消息:一个刻意的「fire-and-forget」

采样器跑在 EventBus 线程上,不在响应式链里,所以它没法把 enqueueMessage 组合进自己的流。OnlineScoringSamplerSupport.publishSampledOnlineScoringSamplerSupport.java:25)就是为此存在的一小段共享胶水:

publisher.enqueueMessage(messages, type)
.contextWrite(ctx -> ctx
.put(RequestContext.WORKSPACE_ID, workspaceId)
.put(RequestContext.WORKSPACE_NAME, StringUtils.defaultIfBlank(workspaceName, workspaceId)))
.subscribe(unused -> {
}, error -> log.error(...));

它做三件事:订阅(否则 Mono 根本不会执行)、把 workspace 塞进响应式上下文(下游打点要用)、统一记录失败。trace 采样器和 span 采样器共用它,省掉两处重复。

OnlineScorePublisher.enqueueMessageOnlineScorePublisher.java:126)负责真正写流:按规则类型挑到对应的流配置和编解码器,逐条 stream.add,成功/失败各打一次点(:152-161)。写入参数由 RedisStreamUtils.buildAddArgs 生成,带上非严格裁剪的 maxLenlimitapps/opik-backend/src/main/java/com/comet/opik/infrastructure/redis/RedisStreamUtils.java:10-15)——流不会无限长大。

3.5 每种规则一条流,每条流可以单独调

OnlineScoringConfig 里有一份全局默认值,外加一个 streams 列表,每项描述一条流(OnlineScoringConfig.java:61、内部类 StreamConfiguration:118)。

OnlineScoringStreamConfigurationAdapter 是把这两层拼起来的适配器(OnlineScoringStreamConfigurationAdapter.java:22):按规则类型找到对应的 StreamConfiguration每个 getter 都是「有流级值就用流级值,否则回落全局值」(例如 getConsumerBatchSize:48-53)。

配置项全局默认(config.yml能否按流覆盖
poolingInterval500ms
longPollingDuration5s
consumerBatchSize10
claimIntervalRatio10
pendingMessageDuration10m
maxRetries3
streamMaxLen / streamTrimLimit10000 / 100
consumerGroupNameonline_scoring不能(只有全局)

这套设计的价值在于:LLM-as-judge 每条消息可能要等一次模型调用(秒级),Python 指标是几十毫秒,两者的批大小和轮询节奏本来就不该一样。六条流(llm_as_judgeuser_defined_metric_pythontrace_thread_llm_as_judgetrace_thread_user_defined_metric_pythonspan_llm_as_judgespan_user_defined_metric_python,见 apps/opik-backend/src/main/java/com/comet/opik/api/evaluators/AutomationRuleEvaluatorType.java:33-38)互不干扰。


4. 在线评估(二):消费端的 scorer

这节讲消息进了 Redis 之后,谁把它捞出来、怎么保证不丢不重复。

4.1 消费管道:一条 Reactor 流水线

BaseRedisSubscriber.setupStreamListenerBaseRedisSubscriber.java:345)是整个消费端的骨架,一条链从头到尾:

Flux.interval(poolingInterval) 每 500ms 一个 tick
│ onBackpressureDrop 跟不上就丢 tick(长轮询下这很正常)

concatMap ─────┬─► 第 N 次读:readGroup(长轮询 5s,只拿没投递过的)
一次只读一批 └─► 每第 claimIntervalRatio 次:autoClaim(认领超时未 ack 的)


flatMapIterable 展开成一条条消息


flatMap(processMessage, consumerBatchSize) 并发处理,度 = 批大小
│ 每条跑在 workersScheduler 上

bufferTimeout(batchSize, poolingInterval/3) 攒一小批结果

├─► postProcessSuccessMessages → ack + remove
└─► postProcessFailureMessages → 按可重试性分流

三个调度器各管一摊,互不抢线程(:224-255):timerScheduler 单线程只发 tick;consumerScheduler 上限 4 线程只做 Redis 读写;workersSchedulerconsumerBatchSize * 2 开,跑真正的打分逻辑。

readGroupautoClaim 交替的那一段(:356-363)值得单独看:它用的是自己维护的计数器而不是 tick 序号,因为背压会把 tick 丢掉,用 tick 序号取模会导致认领节奏漂移。

4.2 处理语义:整条链跑完才 ack

这是全章最重要的一条语义。

消息不是「读到就 ack」,而是走完 processEvent → 打分 → 回写 feedback score 全程且成功,才进入 ack + remove(BaseRedisSubscriber.java:504-512:580)。ack 和 remove 是串起来的:ack 成功才 remove(:586-590)。

失败的分流规则(:514-560):

失败类型判据处理
不可重试异常是 NON_RETRYABLE_EXCEPTIONS 里任一类的实例(:63-71立刻 ack + remove,不重试
可重试、未到上限deliveryCount < maxRetries什么都不做——留在 pending 列表里,等下一轮 autoClaim 捞回来重跑
可重试、已到上限deliveryCount >= maxRetriesack + remove,并记 error 日志(代码里留了 TODO: 送 DLQ

不可重试的清单里有 ClientErrorExceptionIllegalArgumentExceptionNullPointerException 等八类;判定用 isInstance,所以子类自动覆盖:639-642)。这条会在 §6 结出一个很实际的果子。

OnlineScoringBaseScorer 在这之上加了一层薄壳(OnlineScoringBaseScorer.java:45):

  • processEvent 被声明为 final:112),只干一件事——把 workspace / user 塞进响应式上下文,因为回写 feedback score 时要读。
  • 子类真正实现的是 score:139),文档注释明确要求 不许调 .block(),整条链必须保持非阻塞。
  • 中间还留了个 doScore 钩子(:130),给需要「打完分再做点收尾」的子类用——收尾也算在「整条链」里,失败就不算处理成功。

4.3 六个 scorer 的分工

Scorer 类粒度打分方式关键入口
OnlineScoringLlmAsJudgeScorertraceLLM 裁判OnlineScoringLlmAsJudgeScorer.java:173
OnlineScoringSpanLlmAsJudgeScorerspanLLM 裁判OnlineScoringSpanLlmAsJudgeScorer.java:110
OnlineScoringTraceThreadLlmAsJudgeScorerthread(整段会话)LLM 裁判OnlineScoringTraceThreadLlmAsJudgeScorer.java:113
OnlineScoringUserDefinedMetricPythonScorertracePython 沙箱OnlineScoringUserDefinedMetricPythonScorer.java:75
OnlineScoringSpanUserDefinedMetricPythonScorerspanPython 沙箱OnlineScoringSpanUserDefinedMetricPythonScorer.java:72
OnlineScoringTraceThreadUserDefinedMetricPythonScorerthreadPython 沙箱OnlineScoringTraceThreadUserDefinedMetricPythonScorer.java:102

三个「粒度」的差异不只是 ID 不同:

  • trace / span 的消息里直接带着实体本身message.trace() / message.span()),不用回查数据库。
  • thread 的消息只带 threadIds,scorer 要用 retrieveFullThreadContext 递归分页把整段会话捞回来(OnlineScoringBaseScorer.java:195-216,每页上限 TRACE_PAGE_LIMIT = 2000,见 :48),再排序、再打分。thread 版还要额外解析规则(规则可能已被删,这时静默跳过,见 OnlineScoringTraceThreadUserDefinedMetricPythonScorer.java:189-193 附近的 findRule)。

三个 Python scorer 各有一个开关:trace 版看 pythonEvaluatorEnabled、span 判官看 spanLlmAsJudgeEnabled、thread 版看 traceThreadPythonEvaluatorEnabled,关掉时消费者根本不启动(例如 OnlineScoringUserDefinedMetricPythonScorer.java:65-72)。

4.4 一个「按需取 spans」的小优化

Python 指标默认只拿 trace 的 input/output/metadata。但有些指标想看整棵调用树。

Opik 的做法是看用户的函数签名要不要:只有当规则的 arguments 里有 spans 这个键,才发那次数据库查询(OnlineScoringUserDefinedMetricPythonScorer.java:91-97)。

Mono<List<Span>> spansMono = message.code().arguments().containsKey(SPANS_ARGUMENT_KEY)
? spanService.getByTraceIds(Set.of(trace.id())).collectList()....
: Mono.just(List.of());

而且这次取数是留在响应式链里的(注释点名 OPIK-6308),不是先 .block() 拿到再算——否则会把 workersScheduler 的线程钉死在等 R2DBC 上。

LLM 裁判那边有个对称的判断 shouldFetchSpansOnlineScoringLlmAsJudgeScorer.java:191),逻辑更绕一点:模板里写了 {{spans}}、或者变量映射到哨兵值 "spans"、或者要走 agentic tools 路径,才拉 spans。

4.5 OnlineScoringEngine:模板怎么变成请求,回答怎么变成分数

这是一个纯静态工具类(OnlineScoringEngine.java:79),两头各管一件事。

这头:变量取值 + 模板渲染。

规则里的 variables 是一张「模板变量名 → 取值路径」的表。toVariableMapping:629)负责解析路径:

路径写法解析结果
input.question从 trace 的 input JSON 里按 $.question
output取整个 output JSON(jsonPath 退化成 $
metadata.modelmetadata 里按 $.model
任意其它字符串不当路径,当字面量直接替换进去

前缀只认 input. / output. / metadata. 三个(:964-969)。取不到值的变量会被过滤掉(:617),渲染时该变量就保持未替换。

拿到 replacements 之后交给 TemplateParseUtils.render 做 Mustache 渲染(:503),支持纯文本 message,也支持多模态的结构化 content 数组(:512-532)。

一个只在 agentic 路径生效的细节:capReplacementsOnlineScoringEngine.java:384-393)会把哨兵变量(如 {{spans}})的替换值截断并附上「用 read/jq 工具下钻」的提示——大 trace 不至于把 prompt 撑爆;但用户映射的变量不截断(截断会迫使裁判做非确定性的工具下钻,OPIK-7110),上限取自配置 maxPromptFieldChars(默认 4000,infrastructure/OnlineScoringConfig.java:139;调用点 OnlineScoringLlmAsJudgeScorer.java:438-448)。

那头:回答变成 feedback score。

toFeedbackScores:900)要求模型返回这个形状:

{
"<分数名>": { "score": 0.8, "reason": "…" },
"<另一个分数名>": { "score": true, "reason": "…" }
}

解析规则很克制:

  • 先用正则从可能的 ```json 代码块里抠出 JSON,抠不到就把整个回答当 JSON(extractJson:953-961)。
  • 布尔值映射成 1 / 0(:932-933),数值直接取 decimalValue()
  • scorenull 的条目不算失败,而是记进 nullScoreNames,语义是「本条不适用」,事后单独写一条用户可见日志(logSkippedNullScores:893)。
  • 一条都没解析出来时,打一条带「期望结构 + 顶层键名 + 截断到 500 字的原始回答」的 warn(:939-949)——这是排查裁判模型跑偏时最有用的一行。

解析出的 FeedbackScoreBatchItem 一律带上 ScoreSource.ONLINE_SCORING:931),所以 UI 上能和人工标注、离线实验分区分开。最后由基类的 storeScores / storeSpanScores / storeThreadScores 落库(OnlineScoringBaseScorer.java:150:151:161)。

裁判 prompt 本身的工程范式(结构化输出、schema 约束)在 05-metrics-and-judges.md 里讲,这里不重复。


5. 规则的持久化模型

这节讲规则本身存在哪、长什么样、怎么被读出来。

5.1 一张表存六种规则

所有规则都落在 MySQL 的 automation_rule_evaluators 表,code 列是一个 JSON(AutomationRuleEvaluatorDAO.java:35-37)。领域模型是一个 sealed 接口 AutomationRuleEvaluatorModel,六个实现类分别对应六种规则类型(AutomationRuleEvaluatorModel.java:13-18)。

两类规则的 code 形状完全不同:

规则类code 记录定义字段
LlmAsJudgeAutomationRuleEvaluatorModelLlmAsJudgeCodeLlmAsJudgeAutomationRuleEvaluatorModel.java:60model(模型参数)、messages(模板消息)、variables(变量→路径)、schema(结构化输出定义)
UserDefinedMetricPythonAutomationRuleEvaluatorModelUserDefinedMetricPythonCodeUserDefinedMetricPythonAutomationRuleEvaluatorModel.java:58metric(一段 Python 源码字符串)、arguments(参数名→取值路径)

共有字段则完全一致:namesamplingRateenabledfiltersprojectIds、审计字段。

5.2 读规则的热路径要走缓存

采样器对每一批 trace 的每个 project 都要问一次「这个 project 有哪些规则」。这条查询是热路径,所以 findAll 上挂了 @Cacheable,key 是 projectId + workspaceId + typeAutomationRuleEvaluatorService.java:578)。

同一个文件里有一段值得读的警告(:490-497):不要给两参数版的 findAll 也加 @Cacheable——它委托给三参数版,两层缓存会导致嵌套的 Mono.block(),触发 Reactor 线程违规和 Redis 超时。这是那种「不写下来下一个人一定会踩」的注释。

5.3 谁决定「哪些数据进采样」:三个过滤服务

规则里的 filters 是在内存里、对着已经拿到的实体对象求值的——不是 SQL。三个服务各管一种实体:

服务实体入口
TraceFilterEvaluationServiceTraceTraceFilterEvaluationService.java:37
SpanFilterEvaluationServiceSpanSpanFilterEvaluationService.java:36
TraceThreadFilterEvaluationServiceTraceThreadModelTraceThreadFilterEvaluationService.java:41

前两个共享基类 FilterEvaluationServiceBaseFilterEvaluationServiceBase.java:34matchesAllFilters:84,语义是 AND),子类只需实现「字段名 → 取值」的映射(TraceFilterEvaluationService.java:58 那个大 switch)。

这里藏着一个真正棘手的问题:内存过滤必须和 ClickHouse 里的 SQL 过滤语义一致,否则同一条 filter 在 UI 上筛出来的和在线评估选中的会是两批数据。normalizeGuardrailsResult:113-124)就是为此存在的:分析侧的 SQL 用 if(has(groupArray(result),'failed'),'failed','passed') 把一堆护栏校验聚合成一个标量字符串,这里就得手工把 List<GuardrailsValidation> 归一成同样的 "failed" / "passed" / null

5.4 规则执行日志怎么回到用户眼前

在线评估是异步的,用户看不到日志就等于黑盒。Opik 的做法是把「规则执行日志」当成一等产品数据存进 ClickHouse。

scorer 里的一行
userFacingLogger.info("Evaluating traceId '{}' …")
│ 外面包着 MDC:user_log / workspace_id / rule_id / trace_id

UserFacingLoggingFactory 给这个类挂的专属 logger
(名字是 "<类名>.UserFacingLog",setAdditive(false) 所以不进普通日志)


AsyncAppender(neverBlock=true)→ ClickHouseAppender 按批 flush


automation_rule_evaluator_logs 表
(timestamp, level, workspace_id, rule_id, message, markers)


GET 规则日志 API → UI 上「这条规则最近发生了什么」

三个关键点:

  1. 专属 logger + 不向上传播。 UserFacingLoggingFactory.getLoggerUserFacingLoggingFactory.java:41-46)给每个类造一个名为 <FQCN>.UserFacingLog 的 logger,挂上异步 appender 并 setAdditive(false),所以这些面向用户的话不会混进服务端日志。
  2. 上下文靠 MDC 传。 每个 scorer 在打日志前都用 wrapWithMdcworkspace_id / rule_id / trace_id 压进 MDC(键定义在 UserLog.java:8-16),写库时 saveAll 从 MDC 里取(AutomationRuleEvaluatorLogsDAO.java:162-170);workspace_idrule_id 缺一个就直接抛 IllegalStateException:162-166)——宁可炸也不写一条没法归属的日志。
  3. trace_id / thread_model_id 存成 markers map。 它们不是独立列,而是塞进 ClickHouse 的 Map 列(CUSTOM_MARKER_KEYS:37,写入用 mapFromArrays,见 :61)。查询侧 findLogs:79)按 workspace + level + ruleId 过滤,按时间倒序分页。

这套机制的直接效果:用户在 UI 上能看到「这条 trace 因为采样率被跳过」「裁判返回了 null 所以这个分不适用」「你的 Python 代码第 7 行报了 KeyError」——全都是 §3 / §4 / §6 里那些 userFacingLogger 调用的产物。


6. 用户自定义 Python 指标怎么安全跑

这节讲一段用户在浏览器里粘贴的 Python 代码,是怎么被跑起来而又不会把服务端搞死的。

6.1 三跳链路

┌────────────────────┐ HTTP POST ┌──────────────────────┐
│ opik-backend (Java)│ ─────────────────────────► │ opik-python-backend │
│ PythonEvaluator │ /v1/private/evaluators/ │ (Flask) │
│ Service │ python │ evaluator.py │
│ 带重试/超时 │ {code, data, type} └──────────┬───────────┘
└────────────────────┘ │ docker exec

┌──────────────────────────────┐
│ opik-sandbox-executor-python │
│ 一次性容器,无网络,非 root │
│ scoring_runner.pyc │
└──────────────────────────────┘
│ stdout 最后一行 JSON

{"scores":[…]} 或 {"error":…}

6.2 第一跳:Java → Flask

PythonEvaluatorServicePythonEvaluatorService.java:27)是一个很薄的 HTTP 客户端:

  • 端点固定 %s/v1/private/evaluators/python:28)。
  • 两个方法:evaluate(code, data) 走普通指标(:33),evaluateThread(code, context) 走会话指标(:43)——后者的请求体多一个 type: "trace_thread" 字段(TraceThreadPythonEvaluatorRequest.java:36-39)。
  • 重试、连接/读超时全部来自配置(:53-66)。

响应处理是整条链最关键的一处翻译:68-84):

if (statusCode == 400) {
throw new BadRequestException(errorMessage);
}
throw new InternalServerErrorException("Python evaluation failed (HTTP " + statusCode + "): " + errorMessage);

回头看 §4.2 的重试分类表:BadRequestExceptionClientErrorException 的子类,而 ClientErrorException 在不可重试清单里(BaseRedisSubscriber.java:63-71),判定用 isInstance。所以——

用户代码写错(400)→ 消息直接 ack 并丢弃,不重试;服务端故障(5xx / 超时)→ 按 maxRetries 重试。

这是「靠异常类型分层」拿到的一个很干净的结果:重试策略不需要认识 Python,它只认识 HTTP 语义。

6.3 第二跳:Flask 端点的契约

execute_evaluator_pythonevaluator.py:42)做四件事:校验 code 非空、校验 data 非空、取出可选的 type、把三者交给当前执行器(:61)。

有两种执行策略(:15-28):docker(每次开一个容器)和 process(本机子进程)。生产镜像里默认是 dockerapps/opik-python-backend/DockerfileENV PYTHON_CODE_EXECUTOR_STRATEGY="docker"),代码里的默认值是 processevaluator.py:11),本地开发用。

还有一条兜底:执行成功但一个分都没返回,也算用户错误,回 400 并附一句「你的代码没有返回任何 ScoreResult」(:66-69)。

6.4 沙箱边界到底有多严

DockerExecutor.create_containerexecutor_docker.py:304)与镜像 Dockerfile 一起划出这条边界:

边界怎么实现默认值
无网络network_disabled(由 PYTHON_CODE_EXECUTOR_ALLOW_NETWORK 取反)禁网(executor_docker.py:110:315
不能提权security_opt=["no-new-privileges"]开启(:316
非 root镜像里 USER 1001:1001固定(apps/opik-sandbox-executor-python/Dockerfile:74-75
内存上限mem_limit256mexecutor.py:19
CPUcpu_shares 软优先级 + 可选 nano_cpus 硬限512 / 不限(executor.py:15:21
执行超时future.result(timeout=exec_timeout)3s(executor.py:52;超时回 HTTP 504,见 executor_docker.py:488-494
一次性每次执行完 release_container:先补一个新容器,再把旧的 remove(force=True)强制(executor_docker.py:334-343:512
并发上限预热的容器池 + max_parallel5(executor.py:51;池空且拿不到就回 HTTP 503)

「一次性容器」这条是安全模型的核心:容器被复用就意味着上一段用户代码留下的文件、内存、猴子补丁可能影响下一段。Opik 选择每次都换新,代价是容器创建延迟——所以它才需要预热池(_pre_warm_container_pool:214)和后台补池线程(_check_pool:189)来把这个代价挪出请求路径。

镜像本身也做了减法:site-packages 全部编译成 .pyc 并删掉 .py、卸掉 pip/setuptools/wheel(apps/opik-sandbox-executor-python/Dockerfile:16-25)。这既是瘦身,也顺手掐掉了「在沙箱里 pip install」这条路。

6.5 沙箱里那段代码的返回值契约

scoring_runner.py 是容器里真正被执行的入口,命令行参数是 [code, data_json, payload_type]executor_docker.py:453,接收端在 scoring_runner.py:127-129)。

它的执行顺序是:

  1. exec(code, module.__dict__) 把用户代码当一个匿名模块执行(:134)。
  2. 在这个模块里找唯一一个 BaseMetric 的子类get_metric_class:107-111)。
  3. 实例化它,然后调 score——普通指标是 metric.score(**data)(把 data 当关键字参数摊开),会话指标是 metric.score(data)(整个当第一个位置参数)(:150-154)。
  4. 把返回值归一成一个列表(单个 ScoreResult 也行、列表也行、None 变空列表,见 to_scores:114-124)。
  5. 把结果 print 成一行 JSON(:162-163)。

所以用户代码的契约就三条:定义一个 BaseMetric 子类;score 的参数名要和规则里 arguments 的键对上;返回 ScoreResult 或它的列表。

一个很有意思的性能技巧在文件开头(:11-102):完整 import opik 要 ~2.5 秒,在 3 秒的超时下这一项就能把整次执行拖死。于是 runner 往 sys.modules 里预先塞了纯标准库实现的 BaseMetric / ScoreResult 桩模块(~8ms),并把同一个桩对象注册进 sys.meta_path 当 finder。用户只 from opik.evaluation.metrics import BaseMetric 就走轻量路径;一旦碰了别的 opik.* 东西,__getattr__find_spec 触发 _load_real_opik(),把桩清掉再真正 import 一次。一个类同时扮演模块和 finder,是因为 Python 解析带点号的子模块走的是 sys.meta_path 而不是父模块的 __getattr__

6.6 失败如何变成用户可见的一行字

三种失败在 runner 里分别 exit(1) 并打印一条 {"error": …}

失败消息开头位置
代码本身语法/导入错误Field 'code' contains invalid Python code:scoring_runner.py:137
没找到 BaseMetric 子类Field 'code' … doesn't contain a subclass implementation of …:142
score() 运行时抛异常The provided 'code' and 'data' fields can't be evaluated::157

前两种和第三种都会附上 stacktrace,但掐掉了前 3 行traceback.format_exc().splitlines()[3:]:136:156)——那 3 行是 runner 自己的调用栈,暴露出去既没用又泄露内部路径。

接下来这条错误一路向上:parse_execution_result 从 stdout 最后一行取出 error 并包成 {"code": 400, …}executor.py:118-148)→ Flask abort(400, …) → Java 侧 BadRequestException → scorer 的 doOnErroruserFacingLogger.error 写出去(OnlineScoringUserDefinedMetricPythonScorer.java:108-110)→ ClickHouse → 用户在规则日志里看见自己的报错。


7. prompt 自动优化(sdks/opik_optimizer

前面六节讲的是「分怎么打出来」。这一节反过来:有了分之后,怎么自动把 prompt 改好。

7.1 一个模板方法骨架

BaseOptimizerbase_optimizer.py:89)是抽象基类,但它抽象的不是算法,而是流程。子类只需要实现 run_optimization,其余全由基类包办。

optimize_prompt:1502)就是那个模板方法,主体只有十几行(:1561-1595):

optimize_prompt()

├─ _validate_and_prepare(...) ──► _setup_optimization(...) 建 context

├─ _run_baseline(context) ──► _calculate_baseline(...) 算初始分

├─ if _should_skip_optimization(baseline) ──► 直接返回 baseline 结果

└─ _run_algorithm_and_finalize(...)
├─ run_optimization(context) ← 子类实现的算法在这里
├─ _finalize_finish_reason(...)
├─ _build_final_result(...)
└─ _finalize_optimization(status="completed" | "error")

各阶段职责:

阶段方法干了什么
建上下文_setup_optimization:400归一化 prompt、解析工具、校验输入、选评估集、建 agent、重置计数器、建 optimization 记录
选评估集_select_evaluation_dataset:373validation_dataset 就用它,否则用训练集;不支持的优化器会 warn 后回落
建服务端记录_create_optimization_run:334create_optimization失败只 warn 不抛,优化继续跑,只是没有服务端追踪(:368-371
算基线_calculate_baseline:588用初始 prompt 评一次,作为「改没改好」的参照
评一个候选evaluate:642优化器唯一该调的打分入口
评并拿明细evaluate_with_result:734同上,但连 EvaluationResult 一起返回,给需要看单条失败的算法用
记一次 trial_handle_trial_end:796trials_completed += 1、更新最佳分与最佳 prompt、推进度显示
跑算法run_optimization:827默认转发到 _run_optimization,基类里直接 NotImplementedError:1081-1093

OptimizationContextcore/state.py:36)是贯穿全程的唯一状态源:输入配置(prompts / dataset / metric / agent)、运行时状态(baseline_scorecurrent_best_scoretrials_completed)、控制标志(should_stopfinish_reasonmax_trials)都在里面。文档注释明确要求优化器不要自己另建计数器,否则预算、停止判断、报表三者会对不上。

evaluate 的收尾三步(:708-714)体现了「框架管纪律」这个设计:

coerced_score = self._coerce_score(score)
prev_best_score = context.current_best_score
self.on_trial(context, prompts, coerced_score, prev_best_score) # 内部走 _handle_trial_end
self._should_stop_context(context) # 设置 should_stop 标志,而不是抛异常
return coerced_score

设标志而不是抛异常是刻意的(:712 注释):算法可能正在一次种群评估的中间,直接抛会打断状态;把标志放回 context,让算法在自己的循环边界上优雅退出。

评估失败则相反——finish_reason = "error"should_stop = True,然后原样往上抛:703-707),因为评估失败不是「这个候选不好」,是环境坏了。

7.2 自举:优化过程本身也被 Opik 追踪计费

这是 optimizer SDK 最有意思的一处设计。优化会烧掉大量 token,如果这部分不可观测,用户就只能看着账单发呆。

四条线合起来构成自举:

机制做什么位置
_create_optimization_run在 Opik 服务端建一条 optimization 记录,拿到 optimization_idbase_optimizer.py:355
_tag_trace给当前 trace 打上 [优化器简称, optimization_id, 阶段] 三个标签:164-178
_increment_llm_counter / _increment_llm_call_tools_counter累计 LLM 调用次数、工具调用次数:225:229
_add_llm_cost / _add_llm_usage累计花费与 token 用量:233:237

计数不是优化器自己数的,而是agent 反向推回来的_attach_agent_owner:294-303)把 optimizer 挂到 agent 上(agent._optimizer_owner = self),agent 每次调完模型就顺着这个引用回调(agents/optimizable_agent.py:333-346):

def _increment_llm_counter(self) -> None:
optimizer_ref = self.optimizer
if optimizer_ref is not None and hasattr(optimizer_ref, "_increment_llm_counter"):
optimizer_ref._increment_llm_counter()

而 agent 的每次调用本身又是被追踪的:_llm_completetrack_completion()(litellm.completion) 包住 LiteLLM,并把当前 span 数据塞进 metadata(agents/optimizable_agent.py:118-147)。这条链的终点就是 01-tracing-decorator.md 讲的那套 span 树。

注意这些追踪调用几乎全都包在 try/except: pass:175-178:180-185:294-303)。取舍很明确:追踪失败不该让优化失败。

7.3 每个候选评估 = 一次 trial 实验

优化器不是自己写评估循环的,它复用 Python SDK 的评估引擎(04-evaluation-engine.md)。

分叉点在 _run_evaluatorcore/evaluation.py:397):

if optimization_id is not None:
return opik_evaluator.evaluate_optimization_trial(optimization_id=optimization_id, ...)
return opik_evaluator.evaluate(...)

evaluate_optimization_trialsdks/python/src/opik/evaluation/evaluator.py:1289)和普通 evaluate 的差别只有一处,但很关键——它建的 experiment 带 type="trial"optimization_id:1379-1388)。

所以服务端看到的层级是:

optimization(一次优化运行)
├── experiment(type=trial) ← 候选 1
├── experiment(type=trial) ← 候选 2
└── … 每个 trial 内含 N 条 trace / span

一次优化跑完,你能在 UI 上逐个 trial 回看:这个候选 prompt 长什么样、在哪些 dataset item 上翻了车、花了多少钱。

7.4 OptimizableAgent:算法与「怎么调模型」的解耦点

OptimizableAgentagents/optimizable_agent.py:43)是优化器和「实际执行 prompt 的东西」之间的接口。默认实现是 LiteLLMAgent_setup_optimization 在用户没传 agent 时自动建一个(base_optimizer.py:511-513)。

它对外的关键方法:

方法用途
init_agent(prompt)绑定要执行的 prompt,同步 model / model_kwargs(:107-116
invoke_agent(...)执行一次,返回单个输出(多 prompt 场景要子类覆写,:398-423
invoke_agent_candidates(...)返回多个候选输出,给 pass@k 用(:429,默认实现尚未真正支持 pass@k)
_invoke_with_tools / _handle_tool_calls带工具调用的多轮循环(:239:285

你要优化的如果不是「一次 LLM 调用」而是「一个多步 agent」,就子类化它、覆写 invoke_agent——优化算法一行都不用改。

7.5 六个优化器:搜什么、怎么搜

六个算法都在 algorithms/ 下,各自只覆写 run_optimization。不逐个深挖,一张表对比:

优化器搜的是什么搜索策略外部依赖支持工具描述优化run_optimization
FewShotBayesianOptimizer少样本示例的数量与选择先生成 few-shot 模板,再用 TPE 贝叶斯搜示例组合optunafew_shot_bayesian_optimizer.py:823
MetaPromptOptimizerprompt 文本让 LLM 对「当前表现」做元推理,生成改进候选再评无(纯 LLM)meta_prompt_optimizer.py:222
EvolutionaryOptimizerprompt 文本(可含工具描述)遗传算法:种群 + 变异 + 交叉 + 精英保留,支持多目标(分数 vs 长度)deapevolutionary_optimizer.py:392
GepaOptimizerprompt 文本遗传-帕累托(适配外部 GEPA 库,多目标权衡)gepa否(代码里有 FIXME 待启用)gepa_optimizer.py:687
HierarchicalReflectiveOptimizerprompt 文本分批分析失败样本 → 综合出统一失败模式 → 定向改写无(纯 LLM)hierarchical_reflective_optimizer.py:593
ParameterOptimizer不改 prompt,改模型参数(temperature / top_p 等)贝叶斯全局搜 + 按 local_search_ratio 分配的局部精搜optunaparameter_optimizer.py:133

选型直觉,四句话:

  • 任务清晰、给几个例子就能带对FewShotBayesianOptimizer
  • 已有一版还行的 prompt,想让它更规范MetaPromptOptimizer
  • 想大范围探索措辞,愿意烧预算EvolutionaryOptimizer / GepaOptimizer
  • prompt 本身没大问题,是「有些情况就是会错」HierarchicalReflectiveOptimizer(它会先归因失败模式);只想调温度ParameterOptimizer

三个能力标志位(supports_prompt_optimization / supports_tool_optimization / supports_multimodal,见 base_optimizer.py:92-94)是框架做前置校验用的:例如 ParameterOptimizer.supports_prompt_optimization = Falseparameter_optimizer.py:49),传了 optimize_tools 给不支持的优化器会 warn 后忽略(base_optimizer.py:447-452)。


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

  1. 用异常类型而不是错误码分层重试。 Python 沙箱的用户代码错误被翻译成 BadRequestExceptionPythonEvaluatorService.java:92-94),而 Redis 消费者的不可重试清单里有 ClientErrorException 且用 isInstance 判定(BaseRedisSubscriber.java:63-71:639-642)。两个模块互不认识,却自动得到「用户错不重试、系统错重试」的正确行为。

  2. 「整条链跑完才 ack」+ autoClaim = 不需要额外的重试队列。 失败的消息什么都不做,留在 pending 里等超时被认领(BaseRedisSubscriber.java:562-571),重试次数直接读 Redis 的 deliveryCount:651-655)。不用自建重试表。

  3. 指标打在「真正发生」的那一刻。 采样的 sampled 计数记在入队时而非摇号时(OnlineScoringSampler.java:260:348-351 的注释),这样指标反映的是实际负载而不是意图。

  4. 按需 I/O:看函数签名决定要不要查库。 只有用户的 Python 指标声明了 spans 参数才去取 spans(OnlineScoringUserDefinedMetricPythonScorer.java:91)。大多数指标省下一次数据库往返。

  5. 一次性容器 + 预热池。 安全要求容器不可复用,性能要求容器不能现开——用后台补池把创建代价挪出请求路径(executor_docker.py:214:334-343)。

  6. 导入桩:一个类同时当模块和 meta_path finder。 沙箱里 import opik 要 2.5 秒而超时只有 3 秒,于是先塞轻量桩,碰到真需求再热切换(scoring_runner.py:52-88)。

  7. null 分数不算失败。 裁判返回 score: null 被解释成「本条不适用」,单独记一条用户日志而不是报错(判空 OnlineScoringEngine.java:1308-1311、用户日志 :1123)。

  8. 优化框架用「设标志」而不是「抛异常」表达停止。 evaluate 只把 should_stop 写回 context,让算法在自己的循环边界退出(base_optimizer.py:790-791)。

  9. 追踪失败绝不拖垮主流程。 optimizer 里所有 Opik 交互都包在 try/except: pass 或降级分支里(base_optimizer.py:196-199:368-371)。

  10. 内存过滤刻意去对齐 SQL 语义。 normalizeGuardrailsResult 手工复刻 ClickHouse 的聚合表达式(TraceFilterEvaluationService.java:113-124),保证 UI 筛出的和在线评估选中的是同一批数据。


9. 边界与局限

  • 在线评估只对完整 trace 生效。 没有 endTime 的 trace 一律不评(OnlineScoringSampler.java:148:166)——长时间运行、始终没收尾的 trace 就永远不会被打分。

  • 重试到顶就丢。 达到 maxRetries 的消息被 ack + remove,代码里明写着 TODO: 送 DLQBaseRedisSubscriber.java:573-577)。当前版本没有死信队列。

  • 更新路径存在理论上的读复制竞态。 onTracesUpdated 靠 traceId 回查 ClickHouse,多副本集群下可能读到未复制的副本,注释承认了这一点(OnlineScoringSampler.java:175-178)。

  • Python 沙箱有硬性资源天花板。 默认 3 秒超时、256 MB 内存、禁网(executor.py:52:18executor_docker.py:110)——需要外部请求或重计算的指标在这里跑不了。

  • 并发受容器池限制。 默认 max_parallel = 5,池空且 pool_acquire_timeout 默认为 0(快速失败),直接回 HTTP 503(executor.py:51:55executor_docker.py:445-446)。突发流量靠客户端退避重试吸收。

  • 每段用户代码只能有一个指标类。 get_metric_class 取找到的第一个 BaseMetric 子类(scoring_runner.py:107-111),多个类的行为不确定。

  • consumerGroupName 不能按流覆盖。 适配器里唯一直接读全局值的 getter(OnlineScoringStreamConfigurationAdapter.java:56-58)。

  • optimizer 的服务端追踪是尽力而为。 create_optimization 失败只 warn,优化照跑但没有服务端记录(base_optimizer.py:389-392);此后所有依赖 optimization_id 的追踪(trial 实验、trace 标签)都会静默降级。

  • GepaOptimizer 暂不支持工具描述优化,源码里是明确的 FIXME(gepa_optimizer.py:437-439);invoke_agent_candidates 的默认实现也还不是真正的 pass@k(agents/optimizable_agent.py:464 附近的 TODO)。


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

在线评估 · 采样端

主题文件路径符号名
trace 采样入口apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/OnlineScoringSampler.javaonTracesCreatedonTracesUpdatedsampleAndScore
三道闸门同上shouldSampleTraceskiprecordDecision
规则点名(非 SDK trace)同上extractSelectedRuleIdsisEvaluatorSelectedForTrace
span 采样同目录 OnlineScoringSpanSampler.javaonSpansCreatedshouldSampleSpan
thread 采样同目录 TraceThreadOnlineScoringSamplerListener.javaSUPPORTED_EVALUATOR_TYPES
入队胶水同目录 OnlineScoringSamplerSupport.javapublishSampled
写 Redis 流apps/opik-backend/src/main/java/com/comet/opik/domain/evaluators/OnlineScorePublisher.javaenqueueMessageenqueueThreadMessage

在线评估 · 消费端

主题文件路径符号名
Redis 消费管道apps/opik-backend/src/main/java/com/comet/opik/api/resources/v1/events/BaseRedisSubscriber.javasetupStreamListenerprocessMessagepostProcessSuccessMessagespostProcessFailureMessagesackAndRemoveMessagesNON_RETRYABLE_EXCEPTIONS
scorer 骨架同目录 OnlineScoringBaseScorer.javaprocessEventdoScorescorestoreScoresretrieveFullThreadContext
模板渲染 / 分数解析同目录 OnlineScoringEngine.javaprepareLlmRequesttoVariableMappingtoReplacementscapReplacementstoFeedbackScoresextractJsonlogSkippedNullScores
trace 裁判同目录 OnlineScoringLlmAsJudgeScorer.javascoredoScoreshouldFetchSpansMAX_PROMPT_FIELD_CHARS
span 裁判同目录 OnlineScoringSpanLlmAsJudgeScorer.javascoreevaluate
thread 裁判同目录 OnlineScoringTraceThreadLlmAsJudgeScorer.javascoreprocessThreadScoresscoreThread
trace Python 指标同目录 OnlineScoringUserDefinedMetricPythonScorer.javascoreprepareDataSPANS_ARGUMENT_KEY
span / thread Python 指标同目录 OnlineScoringSpanUserDefinedMetricPythonScorer.javaOnlineScoringTraceThreadUserDefinedMetricPythonScorer.javascorefindRule
流配置apps/opik-backend/src/main/java/com/comet/opik/infrastructure/OnlineScoringConfig.javaPAYLOAD_FIELDStreamConfigurationagenticToolsThresholdTokens
配置回落同目录 OnlineScoringStreamConfigurationAdapter.javacreategetConsumerBatchSize

规则持久化与日志

主题文件路径符号名
规则服务(带缓存)apps/opik-backend/src/main/java/com/comet/opik/domain/evaluators/AutomationRuleEvaluatorService.javafindAllfindByIdsave
规则 DAO同目录 AutomationRuleEvaluatorDAO.javasaveEvaluatorupdateEvaluatordeleteEvaluatorsByIds
规则模型(sealed)同目录 AutomationRuleEvaluatorModel.javaAutomationRuleEvaluatorModel
裁判规则同目录 LlmAsJudgeAutomationRuleEvaluatorModel.javaLlmAsJudgeCode
Python 规则同目录 UserDefinedMetricPythonAutomationRuleEvaluatorModel.javaUserDefinedMetricPythonCode
过滤求值同目录 FilterEvaluationServiceBase.javaTraceFilterEvaluationService.javaSpanFilterEvaluationService.javaTraceThreadFilterEvaluationService.javamatchesAllFiltersextractFieldValuenormalizeGuardrailsResult
规则日志落库/查询同目录 AutomationRuleEvaluatorLogsDAO.javasaveAllfindLogsCUSTOM_MARKER_KEYS
用户可见 loggerapps/opik-backend/src/main/java/com/comet/opik/infrastructure/log/UserFacingLoggingFactory.javagetLoggerinit
MDC 键apps/opik-backend/src/main/java/com/comet/opik/domain/evaluators/UserLog.javaMARKERRULE_IDTRACE_IDTHREAD_MODEL_ID

Python 沙箱

主题文件路径符号名
Java 侧 HTTP 客户端apps/opik-backend/src/main/java/com/comet/opik/domain/evaluators/python/PythonEvaluatorService.javaevaluateevaluateThreadprocessResponse
请求/响应契约同目录 PythonEvaluatorRequest.javaPythonEvaluatorResponse.javaPythonScoreResult.javaTraceThreadPythonEvaluatorRequest.javaPythonScoreResultChatMessage
Flask 端点apps/opik-python-backend/src/opik_backend/evaluator.pyexecute_evaluator_pythoninit_executor
执行器基类apps/opik-python-backend/src/opik_backend/executor.pyCodeExecutorBaseparse_execution_resultDEFAULT_MEM_LIMITSATURATED_ERROR
Docker 执行器apps/opik-python-backend/src/opik_backend/executor_docker.pyDockerExecutorrun_scoringcreate_containerrelease_containerget_container
沙箱入口脚本apps/opik-sandbox-executor-python/scoring_runner.pyget_metric_classto_scores_FallbackModule_load_real_opik
沙箱镜像apps/opik-sandbox-executor-python/DockerfileUSER 1001:1001compileall

Prompt 优化 SDK

主题文件路径符号名
优化骨架sdks/opik_optimizer/src/opik_optimizer/base_optimizer.pyBaseOptimizeroptimize_prompt_setup_optimization_calculate_baselineevaluateevaluate_with_result_handle_trial_endrun_optimization
自举追踪同上_create_optimization_run_tag_trace_increment_llm_counter_add_llm_cost_attach_agent_owner
状态对象sdks/opik_optimizer/src/opik_optimizer/core/state.pyOptimizationContextAlgorithmResultFinishReason
评估分叉sdks/opik_optimizer/src/opik_optimizer/core/evaluation.py_run_evaluator_extract_objective_scores
trial 实验sdks/python/src/opik/evaluation/evaluator.pyevaluate_optimization_trial
Agent 接口sdks/opik_optimizer/src/opik_optimizer/agents/optimizable_agent.pyOptimizableAgent_llm_completeinvoke_agent_increment_llm_counter
六个算法sdks/opik_optimizer/src/opik_optimizer/algorithms/*/FewShotBayesianOptimizerMetaPromptOptimizerEvolutionaryOptimizerGepaOptimizerHierarchicalReflectiveOptimizerParameterOptimizer

11. 和本组其它章的关系