Agentic AI Engineering with LangChain / Lab 01
Module 01 · Models, messages and structured output

Lab 01 · Ticket classifier

Turn raw support tickets into typed Pydantic records with with_structured_output, and classify a whole batch at once without flooding the API.

30 minBeginnerAzure OpenAIwith_structured_outputPydanticstr Enumfield_validatorbatchmax_concurrency

What you will build

Northwind receives thousands of support tickets a day. Before any agent touches one, a model turns it into a typed record: category, urgency, sentiment, the order id if there is one, and a one-sentence summary.

The whole lab is one Pydantic schema, one with_structured_output call and one batch call that classifies six tickets with at most three requests in flight.

What you will learn

  • How with_structured_output makes a model return a validated Pydantic object instead of text
  • Why field descriptions are the per-field prompt the model reads
  • How ge, le and max_length both shape the JSON schema and validate the answer
  • How a field_validator cleans up the answers models really give ("N/A", "none")
  • How batch with max_concurrency runs many calls in parallel without a burst of 429 errors
1

Check your prerequisites

You need three things on your machine and three values from your instructor.

  • Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
  • A code editor. VS Code with the Python extension works well.
  • A terminal. PowerShell on Windows, Terminal on macOS or Linux.
PowerShell
python --version
i

From your instructor you get an Azure OpenAI endpoint, an API key and a deployment name. Keep the key private: never paste it into code or share it in chat.

2

Create the project folder and virtual environment

Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.

PowerShell
mkdir C:\labs\lab-01-ticket-classifier
cd C:\labs\lab-01-ticket-classifier
python -m venv .venv
.venv\Scripts\Activate.ps1
✓

Keep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.

When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.

!

PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.

This is what your folder will look like at the end of the lab:

lab-01-ticket-classifier/ ├── .env ├── model_setup.py ├── requirements.txt └── ticket_classifier.py
3

Install the libraries

Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.

requirements.txt
langchain==1.4.2
langchain-core==1.6.4
langchain-openai==1.6.2
python-dotenv==1.2.3
pydantic==2.13.5

Pydantic is already a dependency of LangChain. It is listed here because your code imports it directly.

Then install everything into your virtual environment. It takes a minute or two.

PowerShell
python -m pip install --upgrade pip
pip install -r requirements.txt
4

Add your Azure OpenAI settings

Create a file named .env (the name starts with a dot) in your lab folder. Paste the template and replace the three placeholders with the values from your instructor.

.env
# Azure OpenAI settings. Your instructor gives you these three values.
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/openai/v1
AZURE_OPENAI_API_KEY=<paste-your-key-here>
AZURE_OPENAI_DEPLOYMENT=<your-chat-deployment-name>

# Only used when your endpoint does NOT end in /openai/v1.
OPENAI_API_VERSION=2025-04-01-preview
  • AZURE_OPENAI_ENDPOINT is your resource URL. If it ends in /openai/v1, keep it that way. If it is just https://<resource>.openai.azure.com/, paste that; the helper in the next step handles both.
  • AZURE_OPENAI_API_KEY is your secret key.
  • AZURE_OPENAI_DEPLOYMENT is the deployment name, for example gpt-5-mini. On Azure you call a deployment, not a model name.
!

Never commit .env to Git or share it. If you use Git, add a .gitignore file containing .env and .venv/.

5

Create the Azure OpenAI helper

Every lab talks to Azure OpenAI through one small helper file. Create model_setup.py and add the blocks below one after another. You write it once per lab folder; the lab code then only needs from model_setup import get_model.

Block 1Imports, .env loading and a console fix

load_dotenv() reads your .env file and puts every line into environment variables, so your key never appears in code.

The loop below it switches the console to UTF-8. Windows terminals default to an older encoding and crash when a model prints curly quotes or dashes.

model_setup.py (add to the end of the file)
"""Azure OpenAI model setup shared by every lab.

Import it in lab code as:  from model_setup import get_model, get_embeddings
"""

import asyncio
import os
import sys

from dotenv import load_dotenv
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel

# Read AZURE_OPENAI_* values from the .env file next to your lab code.
load_dotenv()

# Windows consoles default to cp1252 and crash on the curly quotes models love to print.
for _stream in (sys.stdout, sys.stderr):
    if hasattr(_stream, "reconfigure"):
        _stream.reconfigure(encoding="utf-8", errors="replace")

Block 2Read settings safely

_setting() fetches a value from the environment and stops with a friendly message if it is missing, instead of a long stack trace.

_uses_v1_api() checks the shape of your endpoint. Azure offers two:

  • https://<resource>.openai.azure.com/openai/v1 is the newer OpenAI-compatible API.
  • https://<resource>.openai.azure.com/ is the classic API that needs an api_version.
model_setup.py (add to the end of the file)
def _setting(name: str) -> str:
    """Return a required setting from .env, or stop with a readable message."""
    value = os.getenv(name, "").strip()
    if not value:
        sys.exit(f"Missing {name} in your .env file. Open .env and fill it in.")
    return value


def _uses_v1_api(endpoint: str) -> bool:
    """True for the OpenAI-compatible Azure endpoint that ends in /openai/v1."""
    return endpoint.rstrip("/").endswith("/openai/v1")

Block 3Build the chat model

get_model() returns a LangChain chat model connected to your deployment.

  • With a /openai/v1 endpoint it uses ChatOpenAI with base_url. On Azure the deployment name goes in model.
  • With a classic endpoint it uses AzureChatOpenAI with azure_deployment and api_version.

Either way the lab code just calls get_model() and never cares which one it got.

model_setup.py (add to the end of the file)
def get_model(deployment: str | None = None, **kwargs) -> BaseChatModel:
    """Return a chat model for your Azure OpenAI deployment."""
    endpoint = _setting("AZURE_OPENAI_ENDPOINT")
    api_key = _setting("AZURE_OPENAI_API_KEY")
    name = deployment or _setting("AZURE_OPENAI_DEPLOYMENT")

    if _uses_v1_api(endpoint):
        from langchain_openai import ChatOpenAI

        return ChatOpenAI(model=name, base_url=endpoint, api_key=api_key, **kwargs)

    from langchain_openai import AzureChatOpenAI

    return AzureChatOpenAI(
        azure_deployment=name,
        azure_endpoint=endpoint,
        api_key=api_key,
        api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
        **kwargs,
    )

Block 4Build the embeddings model

get_embeddings() does the same for embeddings, which turn text into vectors for search. Only the retrieval labs use it, but keeping one helper for every lab is simpler.

model_setup.py (add to the end of the file)
def get_embeddings(deployment: str | None = None) -> Embeddings:
    """Return an embeddings model for your Azure OpenAI embeddings deployment."""
    endpoint = _setting("AZURE_OPENAI_ENDPOINT")
    api_key = _setting("AZURE_OPENAI_API_KEY")
    name = deployment or _setting("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")

    if _uses_v1_api(endpoint):
        from langchain_openai import OpenAIEmbeddings

        return OpenAIEmbeddings(model=name, base_url=endpoint, api_key=api_key)

    from langchain_openai import AzureOpenAIEmbeddings

    return AzureOpenAIEmbeddings(
        azure_deployment=name,
        azure_endpoint=endpoint,
        api_key=api_key,
        api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
    )

Block 5A Windows fix for async code

use_stable_event_loop() matters only for the async MCP lab. The default Windows event loop can crash on exit after async network sessions close. On macOS and Linux it does nothing.

model_setup.py (add to the end of the file)
def use_stable_event_loop() -> None:
    """On Windows, use the Selector event loop so async MCP code exits cleanly."""
    if sys.platform == "win32":
        asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

Check that the helper can reach your deployment with a one-line test:

PowerShell
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"

You should see a short greeting. If you see an error instead, check the troubleshooting step at the end.

6

Write the ticket classifier

Structured output is what turns a model from a chatbot into a component. A TicketClassification object can be routed, stored in a database or checked in a test. A paragraph of text can only be shown to a human.

You describe the record you want as a Pydantic class. LangChain turns that class into a JSON schema, sends it with the request, and parses the answer back into an instance of your class.

Create an empty file named ticket_classifier.py in your lab folder. Then copy each block below into it, in order, one after another.

Block 1Imports

  • SystemMessage and HumanMessage build the conversation sent for each ticket.
  • BaseModel, Field and field_validator come from Pydantic and define the record.
  • ValidationError is what Pydantic raises when data breaks the schema rules.
  • BaseChatModel and Runnable are only used as type hints.
ticket_classifier.py (add to the end of the file)
"""Lab 01: classify a batch of Northwind support tickets into typed records.

Run:  python ticket_classifier.py
"""

from enum import Enum

from langchain.messages import HumanMessage, SystemMessage
from langchain_core.language_models import BaseChatModel
from langchain_core.runnables import Runnable
from pydantic import BaseModel, Field, ValidationError, field_validator

from model_setup import get_model

Block 2The category enum

A str Enum limits the model to six labels. In JSON each member is a plain string such as "billing", but in your code it is a typed value you can compare with Category.BILLING.

Keep the list short. Models choose badly from thirty labels, and other gives them a safe answer when nothing fits.

ticket_classifier.py (add to the end of the file)
class Category(str, Enum):
    """Northwind support taxonomy. Keep it small: models pick badly from thirty labels."""

    BILLING = "billing"
    SHIPPING = "shipping"
    TECHNICAL = "technical"
    ACCOUNT = "account"
    RETURNS = "returns"
    OTHER = "other"

Block 3The sentiment enum

Same pattern for the customer's tone. A support team can use it to put angry customers in front of a human first.

ticket_classifier.py (add to the end of the file)
class Sentiment(str, Enum):
    """How the customer sounds. It decides whether a human reads the reply first."""

    ANGRY = "angry"
    FRUSTRATED = "frustrated"
    NEUTRAL = "neutral"
    POSITIVE = "positive"

Block 4The record schema and its validator

Each Field(description=...) becomes part of the JSON schema the model sees, so the description is the instruction for that field. If the model keeps picking the wrong value, improve the description before you touch the system prompt.

  • ge=1, le=5 limits urgency to 1 to 5, and max_length=200 caps the summary. These rules are sent to the model and checked on the answer.
  • order_id is optional (str | None, default None).
  • The field_validator runs before normal validation (mode="before"). Models often answer "N/A" or "none" instead of a real JSON null, so the validator turns those into None and upper-cases real ids.
ticket_classifier.py (add to the end of the file)
class TicketClassification(BaseModel):
    """One support ticket as a typed record. Every description is a per-field prompt."""

    category: Category = Field(description="The single best-fitting support category.")
    urgency: int = Field(ge=1, le=5, description="1 = can wait a week, 5 = outage or money lost.")
    sentiment: Sentiment = Field(description="The emotional tone of the customer.")
    order_id: str | None = Field(
        default=None, description="Order id like 'A-100', not an invoice number. Null if absent."
    )
    summary: str = Field(max_length=200, description="One sentence on what the customer needs.")

    @field_validator("order_id", mode="before")
    @classmethod
    def blank_to_none(cls, value: str | None) -> str | None:
        """Models answer 'none' or 'N/A' far more often than a real JSON null."""
        if value is None or value.strip().upper() in {"", "NONE", "NULL", "N/A"}:
            return None
        return value.strip().upper()

Block 5The system prompt

The field descriptions already explain each field, so the system prompt only holds policy: never invent an order id, and keep urgency 5 for real emergencies.

ticket_classifier.py (add to the end of the file)
SYSTEM_PROMPT = (
    "You are the triage engine for Northwind support. Classify exactly one ticket into the "
    "schema. Use only facts present in the ticket: never invent an order id. Urgency 5 is "
    "reserved for outages, security issues and money already lost."
)

Block 6Six sample tickets

A small batch that covers every category. Notice that the first ticket has an invoice number, not an order id. The field description tells the model not to confuse the two.

ticket_classifier.py (add to the end of the file)
TICKETS = [
    "My card was charged twice for invoice INV-9912. Fix this today!",
    "Where is order A-100? It was supposed to arrive on Tuesday.",
    "The export API returns 500 for every request since this morning.",
    "Thanks for the quick replacement on A-118, works perfectly now.",
    "I would like to return order A-205, the size is wrong.",
    "Cannot log in, the password reset email never arrives.",
]

Block 7Check the schema without a model

This function builds records by hand to show the rules working, with no API call:

  • " a-100 " is cleaned to "A-100".
  • "N/A" becomes None.
  • urgency=9 raises a ValidationError, because the field only allows 1 to 5.

The same checks run on every answer the model sends back.

ticket_classifier.py (add to the end of the file)
def check_schema() -> None:
    """Show the schema rules working locally, without calling the model."""
    print("Schema checks (no model call):")
    record = TicketClassification(
        category="shipping", urgency=2, sentiment="neutral", order_id="  a-100 ", summary="Test."
    )
    print(f"  order_id '  a-100 ' -> {record.order_id!r}")
    record = TicketClassification(
        category="shipping", urgency=2, sentiment="neutral", order_id="N/A", summary="Test."
    )
    print(f"  order_id 'N/A'      -> {record.order_id!r}")
    try:
        TicketClassification(category="billing", urgency=9, sentiment="angry", summary="Test.")
    except ValidationError as error:
        print(f"  urgency=9           -> ValidationError: {error.errors()[0]['msg']}")
    print()

Block 8Build the classifier

model.with_structured_output(TicketClassification) returns a new runnable. You call it like the model, but it returns a TicketClassification object instead of a message.

The model is a parameter so a test can pass in a fake one.

Why no temperature=0? For classification you normally want temperature=0, so the same ticket gets the same label tomorrow. Reasoning deployments such as gpt-5-mini do not support a custom temperature and reject the request, so this lab leaves it out. With a non-reasoning model such as gpt-4.1-mini, use get_model(temperature=0).

ticket_classifier.py (add to the end of the file)
def build_classifier(model: BaseChatModel | None = None) -> Runnable:
    """Wrap the chat model so every answer comes back as a TicketClassification."""
    model = model or get_model()
    return model.with_structured_output(TicketClassification)

Block 9Classify the batch

  • check_schema() runs the offline checks first.
  • Each ticket becomes its own conversation: the system prompt plus the ticket.
  • classifier.batch(prompts, config={"max_concurrency": 3}) sends one request per ticket with at most three in flight at a time. Results come back in input order, so they line up with TICKETS.

Without max_concurrency, batch fires every request at once. With a large queue that is how you get a storm of 429 Too Many Requests errors.

ticket_classifier.py (add to the end of the file)
if __name__ == "__main__":
    check_schema()

    classifier = build_classifier()
    prompts = [[SystemMessage(SYSTEM_PROMPT), HumanMessage(ticket)] for ticket in TICKETS]

    # One call per ticket, at most three in flight, results in input order.
    print("Classified tickets:")
    for facts in classifier.batch(prompts, config={"max_concurrency": 3}):
        print(
            f"{facts.category.value:10} u{facts.urgency} {facts.sentiment.value:10} "
            f"order={facts.order_id or '-':6} {facts.summary}"
        )
7

Run the classifier

PowerShell
python ticket_classifier.py

Your output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.

Expected output
Schema checks (no model call):
  order_id '  a-100 ' -> 'A-100'
  order_id 'N/A'      -> None
  urgency=9           -> ValidationError: Input should be less than or equal to 5

Classified tickets:
billing    u5 angry      order=-      Customer reports their card was charged twice for invoice INV-9912 and requests it be fixed today.
shipping   u3 frustrated order=A-100  Customer asks for the location/status of order A-100, which was supposed to arrive on Tuesday but has not.
technical  u5 neutral    order=-      The export API returns HTTP 500 for every request since this morning; please investigate and restore the service.
returns    u1 positive   order=A-118  Customer confirms the replacement for order A-118 works perfectly and thanks support.
returns    u2 neutral    order=A-205  Customer wants to return order A-205 because the item is the wrong size.
account    u3 frustrated order=-      User cannot log in because the password reset email never arrives; they need assistance to receive the reset email or regain account access.

The first three lines come from check_schema() and need no model.

Then you get six classified lines, one per ticket, in the same order as TICKETS. Each line shows the category, the urgency (u1 to u5), the sentiment, the order id and the summary. The first ticket shows order=-: the model did not treat the invoice number as an order id. The exact urgency and sentiment values can vary a little from run to run.

8

Check your work

You are done when

  • The schema checks print 'A-100', None and a ValidationError for urgency=9.
  • Six classified tickets are printed, in the same order as TICKETS.
  • The first ticket has no order id (INV-9912 is an invoice, not an order).
  • Every field in TicketClassification has a non-empty description.

Troubleshooting

Unsupported value: 'temperature' does not support 0

Your deployment is a reasoning model (gpt-5 family, o-series). Remove temperature=0 from get_model(...). Only non-reasoning models such as gpt-4.1 accept it.

The model keeps choosing other

Improve the enum values or the field description first. The model picks labels from the schema, so a vague description leads to vague labels.

A ValidationError stops the whole batch

The model returned a value that breaks the schema, for example a summary longer than 200 characters. Run again, or see the first Go further idea to log errors instead of raising them.

401 Unauthorized or 'Access denied due to invalid subscription key'

The key in .env is wrong or has spaces around it. Copy it again from your instructor, with no quotes.

404 'DeploymentNotFound' or 'The API deployment for this resource does not exist'

AZURE_OPENAI_DEPLOYMENT must be the deployment name exactly as your instructor gave it, and the endpoint must be the same resource.

429 Too Many Requests

The shared deployment is busy. Wait 30 seconds and run again.

ModuleNotFoundError: No module named 'model_setup' (or another package)

Run the command from inside your lab folder, with the virtual environment activated. Reinstall with pip install -r requirements.txt if a library is missing.

'python' is not recognized, or the wrong Python runs

Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.

Go further

  • Use with_structured_output(TicketClassification, include_raw=True) and print parsing_error and raw.usage_metadata per ticket. A malformed answer then becomes a logged metric instead of a crash in the middle of a batch.
  • Add rate_limiter=InMemoryRateLimiter(requests_per_second=2) (from langchain_core.rate_limiters) to get_model(...) and watch the run time change while the concurrency stays the same.
  • Replace the Pydantic class with a TypedDict and see what you lose (validators, defaults, enum objects) and what you gain (a plain dict).

Stuck?

Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.