Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 086 / 100

Python and AI: calling LLM APIs — lessons from production

Add AI features to Python backends.

Expona Admin8 min read

Adding AI features to a Python backend sounds straightforward until you're live with thousands of users and your LLM bill spikes unexpectedly. We've learned that calling LLM APIs well involves more than just importing a library and making requests—it requires thoughtful SDKs, streaming for UX, structured output validation, and deliberate cost management.

Use the official SDK — Anthropic and OpenAI provide typed Python clients

The difference between third-party wrappers and official SDKs becomes clear fast. Official clients from OpenAI and Anthropic handle authentication, retries, type hints, and API changes gracefully. We've found that starting with these saves debugging time that you'd otherwise spend on HTTP headers and JSON serialization edge cases.

For OpenAI, install the official package and authenticate via environment variable:

python
pip install openai

Then use it directly in your code:

python
from openai import OpenAI

client = OpenAI()  # reads OPENAI_API_KEY from env

response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[
        {
            "role": "user",
            "content": "Summarize this invoice in one sentence."
        }
    ],
    temperature=0.7,
    max_tokens=150
)

print(response.choices[0].message.content)

The same pattern applies to Anthropic with their official SDK:

python
pip install anthropic
python
from anthropic import Anthropic

client = Anthropic()

response = client.messages.create(
    model="claude-3-5-sonnet-20241022",
    max_tokens=1024,
    messages=[
        {
            "role": "user",
            "content": "Extract the total amount from this receipt."
        }
    ]
)

print(response.content[0].text)

Both SDKs give you type hints, proper error handling, and automatic retry logic. They also document breaking changes proactively, so your code doesn't silently break when the API evolves.

Stream responses — Stream tokens to the frontend for better UX

Streaming transforms the user experience. Instead of waiting 3-5 seconds for the entire response, users see tokens arrive in real time. This is especially valuable for long-form outputs like report generation or code explanation—users feel the response is working immediately.

With OpenAI, use stream=True:

python
from openai import OpenAI

client = OpenAI()

def generate_report_stream(user_request: str):
    with client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "You are a detailed business analyst."
            },
            {
                "role": "user",
                "content": user_request
            }
        ],
        stream=True,
        temperature=0.8,
        max_tokens=2000
    ) as stream:
        for text in stream.text_stream:
            yield text

In a FastAPI endpoint, yield these tokens directly to the client:

python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()

@app.post("/api/report")
async def generate_report(request: dict):
    return StreamingResponse(
        generate_report_stream(request["query"]),
        media_type="text/event-stream"
    )

On the frontend (React or Next.js), consume this as a ReadableStream:

python
# Pseudocode for frontend consumption
const response = await fetch('/api/report', {
    method: 'POST',
    body: JSON.stringify({ query: userInput })
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    setOutput(prev => prev + decoder.decode(value));
}

With Anthropic, streaming works similarly via iteration:

python
from anthropic import Anthropic

client = Anthropic()

def stream_analysis(text: str):
    with client.messages.stream(
        model="claude-3-5-sonnet-20241022",
        max_tokens=1024,
        messages=[
            {
                "role": "user",
                "content": f"Analyze: {text}"
            }
        ]
    ) as stream:
        for text_chunk in stream.text_stream:
            yield text_chunk

Streaming also reduces perceived latency on slower connections and gives users a chance to stop the request early if needed.

Validate output — Parse structured output with Pydantic models

LLMs are probabilistic; they hallucinate, they misformat, they occasionally ignore instructions. Validating and parsing their output with Pydantic ensures your downstream code doesn't break on malformed JSON or missing fields.

Define a Pydantic model for the structure you expect:

python
from pydantic import BaseModel, Field
from typing import List

class InvoiceLineItem(BaseModel):
    description: str
    quantity: int
    unit_price: float
    total: float

class ExtractedInvoice(BaseModel):
    vendor: str = Field(..., description="Name of the vendor")
    invoice_date: str = Field(..., description="Date in YYYY-MM-DD format")
    total_amount: float
    line_items: List[InvoiceLineItem]
    tax_amount: float | None = None

Use OpenAI's response format specification to request JSON mode:

python
import json
from openai import OpenAI

client = OpenAI()

def extract_invoice(invoice_text: str) -> ExtractedInvoice:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        response_format={"type": "json_object"},
        messages=[
            {
                "role": "system",
                "content": """You extract structured data from invoice text. 
                Always return valid JSON matching this schema:
                {
                    "vendor": "string",
                    "invoice_date": "YYYY-MM-DD",
                    "total_amount": number,
                    "line_items": [
                        {"description": "string", "quantity": number, "unit_price": number, "total": number}
                    ],
                    "tax_amount": number or null
                }"""
            },
            {
                "role": "user",
                "content": f"Extract invoice data: {invoice_text}"
            }
        ],
        temperature=0
    )
    
    raw_json = json.loads(response.choices[0].message.content)
    return ExtractedInvoice(**raw_json)

If parsing fails, catch the validation error and log it for debugging:

python
from pydantic import ValidationError

try:
    invoice = extract_invoice(ocr_text)
except ValidationError as e:
    logger.error(f"Invoice parsing failed: {e}")
    # Re-prompt with stricter instructions or fallback
    invoice = extract_invoice_with_retry(ocr_text)

Anthropic also supports JSON output via the thinking block and explicit instructions:

python
from anthropic import Anthropic
import json

client = Anthropic()

def extract_with_claude(invoice_text: str) -> ExtractedInvoice:
    response = client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=1024,
        messages=[
            {
                "role": "user",
                "content": f"""Extract invoice data and return ONLY valid JSON:
                {json.dumps(ExtractedInvoice.model_json_schema(), indent=2)}
                
                Invoice text:
                {invoice_text}"""
            }
        ]
    )
    
    raw_json = json.loads(response.content[0].text)
    return ExtractedInvoice(**raw_json)

Structured output prevents downstream errors and makes debugging easier when the LLM does miss the mark.

Control costs — Cache results and set token limits

LLM APIs charge per token. In production, costs scale quickly without guardrails. We manage this through response caching, request deduplication, and token budgets per user or request.

Implement a simple Redis cache for repeated queries:

python
import hashlib
import redis
from openai import OpenAI

redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
client = OpenAI()

def generate_summary_with_cache(text: str, user_id: str) -> str:
    # Create a cache key from the input
    cache_key = f"summary:{user_id}:{hashlib.md5(text.encode()).hexdigest()}"
    
    # Check cache first
    cached = redis_client.get(cache_key)
    if cached:
        return cached
    
    # Call API if not cached
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "user",
                "content": f"Summarize: {text}"
            }
        ],
        max_tokens=200
    )
    
    summary = response.choices[0].message.content
    # Cache for 7 days
    redis_client.setex(cache_key, 7 * 24 * 3600, summary)
    return summary

Enforce token limits per request to avoid runaway costs:

python
def generate_with_limit(query: str, max_tokens: int = 500) -> str:
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "user",
                "content": query
            }
        ],
        max_tokens=min(max_tokens, 1000),  # Hard cap
        temperature=0.7
    )
    
    # Log token usage for monitoring
    logger.info(
        f"Tokens used - Input: {response.usage.prompt_tokens}, "
        f"Output: {response.usage.completion_tokens}"
    )
    
    return response.choices[0].message.content

Use Anthropic's prompt caching feature if available for your model:

python
from anthropic import Anthropic

client = Anthropic()

def analyze_with_cache(documents: List[str], question: str) -> str:
    # Large, stable context that may be reused
    cached_context = "\n\n".join(documents)
    
    response = client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=1024,
        system=[
            {
                "type": "text",
                "text": "You are a document analyst."
            },
            {
                "type": "text",
                "text": f"Documents: {cached_context}",
                "cache_control": {"type": "ephemeral"}
            }
        ],
        messages=[
            {
                "role": "user",
                "content": question
            }
        ]
    )
    
    return response.content[0].text

Monitor usage and set alerts when costs exceed thresholds. Implement a rate limiter per user to prevent abuse:

python
from datetime import datetime, timedelta

def check_rate_limit(user_id: str, limit_per_hour: int = 100) -> bool:
    key = f"rate:{user_id}:{datetime.utcnow().hour}"
    count = redis_client.incr(key)
    
    if count == 1:
        redis_client.expire(key, 3600)
    
    return count <= limit_per_hour

Common mistakes

  • Hardcoding API keys — Always use environment variables or a secrets manager. Never commit keys to version control.
  • Ignoring streaming for long operations — If your response takes more than a second or two, stream it. Users perceive this as faster.
  • Not validating LLM output — Treat LLM responses as untrusted input. Parse and validate with Pydantic or a schema validator.
  • No retry logic — API calls fail; use exponential backoff. Official SDKs often handle this, but verify your setup.
  • Not monitoring costs — Log token usage for every request. Set up alerts before your bill surprises you.
  • Mixing streaming and caching poorly — Cache full responses before streaming, not individual tokens. Mixing them adds complexity.

Key takeaways

  • Use official OpenAI and Anthropic SDKs for type safety, error handling, and authentication out of the box.
  • Stream responses to improve perceived latency and UX, especially for outputs longer than a few seconds.
  • Validate LLM output with Pydantic models and structured output formats to prevent downstream errors.
  • Cache responses in Redis, enforce token limits, and monitor usage to control costs in production.
  • Log token usage and implement rate limiting per user to detect abuse and optimize spending.
  • Handle errors gracefully; LLMs are probabilistic, so retries and fallback strategies are essential.
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