Introduction
Day 3 of the “Building AI Agents for Production” crash course is the day the project stops looking like a notebook. The first two sessions handled the theory and a single-file LangGraph prototype. Day 3 is where the same code is rebuilt as a real Python project — with a custom logger, a custom exception class, a YAML-driven config, and a model loader that can swap providers without touching node code.
The walkthrough is built around an autonomous research-and-analyst-report project, but the lessons are framework-agnostic. By the end of this article you’ll have a picture of how to set up the boring scaffolding that every production agent needs — the parts that don’t show up in tutorials but decide whether your agent survives the first week in front of real users. Along the way the session also doubles as a practical lesson on vibe coding with GitHub Copilot and Cursor: when a generic prompt is fine, and when you need to write a specific, experience-driven one.
📌 Part of a 4-day crash course. Day 1 framed the agent mental model, Day 2 built the first LangGraph workflow, Day 3 (this article) sets up the production-shaped project — logger, exceptions, config, model loader — and Day 4 covers the full deployment story with routes, subgraphs, and cloud integration.
📚 Table of contents
- Why Day 3 is the scaffolding day
- The project layout we’re building into
- Vibe coding done right: Copilot, Cursor, and prompts that work
- Building a production-grade logger with structlog
- A custom exception class that traces the real error
- Logging levels and when to raise vs when to log
- YAML-driven configuration loading
- A multi-provider model loader
- The
__init__.pytrick for a single shared logger - Best practices for shipping the foundations layer
- Common mistakes
- Conclusion
- Frequently asked questions
🧱 Why Day 3 is the scaffolding day
A common-sense way to think about an agent project: there’s the agent, and then there’s everything around the agent. Day 3 is the “everything around the agent” pass — the boring scaffolding nobody films a YouTube short about, but which decides whether the workflow you build on Day 4 will be debuggable in production.
🧪 What a typical tutorial ships
- One notebook cell with a hard-coded model name
print()statements for “logging”- Try / except blocks that swallow the traceback
- API key inline in the source file
- No way to swap GPT for Gemini without editing the workflow
🚀 What Day 3 sets up instead
- A structured logger writing JSON to file and console
- A custom exception that captures file name, line number, and traceback
- A YAML config the workflow reads at runtime
- A model loader that resolves provider, model, temperature, and max tokens from config
- A separation of concerns clean enough that the workflow file imports nothing about logging configuration
The phrase used in the session is worth keeping: modular coding — a separation of concerns. The config doesn’t know about LangGraph. LangGraph doesn’t know about your logger’s handler list. The logger doesn’t know which model is loaded. Each module does one thing and can be replaced on its own.
🗂️ The project layout we’re building into
Before any code goes in, the folder structure is fixed. Day 3 builds out four of these folders (logger, exception, utils, config). Day 4 fills in the rest.
research_analyst/
├── api/ # Day 4 — FastAPI routes & services
├── workflow/ # Day 4 — the LangGraph workflow
├── prompts/ # prompt templates, separated from logic
├── utils/
│ ├── config_loader.py # Day 3
│ └── model_loader.py # Day 3
├── config/
│ └── configuration.yaml # Day 3
├── logger/
│ ├── __init__.py # exposes a single global logger
│ └── custom_logger.py # Day 3
├── exception/
│ └── custom_exception.py # Day 3
├── logs/ # auto-created log files
├── requirements.txt
└── .env # local only — never in prod
Two things to notice. First, every folder has a single responsibility. Second, the workflow folder is left empty on Day 3 on purpose — the entire goal of the session is that you can swap in any agent workflow on top of this scaffolding without rewriting how logs, errors, config, or models are handled.
🎛️ Vibe coding done right: Copilot, Cursor, and prompts that work
A recurring theme in the session: yes, you should use Copilot or Cursor while building this. No, that doesn’t mean you can skip understanding the code. The point made repeatedly — and worth repeating — is that a vague prompt produces vague code, and you can’t recognise vague code if you don’t already know what good looks like.
The session demonstrates this with a side-by-side experiment for the logger. First prompt was generic: “can you write one logger for my project using structlog?”. Copilot produced a small, plausible-looking file that wouldn’t survive a real project. Second prompt was specific, written by someone who has shipped loggers before:
✍️ The specific prompt that produced production code
Create a production-ready Python custom logger class that automatically creates a log directory
(if it doesn’t already exist) and generates a timestamp-based log file name for persistent
logging. The logger should log messages to both the console and the file. Use the
structlog library to implement
structured JSON logging. Each log entry should include an ISO timestamp, the log level, and the
output format should be JSON. The logger should support passing additional context fields such as
user_id,
file_name,
error, or any custom metadata.
Same model, same IDE — completely different output, because the prompt encoded engineering decisions the first prompt left to the LLM’s imagination. The takeaway: vibe coding is a productivity tool, not an experience substitute. Learn the fundamentals first, then let Copilot save you typing.
🟦 VS Code + Copilot
Default for most teams. The Copilot extension exposes ask / edit / agent modes and lets you target specific files for context. Premium models (GPT-5, Claude) sit behind a paid tier; the auto mode picks a default for free users.
🟪 Cursor
Same VS Code base, more aggressive AI integration. Useful trick: a collective mode that compares outputs from multiple models on the same prompt and synthesises a final answer. Most production teams still default to Copilot, but Cursor is worth knowing.
📝 Building a production-grade logger with structlog
The choice of structlog over the
stdlib logging module isn’t
cosmetic. Two reasons drive it:
- JSON output by default. Every log line is a structured object — timestamp, level, event, plus any extra fields you pass. That’s exactly the shape CloudWatch, Loki, or Datadog want.
- Free-form context fields. You can attach
user_id,file_name, a run ID, a node name — whatever you want — without subclassing anything.
The class is a single file, logger/custom_logger.py,
and the shape is straightforward.
import os
import logging
from datetime import datetime
import structlog
class CustomLogger:
def __init__(self, log_dir: str = "logs"):
os.makedirs(log_dir, exist_ok=True)
log_file = f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.log"
self.log_file_path = os.path.join(log_dir, log_file)
def get_logger(self, name: str = __file__):
logger_name = os.path.basename(name)
file_handler = logging.FileHandler(self.log_file_path)
console_handler = logging.StreamHandler()
logging.basicConfig(
format="%(message)s",
level=logging.INFO,
handlers=[console_handler, file_handler],
)
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
structlog.stdlib.add_log_level,
structlog.processors.EventRenamer(to="event"),
structlog.processors.JSONRenderer(),
],
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
return structlog.get_logger(logger_name)
Calling it is just as plain.
logger = CustomLogger().get_logger(__file__)
logger.info(
"user uploaded a file",
user_id="u_482",
file_name="report.pdf",
)
logger.error(
"model loading failed",
provider="openai",
error="missing api key",
)
Both calls produce a JSON line on the console and a JSON line in
logs/2026-05-15_18-04-12.log. The
same logger streams cleanly to CloudWatch the moment the container ships, with no code change.
⚠️ Why not just print()?
Three reasons. print doesn’t
survive past stdout, so a container restart loses the history. It has no levels, so you
can’t filter out debug noise without grep. And it isn’t structured, so a log
aggregator can’t query “all errors where user_id = x”. Structured
logging fixes all three.
💥 A custom exception class that traces the real error
Python already ships a generous exception hierarchy — ValueError,
KeyError,
TypeError, the lot. FastAPI ships
HTTPException. So why bother writing
a custom one?
Two reasons. First, when something fails inside an agent node, the default traceback can be hundreds of lines deep through LangGraph, LangChain, the provider SDK, and back. A custom wrapper lets you surface the one line that matters — file, line number, error message — in a consistent shape. Second, it gives downstream code (your logger, your API error handler) a single type to catch.
import sys
import traceback
class ResearchAnalystException(Exception):
def __init__(self, error_message, error_detail: sys = None):
norm_msg = (
str(error_message)
if isinstance(error_message, BaseException)
else error_message
)
if error_detail is None:
exc_type, exc_value, exc_tb = sys.exc_info()
else:
exc_type, exc_value, exc_tb = error_detail.exc_info()
last_tb = traceback.extract_tb(exc_tb)[-1] if exc_tb else None
self.file_name = last_tb.filename if last_tb else "<unknown>"
self.lineno = last_tb.lineno if last_tb else -1
self.error_message = norm_msg
self.traceback_str = "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
super().__init__(self.__str__())
def __str__(self):
return (
f"Error in [{self.file_name}] at line [{self.lineno}]: "
f"{self.error_message}"
)
def __repr__(self):
return self.__str__()
The usage pattern is the same shape you’d see in any well-built backend — catch broad, re-raise narrow.
import sys
try:
a = 1 / 0
except Exception as e:
raise ResearchAnalystException(e, sys)
# Output:
# ResearchAnalystException:
# Error in [custom_exception.py] at line [58]: division by zero
Two things worth calling out. The __str__
and __repr__ dunders are doing real
work — __str__ is what
print() calls,
__repr__ is what the interactive
prompt calls. Implementing both means the message is consistent everywhere the exception is
surfaced. And the wrapper preserves the full traceback string in
self.traceback_str, which is exactly
what you want to attach to a log entry.
🎚️ Logging levels and when to raise vs when to log
Python’s logging levels are a hierarchy. Configure the logger at one level and everything at or above that level is captured.
🪜 The six levels, low to high
- NOTSET — pseudo-level, almost never used in app code
- DEBUG — for development tracing, off in prod
- INFO — normal operations: a request started, a node finished, a tool returned
- WARNING — recoverable issue: a retry happened, a fallback fired
- ERROR — a request failed, but the service is still up
- CRITICAL — the service itself is in trouble
Set the logger to INFO in production
and you capture INFO, WARNING, ERROR, and CRITICAL. Anything below the configured level is dropped
silently — which is the point. DEBUG is for development; you don’t want it firing on
every request in prod.
📝 When to log an exception
At the boundary where you handle the failure — usually the route handler or the outermost service method. Log once, with the full traceback, so the operator can see what happened.
🚨 When to raise an exception
Inside helpers and library code, where the caller is the one who can decide what to do. Raise
a typed exception (your ResearchAnalystException),
don’t log, and let the boundary handle the logging.
Mixing the two — logging and re-raising at every level — produces noise: the same error shows up four times in the log under different module names. Pick one role per layer and stick to it.
📄 YAML-driven configuration loading
Hard-coded model names are the single biggest reason agent projects can’t swap providers
cleanly. Day 3’s answer is a YAML file at
config/configuration.yaml that
describes every provider, plus a loader that the rest of the codebase reads from.
# config/configuration.yaml
llm:
openai:
provider: openai
model_name: gpt-4o-mini
temperature: 0
max_output_tokens: 2048
google:
provider: google
model_name: gemini-1.5-flash
temperature: 0
max_output_tokens: 2048
groq:
provider: groq
model_name: llama-3.1-70b-versatile
temperature: 0
max_output_tokens: 2048
# embeddings, retriever, vector store come later
# embeddings:
# ...
# retriever:
# ...
The loader itself is deliberately tiny. It checks an environment variable for an override path, falls back to the workspace default, and surfaces a typed exception if anything is missing.
import os
import sys
import yaml
from pathlib import Path
from research_analyst.logger import global_logger as log
from research_analyst.exception.custom_exception import ResearchAnalystException
def load_config(config_path: str | None = None) -> dict:
try:
if config_path is None:
config_path = os.getenv("CONFIG_PATH") or "config/configuration.yaml"
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"config file not found: {path}")
with path.open("r", encoding="utf-8") as f:
config = yaml.safe_load(f)
log.info("yaml configuration loaded successfully", path=str(path))
return config
except Exception as e:
log.error("failed to load configuration", error=str(e))
raise ResearchAnalystException(e, sys)
Why a YAML file and not Python? Because a non-engineer (a product manager, an ops person, the person rotating an API key) can read and edit YAML without touching the codebase. The model name, the temperature, the max output tokens — all the knobs that get tuned in the first month of production — live somewhere editable.
🧠 A multi-provider model loader
The model loader is the bridge between the YAML config and the workflow. It does three things: read
the right API key from the environment, pick the provider block from config, and return an
instantiated LangChain chat model. Nothing else in the codebase calls
ChatOpenAI() or
ChatGoogleGenerativeAI() directly.
import os
import sys
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_groq import ChatGroq
from research_analyst.logger import global_logger as log
from research_analyst.exception.custom_exception import ResearchAnalystException
from research_analyst.utils.config_loader import load_config
class APIKeyManager:
REQUIRED = ["OPENAI_API_KEY", "GOOGLE_API_KEY", "GROQ_API_KEY"]
def __init__(self):
load_dotenv()
self.keys = {}
for key in self.REQUIRED:
value = os.getenv(key)
if value:
self.keys[key] = value
log.info("api key loaded", key=key, preview=value[:6] + "...")
else:
log.warning("api key missing", key=key)
def get(self, key: str) -> str:
if key not in self.keys:
raise ResearchAnalystException(
f"missing required api key: {key}", sys,
)
return self.keys[key]
class ModelLoader:
def __init__(self):
self.api = APIKeyManager()
self.config = load_config()
def load_llm(self):
provider = os.getenv("LLM_PROVIDER", "openai")
llm_block = self.config.get("llm", {}).get(provider)
if not llm_block:
raise ResearchAnalystException(
f"no llm config for provider: {provider}", sys,
)
model_name = llm_block["model_name"]
temperature = llm_block.get("temperature", 0)
max_tokens = llm_block.get("max_output_tokens", 2048)
log.info("loading llm", provider=provider, model=model_name)
if provider == "openai":
return ChatOpenAI(
model=model_name,
temperature=temperature,
max_tokens=max_tokens,
api_key=self.api.get("OPENAI_API_KEY"),
)
if provider == "google":
return ChatGoogleGenerativeAI(
model=model_name,
temperature=temperature,
max_output_tokens=max_tokens,
google_api_key=self.api.get("GOOGLE_API_KEY"),
)
if provider == "groq":
return ChatGroq(
model=model_name,
temperature=temperature,
max_tokens=max_tokens,
groq_api_key=self.api.get("GROQ_API_KEY"),
)
raise ResearchAnalystException(
f"unknown provider: {provider}", sys,
)
The point of this layout: every node in the workflow does
llm = ModelLoader().load_llm() and
moves on. To switch from OpenAI to Gemini for a whole environment, you change one environment
variable. To add Anthropic, you add one block to the YAML and one
if branch in the loader. No node
code is touched.
🧪 A quick sanity test
Running python -m research_analyst.utils.model_loader
with a small if __name__ == "__main__":
block that fires a “hello, how are you?” prompt is enough to confirm the API key, the
config, the logger, and the LangChain wiring are all healthy — before the workflow is even
touched.
The same module exposes a sibling load_embedding
method for retrieval workloads. The shape is identical — read a provider key, pick a block
from config, return an embedding model — and it’s the same approach you’d apply
to a re-ranker, a TTS provider, or any other model the agent needs.
📦 The __init__.py trick for a single shared logger
A subtle bug shows up the moment more than one module imports the logger: each import creates a new log file. Run the model loader and you get one file; run the config loader and you get another; by the end of the request your logs are split across half a dozen siblings.
The fix is to instantiate the logger once, inside the package’s
__init__.py, and re-export it.
Python runs __init__.py the first
time the package is imported, so every downstream module sees the same logger instance and the same
file path.
# research_analyst/logger/__init__.py
from research_analyst.logger.custom_logger import CustomLogger
global_logger = CustomLogger().get_logger("research_analyst")
Now any file inside the package can write:
from research_analyst.logger import global_logger as log
log.info("model loaded", provider="openai")
One logger instance, one log file per run, consistent context across every module — and zero repeated boilerplate at the top of each file.
✅ Best practices for shipping the foundations layer
Do
- Pick
structlogorloguruover stdlib for JSON output - Write one custom exception class per service, not per file
- Capture file name, line number, and full traceback in the exception
- Centralise the logger in
__init__.pyso every module shares one instance - Keep model name, temperature, and token caps in YAML, not Python
- Load API keys via a single manager class — never
os.getenvscattered across the codebase - Use vibe coding for boilerplate, write specific prompts that encode your engineering decisions
- Test each module standalone with
python -mbefore plugging it into the workflow
Avoid
- Using
print()as a logger and promising to swap it “later” - Swallowing exceptions in bare
try / exceptblocks - Re-instantiating the logger in every module, producing one log file per import
- Hard-coding model names inside node functions
- Trusting a generic Copilot prompt to write production code
- Committing the
logs/directory or the.envfile to GitHub - Mixing
log.error(...)andraiseon the same line at every layer
🚫 Common mistakes
- Skipping the scaffolding because the demo works. A notebook that loads a model with three lines of code can be rewritten in an afternoon. The reason to do it on day three is that every workflow node you build afterwards plugs in without rewrites — not because Day 3 produces a flashy demo.
- Treating Copilot output as code review. Generic prompts give you generic code. The session demonstrates the gap by running the same task twice with different prompts and getting noticeably different files. Vibe coding works when you can tell good code from plausible code.
- Ignoring the difference between raising and logging. If you raise at every layer, the same error gets logged five times by the time it reaches the route handler. Pick the layer that owns the logging and let everything below it just raise.
-
Putting model names in code. Hard-coding
gpt-4o-miniinside a node function means rotating to a cheaper model is a code change, a PR, and a deploy — instead of a one-line YAML edit. -
Loading
.envfrom three different files. Callload_dotenv()in exactly one place — the API key manager — and let everyone else import from there. Otherwise key precedence becomes a guessing game. -
Forgetting
logs/and.envin.gitignore. The first one bloats the repo with run artefacts; the second one is a security incident waiting to happen. Add both before the first commit.
Conclusion
Day 3 doesn’t look like an exciting session on paper — no new graph nodes, no fancy multi-agent pattern, no shiny tool integration. It builds the four files (logger, exception, config loader, model loader) and the one folder layout that every later session depends on. That’s the point. The reason most agent projects feel impossible to debug in production is that this layer was skipped at the start and bolted on under pressure later.
The mental model to carry into Day 4: an agent project is a Python service first and an LLM application second. Get the logger, the exceptions, the config, and the model loader right, and the workflow you build on top of them is something you can ship. Skip them, and you’ll spend the rest of the project pulling the agent apart to find out where a 500 came from.
Related reading
-
Day 2: Tool Calling, Built-In Tools, and Custom Tools
The notebook prototype Day 3 promotes to production—Wikipedia, Tavily, Yahoo Finance, and the custom @tool decorator.
-
Day 4: Routes, Subgraphs, and Deployment
Where the Day 3 infrastructure lands—LangGraph workflow, FastAPI service, and Docker deployment to production.
-
Guardrails with LangChain: Safe AI Agents
Add PII middleware, content filters, and human-in-the-loop checkpoints to the agent project built across Days 1–4.