Experiments¶
The demo evaluates its graphs as Langfuse experiments.
Each experiment is a script in demo/api/experiments/ that holds its graph's
Git-tracked cases, runs the graph on every case, and scores the results with
code. Every run keeps its traces and scores in Langfuse, where runs can be
compared case by case.
| Experiment | Graph | Cases and checks |
|---|---|---|
lgos-rag |
lgos-rag |
Evaluate The Graph |
Each experiment has two datasets in Langfuse, with the same case format, so the same checks score both:
| Dataset | Holds | Edited in |
|---|---|---|
<experiment>/regression |
The script's cases, which developers review in Git | The script; each run overwrites edits made in Langfuse |
<experiment>/curated |
Cases that testers curate in Langfuse | Langfuse |
Run An Experiment¶
The recipe runs the script with the API's dependencies and the host gateway's
models, so the gateway must be running. Add --editable to use the parent LGOS
checkout. The script prints a line as each case finishes, naming the checks a
failing case missed, then the run's summary.
The script records to Langfuse when LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY,
and LANGFUSE_BASE_URL are set, as in demo/.env. A fresh Langfuse project
needs nothing else: the first run creates both datasets and every score. Without
the keys, the script runs the same cases and checks on local data with Langfuse
disabled, records nothing, and still fails on too many failing cases.
To run that way with demo/.env, add --no-langfuse, which also turns off the
API's own tracing:
To run a Langfuse dataset instead of the script's cases, name it with
--dataset. The run reads the dataset as it is and writes no cases:
Curate Cases In Langfuse¶
Testers add cases to the experiment's curated dataset in the Langfuse UI,
without code. Each item's Input and Expected output follow the case
format of the experiment's page, such as
lgos-rag's. The dataset enforces that
format as a JSON schema, so Langfuse rejects an item that does not match it.
Leave Metadata empty.
After a run of the dataset, Experiments shows its results. Compare runs,
filter items by their passed score, and annotate them with
human scores.
When a curated case exposes a bug, a developer adds it to the script with the
fix, so the regression cases keep it.
Judge Answers In Langfuse¶
Code checks cannot tell whether an answer's content is true. A Langfuse
LLM-as-a-Judge
evaluator does that without code. Its evaluation rule picks the observations it
scores, such as a graph's answer call in the experiment's datasets, and the
Langfuse worker grades each one after the run. The score and its reasoning
attach to the observation, so a judge never fails a run or counts toward
passed. Its prompt lives in Langfuse, where testers can edit it or add
evaluators. Each experiment's page describes its judges, such as
lgos-rag's.
What a judge needs
A judge calls its model through a Langfuse LLM connection, with function
tools for its structured score. A model that supports function tools only
through the Responses API, such as gpt-6-luna, needs the connection's
Use Responses API. The Langfuse worker makes the calls, so a
self-hosted Langfuse that reaches a private gateway needs the gateway's
host in the worker's LANGFUSE_LLM_CONNECTION_WHITELISTED_HOST.
What A Run Does¶
flowchart TD
sync["Sync the regression dataset from the script's cases"] --> run["Run the graph on every synced case"]
run --> checks["Score each check as pass or fail"]
checks --> passed["Score the case passed when every check passed"]
passed --> rate["Score the run's pass_rate"]
rate --> gate{"More failing cases than allowed?"}
gate -->|"yes"| fail["Raise RegressionError listing them"]
gate -->|"no"| done["Print the run summary"]
- The script's cases are the source of truth for the
regressiondataset. Each case is named by the script'scase_id, and the run makes the dataset hold exactly them: it adds or updates changed cases and archives removed ones. The run also creates thecurateddataset, and gives both the case format's JSON schemas. - The Langfuse experiment runner runs the graph on the items the run synced,
not on a fresh read of the dataset, so a concurrent run of other cases cannot
swap them. It records each case's graph calls in its trace. A run with
--datasetskips the sync and runs that dataset's items. - Each check that applies to a case becomes a
BOOLEANscore, and the case is scoredpassedwhen it has checks and passes all of them. An evaluator that raises, returns anything but checks, or names a checkpassedfails its case with a score named after it, holding the error. - The run's
pass_rateis the share of all cases scoredpassed. A case whose graph run raised has no scores, so it counts as failed, and its trace holds the error. The run's metadata records the evaluator version and the settings under test, such as the models. - Model sampling flips a case or two between runs, so each experiment allows a
few failing cases. Beyond that, the run raises
RegressionErrorwith each failing case and its failed checks.
In Langfuse, Datasets shows each dataset's cases and their versions, and
Experiments shows the runs. Compare two runs to see which cases a prompt,
model, or retrieval change fixed or broke, or filter a run's items by passed
to open the failures.
Add An Experiment¶
A new experiment, such as experiments/deep_research.py, is a Langfuse
experiment script
that leaves the dataset sync and the gate to experiments/_shared.py:
- Pydantic models for a case's input and expected output. Their JSON schemas become the case format of both datasets.
ITEMS: local experiment items, each with aninputand anexpected_output, and acase_id(input)function that names each case uniquely.- A task,
async def task(*, item, **kwargs), that validatesitem["input"]with the input model, runs the graph on it, and returns what the checks need. It receives every item as a local one, hosted or not. Pass the graph a LangfuseCallbackHandler()in its callbacks, so the item's trace holds the graph's calls. - An evaluator,
def checks(*, input, output, expected_output, **kwargs), that returns onescore(name, passed=..., comment=...)per check that applies. experiment(context), which returnsrun(context, ...)with the name, description, items,case_id, models, task, evaluators, failure budget, and metadata. Record anevaluator_versionin the metadata, and change it when a check changes, so runs graded differently are not compared.main(experiment, ITEMS)underif __name__ == "__main__":; without Langfuse, it runsITEMSlocally.
run rejects an experiment without cases or with a repeated case ID.
just demo/experiment deep-research then runs the script.
Run In CI¶
The scripts follow the Langfuse experiment runner convention, so the Langfuse
experiment action can run them
unchanged. Point its experiment_path at demo/api/experiments: it skips files
that start with _ and puts the folder on sys.path, so _shared.py is
imported, not run. Without a dataset_name, each script syncs and runs its
regression cases. With one, such as lgos-rag/curated, the script runs the
dataset the action loads, at a dataset_version that CI may pin. Either way,
the action records the commit, branch, and job in the run's metadata. The
action runs the first python on PATH, so install the API project, put its
virtual environment first on PATH, and set
should_skip_sdk_installation: "true". Give the job the gateway's URL and key
too, because the experiments call real models.
Future: Start Runs From Langfuse¶
Testers cannot start a run from Langfuse yet. Langfuse's
Custom Experiment
trigger would close that gap: a dataset page sends a signed webhook with the
dataset's name and a JSON config to an endpoint the team hosts. A small receiver
built from the API image would verify the x-langfuse-signature header, reply
at once, and run the script's experiment(context) on the dataset's items in
the background, so the UI and the CLI share one implementation. Langfuse does
not document the webhook's payload, so capture a real request before building
it.