1. はじめに
これまでの記事では、Codex CLI用自作プロキシの実装や拡張(ストリーミング対応・リトライ戦略)について解説しました。本記事では運用フェーズに焦点を当て、監視とメトリクス収集 をテーマに、PrometheusとGrafanaを活用した具体的な方法を紹介します。
2. なぜ監視とメトリクスが必要か
- 安定稼働: リクエスト数・レスポンス時間・エラー率を把握
- ボトルネック検知: OllamaやOpenAI APIへの呼び出しが遅延していないか可視化
- キャパシティプランニング: 将来的なスケールアウトの根拠
- 障害対応: 異常値を早期に検知し、アラートを飛ばす
3. Prometheusでのメトリクス収集
ライブラリ導入
Node.js/TypeScriptであれば prom-client
を利用します。
pnpm add prom-client
server.ts にメトリクスエンドポイントを追加
import client from 'prom-client'
import { Hono } from 'hono'
const app = new Hono()
// メトリクス登録
const collectDefaultMetrics = client.collectDefaultMetrics
collectDefaultMetrics()
// カスタムメトリクス
export const httpRequestDuration = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
})
// メトリクス公開エンドポイント
app.get('/metrics', (c) => c.text(client.register.metrics()))
これで Prometheus サーバーから http://proxy:8787/metrics
をスクレイプ可能になります。
4. リクエストごとの計測
middlewares.ts
にてリクエスト時間を測定し、Histogramに記録します。
import { httpRequestDuration } from './server'
export const metricsMiddleware = async (c: any, next: any) => {
const end = httpRequestDuration.startTimer()
await next()
end({
method: c.req.method,
route: c.req.path,
status_code: c.res.status
})
}
これにより、リクエストの応答時間がPrometheusに記録されます。
5. Prometheus設定例
Prometheusの prometheus.yml
にターゲットを追加します。
scrape_configs:
- job_name: 'codex-proxy'
static_configs:
- targets: ['localhost:8787']
6. Grafanaでの可視化
ダッシュボード項目例
-
リクエスト数の推移:
rate(http_request_duration_seconds_count[5m])
-
平均レスポンス時間:
rate(http_request_duration_seconds_sum[5m]) / rate(http_request_duration_seconds_count[5m])
-
エラー率:
sum(rate(http_request_duration_seconds_count{status_code!~"2.."}[5m]))
アラート設定例
- エラー率が5%以上でSlack通知
- レイテンシが3秒を超えたらPagerDutyに通知
7. 図解:監視の全体像
8. まとめ
運用段階で監視とメトリクス収集を組み込むことで、Codex CLI用自作プロキシの信頼性と安定性が大幅に向上します。
- Prometheusでメトリクスを収集
- Grafanaで可視化とアラートを設定
次回は、マルチテナント対応とアクセス制御 をテーマに、複数チームやプロジェクトで安全に利用する方法を解説します。
9. 料金・トークン監視とコスト抑制(実装付き)
LLMコストは主に 入力トークン(prompt) と 出力トークン(completion) の合計で決まります。運用では「見える化」と「未然防止」をセットで導入するのが効果的です。
9.1 収集すべきメトリクス
llm_tokens_total{provider,model,phase=prompt|completion,tenant}
llm_requests_total{provider,model,tenant,status}
-
llm_cost_usd_total{provider,model,tenant}
(推定額) -
llm_prompt_chars_histogram{tenant}
(異常に長いプロンプト検知) -
llm_estimated_cost_usd_gauge{tenant}
(当日累計の見込み)
9.2 モデル別の単価テーブル(設定)
単価はモデル・リージョンで変わるため、プロキシ側に設定として持ちます。例:
// src/pricing.ts
export type Pricing = { inputPer1K: number; outputPer1K: number }
export const PRICING: Record<string, Pricing> = {
'openai:gpt-4.1-mini': { inputPer1K: 0.3, outputPer1K: 0.6 },
'openai:gpt-4o': { inputPer1K: 5.0, outputPer1K: 15.0 },
'ollama:gpt-oss': { inputPer1K: 0, outputPer1K: 0 } // ローカルは0円想定(電気代は除く)
}
export function lookupPricing(provider: string, model: string): Pricing {
return PRICING[`${provider}:${model}`] || { inputPer1K: 0, outputPer1K: 0 }
}
※ 実額は各社の最新価格で更新してください。
9.3 使用量の取り出し(OpenAI / Ollama)
OpenAI互換レスポンスには usage
が含まれます。Ollamaは未提供の場合があるため概算します。
// src/usage.ts
export type Usage = { prompt: number; completion: number }
export async function readUsageFromOpenAI(res: Response): Promise<Usage | null> {
try {
const cloned = res.clone()
const json = await cloned.json()
const u = json.usage
if (u) return { prompt: u.prompt_tokens ?? 0, completion: u.completion_tokens ?? 0 }
} catch {}
return null
}
// Ollama概算:入力はプロンプト文字数から近似、出力はdeltaで累積
export function estimateTokensFromText(text: string): number {
// 粗い近似:英語4文字=1トークン、日本語はバリエーションあり。要調整。
return Math.ceil(text.length / 4)
}
9.4 メトリクス記録ミドルウェア
// src/metrics.ts
import client from 'prom-client'
export const tokensCounter = new client.Counter({
name: 'llm_tokens_total', help: 'LLM tokens', labelNames: ['provider','model','phase','tenant']
})
export const costCounter = new client.Counter({
name: 'llm_cost_usd_total', help: 'Estimated cost (USD)', labelNames: ['provider','model','tenant']
})
export const reqCounter = new client.Counter({
name: 'llm_requests_total', help: 'LLM requests', labelNames: ['provider','model','tenant','status']
})
プロキシの転送処理に組み込み:
// src/providers/openai.ts(一部)
import { tokensCounter, costCounter, reqCounter } from '../metrics'
import { lookupPricing } from '../pricing'
import { readUsageFromOpenAI } from '../usage'
export async function callOpenAI(c: any, body: any) {
const tenant = c.req.header('x-tenant') ?? 'default'
const model = body.model
const provider = 'openai'
const url = `${process.env.OPENAI_BASE_URL}/chat/completions`
const res = await fetch(url, { /* 省略 */ })
const usage = await readUsageFromOpenAI(res)
const status = res.status
reqCounter.inc({ provider, model, tenant, status: String(status) })
if (usage) {
const price = lookupPricing(provider, model)
tokensCounter.inc({ provider, model, phase: 'prompt', tenant }, usage.prompt)
tokensCounter.inc({ provider, model, phase: 'completion', tenant }, usage.completion)
const cost = (usage.prompt/1000)*price.inputPer1K + (usage.completion/1000)*price.outputPer1K
costCounter.inc({ provider, model, tenant }, cost)
}
return c.body(await res.arrayBuffer(), res.status, Object.fromEntries(res.headers))
}
Ollama側(概算)も同様に、プロンプト文字数・ストリーム受信文字数から近似して加算します。
9.5 ダッシュボード例(Grafana)
-
日次コスト(USD)
sum(increase(llm_cost_usd_total[1d])) by (tenant)
-
リクエストあたり平均コスト
sum(increase(llm_cost_usd_total[1h])) / sum(increase(llm_requests_total[1h]))
-
プロバイダー別トークン比率
sum(increase(llm_tokens_total[1h])) by (provider,phase)
9.6 アラート設計例(未然防止)
-
日次予算超過予兆(しきい値例:$50/日)
sum(increase(llm_cost_usd_total[12h])) > 25
-
異常プロンプト検知(単発で 50k トークン超など)
sum(increase(llm_tokens_total{phase="prompt"}[5m])) by (tenant) > 50000
-
エラー率急増(5分で5%超)
sum(increase(llm_requests_total{status!~"2.."}[5m])) / sum(increase(llm_requests_total[5m])) > 0.05
9.7 未然防止ガードレール(プロキシ側)
-
事前見積り → ブロック
// src/guardrails.ts import { lookupPricing } from './pricing' import { estimateTokensFromText } from './usage' export function willExceedBudget(opts:{provider:string, model:string, prompt:string, maxTokens:number, remainingUsd:number}){ const price = lookupPricing(opts.provider, opts.model) const promptTok = estimateTokensFromText(opts.prompt) const est = (promptTok/1000)*price.inputPer1K + (opts.maxTokens/1000)*price.outputPer1K return est > opts.remainingUsd }
リクエスト受信時にテナントの残予算と照合し、超過見込みなら 402(Payment Required)や 429 で拒否。
-
max_tokens の自動下げ:予算残に合わせて
max_tokens
を強制的に下げる -
モデル自動ダウングレード:閾値超過時は高価格モデル→安価モデルへ自動切替
-
ストリーミング強制打ち切り:閾値(推定トークン数)に達したらサーバ側で接続をクローズ(注意:応答品質への影響を明記)
9.8 マルチテナントの課金管理
- すべてのメトリクスに
tenant
ラベルを付与 - テナント別の 月次上限 と 日次ソフトリミット を設定
- 日次ジョブで請求サマリを生成しSlack/Emailへ送付
10. まとめ(コスト監視と抑止のつなぎ込み)
- 見える化:トークン/コスト/リクエストのメトリクスをPrometheusで収集し、Grafanaで可視化・しきい値アラート
- 未然防止:プロキシ側に予算・max_tokens・モデル切替のガードレールを実装
- 運用定着:テナント単位の上限設計、日次レポート、アラート運用
これにより、LLM APIの利用が増えても「気づいたら高額請求」という事態を防ぎ、継続的にコスト最適化を図れます。