Module 3: Agents with create_agent
Structured Output in Agents
Capsule overview
So far, when your agent finishes its work — after calling tools, analyzing results, and reasoning about the answer — it hands you an AIMessage with free-form text. Something like "RAG is a technique that combines retrieval with generation...". Natural text, readable by humans, but useless for downstream systems.
What if you need the agent to return JSON with specific fields? A report with topic, summary, sources and confidence? An object you can save to a database, send to an API, or pass to another agent?
That's what the response_format parameter in create_agent is for. You hand it a Pydantic model, and once the agent finishes its tool loop, a final call is made to the model with structured output that extracts the data in the exact format you defined. The result lands in result["structured_response"] — a typed, validated Pydantic object, ready to use in your system.
The problem: the agent's answer is free-form text
Let's look at what a standard agent returns without structured output:
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
@tool
def search(query: str) -> str:
"""Search for information on a topic."""
return f"RAG (Retrieval-Augmented Generation) was proposed by Lewis et al. in 2020. It combines a retriever with a generator to improve factual accuracy."
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search])
result = agent.invoke({"messages": [("user", "Research what RAG is")]})
last_message = result["messages"][-1]
print(type(last_message))
print(last_message.content)
# Expected output:
# <class 'langchain_core.messages.ai.AIMessage'>
# RAG (Retrieval-Augmented Generation) is a technique proposed by Lewis et al. in 2020
# that combines a retrieval component (retriever) with a generative model to improve
# the factual accuracy of the answers...
The result is an AIMessage with content as a string. If you want to pull out the topic, a summary, the sources, and a confidence level, you'd have to parse the text by hand — fragile, error-prone, and not scalable.
response_format: structured output in agents
The response_format parameter in create_agent accepts a Pydantic model that defines the exact shape of the response.
Basic example: a research report
from dotenv import load_dotenv
load_dotenv()
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
class ResearchReport(BaseModel):
topic: str = Field(description="Topic researched")
summary: str = Field(description="Summary of the findings")
sources: list[str] = Field(description="Sources consulted")
confidence: float = Field(description="Confidence level from 0 to 1")
@tool
def search(query: str) -> str:
"""Search for information on a topic."""
return f"RAG (Retrieval-Augmented Generation) was proposed by Lewis et al. in 2020. It combines document retrieval with text generation. Source: arxiv.org/abs/2005.11401"
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
model,
[search],
prompt="Research the requested topic and generate a complete report.",
response_format=ResearchReport
)
result = agent.invoke({"messages": [("user", "What is RAG?")]})
report = result["structured_response"]
print(f"Topic: {report.topic}")
print(f"Summary: {report.summary}")
print(f"Sources: {report.sources}")
print(f"Confidence: {report.confidence}")
print(f"\nType: {type(report)}")
# Expected output:
# Topic: RAG (Retrieval-Augmented Generation)
# Summary: RAG is a technique proposed by Lewis et al. in 2020 that combines the retrieval of relevant documents with text generation to improve the factual accuracy of language models.
# Sources: ['arxiv.org/abs/2005.11401']
# Confidence: 0.9
#
# Type: <class '__main__.ResearchReport'>
result["structured_response"] holds a typed Pydantic object. You can reach .topic, .summary, .sources, .confidence directly, with no text parsing.
How it works under the hood
When you use response_format, the agent follows a two-phase process:
- Tool phase (same as always): the agent runs its ReAct loop — calls tools, analyzes results, iterates until it has enough information
- Extraction phase: once the loop finishes, an extra call is made to the model with structured output, passing it the entire conversation and asking it to extract the data in the shape of the Pydantic model
from dotenv import load_dotenv
load_dotenv()
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_core.messages import AIMessage, ToolMessage
class CityInfo(BaseModel):
city: str = Field(description="Name of the city")
weather: str = Field(description="Current weather")
recommendation: str = Field(description="Recommendation for the visitor")
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
weathers = {
"Madrid": "Sunny, 28°C, humidity 35%",
"London": "Rainy, 12°C, humidity 85%",
}
return weathers.get(city, f"Weather not available for {city}")
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
model,
[get_weather],
prompt="Look up the weather and give the visitor a recommendation.",
response_format=CityInfo
)
result = agent.invoke({"messages": [("user", "What's the weather like in Madrid?")]})
print("=== Agent messages ===")
for msg in result["messages"]:
if isinstance(msg, AIMessage) and msg.tool_calls:
print(f" [Agent] Tool calls: {[tc['name'] for tc in msg.tool_calls]}")
elif isinstance(msg, ToolMessage):
print(f" [Tool] {msg.content}")
elif isinstance(msg, AIMessage):
print(f" [Agent] {msg.content[:80]}")
print("\n=== Structured Response ===")
info = result["structured_response"]
print(f" City: {info.city}")
print(f" Weather: {info.weather}")
print(f" Recommendation: {info.recommendation}")
# Expected output:
# === Agent messages ===
# [Agent] Tool calls: ['get_weather']
# [Tool] Sunny, 28°C, humidity 35%
# [Agent] Madrid has sunny weather at 28°C...
#
# === Structured Response ===
# City: Madrid
# Weather: Sunny, 28°C, humidity 35%
# Recommendation: Wear light clothing and sunscreen. Perfect for walking around outdoors.
The result holds both the messages (the whole conversation including tool calls) and the structured_response (the extracted Pydantic object).
Designing effective Pydantic models for agents
The quality of your structured output depends directly on how you design your Pydantic model. The Field(description=...) entries are instructions for the model.
Simple models vs complex models
from pydantic import BaseModel, Field
class SimpleAnswer(BaseModel):
answer: str = Field(description="Direct answer to the question")
confidence: float = Field(description="Confidence from 0 to 1")
class DetailedAnalysis(BaseModel):
topic: str = Field(description="Main topic analyzed")
key_points: list[str] = Field(description="Key points found, 5 maximum")
pros: list[str] = Field(description="Advantages or positive aspects")
cons: list[str] = Field(description="Disadvantages or negative aspects")
recommendation: str = Field(description="Final recommendation in one sentence")
confidence: float = Field(description="Confidence from 0 to 1")
class ProductComparison(BaseModel):
class Product(BaseModel):
name: str = Field(description="Product name")
price: str = Field(description="Price or price range")
best_for: str = Field(description="What kind of user it's best for")
query: str = Field(description="The user's original query")
products: list[Product] = Field(description="Products compared")
winner: str = Field(description="Recommended product")
reasoning: str = Field(description="Reason for the recommendation")
Rules for effective descriptions
- ✅ Be specific:
"Summary in 2 sentences maximum"instead of"Summary" - ✅ State the format:
"Price in USD format, e.g. '$29.99'"instead of"Price" - ✅ Define the range:
"Confidence from 0 to 1, where 1 is absolute certainty" - ❌ Don't be vague:
"Relevant data"tells the model nothing - ❌ Don't contradict the prompt: if the prompt asks for an analysis, don't define a
"one_word_answer"field
Full example: agent with tools + structured output
This is the most powerful pattern: the tools do the heavy lifting (searching, calculating, querying APIs) and the structured output formats the final answer.
from dotenv import load_dotenv
load_dotenv()
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
class TravelReport(BaseModel):
destination: str = Field(description="Destination city")
weather: str = Field(description="Current weather")
attractions: list[str] = Field(description="Main tourist attractions")
estimated_budget: str = Field(description="Estimated daily budget in USD")
best_season: str = Field(description="Best season to visit")
@tool
def get_weather(city: str) -> str:
"""Get the current weather for a city."""
weathers = {
"Barcelona": "Sunny, 26°C, perfect for the beach",
"Tokyo": "Mild, 20°C, ideal for walking",
"New York": "Cool, 15°C, bring a light jacket",
}
return weathers.get(city, f"Weather not available for {city}")
@tool
def get_attractions(city: str) -> str:
"""Get the main tourist attractions for a city."""
attractions = {
"Barcelona": "Sagrada Familia, Park Güell, La Rambla, Casa Batlló, Barceloneta",
"Tokyo": "Shibuya Crossing, Senso-ji, Tokyo Tower, Akihabara, Tsukiji Market",
"New York": "Central Park, Statue of Liberty, Times Square, Brooklyn Bridge, MoMA",
}
return attractions.get(city, f"Attractions not available for {city}")
@tool
def get_budget(city: str) -> str:
"""Get the estimated daily budget for a tourist."""
budgets = {
"Barcelona": "Mid-range budget: $80-120/day (hostel), $150-250/day (hotel)",
"Tokyo": "Mid-range budget: $100-150/day (hostel), $200-350/day (hotel)",
"New York": "Mid-range budget: $120-180/day (hostel), $250-400/day (hotel)",
}
return budgets.get(city, f"Budget not available for {city}")
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
model,
[get_weather, get_attractions, get_budget],
prompt="You are a travel agent. Use every available tool to gather complete information about the destination.",
response_format=TravelReport
)
result = agent.invoke({
"messages": [("user", "I want to travel to Barcelona, give me all the info")]
})
report = result["structured_response"]
print(f"🏙️ Destination: {report.destination}")
print(f"🌤️ Weather: {report.weather}")
print(f"🎯 Attractions:")
for attraction in report.attractions:
print(f" - {attraction}")
print(f"💰 Budget: {report.estimated_budget}")
print(f"📅 Best season: {report.best_season}")
# Expected output:
# 🏙️ Destination: Barcelona
# 🌤️ Weather: Sunny, 26°C, perfect for the beach
# 🎯 Attractions:
# - Sagrada Familia
# - Park Güell
# - La Rambla
# - Casa Batlló
# - Barceloneta
# 💰 Budget: $80-250/day depending on the type of accommodation
# 📅 Best season: Spring (April-June) or early autumn (September-October)
The agent called all 3 tools on its own, gathered every piece of information, and formatted it exactly according to the TravelReport model.
Accessing structured_response vs messages
The result of agent.invoke() with response_format holds two things:
| Field | Type | Content |
|---|---|---|
result["messages"] | list[BaseMessage] | The whole conversation: HumanMessage, AIMessages, ToolMessages |
result["structured_response"] | Your Pydantic model | The extracted structured answer |
from dotenv import load_dotenv
load_dotenv()
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
class QuickAnswer(BaseModel):
question: str = Field(description="Original question")
answer: str = Field(description="Concise answer")
tool_used: bool = Field(description="Whether any tool was used")
@tool
def search(query: str) -> str:
"""Search for information."""
return f"Result: {query} is a generative AI concept."
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search], response_format=QuickAnswer)
result = agent.invoke({"messages": [("user", "What is RAG?")]})
print(f"Total messages: {len(result['messages'])}")
print(f"Structured response: {result['structured_response']}")
print(f"Type: {type(result['structured_response'])}")
print(f"\nDirect access:")
print(f" .question = {result['structured_response'].question}")
print(f" .answer = {result['structured_response'].answer}")
print(f" .tool_used = {result['structured_response'].tool_used}")
# Expected output:
# Total messages: 4
# Structured response: question='What is RAG?' answer='RAG is a generative AI technique that combines retrieval with text generation.' tool_used=True
# Type: <class '__main__.QuickAnswer'>
#
# Direct access:
# .question = What is RAG?
# .answer = RAG is a generative AI technique that combines retrieval with text generation.
# .tool_used = True
Streaming with structured output
You can combine streaming with structured output. The streaming shows the tools' progress, and at the end you get the structured answer.
from dotenv import load_dotenv
load_dotenv()
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_core.messages import AIMessage, ToolMessage
class Summary(BaseModel):
topic: str = Field(description="Main topic")
summary: str = Field(description="Summary in 1-2 sentences")
key_terms: list[str] = Field(description="Key terms, 5 maximum")
@tool
def search(query: str) -> str:
"""Search for information on a topic."""
return f"{query}: open-source framework for building applications with LLMs, created by Harrison Chase in 2022."
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search], response_format=Summary)
last_step = None
for step in agent.stream(
{"messages": [("user", "What is LangChain?")]},
stream_mode="updates"
):
last_step = step
for node_name, update in step.items():
for msg in update.get("messages", []):
if isinstance(msg, AIMessage) and msg.tool_calls:
for tc in msg.tool_calls:
print(f"⏳ Calling: {tc['name']}...")
elif isinstance(msg, ToolMessage):
print(f"✅ Result received")
elif isinstance(msg, AIMessage) and msg.content:
print(f"💬 Agent answer received")
result = agent.invoke({"messages": [("user", "What is LangChain?")]})
summary = result["structured_response"]
print(f"\n📋 Structured Output:")
print(f" Topic: {summary.topic}")
print(f" Summary: {summary.summary}")
print(f" Terms: {summary.key_terms}")
# Expected output:
# ⏳ Calling: search...
# ✅ Result received
# 💬 Agent answer received
#
# 📋 Structured Output:
# Topic: LangChain
# Summary: LangChain is an open-source framework for building applications with language models (LLMs), created by Harrison Chase in 2022.
# Terms: ['LangChain', 'LLM', 'framework', 'open-source', 'Harrison Chase']
Pydantic models with advanced validation
You can use the full power of Pydantic to validate and constrain the data the agent returns.
from dotenv import load_dotenv
load_dotenv()
from pydantic import BaseModel, Field, field_validator
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
class SentimentAnalysis(BaseModel):
text_analyzed: str = Field(description="The text that was analyzed")
sentiment: str = Field(description="Sentiment: 'positive', 'negative', or 'neutral'")
score: float = Field(description="Sentiment score from -1.0 to 1.0", ge=-1.0, le=1.0)
key_phrases: list[str] = Field(description="Key phrases that drove the sentiment, 3 maximum")
@field_validator("sentiment")
@classmethod
def validate_sentiment(cls, v):
valid = ["positive", "negative", "neutral"]
if v.lower() not in valid:
raise ValueError(f"Sentiment must be one of {valid}")
return v.lower()
@tool
def analyze_text(text: str) -> str:
"""Analyze a text to determine its sentiment."""
positive_words = ["excellent", "great", "incredible", "fantastic", "wonderful"]
negative_words = ["terrible", "horrible", "awful", "bad", "disastrous"]
text_lower = text.lower()
pos = sum(1 for w in positive_words if w in text_lower)
neg = sum(1 for w in negative_words if w in text_lower)
if pos > neg:
return f"Analysis: positive text ({pos} positive words, {neg} negative)"
elif neg > pos:
return f"Analysis: negative text ({pos} positive words, {neg} negative)"
return f"Analysis: neutral text ({pos} positive words, {neg} negative)"
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
model,
[analyze_text],
prompt="Analyze the sentiment of the given text using the available tool.",
response_format=SentimentAnalysis
)
result = agent.invoke({
"messages": [("user", "Analyze: 'The service was excellent, the food incredible and the atmosphere great'")]
})
analysis = result["structured_response"]
print(f"Text: {analysis.text_analyzed}")
print(f"Sentiment: {analysis.sentiment}")
print(f"Score: {analysis.score}")
print(f"Key phrases: {analysis.key_phrases}")
# Expected output:
# Text: The service was excellent, the food incredible and the atmosphere great
# Sentiment: positive
# Score: 0.9
# Key phrases: ['excellent', 'incredible', 'great']
Comparison: response_format vs manual extraction
| Aspect | response_format | Manual extraction with prompts |
|---|---|---|
| Setup | One line: response_format=MyModel | A long prompt describing the JSON format |
| Validation | Automatic (Pydantic) | Manual (parse JSON, validate fields) |
| Typing | Typed Pydantic object | Untyped dict |
| Reliability | High (native structured output) | Medium (the model can change the format) |
| Extra fields | Impossible (fixed schema) | Possible (the model adds extra info) |
| Cost | +1 model call | Included in the response |
When to use each
- ✅
response_format— When you need typed data for downstream systems (APIs, DBs, other agents) - ✅
response_format— When the structure is fixed and known ahead of time - ✅ Manual extraction — When you need flexible or exploratory answers
- ⚠️
response_formathas an extra cost (one additional model call for the extraction)
Pydantic models with optional types
The agent won't always have every piece of information. Use Optional for fields that may be missing.
from dotenv import load_dotenv
load_dotenv()
from typing import Optional
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
class CompanyInfo(BaseModel):
name: str = Field(description="Company name")
founded: Optional[int] = Field(description="Year founded, null if not found", default=None)
ceo: Optional[str] = Field(description="Current CEO, null if not found", default=None)
industry: str = Field(description="Main industry")
description: str = Field(description="Description in 1-2 sentences")
@tool
def search_company(name: str) -> str:
"""Search for information about a company."""
companies = {
"langchain": "LangChain Inc, founded in 2022 by Harrison Chase. Industry: AI/ML. Provides open-source tooling for applications built with LLMs.",
"openai": "OpenAI, founded in 2015 by Sam Altman et al. CEO: Sam Altman. Industry: AI Research. Creators of GPT and ChatGPT.",
}
return companies.get(name.lower(), f"No information found about {name}")
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search_company], response_format=CompanyInfo)
result = agent.invoke({"messages": [("user", "Look up information about LangChain")]})
info = result["structured_response"]
print(f"Company: {info.name}")
print(f"Founded: {info.founded}")
print(f"CEO: {info.ceo}")
print(f"Industry: {info.industry}")
print(f"Description: {info.description}")
# Expected output:
# Company: LangChain
# Founded: 2022
# CEO: Harrison Chase
# Industry: AI/ML
# Description: LangChain provides open-source tooling for building applications that use language models (LLMs).
Connection with the project
In the module project (Capsule 08), you'll combine structured output with streaming and multiple tools to build a complete research agent. The agent will search for information, process it, and deliver a structured ResearchReport that you could store in a database or send to another system.
Troubleshooting
Problem 1: structured_response doesn't show up in the result
Cause: You didn't pass response_format to create_agent.
Fix: Check that you included the parameter:
agent = create_agent(model, tools, response_format=MyModel)
result = agent.invoke(input_data)
print(result["structured_response"])
Problem 2: Pydantic validation error in the response
Cause: The model produced data that fails the Pydantic model's validation (e.g., a float out of range).
Fix: Make the descriptions more explicit and use default values:
class MyModel(BaseModel):
score: float = Field(
description="Score from 0.0 to 1.0. Use 0.5 if you're not sure.",
ge=0.0, le=1.0
)
Problem 3: The model doesn't fill in every field correctly
Cause: The Field descriptions are too vague, or the information wasn't available in the tools.
Fix: Improve the descriptions and consider using Optional:
class Report(BaseModel):
summary: str = Field(description="Summary in exactly 2-3 sentences")
sources: Optional[list[str]] = Field(
description="URLs of the sources consulted. Null if there are no specific URLs.",
default=None
)
Problem 4: The structured output ignores the information from the tools
Cause: The agent's prompt doesn't instruct the model to use the information it gathered. Fix: Be explicit in the prompt:
agent = create_agent(
model, tools,
prompt="Use ALL available tools to gather information. Base your answer EXCLUSIVELY on the data obtained from the tools.",
response_format=MyModel
)
Problem 5: High cost from the extra extraction call
Cause: response_format always adds an additional model call.
Fix: That's expected. If cost is a problem, consider using a cheaper model or evaluate whether you really need structured output for your use case.
Exercises
Exercise 1: Basic structured output (Easy)
Create a Pydantic model MovieReview with fields title, rating (1-10), and review. Create an agent with a search tool that returns movie data, and extract a structured review.
See solution
from dotenv import load_dotenv
load_dotenv()
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
class MovieReview(BaseModel):
title: str = Field(description="Movie title")
rating: int = Field(description="Rating from 1 to 10", ge=1, le=10)
review: str = Field(description="Review in 2-3 sentences")
@tool
def search_movie(title: str) -> str:
"""Search for information about a movie."""
movies = {
"inception": "Inception (2010). Director: Christopher Nolan. A thief who steals secrets from the subconscious is handed an impossible task: plant an idea. Considered a sci-fi masterpiece. IMDb: 8.8/10.",
"the matrix": "The Matrix (1999). Director: Wachowski. A programmer discovers that reality is a simulation. Revolutionized action and science fiction cinema. IMDb: 8.7/10.",
}
return movies.get(title.lower(), f"Movie '{title}' not found in the database.")
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
model,
[search_movie],
prompt="Search for information about the movie and generate a review.",
response_format=MovieReview
)
result = agent.invoke({"messages": [("user", "Review of Inception")]})
review = result["structured_response"]
print(f"Title: {review.title}")
print(f"Rating: {review.rating}/10")
print(f"Review: {review.review}")
# Expected output:
# Title: Inception
# Rating: 9/10
# Review: Inception is a science fiction masterpiece directed by Christopher Nolan. The film explores the idea of stealing ideas from within the subconscious with a complex, visually stunning narrative.
Explanation: The Pydantic model defines the exact structure. The agent looks up the info with the tool and extracts it in the defined format.
Exercise 2: Model with optional fields (Easy)
Create a PersonProfile model with name, age (Optional), occupation, and fun_fact (Optional). Create an agent that looks up info about famous people — some data may not be available.
See solution
from dotenv import load_dotenv
load_dotenv()
from typing import Optional
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
class PersonProfile(BaseModel):
name: str = Field(description="The person's full name")
age: Optional[int] = Field(description="Current age, null if unknown", default=None)
occupation: str = Field(description="Main occupation")
fun_fact: Optional[str] = Field(description="Fun fact, null if there isn't a known one", default=None)
@tool
def search_person(name: str) -> str:
"""Search for information about a person."""
people = {
"guido van rossum": "Guido van Rossum, creator of Python. Born in 1956 in the Netherlands. Worked at Google and Dropbox. Fun fact: Python was named after Monty Python.",
"linus torvalds": "Linus Torvalds, creator of Linux and Git. Born in 1969 in Finland. Works at the Linux Foundation.",
}
return people.get(name.lower(), f"No information found about {name}")
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search_person], response_format=PersonProfile)
result = agent.invoke({"messages": [("user", "Tell me about Guido van Rossum")]})
profile = result["structured_response"]
print(f"Name: {profile.name}")
print(f"Age: {profile.age if profile.age else 'Not available'}")
print(f"Occupation: {profile.occupation}")
print(f"Fun fact: {profile.fun_fact if profile.fun_fact else 'Not available'}")
# Expected output:
# Name: Guido van Rossum
# Age: 69
# Occupation: Creator of Python / Software engineer
# Fun fact: Python was named after Monty Python, the British comedy group.
Explanation: Optional fields with default=None let the model leave fields empty when the information isn't available, without triggering validation errors.
Exercise 3: Multiple tools with structured output (Medium)
Create 3 tools (search_price, search_specs, search_reviews) and a ProductAnalysis model. The agent must use every tool and combine the information into a structured report.
See solution
from dotenv import load_dotenv
load_dotenv()
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
class ProductAnalysis(BaseModel):
product: str = Field(description="Product name")
price_range: str = Field(description="Price range in USD")
key_specs: list[str] = Field(description="Main specs, 4 maximum")
user_rating: float = Field(description="Average user rating from 0 to 5", ge=0, le=5)
verdict: str = Field(description="Final verdict in one sentence")
@tool
def search_price(product: str) -> str:
"""Search for a product's price."""
return f"Price of {product}: $999 - $1,299 USD depending on the configuration."
@tool
def search_specs(product: str) -> str:
"""Search for a product's technical specs."""
return f"Specs of {product}: M4 chip, 16GB RAM, 14\" Liquid Retina XDR display, 18h battery, SSD from 512GB."
@tool
def search_reviews(product: str) -> str:
"""Search for user reviews of a product."""
return f"Reviews of {product}: 4.7/5 stars (2,340 reviews). Users highlight performance and battery life. Criticism: high price."
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
model,
[search_price, search_specs, search_reviews],
prompt="Research the product using ALL available tools before generating the analysis.",
response_format=ProductAnalysis
)
result = agent.invoke({"messages": [("user", "Analyze the MacBook Pro M4")]})
analysis = result["structured_response"]
print(f"Product: {analysis.product}")
print(f"Price: {analysis.price_range}")
print(f"Specs:")
for spec in analysis.key_specs:
print(f" - {spec}")
print(f"Rating: {analysis.user_rating}/5")
print(f"Verdict: {analysis.verdict}")
# Expected output:
# Product: MacBook Pro M4
# Price: $999 - $1,299 USD
# Specs:
# - M4 chip
# - 16GB RAM
# - 14" Liquid Retina XDR display
# - 18-hour battery
# Rating: 4.7/5
# Verdict: An excellent professional laptop with top-tier performance and exceptional battery life, though the price may be steep for some users.
Explanation: The prompt "Research using ALL available tools" tells the agent not to skip any tool. The information from all 3 tools gets combined into a single Pydantic model.
Exercise 4: Nested Pydantic models (Medium)
Create a TeamReport model that holds a list of TeamMember (a nested model). Each member has name, role and contribution. The agent must look up info about a team and structure it.
See solution
from dotenv import load_dotenv
load_dotenv()
from pydantic import BaseModel, Field
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
class TeamMember(BaseModel):
name: str = Field(description="Team member's name")
role: str = Field(description="Role or position on the team")
contribution: str = Field(description="Main contribution to the project")
class TeamReport(BaseModel):
project: str = Field(description="Project name")
team: list[TeamMember] = Field(description="Team members")
total_members: int = Field(description="Total number of members")
status: str = Field(description="Project status: 'active', 'completed', or 'paused'")
@tool
def search_project(name: str) -> str:
"""Search for information about a project and its team."""
return f"""Project: {name}
Team:
- Harrison Chase: Founder and CEO, designed the framework's original architecture
- Ankush Gola: Co-founder and CTO, led the integration with LLM providers
- Nuno Campos: Lead engineer, created LangGraph and the streaming system
Status: Active, with weekly updates"""
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(model, [search_project], response_format=TeamReport)
result = agent.invoke({"messages": [("user", "Give me info about the LangChain team")]})
report = result["structured_response"]
print(f"Project: {report.project}")
print(f"Status: {report.status}")
print(f"Total members: {report.total_members}")
print(f"\nTeam:")
for member in report.team:
print(f" 👤 {member.name} ({member.role})")
print(f" → {member.contribution}")
# Expected output:
# Project: LangChain
# Status: active
# Total members: 3
#
# Team:
# 👤 Harrison Chase (Founder and CEO)
# → Designed the framework's original architecture
# 👤 Ankush Gola (Co-founder and CTO)
# → Led the integration with LLM providers
# 👤 Nuno Campos (Lead Engineer)
# → Created LangGraph and the streaming system
Explanation: Nested Pydantic models (TeamMember inside TeamReport) let you represent complex structures. The model extracts the hierarchical information automatically.
Exercise 5: Structured output with custom validation (Hard)
Create a CodeReview model with validators that check that severity is one of ["low", "medium", "high", "critical"] and that issues has at least one element. The agent analyzes code and returns the review.
See solution
from dotenv import load_dotenv
load_dotenv()
from pydantic import BaseModel, Field, field_validator
from langchain.chat_models import init_chat_model
from langchain.agents import create_agent
from langchain_core.tools import tool
class CodeIssue(BaseModel):
line: int = Field(description="Approximate line number of the problem")
description: str = Field(description="Description of the problem found")
fix: str = Field(description="Suggestion for fixing it")
class CodeReview(BaseModel):
file_name: str = Field(description="Name of the file analyzed")
severity: str = Field(description="Overall severity: 'low', 'medium', 'high', or 'critical'")
issues: list[CodeIssue] = Field(description="List of problems found, at least 1")
overall_quality: float = Field(description="Overall quality from 0 to 10", ge=0, le=10)
summary: str = Field(description="Summary of the review in 1-2 sentences")
@field_validator("severity")
@classmethod
def validate_severity(cls, v):
valid = ["low", "medium", "high", "critical"]
if v.lower() not in valid:
raise ValueError(f"Severity must be: {valid}")
return v.lower()
@field_validator("issues")
@classmethod
def validate_issues(cls, v):
if len(v) < 1:
raise ValueError("There must be at least 1 issue")
return v
@tool
def analyze_code(code: str) -> str:
"""Analyze a Python code snippet to find problems."""
return """Code analysis:
- Line 3: Variable 'x' has no type hint
- Line 7: Generic except block (bare except), it should catch specific exceptions
- Line 12: Hardcoded string that should be a constant or an environment variable
- Overall quality: acceptable but needs security improvements"""
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_agent(
model,
[analyze_code],
prompt="Analyze the given code and generate a detailed review.",
response_format=CodeReview
)
result = agent.invoke({
"messages": [("user", """Review this code:
def process(x):
try:
result = x * 2
db_url = "postgresql://admin:password123@localhost/db"
return result
except:
return None
""")]
})
review = result["structured_response"]
print(f"File: {review.file_name}")
print(f"Severity: {review.severity}")
print(f"Quality: {review.overall_quality}/10")
print(f"\nProblems found:")
for issue in review.issues:
print(f" Line {issue.line}: {issue.description}")
print(f" Fix: {issue.fix}")
print(f"\nSummary: {review.summary}")
# Expected output:
# File: process.py
# Severity: high
# Quality: 4.0/10
#
# Problems found:
# Line 3: Variable 'x' without a type hint
# Fix: Add a type hint: def process(x: int) -> Optional[int]
# Line 7: Generic except block catches every exception
# Fix: Use a specific except: except (ValueError, TypeError) as e
# Line 12: Database credentials hardcoded in the code
# Fix: Use environment variables: os.environ["DB_URL"]
#
# Summary: The code has critical security problems (exposed credentials) and bad practices (bare except, no type hints).
Explanation: The field_validator functions make sure the model's answer follows your business rules. If the model returns an invalid severity, Pydantic raises an error. Nested models (CodeIssue inside CodeReview) let you represent lists of complex objects.
Summary
In this capsule you learned:
response_formatincreate_agentaccepts a Pydantic model and forces the agent to return structured data- After the tool loop, an additional call is made to the model to extract the data in the defined format
- The result holds
result["structured_response"]— a typed, validated Pydantic object, ready to use - The descriptions in
Field()act as instructions for the model — be specific - You can combine tools + structured output: the tools gather the data, the structured output formats it
- Use
Optionalfor fields that may not have information available - Pydantic's validators (
field_validator,ge,le) guarantee data quality in the response - Streaming works with structured output — you can show tool progress before getting the final answer
Next capsule: Legacy vs Modern: API mapping — how to translate code that uses LLMChain, AgentExecutor and other legacy APIs into LangChain's modern APIs.
Additional resources
- Structured Output — LangChain Docs — Conceptual guide to structured output
- How to return structured output from an agent — Official tutorial
- create_agent API Reference — The response_format parameter
- Pydantic Documentation — Models, validators and Field
- How to get structured output from a model — Structured output at the model level
- BaseModel API — Pydantic — Full BaseModel reference
Module 3 — LangChain & LangGraph: From Chains to Agents