Chapter 12 β Deployment & Production
βA demo is not a product. The gap between βit works on my laptopβ and βit works for 10,000 usersβ is where most agent projects die.β
The Production Gap
Your agent works great in development. Now ship it. Hereβs what that actually requires:
- Single user (you)
- Best-case inputs
- Cost doesn't matter
- Errors β print() and fix
- No latency requirements
- Thousands of concurrent users
- Adversarial and edge-case inputs
- Every token costs money
- Errors β graceful degradation + alerts
- Sub-second response time expected
The Production Stack
Serving Agents via API
Wrap your agent in a FastAPI server:
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import asyncio
app = FastAPI()
class ChatRequest(BaseModel):
message: str
thread_id: str = "default"
class ChatResponse(BaseModel):
response: str
tool_calls: list[dict] = []
@app.post("/chat", response_model=ChatResponse)
async def chat(req: ChatRequest):
try:
result = await agent.ainvoke(
{"messages": [("user", req.message)]},
config={"configurable": {"thread_id": req.thread_id}},
)
return ChatResponse(
response=result["messages"][-1].content,
tool_calls=[],
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/chat/stream")
async def chat_stream(req: ChatRequest):
"""Stream agent responses for real-time UX."""
async def generate():
async for event in agent.astream_events(
{"messages": [("user", req.message)]},
config={"configurable": {"thread_id": req.thread_id}},
version="v2",
):
if event["event"] == "on_chat_model_stream":
chunk = event["data"]["chunk"]
if chunk.content:
yield f"data: {chunk.content}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
Observability: Seeing What Your Agent Does
In production, you must be able to trace every decision your agent makes. Key tools:
| Tool | What It Does | Best For |
|---|---|---|
| LangSmith | Full trace visualization, evaluation, datasets | LangGraph users |
| Langfuse | Open-source tracing, prompt management | Self-hosted / privacy |
| Arize Phoenix | Traces + evaluation + embeddings analysis | ML teams |
| Braintrust | Evaluation, logging, prompt playground | Evaluation-focused |
Adding Tracing (LangSmith)
# Just set environment variables β LangGraph auto-traces
export LANGCHAIN_TRACING_V2=true
export LANGCHAIN_API_KEY="ls__..."
export LANGCHAIN_PROJECT="my-research-agent"
# Every agent run now shows in LangSmith:
# - Full message trace (user β LLM β tool β LLM β ...)
# - Token counts and costs per step
# - Latency breakdown
# - Tool inputs/outputs
Custom Logging
import logging
import time
logger = logging.getLogger("agent")
class AgentLogger:
def on_llm_start(self, prompt, **kwargs):
logger.info(f"LLM call started | model={kwargs.get('model')}")
self.start_time = time.time()
def on_llm_end(self, response, **kwargs):
duration = time.time() - self.start_time
tokens = response.usage
logger.info(
f"LLM call completed | "
f"duration={duration:.2f}s | "
f"input_tokens={tokens.prompt_tokens} | "
f"output_tokens={tokens.completion_tokens}"
)
def on_tool_start(self, tool_name, tool_input, **kwargs):
logger.info(f"Tool call: {tool_name}({tool_input})")
def on_tool_error(self, error, **kwargs):
logger.error(f"Tool error: {error}")
Cost Management
Agents can be expensive. A complex task might make 10β20 LLM calls with large context windows.
Cost Estimation
| Model | Input (per 1M tokens) | Output (per 1M tokens) | Typical agent task |
|---|---|---|---|
| GPT-4o | $2.50 | $10.00 | ~$0.05β$0.50 |
| GPT-4o-mini | $0.15 | $0.60 | ~$0.005β$0.05 |
| Claude 3.5 Sonnet | $3.00 | $15.00 | ~$0.06β$0.60 |
| Llama 3.3 (local) | Free | Free | Electricity only |
Cost Control Strategies
# 1. Use cheaper models for simple routing/classification
router_llm = ChatOpenAI(model="gpt-4o-mini") # Cheap for classification
worker_llm = ChatOpenAI(model="gpt-4o") # Powerful for complex work
# 2. Set token budgets
MAX_TOKENS_PER_REQUEST = 50_000
MAX_COST_PER_REQUEST = 1.00 # dollars
# 3. Cache tool results
from functools import lru_cache
@lru_cache(maxsize=1000)
def cached_search(query: str) -> str:
return tavily.search(query)
# 4. Summarize long tool outputs before injecting into context
def truncate_tool_output(output: str, max_chars: int = 2000) -> str:
if len(output) > max_chars:
return output[:max_chars] + "\n...[truncated]"
return output
Error Handling
Agents fail in unique ways. Plan for all of them:
import tenacity
@tenacity.retry(
stop=tenacity.stop_after_attempt(3),
wait=tenacity.wait_exponential(multiplier=1, min=1, max=10),
retry=tenacity.retry_if_exception_type((RateLimitError, TimeoutError)),
)
async def call_llm_with_retry(messages, tools):
return await client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
timeout=30,
)
Deployment Options
| Option | Complexity | Best For |
|---|---|---|
| FastAPI + Docker | Medium | Custom deployments, full control |
| LangServe | Low | LangGraph apps, quick deployment |
| Modal / Fly.io | Low | Serverless, auto-scaling |
| AWS Lambda + API Gateway | Medium | Event-driven, pay-per-use |
| Kubernetes | High | Enterprise, multi-region, high availability |
Docker Deployment
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# docker-compose.yml
services:
agent:
build: .
ports:
- "8000:8000"
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- TAVILY_API_KEY=${TAVILY_API_KEY}
restart: unless-stopped
Safety in Production
Whatβs Next
You know how to deploy. Now letβs explore the open source ecosystem β the projects, tools, and communities that make the agent world tick.
Next: Chapter 13 β Open Source Landscape β
β Previous: Chapter 11 β Agentic Design Patterns Β· Next: Chapter 13 β Open Source Landscape β
Last updated: April 2026