0
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?

Codex CLI用 自作プロキシ 運用編 〜監視とメトリクス収集(Prometheus/Grafana連携)〜

Last updated at Posted at 2025-09-18

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 未然防止ガードレール(プロキシ側)

  1. 事前見積り → ブロック

    // 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 で拒否。

  2. max_tokens の自動下げ:予算残に合わせて max_tokens を強制的に下げる

  3. モデル自動ダウングレード:閾値超過時は高価格モデル→安価モデルへ自動切替

  4. ストリーミング強制打ち切り:閾値(推定トークン数)に達したらサーバ側で接続をクローズ(注意:応答品質への影響を明記)

9.8 マルチテナントの課金管理

  • すべてのメトリクスに tenant ラベルを付与
  • テナント別の 月次上限日次ソフトリミット を設定
  • 日次ジョブで請求サマリを生成しSlack/Emailへ送付

10. まとめ(コスト監視と抑止のつなぎ込み)

  • 見える化:トークン/コスト/リクエストのメトリクスをPrometheusで収集し、Grafanaで可視化・しきい値アラート
  • 未然防止:プロキシ側に予算・max_tokens・モデル切替のガードレールを実装
  • 運用定着:テナント単位の上限設計、日次レポート、アラート運用

これにより、LLM APIの利用が増えても「気づいたら高額請求」という事態を防ぎ、継続的にコスト最適化を図れます。

関連記事一覧

0
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
0
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?