LangfuseのLLMトレースをClickHouseで長期分析する
LangfuseのObservationとScoreをClickHouseへ正規化し、モデル別コスト、prompt version別の品質推移、再試行によるコスト増をSQLで確認する検証ログ。
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つです。
- Observation行とScore行をClickHouseへ投入できること
trace_idでObservationとScoreをJOINできること- model別にcost、token、latency、quality、success rateを集計できること
- prompt version別の日次推移を見られること
- 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_name、prompt_version、model、start_time、trace_idをORDER 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_score、task_success、safety_risk、human_feedbackのように評価軸が変わります。最初から縦持ちにしておくほうが、あとでScore名を増やしやすいと判断しました。
ClickHouseで見えた集計結果
実行結果は次のレポートにまとめました。

まず、model別のcostとqualityを同じ表に出しました。
| model | traces | cost_usd | total_tokens | avg_latency_sec | avg_quality_score | success_rate |
|---|---|---|---|---|---|---|
| claude-3-5-sonnet | 3 | 0.0686 | 7470 | 5.073 | 0.77 | 0.667 |
| gemini-1.5-pro | 3 | 0.0333 | 5710 | 2.723 | 0.817 | 1 |
| gpt-4o-mini | 4 | 0.0308 | 7360 | 2.765 | 0.785 | 0.75 |
この表だけを見ると、claude-3-5-sonnetが高コストで、latencyも長く見えます。ただし、ここで「このmodelが悪い」とは言えません。後で外れ値を見ると、1件の再試行Traceが平均を引き上げていました。
次に、prompt version別の日次推移です。
| day | prompt_version | traces | cost_usd | avg_latency_sec | avg_quality_score |
|---|---|---|---|---|---|
| 2026-07-01 | pricing-v1 | 2 | 0.0172 | 3.405 | 0.7 |
| 2026-07-02 | pricing-v1 | 2 | 0.0312 | 3.41 | 0.79 |
| 2026-07-03 | pricing-v2 | 2 | 0.0255 | 2.655 | 0.895 |
| 2026-07-04 | pricing-v2 | 2 | 0.0409 | 5.42 | 0.71 |
| 2026-07-05 | pricing-v2 | 1 | 0.0067 | 2.06 | 0.86 |
| 2026-07-06 | pricing-v2 | 1 | 0.0112 | 2.61 | 0.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_version | generations | retried_generations | retry_rate | avg_latency_sec | cost_usd |
|---|---|---|---|---|---|
| pricing-v1 | 4 | 1 | 0.25 | 3.408 | 0.0484 |
| pricing-v2 | 6 | 1 | 0.167 | 3.47 | 0.0843 |
pricing-v2は再試行率だけなら下がっています。一方で、costの総額は増えています。件数が増えていること、外れ値Traceが混ざっていること、model単価が違うことを分けて見る必要があります。
最後に、外れ値候補です。
| trace_id | model | prompt_version | cost_usd | latency_sec | quality_score | retry_attempt |
|---|---|---|---|---|---|---|
| trace-hermes-008 | claude-3-5-sonnet | pricing-v2 | 0.0302 | 8.3 | 0.58 | 2 |
| trace-hermes-002 | gpt-4o-mini | pricing-v1 | 0.0108 | 4.37 | 0.62 | 1 |
ここまで絞れれば、Langfuse UIへ戻って該当Traceを読む対象が明確になります。ClickHouseで本文まで見る必要はありません。trace_id、model、prompt version、Score、retry attempt、output hashだけで「見るべきTrace」を選べます。
SQLは調査の入口を作る
代表的なSQLは次の形です。Observationをmodel別に集計し、Scoreテーブルからquality_scoreとtask_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側ではfromStartTimeとtoStartTimeで範囲を絞り、必要なfield groupだけを選べます。特にusage、metrics、trace_context、model、metadataあたりを最初の候補にすると、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_observations | generation、span、eventの時系列行 |
llm_trace_scores | traceやobservationに紐づく評価値 |
llm_trace_daily_rollup | 日次のcost、latency、quality、retry率 |
日次rollupは最初から作らなくても、クエリが重くなってからMaterialized Viewにしてよいです。先に固定すべきなのは、trace_id、observation_id、prompt_version、model、environment、releaseの命名です。ここが揺れると、後からどれだけ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ログを長く使うなら、この分離はかなり効きます。