Skip to content
ExponaBLOG
PRACTICAL AI & MACHINE LEARNING · DAY 082 / 100

Common LLM integration mistakes: lessons from production

Pitfalls in AI features.

Expona Admin8 min read

Introduction

We've all seen it: a shiny new LLM feature launches to production, performs beautifully in demos, then silently fails under real traffic. After shipping AI features across multiple production systems—e-commerce platforms processing thousands of orders, CRM systems handling customer interactions, real-time trading dashboards—we've learned that common LLM integration mistakes are less about the models themselves and far more about how we integrate them into our applications. The gap between a working prototype and a reliable production system comes down to validation, resilience, and boundaries.

This article walks through the mistakes we've made and seen others make, with concrete examples and working solutions. Whether you're adding GPT-backed search to a marketplace or building AI-assisted workflows for a SaaS platform, these lessons will save you production incidents.

Trusting model output without validation

The first instinct when calling an LLM is to assume the response is correct. It rarely is. Models hallucinate facts, return malformed data, contradict themselves, and occasionally produce content that violates your business rules entirely.

We integrated Claude into an e-commerce recommendation engine. The model was asked to rank products by relevance to a customer query. Most responses looked perfect—clean JSON with product IDs and scores. Then one day, it returned product IDs that didn't exist in our catalog. Our frontend crashed trying to fetch metadata. The model had fabricated IDs.

Here's what we should have done from the start:

python

import json
from typing import Optional

def get_product_recommendations(query: str, valid_product_ids: set) -> Optional[list]:
    """
    Get LLM recommendations with validation.
    """
    prompt = f"""Recommend 5 products for this customer query: {query}
    Return JSON with format: {{"recommendations": [{{"product_id": int, "score": float}}]}}"""
    
    response = client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=500,
        messages=[{"role": "user", "content": prompt}]
    )
    
    try:
        # Parse response
        content = response.content[0].text
        data = json.loads(content)
        
        # Validate structure
        if "recommendations" not in data or not isinstance(data["recommendations"], list):
            logger.error("Invalid response structure", extra={"response": content})
            return None
        
        # Validate each recommendation
        validated = []
        for rec in data["recommendations"]:
            if not isinstance(rec, dict):
                logger.warning("Skipping non-dict recommendation")
                continue
            
            product_id = rec.get("product_id")
            score = rec.get("score")
            
            # Validate product exists
            if product_id not in valid_product_ids:
                logger.warning(f"LLM returned non-existent product_id: {product_id}")
                continue
            
            # Validate score is in range
            if not isinstance(score, (int, float)) or not (0 <= score <= 1):
                logger.warning(f"Invalid score {score} for product {product_id}")
                continue
            
            validated.append({"product_id": product_id, "score": score})
        
        if not validated:
            logger.error("No valid recommendations after validation")
            return None
        
        return validated
    
    except json.JSONDecodeError as e:
        logger.error("Failed to parse LLM response as JSON", extra={"error": str(e)})
        return None
    except Exception as e:
        logger.error("Unexpected error processing recommendations", extra={"error": str(e)})
        return None

This validates structure, existence, and business logic constraints. When validation fails, we return a neutral result and log the issue for investigation—no crashes, no bad data leaking downstream.

No timeouts or fallbacks

LLM APIs are external services. They hang. They're slow. They fail. If your application waits indefinitely for a response, your entire request chain blocks, and customers see timeouts.

We integrated an LLM-powered help feature into a SaaS platform. The feature worked great during development. In production, during a brief API outage at the LLM provider, our support ticket creation endpoint hung for 30 seconds per request because the code was waiting for the AI summary to complete. Within seconds, the task queue backed up and the entire service became unresponsive.

Always set a timeout and always have a fallback:

python

import asyncio
from typing import Optional

async def get_ai_ticket_summary(ticket_text: str, timeout_seconds: int = 5) -> Optional[str]:
    """
    Get AI summary with timeout and fallback.
    """
    try:
        response = await asyncio.wait_for(
            client.messages.create(
                model="claude-3-5-sonnet-20241022",
                max_tokens=200,
                messages=[{
                    "role": "user",
                    "content": f"Summarize this support ticket in one sentence: {ticket_text}"
                }]
            ),
            timeout=timeout_seconds
        )
        return response.content[0].text
    
    except asyncio.TimeoutError:
        logger.warning("AI summary generation timed out", extra={
            "timeout_seconds": timeout_seconds,
            "ticket_preview": ticket_text[:100]
        })
        return None
    except Exception as e:
        logger.error("AI summary generation failed", extra={"error": str(e)})
        return None

async def create_support_ticket(text: str, user_id: str) -> dict:
    """
    Create ticket with fallback if AI fails.
    """
    # Try to get AI summary, but don't block ticket creation
    summary = await get_ai_ticket_summary(text)
    
    ticket = {
        "user_id": user_id,
        "text": text,
        "summary": summary or "No AI summary available",  # Fallback
        "auto_categorized": summary is not None,  # Track if AI was used
        "created_at": datetime.utcnow()
    }
    
    await db.tickets.insert_one(ticket)
    return ticket

Notice we also track whether the AI was actually used. If summaries are unavailable, we don't claim they're AI-generated. This matters for user trust and compliance.

Sending too much context

It feels safe to send everything to the LLM: the entire customer history, full database records, all related documents. More context should mean better answers, right? In practice, it creates three problems: cost, latency, and degraded quality due to noise.

We built an AI-powered search for a trading platform. For each user query, we sent the last 30 days of trades, current market data, and the user's preferences. Tokens cost money, requests took 3-5 seconds to complete, and the model was confused by irrelevant information. When we trimmed it down to only the last 5 most relevant trades and current holdings, search got faster and more accurate.

Be ruthless about context size:

python

def prepare_search_context(user_id: str, query: str, limit_trades: int = 5) -> str:
    """
    Prepare focused context for search, not everything.
    """
    # Get recent trades only
    recent_trades = db.trades.find(
        {"user_id": user_id}
    ).sort("created_at", -1).limit(limit_trades)
    
    # Format concisely
    trades_text = "\n".join([
        f"- {t['symbol']} {t['side']} {t['quantity']} @ {t['price']}"
        for t in recent_trades
    ])
    
    # Prepare prompt
    context = f"""User query: {query}

Recent trades:
{trades_text}

Respond with top 3 relevant symbols and brief reasoning."""
    
    # Measure token count
    token_count = len(context.split())
    if token_count > 500:  # Set a limit
        logger.warning("Context exceeds token budget", extra={"tokens": token_count})
    
    return context

In this example, we're explicit about what context goes into the prompt, limit it with limit_trades, and even warn if we exceed our token budget. This keeps requests fast, cheap, and focused.

Exposing API keys to the frontend

Never send API keys to the browser or mobile client. It's tempting—you can call the LLM directly from the frontend, reduce server load, and ship faster. The cost is total loss of control over your API usage, billing, and security.

We saw a marketplace team add a "chat with AI" feature that called OpenAI's API directly from React. Within 48 hours, a user figured out the API key was in the JavaScript bundle. They automated requests and spent $15,000 of the company's credits. More importantly, we lost all visibility into what was being asked and how our LLM quota was being used.

Always route LLM calls through your backend:

python

# Backend (NestJS/Express example, but logic applies to any backend)
# routes/ai.py or similar

from fastapi import APIRouter, HTTPException, Depends
from auth import require_user

router = APIRouter()

@router.post("/api/ai/chat")
async def chat(request: ChatRequest, user_id: str = Depends(require_user)):
    """
    Frontend calls this endpoint, never the LLM directly.
    """
    
    # Rate limiting per user
    if await rate_limiter.is_limited(user_id, "ai_chat"):
        raise HTTPException(status_code=429, detail="Too many requests")
    
    # Validate input
    if len(request.message) > 10000:
        raise HTTPException(status_code=400, detail="Message too long")
    
    # Call LLM from backend (API key never leaves here)
    response = await get_llm_response(request.message)
    
    # Validate response
    if not response:
        raise HTTPException(status_code=500, detail="AI service unavailable")
    
    # Log for audit
    await log_ai_usage(user_id, request.message, response)
    
    return {"message": response}

This gives you rate limiting per user, audit logging, validation, and complete control. The frontend never sees your API key.

Common mistakes summary

Mistake Impact Prevention
No output validation Hallucinated data reaches users and breaks downstream systems Parse, validate structure, check constraints before use
No timeout/fallback Service hangs when LLM API is slow or down Set explicit timeouts, provide neutral fallback, track usage
Excess context Slow, expensive, and degraded quality Send only what's necessary, measure token cost
Frontend API keys Loss of control, billing abuse, security risk Route all LLM calls through backend with auth and rate limits

Key takeaways

  • Validate every LLM output before using it. Check structure, data existence, and business logic. Gracefully degrade when validation fails.
  • Set explicit timeouts on all LLM requests and provide fallbacks. External APIs are unreliable by definition.
  • Minimize context sent to LLMs. More information doesn't equal better answers—focus on relevance and measure token cost.
  • Never expose API keys to the frontend. Route all LLM calls through your backend where you can authenticate, rate-limit, log, and control access.
  • Monitor and log LLM usage. Track latency, cost, validation failures, and timeouts. Use this data to catch problems early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles