Bedrock-LLM-Workload Cost-Audit — Checkliste

Reusable fuer jeden Bedrock-LLM-Workload (Open-WebUI + LiteLLM + Bedrock, oder generisch jeder LLM-Proxy). Reihenfolge: zuerst die Mess-Schritte (Punkt 1-3), dann die Hebel (Punkt 4-10). Wer alle 10 Punkte gruen hat ist optimiert — bei den meisten Stacks sind 3-5 Punkte offen.

1. Token-Aufschluesselung pro Modell ziehen

# pro Modell: Cache Read / Cache Write / Plain Input / Output
aws ce get-cost-and-usage \
  --time-period Start=YYYY-MM-DD,End=YYYY-MM-DD \
  --granularity DAILY \
  --metrics UnblendedCost UsageQuantity \
  --group-by Type=DIMENSION,Key=USAGE_TYPE \
  --filter '{"Dimensions":{"Key":"SERVICE","Values":["Claude Sonnet 4.6 (Amazon Bedrock Edition)"]}}' \
  --region us-east-1 --profile <mgmt>

Erwartete USAGE_TYPE-Splits in eu-central-1:

  • EUC1_CacheReadInputTokenCount-Units — gecached, ~90% billiger
  • EUC1_CacheWriteInputTokenCount-Units — Cache-Erstellung, ~25% teurer als Plain
  • EUC1_InputTokenCount-Units — Plain Input (uncached)
  • EUC1_OutputTokenCount-Units — Output

Cache-Hit-Rate = Cache Read / (Cache Read + Plain Input). Erwartung bei gutem Caching: 70-90%. <40% = problem.

2. Klassifikator-Routing-Verteilung messen

Wenn der Stack einen LiteLLM-Pre-Call-Hook fuer dynamisches Modell-Routing hat (Sonnet → Haiku/Opus): pruefen ob die Verteilung gesund ist.

# CloudWatch Logs Insights
aws logs start-query --profile <prod> --region <region> \
  --log-group-name <log-group> \
  --start-time <unix-24h-ago> --end-time <unix-now> \
  --query-string 'fields @message | filter @message like /vf_classify_route:/ and @message like /->/ | parse @message /-> (?<routed>\S+) \(cls=(?<cls>\w+)\)/ | stats count() by cls'

Erwartung gesunder Stack: ROUTE 20-40% / REASON 40-60% / PLAN 5-15%. Wenn ROUTE <10%: Klassifikator-System-Prompt scharfstellen.

Trap: logger.info darf NICHT konditional auf new_model != data["model"] triggern — sonst sind REASON-Decisions (=Sonnet) silent und die Verteilung ist nicht messbar. Always-Log.

3. Klassifikator-Failure-Rate pruefen

# Bedrock-RateLimit-Errors im Pre-Hook
aws logs start-query --profile <prod> --region <region> \
  --log-group-name <log-group> \
  --start-time <unix-24h-ago> --end-time <unix-now> \
  --query-string 'fields @message | filter @message like /classifier failed/ | stats count()'

Jedes Failure = Call faellt auf Default-Modell (typisch Sonnet) zurueck. Bei >50 Failures/Tag: Bedrock-Quota erhoehen via AWS-Support oder Klassifikator-Cache (LiteLLM cache mit TTL pro User-Message-Hash).

4. Prompt-Caching aktivieren + korrekt strukturieren (groesster Hebel)

LiteLLM-Config:

litellm_settings:
  cache_control_injection_points:
    - location: message
      role: system
 
model_list:
  - model_name: ...
    litellm_params: ...
    model_info:
      supports_prompt_caching: true
      cache_creation_input_token_cost: <wert>
      cache_read_input_token_cost: <wert>

Aber: Aktivieren reicht nicht — der System-Prompt muss strukturiert sein!

cache_control_injection_points: role: system setzt den Cache-Marker am ENDE der system-Message. Alles davor wird gecached, alles dahinter nicht. Heisst:

GUT: stabiler Block (Identitaet, Stammdaten, Werkzeug-Logik, Output-Stil) am Anfang, dynamische Variables ({{CURRENT_DATE}}, {{USER_NAME}}, etc.) am ABSOLUTE Ende.

CACHE-KILLER: dynamische Variables irgendwo MITTENDRIN. Dann ist der gesamte System-Prompt-Block dirty pro Request.

Anti-Patterns (Cache-Killer)

PatternWarum kaputt
{{CURRENT_TIME}} mit Sekunden-PraezisionPro Request neuer Wert → Cache 0% Hit
<kontext> mit Date+User in Zeile 50 von 400Stabiler Suffix-Block (350 Zeilen) wird durch dynamic Prefix invalidiert
User-Email/Rolle vor Werkzeug-DokuPro User-Login andere Cache-Bloecke noetig, viel cold-miss
Datum als Markdown-Heading ## Heute: 18.05.2026Selbe Problematik wie Zeitstempel

Pattern (Cache-Optimized)

<identitaet>           ← stabil, wird gecached
<vf_stammdaten>        ← stabil
<werkzeug_prioritaet>  ← stabil
<output_stil>          ← stabil
<!-- cache_control wird hier oder am Ende automatisch injected -->
<session>              ← dynamisch, kleiner Suffix
Heute ist {{CURRENT_DATE}} ({{CURRENT_WEEKDAY}}). Du sprichst mit {{USER_NAME}}.
</session>

Quelle: _index — VF-Audit fand Cache-Hit-Rate 17% mit {{CURRENT_TIME}} mittendrin. Nach Restructure auf <session>-am-Ende erwartet 70-80%.

5. Dynamisches Modell-Routing via Pre-Hook

Sonnet ist 3x teurer als Haiku (1/M Input, 5/M Output). Wenn ein nicht-trivialer Anteil der User-Anfragen mit Haiku-Quality erfuellt wird (ROUTE-Anteil): VFClassifyRouter-Pattern.

LiteLLM-Pre-Call-Hook der User-Anfrage in ROUTE/REASON/PLAN klassifiziert (via Haiku-Klassifikator-Call, max_tokens=5, timeout 5s) und dynamisch das Backend-Modell waehlt. Default fallback: REASON = Sonnet.

Source-Pattern: ~/source/apps/open-webui-vf/infra/lib/open-webui-vf-stack.ts Z. 600-700 (VFClassifyRouter). Wiederverwendbar fuer jeden LiteLLM-Proxy.

6. Auxiliary-Task-Modell auf Haiku

Open-WebUI hat TASK_MODEL + TASK_MODEL_EXTERNAL Env-Vars fuer interne Aufgaben (Chat-Titel-Generation, Tag-Generation, Search-Query-Formulierung). Default ist claude-sonnet-4-6 — sollte auf Haiku zeigen (1-2-Saetze-Output, Quality reicht).

TASK_MODEL: 'vf-haiku-backend'
TASK_MODEL_EXTERNAL: 'vf-haiku-backend'

Caveat: Wenn das Task-Modell den Pre-Klassifikator triggern wuerde (TRIGGER_MODEL ist Sonnet), unnoetiger Round-Trip. Backend-Model direkt ansprechen (umgeht Klassifikator).

7. ECS-Task-Rightsizing

Default Open-WebUI-Stack: 1 vCPU / 3 GB. Bei 3 Container-Setup (open-webui + litellm + cloudflared) teilen die sich das. Bei niedriger User-Last (<5 aktive User): 0,5 vCPU / 2 GB reicht oft → 50% Cost-Reduktion auf Fargate.

Pruefen vor Rightsizing: CloudWatch Container-Insights → CPU/Memory-Utilization avg. Wenn <40% idle Avg: rightsizen. Wenn 60-80%: lassen wie ist.

mcp-vf-hosted ist meist Min-Size (256/512) — FastMCP ist mostly async I/O.

8. CloudWatch-Retention konfigurieren

Default ONE_MONTH (30d) bei jeder LogGroup. Pro Stack pruefen:

  • App-LogGroup mit Audit-Logs (AUDIT_LOG_LEVEL: METADATA): 14d Compromise zwischen Compliance + Storage-Cost
  • Service-LogGroup ohne Audit (Tool-Health, Tunnel-Status): 7d reicht

LITELLM_LOG-Level: DEBUG (oft default fuer Bring-Up) → INFO nach Sign-Off. DEBUG-Logs sind 5-10x mehr Volumen.

9. ECR-Lifecycle-Policies

Pro Repo: Keep last 10 tagged images + Expire untagged after 7 days. Verhindert Image-Tag-Wildwuchs der ECR-Storage-Cost erhoeht.

aws ecr put-lifecycle-policy \
  --repository-name <repo> \
  --lifecycle-policy-text file://lifecycle.json \
  --profile <prod> --region <region>

10. Stale Secrets aufraeumen

Secrets Manager kostet $0,40/Secret/Monat. Quartalsweise Audit:

aws secretsmanager list-secrets --profile <prod> --region <region> \
  --query 'SecretList[?LastAccessedDate==`null` || LastAccessedDate<`2026-04-01`].{name:Name,lastChanged:LastChangedDate,lastAccessed:LastAccessedDate}' \
  --output table

Loeschen mit 7d-Recovery-Window:

aws secretsmanager delete-secret --secret-id <name> --recovery-window-in-days 7 --profile <prod>

Sofort-Pruef-Liste (5 Min)

Bei neuem Bedrock-LLM-Workload diese 5 Bedrock-spezifischen Punkte:

  • Cache-Hit-Rate >40%? (Sonst Punkt 4 forensisch — System-Prompt-Restructure)
  • Klassifikator vorhanden + jede Decision wird geloggt? (Sonst Punkt 5)
  • TASK_MODEL auf Haiku oder generell guenstigeres Modell? (Punkt 6)
  • function_calling: 'native' im DEFAULT_MODEL_PARAMS? (Sonst KV-Cache invalidiert pro Tool-Call-Runde)
  • thinking.budget_tokens gecappt? (Sonst unsichtbares Thinking-Token-Burning)

Verlauf

  • 2026-05-18 Erstausroll aus VF-Audit (_index). Punkt 4 (Cache-Killer Sekunden-Timestamp im System-Prompt) war der ueberraschendste Treffer — Cache-Hit-Rate 17% statt erwartet 70-80%, ~$215/Mo verschenkt.