Introduction
Day 4 of the Modern Route AI Agents crash course is where the conversation shifts. The first three days were about getting a single agent to do something useful — pick a tool, generate a reply, handle a state. Day 4 is about what happens when you have to put that agent in front of real users. Routes, services, retries, logging, cloud-integrated config, Docker, secret managers, observability, deployment topology. The unglamorous half of agent engineering.
The walkthrough is built around one concrete project — an autonomous research and analyst report generation system in LangGraph — but the lessons are framework-agnostic. By the end of this article you’ll have a picture of how a production-shaped agent codebase is organised, how a non-trivial LangGraph workflow with subgraphs, parallel branches, conditional edges, and a human-in-the-loop is wired up, and which production concerns (logging, secrets, cloud integration, tracing, deployment) you can’t skip if the agent is going to run for paying customers rather than a YouTube demo.
📌 Part of a 4-day crash course. Day 1 covered the agent mental model, Day 2 built the first LangGraph workflow, Day 3 layered project structure and config, and Day 4 (this article) is the production pass: routes, services, parallel subgraphs, cloud integration, and how to ship.
📚 Table of contents
- What changes when you move from a demo agent to production
- The project: autonomous research and analyst report generator
- Codebase shape — routes, services, models, workflow
- Inside the LangGraph workflow
- Subgraphs, parallelization, and the send API
- Conditional edges and the human-in-the-loop
- Pydantic, state, and structured outputs
- Logging, tracing, and observability
- Cloud-integrated config: secrets, storage, models
- Deployment topology: Docker, ECR, Lambda, ECS
- Cost and latency in a multi-LLM pipeline
- Guardrails, retries, and failure handling
- Best practices for shipping agents
- Common mistakes
- Conclusion
- Frequently asked questions
🏗️ What changes when you move from a demo agent to production
A demo agent fits in one notebook cell. A production agent is an entire service. The shift looks obvious on paper and is painful in practice. Day 4 spends most of its time on the things that don’t fit in a notebook.
🧪 Demo agent
- One Jupyter notebook, one prompt, one model call
- API keys hard-coded or in a
.env - No retries, no timeouts, no rate limit handling
- No request tracking, no per-call logs
- Works on the developer’s laptop and nowhere else
🚀 Production agent
- FastAPI service with versioned routes and pydantic validation
- Secrets pulled from AWS Secrets Manager or Vault, not
.env - Structured logs per request, traced through every node
- Failure modes mapped: tool errors, model timeouts, partial output
- Containerised, deployed, monitored, costed
The job title shifts too. A demo agent is a prompt-engineering exercise. A production agent is an AI engineering exercise — the LLM is one component in a service that has the same operational concerns as any other backend.
🧭 The project: autonomous research and analyst report generator
The walkthrough is built around a system that takes a topic from the user, spins up multiple AI analyst personas, runs a parallel research interview for each, and stitches the outputs into a final report — downloadable as DOCX or PDF. It’s a deliberately non-trivial agent because every interesting production concern shows up in it.
🎯 What the system does end to end
- User logs into a dashboard and enters a topic (e.g. “impact of GenAI on developer jobs”)
- The first node creates a set of AI analyst personas tailored to the topic
- Optional human-in-the-loop: the user can refine the personas before research starts
- Each analyst runs a research interview in parallel — asking questions, calling search tools, generating answers
- Per-analyst sections are written, then merged into an introduction, body, and conclusion
- Final report is returned as a downloadable file with sources
Why this shape matters: it forces every production concern into the open. Multiple LLM calls per request (cost). Parallel branches (concurrency). Human approval (state pause). Tool calls to the open web (failure handling). File output (storage). Auth (DB). It’s a representative shape for the kind of agent product an enterprise actually pays for.
🧱 Codebase shape — routes, services, models, workflow
The folder layout is the first concrete production lesson. A real agent service is not one file. The walkthrough splits the code into four clear layers and that separation is what makes the system testable and deployable.
research_analyst/
├── api/
│ ├── main.py # FastAPI app, mounts all routes
│ ├── routes.py # Endpoint definitions
│ ├── services.py # Business logic called by routes
│ └── models.py # Pydantic request/response models
├── workflow/
│ ├── report_generation_workflow.py
│ └── interview_graph_builder.py
├── prompts/
│ └── prompt_templates.py
├── utils/
│ ├── model_loader.py
│ ├── config_loader.py
│ └── cloud.py # S3, Secrets Manager, Bedrock helpers
├── exception/
│ └── exception.py
├── logger/
│ └── logger.py
├── generated_reports/ # output artefacts
├── logs/ # per-run log files
├── requirements.txt
├── Dockerfile
└── .env # local only — never in prod
🛣️ Routes
Thin HTTP layer. Each endpoint maps to one verb-noun action: /login,
/signup, /generate-report,
/submit-feedback, /download-report.
Routes validate inputs, call a service, return a response. No business logic here.
⚙️ Services
Where the actual work lives. A ReportService exposes
start_report_generation,
submit_feedback,
get_report_status, and
download_file. Services call into the workflow layer.
📦 Models
Pydantic schemas for every request and response. They are the contract at the edge of the service — the only place untyped data is allowed to enter, and the only place it’s allowed to leave.
🧠 Workflow
The LangGraph code itself — nodes, edges, subgraphs, the agentic state machine. Pure agent logic, no HTTP or pydantic concerns. Can be tested in a notebook in isolation.
The win from this layout: the workflow file knows nothing about FastAPI, and the routes know nothing about LangGraph. You can swap one without touching the other. That’s ordinary backend hygiene — agent codebases skip it more often than they should.
🧠 Inside the LangGraph workflow
The workflow is built as a state graph. Each box on the diagram is a function (a node); each arrow is an edge. The state object — a typed dict in Python — flows through every node.
🧩 Nodes in the report graph
- create_analyst — takes the topic and the
max_analystcount, asks the LLM to draft N analyst personas - human_feedback — pauses the graph and waits for the user’s feedback on the personas
- conduct_interview — a subgraph (not a single node) that runs the research loop per analyst
- write_report — assembles the per-analyst sections into a body
- write_introduction — generates the intro from the merged sections
- write_conclusion — closes the report
- finalize_report — merges intro, body, conclusion, sources
def build_graph(self):
builder = StateGraph(ReportGenerationState)
builder.add_node("create_analyst", self.create_analyst)
builder.add_node("human_feedback", self.human_feedback)
builder.add_node("conduct_interview", self.interview_graph)
builder.add_node("write_report", self.write_report)
builder.add_node("write_introduction", self.write_introduction)
builder.add_node("write_conclusion", self.write_conclusion)
builder.add_node("finalize_report", self.finalize_report)
builder.add_edge(START, "create_analyst")
builder.add_edge("create_analyst", "human_feedback")
builder.add_conditional_edges(
"human_feedback",
self.initiate_all_interviews,
["create_analyst", "conduct_interview", END],
)
builder.add_edge("conduct_interview", "write_report")
builder.add_edge("conduct_interview", "write_introduction")
builder.add_edge("conduct_interview", "write_conclusion")
builder.add_edge("write_report", "finalize_report")
builder.add_edge("write_introduction", "finalize_report")
builder.add_edge("write_conclusion", "finalize_report")
builder.add_edge("finalize_report", END)
return builder.compile(checkpointer=self.checkpointer)
The structure is doing real work. Three things in particular — subgraphs, conditional edges,
and the send API — are what
separate this from a toy graph.
🪆 Subgraphs, parallelization, and the send API
A subgraph is a self-contained graph used as a single node inside the parent. The interview logic — ask a question, search the web, generate an answer, save the interview, write a section — is its own state machine. Embedding it as a subgraph keeps the parent diagram readable and keeps the interview state isolated.
🔎 Nodes inside the interview subgraph
- generate_question — analyst LLM call producing the next question
- search_web — tool call to a web search provider (Tavily in the demo)
- generate_answer — LLM synthesises an answer from the retrieved context
- save_interview — persists the Q&A pair to the buffer
- write_section — produces a structured section per analyst
There’s an internal loop here: if the answer isn’t good enough, the subgraph routes back to generate_question for another round, capped at a max-turn budget. That cap matters — without it, an evaluator-LLM that’s feeling pedantic can keep the loop running until your token bill is unreadable.
Parallelization is what makes the system fast. With N analysts, you don’t want to run N
interviews serially. LangGraph’s Send
primitive fans the work out:
def initiate_all_interviews(self, state: ReportGenerationState):
human_feedback = state.get("human_analyst_feedback", "")
if human_feedback:
return "create_analyst" # rebuild personas based on feedback
topic = state["topic"]
return [
Send(
"conduct_interview",
{
"analyst": analyst,
"messages": [HumanMessage(content=f"So you said you were writing about {topic}?")],
"max_num_turns": 2,
"context": [],
},
)
for analyst in state["analysts"]
]
Each Send spawns an independent
invocation of the subgraph with its own state. LangGraph executes them concurrently and waits for
all branches to complete before moving to the report-writing stage. The downstream
write_report,
write_introduction, and
write_conclusion nodes are also
parallel — three edges from one source means three concurrent calls.
🔀 Conditional edges and the human-in-the-loop
Normal edges run sequentially. Conditional edges run a router function and pick the next node from a set of allowed targets. Most agent decisions — tool call vs final answer, retry vs continue, escalate vs resolve — are conditional edges.
The classic example is the ReAct loop. The LLM either answers directly, or it emits a tool call. A router inspects the message:
def route_tools(state: AgentState) -> Literal["tools", END]:
last_message = state["messages"][-1]
if hasattr(last_message, "tool_calls") and last_message.tool_calls:
return "tools"
return END
builder.add_conditional_edges("llm", route_tools, ["tools", END])
builder.add_edge("tools", "llm") # back to the brain after the tool runs
In the report generator, the conditional edge sits after human_feedback. Three outcomes: feedback was given → rebuild personas; feedback was empty → start interviews; user aborted → end the run. The human-in-the-loop is implemented as a pause point in the graph — LangGraph’s checkpointer persists state, the UI submits feedback, the graph resumes from where it stopped.
🛑 Why pauses matter in prod
Long-running agents that block a HTTP request for thirty seconds will be killed by your load balancer. The right pattern is: start the workflow, return a run ID immediately, poll for status, resume on user input. The checkpointer is what makes that possible — state lives in a database (SQLite for the demo, Postgres in prod), and any worker can resume any run.
🧬 Pydantic, state, and structured outputs
Four things show up in every well-built agent node, and the walkthrough is explicit about it:
1️⃣ State
A typed dict (or pydantic model) carrying everything the graph needs — topic, analysts, feedback, messages, context, generated sections. Every node reads from and writes to it.
2️⃣ Prompt
System and user prompts loaded from a dedicated prompts/
module — never inline strings in business logic. Easier to A/B test, easier to version.
3️⃣ Pydantic output schema
For any node that returns structured data, the LLM is bound to a pydantic class with
with_structured_output. No
string parsing, no regex on JSON, no flaky downstream code.
4️⃣ LLM call
A model loader pulled from a single utility — never instantiated in the node. Lets you swap GPT-5 for Claude Opus 4.7 for Bedrock-hosted Llama with one config change.
class Analyst(BaseModel):
name: str = Field(description="Name of the analyst")
role: str = Field(description="Role of the analyst in the context of the topic")
affiliation: str = Field(description="Primary affiliation of the analyst")
description: str = Field(description="Description of the analyst's focus, concerns, and motives")
class Perspectives(BaseModel):
analysts: List[Analyst] = Field(description="Comprehensive list of analysts with their roles")
def create_analyst(self, state: GenerateAnalystState):
structured_llm = self.llm.with_structured_output(Perspectives)
system_message = ANALYST_INSTRUCTIONS.format(
topic=state["topic"],
human_analyst_feedback=state.get("human_analyst_feedback", ""),
max_analysts=state["max_analyst"],
)
analysts = structured_llm.invoke(
[SystemMessage(content=system_message), HumanMessage(content="Generate the set of analysts.")]
)
return {"analysts": analysts.analysts}
Pydantic at every layer — HTTP request, internal state, LLM output — is the single biggest defence against silent agent breakage in prod.
📊 Logging, tracing, and observability
A demo agent that prints to stdout is useless the moment a real user hits it. Day 4’s repo bakes in two layers of visibility.
📝 Application logs
A custom logger writes a per-run file under logs/
with timestamp, module, level, and message. Every route, every service call, every node entry
and exit. In prod, the same logger streams to CloudWatch or Loki.
🧵 LLM tracing
LangSmith is enabled with one env var — LANGCHAIN_API_KEY.
Every node call shows up as a span with input, output, latency, and token count. Without this
you cannot debug an agent that “sometimes” hallucinates.
# .env (local) or AWS Secrets Manager (prod)
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=ls__xxx
LANGCHAIN_PROJECT=research-analyst-prod
The minimum bar to call an agent service production-ready: per-run trace, per-node latency, per-call token cost, retry counts, and a way to replay a failed run from its checkpoint.
☁️ Cloud-integrated config: secrets, storage, models
A .env file is fine on your laptop.
In production it’s a security incident waiting to happen. Day 4 makes the explicit point that
the same code path should pull secrets from a cloud-native store in prod, and from
.env in local dev — with the
switch driven by an environment flag, not a code change.
🔐 Secrets Manager
- OpenAI / Anthropic / Tavily API keys
- Database credentials
- LangSmith and tracing tokens
- Rotated by IAM policy, never logged
📦 S3 (or equivalent)
- Generated DOCX / PDF reports
- Uploaded source documents
- Versioned, lifecycled, signed URLs for download
- Never store output on the container’s local disk
🧠 Bedrock / hosted models
- For compliance-bound workloads where data can’t leave AWS
- Model loader returns a Bedrock-backed client in prod, OpenAI-backed in dev
- Same node code in both environments
🗄️ Postgres & checkpoint store
- User auth and run history in Postgres
- LangGraph checkpoints in a managed Postgres for resume-from-pause
- MongoDB if your logs payload is large and unstructured
def load_secret(name: str) -> str:
if os.getenv("APP_ENV") == "production":
client = boto3.client("secretsmanager", region_name="ap-south-1")
return json.loads(client.get_secret_value(SecretId=name)["SecretString"])["value"]
return os.getenv(name, "")
🚢 Deployment topology: Docker, ECR, Lambda, ECS
The repo ships a Dockerfile for a
reason. The deployment story isn’t “run uvicorn
on an EC2 box” — it’s containerise, push, run.
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV APP_ENV=production
EXPOSE 8000
CMD ["uvicorn", "research_analyst.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
📍 Where to run the container
- ECS Fargate / Cloud Run. The default for an HTTP agent service. Long-lived containers, autoscaling on CPU and request count, no server management.
- AWS Lambda (container image). Works for short, single-shot agents triggered via API Gateway. Watch the 15-minute timeout — a multi-step research run easily blows past it. Use it for the lightweight endpoints, not the heavy workflow.
- Kubernetes (EKS / GKE). Worth it once you have multiple agent services, shared observability, and a platform team. Overkill for a single workload.
- Async worker pool. The pattern Day 4 hints at: the FastAPI service accepts the request, drops a job onto a queue (SQS / Celery / Kafka), workers pull jobs and run the LangGraph workflow, results are pushed back via webhook or polling. Decouples latency from request handling.
The CI/CD path is standard: GitHub Actions builds the image, pushes to ECR, ECS pulls the new tag, rolling deploy. Nothing agent-specific — which is the point. An agent service is just a service.
💸 Cost and latency in a multi-LLM pipeline
A single report-generation run in this system fires a lot of LLM calls. Roughly: one to draft the personas, one to refine on feedback, three per analyst inside the interview loop, three section writes, plus intro and conclusion. With three analysts that’s comfortably 15+ calls per user request. Cost stops being a footnote.
📈 Where the cost goes
- Web search results pasted into prompts (input tokens balloon)
- Long context for the final report write
- Retries on bad structured-output parses
- Uncapped interview loops on a noisy topic
✂️ Where to cut
- Use a small model for routing and a big one only for synthesis
- Cap interview turns (the demo caps at 2)
- Compress search results before they hit the LLM — summarise per page
- Cache prompt prefixes when the provider supports it
Latency follows the same shape. The parallel Send
fan-out is what keeps the total wall-clock time bounded — otherwise three analysts in series
would triple the response time. Track p50 and p95 per node, not just per request.
🛡️ Guardrails, retries, and failure handling
Every node that calls something external — the LLM, a search API, a database — can fail. Production agents handle that explicitly.
- Retry on transient errors. Wrap LLM and tool calls with tenacity-style exponential backoff. Cap at three tries.
- Validate structured output. If pydantic parsing fails, retry once with a corrective prompt. After that, downgrade to a safe default and log the failure.
- Cap iteration counts. Max turns in the interview loop, max tool-call depth in the ReAct cycle. No unbounded loops in prod, ever.
- Timeouts. Per-LLM-call timeout, per-tool-call timeout, per- run timeout. The orchestrator should give up gracefully rather than hang.
- Input validation at the route. Pydantic in the API layer catches malformed requests before they reach the LLM. Cheap defence.
- Output filters. PII redaction on the final report, blocklist for sensitive terms, max length cap so the agent can’t generate a 50-page essay nobody asked for.
✅ Best practices for shipping agents
Do
- Separate routes, services, models, workflow — same way you’d structure any backend
- Use pydantic at every boundary: HTTP, state, structured output
- Externalise prompts to a dedicated module, version them like code
- Wire LangSmith (or equivalent) from day one
- Run the workflow async with a queue and a worker pool
- Persist checkpoints so any worker can resume any run
- Cap every loop and every tool call
- Use a small model for routing, a big one only where reasoning matters
Avoid
- Hard-coding API keys or pulling from
.envin prod - Writing generated files to the container’s local disk
- Holding HTTP connections open for the full LangGraph run
- One giant prompt that does five things — it will hallucinate at least one
- Logging full LLM payloads with user PII into shared log sinks
- Skipping the checkpointer because “the demo worked without it”
- Treating the LLM as deterministic in test suites
🚫 Common mistakes
- Skipping the API layer entirely. Exposing a LangGraph run directly to a frontend without a routes/services boundary makes the workflow impossible to test, version, or rate-limit.
- Running heavy agents inside the HTTP request. A 90-second LangGraph run inside a synchronous endpoint will time out behind any reasonable load balancer. Push it to a worker.
- No retry, no fallback. One transient Tavily 503 and the whole report fails. Wrap every external call.
- Storing checkpoints in SQLite in prod. Fine for dev, fatal under concurrency. Move to Postgres or Redis-backed checkpointers before launch.
- Ignoring token cost in design. The cheapest moment to reduce cost is at architecture time — pick which model runs which node before you have a bill.
- No human review of failed runs. If you don’t sample and replay a percentage of failures every week, regressions land silently.
Conclusion
Day 4 is the “everything you’d skip in a tutorial” pass. The LangGraph workflow
itself is interesting — subgraphs, parallel Send,
conditional edges, a human-in-the-loop checkpoint — but the bulk of the work is in the
surrounding service: routes, services, pydantic at every boundary, structured logging, LangSmith
tracing, cloud-native secrets, S3 for output, Postgres for checkpoints, and a container that ships
cleanly into ECS or Cloud Run.
The mental model to take into your own builds: an agent in production is a regular backend service that happens to call an LLM. The same engineering hygiene you’d apply to any other service applies here. The LLM does not earn you an exemption from timeouts, validation, retries, or observability — it makes all of them more important.
Related reading
-
Day 3: Logger, Config & Model Loader
The production Python infrastructure Day 4’s workflow depends on—structlog, YAML config, and model loader abstraction.
-
Evaluating LLM Chatbots and RAG Pipelines
LangSmith, LLM-as-a-judge, and the four core RAG metrics—the natural next step after deploying Day 4’s agent.
-
MCP Explained: Build Your Own Server
Standardise Day 4’s tools as MCP servers so they can be reused across different agents and frameworks.