本記事では、LayerXのバクラクAIエージェント開発における、Langfuseを活用したAIエージェント機能の性能評価と実験管理の取り組みを紹介します。バクラクはバックオフィスに特化したAIエージェントで、日常業務の中に自然にAIを溶け込ませる体験を提供しています。
こちらは LayerX AI Agent ブログリレー 30日目の記事です。前回はバクラク事業部のAI・機械学習部でインターンをしてくださっていた @ProgressSemi による 『コーディングエージェントのための情報検索システムの最前線』 でした。今回はバクラク事業部 機械学習エンジニアの松村(@yu__ya4)からお届けします。

はじめに
多くの企業・個人がAIエージェント開発を進める一方で、プロダクトとしての性能評価に関する実践知はまだ少ないと感じています。私たちも試行錯誤の途上ですが、現時点の実装と運用の一部を共有します。
この記事で扱うタスク(経費精算申請のAIレビュー)や利用するデータセットの作り方は前回の記事で紹介しました。まずはこちらに目を通していただけた方が理解が深まるかと思います。
今回は、AIエージェントで利用するプロンプトやLLMに与えるパラメタやモデルを変更する実験に焦点を当てます。
プロンプト管理
Langfuse には Prompt Management 機能が備わっており、こちらを利用しています。プロダクトのコードベースとは独立してプロンプトのバージョン管理が行えるため、プロダクトのデプロイをせずともプロンプトを変更した実験を高速に回すことができます。Datasets や Observability 機能とうまく組み合わせることで Langfuse で閉じた見通しの良い実験管理が行えて便利です。前回紹介した Datasets 機能と同様に、UIからの操作とSDK経由での操作性が良く使いやすいと感じています。
ちなみに、評価実験ではなくプロダクトからプロンプトを利用する際は、事故を防ぐためにUIからポチポチ変更するのではなく、コードベースで管理した上でデプロイするようにしています。また、デプロイ時には今回紹介する実験管理の仕組みに近い形でCIにおける自動リグレッションテストを走らせるようにしています。詳細は以下のブログをご覧ください。
前回の記事で紹介した経費精算申請のAIレビューエージェントのプロンプトを Langfuse の Prompt Management で管理すると以下のようになります。変数({{review_target}}, {{expense_application}})も扱うことができ、実行時に必要なデータを与えた上でコンパイルすることで完成したプロンプトを獲得します。

また、PlaygroundのUI上で適当な変数を設定した上で、プロンプトやパラメタをいじってポチポチ動作を検証できるのも便利です。特に、エンジニアでなくともAIエージェント / LLMの挙動を簡単に手早く確認できる点が気に入っています。初手はここで少数サンプルに対して実験を行い、ある程度のプロンプトの有効性を検証します。

config という形で任意の設定値などをプロンプト本体と一緒に管理可能です。以下のように、利用するモデルやLLMに渡すパラメタを設定しておき、実行時にもこれを参照することで意図しないモデルやパラメタを利用していたということを防ぎます。

利用時は、プロンプト名やバージョンあるいはラベル名を指定してプロンプトを取得します。同じプロンプトを複数回呼び出す際にはクライアント側でキャッシュも効きます。ハードコードしていたプロンプトがなくなってスッキリしました。
def base_review_expense_application(*, review_input: ReviewInput) -> ReviewResult: prompt = langfuse_client.get_prompt(name="review_expense_application_prompt", version=3,) compiled_prompt = prompt.compile(**review_input.model_dump()) llm_config = prompt.config response = openai_client.responses.parse(input=compiled_prompt, text_format=ReviewResult, **llm_config) return response.output_parsed
>>> review_input = ReviewInput(review_target="タクシー乗車理由", expense_application="2025年10月1日 タクシー代 1000円 なんとなくのってみた。") >>> response = base_review_expense_application(review_input=review_input) >>> response.output_parsed ReviewResult(reason='経費精算申請には「タクシー乗車理由」が具体的に記載されておらず、「なんとなくのってみた。」は業務上の正当な理由とは認められません。', result=False)
LangfuseのExperiments Runner SDK を利用した評価実験
アプリケーションの評価実験はプロンプトへの入出力だけでは完結しません。任意のエージェントアプリケーションに対してデータセット内のデータを入力し、その出力を持って評価を行います。評価の方法もアプリケーションの特性によって様々であり、適切な評価指標を設計します。これらの構成要素を組み合わせることで評価を行い、その結果を適切に記録する仕組みが必要となります。
この仕組みの実現には、最近リリースされた Experiments Runner SDK が有用です。評価に利用したいデータセット(data)と関数(task)、評価関数(evaluator)を用意すれば、データセット内のそれぞれのデータに対して関数を実行し、評価関数を使って評価した結果を Langfuse の Score として記録した上でトレーシングまでしてくれる API が提供されています。
元々は同じような仕組みを自前で実装していたのですが、先月(2025年9月)に Experiments Runner SDK が正式にリリースされたため、移行することとしました(元々の仕組みで本記事を執筆している最中にリリースに気がつきました)。
データセットの取得
Langfuse 上のデータセットは langfuse の API を利用することで取得します。
dataset = langfuse_client.get_dataset(name="review_expense_application_dataset")
中身はこのような形式
>>> dataset.items[3].input {'review_target': 'タクシーの乗車理由', 'expense_application': '2025年9月24日 タクシー代 900円 電車より早いからタクシーにしました。'} >>> dataset.items[3].expected_output {'reason': '単なる時短の主張で、業務上の必要性や代替不可の説明が不足。', 'result': False}
関数の定義
関数は以下のように、item というキーワード引数でデータセット内の各データを受け取る形で定義します。async も対応しています。
def review_expense_application(*, item, **kwargs) -> ReviewResult: review_input = ReviewInput.model_validate(item.input) prompt = langfuse_client.get_prompt(name="review_expense_application_prompt", version=3,) compiled_prompt = prompt.compile(**review_input.model_dump()) llm_config = prompt.config response = openai_client.responses.parse(input=compiled_prompt, text_format=ReviewResult, **llm_config) return response.output_parsed
評価関数の定義
評価関数は、データの input 、expected_output(期待出力 / 正解)およびmetadata と、関数の出力 output を受け取れます。例の result_accuracy_evaluator はシンプルに関数の出力と期待出力の完全一致を評価しており、reason_length_evaluator はエージェントの出力(評価理由の説明)が長すぎないかを評価しています。
def result_accuracy_evaluator(*, output: ReviewResult, expected_output: dict, **kwargs) -> Evaluation: """結果の正確性を評価""" expected_output = ReviewResult.model_validate(expected_output) value = 1.0 if output.result == expected_output.result else 0.0 return Evaluation(name="result_accuracy", value=value, data_type="NUMERIC") def reason_length_evaluator(*, output: ReviewResult, **kwargs) -> Evaluation: """理由の長さを評価(50文字以下: 1.0, 100文字以下: 0.5, それ以上: 0.0)""" reason_length = len(output.reason) if reason_length <= 50: value = 1.0 elif reason_length <= 100: value = 0.5 else: value = 0.0 return Evaluation(name="reason_length", value=value, data_type="NUMERIC")
ちなみに、 data_type を明示的に指定しないと value が 0 の際にどこかで型推論か何かがバグっているようで、-1が Score として記録されてしまうので注意です(後で余裕があれば見る)。
評価実験の実行
以上を run_experiment APIに渡すことで、指定したデータセットに対して、指定した関数を指定した評価関数で評価した結果が得られ、Langfuse に実行 trace とともに記録されます。便利。
result = langfuse_client.run_experiment(
name="easy experiment",
data=dataset.items,
task=review_expense_application,
evaluators=[result_accuracy_evaluator, reason_length_evaluator]
)
>>> result.format()) Individual Results: Hidden (17 items) 💡 Set include_item_results=True to view them ────────────────────────────────────────────────── 🧪 Experiment: easy experiment 📋 Run name: easy experiment - 2025-10-22T03:09:24.754625Z 17 items Evaluations: • reason_length • result_accuracy Average Scores: • reason_length: 0.382 • result_accuracy: 0.647 🔗 Dataset Run: https://langfuse.hogefuga/project/cmg866ber0001ad07fucyr3t6/datasets/cmg9fzfjx00bnad07gmcp3g90/runs/fa975ca1-602c-471b-b8a5-96a4a35547a7
LangfuseのUIにおける実験結果の可視化
実験は Dataset Run という形でデータセットごとに記録されます。LangfuseのUI上でレイテンシやコスト、評価値の平均値などが一覧で確認できます。

特定の Run の詳細からは、データごとの入出力や評価値が確認できます。それぞれの Trace も確認できるので、各サンプルの入出力だけでなく、エージェント内部の各Span(サブルーチン)まで一元的に可視化できるようになります。

このように、複数の Run の出力や評価値を横並びで比較して見ることも可能であり、異なる実験設定における挙動の確認に非常に便利であると感じています。

設定ファイルを用いた実験パラメタ管理
Langfuse の機能を組み合わせるだけでも実験を回すことはできるのですが、もう一工夫します。
プロンプトを固定して、複数のモデルでの結果を比較したいということはよくあると思います。また、様々なパラメタを変更して、アプリケーションに最適なものを探索したいということもあります。そのような際に、現在の仕組みでは毎回 Langfuse にプロンプトをデプロイすることになります。もちろんそれでもいいのですが、少しずつ設定値が異なるだけのプロンプトが大量に生まれるのがあまり好ましくないと感じており、モデルパラメタなどの設定値は別で(も)管理したくなります。
また、実際の実験では複数のデータセットや複数のプロンプトを組み合わせて同時に複数の実験を回したりします。そのような際に、「この組み合わせの実験って回したっけ、、?」「さっきはどういう実験をしたんだっけ、、、?」と混乱してしまうことがよくあります。
ということで、すべての実験の設定事項をひとつのディレクトリ/ファイルに集約して、人間はそこだけを見ればいいようにしています。具体的には、以下のような設定ファイルを用意して、評価実験を回す際には設定ファイルを指定して実行するようにしています。
experiment: description: "easy experiment" dataset: name: "review_expense_application_dataset" # LLMモデル設定 llm: model: "gpt-4.1-mini" temperature: 0.1 top_p: 0.9 max_output_tokens: 1000 # プロンプト設定 prompt: name: "review_expense_application_prompt" version: 3 label:
設定ファイルは以下のような形で配置し、新しい実験を行う際には新しいもの(experiment_xxxx.yaml)を作成します。
├── experiment_configs │ ├── experiment_0001.yaml │ ├── experiment_0002.yaml │ └── experiment_0003.yaml
以下のように設定ファイルの内容を定義し
"""実験設定のスキーマ定義""" from pathlib import Path from typing import Any from pydantic import BaseModel, Field import yaml class ExperimentInfo(BaseModel): """実験情報""" description: str = Field(description="実験の説明") class DatasetConfig(BaseModel): """データセット設定""" name: str = Field(description="使用するデータセット名") class PromptConfig(BaseModel): """プロンプト設定""" name: str = Field(description="Langfuseに登録されたプロンプト名") version: int | None = Field(default=None, description="プロンプトのバージョン") label: str | None = Field(default=None, description="プロンプトのラベル") class LLMConfig(BaseModel): """LLMの設定パラメタ""" model: str | None = Field(default=None, description="使用するモデル名") temperature: float | None = Field(default=None, description="温度パラメタ") max_output_tokens: int | None = Field(default=None, description="最大出力トークン数") top_p: float | None = Field(default=None, description="Top-pサンプリング") model_config = {"extra": "allow"} # その他のLLMパラメタも許可 def has_any_config(self) -> bool: """何か1つでも設定値が存在するかを判定""" values = self.model_dump(exclude_none=True) return len(values) > 0 class ExperimentConfig(BaseModel): """実験設定の全体構成""" experiment: ExperimentInfo = Field(description="実験情報") dataset: DatasetConfig = Field(description="データセット設定") prompt: PromptConfig = Field(description="プロンプト設定") llm: LLMConfig = Field(description="LLM設定") @classmethod def from_yaml_file(cls, file_path: str | Path) -> "ExperimentConfig": """YAMLファイルから設定を読み込む""" path = Path(file_path) if not path.exists(): raise FileNotFoundError(f"設定ファイルが見つかりません: {file_path}") with open(path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) return cls.model_validate(data) def to_yaml_file(self, file_path: str | Path) -> None: """YAMLファイルに設定を保存""" path = Path(file_path) path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: yaml.dump(self.model_dump(by_alias=True), f, allow_unicode=True, sort_keys=False)
以下のような形で設定値を読み込んでアプリケーションロジックで利用します。
experiment_config = ExperimentConfig.from_yaml_file(config_name) prompt_config = experiment_config.prompt llm_config = experiment_config.llm if experiment_config.llm.has_any_config() else LLMConfig()
def review_expense_application( *, review_input: ReviewInput, prompt_config: PromptConfig, llm_config: LLMConfig ) -> ReviewResult: # プロンプトを取得 prompt = langfuse_client.get_prompt(**prompt_config.model_dump(exclude_none=True)) compiled_prompt = prompt.compile(**review_input.model_dump()) # llm_configに設定がなければprompt.configを使用 effective_llm_config = llm_config if llm_config.has_any_config() else LLMConfig.model_validate(prompt.config) response = openai_client.responses.parse( input=compiled_prompt, text_format=ReviewResult, **effective_llm_config.model_dump(exclude_none=True) ) langfuse_client.update_current_span( input=review_input.model_dump(), metadata={ "prompt_config": prompt_config.model_dump(exclude_none=True), "llm_config": effective_llm_config.model_dump(exclude_none=True) }, ) return response.output_parsed
また、本番でも利用するアプリケーションのコードを Experiments Runner SDK の規格に合わせたくない(item というキーワード引数を強要したくない)ため、実験時の run_experiment の task を生成するファクトリを用意しています。これを通じて、実験用パラメタでアプリケーションの関数を実行する task を生成しています。
def create_experiment_task(prompt_config: PromptConfig, llm_config: LLMConfig): def task(*, item, **kwargs): review_input = ReviewInput.model_validate(item.input) return review_expense_application( review_input=review_input, prompt_config=prompt_config, llm_config=llm_config ) return task
また、これらの設定値は run_experiment の実行時に metadata として渡すことで Run の中で簡単に参照できるようにもしています。
result = langfuse_client.run_experiment(
name=experiment_name,
data=dataset.items,
description=experiment_config.experiment.description,
task=task,
evaluators=[result_accuracy_evaluator, reason_length_evaluator],
metadata={
"llm_config": llm_config.model_dump(exclude_none=True),
"prompt_config": prompt_config.model_dump(exclude_none=True),
"dataset_config": experiment_config.dataset.model_dump(),
},
)
実際の実装からは簡単のために省略している部分もありますが、大筋としては以上のような形で実験管理をしながら評価実験を回すことで、AIエージェントサービスの性能検証および改善を行なっています。
おわりに
今回は、AIエージェント機能の性能評価に際してどのように評価実験を実施しているのかを紹介しました。もちろん実態はもっと複雑で難しいです。
たとえば、エージェントプロダクトにおいては複数の処理/プロンプトやモデルが絡み合うことが多いため、どの単位で切り出して評価実験を行うかは重要なポイントです。プロンプト単位なのか、複数のプロンプトを含むtool単位なのか、E2Eでユーザーの体験に直結する指標を見るのか。それぞれを考えた際の実験管理はさらに複雑なものになってきます。評価指標についても、たとえば今回の「レビューの理由」のような自然言語形式のアウトプットの評価方法はより良いものがあるでしょう。よく言われるのが LLM-as-a-Judge による評価です。この辺りは別の機会で紹介します。また、実際のプロダクション環境のデータを使った評価実験やモニタリングにも様々な課題があります。この辺りは近々大作記事が出る予定ですのでお楽しみに。
いずれにせよ、機械学習モデリングのみを前提としていた時代から考えるとアプリケーションの挙動の不確実性や変数が大きく増え、実験のデザインや仕組み作りの難易度が上がっていることを日々感じています。それと同時に、楽しく非常にやりがいのある取り組みだなとも思っています。
LayerXではこのAIエージェント時代をともに駆け抜ける仲間を全方位募集中です。本当に毎日が刺激的で面白いです。少しでも興味を持っていただけたなら是非ともXのDMや以下のフォームなどからご連絡ください。