- Introduction
- Core Metrics for Chatbot Monitoring
- Conversation Quality Evaluation Framework
- Tracing with LangSmith
- Langfuse Integration
- Automated Quality Evaluation Pipeline
- A/B Test Design and Implementation
- Dashboard Composition
- Cost Optimization Monitoring
- Troubleshooting
- Production Checklist
- Failure Cases and Responses
- References

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
| Category | Metric | Description | Target threshold |
|---|---|---|---|
| Latency | Time to First Token (TTFT) | Time until the first token | p95 2s or less |
| End-to-End Latency | Time to complete the full response | p95 5s or less | |
| Retrieval Latency | Time taken by RAG retrieval | p95 500ms or less | |
| Tool Execution Time | Time taken by a tool call | Set individually per tool | |
| Quality | Relevance Score | Relevance of the response to the question | 4.0/5.0 or above |
| Faithfulness Score | Accuracy relative to the context | 4.5/5.0 or above | |
| Hallucination Rate | Rate at which hallucination occurs | 5% or less | |
| Safety Violation Rate | Rate of safety violations | 0.1% or less | |
| Engagement | Conversation Length | Average number of conversation turns | Varies by service |
| Thumbs Up/Down Ratio | Rate of explicit user feedback | 80% or more positive | |
| Regeneration Rate | Rate of response regeneration requests | 10% or less | |
| Session Return Rate | Rate of returning users | 30% or more | |
| Cost | Cost per Conversation | Average cost per conversation | Set per service |
| Input/Output Token Ratio | Ratio of input to output tokens | Monitor | |
| Cache Hit Rate | Cache hit rate | 30% or more | |
| Cost per Resolution | Cost per issue resolved | Set 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.
| Metric | Principle | Strength | Limitation |
|---|---|---|---|
| BLEU | Reference comparison by n-gram precision | Fast and reproducible | Low correlation in open-ended conversation |
| ROUGE | Reference comparison by n-gram recall | Effective for summary evaluation | Unsuitable when many answers are valid |
| BERTScore | Semantic similarity from BERT embeddings | Can capture semantic similarity | Needs reference text, high compute cost |
| Perplexity | The model's uncertainty in token prediction | Measures fluency | Unrelated 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 4 │
│ Prompt 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
| Criterion | LangSmith | Langfuse | Helicone | Custom build |
|---|---|---|---|---|
| License | Commercial (free tier exists) | MIT open source | Open source | Your own |
| Self-hosting | BYOC / Self-hosted | Docker / K8s | Docker | Completely free |
| Tracing | Automatic (LangChain native) | SDK / OpenTelemetry | Proxy-based | Build it yourself |
| Eval framework | Built in (Online + Offline) | Built in (LLM-as-Judge) | Basic | Build it yourself |
| A/B testing | Experiment feature built in | Prompt A/B testing | Not supported | Build it yourself |
| Prompt management | Hub (LangChain Hub) | Built-in version control | Not supported | Build it yourself |
| Data sovereignty | Cloud or BYOC | Fully self-hosted | Self-hosting possible | Full control |
| Cost tracking | Automatic | Automatic | Automatic (proxy) | Build it yourself |
| Framework fit | Optimized for LangChain | Framework-agnostic | Framework-agnostic | Framework-agnostic |
| Learning curve | Medium | Low | Very low | High |
| Added latency | SDK-based (almost none) | SDK-based (almost none) | Proxy 50-80ms | Depends on the build |
| Community | Large | Growing fast | Growing | N/A |
| Best suited for | LangChain-based projects | When data sovereignty matters | When you need fast integration | Full 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.
-
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.
-
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.
-
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.
- Prompt version: changes to the wording, the structure and the few-shot examples of the system prompt
- Model change: GPT-4o vs Claude Sonnet vs Gemini
- Temperature: the creativity-consistency tradeoff, e.g. 0.3 vs 0.7
- RAG configuration: the number of retrieved documents (top_k), the similarity threshold, whether reranking is applied
- Response post-processing: markdown formatting, emoji use, response length limits
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:
- A model update by the LLM provider (an unannounced change)
- A corrupted RAG index, or a missed document update
- A prompt change deployed unintentionally
- A problem with the evaluation (Judge) prompt itself
Debugging order:
- Check the recent traces in LangSmith/Langfuse and analyze the change in the response pattern
- Check the model version and the prompt version at that point in time
- Evaluate the relevance of the RAG retrieval results separately
- 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:
- Server load at the LLM API provider
- An inefficient index in the RAG vector DB
- Longer token processing time caused by a longer prompt
- Network latency
Response strategy:
- Check the duration of each span in the tracing and identify the bottleneck
- Enable streaming responses to improve TTFT
- Optimize the vector DB index or add a cache layer
- 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:
- There genuinely is no difference between the two variants
- The metric's variance is too large and more samples are needed
- Heterogeneity between user segments is diluting the result
Response strategy:
- Run a power analysis up front to calculate the minimum sample size required
- Analyze user segments separately (new/existing, language, inquiry type)
- 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:
- Is every LLM call being traced?
- Are user_id, session_id and prompt_version included as trace metadata?
- Is the retention period of the trace store configured (at least 30 days)?
- Is sensitive user data masked in the traces?
Quality evaluation:
- Is the rule-based filter applied to every response?
- Is the LLM-as-Judge sampling rate configured (10% recommended)?
- Is the golden evaluation dataset ready (at least 100 examples)?
- Is the human review queue actually being worked?
Alert configuration:
- Alert on a quality score collapse (2 standard deviations from the moving average)
- Alert on p95 latency being exceeded (5 seconds)
- Alert on a hallucination rate spike (over 10%)
- Alert on a cost anomaly (daily budget exceeded)
- Alert on an error rate spike (over 5%)
A/B testing:
- Is user assignment done consistently by hash?
- Are the guardrail metrics defined?
- Is a canary release stage included?
- Is the rollback procedure documented?
Cost management:
- Is cost tracking per model and per user running?
- Are daily and monthly cost limits configured?
- Is the cache hit rate being monitored?
- Is the model routing logic implemented?
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
- LangSmith official documentation - the LLM observability platform: https://docs.langchain.com/langsmith/evaluation
- Langfuse, the open-source LLM observability and evaluation platform: https://langfuse.com/docs/observability/overview
- Helicone, the open-source LLM observability platform: https://github.com/Helicone/helicone
- PostHog - How to A/B test LLM models and prompts: https://posthog.com/tutorials/llm-ab-tests
- Langfuse A/B Testing of LLM Prompts: https://langfuse.com/docs/prompt-management/features/a-b-testing
- Braintrust - A/B testing for LLM prompts: https://www.braintrust.dev/articles/ab-testing-llm-prompts
- Evidently AI - the complete guide to LLM-as-a-Judge: https://www.evidentlyai.com/llm-guide/llm-as-a-judge
- Hugging Face - Using LLM-as-a-judge for automated evaluation: https://huggingface.co/learn/cookbook/en/llm_judge
- Confident AI - LLM Chatbot Evaluation Metrics: https://www.confident-ai.com/blog/llm-chatbot-evaluation-explained-top-chatbot-evaluation-metrics-and-testing-techniques
- Weights and Biases - LLM evaluation benchmarking Beyond BLEU and ROUGE: https://wandb.ai/ai-team-articles/llm-evaluation/reports/LLM-evaluation-benchmarking-Beyond-BLEU-and-ROUGE--VmlldzoxNTIzMTY0NQ