---
title: "LangfuseのLLMトレースをClickHouseで長期分析する"
description: "LangfuseのObservationとScoreをClickHouseへ正規化し、モデル別コスト、prompt version別の品質推移、再試行によるコスト増をSQLで確認する検証ログ。"
lang: "ja"
canonical: "https://llm-lab.dev/posts/langfuse-clickhouse-trace-analytics/"
source: "https://llm-lab.dev/posts/langfuse-clickhouse-trace-analytics.md"
publishedAt: "2026-07-07"
updatedAt: "2026-07-07"
category: "Clickhouse"
tags:
  - "clickhouse"
  - "langfuse"
  - "llmops"
  - "observability"
  - "agentops"
---

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

import LinkCard from "../../components/LinkCard.astro";

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で読める形に落とす設計です。

<LinkCard
  href="https://llm-lab.dev/posts/clickhouse-001-claude-mcp/"
  title="ClickHouse × Claude MCPで実現する自然言語データ分析"
  description="Claude DesktopからClickHouse MCP経由でログデータを自然言語分析した検証記事。"
  siteName="LLM Lab"
  image="/images/posts/clickhouse-001-claude-mcp/heroImage.webp"
/>

<LinkCard
  href="https://llm-lab.dev/posts/llm-loop-engineering-langfuse-observability/"
  title="LangfuseでLLMループを観測して、何が効いたのかをTraceとScoreで追う"
  description="LLMループのTrace、Observation、Score分解をLangfuseへ送信して確認した検証記事。"
  siteName="LLM Lab"
  image="/images/posts/llm-loop-engineering-langfuse-observability/loop-trace-report.webp"
/>

## LangfuseとClickHouseで役割を分ける

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

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

一方で、[API & Data Platform](https://langfuse.com/docs/api-and-data-platform/overview)を見ると、Langfuseは外部ワークフローやData Warehouseへつなぐ前提も持っています。row-levelのspan、generation、eventを取り出すなら[Observations API v2](https://langfuse.com/docs/api-and-data-platform/features/observations-api)、集計済みのcost、usage、latency、scoreを見るなら[Metrics API v2](https://langfuse.com/docs/metrics/features/metrics-api)、大量データを定期的に吐き出すなら[Blob Storage Export](https://langfuse.com/docs/api-and-data-platform/features/export-to-blob-storage)という選択肢があります。

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

| 目的 | 使う場所 |
|---|---|
| 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を実行します。

```bash
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](https://clickhouse.com/docs/interfaces/formats/JSONEachRow)を使いました。ClickHouseのドキュメントでは、行ごとに改行区切りのJSON objectとして扱う形式です。Langfuse APIから取った行データをバッチで流すには、この形が扱いやすいです。

テーブルエンジンは[MergeTree](https://clickhouse.com/docs/engines/table-engines/mergetree-family/mergetree)にしました。今回のデータ量は小さいですが、Traceログは時系列で増えるため、`start_time`でpartitionし、検索でよく使う`trace_name`、`prompt_version`、`model`、`start_time`、`trace_id`を`ORDER BY`へ入れています。

```sql
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で見えた集計結果

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

![Langfuse風のObservationとScoreをClickHouseへ投入し、モデル別コスト、prompt version別推移、再試行率、外れ値候補を集計したレポート](/images/posts/langfuse-clickhouse-trace-analytics/report-full.webp)

まず、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`を戻しています。

```sql
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ログを長く使うなら、この分離はかなり効きます。
