Powered by AppSignal & Oban Pro

AIP-C01 07: コスト・性能・オブザーバビリティ

07_operations_optimization.livemd

AIP-C01 07: コスト・性能・オブザーバビリティ

Mix.install([
  {:kino, "~> 0.19"},
  {:kino_vega_lite, "~> 0.1"}
])

概要

最終確認日: 2026-09-22

対応範囲: 4.1〜4.3, 1.2, 2.2, 5.2

このノートで身につけること

  • トークン、モデル、キャッシュ、バッチ、スループットのコストを比較する
  • TTFT、end-to-end latency、throughput、error rate を区別する
  • 技術メトリクスと品質・ビジネスメトリクスを結びつける
  • invocation logging の価値と機密データリスクを評価する

最新仕様の要点

Bedrock Runtime は CloudWatch の AWS/Bedrock namespace に呼び出し数、レイテンシー、トークン、エラー、Prompt Cache などのメトリクスを出します。Model Invocation Logging は既定で無効で、設定すると request / response / metadata を CloudWatch Logs または S3 に送れます。全文ログは診断に有用ですが、PII や秘密情報、保持、アクセス制御、コストのリスクがあります。

Prompt Caching は長く繰り返す prefix の入力コストとレイテンシーを下げる候補です。Batch inference は非同期の大量処理、Provisioned Throughput は安定した高負荷、cross-Region inference は利用可能容量の拡大、Prompt Router は品質とコストの動的選択に使います。

ハンズオン1: 単価を変数としてコストを比較する

価格は変更されるため、現在の Bedrock Pricing から対象リージョン・モデルの「1,000 トークン当たり単価」を入力してください。

input_price = Kino.Input.number("入力 $ / 1K tokens", default: 0.0001, step: 0.0001)
output_price = Kino.Input.number("出力 $ / 1K tokens", default: 0.0005, step: 0.0001)
requests_input = Kino.Input.number("月間リクエスト数", default: 100_000)
avg_input = Kino.Input.number("平均入力 tokens", default: 800)
avg_output = Kino.Input.number("平均出力 tokens", default: 200)

Kino.Layout.grid([input_price, output_price, requests_input, avg_input, avg_output], columns: 3)
requests = Kino.Input.read(requests_input)
input_tokens = requests * Kino.Input.read(avg_input)
output_tokens = requests * Kino.Input.read(avg_output)

input_cost = input_tokens / 1_000 * Kino.Input.read(input_price)
output_cost = output_tokens / 1_000 * Kino.Input.read(output_price)

%{
  monthly_input_tokens: input_tokens,
  monthly_output_tokens: output_tokens,
  input_cost_usd: Float.round(input_cost, 2),
  output_cost_usd: Float.round(output_cost, 2),
  total_usd: Float.round(input_cost + output_cost, 2)
}

ハンズオン2: 最適化シナリオ

baseline = %{requests: 100_000, input_tokens: 800, output_tokens: 200}

scenarios = [
  %{name: "baseline", request_ratio: 1.0, input_ratio: 1.0, output_ratio: 1.0},
  %{name: "prompt compression", request_ratio: 1.0, input_ratio: 0.7, output_ratio: 1.0},
  %{name: "semantic cache 25% hit", request_ratio: 0.75, input_ratio: 1.0, output_ratio: 1.0},
  %{name: "maxTokens tuning", request_ratio: 1.0, input_ratio: 1.0, output_ratio: 0.65},
  %{name: "combined", request_ratio: 0.75, input_ratio: 0.7, output_ratio: 0.65}
]

unit_input = Kino.Input.read(input_price)
unit_output = Kino.Input.read(output_price)

cost_rows =
  scenarios
  |> Enum.map(fn scenario ->
    request_count = baseline.requests * scenario.request_ratio
    in_tokens = request_count * baseline.input_tokens * scenario.input_ratio
    out_tokens = request_count * baseline.output_tokens * scenario.output_ratio
    cost = in_tokens / 1_000 * unit_input + out_tokens / 1_000 * unit_output

    %{scenario: scenario.name, input_tokens: trunc(in_tokens), output_tokens: trunc(out_tokens), cost_usd: Float.round(cost, 2)}
  end)

cost_chart =
  VegaLite.new(width: 680, height: 270, title: "最適化シナリオ別の月額推定")
  |> VegaLite.data_from_values(cost_rows)
  |> VegaLite.mark(:bar, tooltip: true)
  |> VegaLite.encode_field(:y, "scenario", type: :nominal, title: "シナリオ", sort: "-x")
  |> VegaLite.encode_field(:x, "cost_usd", type: :quantitative, title: "月額推定 (USD)")
  |> Kino.VegaLite.new()

Kino.Layout.tabs([
  {"コスト比較", cost_chart},
  {"トークン内訳", Kino.DataTable.new(cost_rows)}
])

キャッシュは「同じ意味なら過去回答を返す」ため、権限、tenant、鮮度、モデル/prompt version、Guardrail version を cache key と invalidation に含めます。コスト削減だけで品質事故を起こさないようにします。

ハンズオン3: SLO とアラーム判定

slo = %{
  success_rate: 0.995,
  p95_latency_ms: 3_000,
  grounded_rate: 0.97,
  cost_per_success_usd: 0.01
}

window = %{
  requests: 20_000,
  successes: 19_860,
  p95_latency_ms: 2_850,
  grounded_responses: 19_100,
  evaluated_responses: 19_500,
  cost_usd: 170.0
}

observed = %{
  success_rate: window.successes / window.requests,
  p95_latency_ms: window.p95_latency_ms,
  grounded_rate: window.grounded_responses / window.evaluated_responses,
  cost_per_success_usd: window.cost_usd / window.successes
}

Enum.map(observed, fn {metric, value} ->
  target = Map.fetch!(slo, metric)
  direction = if metric in [:p95_latency_ms, :cost_per_success_usd], do: :max, else: :min
  pass = if direction == :max, do: value <= target, else: value >= target
  %{metric: metric, observed: value, target: target, pass: pass}
end)
|> Kino.DataTable.new()

成功率だけでは「高速に誤答する」サービスを見逃します。運用、品質、安全、コスト、ビジネス成果の指標を並べます。

ハンズオン4: invocation log を安全に要約する

log_record = %{
  request_id: "req-123",
  model_id: "example-model",
  input_tokens: 820,
  output_tokens: 190,
  latency_ms: 2_340,
  status: 200,
  prompt: "顧客 taro@example.com の契約を要約して",
  response: "taro@example.com の契約は..."
}

safe_log =
  log_record
  |> Map.drop([:prompt, :response])
  |> Map.put(:content_logged, false)
  |> Map.put(:prompt_fingerprint, :crypto.hash(:sha256, log_record.prompt) |> Base.encode16(case: :lower))

safe_log

全文が必要な調査環境では、別ロググループ、KMS、厳格な IAM、短い保持、マスキング、監査を使います。CloudTrail は API 操作の監査、CloudWatch はメトリクス/ログ/アラーム、X-Ray はサービス境界のトレースという役割を区別します。

ハンズオン5: Prompt Cache の損益分岐を測る

Converse の usage.inputTokens はキャッシュ利用時、キャッシュから読んだ/書いたトークンを含まないため、総入力は inputTokens + cacheReadInputTokens + cacheWriteInputTokens で求めます。料金比率はモデルごとに変わるので、現在の価格表から入力してください。

cache_usage = [
  %{request: 1, input: 180, cache_read: 0, cache_write: 4_000, output: 220, latency_ms: 2_900},
  %{request: 2, input: 190, cache_read: 4_000, cache_write: 0, output: 210, latency_ms: 1_700},
  %{request: 3, input: 175, cache_read: 4_000, cache_write: 0, output: 205, latency_ms: 1_620},
  %{request: 4, input: 4_180, cache_read: 0, cache_write: 0, output: 215, latency_ms: 2_850}
]

# 価格そのものではなく、通常入力を 1.0 とした相対単価。対象モデルの料金で置き換える。
rates = %{input: 1.0, cache_read: 0.1, cache_write: 1.25}

cache_analysis =
  Enum.map(cache_usage, fn row ->
    total_input = row.input + row.cache_read + row.cache_write
    relative_cost = row.input * rates.input + row.cache_read * rates.cache_read + row.cache_write * rates.cache_write

    Map.merge(row, %{
      total_input: total_input,
      cache_hit_rate: if(total_input == 0, do: 0.0, else: row.cache_read / total_input),
      relative_input_cost: relative_cost
    })
  end)

cache_rows =
  Enum.flat_map(cache_analysis, fn row ->
    [
      %{request: row.request, metric: "latency_ms", value: row.latency_ms},
      %{request: row.request, metric: "relative_input_cost", value: row.relative_input_cost}
    ]
  end)

cache_chart =
  VegaLite.new(width: 650, height: 260, title: "キャッシュ hit / miss がコストとレイテンシーへ与える影響")
  |> VegaLite.data_from_values(cache_rows)
  |> VegaLite.mark(:bar, tooltip: true)
  |> VegaLite.encode_field(:x, "request", type: :ordinal, title: "request")
  |> VegaLite.encode_field(:x_offset, "metric")
  |> VegaLite.encode_field(:y, "value", type: :quantitative, title: "値(単位は凡例参照)")
  |> VegaLite.encode_field(:color, "metric", type: :nominal, title: "metric")
  |> Kino.VegaLite.new()

Kino.Layout.tabs([
  {"比較", cache_chart},
  {"明細", Kino.DataTable.new(cache_analysis)}
])

1回目の cache write は通常入力より高価な場合があり、再利用が少ない prefix では逆効果です。静的な system / tool definition / 長い参照文書を前、動的なユーザー入力を後ろへ置き、cacheReadInputTokens、hit rate、TTL 内の再利用回数、p95 latency を同時に確認します。暗黙キャッシュは best effort で、同じ入力でも hit は保証されません。

ハンズオン6: 429 をメトリクスから切り分ける

diagnose_throttle = fn sample ->
  cond do
    sample.input_tpm >= sample.input_tpm_quota -> :input_tokens_per_minute
    sample.output_tpm >= sample.output_tpm_quota -> :output_tokens_per_minute
    sample.rpm >= sample.rpm_quota -> :requests_per_minute
    sample.concurrent >= sample.concurrent_quota -> :concurrency
    sample.max_tokens_reserved > sample.output_p95 * 4 -> :excessive_max_tokens
    true -> :inspect_error_type_and_service_quota
  end
end

throttle_samples = [
  %{window: "10:00", input_tpm: 88_000, input_tpm_quota: 100_000, output_tpm: 9_000, output_tpm_quota: 20_000, rpm: 102, rpm_quota: 100, concurrent: 4, concurrent_quota: 10, max_tokens_reserved: 1_000, output_p95: 400},
  %{window: "10:01", input_tpm: 101_000, input_tpm_quota: 100_000, output_tpm: 8_000, output_tpm_quota: 20_000, rpm: 80, rpm_quota: 100, concurrent: 5, concurrent_quota: 10, max_tokens_reserved: 1_000, output_p95: 380},
  %{window: "10:02", input_tpm: 70_000, input_tpm_quota: 100_000, output_tpm: 7_000, output_tpm_quota: 20_000, rpm: 60, rpm_quota: 100, concurrent: 10, concurrent_quota: 10, max_tokens_reserved: 4_000, output_p95: 350}
]

throttle_samples
|> Enum.map(&Map.put(&1, :likely_bottleneck, diagnose_throttle.(&1)))
|> Kino.DataTable.new()

429 へ無条件にモデル変更や Provisioned Throughput を適用しません。まず dimension(model ID / inference profile / region)、エラー型、RPM / TPM / concurrency、maxTokens、retry 増幅を突き合わせます。安定した高利用率と予測可能な需要を測定してから Provisioned Throughput を比較し、突発負荷では cross-Region inference、キュー、同時実行制御も候補にします。

監視する代表指標

分類 指標例
利用 Invocations、input/output/cache tokens
信頼性 throttles、4xx/5xx、fallback rate、queue depth
性能 TTFT、p50/p95/p99 latency、tokens/sec
RAG retrieval latency、Recall@k、empty retrieval、rerank gain
品質・安全 grounded rate、hallucination rate、Guardrail intervention、schema failure
Agent task completion、tool error、steps、loop termination、approval rate
ビジネス containment、conversion、time saved、cost per successful task

判断問題

  1. オンデマンドから Provisioned Throughput に切り替える前に、何を測定しますか。
  2. Prompt Caching と semantic response cache の違いを説明してください。
  3. totalTokens が一定でもコストやレイテンシーが変わる理由を挙げてください。
  4. invocation logging を有効化しない判断が妥当になる条件は何ですか。

公式資料