この記事は、LLMアプリのログが増えたときに、PostgreSQLだけで頑張るのか、ClickHouseへ逃がすのかを考えるための検証メモです。実測値を断定する記事ではなく、1000万件の疑似LLMログを作って同じクエリを投げるためのDDLとSQLを置いています。
はじめに
LLMアプリは、ログがすぐ太ります。
普通のWebアプリなら、1リクエストに対して1行のアクセスログで足りることもあります。LLMアプリではそうはいきません。
1回のユーザー操作で、検索、rerank、model call、tool call、評価、リトライが出ます。RAGなら検索された文書、スコア、使われた文書、回答、評価も見たくなります。
最初はPostgreSQLに入れておけばいい。
自分もまずそう考えます。ユーザー、課金、権限、設定をPostgresに置いているなら、ログも同じDBに入れたくなる。小さいうちはそれで十分です。
ただ、ログが100万件、1000万件、1億件と増えると話が変わります。
「このモデルだけp95が遅い」
「プロンプトを変えてから失敗率が上がった」
「特定テナントだけトークン消費が跳ねている」
「RAGの検索件数と回答失敗率に関係がある」
こういう集計を本番DBに投げ続けるのは怖い。
そこでClickHouseです。
この記事では、LLMログ1000万件を題材に、PostgresとClickHouseの役割分担を考えます。数字を盛る記事ではなく、どこを測れば判断できるのかを整理する記事です。
この記事で見ること
- LLMログの疑似データを1000万件作る
- PostgresとClickHouseに同じようなテーブルを作る
- モデル別p95、日次コスト、テナント別利用量、失敗率を集計する
- ClickHouseでは
MergeTreeのORDER BYをどう決めるか考える - よく見る集計は materialized view に逃がす
- Postgresを捨てるのではなく、分析だけClickHouseに逃がす線引きを考える
この記事は「Postgresは遅いから使うな」という話ではありません。Postgresはトランザクションの記録元として強いです。ここでは、増え続けるログ分析をどこまでPostgresに背負わせるかを考えます。
今回作る疑似LLMログ
まず、LLMアプリで残したくなるログを1テーブルにします。
| 列 | 意味 |
|---|---|
| event_time | リクエスト時刻 |
| tenant_id | 顧客や組織 |
| user_id | ユーザー |
| request_id | リクエストID |
| model | モデル名 |
| prompt_version | プロンプトのバージョン |
| route | chat、rag、agent など |
| status | success、error、timeout |
| error_type | エラー種別 |
| latency_ms | レイテンシ |
| input_tokens | 入力トークン |
| output_tokens | 出力トークン |
| retrieved_docs_count | RAGで取れた文書数 |
| tool_calls | tool call の回数 |
| cost_usd | 推定コスト |
実アプリなら、プロンプト全文や回答全文も残したくなります。
ただ、ベンチ用の集計では入れません。個人情報や秘密情報を含みやすいし、今回見たいのは「大量の数値ログをどう集計するか」だからです。
Postgres側のテーブル
Postgresでは、まず普通にテーブルを作ります。
CREATE TABLE llm_request_logs (
event_time timestamptz NOT NULL,
tenant_id int NOT NULL,
user_id int NOT NULL,
request_id uuid NOT NULL,
model text NOT NULL,
prompt_version int NOT NULL,
route text NOT NULL,
status text NOT NULL,
error_type text NOT NULL,
latency_ms int NOT NULL,
input_tokens int NOT NULL,
output_tokens int NOT NULL,
retrieved_docs_count int NOT NULL,
tool_calls int NOT NULL,
cost_usd numeric(12, 6) NOT NULL
);
1000万件を作るなら、Postgresの generate_series を使えます。1
gen_random_uuid() を使うので、必要なら先に pgcrypto を有効にします。
CREATE EXTENSION IF NOT EXISTS pgcrypto;
INSERT INTO llm_request_logs
SELECT
now() - (g % (60 * 60 * 24 * 30)) * interval '1 second' AS event_time,
(g % 1000)::int AS tenant_id,
(g % 200000)::int AS user_id,
gen_random_uuid() AS request_id,
CASE g % 4
WHEN 0 THEN 'gpt-4.1-mini'
WHEN 1 THEN 'gpt-4.1'
WHEN 2 THEN 'claude-sonnet'
ELSE 'local-embedding-rerank'
END AS model,
(g % 20)::int AS prompt_version,
CASE g % 3
WHEN 0 THEN 'chat'
WHEN 1 THEN 'rag'
ELSE 'agent'
END AS route,
CASE
WHEN g % 97 = 0 THEN 'timeout'
WHEN g % 53 = 0 THEN 'error'
ELSE 'success'
END AS status,
CASE
WHEN g % 97 = 0 THEN 'upstream_timeout'
WHEN g % 53 = 0 THEN 'tool_error'
ELSE ''
END AS error_type,
(50 + (g % 3000))::int AS latency_ms,
(100 + (g % 6000))::int AS input_tokens,
(50 + (g % 2000))::int AS output_tokens,
(g % 12)::int AS retrieved_docs_count,
(g % 5)::int AS tool_calls,
round(((100 + (g % 6000)) + (50 + (g % 2000))) * 0.0000008, 6) AS cost_usd
FROM generate_series(1, 10000000) AS g;
必要なら、よく使う条件にインデックスを貼ります。
CREATE INDEX llm_logs_time_model_idx
ON llm_request_logs (event_time, model);
CREATE INDEX llm_logs_tenant_time_idx
ON llm_request_logs (tenant_id, event_time);
CREATE INDEX llm_logs_route_status_time_idx
ON llm_request_logs (route, status, event_time);
この時点で、Postgresでも普通に集計できます。
問題は、これを本番DBで何度もやりたいかです。
ClickHouse側のテーブル
ClickHouseでは MergeTree を使います。
MergeTree 系のテーブルでは、ORDER BY が物理的な並びとスパースインデックスに効きます。公式の最適化ガイドでも、primary key、つまり多くの場合 ORDER BY の設計がクエリ性能に大きく影響すると説明されています。2
今回よく見るのは、期間、テナント、モデル、ルートです。
そこで、まずはこう置きます。
CREATE TABLE llm_request_logs
(
event_time DateTime64(3),
tenant_id UInt32,
user_id UInt32,
request_id UUID,
model LowCardinality(String),
prompt_version UInt16,
route LowCardinality(String),
status LowCardinality(String),
error_type LowCardinality(String),
latency_ms UInt32,
input_tokens UInt32,
output_tokens UInt32,
retrieved_docs_count UInt8,
tool_calls UInt8,
cost_usd Decimal(12, 6)
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(event_time)
ORDER BY (event_time, tenant_id, model, route);
1000万件の疑似データは、ClickHouseの numbers テーブル関数で作れます。3
INSERT INTO llm_request_logs
SELECT
now64(3) - toIntervalSecond(number % (60 * 60 * 24 * 30)) AS event_time,
toUInt32(number % 1000) AS tenant_id,
toUInt32(number % 200000) AS user_id,
generateUUIDv4() AS request_id,
multiIf(
number % 4 = 0, 'gpt-4.1-mini',
number % 4 = 1, 'gpt-4.1',
number % 4 = 2, 'claude-sonnet',
'local-embedding-rerank'
) AS model,
toUInt16(number % 20) AS prompt_version,
multiIf(number % 3 = 0, 'chat', number % 3 = 1, 'rag', 'agent') AS route,
multiIf(number % 97 = 0, 'timeout', number % 53 = 0, 'error', 'success') AS status,
multiIf(number % 97 = 0, 'upstream_timeout', number % 53 = 0, 'tool_error', '') AS error_type,
toUInt32(50 + (number % 3000)) AS latency_ms,
toUInt32(100 + (number % 6000)) AS input_tokens,
toUInt32(50 + (number % 2000)) AS output_tokens,
toUInt8(number % 12) AS retrieved_docs_count,
toUInt8(number % 5) AS tool_calls,
toDecimal64(((100 + (number % 6000)) + (50 + (number % 2000))) * 0.0000008, 6) AS cost_usd
FROM numbers(10000000);
ORDER BY はコピペで決めないほうがいいです。期間で見るのか、テナントで見るのか、モデルで見るのかで正解が変わります。今回の並びは、期間条件をほぼ毎回入れる前提の例です。
測るクエリ1: モデル別のp95レイテンシ
LLMアプリでまず見たいのは、モデル別の遅さです。
Postgresなら、こう書けます。percentile_cont はPostgresのordered-set aggregateです。4
SELECT
model,
count(*) AS requests,
percentile_cont(0.95) WITHIN GROUP (ORDER BY latency_ms) AS p95_latency_ms,
avg(latency_ms) AS avg_latency_ms
FROM llm_request_logs
WHERE event_time >= now() - interval '7 days'
GROUP BY model
ORDER BY requests DESC;
ClickHouseなら、quantile を使います。quantile は近似値を返す集計関数です。ダッシュボードで傾向を見るなら十分なことが多いですが、厳密な値が必要なら quantileExact なども検討します。5
SELECT
model,
count() AS requests,
quantile(0.95)(latency_ms) AS p95_latency_ms,
avg(latency_ms) AS avg_latency_ms
FROM llm_request_logs
WHERE event_time >= now() - INTERVAL 7 DAY
GROUP BY model
ORDER BY requests DESC;
ここで見たいのは、単に速い遅いではありません。
モデルごとのレイテンシを見るクエリを、ダッシュボードが何回も投げることです。1回ならPostgresでよくても、頻繁に見るなら分析DBへ逃がしたくなります。
測るクエリ2: 日次コスト
次はコストです。
ここからの集計例はClickHouse SQLを中心に書きます。Postgresで試す場合は、toDate(event_time) を event_time::date に、countIf(condition) を count(*) FILTER (WHERE condition) に置き換えると読み替えやすいです。
SELECT
toDate(event_time) AS date,
model,
sum(input_tokens) AS input_tokens,
sum(output_tokens) AS output_tokens,
sum(cost_usd) AS cost_usd
FROM llm_request_logs
WHERE event_time >= now() - INTERVAL 30 DAY
GROUP BY date, model
ORDER BY date, model;
このクエリは、LLMアプリでは現実的です。
モデルを変えたあとにコストが増えたのか。特定のプロンプトだけトークンを食っていないか。顧客ごとの請求や社内のコスト配賦にも関わります。
Postgresでもできます。
でも、ログ量が増えて、同じ集計を顧客向け画面や社内ダッシュボードで何度も見るなら、ClickHouseに寄せたくなります。
測るクエリ3: プロンプト変更前後の失敗率
プロンプトを変えたあと、失敗率が上がっていないかを見ます。
SELECT
prompt_version,
route,
count() AS requests,
countIf(status != 'success') AS failures,
round(failures / requests, 4) AS failure_rate
FROM llm_request_logs
WHERE event_time >= now() - INTERVAL 14 DAY
GROUP BY prompt_version, route
ORDER BY failure_rate DESC, requests DESC
LIMIT 20;
こういうクエリは、LLMアプリらしいです。
プロンプトはコードと違って、変更の影響が見えにくい。だからこそ、ログを残して集計したくなります。
測るクエリ4: RAG検索件数と失敗率
RAGでは、検索件数と失敗率の関係も見たいです。
SELECT
retrieved_docs_count,
count() AS requests,
countIf(status != 'success') AS failures,
round(failures / requests, 4) AS failure_rate,
quantile(0.95)(latency_ms) AS p95_latency_ms
FROM llm_request_logs
WHERE event_time >= now() - INTERVAL 30 DAY
AND route = 'rag'
GROUP BY retrieved_docs_count
ORDER BY retrieved_docs_count;
検索件数が多いほどよい、とは限りません。
文書を多く渡すと回答が安定することもあります。逆に、レイテンシが伸びたり、関係ない文書が混ざって回答が崩れたりすることもあります。
ここは感覚で決めるより、ログで見たいところです。
よく見る集計はmaterialized viewにする
同じ集計を何度も見るなら、毎回1000万件を読む必要はありません。
ClickHouseのmaterialized viewは、データ挿入時にSELECT結果をtarget tableへ書く仕組みとして使えます。公式ブログでも、繰り返し実行する集計を速くする用途が紹介されています。6
日次のモデル別集計を作るなら、たとえばこうです。
CREATE TABLE llm_daily_model_summary
(
date Date,
model LowCardinality(String),
route LowCardinality(String),
requests UInt64,
failures UInt64,
input_tokens UInt64,
output_tokens UInt64,
cost_usd Decimal(18, 6)
)
ENGINE = SummingMergeTree
ORDER BY (date, model, route);
CREATE MATERIALIZED VIEW llm_daily_model_summary_mv
TO llm_daily_model_summary
AS
SELECT
toDate(event_time) AS date,
model,
route,
count() AS requests,
countIf(status != 'success') AS failures,
sum(input_tokens) AS input_tokens,
sum(output_tokens) AS output_tokens,
sum(cost_usd) AS cost_usd
FROM llm_request_logs
GROUP BY date, model, route;
読む側では、SummingMergeTree の未マージ行を考えて sum() します。
SELECT
date,
model,
route,
sum(requests) AS requests,
sum(failures) AS failures,
round(sum(failures) / sum(requests), 4) AS failure_rate,
sum(input_tokens) AS input_tokens,
sum(output_tokens) AS output_tokens,
sum(cost_usd) AS cost_usd
FROM llm_daily_model_summary
WHERE date >= today() - 30
GROUP BY date, model, route
ORDER BY date, model, route;
materialized viewは増やしすぎるとINSERT側の負荷になります。ClickHouse公式ブログでも、source tableごとのmaterialized view数は多くしすぎないほうがよいと説明されています。まずは本当に見る集計だけに絞るのが安全です。
測定結果の表はこう残す
実際に測ったら、結果はこう残すと読みやすいです。
| クエリ | Postgres | ClickHouse | メモ |
|---|---|---|---|
| モデル別p95レイテンシ | 直近7日 | ||
| 日次コスト | 直近30日 | ||
| プロンプト別失敗率 | 直近14日 | ||
| RAG検索件数別の失敗率 | route = rag | ||
| 日次集計テーブル | materialized view使用 |
大事なのは、数値だけで勝ち負けを決めないことです。
見るべきなのは、次です。
- 本番DBに重い集計を投げ続けてよいか
- ダッシュボードの同時閲覧に耐えられるか
- 集計のためにアプリのインデックス設計が歪んでいないか
- ログの保存期間を伸ばせるか
- 新しい分析軸を増やしやすいか
このあたりが苦しくなってきたら、ClickHouseを足す意味が出てきます。
Postgresを捨てる話ではない
ここは強めに書いておきたいです。
Postgresを捨てる必要はありません。
ユーザー、権限、課金、設定、注文のようなデータはPostgresに置くのが自然です。トランザクションが大事なところを、無理にClickHouseへ寄せる必要はありません。
ClickHouseに逃がしたいのは、あとから大量に読むログです。
| データ | 置き場所 |
|---|---|
| ユーザー、権限、課金 | Postgres |
| アプリの正規データ | Postgres |
| LLMリクエストログ | ClickHouse |
| RAG検索ログ | ClickHouse |
| tool callログ | ClickHouse |
| 顧客向け集計 | ClickHouse |
| 社内ダッシュボード | ClickHouse |
Postgresは正規データの記録元。
ClickHouseはログと分析の逃がし先。
この分け方がいちばん現実的だと思います。
まとめ
LLMアプリは、ログが増えます。
モデル別のレイテンシ、日次コスト、プロンプト変更前後の失敗率、RAG検索件数と回答失敗率。見たいものはどんどん増えます。
Postgresでも集計はできます。
でも、ログが増え続けるなら、本番DBに分析まで背負わせるのはだんだん苦しくなります。特に、顧客向けダッシュボードや社内分析画面が何度も同じ集計を投げるようになると、役割分担を考えたくなります。
ClickHouseは、ここに置きやすいです。
MergeTree でログを置く。ORDER BY をクエリに合わせて決める。よく見る日次集計はmaterialized viewに逃がす。Postgresはトランザクションの記録元として残す。
これなら、アプリの中心を壊さずに分析だけ強くできます。
1000万件は、まだ入口です。
この規模で測っておくと、1億件になったときにどこが苦しくなるかを想像しやすくなります。LLMログをPostgresに置き続けるか、ClickHouseへ逃がすか。その判断は、雰囲気ではなくクエリで決めたいです。