top of page

検索結果

空の検索で59件の結果が見つかりました。

  • LibreChat を Cloud Run と Firestore MongoDB 互換で構築する

    1. はじめに LibreChat は、OpenAI・Google Gemini・Anthropic など複数の LLM に対応したオープンソースのチャット UI です。セルフホストすることで、組織や個人の用途に合わせた AI チャット基盤を構築できます。 LibreChat のデータベースには MongoDB が必要です。Google Cloud で MongoDB を使うには、GCE や GKE 上に Docker で構築するか、Google Cloud マーケットプレイスから MongoDB Atlas を契約する方法が一般的でした。前者はインスタンスの管理が必要になり、後者はフルマネージドですが別サービスとの契約・連携が必要です。 そこで注目したのが、Firestore の MongoDB 互換モードです。Firestore の MongoDB 互換モードを利用すると、MongoDB を別途構築・運用することなく、フルマネージドなサーバーレス構成で LibreChat を動かせます。本記事では、この構成の検証結果と構築手順を紹介します。 先に検証結果をお伝えします。 結論 結論としては、基本的なチャット機能は正常に動作します。ただし、エージェントやプロンプトの一覧がリロード後に表示されなくなる、管理者ダッシュボードが使えないといった問題があります。これは Firestore MongoDB 互換が LibreChat の権限管理で内部的に利用する MongoDB ビット演算子クエリをサポートしておらず、権限チェックが失敗するためです。 2. アーキテクチャ概要 今回構築した環境の全体像です。Cloud Run をアプリケーション基盤とし、データベースに Firestore MongoDB 互換を採用したサーバーレス構成になっています。 構成図 GCP リソース一覧 リソース 用途 Cloud Run (LibreChat) メインアプリケーション Firestore Enterprise MongoDB 互換モードのデータベース Vertex AI Gemini API によるチャット Secret Manager JWT シークレット、SA キー等の管理 Cloud Storage librechat.yaml の配信(GCS FUSE マウント) Artifact Registry ghcr.io リモートリポジトリ リポジトリ構成 . ├── librechat.yaml # LibreChat 設定ファイル └── terraform/ ├── main.tf # 全リソース・変数定義 └── terraform.tfvars # 変数値(gitignore 推奨) 3. 前提条件 必要な環境 GCP プロジェクト Terraform >= 1.5 Google Provider >= 7.19 Firestore の `mongodb_compatible_data_access_mode` 属性は v7.19.0 で追加されました。それ以前のバージョンではこの属性が認識されず、MongoDB 互換モードを Terraform から有効にできません。 gcloud CLI 4. 構築手順 librechat.yaml の設定 LibreChat の設定ファイル `librechat.yaml` は、GCS バケットに配置し Cloud Run の GCS FUSE マウントで `/app/config/librechat.yaml` として読み込みます。 この例では、リモート MCP Server として Langfuse のドキュメント検索サーバーを `streamable-http` タイプで設定しています。 # librechat.yaml version: 1.3.6 cache: true mcpServers: langfuse-docs: type: streamable-http url: "https://langfuse.com/api/mcp" timeout: 30000 initTimeout: 10000 registration: socialLogins: [] allowedDomains: [] Terraform コード 全リソースを Terraform で IaC 管理しています。以下の内容を `main.tf` として保存します。各リソースの意図はコメントで説明しています。 main.tf # ================================================================= # 変数定義 # ======================+========================================== variable "project_id" { description = "GCP プロジェクト ID" type = string } variable "region" { description = "GCP リージョン" type = string default = "asia-northeast1" } variable "environment" { description = "環境名 (dev / stg / prod)" type = string default = "dev" } variable "firestore_database_name" { description = "Firestore データベース名" type = string default = "librechat" } variable "firestore_location" { description = "Firestore ロケーション" type = string default = "asia-northeast1" } variable "librechat_cpu" { description = "Cloud Run の vCPU 数" type = string default = "2" } variable "librechat_memory" { description = "Cloud Run のメモリ" type = string default = "1Gi" } # ================================================================= # プロバイダー設定 # ================================================================= terraform { required_version = ">= 1.5" required_providers { google = { source = "hashicorp/google" version = ">= 7.19" } } } provider "google" { project = var.project_id region = var.region } data "google_project" "current" { project_id = var.project_id } # ================================================================= # Firestore (MongoDB 互換モード) # ================================================================= # Enterprise エディション + mongodb_compatible_data_access_mode を有効化することで、 # MongoDB プロトコルでの接続が可能になる。 # 接続文字列の詳細は後述の「Firestore MongoDB 互換モードの解説」を参照。 resource "google_firestore_database" "librechat" { project = var.project_id name = var.firestore_database_name location_id = var.firestore_location type = "FIRESTORE_NATIVE" database_edition = "ENTERPRISE" mongodb_compatible_data_access_mode = "DATA_ACCESS_MODE_ENABLED" concurrency_mode = "PESSIMISTIC" delete_protection_state = "DELETE_PROTECTION_DISABLED" depends_on = [google_project_service.firestore] } resource "google_project_service" "firestore" { project = var.project_id service = "firestore.googleapis.com" disable_on_destroy = false } # ================================================================= # Artifact Registry # ================================================================= # Cloud Run は ghcr.io から直接 pull できないため、 # Artifact Registry にリモートリポジトリを作成して中継する。 resource "google_artifact_registry_repository" "ghcr_remote" { project = var.project_id location = var.region repository_id = "ghcr-remote" format = "DOCKER" mode = "REMOTE_REPOSITORY" # NOTE: custom_repository は deprecated。 # 最新の Provider では common_repository への移行が推奨されている。 remote_repository_config { docker_repository { custom_repository { uri = "https://ghcr.io" } } } depends_on = [google_project_service.artifactregistry] } resource "google_project_service" "artifactregistry" { project = var.project_id service = "artifactregistry.googleapis.com" disable_on_destroy = false } # ================================================================= # Secret Manager # ================================================================= # LibreChat に必要なシークレット(JWT、暗号化キー等)を管理。 # Terraform ではシークレットの「箱」のみ作成する。値は apply 後に手動で設定する。 # 例: echo -n "your-secret-value" | gcloud secrets versions add dev-librechat-jwt-secret --data-file=- # 各シークレットに対して上記コマンドを実行し、適切な値を設定すること。 locals { secrets = { jwt-secret = "LibreChat JWT シークレット" jwt-refresh-secret = "LibreChat JWT リフレッシュシークレット" creds-key = "LibreChat 暗号化キー" creds-iv = "LibreChat 暗号化 IV" } } resource "google_secret_manager_secret" "secrets" { for_each = local.secrets project = var.project_id secret_id = "${var.environment}-librechat-${each.key}" replication { auto {} } depends_on = [google_project_service.secretmanager] } # Vertex AI 用 SA キーを自動生成し Secret Manager に保存 resource "google_service_account_key" "librechat_vertex" { service_account_id = google_service_account.librechat.name } resource "google_secret_manager_secret" "vertex_sa_key" { project = var.project_id secret_id = "${var.environment}-librechat-vertex-sa-key" replication { auto {} } depends_on = [google_project_service.secretmanager] } resource "google_secret_manager_secret_version" "vertex_sa_key" { secret = google_secret_manager_secret.vertex_sa_key.id secret_data = google_service_account_key.librechat_vertex.private_key } resource "google_project_service" "secretmanager" { project = var.project_id service = "secretmanager.googleapis.com" disable_on_destroy = false } # ================================================================= # Cloud Storage - librechat.yaml の配信 # ================================================================= # GCS バケットに配置し、Cloud Run の GCS FUSE マウントで読み込む。 # librechat.yaml の詳細は後述の「librechat.yaml の設定」を参照。 resource "google_storage_bucket" "librechat_config" { project = var.project_id name = "${var.project_id}-librechat-config" location = var.region force_destroy = true uniform_bucket_level_access = true } resource "google_storage_bucket_object" "librechat_yaml" { name = "librechat.yaml" bucket = google_storage_bucket.librechat_config.name source = "${path.module}/../librechat.yaml" } # ================================================================= # IAM - サービスアカウントと権限 # ================================================================= resource "google_service_account" "librechat" { project = var.project_id account_id = "librechat-${var.environment}" display_name = "LibreChat Cloud Run SA (${var.environment})" } resource "google_project_iam_member" "librechat_firestore" { project = var.project_id role = "roles/datastore.user" member = "serviceAccount:${google_service_account.librechat.email}" } resource "google_project_iam_member" "librechat_secretmanager" { project = var.project_id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:${google_service_account.librechat.email}" } resource "google_project_iam_member" "librechat_vertexai" { project = var.project_id role = "roles/aiplatform.user" member = "serviceAccount:${google_service_account.librechat.email}" } resource "google_project_iam_member" "librechat_artifact_reader" { project = var.project_id role = "roles/artifactregistry.reader" member = "serviceAccount:${google_service_account.librechat.email}" } resource "google_storage_bucket_iam_member" "librechat_config_reader" { bucket = google_storage_bucket.librechat_config.name role = "roles/storage.objectViewer" member = "serviceAccount:${google_service_account.librechat.email}" } # Cloud Run サービスエージェントにも Secret Manager へのアクセス権が必要。 # コンテナ起動時にシークレットを注入するのはサービスエージェントであり、 # アプリケーションの SA とは異なる。 resource "google_project_iam_member" "cloudrun_agent_secretmanager" { project = var.project_id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:service-${data.google_project.current.number}@serverless-robot-prod.iam.gserviceaccount.com" } # ================================================================= # Cloud Run - LibreChat # ================================================================= resource "google_cloud_run_v2_service" "librechat" { project = var.project_id name = "librechat-${var.environment}" location = var.region ingress = "INGRESS_TRAFFIC_ALL" deletion_protection = false template { service_account = google_service_account.librechat.email scaling { min_instance_count = 0 max_instance_count = 2 } containers { # Artifact Registry のリモートリポジトリ経由で ghcr.io のイメージを pull image = "${var.region}-docker.pkg.dev/${var.project_id}/ghcr-remote/danny-avila/librechat:v0.8.3" name = "librechat" # PORT は Cloud Run の予約済み環境変数のため設定不可。 # container_port を指定すると Cloud Run が自動的に PORT=3080 を設定する。 ports { container_port = 3080 } # GCS FUSE: バケットをディレクトリとしてマウント volume_mounts { name = "librechat-config" mount_path = "/app/config" } resources { limits = { cpu = var.librechat_cpu memory = var.librechat_memory } cpu_idle = true startup_cpu_boost = true } # ---------- 環境変数 ---------- env { name = "HOST" value = "0.0.0.0" } env { name = "NODE_ENV" value = "production" } env { name = "NO_INDEX" value = "true" } env { name = "CONFIG_PATH" value = "/app/config/librechat.yaml" } # Firestore MongoDB 互換接続 # 接続文字列の詳細は後述の「Firestore MongoDB 互換モードの解説」を参照 env { name = "MONGO_URI" value = "mongodb://${google_firestore_database.librechat.uid}.${var.firestore_location}.firestore.goog:443/${var.firestore_database_name}?loadBalanced=true&tls=true&retryWrites=false&authMechanism=MONGODB-OIDC&authMechanismProperties=ENVIRONMENT:gcp,TOKEN_RESOURCE:FIRESTORE" } # Firestore 互換との接続安定化: # autoIndex を無効にし、接続直後のインデックス一斉作成を抑制 env { name = "MONGO_AUTO_INDEX" value = "false" } env { name = "MONGO_AUTO_CREATE" value = "false" } # mongoMeili 等の非同期エラーが uncaughtException になりクラッシュするのを防止 env { name = "CONTINUE_ON_UNCAUGHT_EXCEPTION" value = "true" } # Meilisearch 無効 (mongoMeili プラグインがクラッシュの原因のため) env { name = "SEARCH" value = "false" } # Vertex AI (Gemini) - SA キーで認証 env { name = "GOOGLE_SERVICE_KEY_FILE" value_source { secret_key_ref { secret = google_secret_manager_secret.vertex_sa_key.secret_id version = "latest" } } } # global を指定。リージョン指定だと一部モデルが利用不可 env { name = "GOOGLE_LOC" value = "global" } # Secret Manager からの参照 env { name = "JWT_SECRET" value_source { secret_key_ref { secret = google_secret_manager_secret.secrets["jwt-secret"].secret_id version = "latest" } } } env { name = "JWT_REFRESH_SECRET" value_source { secret_key_ref { secret = google_secret_manager_secret.secrets["jwt-refresh-secret"].secret_id version = "latest" } } } env { name = "CREDS_KEY" value_source { secret_key_ref { secret = google_secret_manager_secret.secrets["creds-key"].secret_id version = "latest" } } } env { name = "CREDS_IV" value_source { secret_key_ref { secret = google_secret_manager_secret.secrets["creds-iv"].secret_id version = "latest" } } } env { name = "ALLOW_REGISTRATION" value = "true" } env { name = "SESSION_EXPIRY" value = "900000" # 15 min } env { name = "REFRESH_TOKEN_EXPIRY" value = "604800000" # 7 days } startup_probe { http_get { path = "/health" } initial_delay_seconds = 10 period_seconds = 10 timeout_seconds = 5 failure_threshold = 30 } liveness_probe { http_get { path = "/health" } period_seconds = 30 } } volumes { name = "librechat-config" gcs { bucket = google_storage_bucket.librechat_config.name read_only = true } } } depends_on = [ google_project_service.run, google_project_service.aiplatform, google_secret_manager_secret.secrets, google_secret_manager_secret_version.vertex_sa_key, google_storage_bucket_object.librechat_yaml, ] } resource "google_project_service" "run" { project = var.project_id service = "run.googleapis.com" disable_on_destroy = false } resource "google_project_service" "aiplatform" { project = var.project_id service = "aiplatform.googleapis.com" disable_on_destroy = false } terraform.tfvars の設定例 project_id = "your-gcp-project-id" region = "asia-northeast1" environment = "dev" firestore_database_name = "librechat" firestore_location = "asia-northeast1" Firestore MongoDB 互換モードの解説 Firestore MongoDB 互換モードでは、通常の MongoDB と同じプロトコルで接続しますが、接続文字列のパラメータにいくつか注意点があります。 mongodb://..firestore.goog:443/ ?loadBalanced=true &tls=true &retryWrites=false &authMechanism=MONGODB-OIDC &authMechanismProperties=ENVIRONMENT:gcp,TOKEN_RESOURCE:FIRESTORE ホスト名の ``は Firestore データベースごとに自動生成される一意の識別子です。Terraform では `google_firestore_database` の `uid` 属性から動的に組み立てています。 各パラメータの意味は以下の通りです。 パラメータ 値 理由 loadBalanced true 必須。Firestore はマネージドサービスのため、ユーザーからのリクエストは Google Cloud 内部のロードバランサーを経由してバックエンドに分散されます。一方、MongoDB ドライバーは通常、接続先に対して `hello` コマンドを送信し、レプリカセットやスタンドアロン等のサーバー構成を自動検出(トポロジー検出)します。ロードバランサーの背後ではこの検出が正しく動作しないため、`loadBalanced=true` を指定してトポロジー検出をスキップさせます。 retryWrites false 必須。MongoDB ドライバーはデフォルトで書き込み失敗時に自動リトライしますが、Firestore 互換はこの機能に対応していません。デフォルト(true)のままだとエラーになります。 authMechanism MONGODB-OIDC Cloud Run のサービスアカウントの ID トークンで自動認証されます。DB のユーザー名・パスワード管理は不要です。 tls true Firestore への接続には TLS が必須です。 接続の安定化 LibreChat は内部で Mongoose(MongoDB の ODM ライブラリ)を使用しています。Mongoose には DB 接続直後にスキーマ定義に基づいてインデックスを自動作成する機能がありますが、Firestore MongoDB 互換はレート制限が厳しく、大量のインデックス作成要求で接続が切断されます。さらに Meilisearch 連携プラグイン(mongoMeili)が非同期エラーを投げ、Node.js の未捕捉例外としてプロセスが終了してしまいます。 Terraform コード内では以下の環境変数でこれらを抑制しています。 MONGO_AUTO_INDEX=false : インデックス自動作成を無効化 MONGO_AUTO_CREATE=false : コレクション自動作成を無効化 CONTINUE_ON_UNCAUGHT_EXCEPTION=true : 未捕捉例外でプロセスを終了させない SEARCH=false : Meilisearch プラグインを無効化 5. 動作確認と制約 機能ごとの動作可否 機能 動作 備考 ユーザー登録・ログイン OK Gemini (Vertex AI) チャット OK マルチユーザー会話分離 OK ユーザー間で会話は見えない 会話共有リンク OK MCP Server 利用 OK エージェント作成・利用 OK ただしリロードで一覧から消える(後述) 管理者ダッシュボード NG  `$bitsAllSet` 非サポート エージェント一覧(リロード後) NG 権限チェック失敗で空になる プロンプト一覧(リロード後) NG 同上 `$bitsAllSet` 問題 LibreChat v0.8では ACL(Access Control List)ベースのビットマスク権限を採用しています。`aclentries` コレクションに以下のようなドキュメントが保存されます。 { principalType: "user", principalId: "user-id-xxx", resourceType: "agent", resourceId: "agent-id-xxx", permBits: 7 // 0b111 = read(1) + use(2) + edit(4) } エージェントやプロンプトの一覧を取得する際、MongoDB の `$bitsAllSet` 演算子でビットマスクを照合します。 // 「このユーザーが閲覧権限を持つエージェントの一覧」 AclEntry.find({ principalId: userId, resourceType: "agent", permBits: { $bitsAllSet: 1 } // 閲覧ビットが立っているか }).distinct('resourceId') Firestore MongoDB 互換は、ビット演算クエリ演算子を公式にサポートしていません。 Supported features ドキュメント の Bitwise operators セクションで、以下が全て「No」と明記されています。 演算子 サポート $bitsAllSet No $bitsAnySet No $bitsAllClear No $bitsAnyClear No この結果、以下の流れでエージェント一覧が表示されなくなります。 ユーザーがエージェント一覧をリクエスト LibreChat の PermissionService が `$bitsAllSet` クエリを実行 Firestore が `unknown operator $bitsAllSet` エラーを返す PermissionService がエラーをキャッチし、アクセス可能リソース = 空配列で返す UI にはエージェントが0件として表示される データ自体は Firestore に保存されています。リロード時の権限チェッククエリが失敗するため、作成したエージェントやプロンプトが「消えた」ように見えます。 なお、チャット(会話)の一覧取得は PermissionService を経由せず、単純に `userId` でフィルタするだけなので正常に動作します。 6. まとめ 問題なく動作する機能 以下の機能は Firestore MongoDB 互換 + Cloud Run の構成で問題なく利用できます。 ユーザー登録・ログイン チャット マルチユーザーの会話分離 会話共有リンク MCP Server 連携 これらの機能のみを使う場合、Firestore MongoDB 互換 + Cloud Run の構成は十分に実用的です。MongoDB を別途構築・運用する必要がなく、フルマネージドかつサーバーレスで LibreChat を動かせます。 動作しない機能 ただし、以下の機能は Firestore MongoDB 互換の `$bitsAllSet` 演算子未サポートにより動作しません。 管理画面(`/d/admin`): 何も表示されない エージェント一覧 : 作成できるがリロードすると消える プロンプト一覧 : 同上 エージェントやプロンプトの活用、管理者による権限制御が必要な場合は、この構成では対応できません。その場合は Cloud Run + MongoDB Atlas の構成や、GCE / GKE 上に Docker で MongoDB を構築する構成を検討してください。 今後の展望 Firestore MongoDB 互換は現在も機能拡充が進んでおり、 Supported features のページは定期的に更新されています。ビット演算クエリ演算子(`$bitsAllSet` 等)がサポートされた際には、追加検証を実施し別途記事を更新したいと思います。 7. 参考リンク LibreChat GitHub Firestore MongoDB 互換ドキュメント Firestore MongoDB 互換 Supported features Firestore MongoDB 互換 動作の違い

  • Langfuse v4はなにが変わる? v3との違いをざっくり解説

    はじめに Langfuse ライフ、いかがお過ごしですか。 近いうちに Langfuse が v3 から v4 にアップデートされることを、すでにご存じでしょうか。現在、Langfuse の Web UI の左下に、v4 向けプレビュー体験のトグル「Fast (Preview)」(以前は「v4 Beta」)が表示されています。これをオンにすると、「Langfuseが速くなる」という旨が書かれた確認ダイアログが出ると思います。 Fast(Preview) トグル 「Langfuse v4 になったら速度が上がるだけ」と認識している方も多いのではないでしょうか。本記事では、Langfuse v4 の更新がどのような考え方で進められているか、および変更の概要を思想レベルでまとめます。 想定読者:Langfuse v3 の画面や 旧バージョンSDK を触ったことがあるが、v4 の概要をまだ掴めていない方。 注 本記事で触れる v4 / Fast (Preview) の話は、現時点では主に Langfuse Cloud のプレビュー体験を念頭に置いています。UI で新しい体験に切り替えられますが、すべての画面がすでに新データモデルへ移行済みというわけではありません。OSS / セルフホスト向けの移行パスは公式が作業中と明示しており、今後正式な案内が予定されています。 また、新しい UI でデータをほぼリアルタイムに近いタイミングで見るには、後述のPython SDK v4、JavaScript / TypeScript SDK v5、または OpenTelemetry で x-langfuse-ingestion-version: 4 を付けることが推奨されます。それ以外の場合、新 UI では最大おおよそ 10 分ほど表示が遅れることがあります。 Langfuse v4 に向けた、根本からの考え方 ここから先で押さえておきたいのは、v4 は速くなるだけではない、という点です。どの単位をデータの主語にするか、どう保存し・どう問い合わせるかという前提を、いまのLLMの使われ方に合わせて揃え直しています。 エージェントや複数ステップのパイプラインが一般化するにつれ、1 回のユーザー操作に紐づく Observation の数は桁違いに増えます。Trace の中に処理がたくさん入る世界では、「Trace を開いてから中を探す」だけでは運用もクエリも窮屈になりやすい、というのが背景にあります。 クラウド上の製品体験は公式ドキュメント Langfuse Cloud: Fast Preview (v4) で説明されています。クライアント側では、Observation 中心にしたデータモデルに合わせて Python SDK v4 と JavaScript / TypeScript SDK v5 が同じ方向に更新されています。 Observations-first: まず「処理単位」からたどれるようにする v3 までの体験に慣れていると、一覧や探索の入口が Trace に寄りがちです。中身を開いて初めて、LLM 呼び出しやツール実行といったいま調べたい処理にたどり着く、という読み方になります。 v4 の Observations-first は、この順序をひっくり返すイメージです。「どの Trace のどこかが遅いか」ではなく、「どの LLM 呼び出しが遅いか」「どのツールが失敗しているか」のように、日常の問いの起点を Observation に置きます。エージェント的なアプリでは 1 Trace にかなりの数の処理がぶら下がり得るので、最初から処理単位で絞り込めることが重要、という整理です。 評価では、Trace 全体だけでなく Observation ごとに LLM-as-a-judge などを回せるようになる流れも、v4 とセットで押し上げられている話題のひとつです。Observation ごとの評価については弊社ブログ Langfuse の Observation レベル評価:「どのステップが悪いのか」をスコアで特定できるようになった をご覧ください。 画面の使い方や保存データの詳細は、公式ドキュメント Explore Observations in v4 をご覧ください。 データの置き方を変えて、画面や API の表示を速くする 結論から言うと、v4 では一覧・ダッシュボード・公開 API などが速くなる方向です。「すでに速い」という前提ではなく、これから大きなデータ量でも待ち時間を抑えやすくする、という認識が正しいです。 Langfuse 公式ブログの Simplifying Langfuse for Scale (2026-03-10) では、Observation を中心にしたテーブル構成に寄せることで、速度改善しているという説明が丁寧にされています。大規模プロジェクトでは、ダッシュボードの読み込みが桁違いに改善した、といった記述もあります。 具体的には、Trace 用と Observation 用だった 2 つのテーブルを、1 つにまとめています。テーブルを分けたまま結び付ける「join」負担を減らします。 Langfuse スキーマ定義の改善(出典: Simplifying Langfuse for Scale) テーブルをまとめることで、代表的なクエリが数秒〜数分かかっていたものが、1 秒前後まで短くなるイメージが示されています。 Langfuse v4 高速化のイメージ(出典: Simplifying Langfuse for Scale) この「速さ」は、単一画面の実装を直しただけ、というより次の二つがそろった結果として説明しやすいです。第一に、Trace と Observation を毎回結びつけ直さずに済む仕方を土台にすること。第二に、その土台のうえで一覧・ダッシュボード・公開 API・評価などが同じ前提で動くこと。こうすると Observation に直接問いを投げる経路が増え、待ち時間を抑えやすくなる、という読み方です。 Langfuse API v2 がデフォルト API に Langfuse の公開 API には、いま主に二つの世代があります。古い形が v1、新しい形が v2 とざっくり理解できます。v4 の流れでは、Observation・Scoreまわりでこの v2 がデフォルトの API になり、SDK からも v2 を普通に呼ぶ名前がメインに揃います。v1 に相当する呼び出しは legacy 側にまとめられます。名前やパスを変えたことが目的ではなく、「いま推奨されるのは v2」という分かりやすい入口に寄せている、というイメージです。 例えば、 Observations API v2 は、返す列の絞り込みやカーソル方式のページ送りなど、v1 より大量データでも負荷と待ち時間を抑えやすい問い合わせに作り替えられています。 SDK も Langfuse v4 に合わせる Langfuse サーバー側、つまり Langfuse の本体プログラムが Observation 中心のデータモデルに寄せられた以上、アプリ側から送る SDK 側も Trace を都度まとめて更新するだけの書き方では、保存されるデータとしっくりきません。そのため Python v4 と JS/TS v5 は、言語が違ってもLangfuse v4の方針に似た仕様に揃っています。 Trace共通属性は「Trace だけ」ではなく「子にも載せる」 user_id / session_id / metadata / tags などは、Trace 行にだけあるのではなく、配下のObservation にも伝わるのが前提です。Python では propagate_attributes() のようなコンテキストマネージャ、JS/TS では propagateAttributes() のようなコールバックでスコープを切る メソッド として表現されます。言語が違っても、「この範囲で作られる Observation に、Trace共通属性を載せる」という意味は共通です。 Trace への一括更新から、役割を分ける 以前の Trace をまとめて更新するメソッド(Python の update_current_trace() 、JS/TS の updateActiveTrace() など)は、いろいろな種類の情報が一塊になっていました。v4 / v5 ではこれを分け、Trace共通属性は propagate、Trace 全体の入出力や公開状態は別のメソッドにします。 新しいコードでの推奨は、「Trace の入出力に相当する情報」を、親を持たないルートの Observation の入出力として載せることです。通常、Trace にはそうしたルートがひとつあり、そこに載せた入出力が、画面や連携でいう Trace の入出力として扱われるイメージに寄せます。Trace 専用にまとめて入出力を書き込む API は、互換のため残っていますが、主に従来の Trace 単位の LLM-as-a-judge 向けで、新規はルート Observation を使う説明になっています。 Spanの作成を「Observation」から開始にする 今まではSpan と Generation という名前で別々のメソッドで増やす方針でした。これからは、Observation という共通の名前で開始し、observation typeを種別を引数で表す方向です(Python の start_observation / start_as_current_observation 、JS/TS の startObservation / startActiveObservation など)。クライアント側でも主語が Observation だと分かる形にしている、という理解で十分です。 OpenTelemetry 経由の span OpenTelemetry 経由のspanについて、以前はほぼすべての span を送る前提に近い挙動があり、HTTP や DB などインフラ寄りの span が多いとトレースがノイズだらけになりがちでした。Python v4 / JS/TS v5 では、デフォルトで LLM / GenAI に近い span を中心に送るフィルタが入り、まずは見たいものが見える方向の初期設定になっています。OpenTelemetry経由の spanの制御については、別稿の A2A × ADKの"観測粒度"を設計する - Langfuse & Cloud Trace でトレース構造を可視化 - でも触れています。 SDK の破壊的変更の一覧や置き換え手順は、次を参照してください。 Python v3 → v4 JS/TS v4 → v5 おわりに Langfuse v4 は、速度が上がるだけの話ではありません。Observation を中心にデータの持ち方と照会の前提を組み替え、そのうえで UI・公開 API・評価が同じ土台を共有する、という横断的な更新です。Trace の中に処理が密集する使われ方に合わせ、一覧や分析が Observation から直接たどれるようになる、という流れとセットで理解すると全体像がつかみやすいです。Langfuse v4 に合わせ、SDK も Observation 中心モデルにクライアントを合わせるバージョンアップがありました。 さらに詳しく知りたい方は、次のWebページを参照してください。 Langfuse Cloud: Fast Preview (v4) Simplifying Langfuse for Scale(ブログ) Python v3 → v4(SDK) JS/TS v4 → v5(SDK) Observation 中心のデータモデル

  • 【後編】ソースコードから読み解くLangfuse検索の挙動と制約

    この記事のポイント 前編では、Langfuse v3.158.0の "fulltext search" が実装上は部分一致検索であること、そして3層の検索アーキテクチャの全体像を解説しました。 後編では、PR #12578のソースコードを詳しく読み、以下の2点を明らかにします。 プロンプト編集画面のTextモードとChatモードで 検索の挙動が異なる こと、特にChatモードではカウンターとハイライトが食い違うケースがあること サーバーサイド検索(ClickHouse / PostgreSQL)を含めた、日本語利用時の 具体的な制約(Limitation) → 前編はこちら:「Langfuse v3.158.0の"fulltext search"を読み解く — その実態は部分一致検索だった」 ( リンク ) PR #12578のコードリーディング 検索のエントリポイント:MessageSearchProvider 検索機能のアーキテクチャは、React Contextベースの MessageSearchProvider を中心に構成されています。 Playground画面( web/src/features/playground/page/index.tsx )では、全ウィンドウを MessageSearchProvider で包み、各ウィンドウの pageId を渡しています。Prompt Management側( PromptChatMessages.tsx )も同じ MessageSearchProvider を使いますが、ページは1つだけです。つまり共通コンポーネントが両方の画面で再利用されています。 Cmd+F (Mac)/ Ctrl+F (Windows)のキーボードショートカットは、 context.tsx の useEffect でキャプチャされ、 controller.openSearch() を呼び出します。このとき captureRootRef でスコープを限定しているため、ページ全体のブラウザ検索を奪わず、メッセージ編集エリア内だけで機能します。 検索ロジックの核心:controller.tsのbuildMatches 検索の実体は web/src/components/ChatMessages/messageSearch/controller.ts にあります。 buildMatches 関数が全マッチを計算する部分です。 function buildMatches(state: MessageSearchState) {   const searchQuery = getCommittedQuery(state);   if (!searchQuery) return [];   const lowerQuery = searchQuery.toLocaleLowerCase();   const allMatches: MessageSearchMatch[] = [];   for (const [pageIndex, pageId] of state.pageIds.entries()) {     const pageMessages = state.pageMessagesById[pageId];     if (!pageMessages) continue;     for (const [messageIndex, message] of pageMessages.entries()) {       const text = getMessageSearchText(message);       if (!text) continue;       const lowerText = text.toLocaleLowerCase();       let from = lowerText.indexOf(lowerQuery);       while (from !== -1) {         // ... マッチオブジェクトを構築してallMatchesに追加         from = lowerText.indexOf(lowerQuery, from + Math.max(1, lowerQuery.length));       }     }   }   return allMatches; } 非常にシンプルです。 toLocaleLowerCase() でケースフォールディングした後、 indexOf() で部分文字列を探しています。150msのデバウンス(入力が一定時間途切れるまで処理の実行を遅延させる仕組み)付きで、タイピング中に検索が走りすぎないよう制御されています。 CodeMirrorとの連携と3つのマッチングシステム マッチ結果の表示は、controllerとCodeMirrorがそれぞれ独立に処理しています。さらにCodeMirror内部にも2つの仕組みがあり、合計 3つのマッチングシステム が同時に動作しています。 # システム 役割 正規化処理 大文字小文字 1 controller buildMatches マッチカウンター(「1 / 3」表示)とナビゲーション なし( toLocaleLowerCase + indexOf ) 区別しない 2 @codemirror/search 検索ハイライト(黄色系) NFKD + toLowerCase 区別しない 3 highlightSelectionMatches 選択テキストの類似箇所ハイライト(青色系) NFKDのみ 区別する これらの正規化処理の違いが、後述するTextモード/Chatモードでの挙動差の原因になっています。 controllerの syncActiveMatchTarget() はアクティブなマッチに対応するメッセージ行までスクロールし、 selectCodeMirrorRange() でCodeMirrorのselection(カーソル選択範囲)を設定します。 applyCodeMirrorSearchQuery() は各エディタインスタンスに対して @codemirror/search の setSearchQuery エフェクトを発行します。ここで literal: true が設定されており、検索文字列中の . や * がメタ文字として解釈されず、入力そのままの文字列として扱われます。 日本語での検索挙動:コードから読み解く プロンプト編集画面には2つの検索がある プロンプト編集画面にはTextモードとChatモードがあり、それぞれ 異なる検索メカニズム が動いています。 Textモード : PromptLinkingEditor → CodeMirrorEditor がそのまま使われ、 enableSearchKeymap はデフォルトの true です。 Cmd+F を押すとCodeMirror組み込みの検索パネル(エディタ下端に表示)が開きます。これはPR #12578の MessageSearchToolbar とは 別の検索UI です Chatモード : PromptChatMessages が MessageSearchProvider でラップされ、各メッセージの CodeMirrorEditor には enableSearchKeymap={false} が設定されます。 Cmd+F を押すとPR #12578の MessageSearchToolbar (メッセージ一覧上部の検索バー)が開きます この2つは前述の3つのマッチングシステムのうち、どれが有効になるかが異なります。 Textモード:CodeMirror組み込み検索のNFKD正規化 Textモードでは、表中の「#2 @codemirror/search 」が有効になります。 @codemirror/search v6.6.0のソースコードを読むと、 SearchCursor のコンストラクタで以下の処理が行われています。 // 常に適用されるベース正規化 const basicNormalize = x => x.normalize("NFKD"); // SearchCursorのコンストラクタ内 this.normalize = normalize ? x => normalize(basicNormalize(x)) : basicNormalize; caseSensitive: false の場合、最終的な正規化関数は x => x.normalize("NFKD").toLowerCase() になります。 NFKD(Normalization Form Compatibility Decomposition) はUnicodeの互換分解を行う正規化形式で、全角英数字を半角英数字に分解します。たとえば L (U+FF2C、全角)は L (U+004C、半角)に分解されます。 つまりTextモードでは、全角/半角が相互にヒットします。 クエリ「Langfuse」→ NFKD → Langfuse → toLowerCase → langfuse ドキュメント「Langfuse」→ NFKD → Langfuse → toLowerCase → langfuse → マッチする 実際にTextモードのプロンプト編集画面で確認したところ、全角英数字で検索しても半角英数字の文字列にヒットすることが確認できました。 Chatモード:カウンターとハイライトの食い違い Chatモード(PR #12578の MessageSearchToolbar )では、表中の「#1 controller」と「#3 highlightSelectionMatches 」が動作し、「#2 @codemirror/search 」は無効化されています( panel が null のため Decoration.none を返す)。 この組み合わせにより、以下のような挙動が確認されました。全角 Langfuse で検索した結果です。 ドキュメント中のテキスト マッチカウンター(#1) ハイライト(#3) Langfuse (全角) ヒットする 青色(選択色) Langfuse (半角・先頭大文字) ヒットしない 青色(選択色) langfuse (半角・全小文字) ヒットしない なし LANGFUSE (半角・全大文字) ヒットしない なし 実際にChat編集画面で検索を実行した画面 マッチカウンターは「1 / 1」( Langfuse のみ)。しかし Langfuse にも青色のハイライトが表示されています。 この食い違いの原因は、#1と#3の正規化処理の違いです。 #1 controller : toLocaleLowerCase() + indexOf() 。NFKD正規化なし。全角 Langfuse と半角 Langfuse はコードポイントが異なるためマッチしない #3 highlightSelectionMatches :controllerが Langfuse の位置にselectionを設定 → SearchCursor がNFKD正規化( toLowerCaseなし )で同じ文字列を探す → Langfuse はNFKD後に Langfuse になるため、ドキュメント中の Langfuse とマッチ。ただし大文字小文字を区別するため langfuse や LANGFUSE にはマッチしない Chatモードのcontroller側の日本語特性 toLocaleLowerCase() はECMAScript仕様上、Unicode Case Foldingに基づいて大文字/小文字変換を行いますが、全角/半角の変換(全幅変換)は行いません。日本語のひらがな・カタカナ・漢字には大文字/小文字の区別がないため、この関数は実質的にパススルーになります。 indexOf() によるマッチングはUTF-16コードユニット単位の部分文字列比較なので、日本語の部分一致検索自体は問題なく動作します。「プロンプト管理」で検索すれば「Langfuseのプロンプト管理機能は〜」にヒットします。 ClickHouse側(Traces / Observations)の日本語対応 packages/shared/src/server/queries/clickhouse-sql/search.ts を読むと、サーバーサイドの検索は以下のSQLで実現されています。 input ILIKE '%クエリ文字列%' OR output ILIKE '%クエリ文字列%' ClickHouseの ILIKE は LIKE の大文字小文字非区分版で、内部的にはバイト列に対するパターンマッチとして動作します。ClickHouseのスキーマを確認すると、 input / output カラムは Nullable(String) CODEC(ZSTD(3)) で定義されており、 tokenbf_v1 や ngrambf_v1 などのフルテキストインデックスは設定されていません。 もし仮に tokenbf_v1 (トークンベースのbloom filter)が使われていた場合、デフォルトのトークナイザは空白・句読点で分割するため、日本語のように分かち書きしない言語では検索精度に影響が出る可能性があります。しかし、現状の ILIKE '%...%' によるパターンマッチであれば、たとえ日本語文字列でも部分一致検索として機能します。 トレードオフとして、先頭ワイルドカード付きの ILIKE はインデックスが効かず、該当カラムの全行スキャンが発生します。大量のtracesがあるプロジェクトでは(データが日本語でもそうでなくても)パフォーマンスへの影響があり得ることには注意が必要です。 PostgreSQL側(Prompt一覧)の日本語対応 web/src/features/prompts/server/routers/promptRouter.ts を見ると、プロンプト本文検索は以下のPrisma SQLで行われています。 p.prompt::text ILIKE '%クエリ文字列%' prompt カラムはJSON型で、 ::text でテキストにキャストした上で ILIKE を適用しています。PostgreSQLの ILIKE はロケール依存のケースフォールディングを行うため、日本語文字列に対しても部分一致検索が機能します。ClickHouseと同様、GINインデックスなどは設定されていないため全行スキャンとなりますが、Langfuseの使い方として「プロンプトを大量に登録する」ことはあまりmajorな使い方ではないと思われるため、この点が問題になる可能性はあまり高くないかもしれません。 まとめ:現行の制約(Limitation)一覧 Langfuseの検索を日本語で使う際に把握しておきたい制約を一覧にまとめます。 検索の基本特性 「文字列を探す検索」である :ステミング(語形変化の吸収)、同義語展開、関連度スコアリングといった、狭義の全文検索が提供する機能はない 形態素レベルの検索はできない :「走る」で「走った」「走り」はヒットしない ひらがな/カタカナの同一視はされない :「ぷろんぷと」で「プロンプト」はヒットしない TextモードとChatモードの差異 全角/半角の扱いが異なる :Textモード(CodeMirror組み込み検索)ではNFKD正規化により全角/半角が相互にヒットするが、Chatモード(PR #12578のcontroller)ではヒットしない Chatモードではカウンターとハイライトが食い違うケースがある :controllerのマッチカウンターと、CodeMirrorの highlightSelectionMatches で正規化処理が異なるため サーバーサイド検索 インデックスなしの全行スキャン :ClickHouse/PostgreSQLともに ILIKE パターンマッチでインデックスが効かないため、データ量が増えた場合のパフォーマンスは注視が必要 API経由の検索はまだ非対応 :GitHub Discussion #12373でPublic API経由の検索のリクエストが上がっており、近々対応される可能性はある 総括 今回のPR #12578は、Playgroundという「手元で試す」場面に対して、シンプルかつ確実に動く検索を提供しています。日本語ユーザーとしては、今のところ「普通に使えてありがたい」というのが正直な感想ですが、今回の記事中で指摘した不整合は気になるため、Langfuseコミュニティにfeedbackしておこうと思います(&feedbackが反映されたらまた記事として触れさせて頂くかもしれません!)。

  • 【前編】Langfuse v3.158.0の"fulltext search"を読み解く — その実態は部分一致検索だった

    はじめに Langfuse v3.158.0(2026年3月13日リリース)で、PlaygroundおよびPrompt Managementのチャットメッセージ編集画面にテキスト検索機能が追加されました。ChangelogやPRタイトルでは "fulltext search"(全文検索)と表現されています。 日本語環境でLangfuseを使っている身としては、「全文検索」と聞くと気になるのが日本語の対応状況です。そこで今回、ソースコードを読んで実装の中身を調べてみました。 この前編では、まず新機能の概要とLangfuseの検索アーキテクチャの全体像を整理し、今回Langfuseに実装された検索機能が、技術的にはどういう仕組みなのかを明らかにします。後編では、ソースコードをさらに深く読み、日本語で使った場合の具体的な挙動や制約について掘り下げます。 今回の新機能:Playground / Promptsのメッセージウィンドウ内検索 今回追加されたのは、 Cmd+F (Mac)/ Ctrl+F (Windows)でPlaygroundやプロンプト編集画面内に検索バーが表示され、複数のメッセージウィンドウにまたがってテキストを検索できる機能です(PR #12578 by @nimarb)。マッチ箇所はハイライトされ、前後のマッチへの移動も可能です。プロンプトの編集・比較作業中に、特定のフレーズや変数名を素早く見つけたい場面で役立ちます。 実際のプロンプト編集画面における検索の様子 Langfuseにはすでに2つの検索機能が存在していました。2025年5月に導入されたTraces / Observationsのinput/output検索(サーバーサイドでClickHouseに問い合わせる方式)と、2025年7月に導入されたPrompt Management一覧でのプロンプト名・本文検索(PostgreSQLに問い合わせる方式)です。今回のPR #12578は、これらに続く3つ目の検索機能として、クライアントサイド(ブラウザ内)で完結する形で追加されました。 "fulltext search"の中身を見てみよう PRタイトルやChangelogではこの機能を "fulltext search" と表現していますが、この用語は文脈によって指す範囲がかなり広いです。ElasticsearchやPostgreSQLの tsvector / tsquery のようにトークナイズと転置インデックスを伴う狭義の全文検索を指すこともあれば、単にテキスト本文を対象にした検索という広い意味で使われることもあります。 実際にソースコードを読んでみると、Langfuseの実装はJavaScriptの indexOf() やSQLの ILIKE '%...%' による 部分一致検索(substring search) でした。転置インデックスやトークナイザは使われていません。 この違いは、日本語での検索挙動を考える上で重要な意味を持ちます。英語のような言語では、本来はトークンベースの全文検索のほうがステミング(語形変化の吸収)や同義語展開などの豊かな検索体験を実現しやすいです。一方、日本語のように分かち書きしない言語ではトークナイズ自体が難しいという問題があります。Langfuseの実装は言語を理解する全文検索ではなく部分一致検索であるため、 英語でも高度な検索機能は提供されないものの、日本語でも無難に動く という状況になっています。 以降、技術的な区別を明確にするため、Langfuseの検索機能は実装に即して「部分一致検索」と表記します。 Langfuseの検索アーキテクチャは3層構造 前述のとおり、Langfuseの検索機能は3つのレイヤーに分かれています。それぞれの仕組みをもう少し詳しく見てみましょう。 レイヤー1:クライアントサイド検索(今回のPR #12578) Playground / Prompt Managementのメッセージ編集画面で動作します。完全にブラウザ内で完結し、サーバーへのリクエストは発生しません。 レイヤー2:ClickHouseによるTraces / Observations検索 2025年5月のLaunch Week 3で導入された、traces/observationsのinput/outputに対する検索です。v3で導入されたClickHouseバックエンドに対して ILIKE クエリが発行されます。 レイヤー3:PostgreSQLによるPrompt一覧検索 2025年7月に追加されたPrompt Management一覧のプロンプト本文検索です。こちらはPrisma経由でPostgreSQLに ILIKE が発行されます。 この3つは見た目上は同じ「検索バー」ですが、裏側の仕組みはそれぞれ異なります。一方で すべてのレイヤーが「部分一致検索」というアプローチで共通しています 。トークナイザやn-gramインデックスといった狭義の全文検索の仕組みは使われていません。 コラム:全文検索と部分一致検索、そして日本語 「全文検索(Full-Text Search)」は技術的に厳密な意味では、文書をトークン(単語や部分文字列)に分割し、転置インデックスを構築した上で高速にキーワードを引く検索手法を指します。ElasticsearchやPostgreSQLの tsvector / tsquery などが代表的な実装です。対して、SQLの LIKE '%keyword%' やJavaScriptの indexOf() は、テキストを先頭から順に走査する 部分一致検索 です。字面として完全一致しているものしか検索できず、語形変化や同義語の吸収は行われません。 この区別が特に重要になるのが日本語です。狭義の全文検索で必須となるトークナイズは、英語のようにスペースで単語が区切られる言語では単純ですが、日本語は分かち書きをしないため、形態素解析(MeCab等)やn-gramといった特別な処理が必要になります。これらにはそれぞれ辞書依存性や偽陽性(「京都」で「東京都」がヒットする、いわゆる「京都東京都問題」)といった課題があります。 部分一致検索はトークナイズを経由しないため、 「日本語がうまくトークンに分割できず検索自体が機能しない」という問題は発生しません 。ただし万能ではなく、偽陽性の問題はn-gramと同様に存在しますし、活用形の吸収や表記揺れの統合といった形態素レベルの正規化も行われません。 前編のまとめ:現行の検索をどう理解して使うか 現時点のLangfuseの検索は、「語を理解する検索」ではなく「文字列を探す検索」です。入力した文字列がそのまま含まれているかどうかを探す仕組みなので、日本語でも基本的な用途では問題なく使えます。 ただし、ソースコードを詳しく読むと、いくつかの気になる挙動も見えてきました。後編では、PR #12578のコードリーディングを行い、TextモードとChatモードの検索挙動の違い、3つのマッチングシステムの不整合、サーバーサイド検索の日本語対応について掘り下げます。 → 後編はこちら:「ソースコードから読み解くLangfuse検索の挙動と制約」 ( リンク )

  • Langfuse × GCS プライベートバケットで非公開画像をトレース表示する

    こんにちは。ガオ株式会社の黒澤です。この記事では、Langfuseでトレースに非公開な画像を表示する場合に、Google Cloud Storage(以下、GCS)を用いた場合のアーキテクチャパターンについて、実装を踏まえてご紹介します。 執筆時点の情報(2026年3月) 本記事は Langfuse v3.157.0 をセルフホストした環境での検証をもとにしています。将来のバージョンでよりシンプルな方法が提供される可能性があります。 想定読者 Langfuse を GCP 上でセルフホスト している 画像を入力とする LLM(Gemini、GPT-4o など)を使っており、 入力画像をトレースで確認 したい 画像は GCS のプライベートバケット に保存している Langfuse SaaS 版をお使いの方へ SaaS 版でも LangfuseMedia(Langfuse 管理ストレージ)や External Media URL は利用できます。ただし `LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT` などのサーバー側環境変数を変更できないため、「自前の GCS バケットを LangfuseMedia のアップロード先に指定する」構成はセルフホスティングが前提です。 目的 画像を入力とする LLM を本番で使うとき、 入力画像がトレースで確認できるか は LLMOps の基本です。モデルの挙動をデバッグするにも、品質評価をするにも、「そのとき何の画像を渡したか」を確認できることが重要です。 しかし GCS のプライベートバケットに保存した画像を Langfuse で表示しようとすると、いくつかの注意点があります。本記事では、実際の検証で遭遇した注意点と、要件に応じた解決策を整理します。 方式 A:External Media URL(自前でアップロード済みの場合) アプリ側で GCS に画像をアップロードし、その URL を Langfuse に渡します。 with langfuse.start_as_current_observation( as_type="generation", name="analyze-image", input={"messages": [{"role": "user", "content": [ {"type": "text", "text": "この画像を分析してください"}, {"type": "image_url", "image_url": {"url": "https://...画像の URL..."}}, ]}]}, model="gemini-2.5-pro", ) as gen: result = call_vision_model(...) gen.update(output=result) Langfuse はこの URL をそのままブラウザに渡します。 自動インライン表示されず「Load Image」ボタンが表示されます。 クリックすると別タブで画像が開きます。 ▼Langfuse UI に「Load Image」ボタンが表示される なぜ自動表示されないのか? 外部の信頼できない URL を自動で読み込むセキュリティリスクを避けるため、意図的にこの挙動になっています( langfuse/langfuse#5030 )。自動レンダリングを設定可能にする Feature Request( #5142 )はありますが、現時点では未実装です。 注意点:ブラウザ認証なしの URL では 403 になる storage.googleapis.comを渡すと、「Load Image」をクリックしても 403 になります。 ① UI に「Load Image」ボタンが表示される ② クリック → ブラウザが storage.googleapis.com に直接 GET ③ 403 "Anonymous caller does not have storage.objects.get access" ▼ 403 エラー:Anonymous caller does not have storage.objects.get access Google アカウントでログイン済みでも、storage.googleapis.com はブラウザのログインセッションを使いません。実は GCS のプライベートオブジェクトには認証の挙動が異なる 2 つの URL があります。 ブラウザ認証なし ブラウザ認証あり ドメイン storage.googleapis.com storage.cloud.google.com 認証 なし(IAM で許可されていないと 403) ブラウザの Google ログインセッション プライベートバケット ❌ 403 になる ✅ IAM 権限があれば表示される 解決策:ブラウザ認証ありの URL を使う storage.googleapis.com の代わりに storage.cloud.google.com を使います。 # ❌ これは 403 になる # url = "https://storage.googleapis.com/your-bucket/path/to/image.png" # ✅ これなら表示される url = "https://storage.cloud.google.com/your-bucket/path/to/image.png" 実装例: BUCKET_NAME = "your-gcs-bucket" def gcs_uri_to_authenticated_url(gcs_uri: str) -> str: """gs://bucket/key → https://storage.cloud.google.com/bucket/key""" path = gcs_uri.removeprefix("gs://") return f"https:// storage.cloud.google.com /{path}" authenticated_url = gcs_uri_to_authenticated_url(f"gs://{BUCKET_NAME}/uploads/image.png") with langfuse.start_as_current_observation( as_type="generation", name="analyze-image", input={"messages": [{"role": "user", "content": [ {"type": "text", "text": "この画像を分析してください"}, {"type": "image_url", "image_url": {"url": authenticated_url}}, ]}]}, model="gemini-2.5-pro", ) as gen: gen.update(output={"result": "..."}) 「Load Image」をクリックすると、ブラウザが Google アカウントのログインセッションを使って GCS にアクセスし、別タブで画像が表示されます。 ▼ 「Load Image」クリック後、別タブに画像が表示された 条件 Langfuse ユーザーが Google アカウントでブラウザにログインしていること そのアカウントに GCS バケットの `storage.objectViewer`(または同等の権限) が付与されていること 社内チームで Langfuse を使っている場合、チームメンバーは通常 GCP プロジェクトへのアクセス権を持っているため、この条件を満たしているケースが多いです。 制約 「Load Image」ボタンを 1 クリックする必要があります (自動インライン表示ではない) URL にバケット名やオブジェクトパスが含まれるため、 URL 自体を隠したいケースには不向き です GCS の IAM をユーザーに付与できない場合(外部パートナーなど)は後述のプロキシ構成を検討してください 方式 B:LangfuseMedia(Langfuse にアップロードを任せる場合) `LangfuseMedia` を使うと、SDK が自動でアップロードし、Langfuse UI では 自動インライン表示 されます。 from langfuse.media import LangfuseMedia with open("image.png", "rb") as f: media = LangfuseMedia(content_bytes=f.read(), content_type="image/png") with langfuse.start_as_current_observation( as_type="generation", name="analyze-image", input={"messages": [{"role": "user", "content": [ {"type": "text", "text": "この画像を分析してください"}, {"type": "image_url", "image_url": {"url": media}}, ]}]}, model="gemini-2.5-pro", ) as gen: result = call_vision_model(...) gen.update(output=result) ▼ LangfuseMedia によるインライン表示 設定 Langfuse サーバー(docker-compose)に GCS の設定を追加します。 # docker-compose.yml environment: LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: your-gcs-bucket LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: https://storage.googleapis.com # GCS の S3 互換エンドポイント LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true" AWS_REQUEST_CHECKSUM_CALCULATION: when_required `LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT` について AWS SDK はデフォルトで AWS S3 に接続します。GCS を使う場合は `https://storage.googleapis.com` を明示的に指定する必要があります。 `AWS_REQUEST_CHECKSUM_CALCULATION` について Langfuse の AWS SDK v3 はデフォルトで `x-amz-checksum-sha256` ヘッダーを付与しますが、GCS の S3 互換 API はこれを認識せず 400 エラーになります。`when_required` に設定することで回避できます。 注意点:署名付き URL がブラウザに露出する この構成では、Langfuse サーバーが 署名付き URL(presigned URL) を生成してブラウザに渡します。署名付き URL は 有効期限内であれば認証なしでアクセスできます 。 署名付き URL で要件を満たせるかどうかは、扱うデータの性質や組織のポリシーといったセキュリティ要件によります。URL 露出が NG な場合は後述のプロキシ構成を検討してください。 注意点:データ量に応じてコストが増加する LangfuseMedia では SDK が画像を GCS に PUT するため、画像の枚数やサイズに応じて GCS のストレージ費用とオペレーション費用が発生します。方式 A(認証済み URL)は既に GCS にある画像を参照するだけなので、追加のストレージ費用はかかりません(GETオペレーション費用は発生しますが、10,000リクエストあたり数円程度です)。大量の画像を扱う場合はコストを考慮してください。 URL 露出 NG / IAM を渡せない場合:Cloud Run プロキシ構成 以下のいずれかに該当する場合は、Cloud Run プロキシを経由させます。方式 A・B どちらにも対応できます。 有効期限付きであっても URL が外部に漏れてほしくない Langfuse ユーザーに GCS の IAM 権限を付与できない(外部パートナー、委託先など) 画像へのアクセスを特定の IP アドレスに制限したい アーキテクチャ ブラウザ → Cloud LB → Cloud Armor(IP制限) → Cloud Run プロキシ → GCS ▼ アーキテクチャ図 ポイント: GCS の URL はブラウザに一切渡らない Cloud ArmorでIP制限を行う 補足:IP制限ではなくIAMで制限を行いたい場合は、Cloud RunのIAM制限を行う。この場合は、Cloud LBおよびCloud Armorは不要。また、IAM制限とIP制限との併用も可能(Cloud RunのIAM制限は本記事では解説対象外です)。 プロキシ(Cloud Run) ※ソースコード他は長いため折りたたんでいます ソースコード # main.py import os from fastapi import FastAPI, HTTPException, Request from fastapi.responses import Response from google.cloud import storage app = FastAPI() client = storage.Client() ALLOWED_BUCKETS = set(os.environ.get("ALLOWED_BUCKETS", "").split(",")) ALLOWED_PREFIX = os.environ.get("ALLOWED_PREFIX", "media/") MAX_UPLOAD_SIZE = int(os.environ.get("MAX_UPLOAD_SIZE", 10 * 1024 * 1024)) # デフォルト 10MB def _validate(bucket_name: str, object_key: str): if bucket_name not in ALLOWED_BUCKETS: raise HTTPException(status_code=403, detail="Bucket not allowed") if not object_key.startswith(ALLOWED_PREFIX): raise HTTPException(status_code=403, detail="Path not allowed") @app.get("/{bucket_name}/{object_key:path}") def get_object(bucket_name: str, object_key: str): """ブラウザからの画像取得リクエストを GCS に転送する""" _validate(bucket_name, object_key) bucket = client.bucket(bucket_name) blob = bucket.blob(object_key) if not blob.exists(): raise HTTPException(status_code=404, detail="Not found") content = blob.download_as_bytes() return Response(content=content, media_type=blob.content_type or "application/octet-stream") @app.put("/{bucket_name}/{object_key:path}") async def put_object(bucket_name: str, object_key: str, request: Request): """LangfuseMedia からのアップロードリクエストを GCS に転送する""" _validate(bucket_name, object_key) content_length = int(request.headers.get("content-length", 0)) if content_length > MAX_UPLOAD_SIZE: raise HTTPException(status_code=413, detail="File too large") content = await request.body() if len(content) > MAX_UPLOAD_SIZE: raise HTTPException(status_code=413, detail="File too large") content_type = request.headers.get("content-type", "application/octet-stream") bucket = client.bucket(bucket_name) blob = bucket.blob(object_key) blob.upload_from_string(content, content_type=content_type) return Response(status_code=200) デプロイ用ファイル # requirements.txt fastapi uvicorn google-cloud-storage 補足:`--source` デプロイでは Buildpacks が `main.py` と `requirements.txt` を自動検出するため、Dockerfile は不要です。 デプロイ ```bash export PROJECT_ID=your-project export BUCKET_NAME=your-gcs-bucket # サービスアカウント作成・権限付与 gcloud iam service-accounts create langfuse-media-proxy-sa \ --project="${PROJECT_ID}" gcloud storage buckets add-iam-policy-binding "gs://${BUCKET_NAME}" \ --member="serviceAccount:langfuse-media-proxy-sa@${PROJECT_ID}.iam.gserviceaccount.com" \ --role="roles/storage.objectAdmin" # デプロイ(ingress 制限 + デフォルト URL 無効化を同時に設定) gcloud run deploy langfuse-media-proxy \ --source=. \ --region=asia-northeast1 \ --service-account="langfuse-media-proxy-sa@${PROJECT_ID}.iam.gserviceaccount.com" \ --allow-unauthenticated \ --ingress=internal-and-cloud-load-balancing \ --no-default-url \ --set-env-vars="ALLOWED_BUCKETS=${BUCKET_NAME}" \ --port=8080 ``` 補足:アクセス制御について `--allow-unauthenticated` を指定していますが、Cloud Run 自体の認証ではなく Cloud Armor の IP 制限 + ingress 制限 でアクセスを制御します。`--ingress=internal-and-cloud-load-balancing` により LB 経由のアクセスのみ許可され、`--no-default-url` で `*.run.app` URL を無効化するため、Cloud Armor をバイパスして直接アクセスすることはできません。 Global LB + Cloud Armor ```bash # Serverless NEG gcloud compute network-endpoint-groups create langfuse-proxy-neg \ --region=asia-northeast1 \ --network-endpoint-type=serverless \ --cloud-run-service=langfuse-media-proxy \ --project="${PROJECT_ID}" # バックエンドサービス gcloud compute backend-services create langfuse-proxy-backend \ --global \ --project="${PROJECT_ID}" gcloud compute backend-services add-backend langfuse-proxy-backend \ --global \ --network-endpoint-group=langfuse-proxy-neg \ --network-endpoint-group-region=asia-northeast1 \ --project="${PROJECT_ID}" # URL マップ・HTTPS プロキシ・転送ルール gcloud compute url-maps create langfuse-proxy-lb \ --default-service=langfuse-proxy-backend \ --project="${PROJECT_ID}" gcloud compute target-https-proxies create langfuse-proxy-https \ --url-map=langfuse-proxy-lb \ --ssl-certificates=your-ssl-cert \ --project="${PROJECT_ID}" gcloud compute forwarding-rules create langfuse-proxy-rule \ --target-https-proxy=langfuse-proxy-https \ --ports=443 \ --global \ --project="${PROJECT_ID}" # Cloud Armor セキュリティポリシー gcloud compute security-policies create langfuse-media-policy \ --project="${PROJECT_ID}" gcloud compute security-policies rules update 2147483647 \ --security-policy=langfuse-media-policy \ --action=deny-403 \ --project="${PROJECT_ID}" gcloud compute security-policies rules create 1000 \ --security-policy=langfuse-media-policy \ --expression="inIpRange(origin.ip, 'YOUR_IP/32')" \ --action=allow \ --project="${PROJECT_ID}" gcloud compute backend-services update langfuse-proxy-backend \ --security-policy=langfuse-media-policy \ --global \ --project="${PROJECT_ID}" ``` GCS バケットの設定 ```bash gcloud storage buckets create "gs://${BUCKET_NAME}" \ --uniform-bucket-level-access \ --pap \ --project="${PROJECT_ID}" gcloud storage buckets add-iam-policy-binding "gs://${BUCKET_NAME}" \ --member="serviceAccount:langfuse-media-proxy-sa@${PROJECT_ID}.iam.gserviceaccount.com" \ --role="roles/storage.objectAdmin" ``` 補足:バケット側の IP フィルタリングは不要です。アクセス制御は Cloud Armor に一元化します。 使い方 方式 A の場合: プロキシ URL を Langfuse に渡します。 PROXY_BASE_URL = "https://your-proxy-domain.com" BUCKET_NAME = "your-gcs-bucket" def gcs_uri_to_proxy_url(gcs_uri: str) -> str: path = gcs_uri.removeprefix("gs://") return f"{PROXY_BASE_URL}/{path}" 方式 B の場合: `LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT` にプロキシの URL を指定します。 ```yaml # docker-compose.yml environment: LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: your-gcs-bucket LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: https://your-proxy-domain.com LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true" LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: media/ AWS_REQUEST_CHECKSUM_CALCULATION: when_required ``` 補足: CORS について Langfuse UI とプロキシが異なるドメインの場合、クロスオリジンリクエストになります。Langfuse は現在 `` タグで画像を読み込むため CORS ヘッダは不要ですが、将来 `fetch` ベースに変更された場合はプロキシ側で CORS ヘッダの設定が必要になる可能性があります。 懸念点 認証済み URL はセキュリティ的に大丈夫か? この認証方式は GCS コンソールからオブジェクトをダウンロードする際にも使われている GCP の標準的な仕組みです。 認証済み URL(`storage.cloud.google.com`)は認証なしではアクセスできません。 URL を知っていても IAM 権限がなければ 403 になります。 ただし、Langfuse ユーザー全員に Google アカウントと GCS の IAM 権限が必要なため、外部パートナーや Google アカウントを持たないユーザーがいる場合はプロキシ構成を検討してください。 IP制限はGCS の IP フィルタリングで代用できないか?そうすればLB+Cloud Armorは不要では? 検証した限りでは、以下の点で運用が難しいと感じました。 IPv4 だけでなく IPv6 も管理が必要 (接続元がIPv6 対応の場合、ブラウザは IPv6 を優先する(Happy EyeballsによりIPv6を先に試す)) まとめ 方式 A:認証済み URL 方式 B:LangfuseMedia 標準 プロキシ構成(A・B 共通) 画像の表示方法 「Load Image」ボタン 自動インライン A: Load Image / B: 自動インライン プロキシ / LB 不要 不要 必要 GCS URL のブラウザ露出 あり(認証付き) あり(署名付き URL) なし ユーザーに必要な権限 GCS IAM なし なし URL 単体でのアクセス 不可(IAM 認証が必要) 可(有効期限内) 不可(プロキシ経由) 実装コスト 低 低 中 主なランニングコスト 最小 GCS ストレージ・オペレーション(データ量に応じて増加) LB + Cloud Run + Cloud Armor 方式・構成ごとにメリット・デメリットがあることがわかりました。 ユースケースやコスト、セキュリティ要件を踏まえて、本記事が選定の一助となれば幸いです。 補足:署名付き URL はメディア以外でも使われる 本記事ではメディア(画像)表示を扱いましたが、バッチエクスポート(CSV/JSON ダウンロード)でも同じ署名付き URL が生成されます。エクスポートデータには dataset_items 等の全件が含まれるため、URL 露出のポリシーが厳しい環境ではエクスポート機能についても同様の考慮が必要です。 参考 Langfuse: Multi-modality Langfuse: Self-hosting GCS: 認証済みブラウザ ダウンロード Cloud Armor: セキュリティポリシー Cloud Run: 上り(内向き)設定

  • Langfuseで管理者アクセスを監視する:Admin Access Webhookの実態と使いどころ

    本記事でわかること Langfuseにおける「管理者によるトレース閲覧の検知」というニッチだが重要な課題に対して、実機検証ベースで現状の選択肢を整理します。 Langfuse環境で「管理者の会話ログ閲覧」をどう検知するか `LANGFUSE_ADMIN_ACCESS_WEBHOOK` の動作仕様(実機確認済み) この機能の限界と「本番で使えるか」の判断基準 対象読者 Self-hosted Langfuseを複数チーム・複数顧客で共用している方 LLMアプリの運用でコンプライアンス・セキュリティを気にしている方 Langfuseの特権アクセス管理に興味がある方 課題:LLMアプリの会話ログは機密データである LLMアプリを組織で運用していると、避けて通れない問題があります。 ユーザーの会話ログは、多くの場合、機密性の高いデータです。 カスタマーサポートの問い合わせ内容、社内文書への質問、医療・法律領域のやりとり——これらはすべてLangfuseのTrace(トレース)として記録されます。プロンプトの改善やデバッグのために記録することは正しい運用ですが、「誰がそのトレースを見られるか」は別の問題です。 Self-hosted Langfuseを運用している場合、インスタンス全体の管理者は、すべての組織・プロジェクトのトレースに原理上アクセス可能です。 この権限は運用上の必要性から避けられませんが、同時に問題でもあります。 【補足】 `users.admin` は、Org / Project の RBAC ロール(Owner・Admin・Member等)とは別の、インスタンス全体に作用する内部的な管理者フラグです。公式に「Server Admin」というロール名が定義されているわけではないため、本記事では便宜上このフラグを持つユーザーを "Server Admin" と呼びます。 こうした課題を踏まえ、Langfuseには Enterprise 向けの公式 Audit Logs があり、TraceやSessionを含む多くのリソースに対する作成・更新・削除といった変更系の操作が記録されます。ただし、記録対象はあくまで変更系アクションに限られており、管理者がトレースを「閲覧した」こと自体は記録されません。この「閲覧ログが標準では残らない」という隙間を埋める選択肢として、 Langfuse v3.155.1 から追加された `LANGFUSE_ADMIN_ACCESS_WEBHOOK` を、Self-hosted環境で実機検証しました。 Admin Access Webhook とは `LANGFUSE_ADMIN_ACCESS_WEBHOOK` は、環境変数に通知先URLを設定するだけで、 Server AdminがLangfuse上の操作を行った際にPOSTリクエストを送信する 仕組みです。 LANGFUSE_ADMIN_ACCESS_WEBHOOK= https://your-endpoint.example.com/admin-webhook 設定はこれだけです。 ただし、この機能には重要な前提があります。 このWebhookが対象とするのはServer Adminのみです。  通常のOrg Adminがプロジェクトにアクセスしても通知は届きません。 Langfuseにおける「admin」の2種類 種類 役割 Server Admin 全組織・全プロジェクトにアクセス可能 Org Admin / Owner 組織内のロール(OWNER・ADMIN等)。通常の管理者権限 なお、Server Adminへの昇格方法についてもソースコードで調査しました。`LANGFUSE_INIT_USER_*` 環境変数・Instance Management API・tRPC・seedスクリプトのいずれにも `admin=true` をセットする手段は存在せず、 DBを直接更新する(`UPDATE users SET admin = true WHERE email = '...'`)以外の方法は確認できませんでした。   この仕様からも、本機能が現時点では Self-hostedユーザー向けに整備されたものではない 可能性を示唆しています。 実際にどんな操作で通知が来るか 実機で確認した結果をまとめます。 発火するケース AdminがUIからトレースページを開く(2件届く) AdminがURLを直打ちして他チームのプロジェクトにアクセス AdminがcurlでtRPCエンドポイントを叩く Adminが自分の担当プロジェクトを開く 発火しないケース Adminがブラウザで他組織の設定ページを開こうとする(UIがクライアント側でブロック) 通常のOrg AdminやMemberが操作する 重要な点として、 「所属外リソースへのアクセスのみ通知する」ではありません。 発火条件は `admin === true` のユーザーがサーバーサイドの処理を通過した時点です。管理者が自分の担当プロジェクトを開いても通知が届きます。 また、トレース詳細ページを開くと通知が 2件 届きます(内部で2つのtRPCミドルウェアが並行呼び出しされるため)。 通知の内容(ペイロード) 届くJSONはシンプルです。 {  "email": "admin@example.com",  "timestamp": "2026-03-04T00:17:03.152Z",  "project": "cm00000000000000000000proj",  "org": "cm00000000000000000000org0",  "region": "self-hosted" } 実機確認:対象ページを開いた直後に届いたリクエスト フィールド 内容 備考 email 操作したAdminのメールアドレス 常に含まれる timestamp アクセス日時(ISO 8601) 常に含まれる project プロジェクトID orgレベルのアクセスでは `null` org 組織ID トレース・セッションアクセス時は `null` region 環境識別子 Self-hostedは `"self-hosted"` 固定 含まれないもの:  `traceId`・`sessionId`・`userId`・操作の種別 「何を見られたか」の特定はできません。「いつ、誰が、どのプロジェクトに触れたか」までです。 使いどころと限界 現実的に使える場面 ① リアルタイム監視 管理者がトレースを閲覧したタイミングをリアルタイムで検知する。内部統制の「抑止」として機能します。 ② 監査ログの最低限の確保 `email + timestamp + project/org` の組み合わせをSIEMやログ基盤に転送するだけで、特権アクセスの証跡になります。 ③ APIレベルの操作の検知 UIでは他組織のページを直接開けませんが、APIを直接叩くと通知が届きます。UIのガードをすり抜けた操作の検知に役立ちます。 現状の制限 受信側で認証できない 通知に署名がない。エンドポイントURLが漏れると誰でも偽リクエストを送れる 「何を見たか」がわからない `traceId`や`sessionId`が含まれず、詳細な監査には不十分 ノイズが多い 管理者が自分の担当プロジェクトを開くだけでも通知が来る スケール環境で重複が出る Langfuse側に60秒以内の同一キー再送を抑制するdedup処理があるが、プロセスのメモリ上のみで動作する。複数インスタンスをまたいだ重複抑制が機能しない ドキュメント未記載 少なくとも2026年3月時点の公開ドキュメント上では 、この環境変数の説明を見つけることができませんでした。 特に気になる点を一つ挙げると、 同じLangfuseのプロンプト向けWebhookにはHMAC-SHA256署名があるのに、セキュリティ監査用途であるこの機能には署名がありません。 設計の一貫性という観点では疑問が残ります。 この機能は誰のためのものか(仮説) `region` フィールドが `NEXT_PUBLIC_LANGFUSE_CLOUD_REGION` を参照し、Self-hostedは `"self-hosted"` のフォールバック ドキュメント未記載・`chore:` 扱い・レビューなし当日マージ Server Admin自体、DB直接更新以外に設定方法がない (環境変数・API・UIのいずれにも手段が存在しないことをソースコードで確認) これらを総合すると、本機能はLangfuse Cloud内部チームが自社のAdmin操作を監視するために作ったツールである可能性が高く、現時点ではSelf-hosted向けの公式サポートは明確ではありません。 ただし、`self-hosted` という値が明示的にコーディングされている点は、Self-hosted向けの利用も意識していたとも解釈できます。 まとめ Langfuse環境で「管理者が会話ログを閲覧したことを記録する」というニーズ自体は本質的な課題です。そのために `LANGFUSE_ADMIN_ACCESS_WEBHOOK` を活用できる場面はあります。 その際、Langfuse自体の監査ログ機能やアクセス制御と組み合わせ、補完的な用途として位置づけるのが適切です。 一方で、認証ヘッダーの不在や詳細なペイロードの不足、公開ドキュメントでの言及がない点などを踏まえると、現時点では 「特定のユースケースを補完するための、発展途上の機能」 と捉えるのが自然でしょう。 大規模な組織であればより厳密な監査ログ基盤が必要になりますが、 小規模なSelf-hosted環境における内部統制の第一歩 としては、このWebhookを活用する価値は十分にあります。現状の仕様(限界)を正しく把握した上で、補助的なセキュリティ策として導入を検討するのが現実的です。 参考リンク Langfuse GitHub — v3.155.1 Release Langfuse Self-Hosting ドキュメント Langfuse Prompt Webhooks(HMAC署名あり)

  • LibreChatに統合されたLangfuseトレース送信機能を試す

    はじめに この記事では、オープンソースのチャットUI「LibreChat」をDocker Composeでセットアップし、既存のLangfuseへトレースを送信する機能を試します。 LibreChatでLangfuseにトレースを送信するには LibreChatでLangfuseにトレースを送信するには、これまでLiteLLMをプロキシとして挟む構成が必要でした。 LibreChat → LiteLLM (プロキシ) → LLM Provider ↓ Langfuse LiteLLMはLangfuse連携をサポートしており、LLM呼び出しをトレースできますが、LibreChatとは別にLiteLLMの構築・管理が必要になるため、セットアップの複雑さが増すという課題がありました。 LibreChat v0.8.1(2025年12月11日リリース)では、 PR #10292 により、LiteLLMなしでLibreChatから直接Langfuseにトレースを送信できるようになりました。 LibreChat → LLM Provider ↓ Langfuse(直接送信) これにより、LibreChatとLangfuseだけのシンプルな構成でオブザーバビリティを実現できます。 この記事で実現すること この記事では、以下の内容を実践します。 Docker ComposeでLibreChatを構築し、OpenAI APIとLangfuseに接続 基本的なトレースの確認 LibreChatでMCP Server利用時のトレースを確認 注意:この記事では、Langfuseは既に用意されている前提で進めます。Langfuseをセルフホストする方法については、 Langfuse公式ドキュメント を参照してください。Langfuse Cloudを利用する場合は、 https://cloud.langfuse.com  でアカウントを作成してください。 それでは、環境構築から始めます。 環境構築編 LibreChatをDocker Composeでローカル環境に構築し、既存のLangfuseに接続します。 前提条件 以下がインストールされていることを前提とします。 Docker Desktopなど、Docker Composeが使える環境 以下何れかのLangfuseとAPIキー Langfuse Cloud セルフホストしたLangfuse LLMプロバイダーのAPIキー(OpenAI、Anthropic、Google Vertex AIなど) アーキテクチャ概要 今回構築する環境の構成は以下の通りです。 各コンポーネントの役割 LibreChat: チャットUIおよびLLM統合層 MongoDB: LibreChatのデータベース(ユーザー情報、会話履歴など) 外部Langfuse: トレース収集・可視化(Cloudまたはセルフホスト) LLM Provider API: OpenAI、Vertex AI、Anthropicなどのモデルプロバイダー セットアップ手順 1. ディレクトリ構成 以下のようなディレクトリ構成で環境構築します。 librechat-langfuse-integrate/ ├── .env # 環境変数 ├── docker-compose.yml # コンテナ構成定義 └── librechat.yaml # LibreChatの設定ファイル 任意のディレクトリを作成し、以降の手順で各ファイルを配置していきます。 2. 環境変数ファイル (.env) .env ファイルを作成し、Langfuse接続情報とLLMプロバイダーのAPIキーを設定します。 .env # ============================================ # LibreChat Server Settings # ============================================ PORT=3080 ALLOW_REGISTRATION=true # ============================================ # LLM Provider Settings # ============================================ # OpenAI API Key OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxxxxxx # ============================================ # Langfuse Settings (External Server) # ============================================ # Get these values from your Langfuse project settings LANGFUSE_SECRET_KEY=sk-lf-xxxxxxxxxxxxxxxxxxxxxxxx LANGFUSE_PUBLIC_KEY=pk-lf-xxxxxxxxxxxxxxxxxxxxxxxx LANGFUSE_BASE_URL=https://cloud.langfuse.com # または https://your-langfuse-server.com # ============================================ # LibreChat Auth Secrets # ============================================ # セキュリティ上、必ず変更してください # 生成: openssl rand -hex 16 CREDS_KEY=your-creds-key-change-this # 生成: openssl rand -hex 8 CREDS_IV=your-creds-iv-change-this # 生成: openssl rand -hex 32 JWT_SECRET=your-jwt-secret-change-this # 生成: openssl rand -hex 32 JWT_REFRESH_SECRET=your-jwt-refresh-secret-change-this 補足 Langfuseとの接続に必要なのは `LANGFUSE_SECRET_KEY`、`LANGFUSE_PUBLIC_KEY`、`LANGFUSE_BASE_URL` の3つです。これらの値はLangfuseプロジェクトの Settings → API Keys から取得できます。 Auth Secrets(`CREDS_KEY`、`CREDS_IV`、`JWT_SECRET`、`JWT_REFRESH_SECRET`)は、各項目のコメントに記載した `openssl rand` コマンドで生成した値に置き換えてください。 3. Docker Compose設定ファイル (docker-compose.yml) LibreChatとMongoDBのみを起動し、OpenAI APIを利用したシンプルな構成です。 フル機能を含む公式のDocker Compose設定は以下公式GitHubリポジトリの設定ファイルを参照して下さい。 https://github.com/danny-avila/LibreChat/blob/main/docker-compose.yml docker-compose.yml services: # =========================================== # LibreChat Application Server # =========================================== api: container_name: LibreChat image: ghcr.io/danny-avila/librechat:v0.8.1 ports: - "${PORT}:${PORT}" depends_on: - mongodb restart: always extra_hosts: - "host.docker.internal:host-gateway" env_file: - .env environment: - HOST=0.0.0.0 - MONGO_URI=mongodb://mongodb:27017/LibreChat volumes: - ./librechat.yaml:/app/librechat.yaml:ro # =========================================== # MongoDB - LibreChatのデータストア # =========================================== mongodb: container_name: chat-mongodb image: mongo:8.0.17 restart: always volumes: - mongodb-data:/data/db command: mongod --noauth volumes: mongodb-data: 4. LibreChat設定ファイル (librechat.yaml) LibreChatの動作をカスタマイズする設定ファイルです。以下の例はOpenAIモデルとエージェント機能を使用する設定です。 librechat.yaml # Configuration version (required) version: 1.3.5 # Cache settings: Set to true to enable caching cache: true # Endpoints Configuration endpoints: # OpenAI Configuration openAI: apiKey: "${OPENAI_API_KEY}" models: default: - "gpt-5" - "gpt-5-mini" - "gpt-5-nano" titleConvo: true summarize: false titleModel: "gpt-5-nano" # Agents Configuration # Langfuse tracing is enabled for Agents agents: disableBuilder: false # Enable Agent Builder UI recursionLimit: 25 # Default steps an agent can take maxRecursionLimit: 50 # Maximum steps limit # MCP Server Configuration(後ほど検証編2で使用) mcpServers: {} registration: socialLogins: [] 補足 `mcpServers` は後ほどMCP Server機能を検証する際に設定を追加します。 5. Docker ComposeでLibreChatを起動・動作確認 以下のコマンドでLibreChatを起動します。 # LibreChatとMongoDBを起動 docker compose up -d # 起動状態を確認 docker compose ps 初回起動時は、イメージのダウンロードやデータベースの初期化が行われるため、数分かかる場合があります。 サービスが正常に起動しているか、ログを確認します。 # LibreChatコンテナのログを確認 docker compose logs -f api # 正常に起動していれば、以下のようなログが表示されます # LibreChat v0.8.1 # Server listening on port 3082 # Connected to MongoDB 起動を確認したら、以下の手順でLibreChatにアクセスします。 http://localhost:3080 にアクセス 新規登録からアカウントを作成 ログイン後、設定したLLM(OpenAI)を選択し、LLMに挨拶をし、LibreChatで正しくLLMが動作することを確認します。 6. Langfuse接続の確認 LibreChatで簡単な会話を実行した後、Langfuse UIにアクセスしてトレースが記録されているか確認します。 Langfuse UI アクセス先 Langfuse Cloud : https://cloud.langfuse.com セルフホスト : セルフホストのLangfuseURL Traceing ページに移動し、LibreChatからのトレースが表示されていれば成功です。 これで、LibreChatと外部Langfuseの統合環境が完成しました。次は、実際にトレースの詳細を確認します。 検証編1 - 基本的なトレース確認 環境構築が完了したので、トレースが正しく送信されているか確認します。 「5. Docker ComposeでLibreChatを起動・動作確認」での会話のトレース詳細を確認しましょう。 トレースの確認 上記で実行した会話の内容についてトレースを深堀りしてみます。 Langfuse UIの Tracing ページに移動します。 確認できる情報 主に以下のような内容がトレースから確認できます。 Trace ID(各会話に一意のIDが付与されています) Timestamp(トレースが記録された日時) User ID(LibreChat MongoDBのObjectIDと紐づく) Session ID (LibreChatのスレッドID : ConversationIDと紐づく) Input/Output(プロンプトとLLMの応答) Latency(リクエストにかかった時間) Tokens(入力トークン数と出力トークン数) Cost(トークン使用量から計算されたコスト) トレースの詳細を確認 特定のトレースをクリックすると、詳細情報が表示されます。 1. 実行フロー トレースは階層構造で表示され、以下のような流れと各処理にかかったコストが確認できます。 また、自動的にエージェントの処理フローがグラフとしても表示されます。 2. プロンプト詳細 Input 、Output セクションには、実際にLLMに送信されたプロンプトとLLMからの応答がが表示されます。 3. パフォーマンスメトリクス パフォーマンス関連のメトリクスとしては以下のようなものが確認できます。 Latency(リクエストの開始から完了までの時間) Time to First Token (TTFT)(最初のトークンが生成されるまでの時間) これらの指標により、モデルのパフォーマンスを定量的に評価できます。 複数の会話スレッドを確認 LibreChatで新規チャットを開始すると、新しい Conversation ID が付与されます。Langfuse UIの Sessions ページで、Conversation ID ごとにグループ化された会話を確認できます。 これにより、ユーザーがどのような会話の流れを持っているかを追跡できます。 コスト分析 デフォルトで用意されている Langfuse Cost Dashboard ページにより、 以下のようなコスト分析が可能です。 総コスト(トークン使用量から計算された総コスト) モデル別コスト(どのモデルがどれだけコストを使っているか) ユーザー別コスト(どのユーザーがどれだけコストを使っているか) 検証1のまとめ 基本的なトレース送信機能の検証により、以下のことが確認できました。 LibreChatからLangfuseへのトレース送信が正常に動作 会話内容、パフォーマンスメトリクスが正確に記録される Sessions から ConversationIDごとの、会話を追跡できる トークン使用量とコストを可視化できる 次は、MCP Server機能を使用した場合のトレースを確認します。 検証編2 - MCP Server利用時のトレース LibreChatはMCP (Model Context Protocol) Serverをサポートしており、LLMにツール実行機能を追加できます。ここでは、MCP Serverを設定し、ツール呼び出し時のトレースがどのように記録されるかを確認します。 MCP Serverの設定 Langfuse Docs MCP Serverを設定する例を示します。 librechat.yamlの更新 librechat.yaml を編集し、mcpServers セクションに以下の設定を追加します。 mcpServers: langfuse-docs: command: npx args: - "-y" - "mcp-remote" - "https://langfuse.com/api/mcp" metadata: name: "Langfuse Documentation" description: "Access to Langfuse official documentation via MCP" 追記後の librechat.yaml は下記セクションを参照ください。 librechat.yaml # Configuration version (required) version: 1.3.5 # Cache settings: Set to true to enable caching cache: true # Endpoints Configuration endpoints: # OpenAI Configuration openAI: apiKey: "${OPENAI_API_KEY}" models: default: - "gpt-5" - "gpt-5-mini" - "gpt-5-nano" titleConvo: true summarize: false titleModel: "gpt-5-nano" # Agents Configuration # Langfuse tracing is enabled for Agents agents: disableBuilder: false # Enable Agent Builder UI recursionLimit: 25 # Default steps an agent can take maxRecursionLimit: 50 # Maximum steps limit # MCP Server Configuration mcpServers: langfuse-docs: command: npx args: - "-y" - "mcp-remote" - "https://langfuse.com/api/mcp" metadata: name: "Langfuse Documentation" description: "Access to Langfuse official documentation via MCP" registration: socialLogins: [] 設定の反映 # LibreChatコンテナを再起動 docker compose restart api # ログを確認 docker compose logs -f api 起動ログに以下のようなメッセージが表示されれば、MCP Serverが正常に起動しています。 info: MCP servers initialized successfully. Added 3 MCP tools. MCP Serverを使った会話 LibreChat UIに戻り、新しい会話を開始します。MCP Serverが有効化されている場合、チャット欄に「MCP サーバー」という欄が表示され、「MCP サーバー」を押下すると langfuse-docs が選択できます。 langfuse-docs にチェックを入れた後、以下のような指示をLLMに依頼しましょう。 すると、LLMは langfuse-docs の Tool : searchLangfuseDocs を利用して調査しその内容を下に回答を生成してくれます。(生成された回答は長いので一部省略) Langfuse UIでMCP Serverトレースを確認 MCP Serverを使用した会話のトレースを確認します。 Toolの利用 LLMがToolを利用した記録を確認できます。 また、Toolの Input、Outpuより、呼び出し(クエリ内容)とそれに対する戻り値(クエリ結果)が確認できます。 (Outputは階層が深いので一部省略。) 検証2のまとめ MCP Server利用時のトレース検証により、以下のことが確認できました。 MCP Serverのツール呼び出しが正確にトレースされる ツール呼び出しのパラメータと戻り値が記録される まとめ LibreChat v0.8.1で追加されたLangfuse統合機能を、外部Langfuse接続で検証しました。 良かった点 セットアップが簡単(Langfuseの環境変数を3つ追加するだけで動作) コード変更なしで会話が自動的にトレースされる User ID、Conversation IDなどのメタデータが自動記録されLangfuse上でトレースがグルーピングされる MCP Serverのツール呼び出しも含めてトレースされる こんな人におすすめ 既にLangfuseを使っていて、LibreChatの会話も一元管理したい方 企業内でLLMチャットUIを提供し、利用状況を可視化したい方 MCP Serverやエージェント機能のデバッグに詳細なトレース情報が必要な方 今後の期待 今回の検証では、トレースに記録されるObservation Typeが主に generation(LLM呼び出し)に限られていることも確認できました。 Langfuseは generation 以外にも、agent、tool、 など多彩なObservation Type をサポートしています。 例えば、MCP Serverのツール呼び出しが tool 型として記録されるようになれば、Langfuse UI上でのフィルタリングや分析がより直感的になるはずです。 LibreChatのLangfuse統合はv0.8.1で初めて導入された機能です。今後のバージョンアップでこれらのObservation Typeへの対応が進むことで、トレースの表現力がさらに向上し、より実践的なオブザーバビリティが実現できるのではないかと期待しています。 参考リンク LibreChat公式ドキュメント: https://www.librechat.ai/docs Langfuse公式ドキュメント: https://langfuse.com/docs Langfuse LibreChat統合ガイド: https://langfuse.com/integrations/other/librechat LibreChat PR #10292 (Langfuse統合): https://github.com/danny-avila/LibreChat/pull/10292

  • Langfuseによるプロンプト管理 (後半) - プロンプト開発&実験編

    [ 前回の記事 ] では、「Langfuse」を活用したプロンプト管理の方法を具体的に解説しました。Prompt をハードコードすることなく、Diff 、 Commit コメント 、Duplicate (複製) 、タグ などの機能を、直感的にエンジニアから非エンジニアまで幅広く使うことができますという内容です。まだご覧になっていない方は、ぜひチェックしてみてください。 さて今回は、Langfuseでのプロンプト開発と評価について触れていきたいと思います。 Langfuseによるプロンプトの新規作成 Langfuse にログインをしたら、左側のメニューバーから [Prompts] を選択し、右上の [+ New prompt] から新規作成を始めます。 画面右側の [+ New prompt] を選択 そして以下の新規作成ページに移りますので、必要項目を埋めるだけです。 新規作成ページ 各項目の説明は以下の通りです。 Name は Prompt に対する任意の名前で、プログラム側から指定するもの Prompt に具体的に入れたいプロンプト内容を入力します。Text か Chat を選ぶことができ、Chat を指定すると、それぞれのrole (System, Assistant, Developer, User) を指定してメッセージを入れることが可能です。Prompt の中には {{variable}} の形式で変数を埋め込め、ユーザーの入力値などを受け取って処理するために使えます。 Config は前回のブログでご紹介した通り、JSONで任意の値を入れることができるパラメーターになっております。例えば以下のようにモデル名の指定などで使うことができます。 { "model": "models/gemini-2.0-flash-001" } LabelsはデフォルトでProduction ラベルをつけるかどうかの選択です。Productionになっていると、プログラムコードでラベルを指定しない場合に選択されて使用されます。後で変更可能です。 Commit message は必須ではありませんが、Version1 以降はつけておくとチームメンバーなどに意図を伝えるために有益だと思います。 Playgroundの利用 まず新規での作り方を記載しましたが、場合によっては0から考えるのが難しいことがあると思います。そんな時に使える Playground 機能をご紹介します Playground 画面 左側のメニューから [Playground] を選択すると、Playground画面に遷移します。 基本的な使い方としては、試してみたいPrompt を [Messages] に入れ、画面右側で定義した [Model] で、モデルの種類や、各種パラメータを設定するだけ* です。また変数を入れて、右下の [Variables] に値を入れます。 例えば今回のPromptには 以下の文章が LLM の動作原理を説明する文章として正しいかどうかを判定しなさい。 {{text_input}} という記載が含まれており、この変数 text_input の値として テキストボックスに LLM は、主に「Transformer」と呼ばれる深層学習 (ディープラーニング) のアーキテクチャを基盤としています。このモデルは、大量のテキストデータ (書籍、ウェブサイト、記事など) を読み込み、単語や文の間の関係性 (文脈、意味など) を学習します。また学習した知識を基に、与えられた入力 (プロンプト) に対して、最も適切と思われる出力 (文章、回答など) を生成します。 を入れます。そしてSubmit すると、 [Output] にLLM からの回答が入っています。 この内容が気に入れば、右上の [Save as prompt] を押すと先ほどの新規作成画面に遷移し、そのまま新規Prompt にすることができます。 気に入らなければ好きなだけ修正するなり、初期化したければ [Reset playground] をするだけです。 *Model自体の登録はこの画面ではなく、メニューの [Settings] から あらかじめ設定しておく必要がありますのでご注意ください。Langfuse側からLLMモデルを利用しますので、課金されることにご注意ください。 Prompt Experiment の利用 新規作成後、LLMアプリケーションの運用においてPromptの改善が必要な場面に出くわすと思います。Prompt Experiment 利用すると変更したプロンプトに対して、リリース前に過去のユーザーのTrace をDataset にするなどして挙動が改善するかを確かめることができます。 Prompt Experiment 文字通りPrompt の実験として、対象Prompt とバージョンやモデルなどを指定し、 利用するDataset を設定します。 Datasetの指定 このDataset は0から作っておくもよいですし、Trace から直接Dataset に入れることで過去正常に動いたものがデグレしないか、不具合あったInputが改善されているかを実際に確かめていただくことができるようになります。また、LLM as a judge などと連携することで、自動で定量的な評価なども付加することが可能です。LLM as a judge については、 こちらのブログ で詳しく紹介しておりますので、是非あわせてご覧ください。(当該ブログの " テスト実行と結果の可視化" に相当する部分が本項に相当します。) なお今回ご紹介したPlayground とExperiment はSelf-hosted のOSS 版には含まれません (SaaS であれば無償のHobbyプランで使えます) のでご注意ください。 まとめ:Langfuseでプロンプト新規開発とテストを簡単に。 いかがでしたでしょうか。Langfuseを活用し、Promptの新規作成 - 管理までを簡単に実施できることがご確認いただけたと思います。本記事が効率的なLLMアプリケーションの開発とオペレーションのお役に立てれば幸いです。 本機能の具体的なユースケースや使い方のデモなどにご興味がある方は、ぜひ こちらから お気軽にお問い合わせください。

  • Langfuseによるプロンプト管理 (前半) - 基本 & 管理編

    [ 前回の記事 ] では、プロンプト管理の重要性にくわえて、コード埋め込みやGit または データベースによる管理の課題について解説しました。今回は、それらの問題を解決すべく「Langfuse」を活用したプロンプト管理の方法を具体的に解説します。 Langfuseとは?:LLMの開発と運用に特化したオープンプラットフォーム Langfuseは、生成AIアプリケーションの開発や運用に特化したオープンソースのプラットフォームです。可視化やテストなど生成AIアプリケーションのライフサイクル全体を管理するものですが、今回は特にプロンプト管理に焦点を当ててご紹介をしていきます。 Langfuseによるプロンプト管理の主な特徴は以下のとおりです。 使いやすいUI: 直感的なインターフェースで、プロンプトの作成、編集、比較、テストが容易に行えます。 バージョン管理: 変更履歴を自動的に記録し、過去のバージョンとの比較やコピー、修正などが簡単に行えます。 詳細な分析: プロンプトのパフォーマンス(応答時間、コスト、品質スコアなど)を詳細に分析し、改善点を見つけやすくします。 柔軟な評価: LLM as a judge などによる自動評価指標に加えて、人間による評価 (Human Annotation) も組み合わせて、多角的にプロンプトを評価できます。 チームでの共有: プロンプトをチーム内で共有し、共同で作業できます。 実践!Langfuseによるプロンプト管理の具体的なステップ 前提: Prompt はLangfuse から取ってくる Langfuseを使うと、プロンプト管理はどのように変わるのでしょうか? まず前提としてアプリケーションコードに埋め込む形とは異なり、Prompt 本体は Langfuse に格納され、それをプログラム側から fetch する形となります。 アプリケーションはLangfuse に格納されているPrompt を取りに行き、適切なものを入手します。なお本番運用した際に都度取得をする必要は必ずしも無いので、TTL を設定してキャッシュする運用が現実的だと思います (デフォルト60 sec、0で即時反映)。 以下はPython のコード例です。極めてシンプルな実装が可能です。 この場合、wweという名前で管理されているPrompt をfetch してきます。 from langfuse import Langfuse # Initialize Langfuse client langfuse = Langfuse() prompt = langfuse.get_prompt("wwe") TTLを設定する場合や、特定のラベルのPrompt を取得したい場合は以下のようになります。ラベルについては後述します。 # TTL を 300に指定 prompt = langfuse.get_prompt("wwe", cache_ttl_seconds=300) # the-rock-is-cooking というラベルの Promptを取得 prompt = langfuse.get_prompt("wwe", label="the-rock-is-cooking") その他のオプションや記述例はLangfuse の公式ドキュメントに豊富な例が用意されていますので、参考にしてみてください。 URL: https://langfuse.com/docs/prompts/get-started 余談ですが、同サイトの右上にある Search 欄から、Ask AI (Cmd + k) で日本語で質問することも可能です。日本語でも精度が高いので、ぜひ使ってみてください。 さてこれから、実際のプロンプト管理を見ていきましょう。 Prompt の作成とバージョン管理 LangfuseのUI上では、直感的にプロンプトを直接作成・編集できます。 変更を加えるたびに、自動的に新しいバージョンが作成され、変更履歴が記録されます。 以下は プロンプト管理画面のサンプルです。 これは toyidea という名前のプロンプトで、右側で各バージョンを確認することができます。現在、Production に適用されているのは Version 2 のプロンプトです。 また、latest ラベルがついている Version 3 にはコメントがついており、"独自アイディアの追加プロンプト試行" とコメントがついています。併せて作業者もそれぞれ情報として付加されており、いつ誰が何のためにバージョンを変えたのかも非常に分かりやすくなっています。 ラベルについては最新のものには自動で latest がつきますが、この画像のように自分で任意の名前をつけ、前述のサンプルのように指定したものを fetch することもできます。 Version 間での詳細を比較したい場合には、GUI 上でDiff をすることもできます。相違部分がハイライトされおり、一目で差異が分かります。 またPrompt にはConfig として、以下のように任意の情報を持たせておくことができます。モデル名, Temperature などをPrompt と一緒に取得することで、管理を一元化し、結果の整合性を維持することが期待されます。 例えばPrompt はLangfuse から持ってきても、コードの中で別のモデルを指定していると、テストしておいた結果と実際の結果は異なってしまいますが、管理をまとめることでそのような問題を防ぐことができ、コード自体もシンプルになります。 作成したプロンプト活用して、別のアプリケーションなどのために新たなプロンプトを作りたいこともあるでしょう。その際には、Duplicate 機能で任意のVersion だけ あるいはすべてのバージョンを含んでコピーを作ることができます。新たに Prompt を作る際に、どこからか Copy and Paste したり、新規で書く必要はありません。過去に作られている資産を使って作業を効率化することが可能なのです。 まとめ:Langfuseで簡単だけど効果的な プロンプト管理 他にも便利な機能がありますが、それらの紹介は別の機会に譲るとして、今回は一旦ここまでとしたいと思います。見ていただいた通り、Git でのオペレーションもSQL も不要かつ、非常に効果的な管理ができます。 本記事では、プロンプト管理の前提としてLangfuse で管理をする構成やどのように管理をされるのかという点について、いくつかの主要機能について説明をしていきました。 ぜひ参考にしていただき、プロンプトをハードコードする構成ではなく、LLM Ops を実現される一助になれば幸いです。 次回は Langfuse における実際のプロンプトの開発と評価について紹介します。 管理に加えて非常に有意義な機能を簡単に使うことができますので、ぜひご覧ください。

  • [LLMOps] プロンプト管理の課題

    はじめに:生成AIが抱える困難とプロンプト 生成AIアプリケーションの開発は、従来のソフトウェア開発とは異なる難しさがあります。 その一つが、生成AIの出力の不安定さです。そしてこの不安定さに大きく関わっているのが、プロンプトです。生成AIは、人間が与える指示、つまりプロンプトに基づいて動作しますが、プロンプトが適切でなければ、生成AIはその能力を十分に発揮できません。 しかし、プロンプトの重要性は認識されつつも、その管理は後回しにされがちです。多くの開発現場では、プロンプトがコードの中に直接埋め込まれ、場当たり的に修正されているのが現状ではないでしょうか。(少なくとも、筆者は多くそのような現場を見聞きしています) LLMOps のプロンプト管理とは?:なぜ必要で、何が問題なのか プロンプト管理とは、生成AIへの指示(プロンプト)を体系的に作成、テスト、改善、保存、共有するプロセス全体を指します。 プロンプト管理の目的は、主に以下の4つです。 品質向上: 生成AIの出力の品質を向上させ、安定させる。 一貫性確保: 同じプロンプトからは常に同じ品質の出力が得られるようにする。 効率化: プロンプトの作成、テスト、改善のサイクルを効率化する。 再利用性向上: 良いプロンプトをチーム内で共有し、再利用できるようにする。 これらの目的を達成するために、プロンプトを適切に管理する必要があります。 しかし、現状では以下のような問題点があります。 問題点1:コード埋め込みプロンプトの罠 開発時には、作業を優先させることに意識がいってしまい、プロンプトはプログラムコードの中に直接埋め込まれてしまいがちで、結果として以下のような問題を引き起こします。 保守性の低下: プロンプト変更のたびにアプリケーションと一緒に テスト, デプロイのパイプラインなどを回すため、とにかく手間や時間ががかかる。 再利用性の低下: 他のアプリケーションやチームメンバーがプロンプトを再利用しにくい。 可読性の低下: コードとプロンプトが混在し、コード全体の読みやすさが下がる。 バージョン管理の困難: プロンプトの変更履歴が追跡しにくく、問題発生時の原因特定が難しい。 上記のうち特に保守性の低下は致命的であり、開発,修正,テストというサイクルに無駄な工数や待ち時間などが発生してしまいます。 Prompt をコードに埋め込むとコードと同じ工程でリリースをすることになるが、その効果は特になく時間と手間だけが発生する 問題点2:Gitやデータベースでの管理の限界 プロンプトをコードから分離するためには、Gitやデータベースで管理する方法も考えられます。それらの特徴を大まかにまとめるたものが以下の表です。 プロンプト管理:データベース vs. Git 項目 データベース Git 対象ユーザー システム開発者 システム開発者 (あるいはGit作業についてトレーニングを受けた方) 利用スキル要件 DB設計・運用、SQL、加えて何らかの管理用アプリケーションが必要になるケースがある Git操作(コミット、ブランチ、マージ等) データ形式 構造化データ(プロンプト、メタデータ) 非構造化データ(主にテキスト) バージョン管理 履歴記録可能(実装依存)、差分比較は困難 履歴記録・追跡が容易、過去バージョンへ復元可、差分比較も容易 共有・コラボ 共有可能(同時アクセス、排他制御はDBによる)、専門知識必要、リアルタイム共同編集は困難 共有容易(リモートリポジトリ)、プルリクエストでレビュー可、権限管理可、ただし非技術者には難解 テスト・評価 テスト結果保存は要開発、自動評価連携は困難、プロンプトと出力結果の紐付けについても開発が必要 テスト自動化はCI/CD連携でアプリケーションと一体で実施 (プロンプト単体の評価は不可能)、自動での評価機能なし、出力結果との紐付け困難 検索性・再利用性 属性検索可、自然言語特性の完全な表現は困難 Git機能だけでは不十分(別途プロンプトライブラリ等必要) この表からわかるように、Gitとデータベースはそれぞれプロンプト管理に活用できる側面があるものの、多くの課題が残ります。 また プロンプトは本来はシステム開発者ではなく、生成AIのアウトプットの良し悪しが分かる 業務エキスパート (ドメインエキスパート) によって修正、テスト、評価をするべき にもかかわらず、両者ともシステム開発者の手により管理されることになります。 プロンプトがコードと一体化することにより、システム開発者はプロンプトの責任を持たざるを得ないが、正解が分からない悲劇 コードにプロンプトを取り込むことにより、生成AIのシステム開発者の管理スコープはプロンプトに及びます。なぜならば、変更できるのがシステム開発者だけだからです。 しかし生成AIシステムの品質を追求するためには、組織のあるべき姿として、コード管理はシステム開発者が実施し、プロンプトは生成AIに出して欲しい期待値を知っている業務エキスパートの手に委ねるべきなのです。 まとめ:プロンプト管理の課題を克服するために 生成AIアプリケーションの品質は、プロンプトの品質に大きく左右されます。しかし、プロンプトはコードに埋め込まれたり、Gitやデータベースで十分に管理されていなかったりすることが多く、様々な問題を引き起こしています。 これらの問題を解決し、生成AIのポテンシャルを最大限に引き出すためには、プロンプト管理の仕組みをアプリケーション開発者以外でも対応できるよう整備し、プロンプト管理とテスト - 改善のプロセスを一元的に確立させることが不可欠です。 [ 次回の記事 ] では、これらの課題を解決するための実例を「Langfuse」と、Langfuseを活用したプロンプト管理を通して詳しく解説します。

  • Langfuseのサーバーサイドマスキングを試してみた

    過去、Langfuseでのマスキングについて触れてきましたが、これまではクライアントサイドで対応するしかありませんでした。しかし、ついに先日のリリース( v3.152.0 )で、サーバーサイドでのマスキングが設定可能になりました。 ※ 注意 :現時点ではEE(Enterprise Edition)ライセンス専用機能となっています。 今回は公式ドキュメントを参考に、実際に設定してみました。 基本設定 事前準備 マスク処理を行う、Langfuseからのコールバック先が必要になります。 今回は設定の挙動を確認したいため、公式の 実装例 をそのまま利用し、Cloud Runにデプロイしました。 設定 設定自体は、Langfuse Workerのコンテナに環境変数 LANGFUSE_INGESTION_MASKING_CALLBACK_URL  を設定すれば利用できるようになります。 ※ 注意 :Langfuse Worker コンテナにEEライセンスが適用されていない場合、コールバックURLを設定しても、そのURLへリクエストが送られることはありません。 実際にEEライセンスが適用されていない状態でトレースを送信すると、手元の環境では以下のエラーログが出力されました。 warn: Ingestion masking callback URL is configured but enterprise license is not available. Masking will be disabled. 実験 サーバーサイドマスキングは、OpenTelemetryエンドポイント(/api/public/otel)経由のイベントに適用されます。Python SDK v3.x系はこれに対応しているため、SDKを利用した簡易的なトレース送信コードを作成し、テストしました。 テストコード # ※環境に合わせてクライアントを初期化してください langfuse = get_client() with langfuse.start_as_current_observation( as_type="span", name="test-trace", input="山田太郎様からの問い合わせ", ) as root_span: trace_id = langfuse.get_current_trace_id() with langfuse.start_as_current_observation( as_type="span", name="test-span", input="山田太郎です。登録しているメールアドレスは何ですか?", ) as span: span.update( output="山田太郎様のメールアドレスはtest-user@example.comです。", metadata={ "user_email": "test-user@example.com", "support_phone": "123-456-7890" }, ) root_span.update_trace( output="問い合わせが完了しました。", ) langfuse.flush() 検証1:公式の実装例 まずは、サンプル実装に対してリクエストを送信した場合にどうなるか確認してみます。 1階層目のトレース(test-trace)には、今回置換対象となる項目が含まれていないため、2階層目、3階層目のトレース情報を確認してみます。 結果 2階層目 3階層目 置換対象となる、メールアドレス・電話番号が含まれている箇所について、Output、Metadata ともにマスキング(置換)されていることが確認出来ました。 検証2:組織・プロジェクト単位でのマスク mask_trace 関数内で x_langfuse_org_id や x_langfuse_project_id を利用することで、組織単位・プロジェクト単位で異なるマスク処理を適用することができます。 ここで、コールバック先に設定しているアプリケーションの mask_trace を以下の通り変更します。特定の組織のみ、マスク処理の異なるmask_pii2が適用されるようにしました。 def mask_pii2(data): print(f"mask_pii2: {data}") if isinstance(data, str): data = f"[REDACTED] {data}" return data elif isinstance(data, dict): return {k: mask_pii2(v) for k, v in data.items()} elif isinstance(data, list): return [mask_pii2(item) for item in data] return data async def mask_trace( request: Request, x_langfuse_org_id: Optional[str] = Header(None), x_langfuse_project_id: Optional[str] = Header(None) ): body = await request.json() if x_langfuse_org_id == "xxxxxxxxxxxxxxxxxxxxxxx": masked_body = mask_pii2(body) else: masked_body = mask_pii(body) return masked_body コード上の引数 x_langfuse_org_id、x_langfuse_project_id は、Langfuseから送られてくるHTTPヘッダー(x-langfuse-org-id、x-langfuse-project-id)から取得されています。そのため、ヘッダーが付与されていない際には値が取得できない可能性があります。なお、今回利用したPython SDKによる送信方法では、問題なく付与されていました。 ※ 上記コードのように条件分岐を行う場合、指定するのは name ではなく、 id であることに注意して下さい 結果 指定した組織には mask_pii2 が、それ以外の組織には mask_pii が適応されることを確認出来ました。 しかし、mask_pii2は以下の通り、想定される形にはなりませんでした。 サーバサイドで行う場合は、リクエストボディ全体が処理対象となります。そのため、今回のように再帰的に処理をかけると、トレースの構造を定義する resource.attributes などのキーまで書き換わってしまい、Langfuse側で正しくInput/Outputとして認識されなくなってしまいました。 クライアントサイドで利用していた処理を移植したい場合は、メタデータを破壊しないよう、対象となるデータ部分だけを加工するなどの工夫が必要となりそうです。 参考:同じmask_pii2をクライアントサイドの maskオプションに指定した場合 補足:Dataset Run への影響について UIから Dataset Runを実行した場合にはマスク処理が 適応されないこと を確認しました。 マスキングされることによるEvaluator への影響や、結果の目視がしにくいと言った問題は起こらなさそうです。 まとめ 実際に試してみた結果、細かい制御の手軽さという点では、クライアントサイドでマスク処理を行う方に分がある印象です。 しかし、「いざという時のセーフティーネット」として活用できたり、既にOpenTelemetryエンドポイント経由で動作しているアプリケーションに対して、「アプリ側のコード改修無しでマスク処理を適用できる」など、サーバサイドならではのメリットも多く存在します。 また、今回は検証していませんが、環境変数 LANGFUSE_INGESTION_MASKING_PROPAGATED_HEADERS を設定することで、オリジナルのリクエストから任意のカスタムヘッダーをコールバック先へ伝播させることも可能なようです。これを活用すれば、要件に合わせてさらに柔軟なマスキング制御を実現していくこともできそうです。

  • ガバナンスを高めるKong AI Gateway + Langfuseで“アプリ計装なし”のLLMオブザーバビリティ

    LMアプリケーションの可観測性(オブザーバビリティ)を確保しようとする際、Langfuse SDK や OpenTelemetry SDK をアプリケーション側に組み込んで計装するのが一般的なアプローチですが、これは多少なりとも手間がかかることと、社内のエージェントを勝手に動かす人などが意図的に観測されないように対応しないこともありえるでしょう。 しかしながら、LLM への呼び出しを LLM Gateway に集約することで、アプリケーション側での計装が不要になり、ガバナンスを高めることにも寄与します。 そこでこの記事では、Pythonアプリ(Google ADKエージェント / google-genai SDK)からのLLM呼び出しを API gateway である Kong経由に切り替え、Kongの組み込みOpenTelemetr プラグインから Langfuse の OTLP/HTTP エンドポイントへ直接トレースを送信するまでの手順をまとめます。 本記事の作成にあたりまして、Langfuse Night #3 の川村さんの登壇内容を参考にさせていただきました。ありがとうございます! https://www.docswell.com/s/shukawam/Z2QJD9-langfuse-and-kong-gateway 検証環境 コンポーネント バージョン 備考 Kong Gateway 3.10 OSS版を利用 Kong plugins bundled ai-proxy, opentelemetry を使用 google-adk 1.x google-genai SDK 1.x Langfuse Cloud OTLP/HTTP endpoint を使用 この記事での検証はネットワーク的にはローカルでの docker compose を前提としています。Kongとアプリは同一Dockerネットワーク内で通信し、Kong自体は外部公開しません。 全体構成 アーキテクチャの全体像は以下の通りです。 User (curl) ↓ MCP Server / ADK Agent (Python) ↓ genai SDK (base_url → Kong) Kong AI Gateway(Docker network 内部) ├── ai-proxy plugin → LLM リクエストを Vertex AI に送信 └── opentelemetry plugin → Langfuse OTLP/HTTP endpoint どのプラグインで Langfuse に送るか Langfuse の Kong 連携ガイド では、kong-plugin-ai-tracing を導入してトレースする手順が「Recommended」として案内されています。 一方で今回は、追加プラグインの導入・管理を増やしたくなかったので、Kong 組み込みの opentelemetry プラグインを使いました。Kong 側は OTLP/HTTP(Protobuf)で送信でき、バックエンド直送も Collector 経由も選べます。 実装手順 細かい実装の手順やConfigはざっくりこちらにまとめました。ご参考までにどうぞ。 Kong 設定 kong.yaml _format_version: "3.0" services: - name: gemini-service # Service 定義上 url は必須だがダミー値。 # ai-proxy が upstream を置き換え、Vertex AI へ直接接続するため転送されない。 url: http://httpbin.konghq.com routes: - name: gemini-route paths: - /gemini plugins: - name: ai-proxy config: route_type: llm/v1/chat llm_format: gemini logging: log_statistics: true auth: gcp_use_service_account: true gcp_service_account_json: '${GCP_SERVICE_ACCOUNT_JSON}' # 本番環境では、サービスアカウント鍵(JSON)の配布・保管・ローテーションが # セキュリティ上の課題・運用負荷になりやすいため、サービスアカウントキーを # 使わない認証を推奨します。今回はあくまで検証用ということでご理解ください。 model: provider: gemini name: gemini-2.5-flash options: gemini: api_endpoint: us-central1-aiplatform.googleapis.com project_id: ${GCP_PROJECT_ID} location_id: ${GCP_LOCATION_ID} - name: opentelemetry config: traces_endpoint: ${LANGFUSE_HOST}/api/public/otel/v1/traces headers: Authorization: "Basic ${LANGFUSE_AUTH_BASE64}" コード側でKongに向ける コード記載例 KONG_BASE_URL = os.getenv("KONG_BASE_URL", "http://kong:8000/gemini")model = Gemini(model="gemini-2.5-flash", base_url=KONG_BASE_URL) client = genai.Client(http_options={"base_url": KONG_BASE_URL}) docker compose(最小構成) docker-compose.yaml services: kong: build: context: . dockerfile: Dockerfile.kong environment: KONG_DATABASE: "off" KONG_DECLARATIVE_CONFIG: /opt/kong/kong.yml KONG_PLUGINS: bundled KONG_PROXY_LISTEN: "0.0.0.0:8000" # 詰まりどころ(後述) KONG_NGINX_PROXY_CLIENT_BODY_BUFFER_SIZE: "8m" LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY} LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY} LANGFUSE_HOST: ${LANGFUSE_HOST:-https://cloud.langfuse.com} GCP_PROJECT_ID: ${GCP_PROJECT_ID} GCP_LOCATION_ID: ${GCP_LOCATION_ID} volumes: - ./credentials/service-account-key.json:/app/credentials/service-account-key.json:ro app: environment: - KONG_BASE_URL= http://kong:8000/gemini - GOOGLE_API_KEY=dummy depends_on: - kong 動作確認 1. Kong の起動確認 docker compose exec kong kong health 2. Kong 経由で Gemini が応答するか docker compose exec kong curl -s -X POST http://localhost:8000/gemini/v1/chat/completions \  -H "Content-Type: application/json" \  -d '{"messages":[{"role":"user","content":"hello"}],"model":"gemini-2.5-flash"}' \  | jq .choices[0].message.content 3. Langfuse にトレースが届くか Langfuse の Traces を開いて確認します。 実行結果 (Traceの比較) 実装できたら、Langfuseの画面で結果を確認してみます。 すると、エージェント側で計装したもの(パターンA) と Kong経由で自動でLangfuseにTraceを飛ばすもの (パターンB) では微妙に確認できるものが異なることに気がつくと思います。 両者とも基本的な要素は全てカバーされており、構造・トークン・モデル名・レイテンシーなどを確認することができますが、パターンB においては、Traceレベルの Input/Outputに情報が入らず、Promptとの紐付けなどもできません。一方でパターンAはエージェント側で計装しますので、もちろんどのようにでも表示可能です。 パターンA. Kong を通さないもの (エージェント側で計装) トップレベルのTraceにもちゃんと Input/Output が入ってる パターンB. Kong経由 (エージェント側で実装なし) 大体の情報は入ってるが、トップレベルのTraceには Input/Output は入らない おまけ: 実装時の注意メモ 1. リクエストボディが大きいと ai-proxy が 400 を返す エージェントがツールを使うと、2回目以降の LLM 呼び出しで会話履歴+ツール結果が乗り、ボディが急に大きくなります。Nginx 側のデフォルトが小さいと、ボディがテンポラリに逃げたりして ai-proxy の処理が崩れ、次のエラーに当たりました。 400 "request body doesn't contain valid prompts" ツール不使用だと通るが、ツール使用だと落ちる(この分岐が地味に厄介) 対策:先ほどの compose ファイルにもあった通り、Nginx のバッファサイズを上げます。 KONG_NGINX_PROXY_CLIENT_BODY_BUFFER_SIZE: "8m" 2. preserve 前提で組むと 3.11 以降で詰まる preserve は 3.11.0.0 で deprecated です。将来削除される前提なので、早めに “新しい route_type”(今回なら llm/v1/chat)に寄せておくのが安全かと思われます。 まとめ: 今回は、Kong AI GatewayとLangfuseを用いて、アプリケーション側に計装SDKを組み込まずにLLMのオブザーバビリティを確保する手順をご紹介しました。 ゲートウェイ層にLLMの呼び出しとトレース送信の責務を集約することで、アプリケーション側のコードを汚さずに素早く観測を始められるのは、この構成の大きな強みです。

bottom of page