2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Langfuse の裏側 ClickHouse を直接確認して、LLM トレースのコスト・レイテンシを自然言語で分析してみた

2
Posted at

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_details Map、コストは cost_details Map + 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)に向けます。

Claude Desktop のコネクタに clickhouse が登録され、3つのツール(list_databases / list_tables / run_query)が使える状態


4. 手順:自然言語と SQL で聞く

Claude Desktop に MCP でつないだうえで、日本語でそのまま聞いてみます。

mokumoku 環境で流した LLM 呼び出しを、処理の種類ごとに合計コストが高い順で、回数も一緒に

この一文だけで、Claude は list_tables でスキーマを把握し、ReplacingMergeTreeFINALis_deleted = 0type = 'GENERATION' の絞り込み・Decimal 型のキャストまで含めた SQL を生成して実行しました。

日本語の質問から Claude が MCP(run_query)経由で生成・実行した SQL と結果

生成された SQL は次のとおりで、このテーブル特有の書き方(FINAL での重複排除・is_deletedtype 絞り込み・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 でスキーマを見て、FINALtype 絞り込み・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 の集計の速さがわかるようなアプリになっており、とても面白かったので、別途自身でも試してみたいと思いました。


参考

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?