メインコンテンツへスキップ

LLM-as-a-Judgeだけでは足りない? Langfuse Code Evaluatorsで評価を設計し直す

著者
Taro Yamauchi

本記事でわかること
#

  • LLM-as-a-Judgeの「苦手な評価」とは何か
  • Langfuseのコード評価(Code Evaluators)機能の概要と使い方
  • コード評価をLLM評価と組み合わせた実践的な運用パターン
  • INACTIVEなエバリュータへの手動バッチ実行を活用した安全な本番導入フロー

対象読者
#

  • LangfuseでLLM-as-a-Judgeを使っているエンジニア
  • 評価コストや判定のブレに課題を感じている方
  • Langfuseの評価機能を本番導入する前に安全に試したい方

LLM-as-a-Judgeだけでは足りないケース
#

LLMアプリを本番運用していると、こんな疑問が浮かぶことがあります。「この評価、本当にLLMが必要?」

LLM-as-a-Judge(別のLLMに評価させる手法)は「返答のトーンは適切か」「情報が網羅されているか」といった定性的な判断に強く、Langfuseでも広く使われています。

一方で、次のような「明確な正解がある評価」に対してはLLMを使う必要がそもそもないケースがあります。

  • JSON形式チェック: 返答がJSONとして正しくパースできるか
  • フィールド存在チェック: 必須フィールドが含まれているか
  • 文字列一致: 特定のキーワードが含まれているか
  • 数値範囲チェック: スコアが0〜100の範囲に収まっているか
  • Tool callの引数チェック: 必要なパラメータが含まれているか
  • Structured outputの検証: スキーマ通りの出力になっているか

これらをLLM-as-a-Judgeで判定しようとすると、以下の問題が生じます。

問題内容
コスト1件の評価ごとにLLM呼び出しが発生するため、大量ログ評価では無視できないコストになる
再現性のなさ同じ入力でも確率的に判定がブレる
速度LLMの応答待ちが評価パイプラインのボトルネックになる

1,000件のログをバッチで評価する場合、この差は無視できません。ルールで判定できるものまでLLMに委ねると、不要なコストと不安定性を持ち込むことになります。


Code Evaluators:コードで評価する
#

こうした課題に対応するのが、LangfuseのCode Evaluatorsです(v3.176.0以降)。

評価テンプレートの作成時に「LLM-as-a-Judge」と「Code」のどちらかを選択できるようになり、Codeを選ぶとPythonまたはTypeScriptの評価関数をLangfuse UI上で直接記述できます。

前提(SDK要件): Code Evaluators をライブの observation に適用するには、OpenTelemetry ベースの SDK でトレースを取り込んでいる必要があります(Python SDK v3 以降 / JS・TS SDK v4 以降)。旧バージョン(Python v2 / JS・TS v3)を使っている場合は、各マイグレーションガイドに沿った移行が前提になります。

評価テンプレートのタイプ選択UI(LLM-as-a-Judge と Code を切り替える)
テンプレートタイプ選択UI

評価関数の書き方は公式ドキュメントで定義されたコントラクトに従います。エディタを開くと型定義のboilerplateが読み取り専用の固定ヘッダーとして表示され、evaluate 関数の本体のみ編集可能です。

注意: コードをそのままコピーして貼り付けようとしても、固定ヘッダー部分は上書きできないため貼り付けができません。編集可能な部分(evaluate 関数の本体)だけを書き換える形で使ってください。

Python の場合、エディタに表示される固定ヘッダーと編集可能部分は以下の通りです。

# ── 以下は固定ヘッダー(編集不可) ──────────────────────────
from dataclasses import dataclass
from typing import Any

@dataclass
class ObservationContext:
    input: Any = None
    output: Any = None
    metadata: Any = None

@dataclass
class ExperimentContext:
    item_expected_output: Any = None
    item_metadata: Any = None

@dataclass
class EvaluationContext:
    observation: ObservationContext
    experiment: ExperimentContext | None = None

@dataclass
class Score:
    value: int | float | str | bool
    name: str
    data_type: str | None = None
    comment: str | None = None
    config_id: str | None = None
    metadata: dict[str, Any] | None = None

@dataclass
class EvaluationResult:
    scores: list[Score]

def evaluate(ctx: EvaluationContext) -> EvaluationResult:
# ── ここから編集可能 ─────────────────────────────────────────
    import json
    output = ctx.observation.output
    if isinstance(output, dict):
        is_valid = True
    elif isinstance(output, str):
        try:
            json.loads(output)
            is_valid = True
        except (json.JSONDecodeError, TypeError):
            is_valid = False
    else:
        is_valid = False
    return EvaluationResult(
        scores=[Score(name="valid-json", value=is_valid, data_type="BOOLEAN")]
    )

補足: 上記の固定ヘッダーでは data_type: str | None = None と型ヒント上は省略可能に見えますが、実際にはスコアごとに data_type(TypeScript では dataType)を指定する必要があります。値は NUMERIC / CATEGORICAL / BOOLEAN / TEXT のいずれかです(公式の Function contract でも必須項目として定義されています)。

注意: Python では ctx.observation.output はJSON文字列ではなく、すでにパース済みの dict として渡ってくる場合があります。上記のように isinstance で型を確認してから処理してください。

TypeScript も同様に、型定義のboilerplateが固定ヘッダーとして表示され、その下の evaluate 関数(JSDocコメントを含む)が編集可能です。

// ── 以下は固定ヘッダー(編集不可) ──────────────────────────
type EvaluationContext = { ... };
type EvaluationResult = { scores: Score[] };
// (型定義は省略)
// ── ここから編集可能 ─────────────────────────────────────────
/**
 * Evaluates one observation and returns one or more Langfuse scores.
 */
function evaluate(ctx: EvaluationContext): EvaluationResult {
  const output = ctx.observation.output;
  let valid = false;

  if (output !== null && typeof output === "object" && !Array.isArray(output)) {
    const obj = output as Record<string, unknown>;
    valid = typeof obj.answer === "string" && Array.isArray(obj.sources);
  } else if (typeof output === "string") {
    try {
      const parsed = JSON.parse(output);
      if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
        const obj = parsed as Record<string, unknown>;
        valid = typeof obj.answer === "string" && Array.isArray(obj.sources);
      }
    } catch {
      valid = false;
    }
  }

  return {
    scores: [{ name: "schema-valid", value: valid, dataType: "BOOLEAN" }],
  };
}

ctx オブジェクトには以下の情報が含まれます。

フィールド内容
ctx.observation.inputLLMへの入力
ctx.observation.outputLLMの出力
ctx.observation.metadataobservationのメタデータ
ctx.experimentExperiments利用時のみ存在(ライブ観測では None / undefined
ctx.experiment.item_expected_output(Python)/ ctx.experiment.itemExpectedOutput(TypeScript)Experiments利用時の期待出力

Experiments機能(データセットを使った実験機能)と組み合わせると、期待出力との一致チェックなども記述できます。ライブのobservation評価では ctx.experimentNone / undefined になるため、コード内でガードが必要です。

// Experiments利用時:期待出力との完全一致チェック
function evaluate(ctx: EvaluationContext): EvaluationResult {
  const expected = ctx.experiment?.itemExpectedOutput;
  if (expected === undefined || expected === null) {
    return {
      scores: [
        {
          name: "exact-match",
          value: false,
          dataType: "BOOLEAN",
          comment: "experiment context is not available",
        },
      ],
    };
  }

  const matched = ctx.observation.output === expected;
  return {
    scores: [
      { name: "exact-match", value: matched, dataType: "BOOLEAN" },
    ],
  };
}

Code Evaluatorのコードエディタ画面(固定ヘッダーと編集可能な evaluate 関数)
コードエディタUI

テンプレート画面でそのまま動作確認できる
#

コードを書いたら、保存する前にその場でテスト実行できます。サンプルの入出力を入力してコードを走らせると、スコアがプレビュー表示されます。実際に本番データに適用する前に動作を確認できるため、安心です。

保存前にサンプル入出力でテスト実行できるカード
テスト実行カード

実行トレースでデバッグする
#

Code Evaluators は実行のたびに内部トレースを残します。Tracing 画面で environment を langfuse-code-eval で絞り込むと、各評価実行のトレースを確認できます。評価器に渡された入力・出力、Experiments コンテキスト、実行時間(レイテンシ)、返却されたスコア、ログ、エラー内容まで追えるため、「なぜこのスコアになったのか」「なぜ失敗したのか」を切り分けられます。

補足: langfuse-code-eval は内部用の environment で、デフォルトのトレース一覧には表示されません。明示的に environment = langfuse-code-eval でフィルタするか、該当スコアや評価器ログから実行トレースを開いてください。

ランタイム制約
#

Code Evaluatorsはネットワーク外部通信のない隔離環境で実行されます。実装にあたっては、以下の制約を把握しておく必要があります(公式ドキュメント より)。

制約内容
実行時間2秒以内
ソースコードサイズ256KB以内
入力ペイロード5.5MB以内(ソースコードと選択変数の合計)
結果サイズ256KB以内
スコア返却最低1つ以上のスコアを返す必要あり
外部通信ネットワークEgressは不可(外部APIの呼び出し不可)
依存パッケージ標準ライブラリのみ利用可(サードパーティパッケージは不可)
TypeScript制約erasable syntaxのみ対応(enum/namespace/decorator等は不可)
セルフホスト・Pythonaws-lambda ディスパッチャーが必要(insecure-local はTypeScript/JSのみ対応)

なお、Code EvaluatorsはLangfuse UIからのみ作成・管理が可能です。公式APIからの操作は現在LLM-as-a-Judgeエバリュータに限定されています(Fast Preview中のため)。APIから評価スコアを登録したい場合は、Scores via API/SDK をご利用ください。

そのため、評価ロジックを Git で管理したい・CI/CD パイプラインに組み込みたいというニーズには、Code Evaluators(UI 管理)はそのままでは噛み合いません。その場合は、評価コードを自前の環境(CI など)で実行し、結果を Scores API/SDK で Langfuse に送り込む構成のほうが向いています。UI 上で完結する手軽さを取るか、コードのバージョン管理・再現性を取るかで使い分けるとよいでしょう。

self-hosted環境での有効化
#

self-hosted環境でCode Evaluatorsを使うには、以下の環境変数の追加設定が必要です。

TypeScript/JavaScript評価のみ行う場合(ローカル実行):

NEXT_PUBLIC_LANGFUSE_CODE_EVAL_ENABLED=true
LANGFUSE_CODE_EVAL_DISPATCHER=insecure-local
QUEUE_CONSUMER_CODE_EVAL_EXECUTION_QUEUE_IS_ENABLED=true

Python評価も行う場合: LANGFUSE_CODE_EVAL_DISPATCHER=aws-lambda に変更し、AWS Lambda関数の別途セットアップが必要です(詳細は公式ドキュメント 参照)。

注意(本番運用での安全性): insecure-local ディスパッチャーは、評価コードを Langfuse worker プロセス内で実行します。これは未信頼のコードを安全に隔離するためのサンドボックスではありません。self-hosted で本番のライブトラフィックに適用するなら、aws-lambda ディスパッチャーを使い、実行権限・タイムアウト・ネットワーク egress を制限した環境でコードを動かす前提で設計してください。insecure-local は TypeScript/JS の開発・検証用と位置づけるのが安全です。

注意: NEXT_PUBLIC_LANGFUSE_CODE_EVAL_ENABLED はNext.jsのビルド時変数のため、プリビルドDockerイメージに実行時に渡すだけでは有効にならないケースがあります。設定後はコンテナを完全に再作成(docker compose down && docker compose up -d)してください。


LLM評価とコード評価の使い分け
#

2つの評価方式は「どちらかを選ぶ」ものではなく、コード評価テンプレートとLLM評価テンプレートをそれぞれ作成して、同じプロジェクトのデータに対して複数の評価器を適用して使い分ける形で組み合わせることができます。

評価の種類向いている評価
LLM-as-a-Judge定性的・意味的な評価返答のトーン、情報の網羅性、有益さ
Code Evaluators決定論的・構造的な評価JSONチェック、フィールド存在確認、文字列一致

実践的な運用パターンとして、二段構えの評価設計が有効です。

① Gate check(コード評価を適用)
   - JSONとして正しくパースできるか?
   - 必須フィールドが含まれているか?
   → 構造的に問題があれば即アウトとして扱う。
② Quality check(LLM評価を適用)
   - 返答は有益か?
   - トーンは適切か?
   - 情報は十分か?
   → 構造が正しいものだけをLLMで定性評価する。

設計上の注意: この二段構えパターンはLangfuseの機能として自動連携されるわけではありません。アプリ側でコード評価スコアを確認してからLLM評価を呼び出す設計か、それぞれ独立した評価器として設定してスコアを組み合わせる形で実現できます(Langfuse上で「コード評価失敗時にLLM評価を自動スキップする」機能は現時点では提供されていません)。

コスト・速度・再現性が重要なチェックはコード評価に任せ、LLMに「判断」を求めるべき評価だけLLM-as-a-Judgeに絞ることで、評価パイプライン全体のコストと安定性を大きく改善できます。


Code Evaluatorsが向かないケース
#

一方で、Code Evaluatorsが万能というわけではありません。

次のような評価は、依然としてLLM-as-a-Judgeが向いています。

  • 回答がユーザーの意図を満たしているか
  • 文章のトーンや語調が適切か
  • 情報が十分に網羅されているか
  • 説明が自然で読みやすいか

構造的な正しさはCode Evaluators、意味的な品質はLLM評価という役割分担が基本です。両者を組み合わせることで、評価の精度とコスト効率を両立できます。


安全に本番導入するための運用フロー
#

新しくコード評価テンプレートを書いたとき、「いきなり本番のライブトラフィックに適用するのは不安」というのは自然な感覚です。

Langfuseでは、Tracesテーブルから過去データを選択してバッチ評価を実行できます。v3.176.0では、INACTIVEな評価器もこのバッチ実行に使えるようになりました。

おおまかな流れは次の通りです。

  1. Tracing 画面で評価したいトレース/observation を選択する
  2. アクションメニューから評価(Evaluate)を選び、適用する評価器を指定する
  3. INACTIVE な評価器も、過去データへのバッチ評価であれば選択できる(その旨が画面に表示される)
  4. 実行すると、選択した過去データに対してバッチ評価が走る

注意: Langfuse の UI はバージョンによってメニュー名やボタンの配置が変わることがあります。ボタン名そのものより「データを選んで → アクションから評価器を適用する」という流れを押さえてください。最新の正確な手順は公式ドキュメント を参照してください。

実際の画面はおおよそ以下のように遷移します。

評価器を適用するダイアログ。標準では ACTIVE な評価器のみが対象で、ACTIVE な評価器がない場合は一覧が空になる
評価器を適用するダイアログ

過去 observation のバッチ評価用に評価器を用意する画面
バッチ評価用の評価器を用意する

INACTIVE な評価器でも『過去 observation のバッチ評価には利用可能』と表示される
INACTIVE 評価器も選択できる旨の表示

INACTIVE な評価器を選択してバッチ評価を実行する
バッチ評価の実行

従来はINACTIVEにしていると手動バッチでも処理がスキップされていたため、「本番に影響させずに過去データで試す」にはACTIVEに戻す必要がありました。v3.176.0以降はこの制約がなくなります。

これにより、次のような安全な導入フローが取れます。

① 新しいコード評価テンプレートを作成(Running Evaluatorsに追加せずINACTIVE状態のまま)
② テスト実行で既存トレースに対して動作確認
③ Tracing 画面で対象データを選択 → アクションメニューから評価を実行(INACTIVEのまま過去データにバッチ評価)
④ 結果をLangfuseのログ画面で確認・スコアを検証
⑤ 問題なければRunning EvaluatorsにACTIVEとして追加して本番適用

③〜④のステップでINACTIVEのまま実際のデータを使って動作確認できる点がポイントです。本番トラフィックへの影響なしに、リアルなデータでの評価結果を見てから適用判断できます。

注意: バッチ実行でも、評価器に設定されたサンプリングレートは適用されます。「全件評価されていない」と感じた場合は、サンプリング設定を確認してください。

なお、認証エラーやモデルエラーなどでシステム的にブロックされているエバリュータ(blocked状態)は、手動バッチ実行でも引き続きスキップされます。意図しない失敗が起きない設計になっています。


実務での活用シーン
#

Code Evaluatorsは、LLMアプリの種類を問わず幅広いシーンで活用できます。

  • RAGシステム: 取得した文書のJSON構造が正しいか、必須フィールド(chunk_idsourceなど)が存在するかをコードで自動チェック
  • チャットボット: 返答が空でないか、禁止ワードを含んでいないかをルールベースで判定
  • AIエージェント: Tool callで必要な引数が渡されているか、スキーマ通りのStructured Outputが返ってきているかを検証
  • バッチ処理: 出力の数値範囲や形式の正確性をコストをかけずに全件チェック

いずれも「LLMを使うほどではないが、毎回確認したい」という評価に対して、Code Evaluatorsが有効です。


コード評価の設計で避けたいパターン
#

機能としては手軽でも、評価関数の書き方を誤ると「コード評価の利点」をかえって損ないます。実装時に避けたいパターンをいくつか挙げます。

  • 非決定的な要素を持ち込む: 評価関数の中で現在時刻・乱数・実行ごとに変わる値を使うと、同じ入力でもスコアが変わってしまいます。コード評価最大の利点である再現性が崩れるため、判定は入力(ctx)だけから決まるように書きます。
  • 型・存在のガードを省く: ctx.observation.output はパース済みのdict/objectで渡る場合があり、ctx.experiment はライブ観測では None / undefined になります。型チェックや存在チェックを省くと、特定のデータでだけ評価が落ちる原因になります。
  • 複数の失敗理由を1つの boolean スコアに丸め込む: 1つの評価関数からは複数のスコアを返せますscores は配列)。「JSON妥当性」「必須フィールド」「文字列一致」をまとめて1つの true/false に丸めてしまうと、どの条件で落ちたのか分からず切り分けが難しくなります。判定ごとに名前付きスコアを返し、結果を組み合わせるほうが運用しやすくなります。
  • 制約を超える処理を書く: 実行時間2秒・ソースコード256KB・標準ライブラリのみ・外部通信なしという制約内で完結させます。重い処理や外部API依存はそもそも実行できません。

まとめ
#

LangfuseのCode Evaluators機能により、「明確な正解がある評価」をコードで記述できるようになりました。

  • コスト削減: ルールベースの評価にLLMを使わずに済む
  • 再現性の確保: コードが決定論的である限り、同じ入力には常に同じスコアが返り、判定のブレを抑えられる
  • LLM評価との組み合わせ: 決定論的・構造的・客観的なチェックはコード、意味的・定性的な評価はLLMと役割分担できる
  • 実行環境: 評価コードはネットワーク egress なしの制約付き環境で実行される(ただし self-hosted の insecure-local は worker プロセス内実行のため、本番では aws-lambda 実行が前提)

また、INACTIVEなエバリュータへの手動バッチ実行が可能になったことで、「本番に影響を与えずに実データで検証してから適用する」という安全な導入フローが取りやすくなりました。

LLM評価を見直す際は、「この判定は本当にLLMが必要か?」という視点を持つと、不要なコストや判定の揺らぎを減らせるかもしれません。


参考リンク
#