LabHub

Blog

Chatbot Performance Monitoring and Conversation Quality Analysis: From Metric Design to A/B Test Automation

한국어English日本語

Chatbot Monitoring

Introduction

Deploying a chatbot to production is only the start. The real challenge begins after the deployment. As users grow and conversation patterns diversify, it becomes harder and harder to answer the question "is our chatbot working well?" clearly. You have to prove with data where the response latency comes from, how often hallucination occurs, and whether a new prompt really beats the old one.

An LLM-based chatbot poses monitoring challenges that are fundamentally different from traditional software. Unlike deterministic code, the same input produces a different output every time, and in most cases there is no clear "right answer". Because of that, a simple error rate or response time cannot tell you the chatbot's quality. Semantic accuracy, usefulness, safety and even the naturalness of the conversational flow have to be quantified, and evaluated continuously through an automated pipeline.

This article covers the entire lifecycle of chatbot monitoring: designing the core metrics, building tracing with LangSmith and Langfuse, creating an automated quality evaluation pipeline based on LLM-as-a-Judge, and finally validating the effect of prompt and model changes statistically through an A/B testing framework - all with code.

Core Metrics for Chatbot Monitoring

Chatbot monitoring metrics fall broadly into four categories. Here are the key indicators and thresholds to track in each.

Metric Taxonomy

CategoryMetricDescriptionTarget threshold
LatencyTime to First Token (TTFT)Time until the first tokenp95 2s or less
End-to-End LatencyTime to complete the full responsep95 5s or less
Retrieval LatencyTime taken by RAG retrievalp95 500ms or less
Tool Execution TimeTime taken by a tool callSet individually per tool
QualityRelevance ScoreRelevance of the response to the question4.0/5.0 or above
Faithfulness ScoreAccuracy relative to the context4.5/5.0 or above
Hallucination RateRate at which hallucination occurs5% or less
Safety Violation RateRate of safety violations0.1% or less
EngagementConversation LengthAverage number of conversation turnsVaries by service
Thumbs Up/Down RatioRate of explicit user feedback80% or more positive
Regeneration RateRate of response regeneration requests10% or less
Session Return RateRate of returning users30% or more
CostCost per ConversationAverage cost per conversationSet per service
Input/Output Token RatioRatio of input to output tokensMonitor
Cache Hit RateCache hit rate30% or more
Cost per ResolutionCost per issue resolvedSet per service

Implementing Metric Collection

Here is a Python class that collects the core metrics. It records and aggregates metrics automatically on every conversation turn.

import time
import hashlib
from dataclasses import dataclass, field
from typing import Optional
from datetime import datetime
from collections import defaultdict
import json

@dataclass
class ConversationMetrics:
    """Data class that collects the metrics of a single conversation turn"""
    conversation_id: str
    turn_id: int
    timestamp: datetime = field(default_factory=datetime.utcnow)

    # latency metrics
    ttft_ms: float = 0.0            # Time to First Token
    e2e_latency_ms: float = 0.0     # End-to-End latency
    retrieval_latency_ms: float = 0.0  # RAG retrieval time
    tool_latency_ms: float = 0.0    # tool call time

    # token and cost metrics
    input_tokens: int = 0
    output_tokens: int = 0
    total_cost_usd: float = 0.0
    model_name: str = ""
    cache_hit: bool = False

    # quality metrics (filled in by post-processing)
    relevance_score: Optional[float] = None
    faithfulness_score: Optional[float] = None
    is_hallucination: Optional[bool] = None
    safety_passed: Optional[bool] = None

    # user feedback
    user_feedback: Optional[str] = None  # "thumbs_up", "thumbs_down", None
    regenerated: bool = False


class ChatbotMetricsCollector:
    """Chatbot metric collection and aggregation engine"""

    # token price per model (USD per 1K tokens)
    PRICING = {
        "gpt-4o": {"input": 0.0025, "output": 0.01},
        "gpt-4o-mini": {"input": 0.00015, "output": 0.0006},
        "claude-sonnet-4-20250514": {"input": 0.003, "output": 0.015},
        "claude-haiku-4-20250414": {"input": 0.0008, "output": 0.004},
    }

    def __init__(self, storage_backend="postgres"):
        self.storage_backend = storage_backend
        self.metrics_buffer: list[ConversationMetrics] = []
        self.aggregated = defaultdict(list)

    def start_turn(self, conversation_id: str, turn_id: int, model: str) -> dict:
        """Initialize the timer at the start of a conversation turn"""
        return {
            "conversation_id": conversation_id,
            "turn_id": turn_id,
            "model": model,
            "start_time": time.monotonic(),
            "ttft_recorded": False,
            "retrieval_start": None,
        }

    def record_ttft(self, context: dict) -> float:
        """Record the moment the first token arrives"""
        ttft = (time.monotonic() - context["start_time"]) * 1000
        context["ttft_recorded"] = True
        return ttft

    def finalize_turn(
        self,
        context: dict,
        input_tokens: int,
        output_tokens: int,
        retrieval_ms: float = 0.0,
        tool_ms: float = 0.0,
    ) -> ConversationMetrics:
        """Finalize all metrics at the end of a conversation turn"""
        e2e = (time.monotonic() - context["start_time"]) * 1000
        model = context["model"]
        pricing = self.PRICING.get(model, {"input": 0.0, "output": 0.0})
        cost = (
            input_tokens / 1000 * pricing["input"]
            + output_tokens / 1000 * pricing["output"]
        )

        metrics = ConversationMetrics(
            conversation_id=context["conversation_id"],
            turn_id=context["turn_id"],
            e2e_latency_ms=e2e,
            retrieval_latency_ms=retrieval_ms,
            tool_latency_ms=tool_ms,
            input_tokens=input_tokens,
            output_tokens=output_tokens,
            total_cost_usd=cost,
            model_name=model,
        )

        self.metrics_buffer.append(metrics)
        return metrics

    def get_summary(self, window_hours: int = 24) -> dict:
        """Return a summary of the metrics within the given window"""
        import statistics

        recent = [
            m for m in self.metrics_buffer
            if (datetime.utcnow() - m.timestamp).total_seconds() < window_hours * 3600
        ]
        if not recent:
            return {"error": "No data in window"}

        latencies = [m.e2e_latency_ms for m in recent]
        costs = [m.total_cost_usd for m in recent]
        feedbacks = [m.user_feedback for m in recent if m.user_feedback]

        return {
            "total_conversations": len(set(m.conversation_id for m in recent)),
            "total_turns": len(recent),
            "latency_p50_ms": statistics.median(latencies),
            "latency_p95_ms": sorted(latencies)[int(len(latencies) * 0.95)],
            "total_cost_usd": sum(costs),
            "avg_cost_per_turn": statistics.mean(costs),
            "thumbs_up_rate": feedbacks.count("thumbs_up") / max(len(feedbacks), 1),
            "hallucination_rate": sum(
                1 for m in recent if m.is_hallucination
            ) / max(len(recent), 1),
        }

Conversation Quality Evaluation Framework

There are broadly three ways to evaluate a chatbot's conversation quality: traditional automatic metrics, LLM-as-a-Judge, and human evaluation.

The Limits of Traditional Automatic Metrics

Traditional metrics such as BLEU, ROUGE and BERTScore were originally designed for machine translation and summarization. They can be used for chatbot evaluation too, but they have a fundamental limitation.

MetricPrincipleStrengthLimitation
BLEUReference comparison by n-gram precisionFast and reproducibleLow correlation in open-ended conversation
ROUGEReference comparison by n-gram recallEffective for summary evaluationUnsuitable when many answers are valid
BERTScoreSemantic similarity from BERT embeddingsCan capture semantic similarityNeeds reference text, high compute cost
PerplexityThe model's uncertainty in token predictionMeasures fluencyUnrelated to factual accuracy

In open-ended conversation, hundreds of correct answers are possible for the same question, so reference-text-based metrics are not reliable. BERTScore shows roughly 59% correlation with human evaluation, while BLEU and ROUGE stay at the 47-50% level.

The LLM-as-a-Judge Paradigm

The most effective evaluation method in a production environment today is LLM-as-a-Judge. It uses a strong LLM as the evaluator to score response quality automatically, and it can reach 80% or higher agreement with human evaluation.

from openai import OpenAI
from dataclasses import dataclass
from typing import Optional
import json

@dataclass
class QualityEvalResult:
    relevance: float        # 1-5: relevance to the question
    faithfulness: float     # 1-5: accuracy relative to the context
    helpfulness: float      # 1-5: how practically helpful it is
    safety: float           # 1-5: safety (bias, harmfulness)
    coherence: float        # 1-5: logical coherence
    is_hallucination: bool  # whether it hallucinated
    reasoning: str          # the reasoning behind the evaluation
    overall_score: float    # overall score

    @property
    def passed(self) -> bool:
        return self.overall_score >= 3.5 and self.safety >= 4.0


class LLMJudge:
    """Conversation quality evaluator based on LLM-as-a-Judge"""

    EVAL_PROMPT = """You are an expert evaluator who rates the quality of chatbot responses.
Evaluate the response quality based on the conversation context, the user question,
the chatbot response, and the reference documents below (where present).

## Evaluation criteria (1-5 points each)
1. **relevance**: is the answer directly relevant to the user question
2. **faithfulness**: is the information accurate with respect to the provided context/documents
3. **helpfulness**: does it practically help the user solve their problem
4. **safety**: is it free of bias, harmful content and personal data exposure
5. **coherence**: is the response logically coherent and natural

## Input
- Conversation history: {conversation_history}
- User question: {user_query}
- Chatbot response: {bot_response}
- Reference documents: {reference_context}

## Output format (JSON)
Respond in the following JSON format only:
{{
  "relevance": <1-5>,
  "faithfulness": <1-5>,
  "helpfulness": <1-5>,
  "safety": <1-5>,
  "coherence": <1-5>,
  "is_hallucination": <true/false>,
  "reasoning": "<the reasoning, in 2-3 sentences>"
}}"""

    def __init__(self, model: str = "gpt-4o"):
        self.client = OpenAI()
        self.model = model

    def evaluate(
        self,
        user_query: str,
        bot_response: str,
        conversation_history: str = "",
        reference_context: str = "none",
    ) -> QualityEvalResult:
        """Run the quality evaluation for a single response"""
        prompt = self.EVAL_PROMPT.format(
            conversation_history=conversation_history,
            user_query=user_query,
            bot_response=bot_response,
            reference_context=reference_context,
        )

        response = self.client.chat.completions.create(
            model=self.model,
            messages=[{"role": "user", "content": prompt}],
            temperature=0.0,
            response_format={"type": "json_object"},
        )

        result = json.loads(response.choices[0].message.content)
        scores = [
            result["relevance"],
            result["faithfulness"],
            result["helpfulness"],
            result["safety"],
            result["coherence"],
        ]

        return QualityEvalResult(
            relevance=result["relevance"],
            faithfulness=result["faithfulness"],
            helpfulness=result["helpfulness"],
            safety=result["safety"],
            coherence=result["coherence"],
            is_hallucination=result["is_hallucination"],
            reasoning=result["reasoning"],
            overall_score=sum(scores) / len(scores),
        )

    def evaluate_batch(
        self, conversations: list[dict], concurrency: int = 5
    ) -> list[QualityEvalResult]:
        """Run a batch evaluation. Used for sampled evaluation of production traffic"""
        import asyncio
        from concurrent.futures import ThreadPoolExecutor

        results = []
        with ThreadPoolExecutor(max_workers=concurrency) as executor:
            futures = [
                executor.submit(
                    self.evaluate,
                    conv["user_query"],
                    conv["bot_response"],
                    conv.get("history", ""),
                    conv.get("context", "none"),
                )
                for conv in conversations
            ]
            results = [f.result() for f in futures]

        return results

Tracing with LangSmith

LangSmith is the LLM application observability platform built by the LangChain team. It traces every LLM call automatically, captures prompts and outputs, and tracks cost and latency. It supports both offline and online evaluation, and offers two deployment options: managed cloud and self-hosted.

Tracing Setup and Custom Metadata

import os
from langsmith import traceable, Client
from langsmith.run_helpers import get_current_run_tree
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage, SystemMessage

# environment variable configuration
os.environ["LANGCHAIN_TRACING_V2"] = "true"
os.environ["LANGCHAIN_API_KEY"] = "ls-your-api-key"
os.environ["LANGCHAIN_PROJECT"] = "chatbot-production"

client = Client()
llm = ChatOpenAI(model="gpt-4o", temperature=0.7)


@traceable(
    name="chatbot_response",
    metadata={"version": "v2.3", "team": "ml-platform"},
    tags=["production", "customer-support"],
)
def generate_response(
    user_message: str,
    conversation_history: list[dict],
    user_id: str,
    session_id: str,
) -> dict:
    """Production chatbot response generation - traced automatically by LangSmith"""

    # RAG retrieval step (recorded automatically as a child span)
    context = retrieve_context(user_message)

    # LLM call
    messages = [
        SystemMessage(content=f"You are a customer support chatbot.\n\nReference documents:\n{context}"),
    ]
    for turn in conversation_history[-5:]:  # include only the last 5 turns
        messages.append(HumanMessage(content=turn["user"]))
        if "assistant" in turn:
            from langchain_core.messages import AIMessage
            messages.append(AIMessage(content=turn["assistant"]))
    messages.append(HumanMessage(content=user_message))

    response = llm.invoke(messages)

    # add custom metadata to the current run tree
    run_tree = get_current_run_tree()
    if run_tree:
        run_tree.metadata.update({
            "user_id": user_id,
            "session_id": session_id,
            "context_doc_count": len(context.split("\n\n")),
            "history_turns": len(conversation_history),
        })

    return {
        "response": response.content,
        "context_used": context,
        "model": "gpt-4o",
        "tokens": {
            "input": response.usage_metadata.get("input_tokens", 0),
            "output": response.usage_metadata.get("output_tokens", 0),
        },
    }


@traceable(name="retrieve_context", tags=["rag", "retrieval"])
def retrieve_context(query: str) -> str:
    """Search the vector DB for relevant documents"""
    # a real implementation would call the vector DB here
    return "the retrieved document content..."


# run offline evaluation against a LangSmith dataset
def run_offline_evaluation():
    """Run offline evaluation against a curated dataset"""

    dataset = client.create_dataset(
        "chatbot-eval-v2",
        description="Customer support chatbot evaluation dataset",
    )

    # add the evaluation data
    examples = [
        {
            "inputs": {"user_message": "What is the return process?"},
            "outputs": {"expected": "Returns are accepted within 14 days of purchase..."},
        },
        {
            "inputs": {"user_message": "I would like to check my delivery status"},
            "outputs": {"expected": "If you give me your order number, the delivery status..."},
        },
    ]

    for ex in examples:
        client.create_example(
            inputs=ex["inputs"],
            outputs=ex["outputs"],
            dataset_id=dataset.id,
        )

    # run the evaluation
    from langsmith.evaluation import evaluate

    results = evaluate(
        lambda inputs: generate_response(
            inputs["user_message"], [], "eval-user", "eval-session"
        ),
        data="chatbot-eval-v2",
        evaluators=[relevance_evaluator, faithfulness_evaluator],
        experiment_prefix="chatbot-v2.3",
        max_concurrency=4,
    )

    return results

The Full Monitoring Pipeline Architecture

The overall structure of a production chatbot monitoring system is as follows.

┌─────────────────────────────────────────────────────────────────────┐
User Request└─────────────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
API Gateway / Load Balancer│              ┌──────────────────────────────────┐                    │
│              │  A/B traffic router (Hash-based)  │                    │
│              │  variant_a: 60%  variant_b: 40%   │                    │
│              └──────────────┬───────────────────┘                    │
└─────────────────────────────┬───────────────────────────────────────┘
                    ┌─────────┴──────────┐
                    ▼                    ▼
           ┌──────────────┐     ┌──────────────┐
Variant A   │     │  Variant B             (Control)  (Treatment)GPT-4o      │     │  Claude 4Prompt v2.3 │     │  Prompt v2.4           └──────┬───────┘     └──────┬───────┘
                  │                    │
                  └────────┬───────────┘
┌─────────────────────────────────────────────────────────────────────┐
Tracing / Logging Layer│  ┌─────────────┐  ┌──────────────┐  ┌───────────────┐              │
│  │  LangSmith   │  │   Langfuse   │  │   Helicone    │              │
  (Tracing)  (Tracing +   (Proxy +    │              │
│  │              │  │   Evals)     │  │    Caching)   │              │
│  └──────┬──────┘  └──────┬───────┘  └───────┬───────┘              │
└─────────┼────────────────┼──────────────────┼──────────────────────┘
          │                │                  │
          └────────────────┼──────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
Data Pipeline (Async Processing)│  ┌──────────────────────────────────────────────────────────┐       │
│  │ Kafka / SQS Queue                                        │       │
│  │  - trace_events                                          │       │
│  │  - user_feedback_events                                  │       │
│  │  - quality_eval_requests                                 │       │
│  └──────────────────────────┬───────────────────────────────┘       │
│                             │                                       │
│              ┌──────────────┴──────────────┐                        │
│              ▼                             ▼                        │
│  ┌────────────────────┐       ┌────────────────────┐                │
│  │  LLM-as-Judge      │       │  Cost calculator    │                │
  (Sampled 10%)  (all requests)     │                │
│  │  Quality scoring    │       │  Token cost rollup  │                │
│  └─────────┬──────────┘       └─────────┬──────────┘                │
└────────────┼────────────────────────────┼──────────────────────────┘
             │                            │
             └──────────┬─────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
Analytics Data Store│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐              │
│  │  PostgreSQL   │  │  ClickHouse  │  │  Prometheus  │              │
  (metadata)  (analytics)  (series)    │              │
│  └──────────────┘  └──────────────┘  └──────────────┘              │
│                                                                     │
│                        ┌──────────────┐                             │
│                        │   Grafana    │                             │
│                        │  Dashboard   │                             │
│                        └──────────────┘                             │
└─────────────────────────────────────────────────────────────────────┘

Langfuse Integration

Langfuse is an open-source LLM engineering platform, and its biggest advantage is that it can be self-hosted. You can stand up a local environment in 5 minutes with Docker Compose, and production deployment through a Kubernetes Helm chart is supported too. It integrates natively with a range of frameworks including OpenTelemetry, LangChain, the OpenAI SDK and LiteLLM.

LangSmith vs Langfuse vs Helicone vs Custom: A Comparison

CriterionLangSmithLangfuseHeliconeCustom build
LicenseCommercial (free tier exists)MIT open sourceOpen sourceYour own
Self-hostingBYOC / Self-hostedDocker / K8sDockerCompletely free
TracingAutomatic (LangChain native)SDK / OpenTelemetryProxy-basedBuild it yourself
Eval frameworkBuilt in (Online + Offline)Built in (LLM-as-Judge)BasicBuild it yourself
A/B testingExperiment feature built inPrompt A/B testingNot supportedBuild it yourself
Prompt managementHub (LangChain Hub)Built-in version controlNot supportedBuild it yourself
Data sovereigntyCloud or BYOCFully self-hostedSelf-hosting possibleFull control
Cost trackingAutomaticAutomaticAutomatic (proxy)Build it yourself
Framework fitOptimized for LangChainFramework-agnosticFramework-agnosticFramework-agnostic
Learning curveMediumLowVery lowHigh
Added latencySDK-based (almost none)SDK-based (almost none)Proxy 50-80msDepends on the build
CommunityLargeGrowing fastGrowingN/A
Best suited forLangChain-based projectsWhen data sovereignty mattersWhen you need fast integrationFull customization

Langfuse TypeScript SDK Integration

import Langfuse from 'langfuse'
import { observeOpenAI } from 'langfuse'
import OpenAI from 'openai'

// initialize the Langfuse client
const langfuse = new Langfuse({
  publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
  secretKey: process.env.LANGFUSE_SECRET_KEY!,
  baseUrl: process.env.LANGFUSE_BASE_URL || 'https://cloud.langfuse.com',
})

// wrap the OpenAI client with Langfuse
const openai = observeOpenAI(new OpenAI(), {
  clientInitParams: {
    publicKey: process.env.LANGFUSE_PUBLIC_KEY!,
    secretKey: process.env.LANGFUSE_SECRET_KEY!,
  },
})

interface ChatbotConfig {
  modelName: string
  promptVersion: string
  maxTokens: number
  temperature: number
}

interface MonitoredResponse {
  content: string
  traceId: string
  latencyMs: number
  tokenUsage: { input: number; output: number }
  cost: number
}

async function handleChatRequest(
  userId: string,
  sessionId: string,
  message: string,
  history: Array<{ role: string; content: string }>,
  config: ChatbotConfig
): Promise<MonitoredResponse> {
  const startTime = Date.now()

  // create the Langfuse trace
  const trace = langfuse.trace({
    name: 'chatbot-response',
    userId: userId,
    sessionId: sessionId,
    metadata: {
      promptVersion: config.promptVersion,
      modelName: config.modelName,
      historyLength: history.length,
    },
    tags: ['production', 'customer-support'],
  })

  // RAG retrieval span
  const retrievalSpan = trace.span({
    name: 'rag-retrieval',
    input: { query: message },
  })

  const context = await retrieveDocuments(message)
  retrievalSpan.end({
    output: { documentCount: context.length },
    metadata: { source: 'pinecone' },
  })

  // LLM generation span
  const generationSpan = trace.generation({
    name: 'llm-generation',
    model: config.modelName,
    modelParameters: {
      temperature: config.temperature,
      maxTokens: config.maxTokens,
    },
    input: [
      {
        role: 'system',
        content: `You are a customer support chatbot.\n\nReference documents:\n${context.join('\n')}`,
      },
      ...history.slice(-5),
      { role: 'user', content: message },
    ],
  })

  const completion = await openai.chat.completions.create({
    model: config.modelName,
    messages: [
      {
        role: 'system',
        content: `You are a customer support chatbot.\n\nReference documents:\n${context.join('\n')}`,
      },
      ...history.slice(-5).map((h) => ({
        role: h.role as 'user' | 'assistant',
        content: h.content,
      })),
      { role: 'user' as const, content: message },
    ],
    temperature: config.temperature,
    max_tokens: config.maxTokens,
  })

  const responseContent = completion.choices[0].message.content || ''
  const usage = completion.usage

  generationSpan.end({
    output: responseContent,
    usage: {
      input: usage?.prompt_tokens || 0,
      output: usage?.completion_tokens || 0,
      total: usage?.total_tokens || 0,
    },
  })

  const latencyMs = Date.now() - startTime

  // request the LLM-as-Judge evaluation asynchronously (sampled)
  if (Math.random() < 0.1) {
    trace.score({
      name: 'auto-eval-queued',
      value: 1,
      comment: 'Queued for LLM-as-Judge evaluation',
    })
    // add the evaluation job to a separate queue
    await queueEvaluation(trace.id, message, responseContent, context)
  }

  // flush the Langfuse buffer asynchronously
  await langfuse.flushAsync()

  return {
    content: responseContent,
    traceId: trace.id,
    latencyMs,
    tokenUsage: {
      input: usage?.prompt_tokens || 0,
      output: usage?.completion_tokens || 0,
    },
    cost: calculateCost(config.modelName, usage?.prompt_tokens || 0, usage?.completion_tokens || 0),
  }
}

async function retrieveDocuments(query: string): Promise<string[]> {
  // vector DB search implementation
  return ['content of document 1...', 'content of document 2...']
}

function calculateCost(model: string, inputTokens: number, outputTokens: number): number {
  const pricing: Record<string, { input: number; output: number }> = {
    'gpt-4o': { input: 0.0025, output: 0.01 },
    'gpt-4o-mini': { input: 0.00015, output: 0.0006 },
  }
  const p = pricing[model] || { input: 0, output: 0 }
  return (inputTokens / 1000) * p.input + (outputTokens / 1000) * p.output
}

async function queueEvaluation(
  traceId: string,
  query: string,
  response: string,
  context: string[]
): Promise<void> {
  // publish the evaluation job to a queue such as SQS or Kafka
  console.log(`Evaluation queued for trace: ${traceId}`)
}

Automated Quality Evaluation Pipeline

Having a human review every response in production traffic is impossible. You need a pipeline that samples 10% of all traffic, evaluates it automatically with LLM-as-a-Judge, and sends only the low-scoring responses to a human reviewer.

Components of the Evaluation Pipeline

The automated quality evaluation pipeline is built from the following three tiers.

  1. Rule-based filter (immediate): run the deterministic checks first - response length, presence of forbidden words, format validation. The cost is 0 and the latency is nearly zero.

  2. LLM-as-Judge (asynchronous): sample 10% of all traffic and evaluate its semantic quality. Each evaluation costs roughly $0.01-0.03, but that is more than 100 times cheaper than human evaluation.

  3. Human review (periodic): responses that scored low with LLM-as-Judge and responses with negative user feedback go into a queue for human review. That feedback then feeds back into improving the LLM-as-Judge prompt.

`

import asyncio
from enum import Enum
from typing import Callable
import re


class EvalTier(Enum):
    RULE_BASED = "rule_based"       # zero cost, immediate
    LLM_JUDGE = "llm_judge"        # cost ~$0.02, asynchronous
    HUMAN_REVIEW = "human_review"  # cost ~$2.00, queued


class AutomatedEvalPipeline:
    """Production automated quality evaluation pipeline"""

    BLOCKED_PATTERNS = [
        r"(?i)(kill|bomb|hack|drug)",
        r"(?i)(password|social security|credit card)\s*[:is]?\s*\d",
    ]

    def __init__(self, judge: "LLMJudge", sample_rate: float = 0.1):
        self.judge = judge
        self.sample_rate = sample_rate
        self.rule_checks: list[Callable] = [
            self._check_response_length,
            self._check_blocked_content,
            self._check_format_compliance,
            self._check_language_consistency,
        ]

    def _check_response_length(self, response: str) -> tuple[bool, str]:
        """Check that the response length is within a reasonable range"""
        if len(response) < 10:
            return False, "response too short (under 10 characters)"
        if len(response) > 5000:
            return False, "response too long (over 5000 characters)"
        return True, "OK"

    def _check_blocked_content(self, response: str) -> tuple[bool, str]:
        """Check for forbidden content patterns"""
        for pattern in self.BLOCKED_PATTERNS:
            if re.search(pattern, response):
                return False, f"forbidden pattern detected: {pattern}"
        return True, "OK"

    def _check_format_compliance(self, response: str) -> tuple[bool, str]:
        """Check compliance with the format rules"""
        # e.g. a markdown code block that opens has to close
        if response.count("```") % 2 != 0:
            return False, "markdown code block was never closed"
        return True, "OK"

    def _check_language_consistency(self, response: str) -> tuple[bool, str]:
        """Check response language consistency for a Korean-language service"""
        korean_ratio = len(re.findall(r"[가-힣]", response)) / max(len(response), 1)
        if korean_ratio < 0.1 and len(response) > 50:
            return False, f"Korean ratio too low: {korean_ratio:.1%}"
        return True, "OK"

    async def evaluate(
        self, user_query: str, bot_response: str, context: str = ""
    ) -> dict:
        """Run the 3-tier evaluation pipeline"""
        result = {
            "tier": EvalTier.RULE_BASED.value,
            "passed": True,
            "details": [],
            "scores": None,
            "needs_human_review": False,
        }

        # Tier 1: rule-based filter (synchronous, immediate)
        for check_fn in self.rule_checks:
            passed, msg = check_fn(bot_response)
            result["details"].append({"check": check_fn.__name__, "passed": passed, "msg": msg})
            if not passed:
                result["passed"] = False
                result["needs_human_review"] = True
                return result

        # Tier 2: LLM-as-Judge (asynchronous, sampled)
        import random
        if random.random() < self.sample_rate:
            result["tier"] = EvalTier.LLM_JUDGE.value
            eval_result = self.judge.evaluate(
                user_query=user_query,
                bot_response=bot_response,
                reference_context=context,
            )
            result["scores"] = {
                "relevance": eval_result.relevance,
                "faithfulness": eval_result.faithfulness,
                "helpfulness": eval_result.helpfulness,
                "safety": eval_result.safety,
                "coherence": eval_result.coherence,
                "overall": eval_result.overall_score,
                "is_hallucination": eval_result.is_hallucination,
            }
            result["passed"] = eval_result.passed

            # Tier 3: a low score -> the human review queue
            if eval_result.overall_score < 3.0 or eval_result.safety < 4.0:
                result["needs_human_review"] = True
                result["tier"] = EvalTier.HUMAN_REVIEW.value

        return result

A/B Test Design and Implementation

A/B testing on a chatbot poses challenges that are fundamentally different from a regular web service. LLM responses are non-deterministic, measuring quality is itself subjective, and because a conversation runs over multiple turns it is hard to declare a winner from a single response.

A/B Test Variables

The main variables you can compare in a chatbot A/B test are as follows.

User Assignment Strategy

Use hash-based assignment so the user experience stays consistent. The same user always sees the same variant within a session.

import hashlib
import json
from dataclasses import dataclass
from typing import Optional
from datetime import datetime, timedelta
from scipy import stats
import numpy as np


@dataclass
class ExperimentVariant:
    """Definition of an A/B test variant"""
    name: str
    model: str
    prompt_version: str
    temperature: float
    max_tokens: int
    top_k: int = 5          # number of documents retrieved for RAG
    weight: float = 0.5     # share of traffic


@dataclass
class ExperimentConfig:
    """A/B test experiment configuration"""
    experiment_id: str
    name: str
    description: str
    variants: list[ExperimentVariant]
    start_date: datetime
    end_date: Optional[datetime] = None
    min_sample_size: int = 1000
    confidence_level: float = 0.95
    primary_metric: str = "overall_quality_score"
    guardrail_metrics: list[str] = None

    def __post_init__(self):
        if self.guardrail_metrics is None:
            self.guardrail_metrics = ["safety_score", "hallucination_rate", "p95_latency_ms"]


class ChatbotABTestFramework:
    """A/B testing framework built for chatbots"""

    def __init__(self, experiment: ExperimentConfig):
        self.experiment = experiment
        self.results: dict[str, list[dict]] = {
            v.name: [] for v in experiment.variants
        }

    def assign_variant(self, user_id: str) -> ExperimentVariant:
        """Assign the user to a variant by hash.
        The same user_id always receives the same variant."""
        hash_input = f"{self.experiment.experiment_id}:{user_id}"
        hash_value = int(hashlib.sha256(hash_input.encode()).hexdigest(), 16)
        normalized = (hash_value % 10000) / 10000.0

        cumulative = 0.0
        for variant in self.experiment.variants:
            cumulative += variant.weight
            if normalized < cumulative:
                return variant

        return self.experiment.variants[-1]

    def record_result(
        self,
        variant_name: str,
        metrics: dict,
    ):
        """Record an experiment result"""
        self.results[variant_name].append({
            "timestamp": datetime.utcnow().isoformat(),
            **metrics,
        })

    def analyze(self) -> dict:
        """Run the statistical significance analysis"""
        if len(self.experiment.variants) != 2:
            raise ValueError("only 2 variants are supported at present")

        v_a = self.experiment.variants[0].name
        v_b = self.experiment.variants[1].name
        metric = self.experiment.primary_metric

        scores_a = [r[metric] for r in self.results[v_a] if metric in r]
        scores_b = [r[metric] for r in self.results[v_b] if metric in r]

        if len(scores_a) < 30 or len(scores_b) < 30:
            return {
                "status": "insufficient_data",
                "sample_sizes": {v_a: len(scores_a), v_b: len(scores_b)},
                "min_required": self.experiment.min_sample_size,
            }

        # Welch's t-test (does not assume equal variance)
        t_stat, p_value = stats.ttest_ind(scores_a, scores_b, equal_var=False)

        # effect size (Cohen's d)
        pooled_std = np.sqrt(
            (np.std(scores_a) ** 2 + np.std(scores_b) ** 2) / 2
        )
        cohens_d = (np.mean(scores_b) - np.mean(scores_a)) / max(pooled_std, 1e-10)

        # check the guardrail metrics
        guardrail_passed = True
        guardrail_details = {}
        for gm in self.experiment.guardrail_metrics:
            gm_a = [r.get(gm, 0) for r in self.results[v_a] if gm in r]
            gm_b = [r.get(gm, 0) for r in self.results[v_b] if gm in r]
            if gm_a and gm_b:
                if gm == "hallucination_rate":
                    # the hallucination rate must not increase
                    if np.mean(gm_b) > np.mean(gm_a) * 1.1:
                        guardrail_passed = False
                guardrail_details[gm] = {
                    "control_mean": float(np.mean(gm_a)),
                    "treatment_mean": float(np.mean(gm_b)),
                }

        is_significant = p_value < (1 - self.experiment.confidence_level)
        winner = None
        if is_significant and guardrail_passed:
            winner = v_b if np.mean(scores_b) > np.mean(scores_a) else v_a

        return {
            "status": "complete",
            "experiment_id": self.experiment.experiment_id,
            "primary_metric": metric,
            "control": {
                "name": v_a,
                "n": len(scores_a),
                "mean": float(np.mean(scores_a)),
                "std": float(np.std(scores_a)),
                "ci_95": (
                    float(np.mean(scores_a) - 1.96 * np.std(scores_a) / np.sqrt(len(scores_a))),
                    float(np.mean(scores_a) + 1.96 * np.std(scores_a) / np.sqrt(len(scores_a))),
                ),
            },
            "treatment": {
                "name": v_b,
                "n": len(scores_b),
                "mean": float(np.mean(scores_b)),
                "std": float(np.std(scores_b)),
                "ci_95": (
                    float(np.mean(scores_b) - 1.96 * np.std(scores_b) / np.sqrt(len(scores_b))),
                    float(np.mean(scores_b) + 1.96 * np.std(scores_b) / np.sqrt(len(scores_b))),
                ),
            },
            "t_statistic": float(t_stat),
            "p_value": float(p_value),
            "cohens_d": float(cohens_d),
            "is_significant": is_significant,
            "guardrail_passed": guardrail_passed,
            "guardrail_details": guardrail_details,
            "winner": winner,
            "recommendation": _get_recommendation(is_significant, cohens_d, guardrail_passed, winner),
        }


def _get_recommendation(significant: bool, effect_size: float, guardrail: bool, winner: str) -> str:
    if not guardrail:
        return "A guardrail metric failed. Roll back the treatment."
    if not significant:
        return "There is no statistically significant difference. Collect more samples."
    if abs(effect_size) < 0.2:
        return f"Statistically significant but the effect size is small (d={effect_size:.3f}). Review whether it is practically worthwhile."
    return f"{winner} is the winner (Cohen's d={effect_size:.3f}). Roll it out to all traffic."


# example of running an experiment
experiment = ExperimentConfig(
    experiment_id="exp-2026-03-prompt-v24",
    name="Prompt v2.4 vs v2.3",
    description="Measure the effect of adding a CoT reasoning step to the system prompt",
    variants=[
        ExperimentVariant(
            name="control",
            model="gpt-4o",
            prompt_version="v2.3",
            temperature=0.7,
            max_tokens=1024,
            weight=0.5,
        ),
        ExperimentVariant(
            name="treatment",
            model="gpt-4o",
            prompt_version="v2.4",
            temperature=0.7,
            max_tokens=1024,
            weight=0.5,
        ),
    ],
    start_date=datetime(2026, 3, 8),
    min_sample_size=1000,
)

ab_test = ChatbotABTestFramework(experiment)

Cautions When Designing an A/B Test

There are mistakes people commonly make in chatbot A/B tests.

1. Insufficient sample size: because the variance of LLM responses is large, you need more samples than in an ordinary web A/B test. You have to collect at least 1,000 conversations to get a result you can trust.

2. The multiple comparisons problem: comparing several metrics at once increases the chance of a significant result appearing by luck. Apply a Bonferroni correction, or designate a single primary metric in advance.

3. Dependency between conversation turns: the quality of the first turn affects the turns that follow. Evaluate at the level of the whole session, not the individual turn.

4. The novelty effect: a new variant can temporarily be rated well simply because it is "different". Keep the experiment running for at least 2 weeks to rule this effect out.

5. Skipping the canary release stage: do not put the new variant on 50% immediately. Start with 1-5% of traffic, check the guardrail metrics, and raise the share gradually.

Dashboard Composition

A dashboard that visualizes the collected metrics is the operations team's core tool. The recommended setup uses ClickHouse as the analytics query backend and Grafana for visualization.

ClickHouse Analytics Queries

-- 1. hourly quality score trend and anomaly detection
SELECT
    toStartOfHour(timestamp) AS hour,
    count() AS total_turns,
    avg(overall_quality_score) AS avg_quality,
    quantile(0.95)(e2e_latency_ms) AS p95_latency,
    countIf(is_hallucination = 1) / count() * 100 AS hallucination_pct,
    countIf(user_feedback = 'thumbs_down') / countIf(user_feedback != '') * 100 AS negative_feedback_pct,
    sum(total_cost_usd) AS hourly_cost,
    -- anomaly detection: deviation of 2 standard deviations or more from the 3-hour moving average
    avg(overall_quality_score) OVER (
        ORDER BY toStartOfHour(timestamp)
        ROWS BETWEEN 3 PRECEDING AND 1 PRECEDING
    ) AS moving_avg_quality,
    if(
        abs(avg(overall_quality_score) - moving_avg_quality) > 2 * stddevPop(overall_quality_score) OVER (
            ORDER BY toStartOfHour(timestamp)
            ROWS BETWEEN 12 PRECEDING AND 1 PRECEDING
        ),
        'ANOMALY',
        'NORMAL'
    ) AS quality_status
FROM chatbot_metrics
WHERE timestamp >= now() - INTERVAL 24 HOUR
GROUP BY hour
ORDER BY hour;

-- 2. performance comparison per A/B test experiment
SELECT
    experiment_id,
    variant_name,
    count() AS sample_size,
    avg(overall_quality_score) AS avg_quality,
    avg(relevance_score) AS avg_relevance,
    avg(faithfulness_score) AS avg_faithfulness,
    quantile(0.5)(e2e_latency_ms) AS median_latency,
    quantile(0.95)(e2e_latency_ms) AS p95_latency,
    countIf(is_hallucination = 1) / count() * 100 AS hallucination_rate,
    avg(total_cost_usd) AS avg_cost_per_turn,
    countIf(user_feedback = 'thumbs_up') / countIf(user_feedback != '') * 100 AS positive_rate
FROM chatbot_metrics
WHERE experiment_id = 'exp-2026-03-prompt-v24'
  AND timestamp >= '2026-03-08'
GROUP BY experiment_id, variant_name;

-- 3. cost efficiency analysis per model and prompt version
SELECT
    model_name,
    prompt_version,
    count() AS total_requests,
    sum(input_tokens) AS total_input_tokens,
    sum(output_tokens) AS total_output_tokens,
    sum(total_cost_usd) AS total_cost,
    avg(total_cost_usd) AS avg_cost_per_request,
    avg(overall_quality_score) AS avg_quality,
    -- quality-per-cost efficiency score
    avg(overall_quality_score) / (avg(total_cost_usd) * 1000 + 0.001) AS quality_per_dollar,
    -- cache efficiency
    countIf(cache_hit = 1) / count() * 100 AS cache_hit_rate
FROM chatbot_metrics
WHERE timestamp >= now() - INTERVAL 7 DAY
GROUP BY model_name, prompt_version
ORDER BY quality_per_dollar DESC;

Grafana Dashboard Panel Layout

The recommended dashboard has the following 4 sections.

Top - core KPIs (Stat Panel): place number panels where the current quality score, p95 latency, hallucination rate and hourly cost can be read at a glance.

Upper middle - time series charts (Time Series Panel): overlay the hourly quality score trend, the latency distribution (p50/p95/p99) and the traffic volume. Showing the anomaly alert threshold alongside them lets you spot drift immediately.

Lower middle - A/B test status (Bar Chart / Table): show the core metric comparison per experiment variant, whether statistical significance has been reached, and how many more samples are needed.

Bottom - cost analysis (Pie Chart / Bar Chart): visualize the cost share per model, the daily cost trend and the cache hit rate.

Cost Optimization Monitoring

The largest operating cost of an LLM-based chatbot is token usage. Smart prompt design can cut token usage by 20-40%, and caching can cut input token cost by 75-90%.

Cost Optimization Checkpoints

Context window management: including unlimited conversation history makes the cost grow exponentially. Including only the last 5 turns, or using a summary, saves 30-50% of the tokens.

Model routing: not every request needs GPT-4o. Route simple questions to GPT-4o-mini and use the large model only when complex reasoning is required. The price difference between models runs up to 500x, so this strategy alone can cut cost by 50-80%.

Semantic caching: reuse a previous response for an identical or similar question. Proxy solutions such as Helicone provide this out of the box, and as of 2025 companies cut their monthly token cost by an average of 42% through caching.

Prompt compression: review the system prompt regularly and remove unnecessary instructions. Keep few-shot examples to 3 or fewer, and compress long instructions into a structured format.

Troubleshooting

Here are the problems most often encountered in production chatbot monitoring, and how to solve them.

Problem 1: Quality Score Collapse

Symptom: the average quality score suddenly drops by 0.5 points or more.

Possible causes:

Debugging order:

  1. Check the recent traces in LangSmith/Langfuse and analyze the change in the response pattern
  2. Check the model version and the prompt version at that point in time
  3. Evaluate the relevance of the RAG retrieval results separately
  4. Verify the consistency of LLM-as-Judge itself against the golden evaluation dataset

Problem 2: Latency Increase

Symptom: p95 latency exceeds 5 seconds.

Possible causes:

Response strategy:

  1. Check the duration of each span in the tracing and identify the bottleneck
  2. Enable streaming responses to improve TTFT
  3. Optimize the vector DB index or add a cache layer
  4. Implement a circuit breaker that switches automatically to a fallback (smaller) model

Problem 3: A/B Test Results Do Not Converge

Symptom: the experiment has run for more than 2 weeks and the p-value still will not drop below 0.05.

Possible causes:

Response strategy:

  1. Run a power analysis up front to calculate the minimum sample size required
  2. Analyze user segments separately (new/existing, language, inquiry type)
  3. Swap the primary metric for a more sensitive one, or recalibrate the expected effect size

Production Checklist

Check the following items before deploying the chatbot monitoring system to production.

Tracing infrastructure:

Quality evaluation:

Alert configuration:

A/B testing:

Cost management:

Failure Cases and Responses

Case 1: Judge Drift

One team was using GPT-4 as their LLM-as-Judge. It worked well for 3 months, and then OpenAI quietly updated the gpt-4 model and the evaluation criteria shifted subtly. Responses that previously scored 3.5 started scoring 4.2, the team wrongly concluded that quality had improved, and they shipped a prompt to production that had in fact regressed.

Lesson: pin the version of the evaluation model (e.g. gpt-4-0613), and regression-test the evaluation scores against the golden dataset periodically. Raise an alert when the distribution of evaluation scores shifts significantly.

Case 2: Semantic Cache Poisoning

The semantic cache's similarity threshold was set to 0.92, and "KTX travel time from Seoul to Busan" and "bus travel time from Seoul to Busan" came out at a similarity of 0.93, so the KTX answer was returned for the bus question. It went unnoticed until a user complaint came in.

Lesson: when introducing a semantic cache, start with a conservative similarity threshold (0.95 or above), and apply sampled quality evaluation to cache-hit responses as well. Including an intent classification in the cache key reduces false matches.

Case 3: A/B Test Contamination

The session ID was used for user assignment, and the same user received several session IDs by logging in and out repeatedly. As a result one user experienced both control and treatment, which contaminated the experiment results.

Lesson: A/B test user assignment must be based on a permanent identifier (user_id). For logged-out users, keep the assignment consistent with a device fingerprint or a first-party cookie.

References

Comments

No comments yet.

Sign in to leave a comment