CI/CD 中的实验
在你的 CI/CD 流水线中使用 Langfuse 实验,在发布前捕获质量回归。
工作流是:
- 用你的测试用例创建一个 Langfuse 数据集。
- 用 Python 或 JS/TS SDK编写一个实验,针对数据集测试你的应用。
- 添加评估器为任务输出打分。
- 当分数违反你的阈值时抛出
RegressionError。 - 创建一个 GitHub Actions 工作流,用 langfuse/experiment-action 运行该脚本。
GitHub Actions 工作流
用你需要的触发器创建工作流,例如 pull_request 或 release。
将 action 固定到 langfuse/experiment-action releases 中的某个发布版本。
该 action 默认安装最新 SDK 版本;仅当你想要特定 SDK 版本时才设置 python_sdk_version 或 js_sdk_version。
该 GitHub Action 需要 Langfuse Python SDK v4.6.0+ 或 JS SDK v5.3.0+。
name: Langfuse experiment gate
on:
# Run the gate for every pull request. Change this to `push`, `release`, or another
# trigger if you want to run experiments at a different point in your workflow.
pull_request:
permissions:
# Required to check out the repository.
contents: read
# Required to post or update the experiment result comment on pull requests.
pull-requests: write
# Optional: lets the result link to this specific job's logs.
# Without this permission, the action falls back to the workflow-run URL.
actions: read
jobs:
experiment:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# Add this only if your experiments use the Python SDK
- uses: actions/setup-python@v6
with:
python-version: "3.14"
# Add this only if your experiments use the JS/TS SDK
- uses: actions/setup-node@v6
with:
node-version: "24"
- uses: langfuse/experiment-action@<release tag>
with:
# the credentials for Langfuse
langfuse_public_key: ${{ secrets.LANGFUSE_PUBLIC_KEY }}
langfuse_secret_key: ${{ secrets.LANGFUSE_SECRET_KEY }}
langfuse_base_url: https://cloud.langfuse.com
# the location of your experiment scripts
experiment_path: experiments/support-agent-gate
# the dataset to run the experiment against
dataset_name: support-agent-regression-set
# GitHub token so that the action can comment on PRs
github_token: ${{ github.token }}
实验定义
该 action 从工作流中配置的 experiment_path 运行你的实验代码。
每个脚本必须定义一个接受 context 参数的 experiment(context) 函数。
此上下文由 GitHub Action 创建,并为你处理 CI 专属设置:
- 从 action 输入初始化 Langfuse SDK 客户端
- 从
dataset_name加载数据集条目并应用dataset_version - 在
langfuse.*下添加默认元数据,如 commit SHA、branch、job URL 和 actor。这些值在 Langfuse UI 中可见。
使用 context.runExperiment(JS/TS)或 context.run_experiment(Python)以这些默认值运行实验。
from langfuse import RunnerContext
from langfuse.api import DatasetItem
# Define a task that calls your agent with each dataset item.
def my_task(item: DatasetItem, **kwargs):
...
def experiment(context: RunnerContext):
return context.run_experiment(
name="PR gate",
task=my_task,
)
import type { ExperimentTaskParams, RunnerContext } from "@langfuse/client";
// Define a task that calls your agent with each dataset item.
async function myTask(item: ExperimentTaskParams) {
// ...
}
export async function experiment(context: RunnerContext) {
return await context.runExperiment({
name: "PR gate",
task: myTask,
});
}
当你想覆盖 action 提供的默认值(如 data 或 metadata)时,向 context.runExperiment / context.run_experiment 传递显式值。
Action 输入和输出
| 输入 | 必需 | 描述 |
|---|---|---|
langfuse_public_key |
是 | SDK 客户端使用的 Langfuse 公钥。将其存为 GitHub secret。 |
langfuse_secret_key |
是 | SDK 客户端使用的 Langfuse 密钥。将其存为 GitHub secret。 |
langfuse_base_url |
否 | Langfuse 主机。默认为 https://cloud.langfuse.com;如果你使用另一个 Langfuse 实例,参见区域和自托管 URL。 |
experiment_path |
是 | 实验脚本文件的路径、包含实验脚本的目录,或 glob 模式。支持 Python、TypeScript 和 JavaScript。 |
dataset_name |
否 | 由 action 加载并通过 RunnerContext 提供给 SDK 的 Langfuse 数据集。如果省略,脚本必须提供自己的数据。 |
dataset_version |
否 | 可选的时间戳,用于为可复现的 CI 运行固定数据集版本。默认为最新数据集版本。 |
experiment_metadata |
否 | 与默认 GitHub 元数据一起添加到实验的额外 key=value 元数据。此元数据在 Langfuse UI 中可见。 |
should_fail_on_regression |
否 | 当实验抛出 RegressionError 时使 CI 作业失败。默认为 true。 |
should_fail_on_script_error |
否 | 当实验脚本崩溃或抛出非回归错误时使 CI 作业失败。默认为 true。 |
should_comment_on_pr |
否 | 将实验报告作为 pull request 评论发布或更新。默认为 true。 |
python_sdk_version |
否 | action 为 .py 实验安装的 Langfuse Python SDK 版本。默认为 latest;使用 v4.6.0 或更新。 |
js_sdk_version |
否 | action 为 TypeScript 或 JavaScript 实验安装的 @langfuse/client 版本。默认为 latest;使用 v5.3.0 或更新。 |
should_skip_sdk_installation |
否 | 当你在此 action 之前自己管理 Python 或 Node 环境时跳过 SDK 安装。对于 TypeScript 实验,请自行提供 @langfuse/client、@langfuse/tracing、@langfuse/otel、@opentelemetry/sdk-node 和 tsx。默认为 false。 |
github_token |
否 | 用于发布 PR 评论和解析当前作业 URL 的 GitHub token。留空以跳过两者。 |
完整的输入参考参见 langfuse/experiment-action README。
| 输出 | 描述 |
|---|---|
result_json |
用于下游工作流步骤的规范化 JSON 结果。 |
failed |
如果任何实验脚本出错或抛出回归则为 true;否则为 false。 |
在回归时失败
当某个结果应阻止工作流时抛出 RegressionError。下面的示例在平均精确匹配准确率低于阈值时失败。
from langfuse import Evaluation, RegressionError, RunnerContext
THRESHOLD = 0.95
def experiment(context: RunnerContext):
result = context.run_experiment(
name="PR gate: support agent",
task=answer_support_question,
evaluators=[exact_match],
run_evaluators=[avg_accuracy],
)
accuracy = next(
(
evaluation.value
for evaluation in result.run_evaluations
if evaluation.name == "avg_accuracy"
),
None,
)
if not isinstance(accuracy, (int, float)) or accuracy < THRESHOLD:
raise RegressionError(
# Attach the result so the action can include scores in the PR comment and `result_json` output.
result=result,
metric="avg_accuracy",
value=float(accuracy) if isinstance(accuracy, (int, float)) else 0.0,
threshold=THRESHOLD,
)
return result
def answer_support_question(item, **kwargs):
# Replace this stub with your application logic.
return item.input["question"]
def exact_match(*, output, expected_output, **kwargs):
passed = output.strip() == (expected_output or "").strip()
return Evaluation(
name="exact_match",
value=1.0 if passed else 0.0,
comment="match" if passed else "mismatch",
)
def avg_accuracy(*, item_results, **kwargs):
scores = [
evaluation.value
for item in item_results
for evaluation in item.evaluations
if evaluation.name == "exact_match" and isinstance(evaluation.value, (int, float))
]
return Evaluation(name="avg_accuracy", value=sum(scores) / len(scores) if scores else 0.0)
import {
RegressionError,
type Evaluation,
type ExperimentTaskParams,
type RunnerContext,
} from "@langfuse/client";
const THRESHOLD = 0.95;
export async function experiment(context: RunnerContext) {
const result = await context.runExperiment({
name: "PR gate: support agent",
task: answerSupportQuestion,
evaluators: [exactMatch],
runEvaluators: [avgAccuracy],
});
const accuracy = result.runEvaluations.find(
(evaluation) => evaluation.name === "avg_accuracy",
)?.value;
if (typeof accuracy !== "number" || accuracy < THRESHOLD) {
throw new RegressionError({
// Attach the result so the action can include scores in the PR comment and `result_json` output.
result,
metric: "avg_accuracy",
value: typeof accuracy === "number" ? accuracy : 0,
threshold: THRESHOLD,
});
}
return result;
}
async function answerSupportQuestion(item: ExperimentTaskParams) {
const { question } = item.input as { question: string };
// Replace this with your application logic, for example calling your agent.
return await supportAgent(question);
}
async function supportAgent(question: string) {
return question;
}
async function exactMatch({
output,
expectedOutput,
}: {
output: string;
expectedOutput?: string;
}): Promise<Evaluation> {
const passed = output.trim() === expectedOutput?.trim();
return { name: "exact_match", value: passed ? 1 : 0 };
}
async function avgAccuracy({
itemResults,
}: {
itemResults: Array<{ evaluations: Evaluation[] }>;
}): Promise<Evaluation> {
const scores = itemResults
.flatMap((item) => item.evaluations)
.filter((evaluation) => evaluation.name === "exact_match")
.map((evaluation) => Number(evaluation.value))
.filter((score) => Number.isFinite(score));
return {
name: "avg_accuracy",
value: scores.length
? scores.reduce((sum, score) => sum + score, 0) / scores.length
: 0,
};
}
Action 输出
当提供 github_token 且工作流具有 pull-requests: write 时,该 action 发布或更新一个 pull request 评论,包含:
- 每个实验脚本的 pass、regression 或 script-error 状态
- 运行级分数,如
avg_accuracy - 指向 GitHub Action 运行的链接
- 指向 Langfuse 实验对比视图的链接(用于有数据集支持的运行)
- 条目输出和条目级分数的紧凑表格
相同的规范化数据可作为 result_json action 输出获得。当后续工作流步骤需要将结果作为 artifact 上传、发送 Slack 通知或馈送另一个报告系统时使用它。输出 schema 可在 langfuse/experiment-action 仓库中获得。
- uses: langfuse/experiment-action@<release tag>
id: experiment
with:
# ...
- name: Store experiment result
if: always()
env:
RESULT_JSON: ${{ steps.experiment.outputs.result_json }}
run: printf '%s' "$RESULT_JSON" > experiment-result.json
额外的 secrets
如果你的实验需要提供商密钥或其他 secrets,将它们作为 action 步骤上的环境变量设置。实验子进程继承步骤环境。
- uses: langfuse/experiment-action@<release tag>
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
langfuse_public_key: ${{ secrets.LANGFUSE_PUBLIC_KEY }}
langfuse_secret_key: ${{ secrets.LANGFUSE_SECRET_KEY }}
experiment_path: experiments/support-agent-gate
dataset_name: support-agent-regression-set
你的实验可以在 Python 中从 os.environ[...] 或在 TypeScript 和 JavaScript 中从 process.env... 读取这些值。细节参见 langfuse/experiment-action README。
其他 CI/CD 系统
将实验运行器与 Pytest 和 Vitest 等测试框架集成,在你的 CI 流水线中运行自动评估。使用评估器创建基于实验结果使测试失败的断言。
import pytest
from langfuse import get_client, Evaluation
from langfuse.openai import OpenAI
# Test data for European capitals
test_data = [
{"input": "What is the capital of France?", "expected_output": "Paris"},
{"input": "What is the capital of Germany?", "expected_output": "Berlin"},
{"input": "What is the capital of Spain?", "expected_output": "Madrid"},
]
def geography_task(*, item, **kwargs):
"""Task function that answers geography questions"""
question = item["input"]
response = OpenAI().chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": question}]
)
return response.choices[0].message.content
def accuracy_evaluator(*, input, output, expected_output, **kwargs):
"""Evaluator that checks if the expected answer is in the output"""
if expected_output and expected_output.lower() in output.lower():
return Evaluation(name="accuracy", value=1.0)
return Evaluation(name="accuracy", value=0.0)
def average_accuracy_evaluator(*, item_results, **kwargs):
"""Run evaluator that calculates average accuracy across all items"""
accuracies = [
evaluation.value for result in item_results
for evaluation in result.evaluations if evaluation.name == "accuracy"
]
if not accuracies:
return Evaluation(name="avg_accuracy", value=None)
avg = sum(accuracies) / len(accuracies)
return Evaluation(name="avg_accuracy", value=avg, comment=f"Average accuracy: {avg:.2%}")
@pytest.fixture
def langfuse_client():
"""Initialize Langfuse client for testing"""
return get_client()
def test_geography_accuracy_passes(langfuse_client):
"""Test that passes when accuracy is above threshold"""
result = langfuse_client.run_experiment(
name="Geography Test - Should Pass",
data=test_data,
task=geography_task,
evaluators=[accuracy_evaluator],
run_evaluators=[average_accuracy_evaluator]
)
# Access the run evaluator result directly
avg_accuracy = next(
(
evaluation.value
for evaluation in result.run_evaluations
if evaluation.name == "avg_accuracy"
),
None,
)
# Assert minimum accuracy threshold
assert isinstance(avg_accuracy, (int, float)) and avg_accuracy >= 0.8, (
f"Average accuracy {avg_accuracy} below threshold 0.8"
)
def test_geography_accuracy_fails(langfuse_client):
"""Example test that demonstrates failure conditions"""
# Use a weaker model or harder questions to demonstrate test failure
def failing_task(*, item, **kwargs):
# Simulate a task that gives wrong answers
return "I don't know"
result = langfuse_client.run_experiment(
name="Geography Test - Should Fail",
data=test_data,
task=failing_task,
evaluators=[accuracy_evaluator],
run_evaluators=[average_accuracy_evaluator]
)
# Access the run evaluator result directly
avg_accuracy = next(
(
evaluation.value
for evaluation in result.run_evaluations
if evaluation.name == "avg_accuracy"
),
None,
)
# This test will fail because the task gives wrong answers
with pytest.raises(AssertionError):
assert isinstance(avg_accuracy, (int, float)) and avg_accuracy >= 0.8, (
f"Expected test to fail with low accuracy: {avg_accuracy}"
)
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { OpenAI } from "openai";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { LangfuseClient, ExperimentItem } from "@langfuse/client";
import { observeOpenAI } from "@langfuse/openai";
import { LangfuseSpanProcessor } from "@langfuse/otel";
// Test data for European capitals
const testData: ExperimentItem[] = [
{ input: "What is the capital of France?", expectedOutput: "Paris" },
{ input: "What is the capital of Germany?", expectedOutput: "Berlin" },
{ input: "What is the capital of Spain?", expectedOutput: "Madrid" },
];
let otelSdk: NodeSDK;
let langfuse: LangfuseClient;
beforeAll(async () => {
// Initialize OpenTelemetry
otelSdk = new NodeSDK({ spanProcessors: [new LangfuseSpanProcessor()] });
otelSdk.start();
// Initialize Langfuse client
langfuse = new LangfuseClient();
});
afterAll(async () => {
// Clean shutdown
await otelSdk.shutdown();
});
const geographyTask = async (item: ExperimentItem) => {
const question = item.input;
const response = await observeOpenAI(new OpenAI()).chat.completions.create({
model: "gpt-4",
messages: [{ role: "user", content: question }],
});
return response.choices[0].message.content;
};
const accuracyEvaluator = async ({ input, output, expectedOutput }) => {
if (
expectedOutput &&
output.toLowerCase().includes(expectedOutput.toLowerCase())
) {
return { name: "accuracy", value: 1 };
}
return { name: "accuracy", value: 0 };
};
const averageAccuracyEvaluator = async ({ itemResults }) => {
// Calculate average accuracy across all items
const accuracies = itemResults
.flatMap((result) => result.evaluations)
.filter((evaluation) => evaluation.name === "accuracy")
.map((evaluation) => evaluation.value as number);
if (accuracies.length === 0) {
return { name: "avg_accuracy", value: null };
}
const avg = accuracies.reduce((sum, val) => sum + val, 0) / accuracies.length;
return {
name: "avg_accuracy",
value: avg,
comment: `Average accuracy: ${(avg * 100).toFixed(1)}%`,
};
};
describe("Geography Experiment Tests", () => {
it("should pass when accuracy is above threshold", async () => {
const result = await langfuse.experiment.run({
name: "Geography Test - Should Pass",
data: testData,
task: geographyTask,
evaluators: [accuracyEvaluator],
runEvaluators: [averageAccuracyEvaluator],
});
// Access the run evaluator result directly
const avgAccuracy = result.runEvaluations.find(
(evaluation) => evaluation.name === "avg_accuracy",
)?.value as number;
// Assert minimum accuracy threshold
expect(avgAccuracy).toBeGreaterThanOrEqual(0.8);
}, 30_000); // 30 second timeout for API calls
it("should fail when accuracy is below threshold", async () => {
// Task that gives wrong answers to demonstrate test failure
const failingTask = async (item: ExperimentItem) => {
return "I don't know";
};
const result = await langfuse.experiment.run({
name: "Geography Test - Should Fail",
data: testData,
task: failingTask,
evaluators: [accuracyEvaluator],
runEvaluators: [averageAccuracyEvaluator],
});
// Access the run evaluator result directly
const avgAccuracy = result.runEvaluations.find(
(evaluation) => evaluation.name === "avg_accuracy",
)?.value as number;
// This test will fail because the task gives wrong answers
expect(() => {
expect(avgAccuracy).toBeGreaterThanOrEqual(0.8);
}).toThrow();
}, 30_000);
});
这些示例展示了如何使用实验运行器的评估结果在你的 CI 流水线中创建有意义的测试断言。当准确率低于可接受的阈值时,测试可以失败,从而自动确保模型质量标准。