AI Integration for Backend Engineers: A Practical Playbook
A practical guide for backend engineers integrating AI and LLMs into production systems covering architecture patterns, rate limiting, prompt management, RAG, caching, and cost optimization.
Backend engineers are being asked to “add AI” to everything now. And most of the content out there is either too academic (ML research papers) or too simple (call the API and print the result). Neither helps you build production AI features.
Here is the thing: AI integration is fundamentally a backend engineering problem. You are calling an API, handling responses, managing state, and dealing with failure modes. The API just happens to return natural language instead of structured data. That creates some unique challenges, but your existing skills apply directly.
I have built AI features for content platforms, customer support tools, and data analysis pipelines. This playbook covers the patterns that work in production. This builds on my experience mentoring engineers on AI integration and from actual production deployments.
AI Integration is Not AI Research
Let me be clear about what backend engineers need to know vs what they can ignore:
You need to know:
- How to call LLM APIs efficiently (rate limiting, retries, streaming)
- How to manage prompts (versioning, testing, evaluation)
- How to cache responses (cost reduction, latency improvement)
- How to validate outputs (the model can return garbage)
- How to handle costs (model selection, token optimization)
You do NOT need to know:
- How transformers work internally
- Training or fine-tuning processes
- Mathematical foundations of embeddings
- Neural network architectures
If you can build a reliable REST API integration with retry logic and caching, you can build production AI features.
Architecture Patterns
Pattern 1: Synchronous (Request-Response)
The simplest pattern. User sends request, backend calls LLM, returns result:
User → API → LLM API → Response → User
(200-2000ms round trip)
Use when:
- Response time under 3 seconds is acceptable
- Result is needed immediately
- Low to moderate volume (under 100 concurrent requests)
Implementation:
func (h *Handler) SummarizeText(w http.ResponseWriter, r *http.Request) {
var req SummarizeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
summary, err := h.aiService.Summarize(ctx, req.Text)
if err != nil {
slog.Error("summarization failed", "error", err)
writeError(w, http.StatusInternalServerError, "summarization failed")
return
}
writeJSON(w, http.StatusOK, map[string]string{"summary": summary})
}
Pattern 2: Asynchronous (Queue-Based)
For long-running or high-volume AI tasks:
User → API → Queue → Worker → LLM API → Result Store
(returns job ID immediately)
User → API → Poll/Webhook → Get Result
Use when:
- Processing takes more than 5 seconds
- High volume requiring rate limit management
- Results can be consumed later
- Batch processing of multiple items
Implementation:
func (h *Handler) AnalyzeDocument(w http.ResponseWriter, r *http.Request) {
var req AnalyzeRequest
json.NewDecoder(r.Body).Decode(&req)
// Create job and queue it
jobID := uuid.NewString()
if err := h.queue.Publish(ctx, "ai-analysis", AnalysisJob{
ID: jobID,
Document: req.DocumentID,
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to queue job")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{
"job_id": jobID,
"status": "processing",
})
}
// Worker processes jobs from queue
func (w *Worker) ProcessAnalysis(ctx context.Context, job AnalysisJob) error {
result, err := w.aiService.Analyze(ctx, job.Document)
if err != nil {
return fmt.Errorf("analysis failed: %w", err)
}
return w.resultStore.Save(ctx, job.ID, result)
}
Pattern 3: Streaming
For chat interfaces or progressive content generation:
func (h *Handler) ChatStream(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming not supported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
stream, err := h.aiService.ChatStream(r.Context(), req.Messages)
if err != nil {
return
}
for chunk := range stream {
fmt.Fprintf(w, "data: %s\n\n", chunk)
flusher.Flush()
}
}
OpenAI API: Rate Limiting and Retry Logic
The OpenAI API has aggressive rate limits. You will hit them. Build defensive code from day one:
type AIClient struct {
client *http.Client
apiKey string
limiter *rate.Limiter
maxRetries int
}
func NewAIClient(apiKey string, rateLimit float64) *AIClient {
return &AIClient{
client: &http.Client{Timeout: 60 * time.Second},
apiKey: apiKey,
limiter: rate.NewLimiter(rate.Limit(rateLimit), int(rateLimit)),
maxRetries: 3,
}
}
func (c *AIClient) Complete(ctx context.Context, req CompletionRequest) (*CompletionResponse, error) {
// Wait for rate limiter
if err := c.limiter.Wait(ctx); err != nil {
return nil, fmt.Errorf("rate limiter: %w", err)
}
var lastErr error
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff
backoff := time.Duration(math.Pow(2, float64(attempt))) * time.Second
select {
case <-time.After(backoff):
case <-ctx.Done():
return nil, ctx.Err()
}
}
resp, err := c.doRequest(ctx, req)
if err == nil {
return resp, nil
}
lastErr = err
// Only retry on rate limit or server errors
if !isRetryable(err) {
return nil, err
}
}
return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}
func isRetryable(err error) bool {
var apiErr *APIError
if errors.As(err, &apiErr) {
return apiErr.StatusCode == 429 || apiErr.StatusCode >= 500
}
return false
}
Token Budget Management
Each API call has token limits. Manage your token budget:
func (s *AIService) buildPrompt(systemPrompt, userInput string, maxTokens int) string {
// Estimate tokens (rough: 1 token ≈ 4 chars for English)
systemTokens := len(systemPrompt) / 4
inputTokens := len(userInput) / 4
availableForContext := maxTokens - systemTokens - inputTokens - 500 // Reserve for response
if availableForContext < 0 {
// Truncate user input to fit
maxInputChars := (maxTokens - systemTokens - 500) * 4
userInput = userInput[:maxInputChars] + "... [truncated]"
}
return fmt.Sprintf("%s\n\n%s", systemPrompt, userInput)
}
Prompt Management: Version Control for Prompts
Prompts are code. Treat them accordingly:
// prompts/summarize.go
package prompts
const SummarizeV1 = `You are a technical content summarizer.
Given the following text, produce a concise summary in 2-3 sentences.
Focus on key technical decisions and their rationale.
Do not include opinions or recommendations.
Text to summarize:
{{.Text}}
Summary:`
const SummarizeV2 = `Summarize the following technical content in exactly 3 bullet points.
Each bullet should capture one key insight or decision.
Use plain language accessible to junior engineers.
Content:
{{.Text}}
Bullets:`
Prompt Testing
func TestSummarizePrompt(t *testing.T) {
// Golden test: known input should produce expected output characteristics
input := loadTestFixture("technical-article.txt")
result, err := aiService.Summarize(context.Background(), input)
require.NoError(t, err)
// Validate output characteristics
assert.Less(t, len(result), 500, "summary should be concise")
assert.NotContains(t, result, "I think", "should not contain opinions")
assert.Greater(t, len(result), 50, "summary should be meaningful")
}
RAG: When and How to Add Retrieval
RAG (Retrieval-Augmented Generation) adds domain knowledge to LLM responses without fine-tuning. The next article in this series covers building a complete RAG system in Go, but here is the high-level decision:
Use RAG when:
- The LLM needs to answer questions about your specific data
- Information changes frequently (product docs, knowledge base)
- You need citations and source attribution
- Fine-tuning is too expensive or slow for your iteration speed
Skip RAG when:
- The LLM already knows the answer (general knowledge)
- Your data fits in the context window (under 100K tokens)
- Accuracy requirements are low (creative writing, brainstorming)
Caching Strategies for LLM Responses
LLM calls are expensive and slow. Cache aggressively:
type CachedAIService struct {
client *AIClient
cache *redis.Client
ttl time.Duration
}
func (s *CachedAIService) Complete(ctx context.Context, req CompletionRequest) (*CompletionResponse, error) {
// Generate cache key from prompt content hash
cacheKey := s.buildCacheKey(req)
// Check cache
cached, err := s.cache.Get(ctx, cacheKey).Bytes()
if err == nil {
var resp CompletionResponse
json.Unmarshal(cached, &resp)
return &resp, nil
}
// Cache miss: call LLM
resp, err := s.client.Complete(ctx, req)
if err != nil {
return nil, err
}
// Store in cache
data, _ := json.Marshal(resp)
s.cache.Set(ctx, cacheKey, data, s.ttl)
return resp, nil
}
func (s *CachedAIService) buildCacheKey(req CompletionRequest) string {
// Hash the full request to create a stable cache key
h := sha256.New()
h.Write([]byte(req.Model))
h.Write([]byte(req.SystemPrompt))
h.Write([]byte(req.UserMessage))
return fmt.Sprintf("ai:completion:%x", h.Sum(nil))
}
Cache hit rates depend on your use case. For FAQ-style queries: 60-80% hit rate. For unique user inputs: 10-20% hit rate. Even low hit rates save money at scale.
Error Handling for Non-Deterministic Outputs
LLM responses can be structurally valid but semantically wrong. You need output validation:
type StructuredResponse struct {
Category string `json:"category"`
Confidence float64 `json:"confidence"`
Tags []string `json:"tags"`
}
func (s *AIService) Classify(ctx context.Context, text string) (*StructuredResponse, error) {
raw, err := s.client.Complete(ctx, classifyPrompt(text))
if err != nil {
return nil, fmt.Errorf("llm call failed: %w", err)
}
// Parse structured output
var result StructuredResponse
if err := json.Unmarshal([]byte(raw.Content), &result); err != nil {
// LLM returned non-JSON despite instructions
return nil, fmt.Errorf("failed to parse LLM output as JSON: %w", err)
}
// Validate output semantics
if err := validateClassification(&result); err != nil {
return nil, fmt.Errorf("invalid classification output: %w", err)
}
return &result, nil
}
func validateClassification(r *StructuredResponse) error {
validCategories := map[string]bool{
"technical": true, "business": true, "support": true,
}
if !validCategories[r.Category] {
return fmt.Errorf("invalid category: %s", r.Category)
}
if r.Confidence < 0 || r.Confidence > 1 {
return fmt.Errorf("confidence out of range: %f", r.Confidence)
}
return nil
}
Retry on Malformed Output
Sometimes the LLM produces valid JSON but misses required fields. Retry with a corrective prompt:
func (s *AIService) ClassifyWithRetry(ctx context.Context, text string) (*StructuredResponse, error) {
for attempt := 0; attempt < 3; attempt++ {
result, err := s.Classify(ctx, text)
if err == nil {
return result, nil
}
// If it is a validation error, retry with corrective feedback
if strings.Contains(err.Error(), "invalid classification") {
slog.Warn("retrying classification with corrective prompt", "attempt", attempt)
continue
}
return nil, err
}
return nil, fmt.Errorf("classification failed after 3 attempts")
}
Testing AI Features
Golden Dataset Testing
Maintain a set of known input/output pairs:
func TestClassification_GoldenDataset(t *testing.T) {
golden := []struct {
input string
expected string
}{
{"How do I reset my password?", "support"},
{"What are your pricing tiers?", "business"},
{"Getting 500 error on the API", "technical"},
}
for _, tc := range golden {
result, err := aiService.Classify(context.Background(), tc.input)
require.NoError(t, err)
assert.Equal(t, tc.expected, result.Category)
}
}
Evaluation Metrics
Track these over time:
- Accuracy: Percentage of correct classifications vs golden dataset
- Latency P95: Response time including LLM call
- Cost per request: Tokens consumed per classification
- Cache hit rate: Percentage of requests served from cache
- Retry rate: How often the first attempt fails validation
Cost Optimization
Model Selection by Task Complexity
Not every request needs GPT-4o:
func (s *AIService) selectModel(task TaskType) string {
switch task {
case TaskClassification, TaskExtraction, TaskSummarize:
return "gpt-4o-mini" // Simple tasks: cheaper model
case TaskAnalysis, TaskGeneration, TaskReasoning:
return "gpt-4o" // Complex tasks: powerful model
default:
return "gpt-4o-mini"
}
}
Token Management
// Trim context to minimize token usage
func trimContext(docs []string, maxTokens int) []string {
var result []string
totalTokens := 0
for _, doc := range docs {
docTokens := estimateTokens(doc)
if totalTokens+docTokens > maxTokens {
break
}
result = append(result, doc)
totalTokens += docTokens
}
return result
}
func estimateTokens(text string) int {
// Rough estimate: 1 token per 4 characters for English
return len(text) / 4
}
Production Checklist for AI Features
Before shipping an AI feature:
- Rate limiting configured for LLM API
- Retry logic with exponential backoff
- Output validation beyond HTTP status codes
- Caching layer for repeated queries
- Cost monitoring and alerts for token usage
- Timeout configuration (LLM calls can be slow)
- Fallback behavior when LLM is unavailable
- Prompt versioning in source control
- Golden dataset tests passing
- Structured logging for debugging
- Model selection appropriate for task complexity
- User-facing error messages that do not expose LLM internals
Wrapping Up
AI integration is a backend engineering discipline. The same principles that make reliable APIs, idempotent operations, retry logic, caching, and monitoring, apply directly to LLM integration.
Start simple: one synchronous endpoint, one model, one prompt. Get it working in production. Then optimize with caching, async processing, and model routing.
The engineers who will excel at AI integration are not the ones with ML PhDs. They are the ones who understand distributed systems, failure modes, and cost optimization. That is backend engineering.
Need help building AI features into your product? I build custom applications with AI integration for teams that want production-ready AI without hiring a dedicated ML team. Let us talk about what is possible for your use case.
See AI integration applied end-to-end in my MFunnel AI SaaS project and AI agent portfolio work.