Carbon-LLMGuides · LangChain
Retour à l'accueil

LangChain + carbon-llm — tracker chaque LLM call via un BaseCallbackHandler

Guide pas à pas pour mesurer le CO₂e de chaque appel LangChain (langchain.js et Python) via un Callback Handler. Aucun patching de chain, fire-and-forget, multi-tenant, support des Runnables LCEL.

Pourquoi un Callback Handler plutôt qu'un wrapper ?

LangChain expose un système de callbacks officiel (BaseCallbackHandler en Python, BaseCallbackHandler en TS) qui s'exécute aux moments clés du cycle de vie d'une chain : on_llm_start, on_llm_end, on_chain_end, etc. C'est exactement la couche d'observabilité que LangChain a conçue pour ce cas — Langfuse, LangSmith, Helicone et carbon-llm s'y branchent tous.

Avantage vs un wrapper monkey-patch : pas de risque de casser des chains internes, pas de dépendance à une version de LangChain spécifique. Le contrat callback est stable depuis 0.0.x.

Le callback s'exécute après le LLM call mais avant que la chain reprenne — il a accès à l'usage final mais ne ralentit pas l'utilisateur car le track est fire-and-forget.

Variables d'environnement

Ajoutez votre clé API à .env.local (et aux env vars de prod).

.env.local

CARBON_LLM_API_KEY=test_xxxxxxxxxxxxxxxxxxxx
CARBON_LLM_BASE_URL=https://carbon-llm.com
CARBON_LLM_DEFAULT_TENANT_ID=default-tenant
Handler TypeScript / langchain.js

Créez lib/carbon-llm-handler.ts qui exporte une classe extending BaseCallbackHandler. Pas de dépendance externe au-delà du package langchain core que vous avez déjà.

lib/carbon-llm-handler.ts (langchain.js)

import { BaseCallbackHandler } from "@langchain/core/callbacks/base"
import type { LLMResult } from "@langchain/core/outputs"

const BASE_URL = process.env.CARBON_LLM_BASE_URL ?? "https://carbon-llm.com"
const API_KEY = process.env.CARBON_LLM_API_KEY
const DEFAULT_TENANT = process.env.CARBON_LLM_DEFAULT_TENANT_ID ?? "default"

export class CarbonLlmCallbackHandler extends BaseCallbackHandler {
  name = "carbon_llm_callback_handler"

  constructor(private opts: { tenantId?: string } = {}) {
    super()
  }

  async handleLLMEnd(output: LLMResult): Promise<void> {
    if (!API_KEY) return // dev local sans clé : no-op silencieux

    const usage = output.llmOutput?.tokenUsage as
      | { promptTokens?: number; completionTokens?: number }
      | undefined

    // Le model name est dans llmOutput selon les providers
    const model =
      (output.llmOutput?.modelName as string | undefined) ??
      (output.generations?.[0]?.[0]?.generationInfo?.model_name as string | undefined) ??
      "unknown"

    if (!usage) return

    // Fire-and-forget — pas d'await, pas de blocage chain
    fetch(`${BASE_URL}/api/v1/track`, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model,
        prompt_tokens: usage.promptTokens ?? 0,
        completion_tokens: usage.completionTokens ?? 0,
        tenant_id: this.opts.tenantId ?? DEFAULT_TENANT,
        ingestion_source: "langchain_js",
      }),
    }).catch((e) => console.error("[carbon-llm] track failed:", e))
  }
}
Usage TypeScript : par chain ou global

Trois façons d'attacher le handler. La plus simple est par chain :

Exemple langchain.js

import { ChatOpenAI } from "@langchain/openai"
import { CarbonLlmCallbackHandler } from "@/lib/carbon-llm-handler"

// Option 1 — par chain, avec tenant_id du customer courant
const result = await new ChatOpenAI({ model: "gpt-4o" }).invoke(
  "Hello world",
  { callbacks: [new CarbonLlmCallbackHandler({ tenantId: user.organizationId })] },
)

// Option 2 — global (toutes les chains de la requête)
import { CallbackManager } from "@langchain/core/callbacks/manager"
const cm = CallbackManager.fromHandlers({ handleLLMEnd: () => null })
cm.addHandler(new CarbonLlmCallbackHandler({ tenantId: user.organizationId }))

// Option 3 — LCEL : les Runnables héritent
const chain = prompt.pipe(new ChatOpenAI({ model: "gpt-4o" })).pipe(parser)
const out = await chain.invoke({ q: "..." }, {
  callbacks: [new CarbonLlmCallbackHandler({ tenantId: user.organizationId })],
})
Handler Python / langchain (Python)

Même pattern côté Python. Hérite de BaseCallbackHandler, override on_llm_end, fire-and-forget via httpx.AsyncClient.

carbon_llm_handler.py

import os
import httpx
from typing import Any, Dict, List, Optional
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.outputs import LLMResult

BASE_URL = os.environ.get("CARBON_LLM_BASE_URL", "https://carbon-llm.com")
API_KEY = os.environ.get("CARBON_LLM_API_KEY")
DEFAULT_TENANT = os.environ.get("CARBON_LLM_DEFAULT_TENANT_ID", "default")

# Pool client httpx réutilisé — évite de recréer une connexion par event
_client: Optional[httpx.AsyncClient] = None

def _get_client() -> httpx.AsyncClient:
    global _client
    if _client is None:
        _client = httpx.AsyncClient(timeout=5.0)
    return _client


class CarbonLlmHandler(BaseCallbackHandler):
    """Track LangChain LLM calls to carbon-llm. Async, fire-and-forget."""

    def __init__(self, tenant_id: Optional[str] = None) -> None:
        self.tenant_id = tenant_id or DEFAULT_TENANT

    async def on_llm_end(
        self,
        response: LLMResult,
        **kwargs: Any,
    ) -> None:
        if not API_KEY:
            return

        token_usage = (response.llm_output or {}).get("token_usage", {})
        model = (response.llm_output or {}).get("model_name", "unknown")
        prompt_tokens = token_usage.get("prompt_tokens", 0)
        completion_tokens = token_usage.get("completion_tokens", 0)

        if prompt_tokens == 0 and completion_tokens == 0:
            return

        # Fire-and-forget — log on error mais ne pas bloquer la chain
        try:
            await _get_client().post(
                f"{BASE_URL}/api/v1/track",
                headers={"Authorization": f"Bearer {API_KEY}"},
                json={
                    "model": model,
                    "prompt_tokens": prompt_tokens,
                    "completion_tokens": completion_tokens,
                    "tenant_id": self.tenant_id,
                    "ingestion_source": "langchain_python",
                },
            )
        except Exception as e:
            print(f"[carbon-llm] track failed: {e}")
Usage Python

Attacher le handler à n'importe quelle chain ou Runnable LCEL :

Exemple LangChain Python

from langchain_openai import ChatOpenAI
from carbon_llm_handler import CarbonLlmHandler

# Par chain
handler = CarbonLlmHandler(tenant_id=user.organization_id)
llm = ChatOpenAI(model="gpt-4o")
result = await llm.ainvoke("Hello", config={"callbacks": [handler]})

# LCEL — les Runnables héritent
chain = prompt | ChatOpenAI(model="gpt-4o") | parser
out = await chain.ainvoke({"q": "..."}, config={"callbacks": [handler]})
Multi-tenant : un dashboard par client final

Le tenantId / tenant_id passé au handler s'attache à chaque event. Dans un SaaS multi-tenant, instanciez le handler par requête HTTP avec l'ID du client courant. Au niveau dashboard, vous obtenez un PDF mensuel par tenant et un export ESRS E1-6 filtré.

Bonne pratique : utilisez votre UUID interne customer (immuable) plutôt que l'email — les tenants apparaîtront aussi dans le bundle audit signé.

Erreurs courantes

Si vos events n'apparaissent pas dans le dashboard, 95 % du temps c'est l'un de ces 3 problèmes :

1. Le provider ne retourne pas l'usage. Vérifiez que llm_output.token_usage / llmOutput.tokenUsage est rempli — certains providers (Bedrock, certains modèles via proxy) ne le donnent pas. Solution : passez via @langchain/openai ou @langchain/anthropic officiel, ou utilisez ChatModel.with_config({ output_token_usage: true }).

2. Le handler n'est pas attaché aux sous-chains. Les LCEL Runnables propagent les callbacks via le 2ᵉ argument config. Si vous instanciez manuellement un sous-llm, attachez-le aussi.

3. Le streaming masque l'usage. Pour streamText / .stream(), l'usage final n'est disponible qu'après le dernier chunk — overrider on_llm_end (Python) / handleLLMEnd (JS) qui s'appelle de toute façon en fin de stream.

Prêt à tracker votre stack LangChain ?

Gratuit jusqu'à 100 000 événements/mois — sans carte bancaire. Founders pricing 49 €/mois verrouillé à vie pour les 50 premiers Pro.