Salta al contenuto principale

Cost tracking per component: quale step brucia token, cost per user, FinOps integration

Cost tracking per component: quale step brucia token, cost per user, FinOps integration Il messaggio del CFO arriva alle 9:14 di lunedi mattina:...

Cosa ne pensi di questo articolo?

Cost tracking per component: quale step brucia token, cost per user, FinOps integration
AI ObservabilityFoto:Atlantic AmbiencePexels

In Breve

Cost tracking per component: quale step brucia token, cost per user, FinOps integration Il messaggio del CFO arriva alle 9:14 di lunedi mattina:...

Cost tracking per component: quale step brucia token, cost per user, FinOps integration

Il messaggio del CFO arriva alle 9:14 di lunedi mattina: "Federico, la fattura LLM del mese scorso è triplicata. Cosa è successo?". Apri la dashboard del provider e vedi un grafico che sale dritto, ma non c'è un contesto: 47.000 query totali, costo aggregato 2.840 EUR, e basta. Quale feature brucia? Quale cliente B2B sta sopra il suo budget? Quale step del pipeline RAG è il responsabile? Senza cost tracking per componente sei cieco e l'unica risposta possibile è "indagine in corso", che in azienda significa "non lo so".

Il problema vero non è il costo in se: 2.840 EUR su un fatturato SaaS sano è fisiologico se sai dimostrare unit economics positivi. Il problema è l'incapacita di rispondere a tre domande operative: quale componente del sistema spende di piu (per ottimizzazione tecnica), quale cliente o feature genera quel costo (per chargeback e pricing), il pattern è anomalo o è crescita organica (per allarmi). Questo articolo mostra come istruire un sistema cost tracking che risponde a tutte e tre, partendo dal middleware di intercettazione fino all'export Excel per il CFO.

Tutto il codice mostrato è tratto dal ml-service che gestisce la sezione AI del mio portfolio (RAG su 702 articoli, agent dev-tools, chat con Ollama llama3.1:8b on-prem). Il caso è interessante perché il provider primario è Ollama: il costo per token è zero, ma il VPS dedicato pesa circa 30 EUR/mese fissi e devi imparare ad ammortizzarlo correttamente per non mostrare cost zero al CFO (che giustamente non ti crede e ti chiede di rifare i conti).

Cosa Imparerai

  • Implementare middleware async Python che conta token e calcola cost per ogni chiamata LLM
  • Progettare schema ClickHouse cost_events con tag taxonomy multi-dimensionale
  • Scrivere query SQL aggregate per cost per agent step, per user, per feature
  • Esporre metriche Prometheus cost-per-user con gauge e histogram
  • Costruire decorator @track_cost compatibile con LangChain LCEL e streaming
  • Generare export FinOps multi-sheet Excel per il CFO con anomaly detection
  • Configurare alert su cost spike per user con risk score multi-segnale

Prerequisiti

  • Lettura di EP05 (Span Design) della serie per la trace hierarchy
  • Familiarita con Python async/await e middleware FastAPI
  • Conoscenza base SQL e idealmente ClickHouse o PostgreSQL analytics
  • Esperienza con Prometheus exporter e PromQL
  • Comprensione concetti FinOps (chargeback, showback, unit economics)

Sezione 1 - Cost per step: il modello di calcolo

Prima ancora di scrivere codice, devi decidere cosa misuri in cost. La tentazione è sommare i token e moltiplicare per il prezzo del provider, ma è un modello fragile: ignora il costo dell'embedding model self-hosted, non considera il rerank cross-encoder, e si rompe completamente con Ollama on-prem dove il costo per token è zero ma il VPS pesa fisso. Il modello che uso nel ml-service portfolio distingue tre famiglie di componenti, ciascuna con la sua formula.

Componente Formula cost Cost tipico per query Note operative
Retrieval (embedding + vector search) tokens_in * 0.000020 EUR + 0 (RediSearch self-hosted) 0.0001 - 0.0008 EUR (0.01 - 0.08 cent) Embedding model all-MiniLM-L6-v2 self-hosted, costo fisso ammortizzato sul VPS
Rerank (cross-encoder MS-MARCO) (num_candidates / 32) * 0.000050 EUR 0.0005 - 0.0015 EUR (0.05 - 0.15 cent) Costo CPU dominante, un batch di 32 documenti è la unit di misura
Agent step (LLM judge, tool routing) tokens_in * price_in + tokens_out * price_out 0.005 - 0.020 EUR (0.5 - 2 cent) per step Multi-step puo esplodere, log su span event ogni step
Generation (main LLM) tokens_in * price_in + tokens_out * price_out 0.001 (Ollama amortized) - 0.050 EUR (GPT-4o) Componente di gran lunga piu costoso, target ottimizzazione primaria
Total RAG query somma dei 4 sopra 0.007 - 0.080 EUR (0.7 - 8 cent) Generation tipicamente 80-95% del totale

La tabella ti dice subito dove ottimizzare: il rerank cost rispetto al cross-encoder costa 100x meno della generation, quindi non perdere tempo a ottimizzarlo. Sul portfolio ho misurato che riducendo i token in input alla generation di 200 unita (via prompt compression) risparmio circa il 18% del costo totale per query, mentre eliminare il rerank mi farebbe risparmiare lo 0.3% peggiorando la qualita. Senza dati per componente avrei probabilmente ottimizzato la cosa sbagliata.

Una sottigliezza importante è la distinzione tra cost variabile e cost fisso ammortizzato. I provider API tipo OpenAI, Anthropic, Mistral o Cohere applicano un modello puro variabile: paghi tot per token, e basta. I modelli self-hosted (Ollama, vLLM, TGI) hanno cost fisso (server, GPU, energia, manutenzione) che va diviso per il numero di query. Modelli managed-but-fixed come Bedrock provisioned throughput o Azure dedicated capacity sono ibridi: paghi una capacita garantita oraria piu un minimo per token. Nel cost tracker devi gestire tutti e tre i casi, altrimenti i confronti tra setup diventano misleading.

Un altro pattern che si vede spesso male implementato è il cost dei retry. Una chiamata LLM che fallisce con timeout e viene ritentata 3 volte costa 3x. Se non logghi il cost del retry separatamente, vedi solo il cost della chiamata finale (quella che ha avuto successo) e ignori il 200% di overhead. Nel ml-service portfolio ogni retry genera un cost_event distinto con status='retry' e un retry_attempt incrementale; il dashboard "cost per query effettiva" somma tutti i cost_event dello stesso request_id, mostrando il cost reale incluso il rumore degli errori. La differenza è notevole: durante una settimana di problematiche di rete con il provider esterno avevamo retry rate al 12%, che traduceva in un overhead di cost del 12% non visibile dalle dashboard ufficiali del provider.

Sezione 2 - Middleware async per token counting

Il primo pezzo concreto è il middleware FastAPI che intercetta ogni chiamata LLM in uscita, conta i token, calcola il cost e fa flush asincrono su ClickHouse. Il vincolo critico: non aggiungere latency utente. Il calcolo cost deve essere non-blocking, l'INSERT su database deve essere fire-and-forget, gli errori non devono mai bubble up al chiamante. Il pattern è "best effort logging".

# ml-service/app/middleware/cost_tracking.py
import asyncio
import time
from contextvars import ContextVar
from typing import Any, Optional
from fastapi import Request
import tiktoken
import structlog

logger = structlog.get_logger(__name__)

# Context propagato per request, popolato dal middleware auth
current_user_id: ContextVar[Optional[str]] = ContextVar("user_id", default=None)
current_feature_id: ContextVar[Optional[str]] = ContextVar("feature", default=None)

# Pricing catalog: aggiornare quando cambia listino provider
# Prezzi in EUR per 1K token (input/output)
PRICING_CATALOG: dict[str, dict[str, float]] = {
    "gpt-4o-2024-11-20": {"input": 0.0023, "output": 0.0092},
    "gpt-4o-mini": {"input": 0.00014, "output": 0.00057},
    "claude-3-5-sonnet-20241022": {"input": 0.0028, "output": 0.0138},
    # Ollama: prezzo per token è zero, ma VPS costa 30 EUR/mese
    # Cost ammortizzato: 30 / query_mensili stimate (15K) / 1000 = 0.000002 EUR/query
    "llama3.1:8b": {"input": 0.0, "output": 0.0, "amortized_per_query": 0.002},
    "all-MiniLM-L6-v2": {"input": 0.00002, "output": 0.0},
}


async def calculate_cost_async(
    model: str,
    tokens_in: int,
    tokens_out: int,
) -> float:
    """Calcola cost in EUR. Async ma in realta CPU-only,
    wrappato per uniformita interfaccia."""
    pricing = PRICING_CATALOG.get(model)
    if pricing is None:
        # Fallback conservativo: assumi GPT-4o per non sottostimare
        logger.warning("unknown_model_pricing", model=model)
        pricing = PRICING_CATALOG["gpt-4o-2024-11-20"]

    cost_input = (tokens_in / 1000.0) * pricing["input"]
    cost_output = (tokens_out / 1000.0) * pricing["output"]
    cost_amortized = pricing.get("amortized_per_query", 0.0)
    return cost_input + cost_output + cost_amortized


def count_tokens_safe(text: str, model: str = "gpt-4o") -> int:
    """Conta token via tiktoken. Fallback su approssimazione 4 char/token
    se il modello non è nel registry tiktoken."""
    try:
        encoding = tiktoken.encoding_for_model(model)
        return len(encoding.encode(text))
    except KeyError:
        # Modelli custom (Ollama, fine-tuned): approssimazione
        return len(text) // 4

Il pattern dei ContextVar è fondamentale in Python async: ti permette di propagare lo user_id e il feature_id attraverso tutto lo stack senza dover passarlo come parametro a ogni funzione. Il middleware auth lo popola all'inizio della request, il cost tracker lo legge al momento del logging. Lavora correttamente anche con asyncio.gather() perché i context sono per-task. Senza questo pattern dovresti aggiungere user_id come argomento a 30 funzioni del retrieval/agent/generation pipeline, con tutti i refactor che ne conseguono.

L'uso di tiktoken per il token counting merita una nota di affidabilita. Tiktoken è la libreria ufficiale OpenAI che implementa il BPE tokenizer di GPT-3.5/GPT-4: è precisa al 100% per quei modelli. Per Claude (tokenizer Anthropic proprietario) e Llama (SentencePiece) non c'è una libreria open source ufficiale, e tiktoken sottostima leggermente il count (5-15% di errore). La soluzione pulita: usare il tokenizer nativo del modello via transformers di HuggingFace per i modelli locali e accettare l'errore residuo per i provider che non espongono il count nelle response. Nel ml-service portfolio uso il pattern "trust the response, fallback to estimation": se la response del provider contiene usage_metadata.completion_tokens uso quel valore, altrimenti stimo via tiktoken. La precisione finale del cost è del +/- 3%, accettabile per dashboard executive ma da tenere a mente quando giri numeri al CFO ("circa 32 EUR" non "esattamente 32.18 EUR").

Il pattern fire-and-forget per l'INSERT su database è un'ulteriore best practice spesso trascurata. Il cost tracking non deve mai introdurre latency utente. Se ClickHouse è down, se la rete verso il database è lenta, se la queue è satura, l'utente non se ne deve accorgere. Tutto il path di logging deve essere wrappato in try/except con logging silenzioso e timeout aggressivo (max 100ms). Nel codice production uso una asyncio.Queue(maxsize=10000) bufferizzata: il middleware fa queue.put_nowait(event), un worker dedicato in background fa batch INSERT da 100 eventi ogni 5 secondi. Se la queue è piena (database giu da minuti), gli eventi vengono droppati con un counter Prometheus cost_events_dropped_total: alert su drop > 0 mi avvisa che ho un problema senza degradare l'esperienza utente.

Sezione 3 - Schema ClickHouse cost_events multi-component

Una volta calcolato il cost, devi salvarlo in modo che le query analitiche siano veloci anche con 100 milioni di righe. PostgreSQL regge fino a 1-2M eventi/mese poi rallenta drasticamente sulle aggregazioni. ClickHouse è progettato esattamente per questo workload: append-only, columnar storage, compressione 10x rispetto a Postgres. Lo schema che uso nel ml-service portfolio è progettato per supportare i 5 tag obbligatori (customer/feature/env/component/model) e mantenere le query GROUP BY sotto i 200ms.

-- ml-service/migrations/clickhouse/001_cost_events.sql
-- Tabella append-only per ogni evento di costo LLM
-- PARTITION mensile per facilitare drop di dati vecchi
-- ORDER BY ottimizzato per le query piu frequenti (per user, per giorno)
CREATE TABLE IF NOT EXISTS cost_events
(
    -- Identificativi temporali e correlazione
    timestamp        DateTime64(3, 'UTC') CODEC(Delta, ZSTD(1)),
    trace_id         String CODEC(ZSTD(3)),
    span_id          String CODEC(ZSTD(3)),
    request_id       String CODEC(ZSTD(3)),

    -- Tag taxonomy obbligatoria FinOps (5 dimensioni)
    customer_id      LowCardinality(String),    -- 'cust_abc123' o 'internal'
    feature_id       LowCardinality(String),    -- 'rag_chat', 'audit_freelancer'
    environment      LowCardinality(String),    -- 'prod', 'staging', 'dev'
    component        LowCardinality(String),    -- 'retrieval', 'agent', 'generation'
    model_id         LowCardinality(String),    -- 'llama3.1:8b', 'gpt-4o'

    -- Tag opzionali (NULL ok)
    user_id          Nullable(String),
    team_id          LowCardinality(Nullable(String)),
    region           LowCardinality(Nullable(String)),
    priority         LowCardinality(Nullable(String)),

    -- Misure quantitative
    tokens_input     UInt32 CODEC(T64, ZSTD(1)),
    tokens_output    UInt32 CODEC(T64, ZSTD(1)),
    latency_ms       UInt32 CODEC(T64, ZSTD(1)),

    -- Cost in EUR (decimale a 6 cifre per gestire frazioni di centesimo)
    cost_eur         Decimal(18, 6),
    cost_amortized   Decimal(18, 6),  -- per Ollama on-prem
    cost_equiv_api   Decimal(18, 6),  -- quanto costerebbe con OpenAI

    -- Esito
    status           LowCardinality(String),  -- 'success', 'error', 'cached'
    error_type       LowCardinality(Nullable(String))
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(timestamp)
ORDER BY (customer_id, feature_id, timestamp)
TTL timestamp + INTERVAL 24 MONTH DELETE
SETTINGS index_granularity = 8192;

-- Indice secondario per query per user
ALTER TABLE cost_events
ADD INDEX idx_user_id user_id TYPE bloom_filter(0.01) GRANULARITY 4;

-- Indice per query trace correlation
ALTER TABLE cost_events
ADD INDEX idx_trace trace_id TYPE bloom_filter(0.001) GRANULARITY 1;

Le scelte chiave: LowCardinality(String) sui tag a bassa varianza (max 1000 valori distinti) comprime i dati 50-100x e accelera i GROUP BY. Decimal(18,6) per il cost evita errori di floating point sull'aggregazione di milioni di centesimi. PARTITION BY toYYYYMM permette di droppare con un comando i dati di mesi vecchi senza scansionare tutto. TTL DELETE automatizza la retention a 24 mesi (compliance GDPR e contenimento storage). Il bloom_filter sul user_id rende le query "cost per user X negli ultimi 30 giorni" sotto 50ms anche con 500M righe.

L'ORDER BY (customer_id, feature_id, timestamp) è il sorting key di ClickHouse, equivalente concettuale di un primary key cluster index in PostgreSQL ma usato come heuristic di compressione invece che come constraint di unicita. Le colonne in cima al sorting key sono quelle dove le query GROUP BY sono piu efficienti. Nel mio caso le query analitiche piu frequenti sono "cost per cliente nell'ultimo mese" (customer_id primario) e "cost per feature di un cliente specifico" (customer_id + feature_id). Se invece le tue query principali sono "cost per modello globale" l'ordering corretto sarebbe ORDER BY (model_id, timestamp), e la differenza in performance puo essere 10-50x. Sbagliare l'ordering iniziale costa caro: ClickHouse non supporta riordering live, devi creare nuova tabella e migrare con INSERT SELECT, operazione che su 100M righe puo richiedere ore.

Un altro dettaglio importante: la scelta tra ReplicatedMergeTree e semplice MergeTree. Per single-node deployment (caso del mio portfolio) MergeTree base è sufficiente. Per cluster multi-node con replication serve ReplicatedMergeTree con coordinazione via ClickHouse Keeper (ex Zookeeper). La complessita operativa cresce significativamente: monitoring di replica lag, gestione split-brain, backup distribuito. Per startup early-stage il consiglio è partire single-node e migrare a clustering solo quando hai eviedenza di problemi di scalabilita o disponibilita. Il portfolio personale gira con MergeTree single-node da 6 mesi senza un solo problema, su un VPS dedicato a 18 EUR/mese.

Sezione 4 - SQL aggregation: cost per agent step

Lo schema vale solo se le query analitiche sono leggibili e veloci. La query piu importante in produzione è "quale step dell'agent multi-tool brucia il token", per identificare colli di bottiglia di costo. Sotto trovi la query che uso nel portfolio per generare il dashboard "agent breakdown" su Grafana, eseguita ogni 5 minuti via materialized view.

-- ml-service/queries/cost_per_agent_step.sql
-- Breakdown cost per step dell'agent, ultimo periodo configurabile
-- Mostra: numero step, cost medio per step, P50/P95/P99 cost, top model usato
WITH step_costs AS (
    SELECT
        feature_id,
        component,
        -- Estrai step number dallo span_id (convenzione: 'agent.step.N')
        toUInt32OrZero(extract(span_id, 'step\\.([0-9]+)')) AS step_number,
        model_id,
        cost_eur,
        tokens_input + tokens_output AS tokens_total,
        latency_ms,
        status
    FROM cost_events
    WHERE timestamp >= now() - INTERVAL 24 HOUR
      AND environment = 'prod'
      AND component IN ('agent', 'tool_call', 'generation')
      AND feature_id = {feature:String}  -- parametro template
),
aggregated AS (
    SELECT
        feature_id,
        component,
        step_number,
        count() AS executions,
        countIf(status = 'error') AS errors,
        round(avg(cost_eur), 6) AS avg_cost_eur,
        round(quantile(0.5)(cost_eur), 6) AS p50_cost_eur,
        round(quantile(0.95)(cost_eur), 6) AS p95_cost_eur,
        round(quantile(0.99)(cost_eur), 6) AS p99_cost_eur,
        round(sum(cost_eur), 4) AS total_cost_eur,
        round(avg(tokens_total), 0) AS avg_tokens,
        round(quantile(0.95)(latency_ms), 0) AS p95_latency_ms,
        argMax(model_id, cost_eur) AS most_expensive_model
    FROM step_costs
    GROUP BY feature_id, component, step_number
)
SELECT
    component,
    step_number,
    executions,
    errors,
    round(errors * 100.0 / executions, 2) AS error_rate_pct,
    avg_cost_eur,
    p50_cost_eur,
    p95_cost_eur,
    p99_cost_eur,
    total_cost_eur,
    -- Percentuale del cost totale del feature
    round(total_cost_eur * 100.0 / sum(total_cost_eur) OVER (), 2) AS pct_of_feature_total,
    avg_tokens,
    p95_latency_ms,
    most_expensive_model
FROM aggregated
ORDER BY total_cost_eur DESC
LIMIT 50;

Il pattern quantile(0.95)(cost_eur) in ClickHouse è molto piu efficiente del calcolo via window function in Postgres: usa l'algoritmo t-digest internamente, errore <1% e velocita 10x. Il argMax(model_id, cost_eur) ritorna il modello che ha generato lo step piu costoso, utile per scoprire chiamate accidentali a GPT-4 quando il default era GPT-4o-mini. Il WHERE feature_id = {feature:String} usa il binding parametrizzato di ClickHouse: previene SQL injection e permette di riutilizzare la query da diversi entry point (Grafana variable, Python script export, alert manager).

Il calcolo della percentuale del cost totale del feature con la window function sum(total_cost_eur) OVER () è particolarmente utile per le code review settimanali con il team product. Vedi a colpo d'occhio che, ad esempio, l'agent step numero 2 (tool routing) consuma il 45% del cost totale della feature rag_chat: l'investimento ingegneristico per ottimizzarlo è giustificato. Senza la percentuale, vedi solo numeri assoluti che non hanno significato fuori contesto (cost di 18 EUR è tanto o poco? Dipende dal totale). Le metriche relative aiutano a guidare decisioni di prioritizzazione meglio di quelle assolute.

Una pratica utile in produzione è schedulare la query come materialized view per pre-aggregare i dati ogni 5 minuti, evitando il ricalcolo on-demand quando il dashboard Grafana è aperto. La sintassi: CREATE MATERIALIZED VIEW cost_per_step_5m TO cost_per_step_aggregated AS SELECT ... . Il vantaggio: la dashboard apre in 50ms invece di 2 secondi. Lo svantaggio: i dati hanno latenza di 5 minuti, non sono "ora". Per dashboard executive il trade-off è chiaramente vincente. Per debugging real-time (intercettare un attacco in corso) usi invece la query diretta su cost_events con filtri temporali stretti.

Sezione 5 - Prometheus exporter: cost per user gauge

ClickHouse va bene per analisi storiche e dashboard executive, ma per alerting real-time serve Prometheus. Il pattern: un servizio cost_tracker_service legge ClickHouse ogni 30 secondi (query molto leggera, solo gli ultimi 5 minuti), aggrega per user_id e popola un gauge Prometheus. AlertManager poi triggera notifiche su soglie configurabili (80% budget warning, 100% hard cap, 110% emergency).

# ml-service/app/observability/cost_exporter.py
import asyncio
from prometheus_client import Gauge, Counter, Histogram
from clickhouse_driver import Client
import structlog

logger = structlog.get_logger(__name__)

# Gauge: valore corrente per label combination (user_id, feature_id)
# Uso 'gauge' non 'counter' perché rappresenta lo state attuale del consumo
COST_PER_USER_EUR = Gauge(
    "llm_cost_per_user_month_eur",
    "Cost LLM per utente nel mese corrente, in EUR",
    labelnames=["user_id", "feature_id", "tier"],
)

# Counter: totale cost generato (mai decresce, per rate query PromQL)
COST_TOTAL_EUR = Counter(
    "llm_cost_total_eur",
    "Cost LLM totale generato dall'inizio del processo, EUR",
    labelnames=["customer_id", "feature_id", "model_id", "component"],
)

# Histogram: distribuzione cost per query (per p50/p95/p99 alert)
COST_PER_QUERY_EUR = Histogram(
    "llm_cost_per_query_eur",
    "Distribuzione cost per singola query LLM",
    labelnames=["feature_id", "component"],
    buckets=(0.0001, 0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0),
)

BUDGET_BY_TIER = {"free": 1.0, "pro": 50.0, "enterprise": 500.0}


async def refresh_cost_per_user_loop(clickhouse: Client, interval_sec: int = 30) -> None:
    """Loop background: aggrega cost mensile per user e aggiorna gauge.
    Idempotente: chiamabile in parallelo senza race condition (Prometheus
    Gauge.set sostituisce il valore precedente)."""
    while True:
        try:
            rows = clickhouse.execute(
                """
                SELECT
                    user_id,
                    feature_id,
                    -- Tier dedotto da customer metadata, qui semplificato
                    coalesce(any(team_id), 'free') AS tier,
                    sum(cost_eur) AS month_cost
                FROM cost_events
                WHERE timestamp >= toStartOfMonth(now())
                  AND user_id IS NOT NULL
                  AND environment = 'prod'
                GROUP BY user_id, feature_id
                """
            )
            # Reset gauge per evitare label stale (utenti inattivi questo mese)
            COST_PER_USER_EUR.clear()
            for user_id, feature_id, tier, cost in rows:
                COST_PER_USER_EUR.labels(
                    user_id=user_id,
                    feature_id=feature_id,
                    tier=tier,
                ).set(float(cost))

            logger.info("cost_per_user_refreshed", users_count=len(rows))
        except Exception as exc:
            # Mai far crashare il loop di telemetria
            logger.error("cost_exporter_loop_failed", error=str(exc))

        await asyncio.sleep(interval_sec)

L'idea della Gauge.clear() a inizio iterazione è importante: senza, gli utenti che hanno fatto query a inizio mese ma poi sono diventati inattivi continuerebbero a comparire con il loro vecchio valore, generando alert spurii e gonfiando la cardinality di Prometheus. Con clear() solo gli utenti attivi nel periodo di query (da inizio mese) compaiono nel gauge. La cardinality va comunque monitorata: con 10K utenti attivi e 5 feature il gauge ha 50K serie, che è al limite di Prometheus standalone. Sopra questa scala valuta VictoriaMetrics o sharding per tier (free su istanza separata).

Per gestire la cardinalita esplosiva esistono tre strategie complementari. (1) Aggregation by tier: invece di un gauge per ogni user_id, esponi un gauge per ogni tier (free/pro/enterprise) e un counter di violazioni per quel tier. Cardinalita ridotta da 10K a 3, ma perdi granularita per debug. (2) Top-N exposure: esponi solo i top 100 utenti per cost del mese corrente, gli altri vanno aggregati come "other". Cardinalita controllata, ma non visibilita su utenti in coda. (3) Histogram con label essenziali: usa llm_cost_per_query_eur_bucket{tier="free"} con bucket logaritmici, cardinalita fissa indipendente dal numero utenti. La scelta dipende dal caso: per il portfolio personale ho user-level perché utenti totali sono pochi (~500); per un SaaS enterprise con 100K utenti userei strategy (2) con drill-down on-demand via ClickHouse query separata.

Il asyncio.sleep(interval_sec) e non un timer fixed-rate è deliberato: se la query ClickHouse impiega 2 secondi, il prossimo refresh parte 30 secondi dopo, totale 32 secondi. Un timer fixed-rate triggererebbe un secondo refresh mentre il primo è ancora in corso, generando race condition e doppia query inutile. Per casi avanzati con piu loop concorrenti usa asyncio.Lock() esplicito. La regola "make it simple first" si applica anche al monitoring: non aggiungere complessita prima di averla misurata necessaria.

Sezione 6 - Decorator @track_cost per LangChain LCEL

Tutti gli esempi finora richiedono di chiamare manualmente il middleware. In una codebase di una certa dimensione preferisci un decorator riutilizzabile che si attacca a qualsiasi chain LangChain LCEL e logga automaticamente il cost. Il pattern usa BaseCallbackHandler di LangChain per intercettare gli eventi on_llm_end e on_llm_new_token, accumulare i token e fare flush al termine.

# ml-service/app/observability/track_cost_decorator.py
import asyncio
import functools
import time
from typing import Any, Callable
from langchain_core.callbacks import AsyncCallbackHandler
from langchain_core.outputs import LLMResult
from langchain_core.runnables import RunnableConfig
from .cost_tracking import (
    calculate_cost_async,
    current_user_id,
    current_feature_id,
    count_tokens_safe,
)
from .cost_events_client import write_cost_event_async

class CostTrackingCallback(AsyncCallbackHandler):
    """Callback handler che intercetta token usage da chiamate LLM.
    Funziona con streaming (accumulo chunk) e non-streaming (response.usage)."""

    def __init__(self, feature_id: str, component: str = "generation"):
        self.feature_id = feature_id
        self.component = component
        self.tokens_in = 0
        self.tokens_out = 0
        self.start_time: float = 0.0
        self.model_id: str = "unknown"
        self.streaming_chunks: list[str] = []

    async def on_llm_start(
        self, serialized: dict, prompts: list[str], **kwargs: Any
    ) -> None:
        """Inizio chiamata LLM: registra timestamp, modello, conta input token."""
        self.start_time = time.perf_counter()
        self.model_id = serialized.get("kwargs", {}).get("model", "unknown")
        # Conta input token sommando tutti i prompt (multi-turn chat)
        self.tokens_in = sum(count_tokens_safe(p, self.model_id) for p in prompts)

    async def on_llm_new_token(self, token: str, **kwargs: Any) -> None:
        """Streaming chunk ricevuto: accumula per token count finale."""
        self.streaming_chunks.append(token)

    async def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
        """Fine chiamata LLM: calcola cost e fa flush async (fire-and-forget)."""
        latency_ms = int((time.perf_counter() - self.start_time) * 1000)

        # Output token: prima prova usage_metadata (provider che lo supportano),
        # poi fallback su accumulazione streaming, infine count manuale
        token_usage = response.llm_output.get("token_usage", {}) if response.llm_output else {}
        self.tokens_out = token_usage.get("completion_tokens") or 0
        if self.tokens_out == 0 and self.streaming_chunks:
            self.tokens_out = count_tokens_safe(
                "".join(self.streaming_chunks), self.model_id
            )

        cost = await calculate_cost_async(self.model_id, self.tokens_in, self.tokens_out)

        # Fire-and-forget: l'INSERT non deve bloccare la response al chiamante
        asyncio.create_task(
            write_cost_event_async(
                user_id=current_user_id.get(),
                feature_id=self.feature_id,
                component=self.component,
                model_id=self.model_id,
                tokens_input=self.tokens_in,
                tokens_output=self.tokens_out,
                latency_ms=latency_ms,
                cost_eur=cost,
                status="success",
            )
        )


def track_cost(feature_id: str, component: str = "generation") -> Callable:
    """Decorator per chain LCEL: aggiunge CostTrackingCallback al config."""
    def decorator(func: Callable) -> Callable:
        @functools.wraps(func)
        async def wrapper(*args: Any, **kwargs: Any) -> Any:
            callback = CostTrackingCallback(feature_id=feature_id, component=component)
            config: RunnableConfig = kwargs.pop("config", {})
            existing_callbacks = config.get("callbacks", [])
            config["callbacks"] = list(existing_callbacks) + [callback]
            return await func(*args, config=config, **kwargs)
        return wrapper
    return decorator


# Esempio di uso su una RAG chain LCEL
@track_cost(feature_id="rag_chat", component="generation")
async def run_rag_chain(query: str, config: RunnableConfig) -> str:
    """La chain LCEL è definita altrove: retriever | prompt | llm | parser.
    Il decorator inietta il callback senza modificare il codice della chain."""
    chain = build_rag_chain()  # definita in app/rag/chain.py
    return await chain.ainvoke({"query": query}, config=config)

Il pattern è compatibile sia con chiamate sincrone che con streaming, sia con il singolo llm.ainvoke() che con la full chain retriever | prompt | llm | parser: i callback LangChain si propagano automaticamente lungo la chain LCEL, quindi anche se l'agent fa internamente 5 step LLM ognuno triggera il callback e genera un cost_event separato. L'unica accortezza: in ambienti molto loaded (>1000 query/sec) usa una asyncio.Queue bufferizzata per evitare di creare migliaia di task paralleli; il pattern asyncio.create_task diretto va bene fino a ~200 query/sec.

Per chi usa LlamaIndex invece di LangChain, il pattern equivalente è attaccare un TokenCountingHandler al callback_manager globale. La semantica è simile (intercetta token usage events, accumula totali) ma la API è diversa: in LlamaIndex il counting è integrato nel callback system core, non serve sottoclassare un base handler. La differenza pragmatica: LangChain ti da piu controllo (subclass custom, override metodi specifici) ma piu boilerplate; LlamaIndex è piu opinionated, integration in 5 linee di codice ma meno flessibile. Per il ml-service portfolio uso LangChain perché devo gestire 4 provider diversi (Ollama, OpenAI, Anthropic, sentence-transformers locali) e ho bisogno della flessibilita; per progetti single-provider RAG-only LlamaIndex è tipicamente piu rapido.

Una nota importante sul testing del cost tracking. Il decorator è code che incide su ogni chiamata LLM: bug qui possono provocare cost dropping (eventi non loggati, sotto-stima budget) o cost duplication (eventi loggati 2x, sovra-stima). I test devono coprire: (1) chiamata singola sincrona, verifica 1 evento creato con tokens corretti; (2) chiamata streaming, verifica accumulazione chunk e token finale corretto; (3) chiamata che fallisce con exception, verifica evento con status='error'; (4) chain LCEL multi-step, verifica N eventi creati con span_id distinti. Nel ml-service portfolio uso pytest con un mock LLM che ritorna usage_metadata fissa, e verifico via cost_events in memory mock che gli INSERT siano corretti. Coverage del modulo cost_tracking è al 95%, perché bug qui costano davvero soldi.

Sezione 7 - Export FinOps multi-sheet per CFO

I dati ci sono, ora bisogna presentarli a chi prende decisioni di business. Il CFO non apre Grafana, non sa cosa sia ClickHouse, e se gli mandi un CSV ti rimanda una mail "puoi metterlo in tabella?". La soluzione che ho stabilizzato nel ml-service portfolio è un export Excel multi-sheet generato il 1 di ogni mese via cron, con KPI a livello executive nel primo sheet e dettaglio drill-down nei successivi.

# ml-service/app/finops/export_monthly.py
import pandas as pd
from datetime import datetime, timedelta
from openpyxl.styles import PatternFill, Font, Alignment
from openpyxl.utils import get_column_letter
from clickhouse_driver import Client
from pathlib import Path

def export_monthly_finops(clickhouse: Client, year: int, month: int, out_dir: Path) -> Path:
    """Genera report Excel mensile per FinOps + CFO.
    Sheet: Summary, Per Customer, Per Feature, Per Component, Anomalies, Forecast."""
    period_start = datetime(year, month, 1)
    period_end = (period_start + timedelta(days=32)).replace(day=1)

    # Sheet 1: Summary KPI
    summary_df = pd.DataFrame(clickhouse.execute(
        """
        SELECT
            count(DISTINCT user_id) AS active_users,
            count(DISTINCT customer_id) AS active_customers,
            count() AS total_queries,
            round(sum(cost_eur), 2) AS total_cost_eur,
            round(sum(cost_equiv_api), 2) AS equivalent_api_cost_eur,
            round(sum(cost_equiv_api - cost_eur), 2) AS savings_vs_api_eur,
            round(sum(cost_eur) / count(), 6) AS avg_cost_per_query_eur,
            round(sum(cost_eur) / count(DISTINCT user_id), 4) AS avg_cost_per_user_eur
        FROM cost_events
        WHERE timestamp >= %(start)s AND timestamp < %(end)s
          AND environment = 'prod'
        """,
        {"start": period_start, "end": period_end},
    ), columns=[
        "active_users", "active_customers", "total_queries",
        "total_cost_eur", "equivalent_api_cost_eur", "savings_vs_api_eur",
        "avg_cost_per_query_eur", "avg_cost_per_user_eur",
    ]).T.reset_index()
    summary_df.columns = ["KPI", "Value"]

    # Sheet 2: Per Customer (chargeback B2B)
    per_customer_df = pd.DataFrame(clickhouse.execute(
        """
        SELECT
            customer_id,
            count() AS queries,
            round(sum(cost_eur), 2) AS cost_eur,
            round(sum(cost_eur) / count(), 6) AS cost_per_query_eur,
            argMax(feature_id, cost_eur) AS top_feature
        FROM cost_events
        WHERE timestamp >= %(start)s AND timestamp < %(end)s
          AND environment = 'prod'
          AND customer_id != 'internal'
        GROUP BY customer_id
        ORDER BY cost_eur DESC
        """,
        {"start": period_start, "end": period_end},
    ), columns=["customer_id", "queries", "cost_eur", "cost_per_query_eur", "top_feature"])

    # Sheet 5: Anomalies (utenti sopra 3 sigma)
    anomalies_df = pd.DataFrame(clickhouse.execute(
        """
        WITH user_stats AS (
            SELECT user_id, sum(cost_eur) AS user_cost
            FROM cost_events
            WHERE timestamp >= %(start)s AND timestamp < %(end)s
              AND user_id IS NOT NULL
            GROUP BY user_id
        ),
        global_stats AS (
            SELECT avg(user_cost) AS mu, stddevPop(user_cost) AS sigma
            FROM user_stats
        )
        SELECT user_id, user_cost,
               round((user_cost - g.mu) / nullIf(g.sigma, 0), 2) AS sigma_deviation
        FROM user_stats u CROSS JOIN global_stats g
        WHERE user_cost > g.mu + 3 * g.sigma
        ORDER BY user_cost DESC
        """,
        {"start": period_start, "end": period_end},
    ), columns=["user_id", "user_cost", "sigma_deviation"])

    # Scrittura multi-sheet con formattazione
    out_path = out_dir / f"finops_{year}_{month:02d}.xlsx"
    with pd.ExcelWriter(out_path, engine="openpyxl") as writer:
        summary_df.to_excel(writer, sheet_name="Summary", index=False)
        per_customer_df.to_excel(writer, sheet_name="Per Customer", index=False)
        anomalies_df.to_excel(writer, sheet_name="Anomalies", index=False)

        # Formatting: header grigio, font bold, autosize colonne
        for sheet_name in writer.sheets:
            sheet = writer.sheets[sheet_name]
            header_fill = PatternFill("solid", fgColor="D3D3D3")
            for cell in sheet[1]:
                cell.fill = header_fill
                cell.font = Font(bold=True)
                cell.alignment = Alignment(horizontal="center")
            for col_idx, col in enumerate(sheet.columns, start=1):
                max_len = max(len(str(c.value or "")) for c in col)
                sheet.column_dimensions[get_column_letter(col_idx)].width = min(max_len + 2, 50)

    return out_path

Il file Excel generato pesa tipicamente 50-200 KB per un mese di dati e si apre istantaneamente anche su un MacBook del CFO con 8GB RAM. Sotto trovi anche la tag taxonomy completa che usiamo, fondamentale perché un evento senza tag è un evento inutile per chargeback.

Tag Obbligatorio Cardinalita tipica Esempi Uso primario
customer_id Si 10 - 10.000 cust_abc123, internal Chargeback B2B, fatturazione cliente
feature_id Si 5 - 50 rag_chat, audit_freelancer Unit economics per prodotto SaaS
environment Si 3 - 5 prod, staging, dev Separazione cost R&D vs revenue-generating
component Si 5 - 10 retrieval, agent, generation, embedding Ottimizzazione tecnica, individuazione bottleneck
model_id Si 10 - 50 llama3.1:8b, gpt-4o-mini Model A/B test, comparazione cost/quality
team_id No 5 - 100 team_research, team_support Showback interno multi-team
region No 3 - 10 eu-west, us-east Data residency cost, GDPR analysis
priority No 3 critical, normal, batch Justification SLA tier, budget allocation

Sezione 8 - Alert su rate cost spike per user

L'ultimo pezzo è l'alerting in tempo reale. Le regole vanno scritte in PromQL e fatte caricare da Prometheus AlertManager con routing su Slack (warning) e PagerDuty (critical). Sotto la regola che uso nel portfolio per intercettare utenti che esplodono il budget e probabili abusi (bot, leak credenziali, feature flag rotto).

# infra/prometheus/alerts/llm_cost.yml
groups:
- name: llm_cost_alerts
  interval: 30s
  rules:

  # Warning: utente sopra 80% del budget mensile
  - alert: LLMUserCostWarning
    expr: |
      (
        llm_cost_per_user_month_eur{tier="free"} > 0.80
        or llm_cost_per_user_month_eur{tier="pro"} > 40
        or llm_cost_per_user_month_eur{tier="enterprise"} > 400
      )
    for: 2m
    labels:
      severity: warning
      team: ml-platform
    annotations:
      summary: "User {{ $labels.user_id }} oltre 80% budget mensile"
      description: |
        User {{ $labels.user_id }} (tier {{ $labels.tier }})
        ha consumato {{ $value | humanize }} EUR su budget previsto.
        Notificare upsell o verificare abuso.
      runbook: "https://docs.internal/runbooks/llm-cost-warning"

  # Critical: rate di cost per minuto anomalo (spike >5x baseline)
  - alert: LLMUserCostSpike
    expr: |
      (
        rate(llm_cost_total_eur[5m])
        / on() group_left avg_over_time(rate(llm_cost_total_eur[5m])[24h:5m])
      ) > 5
    for: 3m
    labels:
      severity: critical
      team: ml-platform
      page: "true"
    annotations:
      summary: "Cost rate spike: {{ $value | printf \"%.1f\" }}x baseline 24h"
      description: |
        Il rate di cost LLM è {{ $value }}x superiore alla media
        delle ultime 24h. Possibili cause: bot attack, retry loop,
        feature flag rotto, cache disabilitata.
        Investigare immediatamente i top user da dashboard.

  # Critical: utente specifico oltre hard cap (100% budget)
  - alert: LLMUserBudgetExceeded
    expr: |
      llm_cost_per_user_month_eur{tier="free"} > 1.0
      or llm_cost_per_user_month_eur{tier="pro"} > 50
      or llm_cost_per_user_month_eur{tier="enterprise"} > 500
    for: 1m
    labels:
      severity: critical
      team: ml-platform
    annotations:
      summary: "User {{ $labels.user_id }} ha sforato budget mensile"
      description: |
        User {{ $labels.user_id }} a {{ $value | humanize }} EUR.
        Sistema applica auto-cap (HTTP 429), notificare account manager
        per upsell o sospensione manuale.

Il pattern del rate spike è interessante: confronta il rate degli ultimi 5 minuti con la media del rate calcolata su finestre di 5 minuti per le ultime 24 ore. Se il rate corrente è 5x superiore alla media storica e questo persiste per 3 minuti, scatta alert critical. Questo evita falsi positivi su singoli picchi (es. utente che fa una richiesta espensiva una tantum) e cattura veri anomaly pattern (es. retry loop infinito o attacco). Nel ml-service portfolio ho settato la soglia a 5x dopo 2 settimane di tuning: piu basso (3x) generava 5-10 falsi alert al giorno, piu alto (10x) mancava abusi reali. La soglia ottimale dipende dalla varianza naturale del tuo traffico.

Le tre regole sopra coprono il 90% degli scenari di spesa anomala, ma in produzione vanno integrate con un quarto layer di auto-remediation. Quando l'alert LLMUserBudgetExceeded scatta non basta notificare: il sistema deve applicare automaticamente un cap. Pattern che uso: il cost_exporter aggiorna anche un Redis hash user:budget_status con flag boolean per user_id (true se sopra cap). Il middleware FastAPI legge questo hash all'inizio di ogni request e ritorna 429 se l'utente è flaggato. Cosi l'auto-cap è applicato in <50ms dall'evento di overshoot, senza intervento umano. L'account manager riceve la notifica Slack e contatta il cliente per upsell o sblocco manuale. Senza auto-remediation, un cliente abusivo puo bruciare 1000 EUR in 30 minuti prima che qualcuno reagisca al PagerDuty notturno.

Un dettaglio operativo importante: le soglie hardcoded nello YAML (1.0 EUR per free, 50 per pro, 500 per enterprise) sono semplificazioni didattiche. In produzione vanno parametrizzate via Helm values o configmap Kubernetes, in modo da poter aggiornare i budget tier senza rebuild dell'immagine container. Il flusso ideale: product manager chiede di alzare il budget free tier a 2 EUR/mese, DevOps modifica il configmap, AlertManager rilegge la regola entro 60 secondi, il sistema continua a girare. Tempo totale dalla decisione business al cambio in produzione: 5 minuti. Senza parametrizzazione: 30 minuti, con il rischio che qualcuno dimentichi di rebuilder. La differenza si nota dopo 6 mesi di operations.

Sezione 9 - Esempio Federico: cost zero per token Ollama, ma compute fisso 30 EUR/mese

Veniamo al caso pratico del mio portfolio personale, che è interessante perché la maggior parte degli articoli sul tema parla di provider API a pagamento (OpenAI, Anthropic) ma il mio setup è on-prem con Ollama, dove le formule cambiano. Il ml-service gira su un VPS Hetzner CCX13 dedicato (4 vCPU AMD, 16GB RAM, 80GB SSD) che costa esattamente 30 EUR/mese incluso traffico illimitato.

Su questo VPS gira Ollama con llama3.1:8b come modello principale e all-MiniLM-L6-v2 per gli embedding. Il costo per token è matematicamente zero: nessuna API esterna, nessuna fattura per chiamata, nessun usage-based billing. Ma se mostro al CFO una dashboard "cost LLM = 0 EUR/mese" non viene preso sul serio. Il vero costo è il VPS, e va ammortizzato per query.

Numeri reali misurati a marzo 2026 sul ml-service: 14.812 query mensili totali (chat RAG + dev-tools agent + summarize). Ammortizzando i 30 EUR del VPS: 30 / 14.812 = 0.00203 EUR per query, ovvero 0.2 centesimi. Ho impostato questo valore come amortized_per_query nel pricing catalog (vedi snippet sezione 2). Il sistema cost tracking riporta quindi al CFO: "cost LLM totale mensile ~30 EUR fissi, cost medio per query 0.2 cent, equivalente cost se usassi GPT-4o sarebbe ~370 EUR/mese". La savings_vs_api di 340 EUR/mese giustifica chiaramente la scelta on-prem.

Il break-even point è importante: sotto 5.000 query/mese il VPS dedicato non ha senso (il cost ammortizzato sarebbe 0.6 cent, simile a GPT-4o-mini). Tra 5K e 50K query/mese Ollama on-prem è chiaramente vincente. Sopra 50K query/mese il VPS attuale satura e devi scalare verticalmente (CCX23 a 60 EUR/mese o GPU dedicata): a quel punto il break-even si sposta. Senza cost tracking per componente non avrei avuto questi numeri per giustificare l'investimento al CFO. Con cost tracking, la conversation diventa "abbiamo 340 EUR/mese di savings dimostrabili e il VPS è al 35% di capacita, possiamo crescere senza extra cost fino a circa 40K query/mese".

Un altro vantaggio inaspettato del cost tracking strutturato è la predittibilita di pricing nei contratti enterprise. Quando un cliente B2B chiede "quanto costera l'integrazione del vostro AI nel nostro flusso", la risposta storica era "boh, dipende": vago e poco professionale. Con i dati storici per feature e per pattern d'uso posso modellare in 30 minuti uno scenario "10K query/mese di rag_chat con cost medio 0.7 cent = 70 EUR/mese, margine 50% = pricing 105 EUR/mese". La conversation commerciale diventa data-driven, il cliente apprezza la trasparenza, le contrattazioni si chiudono piu rapidamente. Negli ultimi 3 mesi il cost tracking mi ha permesso di proporre pricing-based-on-usage a 2 clienti enterprise dove prima avrei usato pricing flat con risk premium del 30% per coprire l'incertezza.

Vale la pena anche menzionare il monitoring del compute utilization oltre al puro cost. Il VPS Ollama oggi è al 35% di CPU media, 60% peak in orari di traffico (10-12 e 16-18 italiani). Conoscere questo profilo mi permette di pianificare batch job non interattivi (re-indexing notturno, generazione embedding di nuovi articoli) negli orari di basso utilizzo, dove il cost marginal è zero (il VPS sta gia girando, fallo lavorare). Senza tracking del compute oltre il cost, questi job andrebbero su orari random e potrebbero competere con il traffico utente, degradando la latency. La metrica utile: node_cpu_seconds_total di node_exporter Prometheus correlata con i cost_event timestamp via Grafana cross-panel. La correlazione tra utilizzo VPS e numero query mostrata in un grafico convince qualsiasi stakeholder della validita del modello on-prem.

FAQ

Come calcolo il costo di una chiamata LLM se uso Ollama on-prem dove non pago per token?

Il costo non è zero, è fisso e va ammortizzato per query. Nel ml-service portfolio: VPS Hetzner CCX13 a circa 30 EUR/mese / numero query mensili (15.000) = 0.002 EUR per query, ovvero 0.2 centesimi. Salvo nel cost_events table sia cost_amortized sia cost_equivalent_api (quanto avrei speso con OpenAI), cosi al CFO mostro chiaramente il risparmio reale. Sotto 5000 query/mese il break-even non c'è e conviene API esterna, sopra l'on-prem vince nettamente.

ClickHouse vs PostgreSQL per storage cost_events: quale scegliere?

Sotto 1 milione eventi/mese PostgreSQL con indici b-tree su (user_id, timestamp) è sufficiente. Sopra 10 milioni eventi/mese ClickHouse è obbligatorio: GROUP BY su 100M righe in 200ms vs 30 secondi Postgres. Nel portfolio uso PostgreSQL come fonte transazionale (ogni evento INSERT immediato dal middleware) e replico async su ClickHouse via materialized view. Storage compresso 10x: 100M eventi 8GB ClickHouse vs 80GB Postgres. Per chi parte da zero e prevede scaling, ClickHouse direttamente.

Cost per user: come gestisco utenti che superano il budget mensile senza bloccarli all'improvviso?

Tre livelli di guardrail. (1) Soft warning a 80%: notifica email, banner UI. (2) Hard cap a 100%: errore 429 con messaggio specifico, ma query in corso completano. (3) Grace period 110% per enterprise, addebito extra in fattura. Implementazione: gauge Prometheus llm_cost_per_user_eur aggiornato a ogni query. AlertManager triggera Slack a 80% e PagerDuty a 95% per intervento manuale (potrebbe essere abuso o feature flag rotto). Mai bloccare brutalmente: utente B2B che vede 429 senza warning chiama supporto in 30 secondi.

Decorator @track_cost su LangChain LCEL: funziona con streaming e async?

Si ma con attenzione su streaming. Per chiamate sincrone async standard il decorator wraps RunnableLambda e legge tokens da response.usage_metadata. Per streaming devi accumulare gli StreamingChunk e calcolare token alla fine via callback on_llm_new_token. Nel codice portfolio uso BaseCallbackHandler custom che intercetta on_llm_end e on_llm_new_token, accumula token_in/token_out e a end calcola cost e fa INSERT su cost_events. Limitazione: se provider ritorna usage_metadata=None (es. Ollama in alcune versioni) fallback su tiktoken count manuale, errore 5-10%.

Quale formato uso per export FinOps verso il CFO che usa Excel?

XLSX multi-sheet con pandas + openpyxl, generato mensilmente da cron. Sheet 1 Summary KPI, Sheet 2 Per Customer (chargeback), Sheet 3 Per Feature (unit economics), Sheet 4 Anomalies (outlier sigma 3), Sheet 5 Forecast (Prophet/ARIMA). Tutti numeri formattati EUR 2 decimali, header grigi, righe alternate. Aggiungi PDF executive summary 1 pagina via reportlab per chi non apre Excel. Mai mandare CSV grezzo: il CFO ti rimanda email "puoi formattarlo?" e perdi 2 ore.

Come distinguo tra cost spike legittimo e anomaly?

Approccio multi-segnale. (1) Baseline storica per utente: media mobile 30 giorni, spike >5x triggera alert ma non block. (2) Pattern temporale: bursting in 60 secondi è bot, query distribuite è uso legittimo. (3) Diversita prompt: stessa query 200 volte con hash uguale è bug client. (4) Cohort analysis: confronto vs media del segmento (enterprise vs free). Una query SQL ricorrente combina i 4 segnali e ritorna risk_score 0-100. Sopra 70 alert Slack manuale, sopra 90 auto-pause con notifica utente.

Tag taxonomy FinOps: quali campi obbligatori per chargeback affidabile?

Cinque tag obbligatori. (1) customer_id: cliente che paga, NULL se interno. (2) feature_id: prodotto SaaS per unit economics. (3) environment: dev/staging/prod, in dev cost a R&D non al cliente. (4) component: retrieval/agent/generation, per ottimizzazione tecnica. (5) model_id: nome esatto modello, per A/B test. Opzionali utili: team_id, region, priority. Tag mancanti = chargeback impossibile, FinOps team rimanda allo sviluppo. Validation in middleware: rifiuta INSERT se mancano i 5 obbligatori.

Conclusione

Il cost tracking per componente non è un nice-to-have da aggiungere quando arriva la prima fattura salata: è un requisito di osservabilita strutturale che va progettato insieme al sistema, prima che sia in produzione. La differenza tra un team che riceve la mail del CFO e risponde "37% del cost viene da retrieval di 3 customer enterprise specifici, ottimizzazione 2 settimane" e un team che risponde "indagine in corso" è tutta nei 5 tag obbligatori (customer/feature/env/component/model) salvati su ogni evento.

Le scelte tecniche presentate (ClickHouse per analytics, Prometheus per real-time, decorator LangChain LCEL per zero friction sviluppo, export Excel per executive) non sono dogmatiche ma rappresentano un punto di equilibrio sperimentato nel mio ml-service portfolio. Per volumi sotto 100K query/mese puoi sostituire ClickHouse con Postgres analytics, sopra 10M query/mese valuta TimescaleDB o partizionamento applicativo. Il pattern dei tag obbligatori e del middleware async invece è invariante: vale per qualsiasi scala.

Il caso Ollama on-prem mostrato nell'esempio Federico è particolarmente istruttivo perché rompe il modello mentale "cost = tokens * price". Quando il provider è self-hosted devi imparare a ammortizzare il costo fisso (VPS, GPU, manutenzione) per query e mostrare al business il cost equivalente API per giustificare l'investimento. Senza questo passaggio, l'AI on-prem appare gratis (irrealistico) o appare costosa (perché il CFO vede solo la voce VPS in budget). Con cost tracking strutturato, la conversation diventa data-driven e la decisione tecnologica si difende con numeri.

Prossimo episodio: entriamo nel mondo dell'alerting di produzione: latency spike, error rate, cost anomaly e token budget. Vedremo come tarare le soglie senza generare false alarm fatigue (il vero killer di ogni sistema di monitoring), come strutturare la escalation warning -> critical -> auto-remediation, e come integrare il cost tracking visto qui in regole AlertManager che salvano il bilancio mensile.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti