Analyse des utilisateurs coûteux
Utilise cette skill quand l'utilisateur souhaite comprendre les utilisateurs les plus coûteux en observabilité IA. Le travail ne consiste pas simplement à classer les utilisateurs par coût. La réponse utile explique ce qui rend les utilisateurs principaux coûteux : volume, choix de modèle, taille du prompt, taille de la sortie, comportement du cache, retries/erreurs, type de trace, dimensions de feature ou tenant, et exemples de traces représentatifs.
Pour les rollups généraux de coûts, utilise aussi exploring-llm-costs. Pour lire les traces individuelles, utilise aussi exploring-llm-traces.
Outils
| Outil | Objectif |
|---|---|
posthog:execute-sql |
Classer les utilisateurs et comparer leurs métriques à la baseline du projet |
posthog:query-llm-traces-list |
Trouver les traces coûteuses pour un utilisateur spécifique |
posthog:query-llm-trace |
Lire les traces représentatives pour expliquer ce qui s'est réellement passé |
posthog:read-data-schema |
Découvrir les propriétés d'événement ou de personne personnalisées avant groupage |
posthog:generate-app-url |
Construire des liens qualifiés par région et projet vers l'interface utilisateur |
Règles fondamentales
- Commence par une plage de temps délimitée. Si l'utilisateur n'en spécifie pas une, utilise les 30 derniers jours et dis-le. Si l'utilisateur fournit un lien ou des filtres existants, préserve la plage de dates, le filtre de compte test et les filtres de propriété.
- Commence par le coût generated-call. La requête de classement par utilisateur regroupe les lignes
$ai_generationpardistinct_id, avectraces,generations,errors,total_cost,first_seenetlast_seen. C'est le meilleur premier passage pour trouver les utilisateurs coûteux. - Pour le coût total par utilisateur, inclus les embeddings délibérément. Les rollups de coûts plus larges doivent inclure
event IN ('$ai_generation', '$ai_embedding'), mais signale quand l'ensemble d'événements change. - Filtre les défauts trace-id lors de l'interprétation des utilisateurs. Certains SDK utilisent
$ai_trace_idcommedistinct_idquand aucun utilisateur n'est défini. Pour les utilisateurs identifiés, excluredistinct_id = properties.$ai_trace_idet signale combien de dépense devient non attribuée. - Ne devin pas les dimensions personnalisées. Découvre les propriétés d'événement et de personne avant de grouper par
feature,tenant_id,plan,workflow_nameou des champs similaires spécifiques au client. - Lis les traces avant d'expliquer la causalité. Les agrégats identifient les suspects ; les traces représentatives montrent si l'utilisateur est coûteux en raison d'un workflow réel, de retries, de boucles, de contexte volumineux, de générations riches en outils ou d'autres comportements.
Workflow
1. Classer les utilisateurs par coût generated-call
Utilise ceci en premier quand la question demande les utilisateurs les plus coûteux :
posthog:execute-sql
SELECT
distinct_id,
argMax(email, timestamp) AS email,
argMax(name, timestamp) AS name,
countDistinctIf(ai_trace_id, notEmpty(ai_trace_id)) AS traces,
count() AS generations,
countIf(notEmpty(ai_error) OR ai_is_error = 'true') AS errors,
round(sum(ai_total_cost_usd), 4) AS total_cost,
round(avg(ai_total_cost_usd), 6) AS avg_cost_per_generation,
sum(ai_input_tokens) AS input_tokens,
sum(ai_output_tokens) AS output_tokens,
min(timestamp) AS first_seen,
max(timestamp) AS last_seen
FROM (
SELECT
distinct_id,
timestamp,
toString(properties.$ai_trace_id) AS ai_trace_id,
toFloat(properties.$ai_total_cost_usd) AS ai_total_cost_usd,
toString(properties.$ai_error) AS ai_error,
toString(properties.$ai_is_error) AS ai_is_error,
toInt(properties.$ai_input_tokens) AS ai_input_tokens,
toInt(properties.$ai_output_tokens) AS ai_output_tokens,
toString(person.properties.email) AS email,
toString(person.properties.name) AS name
FROM events
WHERE event = '$ai_generation'
AND timestamp >= now() - INTERVAL 30 DAY
)
GROUP BY distinct_id
ORDER BY total_cost DESC
LIMIT 25
Si l'utilisateur demande des utilisateurs identifiés, ajoute ceci dans la clause WHERE interne :
AND (
properties.$ai_trace_id IS NULL
OR distinct_id != properties.$ai_trace_id
)
Projette uniquement les colonnes d'étiquette explicites dont tu as besoin, comme email et name. Ne sélectionne jamais l'objet brut person.properties ou un tuple le contenant : il sérialise le blob de propriété complet dans le résultat et fuit les données personnelles bien au-delà d'une étiquette. Si un utilisateur n'a pas d'email ou de nom, reviens à distinct_id.
2. Établir la baseline
L'utilisateur principal n'est significatif que par rapport à tous les autres. Exécute une baseline par utilisateur pour pouvoir dire si un utilisateur est coûteux parce qu'il a plus de générations, plus de traces, un coût plus élevé par génération, des prompts plus longs, des sorties plus longues ou un taux d'erreur plus élevé.
posthog:execute-sql
WITH per_user AS (
SELECT
distinct_id,
count() AS generations,
countDistinctIf(toString(properties.$ai_trace_id), notEmpty(toString(properties.$ai_trace_id))) AS traces,
countIf(notEmpty(toString(properties.$ai_error)) OR toString(properties.$ai_is_error) = 'true') AS errors,
sum(toFloat(properties.$ai_total_cost_usd)) AS total_cost,
avg(toFloat(properties.$ai_total_cost_usd)) AS avg_cost_per_generation,
avg(toInt(properties.$ai_input_tokens)) AS avg_input_tokens,
avg(toInt(properties.$ai_output_tokens)) AS avg_output_tokens
FROM events
WHERE event = '$ai_generation'
AND timestamp >= now() - INTERVAL 30 DAY
GROUP BY distinct_id
)
SELECT
count() AS users,
round(sum(total_cost), 4) AS project_total_cost,
round(avg(total_cost), 4) AS avg_cost_per_user,
round(quantile(0.5)(total_cost), 4) AS p50_user_cost,
round(quantile(0.9)(total_cost), 4) AS p90_user_cost,
round(quantile(0.99)(total_cost), 4) AS p99_user_cost,
round(avg(avg_cost_per_generation), 6) AS mean_user_cost_per_generation,
round(avg(avg_input_tokens), 0) AS mean_user_input_tokens,
round(avg(avg_output_tokens), 0) AS mean_user_output_tokens,
round(sum(errors) / nullIf(sum(generations), 0), 4) AS error_rate
FROM per_user
Les colonnes d'agrégat extérieures sont nommées différemment des colonnes CTE qu'elles agrègent (project_total_cost, pas total_cost). HogQL résout un total_cost nu dans le sum()/avg() extérieur vers l'alias de sortie du même nom, ce qui imbrique une fonction d'agrégat dans une autre et fait échouer la requête avec Aggregate function sum(per_user.total_cost) is found inside another aggregate function. Garde les deux niveaux de noms distincts.
Si la requête de baseline génère toujours une erreur, signale que la baseline est indisponible et dis-le dans la réponse. Ne fabrique pas de chiffres p50/p90/p99 et ne prétends pas qu'un utilisateur est « Nx au-dessus de la médiane » sans eux — classe par coût absolu et partage de dépense à la place, et note que la distribution par utilisateur n'a pas pu être calculée.
Lors de la présentation des utilisateurs principaux, inclus la part de chaque utilisateur sur la dépense totale et combien de fois il dépasse p50/p90. Cela rend l'asymétrie évidente.
3. Décomposer les leviers de coût de l'utilisateur principal
Pour chaque utilisateur principal méritant une explication, décompose sa dépense par modèle et économies de tokens.
posthog:execute-sql
SELECT
toString(properties.$ai_provider) AS provider,
toString(properties.$ai_model) AS model,
count() AS generations,
countDistinctIf(toString(properties.$ai_trace_id), notEmpty(toString(properties.$ai_trace_id))) AS traces,
round(sum(toFloat(properties.$ai_total_cost_usd)), 4) AS total_cost,
round(avg(toFloat(properties.$ai_total_cost_usd)), 6) AS avg_cost_per_generation,
sum(toInt(properties.$ai_input_tokens)) AS input_tokens,
sum(toInt(properties.$ai_output_tokens)) AS output_tokens,
sum(toInt(properties.$ai_reasoning_tokens)) AS reasoning_tokens,
sum(toInt(properties.$ai_cache_read_input_tokens)) AS cache_read_tokens,
sum(toInt(properties.$ai_cache_creation_input_tokens)) AS cache_write_tokens,
round(sum(toFloat(properties.$ai_input_cost_usd)), 4) AS input_cost,
round(sum(toFloat(properties.$ai_output_cost_usd)), 4) AS output_cost,
round(sum(toFloat(properties.$ai_request_cost_usd)), 4) AS request_cost,
round(sum(toFloat(properties.$ai_web_search_cost_usd)), 4) AS web_search_cost,
countIf(notEmpty(toString(properties.$ai_error)) OR toString(properties.$ai_is_error) = 'true') AS errors
FROM events
WHERE event = '$ai_generation'
AND timestamp >= now() - INTERVAL 30 DAY
AND distinct_id = '<distinct_id>'
GROUP BY provider, model
ORDER BY total_cost DESC
Interprète le résultat en utilisant cet arbre de décision :
- Générations élevées, coût ordinaire par génération signifie que le volume est le levier.
- Coût élevé par génération, volume ordinaire signifie que les modèles coûteux, le contexte long, les sorties longues, les tokens de raisonnement, les frais de web-search ou les frais de requête sont le levier.
- Tokens d'entrée élevés pointe généralement vers du bloat contextuel, l'historique de conversation répété, les documents récupérés volumineux ou la troncature manquante.
- Tokens de sortie ou de raisonnement élevés pointe vers des réponses verbeuses, les modèles de raisonnement de type chain-of-thought, les limites de sortie manquantes ou les boucles d'outils.
- Réutilisation du cache faible avec entrée répétée élevée pointe vers le cache de prompt manqué. Utilise la formule de cache de
exploring-llm-costs/references/cache-accounting.md. - Erreurs élevées ou nombreuses traces coûteuses pointe vers les retries, les appels d'outils échoués ou les boucles. Lis les traces avant de dire lequel.
- Coût de requête ou web-search élevé pointe vers les frais fixes du provider ou les générations riches en outils, pas uniquement le volume de tokens.
4. Comparer l'utilisateur principal à tous les autres
Exécute la même décomposition de modèle ou de tokens pour l'ensemble du projet, puis compare. Ne te fie pas uniquement aux totaux bruts. Tu veux des déclarations comme « cet utilisateur a utilisé les mêmes modèles que tout le monde, mais a eu 9x plus de générations » ou « son volume était normal, mais 82 % de la dépense est allé à un modèle coûteux qui est rare ailleurs ».
Comparaisons utiles :
- Part de l'utilisateur principal sur le coût total du projet
- Générations et traces de l'utilisateur principal par rapport aux utilisateurs p50/p90
- Coût moyen par génération par rapport à la moyenne du projet
- Tokens d'entrée par génération par rapport à la moyenne du projet
- Tokens de sortie ou de raisonnement par génération par rapport à la moyenne du projet
- Taux d'erreur par rapport à la moyenne du projet
- Mix de modèle par rapport au mix de modèle global
- Taux de cache-hit par rapport au taux de cache-hit global pour le même modèle
5. Trouver les traces coûteuses de l'utilisateur
Utilise SQL pour la liste de traces classées, puis lis les traces représentatives avec posthog:query-llm-trace.
posthog:execute-sql
SELECT
toString(properties.$ai_trace_id) AS trace_id,
count() AS generations,
round(sum(toFloat(properties.$ai_total_cost_usd)), 4) AS total_cost,
round(avg(toFloat(properties.$ai_total_cost_usd)), 6) AS avg_cost_per_generation,
sum(toInt(properties.$ai_input_tokens)) AS input_tokens,
sum(toInt(properties.$ai_output_tokens)) AS output_tokens,
countIf(notEmpty(toString(properties.$ai_error)) OR toString(properties.$ai_is_error) = 'true') AS errors,
min(timestamp) AS started_at,
max(timestamp) AS ended_at
FROM events
WHERE event = '$ai_generation'
AND timestamp >= now() - INTERVAL 30 DAY
AND distinct_id = '<distinct_id>'
AND notEmpty(toString(properties.$ai_trace_id))
GROUP BY trace_id
ORDER BY total_cost DESC
LIMIT 10
Ouvre au moins les 2-3 traces principales de l'utilisateur :
posthog:query-llm-trace
{
"traceId": "<trace_id>",
"dateRange": { "date_from": "-30d" }
}
Cherche le premier motif concret qui explique l'agrégat :
- appels d'outils répétés ou boucles de retry
- fenêtres contextuelles volumineuses ou documents récupérés répétés
- sessions multi-tours longues
- modèle coûteux sélectionné pour les tâches ordinaires
- nombreux petits appels du même workflow
- sorties verbeuses ou raisonnement sans contrainte
- appels lourds en web-search ou frais de requête
- erreurs qui ont toujours encouru des coûts de modèle
6. Vérifier les dimensions personnalisées quand l'agrégat est ambigu
Si l'utilisateur principal semble coûteux mais la décomposition modèle/tokens n'explique pas pourquoi, découvre les propriétés d'événement personnalisées sur $ai_generation et groupe par les dimensions de produit probables. Les exemples courants sont feature, tenant_id, organization_id, workflow_name, agent, route ou environment, mais ne devin pas.
- Appelle
posthog:read-data-schemaaveckind: "event_properties"etevent_name: "$ai_generation". - Pour les champs prometteurs, appelle
posthog:read-data-schemaaveckind: "event_property_values"pour confirmer les valeurs réelles. - Groupe le coût de l'utilisateur principal par la propriété découverte.
posthog:execute-sql
SELECT
toString(properties.<property_name>) AS dimension,
count() AS generations,
countDistinctIf(toString(properties.$ai_trace_id), notEmpty(toString(properties.$ai_trace_id))) AS traces,
round(sum(toFloat(properties.$ai_total_cost_usd)), 4) AS total_cost,
round(avg(toFloat(properties.$ai_total_cost_usd)), 6) AS avg_cost_per_generation
FROM events
WHERE event = '$ai_generation'
AND timestamp >= now() - INTERVAL 30 DAY
AND distinct_id = '<distinct_id>'
AND isNotNull(properties.<property_name>)
GROUP BY dimension
ORDER BY total_cost DESC
LIMIT 20
C'est souvent la différence entre « l'utilisateur 123 est coûteux » et « leur workflow d'examen de contrat est coûteux parce que chaque exécution alimente un document de 90k tokens au modèle le plus cher ».
Construire des liens d'interface utilisateur
Utilise posthog:generate-app-url pour les liens. Ne codifie pas l'hôte car le projet peut être dans une région différente.
- Liste des traces :
generate-app-url { "url": "/ai-observability/traces" } - Trace unique :
generate-app-url { "url": "/ai-observability/traces/{id}", "params": { "id": "<trace_id>" } }
Pour une trace unique, ajoute ?timestamp=<url_encoded_started_at> quand tu as le timestamp de la trace pour que l'interface utilisateur ouvre la bonne fenêtre de temps.
Forme de la réponse
Mène avec la réponse, pas les requêtes. Une bonne réponse contient :
- Utilisateurs principaux - classés par coût total, avec coût total, part de dépense, générations, traces, coût moyen par génération et taux d'erreur. Identifie chaque utilisateur par une étiquette seulement (email, nom ou
distinct_id). N'imprime pas les objets brutsperson.propertiesou d'autres champs personnels que l'utilisateur n'a pas demandés. - Pourquoi ils sont coûteux - un ou deux leviers concrets par utilisateur, comparés à la baseline.
- Preuve - décompositions modèle/tokens/cache/dimension personnalisée plus traces d'exemple liées que tu as lues.
- Leviers probables - idées d'optimisation spécifiques liées au levier observé : réduire le contexte, plafonner la sortie, utiliser un modèle moins cher pour un workflow, améliorer le cache, corriger les boucles de retry ou diviser le trafic d'une feature.
- Avertissements - que le résultat inclut les embeddings, exclut les défauts trace-id ou utilise un ensemble d'événements différent du classement initial.
Évite les conseils génériques. « Utilise des modèles moins chers » n'est pas utile à moins que les données montrent que le mix de modèles est le levier. « Réduis la taille du prompt » n'est pas utile à moins que les tokens d'entrée soient élevés par rapport à la baseline.
Skills connexes
exploring-llm-costs— dépense au niveau du projet : totaux, décompositions et régressions de coûtsexploring-llm-traces— lire les traces derrière les générations coûteuses d'un utilisateur