DS DevShelfHub Projects · AI tools
Articles / Building AI Agents for Production — Day 3: Logger, Exceptions, Config, and Model Loader

AI Engineering

Building AI Agents — Day 3: Logger, Exceptions, Config & Model Loader

By DevShelfHub

Day 3 of the AI Agents crash course — turning a notebook prototype into a production-shaped Python project with structlog, custom exceptions, YAML config, and a provider-agnostic model loader. Plus practical lessons on vibe coding with Copilot and Cursor.

Building AI Agents — Day 3: Logger, Exceptions, Config & Model Loader

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__.py trick 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.

Python
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.

Python
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.

Text
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.

Python
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.

Python
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.

YAML
# 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.

Python
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.

Python
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.

Python
# 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:

Python
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 structlog or loguru over 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__.py so 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.getenv scattered across the codebase
  • Use vibe coding for boilerplate, write specific prompts that encode your engineering decisions
  • Test each module standalone with python -m before plugging it into the workflow

Avoid

  • Using print() as a logger and promising to swap it “later”
  • Swallowing exceptions in bare try / except blocks
  • 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 .env file to GitHub
  • Mixing log.error(...) and raise on 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-mini inside 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 .env from three different files. Call load_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 .env in .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.

Building AI Agents for Production — Day 3: Logger, Exceptions, Config, and Model Loader FAQ

Can I do all of this with a generic Copilot prompt?

You can, but you won’t recognise whether the output is right. The session demonstrates this live — the same model produces a noticeably better logger when the prompt includes the engineering decisions a senior dev would make (file and console handlers, JSON output, ISO timestamps, free-form context fields). Vibe coding is faster when you already know what you want; it isn’t a substitute for knowing.

Why a YAML config and not Python or JSON?

YAML is human-friendly for the kind of edits a non-engineer might make — rotating a model name, bumping max tokens, adding a new provider. Python configs are powerful but they tempt people to write logic inside the config, which defeats the point. JSON works fine but doesn’t support comments, which makes long config files harder to maintain.

Do I really need a custom exception if Python already has so many built-in classes?

You need a custom one if you want consistent error messages across your service. The built-ins tell you what failed (a KeyError, a ValueError) — the custom wrapper tells you where and lets your route handler catch one type and produce one consistent HTTP response. Keep using the built-ins for what they’re designed for; wrap them at the boundary.

Should the model loader return a raw provider client or a LangChain chat model?

A LangChain chat model, in this codebase, because the workflow uses LangGraph and binds models with .with_structured_output(...) and .bind_tools(...). If your stack uses the provider SDKs directly, return the SDK client instead. The principle is the same — one class instantiates one thing, the rest of the code never imports a provider package directly.

How does Day 3 connect to Day 4?

Day 3 builds the foundations — logger, exception, config, model loader — that Day 4’s LangGraph workflow assumes. Every node in the Day 4 graph calls log.info(...) from the shared logger, wraps failures in ResearchAnalystException, reads its model from ModelLoader().load_llm(), and pulls knobs from the YAML config. If you skipped Day 3, every one of those calls would have to be invented inside the workflow.

What’s the right way to test these modules before plugging them into the workflow?

Add a small if __name__ == '__main__': block at the bottom of each file and run it with python -m research_analyst.utils.model_loader. For the logger, log a couple of INFO and ERROR lines and check both the console and the file. For the exception, deliberately divide by zero. For the config loader, print the loaded dict. For the model loader, fire a hello-world prompt and confirm a response. That’s enough smoke-testing to know the foundations are healthy before the workflow is wired up.