つれづれなる Agent OPS
つれづれなる Agent OPS
Clickhouse

LangfuseのLLMトレースをClickHouseで長期分析する

LangfuseのObservationとScoreをClickHouseへ正規化し、モデル別コスト、prompt version別の品質推移、再試行によるコスト増をSQLで確認する検証ログ。

Xでシェア
Markdown を表示

LangfuseにTraceを貯め始めると、最初はUIを見るだけでかなり助かります。どのpromptを投げたか、どのmodelが返したか、token、cost、latency、ScoreがTrace単位で並ぶ。失敗した1件を追うには、これで十分です。

ただ、Traceが増えると別の問題が出ます。

30日分を横に見たい。prompt versionごとの品質変化も見たい。再試行でcostが増えているのか、model単価で増えているのかも分けたい。

はいはい、これは個別Traceを開いて見る話ではない。

Langfuseをやめて分析基盤を自作したいわけではありません。むしろ逆です。Langfuseを一次観測の場所として使い、長期・横断分析だけをClickHouseへ逃がすほうが、運用としては自然に感じました。

今回は、LangfuseのObservation API風のfixtureをClickHouseへ正規化し、モデル別コスト、prompt version別の品質推移、再試行によるコスト増、外れ値候補をSQLで確認しました。fixtureは公開記事用に作ったもので、実在の顧客データや本番Traceではありません。検証したいのは、データの傾向そのものではなく、Langfuseの観測データをClickHouseで読める形に落とす設計です。

LLM Lab ClickHouse × Claude MCPで実現する自然言語データ分析 Claude DesktopからClickHouse MCP経由でログデータを自然言語分析した検証記事。 https://llm-lab.dev/posts/clickhouse-001-claude-mcp/ LLM Lab LangfuseでLLMループを観測して、何が効いたのかをTraceとScoreで追う LLMループのTrace、Observation、Score分解をLangfuseへ送信して確認した検証記事。 https://llm-lab.dev/posts/llm-loop-engineering-langfuse-observability/

LangfuseとClickHouseで役割を分ける

LangfuseのObservability Overviewでは、LLMアプリケーションのrequest、prompt、response、token usage、latency、toolやretrievalの処理をTraceとして追う考え方が説明されています。さらに、Evaluation Overviewでは、live trace、dataset、experiment、Scoreを使って評価を回す流れが整理されています。

この範囲はLangfuseの得意領域です。1つの失敗Traceを開き、input、output、Observation、Scoreを見ながら原因を追う。あるいは、評価Scoreの根拠を見て、人間がannotationする。ここはClickHouseで置き換えたい場所ではありません。

一方で、API & Data Platformを見ると、Langfuseは外部ワークフローやData Warehouseへつなぐ前提も持っています。row-levelのspan、generation、eventを取り出すならObservations API v2、集計済みのcost、usage、latency、scoreを見るならMetrics API v2、大量データを定期的に吐き出すならBlob Storage Exportという選択肢があります。

今回の使い分けは、次の形です。

目的使う場所
1件のTraceを読むLangfuse UI
Scoreの根拠を見るLangfuse UI
直近の集計をAPIで見るLangfuse Metrics API
長期保持や社内データとのJOINをするClickHouse
outlier候補だけを抽出して個別Traceへ戻るClickHouseからtrace_idでLangfuseへ戻る

ClickHouseを足す理由は、Langfuseを薄くするためではありません。Traceの一次情報はLangfuseに置き、ClickHouseでは「どのTraceを見るべきか」を絞る。そう考えると、役割分担がかなりすっきりします。

検証で作ったもの

検証用に、Langfuse Observations API v2のレスポンスに近いfixtureを用意しました。10件のGeneration Observationと、20件のScoreを持つ小さなデータです。題材はHermes Agent風の価格判断Traceですが、公開用に作ったfixtureなので、prompt本文、completion本文、ユーザー入力、固有の業務情報は含めていません。

使った検証スクリプトは自作です。Langfuse公式CLIでもClickHouse公式CLIでもありません。入力はfixtureのJSON、出力はClickHouseへ投入するJSONEachRow、DDL、SQL、HTMLレポートです。Docker上のClickHouseへ接続する場合は、同じJSONEachRowをHTTP API経由で投入し、SQLを実行します。

npm run clickhouse:up
npm run verify:clickhouse
npm run clickhouse:down

verify:clickhouse が確認することは、次の5つです。

  1. Observation行とScore行をClickHouseへ投入できること
  2. trace_id でObservationとScoreをJOINできること
  3. model別にcost、token、latency、quality、success rateを集計できること
  4. prompt version別の日次推移を見られること
  5. cost、quality、retry attemptから外れ値候補を抽出できること

検証中に地味に詰まったのは、ClickHouse Docker imageのdefaultユーザーです。最初は検証用なので空パスワードでよいと思い、CLICKHOUSE_PASSWORDを空にして起動しました。するとentrypoint側では未設定扱いになり、defaultユーザーのネットワークアクセスが無効化されました。HTTP APIは401を返し、SQL以前で止まります。

え、そこからか。

最終的には、検証用の固定パスワードを明示し、HTTP APIのURLにもuserとpasswordを渡す形にしました。これはClickHouse分析そのものの本題ではありませんが、記事用の再現環境ではかなり大事です。DB接続が曖昧だと、Langfuseのデータモデルを疑う前に検証が止まります。

ClickHouseへの投入形式にはJSONEachRowを使いました。ClickHouseのドキュメントでは、行ごとに改行区切りのJSON objectとして扱う形式です。Langfuse APIから取った行データをバッチで流すには、この形が扱いやすいです。

テーブルエンジンはMergeTreeにしました。今回のデータ量は小さいですが、Traceログは時系列で増えるため、start_timeでpartitionし、検索でよく使うtrace_nameprompt_versionmodelstart_timetrace_idORDER BYへ入れています。

CREATE TABLE llm_trace_observations
(
  id String,
  trace_id String,
  trace_name LowCardinality(String),
  observation_name String,
  observation_type LowCardinality(String),
  start_time DateTime64(3, 'UTC'),
  prompt_version LowCardinality(String),
  model LowCardinality(String),
  input_tokens UInt32,
  output_tokens UInt32,
  total_tokens UInt32,
  total_cost Decimal(18, 9),
  latency_ms UInt32,
  retry_attempt UInt8,
  success Bool,
  redaction_mode LowCardinality(String),
  output_hash String,
  tags Array(String)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(start_time)
ORDER BY (trace_name, prompt_version, model, start_time, trace_id);

Scoreは別テーブルにしました。ObservationにScore列を横持ちで増やす案もありますが、Scoreは種類が増えやすく、quality_scoretask_successsafety_riskhuman_feedbackのように評価軸が変わります。最初から縦持ちにしておくほうが、あとでScore名を増やしやすいと判断しました。

ClickHouseで見えた集計結果

実行結果は次のレポートにまとめました。

Langfuse風のObservationとScoreをClickHouseへ投入し、モデル別コスト、prompt version別推移、再試行率、外れ値候補を集計したレポート

まず、model別のcostとqualityを同じ表に出しました。

modeltracescost_usdtotal_tokensavg_latency_secavg_quality_scoresuccess_rate
claude-3-5-sonnet30.068674705.0730.770.667
gemini-1.5-pro30.033357102.7230.8171
gpt-4o-mini40.030873602.7650.7850.75

この表だけを見ると、claude-3-5-sonnetが高コストで、latencyも長く見えます。ただし、ここで「このmodelが悪い」とは言えません。後で外れ値を見ると、1件の再試行Traceが平均を引き上げていました。

次に、prompt version別の日次推移です。

dayprompt_versiontracescost_usdavg_latency_secavg_quality_score
2026-07-01pricing-v120.01723.4050.7
2026-07-02pricing-v120.03123.410.79
2026-07-03pricing-v220.02552.6550.895
2026-07-04pricing-v220.04095.420.71
2026-07-05pricing-v210.00672.060.86
2026-07-06pricing-v210.01122.610.85

pricing-v2に変えた直後の2026-07-03は、qualityが0.895まで上がり、latencyも短くなっています。しかし翌日の2026-07-04だけ、costとlatencyが跳ね、qualityが0.71まで落ちています。

ここが見えると、調査の入り方が変わります。「pricing-v2は成功か失敗か」ではなく、「pricing-v2は概ね良いが、2026-07-04の外れ値Traceを先に見る」と判断できます。個別Traceを開くのは、その後でよいです。

再試行率も見ました。

prompt_versiongenerationsretried_generationsretry_rateavg_latency_seccost_usd
pricing-v1410.253.4080.0484
pricing-v2610.1673.470.0843

pricing-v2は再試行率だけなら下がっています。一方で、costの総額は増えています。件数が増えていること、外れ値Traceが混ざっていること、model単価が違うことを分けて見る必要があります。

最後に、外れ値候補です。

trace_idmodelprompt_versioncost_usdlatency_secquality_scoreretry_attempt
trace-hermes-008claude-3-5-sonnetpricing-v20.03028.30.582
trace-hermes-002gpt-4o-minipricing-v10.01084.370.621

ここまで絞れれば、Langfuse UIへ戻って該当Traceを読む対象が明確になります。ClickHouseで本文まで見る必要はありません。trace_id、model、prompt version、Score、retry attempt、output hashだけで「見るべきTrace」を選べます。

SQLは調査の入口を作る

代表的なSQLは次の形です。Observationをmodel別に集計し、Scoreテーブルからquality_scoretask_successを戻しています。

WITH trace_quality AS
(
  SELECT
    trace_id,
    avgIf(score_value, score_name = 'quality_score') AS quality_score,
    maxIf(score_value, score_name = 'task_success') AS task_success
  FROM llm_trace_scores
  GROUP BY trace_id
)
SELECT
  model,
  countDistinct(o.trace_id) AS traces,
  round(sum(o.total_cost), 6) AS cost_usd,
  sum(o.total_tokens) AS total_tokens,
  round(avg(o.latency_ms) / 1000, 3) AS avg_latency_sec,
  round(avg(q.quality_score), 3) AS avg_quality_score,
  round(avg(q.task_success), 3) AS success_rate
FROM llm_trace_observations AS o
LEFT JOIN trace_quality AS q ON o.trace_id = q.trace_id
WHERE o.observation_type = 'GENERATION'
GROUP BY model
ORDER BY cost_usd DESC;

このSQLの目的は、Langfuseの代わりに根本原因を確定することではありません。どのmodel、どのprompt version、どの日、どのTrace IDを見るべきかを絞ることです。

Langfuse UIは「なぜその1件が失敗したか」を見る場所です。ClickHouseは「どの1件を見に行くか」を決める場所です。この順番にすると、Traceが増えても調査の入口が散らかりにくくなります。

本文を保存しない設計も先に試す

今回のfixtureでは、prompt本文とcompletion本文を保存していません。代わりに、output_hash、token、cost、latency、retry attempt、Scoreだけを入れました。

これは万能ではありません。生成文そのものの言い回し、業務文脈、ユーザー入力の誤解を調べるには、本文が必要な場面があります。一方で、最初から本文をClickHouseへ複製すると、保存範囲、保持期間、権限、マスキング、削除依頼対応が重くなります。

今回の小さな検証では、本文なしでも次の判断はできました。

  • costが高いmodelを見つける
  • prompt version変更後のquality推移を見る
  • retry attemptがcostを押し上げているTraceを見つける
  • low qualityかつhigh costなTrace IDを抽出する

つまり、ClickHouse側に必ずしも全文を置かなくても、調査対象の候補は作れます。本文が必要になったら、trace_idでLangfuse UIへ戻る。まずはこの分担から始めるほうが、公開範囲を広げすぎずに済みます。

運用に入れるならExport経路を選ぶ

今回の検証はfixtureから始めましたが、実運用では取得経路を選ぶ必要があります。

小さく始めるなら、Observations API v2で期間を区切って取得し、ClickHouseへJSONEachRowで入れる形が分かりやすいです。API側ではfromStartTimetoStartTimeで範囲を絞り、必要なfield groupだけを選べます。特にusagemetricstrace_contextmodelmetadataあたりを最初の候補にすると、costとlatencyの分析に使いやすいです。

一方で、日次や時間単位で大量に出すならBlob Storage Exportを検討したほうがよいです。公式ドキュメントでは、traces、observations、enriched observations、scoresをS3、GCS、Azure Blob StorageなどへスケジュールExportできると説明されています。Cloudのプランやself-hosted環境によって利用可否が変わるため、ここは導入前に確認が必要です。

ClickHouse側では、最初から複雑なData Warehouseにしなくてよいです。まずは次の3テーブル程度で十分だと感じました。

テーブル役割
llm_trace_observationsgeneration、span、eventの時系列行
llm_trace_scorestraceやobservationに紐づく評価値
llm_trace_daily_rollup日次のcost、latency、quality、retry率

日次rollupは最初から作らなくても、クエリが重くなってからMaterialized Viewにしてよいです。先に固定すべきなのは、trace_idobservation_idprompt_versionmodelenvironmentreleaseの命名です。ここが揺れると、後からどれだけSQLを書いても調査単位がぶれます。

ClickHouseは二次分析の置き場にする

LangfuseのTraceをClickHouseで分析してみると、役割分担がはっきりしました。

Langfuseは、LLMアプリケーションの一次観測に向いています。prompt、response、Observation、ScoreをTrace単位で追えるので、失敗した1件を読むには強いです。

ClickHouseは、長期・横断分析に向いています。model別コスト、prompt version別の品質推移、再試行率、外れ値候補の抽出のように、Traceを横に並べる調査で効きます。

今回の検証では、10件のObservationと20件のScoreをClickHouseへ投入し、モデル別コスト、prompt version別推移、再試行圧、外れ値候補をSQLで確認しました。fixtureなので、この数値自体に一般的な意味はありません。しかし、trace_idを軸にLangfuseとClickHouseを行き来する形は、そのまま運用設計へ持ち込めそうです。

個別Traceを読む場所と、Trace群から異常候補を選ぶ場所を分ける。LLMOpsログを長く使うなら、この分離はかなり効きます。

DUOps

Author

DUOps(デュオプス)

LLMOps、Agent、MCP、Langfuse、Cloudflare 周辺の実装と運用を、個人で試しながら記録しています。

Xを見る

コメント

Related