Skip to content

9.3 Experiment 子系统:从 Prompt Experiment 到 Skill / AgentLoop 评估

学习目标

完成本节后,你将能够:

  1. 说清 Dataset -> DatasetRun / Experiment -> DatasetRunItem -> Trace / Observation -> Score 这条离线实验链路。
  2. 区分当前 OSS repo 里的 Prompt Experiment 和更一般的 Experiment 运行模型。
  3. 理解为什么 UI 创建 experiment 时必须选择 prompt,但底层 DatasetRun 抽象并不要求一定是 prompt。
  4. 看懂 prompt experiment 从 UI、tRPC、Postgres、BullMQ、worker、LLM completion、internal tracing、ClickHouse、Evaluation 的完整执行过程。
  5. 评估如果把 skills 当成新的候选资产、把一次 LLM completion 扩展成 agent loop runtime,Langfuse 当前抽象哪些地方顺,哪些地方还缺控制面。
  6. 复用这套模型设计自己的 SkillOps / AgentOps evaluation infra。

9.3.1 先给结论

【标记约定】 本节会明确区分两类内容:【当前实现】 表示当前 Langfuse OSS repo 已经存在的代码路径、表结构和运行链路;【扩展设计】 表示基于当前抽象推导出的改造建议或未来形态。看到“应该怎么改”“建议怎么做”“如果扩展”时,都按扩展设计理解,不要当成当前 repo 已实现。

当前 Langfuse repo 已经有一套比较完整的实验骨架:

text
Dataset
  -> DatasetRun / Experiment
  -> DatasetRunItem
  -> Trace / Observation tree
  -> Score
  -> Experiment comparison / Score analytics

但是当前 UI 内置的实验执行器主要是 Prompt Experiment

text
Prompt Management
  + Dataset
  + Model / Provider config
  -> 对每个 DatasetItem 做一次 LLM completion
  -> 写入 trace / root observation
  -> 触发 experiment evaluator

这不是现代 agent 系统的完整 runtime。它没有内置一个多步循环来执行 plan、tool call、file read/write、sub-agent、patch、test、retry。它的运行核心是:

text
replaceVariablesInPrompt(...)
  -> fetchLLMCompletion({ streaming: false, ... })

所以本节把这个子系统命名为 Experiment 子系统Candidate 是本教程为了教学引入的上层抽象,表示“拿来被 dataset 评估的候选方案”。当前 repo 中最成熟的一类 candidate 是 Prompt;如果要扩展到 skills,可以把 Skill 设计成第二类 candidate。

text
Candidate = Prompt | Skill | Workflow | Agent
Runtime   = LLM Completion | Agent Loop | External Runner
Experiment = Dataset + CandidateVersion + RuntimeConfig + EvaluatorSuite 的一次运行快照

评估结论是:

问题结论
Langfuse 的事实抽象够不够?基本够。Trace / Observation tree 很适合表达 agent loop 里的多步骤事实;Score 也足够挂到 trace、observation、session、dataset run。
Langfuse 的 experiment 抽象顺不顺?顺。DatasetRun 表示一次 run,DatasetRunItem 把样本和运行产物连接起来,这个模型不关心 runtime 是 prompt completion 还是 agent loop。
当前 OSS 是否已经有 Skill Candidate 控制面?没有。没有 Skill / SkillVersion / artifact hash / runtime policy / run spec 等一等对象。
当前 UI Prompt Experiment 能不能直接代表现代 agent eval?不能。它是 prompt 管理 + 单次 completion runtime;agent eval 需要扩展 candidate 维度和 runtime 维度。
如果自己做类似 infra,最值得复用什么?复用 DatasetRunItem -> Trace / Observation -> Score 这条事实链路,把新的 candidate/runtime 信息放进 run spec 和 event metadata,再补控制面。

这也是本节最重要的设计判断:Langfuse 的数据面抽象已经接近通用 LLM/agent evaluation infra;但候选资产管理和 runtime 编排控制面仍是 prompt-centric 的。

9.3.2 业务闭环:这个子系统解决什么问题

Evaluation 子系统回答的是“运行结果好不好”。Experiment 子系统回答的是“可比较的运行结果从哪里来”。

一个实验闭环不是从 evaluator 开始,而是从候选方案开始:

用业务语言说:

  1. 你有一批固定 case,也就是 dataset。
  2. 你有一个候选方案,例如 prompt v3、skill v7、agent workflow commit abc123
  3. 你选择一个 runtime,例如一次 chat completion、一个 AgentLoop runner、外部 CI 任务。
  4. runtime 对每个 dataset item 执行一次任务。
  5. 每个 item 产生一条 trace,trace 里面有 observation tree。
  6. DatasetRunItem 负责把“这个样本”和“这次运行产物”连接起来。
  7. evaluator 对运行产物打 score。
  8. experiment comparison 按 run 和 item 聚合,告诉你哪个候选方案更好。

所以这个子系统的职责不是打分,而是产生可比较、可追溯、可复现的运行事实。

9.3.3 核心对象:先把候选方案、DatasetRun、Runtime 分开

名词当前 repo 中的表达本节怎么理解容易误解的点
Candidate当前主要是 Prompt,未来可以是 Skill / Workflow / Agent被拿来评估的候选方案不等于运行结果,也不等于 evaluator。
CandidateVersionPrompt.name + version/label;skill 场景需要新增版本和 artifact hash候选方案的一次不可变版本如果只存名字不存版本,experiment 不能复现。
DatasetPrisma Dataset一组测试材料不是运行结果。
DatasetItemPrisma DatasetItem一个测试 case,含 input / expectedOutput / metadata不是 trace;它只是输入材料。
DatasetRun / ExperimentPrisma DatasetRuns,ClickHouse event 里叫 experiment_id对一个 dataset 的一次运行快照不是多个 session 的集合。
DatasetRunItemPrisma DatasetRunItems,ClickHouse dataset_run_items_rmt一个 dataset item 在一次 run 中对应的运行产物连接不是运行过程本身,运行过程还是 trace / observation。
Runtime当前 UI prompt experiment worker;SDK/remote 可用外部 runtime执行 candidate 的东西当前内置 runtime 是单次 LLM completion,不是 agent loop。
TraceClickHouse traces / events trace context一次 item 执行产生的完整运行树对 agent 来说更像一次 task run,而不是传统 HTTP request。
ObservationClickHouse observations / events运行中的一个步骤:generation、span、tool、retrieval 等agent loop 的最底层事实通常在 observation tree。
ScoreClickHouse scoresevaluator 或人工/API 写入的评价事实Score 是结果,不是 scorer 配置。

这几个对象的组合关系如下:

读源码时要抓住这条主线:Prompt 只是当前内置 candidate;DatasetRun 才是 experiment run 的底层承载对象。

9.3.4 子系统总图:控制面、运行面、事实面、评价面

这张图有四个重点:

  1. Postgres 存控制面和关系:prompt、dataset、dataset item、dataset run 这些对象需要权限、唯一约束、更新和删除。
  2. Redis/BullMQ 只存短期调度:experiment create、dataset run item upsert、eval execution 都是异步任务,不是最终事实。
  3. ClickHouse 存运行事实和分析投影:trace / observation / score / dataset run item projection 都服务于高吞吐查询。
  4. runtime 可以替换:当前内置 runtime 是 prompt completion;SDK、remote webhook、未来 agent loop 都可以产生同样的 trace/observation/run-item 事实。

9.3.5 当前实现:UI Prompt Experiment 怎么开启

【当前实现】 本节描述当前 OSS repo 中已经存在的 Prompt Experiment 创建路径。

先看当前 OSS 里最具体的路径。用户在 UI 里启动 Prompt Experiment,本质是调用 tRPC mutation:

源码入口:

  • web/src/features/experiments/server/router.ts
  • mutation:createExperiment

输入格式被 zod 固定为:

ts
{
  projectId: string;
  name: string;
  runName: string;
  promptId: string;
  datasetId: string;
  datasetVersion?: Date;
  description?: string;
  modelConfig: {
    provider: string;
    model: string;
    modelParams: ZodModelConfig;
  };
  structuredOutputSchema?: Record<string, unknown>;
}

这里 promptId 是 required,所以 UI Prompt Experiment 一定要选择 prompt。原因不是 DatasetRun 必须绑定 prompt,而是当前 UI 内置 runtime 需要 prompt 来生成 messages。

创建过程可以压缩成:

dataset_runs.metadata 在这条路径里是 prompt runtime 的配置载体:

json
{
  "prompt_id": "prompt_123",
  "provider": "openai",
  "model": "gpt-4.1",
  "model_params": {
    "temperature": 0
  },
  "structured_output_schema": {
    "type": "object"
  },
  "experiment_name": "support-bot-prompt-test",
  "experiment_run_name": "prompt-v3-gpt-4.1",
  "dataset_version": "2026-07-02T00:00:00.000Z"
}

对应源码契约是 packages/shared/src/server/llm/types.tsExperimentMetadataSchema

ts
{
  prompt_id: string;
  provider: string;
  model: string;
  model_params: ZodModelConfig;
  structured_output_schema?: LLMJSONSchema;
  experiment_name?: string;
  experiment_run_name?: string;
  error?: string;
  dataset_version?: Date;
}

这说明当前内置 experiment worker 是 prompt-specific 的。它读取 dataset_runs.metadata 后必须能拿到 prompt_id、模型 provider、model 和 model params。

9.3.6 队列契约:ExperimentCreateEvent 只传 run 指针

【当前实现】 ExperimentCreateEvent 是当前 repo 已定义的 BullMQ payload schema,不是未来设计。

创建 experiment 后,UI 不同步执行 dataset 的全部 items,而是投递队列:

源码入口:

  • packages/shared/src/server/queues.ts
  • ExperimentCreateEventSchema
  • QueueName.ExperimentCreate
  • QueueJobs.ExperimentCreateJob

队列 payload 很小:

ts
{
  projectId: string;
  datasetId: string;
  runId: string;
  description?: string;
}

这有一个重要设计含义:队列里不复制 prompt 内容、dataset items、model config。worker 收到 runId 后再去 Postgres 读取 dataset_runs.metadata、prompt、API key、dataset items。

这样做的好处:

设计点作用
queue payload 小不把大量 dataset item 和 prompt 内容塞进 Redis。
runId 是主指针worker 可以重试,重试时从 DB 重新读取当前 run 配置。
配置在 Postgresrun metadata 有事务和权限边界。
dataset item 运行前读取可以按 dataset version / ACTIVE status 选择 item。

【扩展设计】 代价是:如果你要做强复现,必须保证 metadata 存的是不可变版本指针,而不是会漂移的 label。当前 prompt experiment 存 prompt_id,worker 根据 id resolve prompt;如果未来 skill candidate 只存 skillName=qa-bot 而不存 version/hash,实验就很难复现。

9.3.7 Worker 执行:一次 DatasetItem 如何变成 Trace

【当前实现】 本节描述 worker/src/features/experiments/experimentServiceClickhouse.ts 的真实执行流程。

worker 的核心入口:

  • worker/src/features/experiments/experimentServiceClickhouse.ts
  • createExperimentJobClickhouse
  • processItem
  • processLLMCall

整体执行顺序:

对一个 item,源码里先生成两个关键 id:

ID来源作用
traceIdcreateW3CTraceId(runId + "-" + datasetItem.id)同一个 run + item deterministic 地对应一条 trace,避免重复创建。
runItemIduuid.v4()当前 dataset run item 的 id。

然后 worker 先写一个 DATASET_RUN_ITEM_CREATE 事件:

json
{
  "id": "run_item_123",
  "type": "dataset-run-item-create",
  "timestamp": "2026-07-02T00:00:00.000Z",
  "body": {
    "id": "run_item_123",
    "traceId": "trace_abc",
    "observationId": null,
    "error": null,
    "createdAt": "2026-07-02T00:00:00.000Z",
    "datasetId": "dataset_123",
    "runId": "run_123",
    "datasetItemId": "item_123",
    "datasetVersion": "2026-07-01T00:00:00.000Z"
  }
}

这个事件的作用不是写 LLM 输出,而是先建立 run item 到 trace 的连接。后续 completion 写出来的 root observation 会使用同一条 trace。

9.3.8 当前 runtime:Prompt + DatasetItem -> 一次 LLM Completion

【当前实现】 当前内置执行器可以理解为 PromptCompletionRunner:它把 Prompt + DatasetItem + ModelConfig 变成一次非 streaming 的 LLM completion。fetchLLMCompletion 是这个 runner 内部的低层调用,不应该作为 Experiment 子系统的顶层抽象名。

当前 Prompt Experiment runtime 的核心就是两步。

第一步,把 dataset item input 填到 prompt:

源码入口:

  • worker/src/features/experiments/utils.ts
  • replaceVariablesInPrompt

它支持两种 prompt 形态:

Prompt 类型当前处理方式
text prompt当成 system message,替换
chat prompt编译 chat messages,支持 message placeholder,再替换普通变量。

例子:

text
Prompt:
你是客服助手,请根据文档回答:
{{documentation}}

问题:{{question}}
json
{
  "documentation": "退款政策:30 天内可退。",
  "question": "我 20 天前买的,可以退款吗?"
}

会被编译成:

json
[
  {
    "role": "system",
    "content": "你是客服助手,请根据文档回答:\n退款政策:30 天内可退。\n\n问题:我 20 天前买的,可以退款吗?"
  }
]

第二步,调用 LLM:

源码入口:

  • worker/src/features/experiments/experimentServiceClickhouse.ts
  • processLLMCall
  • fetchLLMCompletion

关键调用形态:

ts
fetchLLMCompletion({
  streaming: false,
  llmConnection: config.validatedApiKey,
  maxRetries: 1,
  messages,
  modelParams: {
    provider: config.provider,
    model: config.model,
    adapter: config.validatedApiKey.adapter,
    ...config.model_params,
  },
  structuredOutputSchema: config.structuredOutputSchema,
  traceSinkParams,
});

这说明当前内置 Prompt Experiment 是:

text
Prompt Management
  + prompt variable rendering
  + one non-streaming LLM completion
  + Langfuse internal tracing

它不是:

text
while not done:
  plan
  call model
  call tools
  read/write files
  spawn sub-agent
  apply patch
  run tests
  retry

所以如果你要评估 skills 或 coding agents,不能把现有 prompt experiment worker 直接理解成 agent runner。你可以复用它的 experiment/run-item/eval 数据模型,但需要新的 runtime。

9.3.9 运行事实:experiment context 如何进入 events

【当前实现】 本节描述当前 Prompt Experiment worker 如何把 experiment context 写入 ClickHouse event fields。

Prompt worker 调用 fetchLLMCompletion 时会传 traceSinkParams。其中最关键的是 createInternalEventsWriter

源码入口:

  • worker/src/features/experiments/experimentServiceClickhouse.ts
  • createInternalEventsWriter
  • packages/shared/src/server/repositories/definitions.ts

writer 会把 experiment context 写到 root event / observation 上:

ts
experimentContext: {
  id: config.runId,
  name: config.datasetRun.name,
  metadata: asRecord(config.datasetRun.metadata),
  description: config.datasetRun.description,
  datasetId: datasetItem.datasetId,
  itemId: datasetItem.id,
  itemVersion: convertDateToClickhouseDateTime(datasetItem.validFrom),
  itemExpectedOutput: datasetItem.expectedOutput,
  itemMetadata: asRecord(datasetItem.metadata),
}

ClickHouse event record 里对应这些字段:

字段含义
experiment_id当前 DatasetRun.id
experiment_name当前 run name。
experiment_metadata_names / experiment_metadata_valuesrun metadata。
experiment_descriptionrun description。
experiment_dataset_id所属 dataset。
experiment_item_id当前 dataset item。
experiment_item_version当前 dataset item version。
experiment_item_expected_outputexpected output,供 evaluator 使用。
experiment_item_metadata_names / experiment_item_metadata_valuesitem metadata。
experiment_item_root_span_id这个 item run 的 root observation。

这组字段解释了为什么 experiment 查询可以主要读 events,而不是每次都回 Postgres join:

这是 Langfuse 数据面的一个重要设计:运行时把实验上下文铺到事实行里,查询时就可以按 experiment 维度快速聚合。

9.3.10 触发 Evaluation:为什么 experiment evaluator 评 root observation

【当前实现】 当前 repo 已经有 target=experiment 的 observation eval 调度路径,核心是命中 experiment item 的 root observation。

当 root event record ready 时,worker 会执行:

ts
onRootEventRecordReady: async (rootEventRecord) => {
  await scheduleExperimentObservationEvals({
    observation: convertEventRecordToObservationForEval(rootEventRecord),
  });
}

源码入口:

  • worker/src/features/experiments/scheduleExperimentEvals.ts
  • worker/src/features/evaluation/observationEval

这条新路径的含义是:

text
experiment target
  -> 实际命中 experiment item 的 root observation
  -> evaluator 输入里同时带 actual output 和 expected output
  -> 输出标准 Score

为什么是 root observation?

因为一次 experiment item run 可能有很多 observation:

如果 evaluator target 是 experiment,通常想评的是这个 dataset item 的整体结果,而不是每个中间 tool 或 generation。experiment_item_root_span_id 就是为了让 scheduler 能判断:

text
只对 span_id = experiment_item_root_span_id 的 observation 触发 experiment evaluator

这和 agent loop 也很契合。agent run 可以有很多子步骤,但 experiment item 的输出语义一般挂在 root observation 上;需要评某个工具调用时,再用 observation-level evaluator。

9.3.11 SDK/API 路径:底层 experiment 并不要求 prompt

【当前实现】 Dataset Run Item API 是当前 repo 已有的通用入口,它证明底层 DatasetRun / DatasetRunItem 并不要求 prompt。

除了 UI Prompt Experiment,Langfuse 还有更通用的 Dataset Run Item API 路径。

源码入口:

  • web/src/features/datasets/server/publicDatasetService.ts
  • createDatasetRunItemForApi
  • packages/shared/src/server/repositories/dataset-runs.ts
  • createOrFetchDatasetRun

这条路径的思路是:

text
外部应用自己运行 task
  -> 自己产生 trace / observation
  -> 调 API 把 dataset item + run name + traceId 连接起来
  -> Langfuse 创建或复用 DatasetRun
  -> 写 DATASET_RUN_ITEM_CREATE
  -> 进入 experiment comparison / eval queue

这说明底层 abstraction 已经支持更通用的 runtime。外部系统可以是:

  • 你的生产应用;
  • CI 里的 regression test runner;
  • 一个 Python / JS SDK experiment runner;
  • 一个 coding agent runner;
  • 一个 skill runtime。

只要它能做到两件事:

  1. 产生 trace / observation;
  2. 把 dataset item 和 trace 通过 dataset run item 连接起来。

它就能进入 Langfuse 的 experiment comparison 和 score analytics。

这也是为什么不能把 “experiment 必须选 prompt” 当成系统底层事实。更准确地说:

text
UI Prompt Experiment 必须选 prompt。
DatasetRun / DatasetRunItem 抽象不要求 prompt。

9.3.12 Remote Experiment:外部 runtime 的产品入口

【当前实现】 Remote Experiment webhook 是当前 repo 已有的轻量外部 runtime 入口;但它只触发外部系统,不在 Langfuse 内部管理完整 runner。

Dataset 里还有 remote experiment 相关字段:

源码入口:

  • Prisma Dataset.remoteExperimentUrl
  • Prisma Dataset.remoteExperimentPayload
  • Prisma Dataset.remoteExperimentEnabled
  • web/src/features/datasets/server/dataset-router.ts
  • upsertRemoteExperiment
  • triggerRemoteExperiment

触发 remote experiment 时,Langfuse 会向配置的 URL 发 POST:

json
{
  "projectId": "project_123",
  "datasetId": "dataset_123",
  "datasetName": "support-regression",
  "payload": {
    "branch": "main",
    "runtime": "external-agent-runner"
  }
}

这条链路不会在 Langfuse worker 里直接执行任务。它只是告诉外部系统:

text
这个 dataset 需要跑一次 experiment;
你负责执行;
执行完把 trace / dataset run item / score 写回 Langfuse。

所以 remote experiment 可以看作当前 OSS 对“外部 runtime”的一个轻量入口。但它还不是完整的 Candidate / Runtime 控制面,因为它没有在 Langfuse 内部一等管理:

  • candidate 是什么;
  • candidate version 是什么;
  • runtime image / tool policy / secrets / timeout 是什么;
  • 本次 run spec 是什么;
  • artifact hash 是什么;
  • evaluator suite 是什么。

这些需要你自己放在 payload、dataset run metadata 或外部系统里。

9.3.13 存储落点:哪些数据在哪里

这个子系统跨 Postgres、ClickHouse、Redis 和外部 runtime。按存储视角整理如下:

数据当前存储具体对象为什么放这里
Prompt 资产Postgresprompts版本、标签、权限、更新、缓存失效都需要事务型控制面。
Dataset 定义Postgresdatasetsdataset 名称、schema、remote experiment 配置需要可编辑。
DatasetItemPostgresdataset_items输入、expected output、metadata、版本有效期需要关系型约束。
DatasetRun / ExperimentPostgresdataset_runs一次 run 的名字、描述、metadata、唯一约束。
DatasetRunItem 关系Postgres + ClickHousedataset_run_itemsdataset_run_items_rmtPostgres 保关系;ClickHouse 保分析投影。
Trace / Observation 事实ClickHouseevents_full / events_core / legacy observations高吞吐写入和按时间、project、trace 聚合查询。
Experiment contextClickHouse event fieldsexperiment_idexperiment_item_idexperiment_item_root_span_id查询 experiment 时避免频繁回表 join。
ScoreClickHousescores评价结果是事实数据,需要分析、聚合、过滤、趋势。
Experiment create jobRedis/BullMQexperiment-create-queue短期异步调度。
Dataset run item eval jobRedis/BullMQdataset-run-item-upsert-queue连接结果后触发 legacy evaluator。
Evaluator 配置Postgreseval_templatesjob_configurationsscore_configs评价方法、命中规则、score schema 是控制面。

可以把存储关系画成:

【扩展设计】 下面是 Skill / AgentLoop 接入后的新增控制面建议,不是当前 OSS repo 已有表。

如果未来做 Skill Candidate,最关键的新增存储不是 ClickHouse 事实表,而是 Postgres 控制面:

text
skills
skill_versions
skill_artifacts
runtime_configs
experiment_run_specs

事实层仍然可以继续使用 trace / observation / score。

9.3.14 查询面:Experiment 为什么能比较

Experiment comparison 的查询核心在:

  • packages/shared/src/server/repositories/experiments.ts
  • getExperimentsFromEvents
  • getExperimentMetricsFromEvents
  • experiment item comparison 查询
  • run-level score 查询

它主要从 ClickHouse 的 events 和 scores 读取:

Experiment 页面能比较三类东西:

指标来源含义
item 数、error 数、start timeevents / dataset run item projection这个 run 跑了多少样本,哪些失败。
cost / latencyevents aggregation这个 run 的成本和性能。
item-level scorescores join trace / observation每个样本输出质量。
run-level scorescores.dataset_run_id = experiment_id整次 run 的总体评价。

这里有一个重要细节:experiment item 查询会过滤 root observation:

text
e.span_id = e.experiment_item_root_span_id

这意味着 comparison 默认比较每个 item 的整体输出,而不是把所有中间 observation 都当作一条 item 结果。

这对 agent loop 很重要。agent 一次运行可能有十几个甚至上百个 observation,但 experiment comparison 不应该把每个 tool call 都当成一个测试样本。它应该以 root observation 作为 item 结果,再按需展开子树看原因。

9.3.15 为什么当前实现不是现代 AgentLoop Runtime

【当前实现】 本节先说明当前 Prompt Experiment runtime 的真实边界;后半部分的 AgentLoop 是对照用的扩展目标。

先把当前 Prompt Experiment 的运行模型画出来:

这个模型适合:

  • 测 prompt 版本;
  • 测 model/provider/temperature;
  • 测 structured output schema;
  • 测一轮问答式任务;
  • 快速把 dataset input 映射到 prompt variables。

【扩展设计】 下面的 AgentLoop Runtime 是未来要接 Skill / Workflow / Agent 时的目标形态,不是当前内置 prompt experiment worker 已实现的执行循环。

但现代 agent / workflow 常常是:

二者的差异不是“多调用几次模型”这么简单,而是 runtime contract 不同:

维度当前 Prompt ExperimentAgentLoop Runtime
输入dataset item JSON 映射到 prompt variablesdataset item 可能包含任务、repo、文件、环境、权限、工具策略。
候选资产prompt content + prompt configskill markdown、代码、工具定义、workflow、agent policy。
执行过程一次 fetchLLMCompletion多步 loop,可能跨模型、工具、文件、命令、sub-agent。
事实形态通常一个 root generation / eventobservation tree,root 下有 plan/tool/file/test/retry 子节点。
失败形态completion error 或输出不合格tool error、权限错误、patch 冲突、测试失败、超时、上下文爆炸。
复现要求prompt id + model params 基本够artifact hash、runtime image、sandbox policy、secrets scope、tool versions 都要记录。
evaluator 输入root output + expected outputroot output + child observations + artifacts + diffs + test results。

所以如果你要把 skills 纳入 Langfuse 式实验系统,扩展点至少有两个:

  1. 候选资产维度Prompt -> Skill / Workflow / Agent
  2. runtime 维度LLM completion -> AgentLoop runtime

9.3.16 Skill 作为 Candidate:应该怎么建模

【扩展设计】 本节是基于当前 experiment 抽象推导出的 Skill Candidate 建模建议。当前 OSS repo 还没有 SkillSkillVersionSkillArtifact 这些一等表。

用户给出的 skill 定义是“一组 markdown 文件和代码”。这个定义很适合作为 Candidate Asset,但需要工程化成可版本化、可复现、可比较的对象。

一个可评估的 skill candidate 至少要包含:

字段含义为什么需要
nameskill 名称比较和检索。
version递增版本或语义版本experiment 要能指向不可变版本。
artifactHashmarkdown + code bundle hash保证复现,避免同名文件变了。
gitCommit来源 commit能回到源码。
entrypointruntime 如何加载 skillagent runner 需要知道从哪里开始。
dependencies包、工具、系统依赖复现实验环境。
toolPolicy允许哪些工具安全和可比较性。
metadataowner、tags、domain 等管理和筛选。

本教程建议把它叫做 CandidateAsset

命名优点缺点建议
Asset更宽,prompt/skill 都可以是资产太静态,不能表达“拿来对比竞争”的实验语义适合资产管理 UI。
Candidate很贴合 experiment:候选方案、候选版本对非实验场景略窄适合 evaluation / experiment 子系统。
Runnable强调可执行容易和 runtime 混淆不建议作为顶层业务名。
Skill精确表达当前需求不能覆盖 Prompt / Workflow / Agent可作为 Candidate 的一种 type。

更顺的抽象是:

text
Candidate
  type = prompt | skill | workflow | agent
  version = immutable version pointer
  artifact = executable/readable material

Runtime
  type = llm-completion | agent-loop | external-runner
  config = model/tool/sandbox/concurrency/timeout

ExperimentRun
  dataset + candidateVersion + runtimeConfig + evaluatorSuite

用图表示:

当前 repo 里没有这些表。现阶段如果要做 MVP,可以先把 candidate/run spec 放到 dataset_runs.metadata,但要知道这是过渡方案。

9.3.17 ExperimentRuntimeRunner:如何把 AgentLoop 接到 Langfuse 事实层

【扩展设计】 本节定义的是建议接口和建议运行形态。当前 repo 内置的是 prompt completion 路径;ExperimentRuntimeRunner 不是当前代码里已有的 interface。

AgentLoop runtime 不一定要内置在 Langfuse web/worker 进程里。更合理的方式通常是外部 runner:

建议把执行器接口叫 ExperimentRuntimeRunner。它负责消费 candidate managed assets 和 runtime profile,对一个 dataset item 执行一次受控运行,然后返回 trace/root observation/artifacts/error。

ts
interface ExperimentRuntimeRunner {
  runItem(input: {
    projectId: string;
    datasetRunId: string;
    datasetItem: DatasetItem;
    candidate: CandidateVersion;
    runtimeProfile: RuntimeProfile;
    traceContext: TraceContext;
  }): Promise<{
    traceId: string;
    rootObservationId?: string;
    output?: unknown;
    artifacts?: RuntimeArtifact[];
    error?: RuntimeError;
  }>;
}

对 Prompt Experiment 来说,可以理解为:

text
PromptCompletionRunner
  -> resolve prompt
  -> render variables
  -> fetchLLMCompletion
  -> emit root observation

对 Skill Experiment 来说,可以理解为:

text
SkillAgentRunner
  -> 按 skill 自己的发现和调用约定加载 markdown/code
  -> 创建可控 runtime / sandbox
  -> 执行 agent loop
  -> emit observation tree
  -> return root output / artifacts
text
Langfuse 负责:
  dataset / run / trace / score / comparison / evaluator

Agent runner 负责:
  拉取 dataset
  加载 candidate artifact
  执行 agent loop
  上报 trace / observation
  创建 dataset run item
  可选写 score

运行过程可以设计成:

关键是把 agent loop 的每一步映射成 observation tree:

Agent 行为推荐 observation 表达说明
整个 item runroot span / event对应 experiment item 的整体输出。
模型规划generation 或 span记录 prompt/messages/model/token/cost。
工具调用tool observation / span记录 tool name、input、output、error。
文件读取spanmetadata 记录 path、size、hash。
文件修改spanmetadata 记录 patch summary、diff hash。
测试执行spanmetadata/output 记录 command、exit code、logs。
子 agentspan subtreeparent/child 表示 delegation。
最终产物root output 或 artifact linkevaluator 默认评 root output,也可读子树。

这正是 observation tree 比“一个可变 trace row + 若干 child rows”更适合 agent 的地方:agent 的运行事实天然是树状的,root 表示整体任务,children 表示每一步,score 可以挂到不同层级。

9.3.18 RunSpec:扩展时最缺的一等契约

【扩展设计】 当前 repo 使用 dataset_runs.metadata 承载 Prompt Experiment 的配置;本节讨论的是把它结构化为 RunSpec 的建议。

如果把 skills 和 agent loop 接进来,dataset_runs.metadata 不能一直当杂物箱。你需要一个稳定的 RunSpec。

当前 Prompt Experiment 的 run metadata 大概是:

json
{
  "prompt_id": "prompt_123",
  "provider": "openai",
  "model": "gpt-4.1",
  "model_params": { "temperature": 0 },
  "experiment_name": "support-test",
  "experiment_run_name": "prompt-v3"
}

泛化后可以是:

json
{
  "candidate": {
    "type": "skill",
    "id": "skill_support_agent",
    "version": "7",
    "artifactHash": "sha256:abc123",
    "gitCommit": "abc123"
  },
  "runtime": {
    "type": "agent-loop",
    "runner": "codex",
    "model": "gpt-4.1",
    "timeoutSeconds": 900,
    "maxIterations": 30,
    "toolPolicy": {
      "shell": "sandboxed",
      "network": "deny-by-default",
      "fileWrite": "workspace-only"
    }
  },
  "dataset": {
    "id": "dataset_support_regression",
    "version": "2026-07-02T00:00:00.000Z"
  },
  "evaluators": {
    "suiteId": "support-agent-evals",
    "version": "3"
  }
}

这个 RunSpec 有四个作用:

作用说明
复现后续能知道当时跑的是哪个 skill 文件、哪个模型、哪个 runner、哪些工具权限。
比较experiment comparison 可以按 candidate version、runtime、model 分组。
审计出问题时能知道 runner 有没有越权、用了什么外部依赖。
扩展Prompt、Skill、Workflow 都可以用同一套 run spec,只是 candidate type 不同。

如果不做 RunSpec,短期也能把字段塞进 metadata,但长期会遇到:

  • UI 不知道哪些 metadata 是 candidate,哪些是 runtime;
  • 查询无法稳定按 candidate version 聚合;
  • 同名 run 不知道是否真的可比较;
  • 失败重试不知道应该恢复哪个 artifact;
  • evaluator suite 版本不清楚,score 可比性下降。

9.3.19 Langfuse 抽象是否足够:分层评估

【当前实现 / 扩展设计】 本节的“现有抽象”列描述当前 repo 的事实;“还需要”“最小补法”描述扩展 Skill / AgentLoop 时的建议。

把 “skills + agent loop runtime” 放进背景里看,Langfuse 当前抽象可以分成三层评估。

事实层:足够顺

现有抽象对 agent / skill eval 是否足够原因
Trace可以表达一次 dataset item run / task run。
Observation tree可以表达多步模型、工具、检索、文件、测试、子 agent。
Score可以挂 trace、observation、session、dataset run,覆盖 item-level、step-level、run-level。
DatasetRunItem可以把 dataset item 和 agent run 的 trace 连接起来。
DatasetRun / Experiment可以表达“某 candidate/runtime 在某 dataset 上跑一次”。

所以从数据面看,这条路是顺的:

text
SkillVersion + AgentLoop
  -> Trace / Observation tree
  -> DatasetRunItem
  -> Score
  -> Experiment comparison

查询分析层:基本顺,但需要 candidate-aware 字段

现有 experiment 查询已经能按 experiment_idexperiment_item_id、score、cost、latency 做比较。扩展 agent 后还需要让查询能看懂:

  • candidate type;
  • candidate id;
  • candidate version;
  • artifact hash;
  • runtime type;
  • runner;
  • model;
  • evaluator suite;
  • tool policy。

【扩展设计】 这些字段可以先放在 experiment_metadata_* 里;更好的长期形态是把 run spec 结构化,再把高频筛选字段投影到 ClickHouse event / experiment aggregation。

控制面:当前不够

缺口为什么是缺口最小补法
Candidate Registry当前只有 prompt 管理,没有 skill/workflow/agent 的统一资产管理。新增 Candidate / CandidateVersion,或先外部管理并把引用写入 run metadata。
Artifact Versioningskill 是 markdown + code,必须记录 bundle hash。artifact hash + git commit + dependency lock。
Runtime RegistryAgentLoop runner 有工具、沙箱、超时、并发、secrets。RuntimeConfig / RunnerConfig。
RunSpecdataset run metadata 是自由 JSON,不足以承载长期 contract。定义结构化 schema,逐步从 metadata 迁移。
Evaluator Suite Binding一次 experiment 应该知道用哪组 evaluator。在 run spec 记录 suite id/version,或用 JobConfiguration filter 约束。
Agent-native Observation Semantics当前 observation 足够通用,但 agent tool/file/test 需要命名规范。定义 observation type/name/metadata conventions。
Reproducible Runnerprompt completion 只需 model config;agent loop 还要环境。记录 runtime image、tool versions、sandbox policy。

所以结论要分开说:

text
数据面抽象:顺,可以承载。
查询面抽象:基本顺,需要补 candidate-aware metadata/index。
控制面抽象:当前 prompt-centric,不足以一等管理 skills 和 agent loop。

9.3.20 如果从零做类似 infra,应该怎么拆

【扩展设计】 本节是面向“如何做类似 infra”的设计拆解,不是当前 repo 的目录结构。

本节不是只为读 Langfuse,也要帮助你做类似 infra。可以按下面 8 个模块拆。

对应到 Langfuse 当前 repo:

模块Langfuse 当前已有缺口
Candidate RegistryPrompt ManagementSkill / Workflow / Agent registry。
Dataset StoreDataset / DatasetItem对 skills 可直接复用。
RunSpecdataset_runs.metadata结构化 schema、一等对象、版本约束。
Runtime RunnerPrompt Experiment worker;SDK/remote 外部 runnerAgentLoop runner。
Trace SinkIngestion + eventsagent-native conventions。
Run Item LinkerDatasetRunItem API / ingestion event对 agent 可直接复用。
Evaluation EngineEvaluation 子系统需要 evaluator suite 与 run spec 更紧密绑定。
AnalyticsExperiment repository / Score analyticscandidate-aware filters 和 comparison 维度。

这也是建议的扩展顺序:

  1. 先不要改 ClickHouse 事实模型,先用现有 trace/observation/score 跑通。
  2. 外部实现 skill runner,把 run spec 写进 dataset_runs.metadata
  3. 对每个 dataset item 产生 trace,并创建 dataset run item。
  4. 让 evaluator 先评 root observation,必要时再评具体 child observation。
  5. 当 metadata 查询变慢或 UI 需要强类型筛选时,再把 candidate/runtime 字段一等化。

9.3.21 一个完整 Skill Experiment 例子

【扩展设计】 这是 Skill / AgentLoop 接入后的目标形态例子,不是当前 Prompt Experiment worker 已经支持的能力。

假设你要评估一个客服 agent skill:

text
skills/support-agent/
  SKILL.md
  tools/search_policy.md
  src/format_answer.ts

Dataset item:

json
{
  "input": {
    "question": "我 20 天前买的商品可以退款吗?",
    "customer_tier": "gold"
  },
  "expectedOutput": {
    "decision": "eligible",
    "mustMention": ["30 天内可退", "原支付方式退回"]
  },
  "metadata": {
    "topic": "refund",
    "locale": "zh-CN"
  }
}

一次 run 的过程应该是:

这次 run 的 trace tree 可以长这样:

Score 可以挂在不同层级:

Score target例子表达什么
root observationcorrectness=0.92这个 item 的最终回答好不好。
child tool observationretrieval_relevance=0.8检索步骤有没有找到正确证据。
tracetask_success=true整次 item run 是否成功。
dataset runpass_rate=0.87整个 skill v7 run 的聚合表现。

Experiment comparison 就可以回答:

  • skill v7 是否比 skill v6 更准确;
  • 新 runtime 是否成本更高;
  • 哪些 dataset items 从 pass 变 fail;
  • 失败集中在检索、格式化还是最终回答;
  • 某 evaluator suite 下是否可以晋级到 production。

9.3.22 现有 Prompt Experiment 和未来 Skill Experiment 的对照

【当前实现 / 扩展设计】 表格左列是当前 repo 已实现的 Prompt Experiment;右列是 Skill / AgentLoop 接入后的扩展目标。

当前 Prompt ExperimentSkill / AgentLoop Experiment
CandidatePromptSkill / Workflow / Agent
Candidate versionprompt_id、prompt version/labelskill version + artifact hash + git commit
RuntimeLangfuse worker 内置 completion外部 runner 或未来内置 agent loop
Runtime configprovider、model、model paramsrunner、model、sandbox、tools、secrets、timeout、concurrency
Input mappingprompt variables / placeholderstask input + skill context + environment
Executionone completionmulti-step loop
Trace shaperoot event / generationrich observation tree
DatasetRunItem已有复用
Evaluationroot experiment observation evaluatorroot evaluator + step evaluator + run-level evaluator
Comparisonrun/item score、cost、latency再加 candidate/runtime 维度

这个对照表的核心结论是:Skill Experiment 不是推翻 Prompt Experiment,而是把 Prompt Experiment 泛化成 Candidate + Runtime 的一种特例。

当前 repo 已经实现了特例 A,并通过 SDK/API/remote 露出了通往 B/C 的数据面入口;但还没有把 B/C 做成一等产品体验。

9.3.23 二次开发入口:如果要真的接 Skill / AgentLoop

【扩展设计】 本节是改造路线建议;真正落地前需要再按 repo 规范拆 PR、补 schema、补测试和迁移。

如果要在当前 OSS repo 基础上做一个 MVP,建议不要第一步就大改 schema。可以分三阶段。

阶段 1:外部 Skill Runner,复用现有数据面

目标:最小闭环跑通。

做法:

  1. 外部系统管理 skill files、版本和 artifact hash。
  2. 用 Langfuse Dataset 管理测试 case。
  3. runner 创建 DatasetRun,把 run spec 写进 metadata
  4. runner 对每个 item 执行 agent loop。
  5. runner 上报 trace / observation tree。
  6. runner 调 dataset run item API 连接 item 和 trace。
  7. 用现有 evaluator 和 score analytics 做评分和比较。

需要读的源码:

目的文件
创建 / 复用 dataset runpackages/shared/src/server/repositories/dataset-runs.ts
创建 dataset run item APIweb/src/features/datasets/server/publicDatasetService.ts
dataset run item ingestion eventpackages/shared/src/server/ingestion/types.ts
event / observation 写入packages/shared/src/server/ingestion/processEventBatch.tsworker/src/services/IngestionService/index.ts
experiment comparisonpackages/shared/src/server/repositories/experiments.ts
evaluationworker/src/features/evaluation/observationEval

阶段 2:结构化 RunSpec 和 candidate metadata

目标:让 UI 和查询知道 run 到底跑的是什么。

做法:

  • 定义 ExperimentRunSpecSchema
  • dataset_runs.metadata 里使用固定结构。
  • 在 events 的 experiment metadata 里同步 candidate/runtime 高频字段。
  • 在 experiment table 增加 candidate type/version/runtime filters。

高频字段示例:

json
{
  "candidate_type": "skill",
  "candidate_name": "support-agent",
  "candidate_version": "7",
  "candidate_artifact_hash": "sha256:abc123",
  "runtime_type": "agent-loop",
  "runtime_runner": "codex",
  "runtime_model": "gpt-4.1",
  "evaluator_suite": "support-agent-evals@3"
}

阶段 3:一等 Candidate / Runtime 控制面

目标:把外部 metadata 变成产品对象。

新增对象可以是:

对象职责
Candidate统一管理 prompt / skill / workflow / agent。
CandidateVersion不可变版本、artifact hash、来源 commit。
SkillArtifactmarkdown/code/dependencies/entrypoint。
RuntimeConfigrunner、model、tool policy、timeout、secrets scope。
EvaluatorSuite一组 evaluator 和版本绑定。
ExperimentRunSpecdataset + candidateVersion + runtimeConfig + evaluatorSuite。

这时 Prompt Experiment 可以变成通用 experiment wizard 的一个 preset:

text
Preset: Prompt Completion
  candidate.type = prompt
  runtime.type = llm-completion

Preset: Skill AgentLoop
  candidate.type = skill
  runtime.type = agent-loop

9.3.24 设计取舍:为什么不直接新建一张 experiment_results 表

做类似 infra 时很容易想新建一张大表:

text
experiment_results(
  run_id,
  item_id,
  input,
  output,
  score,
  cost,
  latency,
  logs
)

这对简单 prompt benchmark 够用,但对 LLM/agent infra 很快不够:

简单大表的问题Langfuse 当前抽象怎么解决
只能存最终输出,无法表达 agent 中间步骤Trace / Observation tree 表达完整运行过程。
score 类型会越来越多独立 scores 事实表,按 target 关联。
run item 和 trace 强耦合DatasetRunItem 做连接对象,trace 仍是通用观测事实。
无法复用线上 traceDatasetItem 可以从 trace 沉淀,DatasetRunItem 可以连接已有 trace。
查询和写入吞吐受限ClickHouse 存事实和分析投影,Postgres 存控制面。
evaluator 和实验结果混在一起Evaluation 子系统单独处理 evaluator definition / rule / execution / score。

Langfuse 的设计更像:

text
Experiment relation
  + Observability facts
  + Evaluation facts
  + Analytics projection

而不是一张 experiment_results 表包办一切。

这就是它能自然扩展到 agent loop 的原因:agent loop 只是产生更丰富的 observation tree,并不要求重写 experiment comparison 的核心抽象。

9.3.25 不变量:扩展时不能破坏什么

如果你基于当前 repo 扩展 Skill / AgentLoop experiment,下面这些不变量不能破坏。

不变量说明
DatasetRun 表示一次 run 快照不要把一个 run 当成可变 workspace;run 应该能被比较和复现。
DatasetRunItem 连接 item 和运行产物不要把运行日志塞进 run item;日志和步骤属于 trace / observation。
每个 item run 应有稳定 tracecomparison 和 evaluator 都依赖 trace/observation 作为事实源。
experiment item 的整体输出应有 root observationevaluator 和 comparison 需要一个稳定的 item-level target。
Score 仍是评价结果的统一事实不要为某个 candidate 类型单独发明另一套 score 表。
Candidate version 必须不可变否则 run 不能复现,comparison 没有意义。
Runtime config 必须可追溯agent loop 的结果受工具、沙箱、模型、依赖强影响。
evaluator suite/version 要能追溯同一批 score 如果来自不同 evaluator 版本,不能直接比较。
Postgres 控制面和 ClickHouse 事实面分工要清楚不要把高吞吐步骤日志写进 Postgres,也不要把权限型配置只放 ClickHouse。

9.3.26 源码阅读顺序

建议按下面顺序读,而不是从 UI 组件开始乱翻。

顺序文件读什么
1packages/shared/prisma/schema.prismaDatasetDatasetItemDatasetRunsDatasetRunItemsPrompt 的关系。
2packages/shared/src/server/llm/types.tsExperimentMetadataSchema:当前 Prompt Experiment metadata 契约。
3web/src/features/experiments/server/router.tsUI Prompt Experiment 如何创建 DatasetRun 并投递队列。
4packages/shared/src/server/queues.tsExperimentCreateEventSchemaDatasetRunItemUpsertEventSchema
5worker/src/features/experiments/utils.tsworker 如何从 run metadata 解析 prompt、model、variables。
6worker/src/features/experiments/experimentServiceClickhouse.ts每个 dataset item 如何变成 run item、completion、trace、root event。
7worker/src/features/experiments/scheduleExperimentEvals.tsroot observation ready 后如何触发 experiment evaluator。
8web/src/features/datasets/server/publicDatasetService.tsSDK/API 如何通用创建 dataset run item。
9web/src/features/datasets/server/dataset-router.tsremote experiment webhook 作为外部 runtime 入口。
10packages/shared/src/server/repositories/experiments.tsexperiment list、metrics、comparison 如何从 events/scores 聚合。
11worker/src/features/evaluation/observationEvalevent/experiment evaluator 如何调度和执行。
12packages/shared/src/server/repositories/scores.tsrun-level score 和 score analytics 如何查询。

读完这些文件,再回头看 UI 组件会更清楚:UI 只是控制面入口,真正的系统边界在 dataset run、queue payload、runtime writer、event fields 和 scores。

9.3.27 本节小结

本节可以压缩成五句话:

  1. 当前 UI Prompt Experiment 是 Prompt + Dataset + ModelConfig -> 一次 completion per item
  2. 底层 experiment 模型不是 prompt-only;DatasetRun / DatasetRunItem / Trace / Observation / Score 可以承载更通用的 candidate runtime。
  3. Skills 可以作为新的 candidate 类型,但必须有版本、artifact hash、entrypoint、dependency、tool policy 等控制面。
  4. AgentLoop runtime 可以自然映射到 observation tree,root observation 表达 item 级结果,child observations 表达工具、文件、测试、子 agent 等步骤。
  5. Langfuse 当前事实层和评价层是顺的;要做完整 SkillOps / AgentOps,还需要补 Candidate Registry、Runtime Registry、RunSpec、Evaluator Suite binding 和 agent-native conventions。

下一节继续拆其它子系统时,也可以沿用这个判断方式:

text
先问:这个子系统产生什么事实?
再问:谁定义控制面?
再问:异步 runtime 如何执行?
再问:事实如何被评价和查询?
最后问:如果换一种 candidate/runtime,抽象是否还能成立?