Blog

Langfuse i Jenkins: CI/CD dla aplikacji LLM krok po kroku

Kategoria:

Langfuse wyrósł na standard obserwowalności i ewaluacji aplikacji LLM, ale w firmach, z którymi pracuję, infrastruktura CI od lat stoi na Jenkinsie. Stawianie osobnego serwera tylko dla modułów AI nie ma sensu: testy promptów i ewaluacje modeli da się wpleść w istniejący pipeline. Poniżej opisuję konfigurację, którą wdrażam u klientów, od poświadczeń, przez Jenkinsfile, po pułapkę z asynchronicznym wysyłaniem danych, na której zatrzymuje się większość zespołów zaczynających z LLMOps. O tym, gdzie modele językowe przydają się w samej pracy testera, piszę w tekście LLM w testowaniu.

Krok 1: klucze API poza repozytorium

Najczęstszy błąd, jaki widzę na starcie, to klucze Langfuse wpisane na sztywno w kodzie repozytorium albo w skrypcie pipeline’u. SDK potrzebuje trzech zmiennych środowiskowych:

  • LANGFUSE_SECRET_KEY: klucz prywatny projektu,
  • LANGFUSE_PUBLIC_KEY: klucz publiczny,
  • LANGFUSE_HOST: https://cloud.langfuse.com dla wersji chmurowej albo adres własnej instancji self-hosted.

W Jenkinsie zakładam je przez plugin Credentials Binding: Manage Jenkins → Credentials, nowy wpis typu „Secret text”, nazwa w stylu langfuse-secret-key-prod. Jenkins maskuje takie wartości w logach konsoli, więc klucz nie wycieka nawet przy gadatliwym buildzie.

Krok 2: Jenkinsfile z mapowaniem poświadczeń

Deklaratywny pipeline poniżej instaluje zależności, uruchamia testy w pytest i wysyła ślady (traces) do Langfuse. Zakładam, że agent ma już zainstalowanego Pythona.

pipeline {
    agent any
    environment {
        // Mapowanie poświadczeń Jenkinsa na zmienne wymagane przez SDK Langfuse
        LANGFUSE_SECRET_KEY = credentials('langfuse-secret-key')
        LANGFUSE_PUBLIC_KEY = credentials('langfuse-public-key')
        LANGFUSE_HOST       = 'https://cloud.langfuse.com'
        OPENAI_API_KEY      = credentials('openai-api-key')
    }
    stages {
        stage('Setup') {
            steps {
                sh 'pip install -r requirements.txt langfuse'
            }
        }
        stage('LLM Evaluation') {
            steps {
                sh 'pytest tests/test_llm_logic.py'
            }
        }
    }
}

Sekcja environment mapuje poświadczenia z Jenkinsa na zmienne, których oczekuje SDK. W kodzie testów nic więcej nie konfiguruję: konstruktor Langfuse() wywołany bez argumentów czyta te zmienne sam.

Krok 3: flush(), czyli dlaczego dashboard jest pusty

Ten problem debugowałem u trzech różnych klientów: build zielony, pytest przeszedł, a w dashboardzie Langfuse pusto. Przyczyna siedzi w architekturze SDK. Zarówno wersja Python, jak i JS/TS wysyłają zdarzenia asynchronicznie w tle, żeby nie spowalniać głównego wątku aplikacji. Na serwerze produkcyjnym to zaleta. W CI kontener ginie natychmiast po zakończeniu komendy i SDK nie zdąża wypchnąć danych na serwer.

Wysłanie trzeba wymusić metodą flush() przed końcem procesu. Najwygodniej zrobić to raz, w pytest fixture o zasięgu sesji:

# conftest.py
import pytest
from langfuse import Langfuse

@pytest.fixture(scope="session", autouse=True)
def langfuse_client():
    client = Langfuse()
    yield client
    client.flush()  # bez tego dane nie dotrą do dashboardu

Dzięki autouse=True fixture wykona się przy każdym uruchomieniu testów i opróżni kolejkę po ostatnim z nich, niezależnie od tego, czy autor pojedynczego testu pamiętał o flushu.

Tagowanie buildów, zanim zrobi się bałagan

Gdy Jenkins zaczyna mielić kilkanaście buildów dziennie, trace’y z CI mieszają się z produkcją i lokalnym developmentem. Rozdzielam je od pierwszego dnia tagami zbudowanymi ze zmiennych, które Jenkins i tak udostępnia w każdym buildzie: BUILD_NUMBER i GIT_COMMIT.

import os

trace = langfuse.trace(
    name="llm-ci-test",
    tags=[
        "jenkins",
        f"build-{os.environ.get('BUILD_NUMBER', 'local')}",
        os.environ.get('GIT_COMMIT', 'no-commit')[:8],
    ],
)

W panelu Langfuse filtruję potem wyniki po numerze builda i widzę, która zmiana promptu położyła ewaluację. Zanim wprowadziłem tagi, samo dopasowanie trace’a do commita potrafiło zająć mi pół godziny.

Bramki jakości: build ma się wywalić, gdy model głupieje

Samo wysłanie danych do dashboardu niczego nie pilnuje. Jenkins musi dostać jednoznaczny sygnał, czy model odpowiada poprawnie, a brak wyjątku w kodzie testu o tym nie świadczy. Dlatego w skryptach ewaluacyjnych dodaję asercje na wyniki ocen (scores). Przy ewaluacji model-based, gdzie drugi model ocenia odpowiedzi pierwszego, skrypt pobiera średnią ocen i kończy się kodem błędu, gdy spadnie poniżej ustalonego progu, u mnie zwykle 0.8. Jenkins oznacza wtedy build jako FAILED i blokuje wdrożenie tym samym mechanizmem, który od lat zatrzymuje wadliwe release’y przy klasycznych testach, jak go poukładać, opisuję w tekście o pipeline CI/CD z testami automatycznymi.

Jedno zastrzeżenie praktyczne: ewaluacje model-based kosztują, bo za każdą ocenę płacimy tokenami. Pełnego zestawu nie uruchamiam więc przy każdym commicie. W Jenkinsie rozdzielam to na dwa joby: lekki smoke test promptów w każdym buildzie i pełną ewaluację raz dziennie, wyzwalaną triggerem cron. Rachunek za API spadł u jednego z klientów czterokrotnie, a regresje i tak wychodzą najpóźniej następnego ranka.

Całość (poświadczenia, Jenkinsfile, flush, tagi i bramka jakości) to dla zespołu znającego Jenkinsa jedno popołudnie pracy. W zamian każdy commit zmieniający prompt przechodzi tę samą kontrolę co reszta kodu, a regresję jakości modelu łapię w CI, zanim zobaczy ją użytkownik.