Production engineering beyond conference demos: RAG, costs, data protection and responsible use of AI tools.
"AI amplifies those who think. It does not replace them."
Cesar Zanis
01
The mindset shift (read first)
01
AI amplifies understanding
Code generation is increasingly accessible. The value of engineering extends beyond writing a CRUD application.
Professionals need to understand the business, design solutions and coordinate tools to deliver them. AI amplifies sound judgment; it does not replace it.
The quality of an AI response depends strongly on the quality of the context you provide.
02
The problem first, the tool second
Before opening an AI tool, work through the business problem. Who is the user? What is the real pain point? What happens without this feature?
'Build a registration system' gives the model little useful context. Explaining that a field inspector uses a Honeywell handheld with unreliable internet and must save forms without losing data, even if the battery dies, makes the constraints concrete.
Your experience supplies the context that makes the output useful.
03
AI for planning ≠ AI for implementation
Use reasoning models for architectural exploration: compare alternatives, analyze trade-offs, review ADRs and question decisions. The final decision remains yours.
Use editor-integrated agents for implementation: boilerplate, CRUD, tests and refactoring. They can work with real project files, execute commands and correct errors.
Do not delegate accountability for architecture to AI. It proposes; you decide.
02
Production patterns
RAG: when and how to use it well
The difference between a generic chatbot and a useful assistant.
RAG (Retrieval-Augmented Generation) retrieves relevant context before generating an answer. Without retrieval, a model relies on its training knowledge. Well-designed RAG supplies your documentation, knowledge base and decision history.
A common mistake is using vector search alone while ignoring lexical search such as BM25 or full-text search. Evaluate hybrid retrieval and reranking against a representative set of questions.
Python
# A minimal working RAG architecture
# 1. Indexing (offline, when content changes)
def index_document(doc: str, doc_id: str):
# Chunking: overlapping blocks to preserve context
chunks = chunk_with_overlap(doc, size=800, overlap=100)
for i, chunk in enumerate(chunks):
embedding = embed(chunk) # via API: text-embedding-004
vector_store.upsert(
id=f"{doc_id}_{i}",
vector=embedding,
metadata={"text": chunk, "doc_id": doc_id}
)
# 2. Retrieval (in real time, per user query)
def retrieve(query: str, top_k: int = 5) -> list[str]:
query_embedding = embed(query)
# Semantic search (vectors)
semantic_results = vector_store.query(query_embedding, top_k=top_k)
# Lexical search (exact words): do not ignore it
lexical_results = full_text_search(query, top_k=top_k)
# Hybrid reranking: RRF (Reciprocal Rank Fusion)
return reciprocal_rank_fusion([semantic_results, lexical_results])
# 3. Generation: context injected into the prompt
def answer(query: str) -> str:
context_chunks = retrieve(query)
context = "\n\n".join(context_chunks)
prompt = f"""Responda baseado APENAS no contexto abaixo.
Se a resposta não estiver no contexto, diga explicitamente.
CONTEXTO:
{context}
PERGUNTA: {query}"""
return llm.generate(prompt)
LLM costs beyond the playground
Measure input, output and context costs separately.
Production costs include system instructions, conversation history, retrieved context and generated output. Each grows differently.
Summarize history instead of resending every turn. Route straightforward tasks to smaller models and reserve larger models for tasks that need them. Evaluate response caching where appropriate, and set explicit output limits. Measure quality and cost together.
Python
# Cost controls in production
# 1. Compress history: do not send everything
def compress_history(history: list[dict], max_turns: int = 4) -> str:
if len(history) <= max_turns:
return format_history(history)
# Summarize older turns and keep the last N messages in full
old_turns = history[:-max_turns]
recent_turns = history[-max_turns:]
summary = llm.generate(
f"Resuma em 2-3 frases os pontos principais desta conversa: {old_turns}",
config={"maxOutputTokens": 150} # short summary, small model
)
return f"[Resumo anterior: {summary}]\n{format_history(recent_turns)}"
# 2. Route by complexity: choose a model for each task
def route_to_model(query: str) -> str:
# Fast, inexpensive classification
is_complex = classifier.predict(query) # local or flash model
return "gemini-3.8-flash" if is_complex else "gemini-3.5-flash-lite"
# 3. Explicit output limit
generation_config = {
"maxOutputTokens": 600, # enough for a useful answer
"temperature": 0.3, # adjust temperature to the task and model
}
LGPD + AI: data protection requirements
Define the lawful basis and send only necessary data.
Before sending personal data to an AI API, define its purpose and applicable lawful basis, minimize the data, and review retention, access and international transfers. Consent is one lawful basis under Brazil's LGPD, not the only one. Training policies vary by product and contract; for example, OpenAI API data is not used for training by default. Evaluate anonymization, contractual controls and local processing for the situation. Local processing also requires secure access, logs and backups.
Python
# Basic masking before sending data to an external LLM
import re
def anonymize_for_llm(text: str) -> tuple[str, dict]:
"""Remove PII antes de enviar. Retorna texto limpo + mapa para substituição."""
replacements = {}
counter = {"cpf": 0, "email": 0, "phone": 0}
# CPF
def replace_cpf(m):
key = f"[CPF_{counter['cpf']}]"
replacements[key] = m.group(0)
counter["cpf"] += 1
return key
# Email
def replace_email(m):
key = f"[EMAIL_{counter['email']}]"
replacements[key] = m.group(0)
counter["email"] += 1
return key
text = re.sub(r'\d{3}\.\d{3}\.\d{3}-\d{2}', replace_cpf, text)
text = re.sub(r'[\w.-]+@[\w.-]+\.\w+', replace_email, text)
return text, replacements
# Usage
clean_text, pii_map = anonymize_for_llm(user_message)
llm_response = llm.generate(clean_text)
# Never log pii_map: it contains the original data
# Use only if the response needs the original values
Local AI vs Cloud AI
Know where each approach makes sense.
Local tools such as Ollama, vLLM and llama.cpp run models on infrastructure you control. They can suit classification, extraction and workloads with strict data-handling requirements. Check every dependency and telemetry path rather than assuming data never leaves.
Cloud platforms such as Vertex AI, AWS Bedrock and Azure OpenAI provide managed access to models without operating your own GPUs. Compare capability, privacy, latency and cost for the task.
A hybrid approach can use a local model for routing and a cloud model for harder requests, when the data policy permits it.
Abstract the provider behind an interface when multiple clouds are needed. Dependency Inversion applies here as it does to databases.
Python
# Provider abstraction: switch clouds without changing business logic
from abc import ABC, abstractmethod
class LLMProvider(ABC):
@abstractmethod
def generate(self, prompt: str, config: dict) -> str: ...
# Google Cloud
class VertexProvider(LLMProvider):
def generate(self, prompt: str, config: dict) -> str:
from vertexai.generative_models import GenerativeModel
model = GenerativeModel(config.get("model", "gemini-3.5-flash-lite"))
response = model.generate_content(prompt)
return response.text
# AWS
class BedrockProvider(LLMProvider):
def generate(self, prompt: str, config: dict) -> str:
import boto3
client = boto3.client("bedrock-runtime")
# Claude via Bedrock
response = client.invoke_model(
modelId=config.get("model", "anthropic.claude-3-haiku"),
body={"prompt": prompt, "max_tokens": config.get("max_tokens", 600)}
)
return response["body"].read().decode()
# Local (Ollama)
class OllamaProvider(LLMProvider):
def generate(self, prompt: str, config: dict) -> str:
import requests
r = requests.post("http://localhost:11434/api/generate", json={
"model": config.get("model", "llama3.2"),
"prompt": prompt, "stream": False
})
return r.json()["response"]
# Model routing
def get_provider(task_type: str) -> LLMProvider:
if task_type in ("classify", "extract", "moderate"):
return OllamaProvider() # local execution; protect access and logs
if task_type == "generate":
return VertexProvider() # cloud, large model
return BedrockProvider() # multi-cloud fallback
A practical criterion: if you cannot explain what an agent does in one sentence, you do not yet understand it well enough to operate it in production. Simplicity is a sign of clarity.