Langfuse 文档 中文 英文原文 ↗
文档 / CI/CD 中的实验

CI/CD 中的实验

在你的 CI/CD 流水线中使用 Langfuse 实验,在发布前捕获质量回归。

工作流是:

  1. 用你的测试用例创建一个 Langfuse 数据集
  2. Python 或 JS/TS SDK编写一个实验,针对数据集测试你的应用。
  3. 添加评估器为任务输出打分。
  4. 当分数违反你的阈值时抛出 RegressionError
  5. 创建一个 GitHub Actions 工作流,用 langfuse/experiment-action 运行该脚本。

GitHub Actions 工作流

用你需要的触发器创建工作流,例如 pull_requestrelease。 将 action 固定到 langfuse/experiment-action releases 中的某个发布版本。

该 action 默认安装最新 SDK 版本;仅当你想要特定 SDK 版本时才设置 python_sdk_versionjs_sdk_version

该 GitHub Action 需要 Langfuse Python SDK v4.6.0+ 或 JS SDK v5.3.0+。

yaml
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 专属设置:

使用 context.runExperiment(JS/TS)或 context.run_experiment(Python)以这些默认值运行实验。

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,
    )
ts
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 提供的默认值(如 datametadata)时,向 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-nodetsx。默认为 false
github_token 用于发布 PR 评论和解析当前作业 URL 的 GitHub token。留空以跳过两者。

完整的输入参考参见 langfuse/experiment-action README

输出 描述
result_json 用于下游工作流步骤的规范化 JSON 结果。
failed 如果任何实验脚本出错或抛出回归则为 true;否则为 false

在回归时失败

当某个结果应阻止工作流时抛出 RegressionError。下面的示例在平均精确匹配准确率低于阈值时失败。

python
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)
ts
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 评论,包含:

相同的规范化数据可作为 result_json action 输出获得。当后续工作流步骤需要将结果作为 artifact 上传、发送 Slack 通知或馈送另一个报告系统时使用它。输出 schema 可在 langfuse/experiment-action 仓库中获得。

yaml
- 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 步骤上的环境变量设置。实验子进程继承步骤环境。

yaml
- 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 流水线中运行自动评估。使用评估器创建基于实验结果使测试失败的断言。

python
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}"
        )
typescript
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 流水线中创建有意义的测试断言。当准确率低于可接受的阈值时,测试可以失败,从而自动确保模型质量标准。

非官方中文翻译 · 图片/视频/代码均链接官方资源 · 版权归 Langfuse GmbH 所有 查看英文原文 ↗