Framework · structured output

LangChain

LangChain is the layer under a lot of agents: one interface for models, prompts, messages, and tools. The skill that pays off first is structured output — a Pydantic class instead of a paragraph your code has to slice apart.

Beginner → intermediateLangChain 1.x

What LangChain is for

AnalogyLangChain is a power strip. The model is the appliance. The strip is the shared plug shape: prompts, tools, and messages that work across brands.
Data flow
Prompt→Model→Tools or parser→One result

Official docs describe an agent as a model plus a harness. The harness is the loop around the model: prompt, tools, and middleware. `create_agent` is the small harness. Use LangGraph when the path must mix fixed steps with model decisions. Use a “deep” harness when you also want a filesystem and subagents. LangSmith is how you trace any of them.

Starter

# pip install langchain
# The smallest idea: a prompt, a model, and a result.
print("chain = prompt | model | parser")
print("agent = create_agent(model, tools, system_prompt)")

Example

One weather tool and a system prompt is a LangChain agent. A refund that must pause for a human is already a LangGraph problem.

You can call a model with a few lines of the provider SDK. LangChain earns its place when you compose pieces: a prompt, a model, a parser, a retriever, a tool. LCEL, the LangChain Expression Language, joins those pieces with a pipe:

chain = prompt | model | parser
answer = chain.invoke({"question": "What is RAG?"})

Chat models speak in messages (system, human, AI). Older “LLM” classes spoke in one string. New code should use chat models. For multi-step agents with loops and pauses, move to LangGraph; LangChain still supplies the model and the tools.

A first chain

AnalogyThe pipe is an assembly line. Each station takes what the previous station made. You can look at any station without unpacking the whole factory.
Data flow
dict with variables→Prompt→Chat model→Parser→string or object

init_chat_model with a provider prefix (openai, anthropic, or groq) keeps the same chain and swaps the brand. Keys come from the environment. Stream with .stream when a person is watching. A single raw SDK call is still fine for one completion with no tools. Pick the version your team will understand in six months.

Starter

# pip install langchain langchain-openai  (or another provider package)
print("prompt = ChatPromptTemplate.from_messages([")
print('    ("system", "Answer in two sentences."),')
print('    ("human", "{question}"),')
print("])")
print("chain = prompt | model | StrOutputParser()")

Example

A release-notes blurb is this chain: the diff goes in `{question}`, plain text comes out. No tools, no loop.

from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_messages([
    ("system", "Answer in two sentences."),
    ("human", "{question}"),
])
model = init_chat_model("groq:openai/gpt-oss-120b", temperature=0)
chain = prompt | model | StrOutputParser()
print(chain.invoke({"question": "What is a vector database?"}))

init_chat_model takes a provider:model string, so the same chain can point at Groq, OpenAI, or Anthropic by changing one argument. Keys come from the environment (GROQ_API_KEY, OPENAI_API_KEY).

Use case: a release-notes blurb

Product pastes a git diff summary into {question}. The chain always returns plain text. No tools, no loop. This is the right size for LangChain. A single raw SDK call is also fine here; pick the one your team will still understand in six months.

Structured output with Pydantic

AnalogyStructured output is a form instead of an essay. The model still writes, but the boxes have names and types, so your code can read `movie.year` instead of hunting through a paragraph.
Data flow
Schema→Model call→Typed object→Your database

Field descriptions are instructions, the same way a tool docstring is. Pydantic then checks types. A schema makes the shape reliable. It does not make the biography true. If the rating must be real, look it up and let the schema only format that result.

Starter

from pydantic import BaseModel, Field

class Movie(BaseModel):
    title: str = Field(description="The title of the movie")
    year: int = Field(description="Year released")

# model_with_structure = model.with_structured_output(Movie)
# movie = model_with_structure.invoke("Tell me about Inception")
print(Movie(title="Inception", year=2010))

Example

“Tell me about Inception” as prose is an essay. The same question through `Movie` is four fields you can store.

Ask “tell me about Inception” and you get paragraphs. Wrap the model with a schema and you get fields your code can store.

from pydantic import BaseModel, Field

class Movie(BaseModel):
    title: str = Field(description="The title of the movie")
    year: int = Field(description="The year the movie was released")
    director: str = Field(description="The director of the movie")
    rating: float = Field(description="The movie's rating out of 10")

model_with_structure = model.with_structured_output(Movie)
response = model_with_structure.invoke("Provide details about the movie Inception")
print(response.title, response.year, response.director, response.rating)

Field descriptions are instructions to the model, the same way a tool docstring is. Pydantic then checks the types. A year that comes back as "two thousand ten" fails instead of landing in your database.

Facts still need a source. A schema makes the shape reliable. It does not make the biography true. Models mix up cast lists. If the rating must be real, look it up with a tool or a database, and let the schema only format that result.

Nested objects and lists

AnalogyA nested schema is a form with a table inside it. The cast is a list of rows. Each row has its own boxes.
Data flow
Actor schema→List of actors→Movie schema→One object with a cast

Some hosts need method='json_schema' for nested lists. The default asks for a tool call, and required lists sometimes come back empty. Put units in the field description (budget in millions USD) or the number will be ambiguous. model_dump() turns the whole tree into JSON.

Starter

from pydantic import BaseModel

class Actor(BaseModel):
    name: str
    role: str

class MovieDetails(BaseModel):
    title: str
    cast: list[Actor]

print(MovieDetails(title="Inception", cast=[Actor(name="Leonardo DiCaprio", role="Dom Cobb")]))

Example

Meeting notes: title, a list of decisions, and a list of owners. The UI renders the list. Nobody regexes a paragraph.

class Actor(BaseModel):
    name: str
    role: str

class MovieDetails(BaseModel):
    title: str
    year: int
    cast: list[Actor]
    genres: list[str]
    budget: float | None = Field(None, description="Budget in millions USD")

rich = model.with_structured_output(MovieDetails, method="json_schema")
details = rich.invoke("Provide details about the movie Inception")
print(details.cast[0].name, details.budget)

Some hosts (Groq’s gpt-oss in the public labs) need method="json_schema" for nested lists. The default method asks for a tool call, and required lists sometimes come back empty. budget=160.0 means 160 million because the description said “millions USD”. The unit lives in the field text. Be explicit.

Use case: a meeting note

Schema: title, decisions: list[str], owners: list[Actor] where Actor is name plus action. The notes app saves details.model_dump() as JSON. The UI never regexes a paragraph.

The same shape on an agent: response_format

Analogyresponse_format is the form the agent must hand in after it has used its tools. The tools gather facts. The form is the only thing your code keeps.
Data flow
User message→Tool calls→Tool results→Schema tool→structured_response

Pydantic validates. A TypedDict is only a shape, returned as a dict. A dataclass is an object without those checks. On some hosts the schema has to be wrapped as a tool (`ToolStrategy`) because the model cannot return JSON and call tools in the same request. If the model never calls that tool, there is no structured_response.

Starter

from pydantic import BaseModel, Field

class ContactInfo(BaseModel):
    name: str = Field(description="Person's name")
    email: str = Field(description="Email address")

# agent = create_agent(model, response_format=ContactInfo)
# result["structured_response"].email
print(ContactInfo(name="Ada", email="ada@example.com").model_dump())

Example

“John Doe, john@example.com, (555) 123-4567” should become three fields. Your CRM writes the fields. It does not parse the sentence.

with_structured_output is one model call. An agent may call tools first, then fill the schema. Pass the class as response_format. The object comes back on result["structured_response"].

from langchain.agents import create_agent
from langchain.agents.structured_output import ToolStrategy

class ContactInfo(BaseModel):
    """Contact information for a person."""
    name: str = Field(description="The name of the person")
    email: str = Field(description="The email address of the person")
    phone: str = Field(description="The phone number of the person")

agent = create_agent(
    model="groq:openai/gpt-oss-120b",
    response_format=ToolStrategy(ContactInfo),
)
result = agent.invoke({
    "messages": [{"role": "user", "content":
        "Extract contact info from: John Doe, john@example.com, (555) 123-4567"}]
})
print(result["structured_response"])
Schema styleYou getUse it when
Pydantic BaseModelAn object, validatedA bad value must fail
TypedDictA plain dict, no checksYou only need the keys
dataclassAn object, no Pydantic checksYou want attributes without the dependency

Comments on a TypedDict are invisible to the model. Put descriptions in Annotated[str, ..., "The name of the person"] if you are not using Pydantic fields.

A shop agent that returns a ticket

AnalogyThe shop agent is a clerk who must look at the ledger before filling in the form. The form is not allowed to invent a status the ledger did not show.
Data flow
Question→lookup_order→Ledger text→Ticket schema→order_id + status

Wire the tool and the schema into `create_agent`. The system prompt should say “fill the ticket only from tool results.” With a tool strategy, the class name becomes a tool the model must call to finish. Ask about B22 and the status should be the unknown branch, not a guessed shipment.

Starter

ORDERS = {"A17": "shipped on 3 March"}

def lookup_order(order_id: str) -> str:
    return ORDERS.get(order_id, "unknown order")

print(lookup_order("A17"))
print({"order_id": "A17", "status": "shipped"})

Example

Support software wants an order id and a status. A sentence like “it is on the way” cannot update a database. The ticket object can.

Support software wants an order id and a status, not a sentence. The agent looks the order up, then fills Ticket.

from langchain.tools import tool

ORDERS = {"A17": "shipped on 3 March", "C40": "waiting for stock"}

class Ticket(BaseModel):
    """A support ticket for one order."""
    order_id: str
    status: str

@tool
def lookup_order(order_id: str) -> str:
    """Look up an order's shipping status by its id, such as A17."""
    status = ORDERS.get(order_id)
    return f"{order_id} {status}." if status else f"{order_id} is not an order we have."

agent = create_agent(
    model,
    tools=[lookup_order],
    response_format=ToolStrategy(Ticket),
    system_prompt=(
        "You are the support assistant for a small online shop. "
        "Look up the order, then fill in the Ticket using only what the tools returned."
    ),
)
result = agent.invoke({"messages": [{"role": "user", "content": "Where is my order A17?"}]})
print(result["structured_response"])

With ToolStrategy, the class becomes one more tool. The model must call it to finish. If your prompt never leads there, there is no structured_response. OpenAI and Anthropic models can often use native structured output; Groq’s gpt-oss needs ToolStrategy because it cannot return JSON and call tools in the same request.

Three more places this pattern fits

  • HR inbox: tool reads the policy PDF chunk, schema returns eligible: bool and clause_id.
  • CRM update: schema is company, next_step, date. Your app writes the row. Nobody parses “let’s circle back Tuesday”.
  • Incident bot: tools hit status pages, schema returns service, severity, customer_impact.

When a raw SDK is simpler

AnalogyA framework is a truck. A single grocery run does not need a truck. A moving day does.
Data flow
One call?→Raw SDK→Many steps or tools?→LangChain→Branches and pauses?→LangGraph

Skip the framework for one completion. Use it when you want the same code to swap providers, bind tools, and trace with LangSmith. Set `LANGSMITH_TRACING=true` before the chain feels mysterious. Retrieved text is untrusted: a document can say “ignore your instructions.” Keep the system prompt outside that blob. Pin versions.

Starter

def choose(tools: int, pauses: bool) -> str:
    if pauses:
        return "LangGraph"
    if tools:
        return "LangChain create_agent"
    return "provider SDK"

print(choose(0, False))

Example

A latency-critical endpoint that only rewrites one sentence should stay on the provider SDK. An agent that looks up orders should not.

  • One completion, no tools, no schema — the provider SDK is shorter.
  • A latency-critical endpoint — fewer layers, easier profiles.
  • Tutorials that import LLMChain — that API is old. Prefer langchain_core pipes or create_agent.

Pin versions. Turn on LangSmith or another tracer before the chain is “mysteriously wrong”. Retrieved text is untrusted: a document can contain “ignore your instructions”. Keep the system prompt outside the retrieved blob.

Practice

  • Add customer: str = "unknown" to Ticket and see the default when the user never gives a name.
  • Ask about order B22 and read the status the tool forces.
  • Print result["structured_response"].model_dump().
  • Rewrite ContactInfo as a TypedDict and compare what you get back.