1. はじめに
以前検証もしてますが、LLM オブザーバビリティツールの Langfuse が、トレースの保存先に列指向データベースの ClickHouse を使っている、という話が出発点です。アプリの画面だけでなく、トレースを貯めている ClickHouse のテーブルを直接確認したら、どんな構造になっているのか見てみたい。MCP で DB 接続を試してきた延長で、ClickHouse × AI のもくもく会のお題として、お試しで確認してみました。
Langfuse はセルフホスト版(v3)で、トレース・オブザベーション・スコアの保存に ClickHouse を採用しています。公式ドキュメントにもこう書かれています。
ClickHouse is the main OLAP storage solution within Langfuse for our Trace, Observation, and Score entities.
(ClickHouse (self-hosted) — Langfuse Docs)
今回は、ローカルに立てた Langfuse へ Langfuse SDK 経由で Gemini の実トレースを 100 件流し込み、その裏の ClickHouse にあるテーブル(traces / observations)を直接 SQL と自然言語で集計します。observations のスキーマは、トークンやコストを Map 型で持ち、エンジンが ReplacingMergeTree になっているなど特徴があります。そこも含めて確認していきます。
1.1. 今回やること
| # | ゴール | OKと判断する状態 |
|---|---|---|
| 1 | Langfuse に実トレースを貯める | Langfuse SDK 経由で Gemini 実呼び出しが default.observations に入り、件数が返る |
| 2 |
observations を直接集計できる |
usage_details Map・ReplacingMergeTree を踏まえた SQL で、コスト・レイテンシ・相関が取れる |
| 3 | 自然言語からも分析できる | Claude Desktop に MCP でつなぎ、日本語の質問から集計 SQL を生成・実行できる |
1.2. 結論(先に要点)
-
traces/observationsを直接集計できた。ただしトークンはusage_detailsMap、コストはcost_detailsMap +total_costで持ち、エンジンがReplacingMergeTreeなので集計にはFINALでの重複排除が要る - 出力トークン数とレイテンシは強い正の相関(0.984)。処理種別で割っても全部 +0.96〜0.99 で符号は割れない。単一モデルでは出力が長いほど素直に遅い
- この 100 件はレイテンシのテールが目立たず、p99/p50 ≈ 2.5 倍に収まった(「LLM はテールが重い」はモデルや混雑など条件次第と考えられる)
- Claude Desktop に MCP でつなぐと、日本語の質問からこの集計を行える
2. 検証環境
| 項目 | 値 |
|---|---|
| Langfuse | セルフホスト v3(langfuse/langfuse:3 / langfuse-worker:3)/ Docker Compose |
| ClickHouse | Langfuse 同梱(clickhouse/clickhouse-server・実体 26.3.9.8) |
| トレース投入 | Langfuse Python SDK 4.9 + google-genai |
| LLM | Gemini 3.1 Flash Lite |
| AI クライアント | Claude Desktop + mcp-clickhouse
|
| データ |
default.observations に GENERATION 100 件(environment='mokumoku') |
3. 構成とデータの作り方
3.1. 全体構成
流れは、Gemini を実呼び出しして Langfuse SDK でトレースを送り、Langfuse の worker が ClickHouse に取り込み、その ClickHouse を直接(または Claude Desktop の MCP 経由で)集計する、という形です。
3.2. Langfuse にトレースを送る
トレースは、5 種類の処理(チャット応答・要約・RAG 回答・分類・ツール呼び出し相当)で Gemini を呼び、その結果を Langfuse SDK で Langfuse に送って作りました。SDK の observation(generation)コンテキストの中で Gemini を呼ぶと、開始・終了時刻が自動で記録され、それがそのままレイテンシになります。
with langfuse.start_as_current_observation(
name=category, as_type="generation", model="gemini-3.1-flash-lite", input=prompt
):
resp = gclient.models.generate_content(model=MODEL, contents=prompt)
u = resp.usage_metadata
langfuse.update_current_generation(
output=resp.text,
usage_details={"input": u.prompt_token_count, "output": u.candidates_token_count,
"total": u.total_token_count},
cost_details={"input": cin, "output": cout, "total": cin + cout}, # 公式単価で算出
)
cost_details をわざわざ渡しているのには理由があります。Langfuse はモデルの料金表が登録されていれば total_cost を自動計算しますが、新しい gemini-3.1-flash-lite はこのローカル Langfuse に料金が無く、何もしないと total_cost が NULL になります。そこで実トークン数に公式の有償ティア単価(入力 0.25 ドル / 出力 1.50 ドル、いずれも 100 万トークンあたり。Gemini API pricing)を掛けて cost_details として渡し、実 total_cost を埋めています。ここでの金額は公式の有償ティア単価で計算した換算値です。
投入分だけ後で絞れるよう、Langfuse クライアントの environment="mokumoku" を指定しています(observations.environment に入ります)。これで 100 件を流しました。
なお実データは Gemini 3.1 Flash Lite の単一モデルで、複数モデルの比較は今回はしていません。
3.3. observations のスキーマを確認する
observations テーブルの要点を抜き出すと次のとおりです。
CREATE TABLE default.observations
(
`id` String,
`trace_id` String,
`project_id` String,
`environment` LowCardinality(String) DEFAULT 'default',
`type` LowCardinality(String), -- GENERATION / SPAN / TOOL / AGENT ...
`start_time` DateTime64(3),
`end_time` Nullable(DateTime64(3)), -- レイテンシは end - start
`name` String,
`provided_model_name` Nullable(String), -- モデル名
`usage_details` Map(LowCardinality(String), UInt64), -- トークン(input/output/total)
`cost_details` Map(LowCardinality(String), Decimal(18, 12)),-- コスト内訳
`total_cost` Nullable(Decimal(18, 12)),
`event_ts` DateTime64(3),
`is_deleted` UInt8,
...
)
ENGINE = ReplacingMergeTree(event_ts, is_deleted)
PARTITION BY toYYYYMM(start_time)
PRIMARY KEY (project_id, type, toDate(start_time))
ORDER BY (project_id, type, toDate(start_time), id)
押さえどころは 3 点です。
-
トークンもコストも Map 型:
usage_details['output']のようにキー指定で取り出す。可変なメトリクスを 1 列で持てる設計 -
エンジンが
ReplacingMergeTree(event_ts, is_deleted): 同じidの更新は新しい行として書き、バックグラウンドのマージで古い行が消える(upsert)。マージが済むまでは新旧の行が両方残るので、FINAL(またはargMax)を付けないと同じレコードを重複して数えることがある -
typeの階層: 1 トレースの中に GENERATION / SPAN / TOOL / AGENT が並ぶ。LLM 呼び出しだけ見るならtype='GENERATION'で絞る
traces テーブルも同じく ReplacingMergeTree で、user_id / session_id / tags などトレース単位の属性を持ちます。
3.4. 投入したデータの内訳
投入後、FINAL を付けてカーディナリティを確認しました。
SELECT count() AS obs, uniqExact(trace_id) AS traces,
uniqExact(provided_model_name) AS models, uniqExact(name) AS names
FROM default.observations FINAL
WHERE environment = 'mokumoku' AND is_deleted = 0 AND type = 'GENERATION';
-- obs=100, traces=100, models=1, names=5
100 件すべて GENERATION、1 トレース 1 generation、モデルは 1 種類、処理種別(name)は 5 種類でした。FINAL なしの素の count() も 100 で、今回は重複行は発生していませんでしたが、ReplacingMergeTree の性質上、集計クエリには FINAL を付けてあります。
なお user_id は本物では traces 側の属性で、今回 SDK で設定していないため、ユーザー別の集計は対象外としています。
3.5. Claude Desktop / clickhouse-client でつなぐ
直接 SQL を叩くなら、Langfuse 同梱の ClickHouse コンテナに clickhouse-client でつなぎます。自然言語で聞くなら、Claude Desktop に mcp-clickhouse を登録し、ホストをこのローカル ClickHouse(localhost:8123)に向けます。
4. 手順:自然言語と SQL で聞く
Claude Desktop に MCP でつないだうえで、日本語でそのまま聞いてみます。
mokumoku 環境で流した LLM 呼び出しを、処理の種類ごとに合計コストが高い順で、回数も一緒に
この一文だけで、Claude は list_tables でスキーマを把握し、ReplacingMergeTree の FINAL・is_deleted = 0・type = 'GENERATION' の絞り込み・Decimal 型のキャストまで含めた SQL を生成して実行しました。
生成された SQL は次のとおりで、このテーブル特有の書き方(FINAL での重複排除・is_deleted・type 絞り込み・Decimal キャスト)を押さえています。
SELECT name, count() AS calls, round(sum(toFloat64(total_cost)), 6) AS total_cost_usd
FROM default.observations FINAL
WHERE environment = 'mokumoku' AND is_deleted = 0 AND type = 'GENERATION'
GROUP BY name ORDER BY total_cost_usd DESC
以降の集計(5 章)も同じ要領です。トークンは usage_details['output'] のように Map をキー指定し、レイテンシは end_time - start_time で取り出します。
5. 計測結果
5.1. コスト(処理種別別)
cost_details 経由で入った total_cost を集計します(4 章で Claude が生成したクエリに、1 回平均 avg(toFloat64(total_cost)) を加えたもの)。100 件の合計は約 0.0239 ドル(入力 4,264 トークン / 出力 15,211 トークン)。出力単価が入力の 6 倍で出力トークンが多いため、コストは出力側でほぼ決まります。
| 処理種別 | 呼び出し回数 | 合計コスト (USD・換算) | 1回平均 (USD・換算) |
|---|---|---|---|
| tool-call | 22 | 0.006693 | 0.000304 |
| summarize | 27 | 0.005309 | 0.000197 |
| rag-answer | 22 | 0.004895 | 0.000223 |
| chat-completion | 14 | 0.004800 | 0.000343 |
| classify | 15 | 0.002186 | 0.000146 |
5.2. レイテンシ分布
レイテンシは end_time - start_time で出します。
SELECT round(quantileExact(0.50)(dateDiff('ms', start_time, end_time))) AS p50,
round(quantileExact(0.95)(dateDiff('ms', start_time, end_time))) AS p95,
round(quantileExact(0.99)(dateDiff('ms', start_time, end_time))) AS p99,
min(dateDiff('ms', start_time, end_time)) AS min_ms,
max(dateDiff('ms', start_time, end_time)) AS max_ms
FROM default.observations FINAL
WHERE environment = 'mokumoku' AND is_deleted = 0 AND type = 'GENERATION';
| 指標 | 値 (ms) |
|---|---|
| 最小 | 516 |
| p50(中央値) | 994 |
| p95 | 2,304 |
| p99 | 2,522 |
| 最大 | 2,522 |
中央値は約 1.0 秒、p99 でも約 2.5 秒。この 100 件はテールが目立たず、いちばん遅い呼び出しでも 2.5 秒でした。
5.3. 1分刻みの推移
SELECT toStartOfMinute(start_time) AS minute, count() AS calls,
round(avg(dateDiff('ms', start_time, end_time))) AS avg_lat_ms
FROM default.observations FINAL
WHERE environment = 'mokumoku' AND is_deleted = 0 AND type = 'GENERATION'
GROUP BY minute ORDER BY minute;
呼び出しは約 9 分(10:28〜10:36)に分布し、毎分 7〜12 回、平均レイテンシも 996〜1,416ms とおおむね安定していました。
5.4. エラー率
level='ERROR' で数えます。今回は 100 件すべて成功で、エラー率は 0% でした。
5.5. 出力トークンとレイテンシの相関
usage_details['output'] とレイテンシの相関を取ります。
SELECT round(corr(usage_details['output'], dateDiff('ms', start_time, end_time)), 3) AS corr_overall
FROM default.observations FINAL
WHERE environment = 'mokumoku' AND is_deleted = 0 AND type = 'GENERATION';
-- corr_overall = 0.984
全体で 0.984 と強い正の相関でした。処理種別で割っても、符号は割れず全部プラスです。
| 処理種別 | 件数 | 相関係数 | 平均出力トークン | 平均レイテンシ (ms) |
|---|---|---|---|---|
| tool-call | 22 | 0.993 | 198 | 1,296 |
| rag-answer | 22 | 0.991 | 140 | 1,141 |
| chat-completion | 14 | 0.989 | 225 | 1,446 |
| classify | 15 | 0.969 | 91 | 993 |
| summarize | 27 | 0.960 | 121 | 1,069 |
5.6. 成果物:ダッシュボード
ここまでの集計をもとに、単一 HTML のダッシュボードにまとめました(Chart.js)。コスト・レイテンシ分布・時系列・散布図が 1 枚に収まっています。
6. 考察
6.1. 出力が長いほど素直に遅い
出力トークン数とレイテンシは全体 0.984、処理種別で割っても 0.96〜0.99 と一貫して強い正でした。単一モデルでストリーミング生成する以上、応答時間は出力トークン数にほぼ比例します。「種別ごとに符号が割れて全体で打ち消し合う」ような現象は起きず、素直な結果でした。
6.2. この計測ではテールは目立たなかった
p99 は 2,522ms(p99/p50 ≈ 2.5 倍)で、今回の 100 件はテールが目立ちませんでした。「LLM はテールが重い」はモデルや混雑など条件次第と考えられます。
6.3. スキーマの設計と、読むときの注意
observations はトークン・コストを Map 型で持ち、エンジンが ReplacingMergeTree でした。可変なメトリクスを Map で 1 列にまとめ、更新は event_ts の新しい行として書き、バックグラウンドのマージで古い行が消える設計です。マージが済むまでは新旧が両方残るため、読むときに FINAL(または argMax(event_ts))を付けないと、同じレコードを重複して数えることがあります。
6.4. 自然言語でもスキーマに合った SQL を生成できた
4 章のとおり、素直な日本語から Claude が list_tables でスキーマを見て、FINAL・type 絞り込み・Decimal キャスト込みの SQL を生成しました。Map やマージエンジン特有の書き方も、スキーマを参照できる MCP 経由なら吸収してくれました(自然言語で確かめたのはコスト集計まで。Map ベースの相関は SQL を直接書いて取得しています)。
6.5. 補足:Langfuse Cloud なら UI から自然言語で絞り込める
今回はセルフホストで ClickHouse を直接見ましたが、Langfuse Cloud を使うと UI 側に便利な機能があります。
- Natural Language Filtering(2025-09-30・beta): 「レイテンシ 2 秒超」「本番のエラー」のように、日本語でトレースのフィルタを作れる。SQL を書かず画面内で絞り込める(changelog)
- ClickHouse の運用が要らない(マネージド)。集計やダッシュボードは Langfuse の画面と Query API / SDK で完結する
セルフホスト(本記事)は ClickHouse を自分で持つぶん、今回のように裏のテーブルを直接 SQL / MCP で確認できる、という違いです。
7. まとめ
| 観点 | 結果 |
|---|---|
| スキーマの直接集計 |
traces / observations をそのまま SQL で集計できた |
| スキーマの癖 | トークン/コストは Map、エンジンは ReplacingMergeTree(集計に FINAL が要る) |
| レイテンシ | p50 約1.0秒・p99 約2.5秒。今回はテールが目立たない |
| 相関 | 出力トークン×レイテンシは強い正の相関(全体 0.984/種別別も +0.96〜0.99) |
| 自然言語での分析 | 素直な日本語から、Claude が FINAL・type 絞り込み・Decimal キャスト込みの SQL を生成・実行できた |
Langfuse のスキーマを直接確認すると、Map によるメトリクス格納や ReplacingMergeTree による upsert といった設計と、その読み方(FINAL での重複排除)まで分かりました。
8. もくもく会の感想
もくもく会でアプリを作ろう、という会だったにもかかわらずインフラ系のデータを確認する、みたいなお題にしてしまったのは、正直自身のセンスのなさを強く感じました・・・
実際に他に発表されていた方々は、サンプルの大規模データや鉄道のデータ、地図データとかを活用して ClickHouse の集計の速さがわかるようなアプリになっており、とても面白かったので、別途自身でも試してみたいと思いました。



