Module 3: Function Calling Patterns
4. Structured Extraction via Function Calling
Overview
So far you've used function calling to make the model do things: search the web, calculate, create tickets. But there's a second use of function calling that's just as powerful — and in production, sometimes more important: extraction. Instead of asking the model to execute an action, you define a Pydantic schema as a "tool" and the model fills it with structured data extracted from text.
Think about it: you get an email from a customer saying "Hi, I'm Maria Garcia, CTO of TechCorp. We need to migrate 3 servers before March 15. The budget is $45,000 USD." With function calling for extraction, you define a ContactInfo model with fields like name, role, company, deadline, budget — and the LLM fills it in automatically. No regex. No NLP pipelines. No parsing anything by hand.
This pattern — using tool schemas as extraction mechanisms — is one of the most widely used in production systems. Extracting entities from documents, classifying tickets, parsing invoices, normalizing form data... all with the same mechanics you already know from function calling, but with a shift in mindset: the "tool" doesn't execute anything, it just structures data.
The Problem: Unstructured Data
The real world is free text
The vast majority of data that reaches a system doesn't arrive as neat JSON. It arrives as:
- Emails: "Confirming our meeting Tuesday at 3pm with the sales team"
- Support tickets: natural-language descriptions mixing symptoms, versions, context
- Scanned invoices (post-OCR): semi-structured text with data in variable positions
- Resumes and profiles: professional information in completely free formats
Your system needs structured data: a dict with specific fields, validated types, normalized values. The gap between free text and structured data is one of the most common problems in software engineering.
The modern approach
Before LLMs, the options were regex and classic NLP (spaCy, NLTK). Regex works for fixed patterns, but "The budget is 45K", "Budget: USD 45,000", "we have forty-five thousand dollars available" — they all mean the same thing and no regex covers them all.
With function calling, you tell the LLM "fill in this schema" — and the model understands natural language, infers implicit values, and returns validatable data:
from pydantic import BaseModel, Field
from typing import Optional
class ContactInfo(BaseModel):
name: str = Field(description="Person's full name")
email: Optional[str] = Field(description="Email if mentioned")
company: str = Field(description="Company or organization")
role: str = Field(description="Professional title or role")
budget: Optional[float] = Field(description="Budget in USD if mentioned")
You're not asking for "return JSON". You're using the function calling mechanism — the same one it uses to decide whether to call get_weather or search — but the "function" is simply a data schema. The model "invokes the function" with the extracted data as its arguments.
Function Calling as an Extraction Mechanism
The mental shift
In normal tool calling: you define a function that does something, the model decides when to call it, your code executes it, the result goes back to the model.
In extraction: you define a schema that describes the data you want, the model "calls the tool" with the extracted data as arguments, and you take those arguments — they are your result. The function doesn't need to do anything.
Direct approach: bind_tools with a dummy tool
You can use bind_tools with a forced tool_choice and a tool whose only purpose is defining the schema:
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from typing import Optional
from langchain.chat_models import init_chat_model
class ExtractedContact(BaseModel):
name: str = Field(description="Person's full name")
email: Optional[str] = Field(description="Email if mentioned")
company: str = Field(description="Company or organization")
role: str = Field(description="Professional title or role")
@tool(args_schema=ExtractedContact)
def extract_contact(name: str, email: Optional[str], company: str, role: str) -> str:
"""Extract contact information from a text."""
return "OK" # Never actually executed
model = init_chat_model("openai:gpt-4.1-mini")
model_with_tools = model.bind_tools([extract_contact], tool_choice="any")
response = model_with_tools.invoke(
"Hi, I'm Maria Garcia, CTO of TechCorp. My email is maria@techcorp.com"
)
contact_data = response.tool_calls[0]["args"]
# {'name': 'Maria Garcia', 'email': 'maria@techcorp.com', 'company': 'TechCorp', 'role': 'CTO'}
It works, but you're creating a dummy function you never need to execute. LangChain offers something better.
with_structured_output
The API designed for extraction
with_structured_output does the same thing internally (bind schema as tool, force call, extract args), but with a semantic API: "I want structured output", not "I want to fake a tool call."
from pydantic import BaseModel, Field
from typing import Optional
from langchain.chat_models import init_chat_model
class ExtractedContact(BaseModel):
name: str = Field(description="Person's full name")
email: Optional[str] = Field(description="Email if mentioned")
company: str = Field(description="Company or organization")
role: str = Field(description="Professional title or role")
model = init_chat_model("openai:gpt-4.1-mini")
structured_model = model.with_structured_output(ExtractedContact)
result = structured_model.invoke(
"Hi, I'm Maria Garcia, CTO of TechCorp. My email is maria@techcorp.com"
)
print(result)
# ExtractedContact(name='Maria Garcia', email='maria@techcorp.com',
# company='TechCorp', role='CTO')
print(result.name) # "Maria Garcia"
print(result.company) # "TechCorp"
The result is a Pydantic object, not a dict. Automatic validation, IDE autocomplete, type safety.
Example: Extracting invoice data
from pydantic import BaseModel, Field
from typing import List
from langchain.chat_models import init_chat_model
class InvoiceItem(BaseModel):
description: str = Field(description="Description of the item or service")
quantity: int = Field(description="Quantity", ge=1)
unit_price: float = Field(description="Unit price in the invoice's currency")
class Invoice(BaseModel):
invoice_number: str = Field(description="Invoice number")
vendor: str = Field(description="Vendor name")
date: str = Field(description="Date in YYYY-MM-DD format")
items: List[InvoiceItem] = Field(description="List of invoiced items")
total: float = Field(description="Total amount")
currency: str = Field(description="ISO 4217 currency code (USD, MXN, EUR)")
model = init_chat_model("openai:gpt-4.1-mini")
extractor = model.with_structured_output(Invoice)
invoice_text = """
INVOICE #INV-2026-0847
Vendor: CloudServ Solutions Inc.
Date: March 3, 2026
Services:
- Dedicated hosting (3 months) x1 — $2,400 USD
- Premium support x3 — $150 USD each
- Data migration x1 — $800 USD
Total: $3,650 USD
"""
result = extractor.invoke(f"Extract the data from this invoice:\n{invoice_text}")
print(result.invoice_number) # "INV-2026-0847"
print(result.vendor) # "CloudServ Solutions Inc."
print(result.date) # "2026-03-03"
print(result.total) # 3650.0
for item in result.items:
print(f" {item.description}: {item.quantity} x ${item.unit_price}")
The model understands "March 3, 2026" and converts it to "2026-03-03". It understands "x3" as quantity 3 and "$150 USD each" as the unit price. This is function calling extraction in action: the LLM does the "interpretation" and Pydantic validates the structure.
Example: Extracting people from free text
from pydantic import BaseModel, Field
from typing import Optional
from langchain.chat_models import init_chat_model
class Person(BaseModel):
name: str = Field(description="Person's full name")
age: Optional[int] = Field(description="Age if mentioned")
role: str = Field(description="Professional role or title")
model = init_chat_model("openai:gpt-4.1-mini")
extractor = model.with_structured_output(Person)
texts = [
"Maria Garcia, 35, is the CTO of TechCorp in Mexico City.",
"I met an engineer named Carlos who works out of Medellin.",
"The marketing director, Ana Lopez (28), leads the team in Madrid.",
]
for text in texts:
person = extractor.invoke(f"Extract the person's information:\n{text}")
print(f" {person.name} | {person.role} | Age: {person.age}")
# Maria Garcia | CTO of TechCorp | Age: 35
# Carlos | Engineer | Age: None
# Ana Lopez | Marketing Director | Age: 28
Notice how it handles Optional correctly: there's no age for Carlos, and the model returns None instead of inventing a number.
Extracting Multiple Entities
One document with N entities
You rarely need to extract a single entity. A contract mentions multiple people, a report has multiple metrics. For that, you define a wrapper model with a list:
from pydantic import BaseModel, Field
from typing import List, Optional
from langchain.chat_models import init_chat_model
class Person(BaseModel):
name: str = Field(description="Full name")
age: Optional[int] = Field(description="Age if mentioned")
role: str = Field(description="Professional role or title")
class ExtractedPeople(BaseModel):
people: List[Person] = Field(description="Every person mentioned in the text")
model = init_chat_model("openai:gpt-4.1-mini")
extractor = model.with_structured_output(ExtractedPeople)
text = """
The leadership team includes Maria Garcia (35), who as CTO leads
the technical strategy. Juan Perez, 42, handles finances as CFO.
The new product director is Ana Lopez, and Roberto Sanchez (29) joined
as lead engineer last week.
"""
result = extractor.invoke(f"Extract every person mentioned:\n{text}")
print(f"Found {len(result.people)} people:")
for person in result.people:
age_str = f", {person.age} years old" if person.age else ""
print(f" - {person.name}{age_str} — {person.role}")
# Found 4 people:
# - Maria Garcia, 35 years old — CTO
# - Juan Perez, 42 years old — CFO
# - Ana Lopez — Product Director
# - Roberto Sanchez, 29 years old — Lead Engineer
Multi-type extraction: different entities from the same text
Sometimes you need to extract different types of entities at once:
from pydantic import BaseModel, Field
from typing import List, Optional
from langchain.chat_models import init_chat_model
class PersonEntity(BaseModel):
name: str = Field(description="Full name")
role: Optional[str] = Field(description="Role or title if mentioned")
class CompanyEntity(BaseModel):
name: str = Field(description="Company name")
industry: Optional[str] = Field(description="Industry if it can be inferred")
class MoneyEntity(BaseModel):
amount: float = Field(description="Numeric amount")
currency: str = Field(description="ISO 4217 code")
context: str = Field(description="What this amount is for")
class DocumentEntities(BaseModel):
people: List[PersonEntity] = Field(default_factory=list)
companies: List[CompanyEntity] = Field(default_factory=list)
money: List[MoneyEntity] = Field(default_factory=list)
model = init_chat_model("openai:gpt-4.1-mini")
extractor = model.with_structured_output(DocumentEntities)
article = """
TechCorp, the AI startup founded by Maria Garcia in 2023, announced a
Series A round of $12M USD led by Sequoia Capital. The company competes with DataAI
and NeuralSoft in MLOps. CTO Juan Ramirez confirmed that $4M will go to infrastructure.
"""
entities = extractor.invoke(f"Extract every entity:\n{article}")
for p in entities.people:
print(f" Person: {p.name} ({p.role or 'no role'})")
for c in entities.companies:
print(f" Company: {c.name}")
for m in entities.money:
print(f" ${m.amount:,.0f} {m.currency} — {m.context}")
A single call extracts people, companies, and amounts — typed, validated, with context.
Classification via Function Calling
Classifying is extracting a category
Classification is a special case of extraction: you extract the category the text belongs to. You define a field with Literal or Enum and the model picks the right category.
from pydantic import BaseModel, Field
from typing import Literal
from langchain.chat_models import init_chat_model
class TicketClassification(BaseModel):
category: Literal["bug", "feature_request", "question", "billing", "security"] = Field(
description="Main ticket category"
)
priority: Literal["low", "medium", "high", "critical"] = Field(
description="Priority based on the impact described"
)
summary: str = Field(description="One-sentence summary")
model = init_chat_model("openai:gpt-4.1-mini")
classifier = model.with_structured_output(TicketClassification)
tickets = [
"I can't log in since yesterday. Error 500 with Google SSO. It affects 15 people.",
"It would be great to have a Slack integration for ticket notifications.",
"I was charged twice for the March subscription. I need a $49 USD refund.",
]
for ticket in tickets:
result = classifier.invoke(f"Classify this ticket:\n{ticket}")
print(f" [{result.category}] {result.priority} — {result.summary}")
# [bug] high — Error 500 on login with Google SSO affecting 15 people.
# [feature_request] low — Request for a Slack integration for notifications.
# [billing] medium — Duplicate subscription charge, needs a $49 USD refund.
Classification with reasoning
Add a reasoning field before the classification fields. Since the model generates tokens sequentially, writing the reasoning first improves classification quality — it's chain-of-thought applied to extraction.
from pydantic import BaseModel, Field
from typing import Literal
from langchain.chat_models import init_chat_model
class ClassificationWithReasoning(BaseModel):
reasoning: str = Field(
description="Step-by-step reasoning BEFORE classifying. "
"This field comes first so you think before deciding."
)
intent: Literal["purchase", "complaint", "inquiry", "cancellation", "feedback"] = Field(
description="The customer's main intent"
)
urgency: Literal["low", "medium", "high"] = Field(
description="Urgency based on tone and content"
)
model = init_chat_model("openai:gpt-4.1-mini")
classifier = model.with_structured_output(ClassificationWithReasoning)
message = "I've been without internet for 3 days and nobody gives me an answer. If it isn't fixed today, I'm cancelling."
result = classifier.invoke(f"Classify this message:\n{message}")
print(f"Intent: {result.intent} | Urgency: {result.urgency}")
print(f"Reasoning: {result.reasoning}")
Multi-label classification
When a document belongs to several categories, use a list of Literal:
from pydantic import BaseModel, Field
from typing import List, Literal
class MultiLabelClassification(BaseModel):
primary_topic: Literal[
"technology", "business", "science", "politics", "health"
] = Field(description="Main topic")
secondary_topics: List[Literal[
"technology", "business", "science", "politics", "health"
]] = Field(description="Secondary topics (can be empty)", default_factory=list)
sentiment: Literal["positive", "negative", "neutral", "mixed"] = Field(
description="Overall sentiment"
)
# model.with_structured_output(MultiLabelClassification) → classifies with multiple labels
When to Use Extraction vs Action Tools
| Situation | Extraction (with_structured_output) | Action Tool (@tool) |
|---|---|---|
| Parsing data from free text | ✅ Ideal | ❌ Unnecessary overhead |
| Classifying documents | ✅ Ideal | ❌ You're not executing anything |
| Querying an API | ❌ Doesn't apply | ✅ Real I/O |
| Extracting entities from an email | ✅ Ideal | ❌ Unnecessary |
| Saving to a database | ❌ Doesn't apply | ✅ Real side effect |
| Normalizing inconsistent data | ✅ Ideal | ❌ There's no external action |
| Calculating something | ❌ LLMs are bad at math | ✅ Deterministic execution |
| Generating a structured summary | ✅ Ideal | ❌ It isn't an action |
Rule: data from text → Extraction. Executing something real → Action Tool. Both → Extraction first, actions after.
Combining extraction + actions
from pydantic import BaseModel, Field
from typing import List, Optional
from langchain_core.tools import tool
from langchain.chat_models import init_chat_model
class TaskItem(BaseModel):
description: str = Field(description="Task description")
assignee: Optional[str] = Field(description="Assigned person if mentioned")
priority: str = Field(description="high, medium, or low based on the context")
class ExtractedTasks(BaseModel):
tasks: List[TaskItem] = Field(description="Tasks extracted from the text")
@tool
def create_ticket(description: str, assignee: str, priority: str) -> str:
"""Create a ticket in the project management system."""
return f"Ticket created: '{description}' → {assignee} [{priority}]"
model = init_chat_model("openai:gpt-4.1-mini")
meeting_notes = """
Notes from the March 5 meeting:
- Carlos must finish the payments API before Friday (urgent)
- Ana will prepare the presentation for the board, deadline March 15
- Roberto is researching CDN options, not urgent
"""
# Step 1: EXTRACTION
extractor = model.with_structured_output(ExtractedTasks)
extracted = extractor.invoke(f"Extract the tasks:\n{meeting_notes}")
# Step 2: ACTIONS with the extracted data
for task in extracted.tasks:
if task.assignee:
result = create_ticket.invoke({
"description": task.description,
"assignee": task.assignee,
"priority": task.priority,
})
print(result)
# Ticket created: 'Finish the payments API' → Carlos [high]
# Ticket created: 'Prepare presentation for the board' → Ana [medium]
# Ticket created: 'Research CDN options' → Roberto [low]
Connection to the Project
This module's project (capsule 08) is an extraction + routing system that takes free-text documents, extracts entities (people, companies, dates, amounts) using with_structured_output, classifies each document by type/urgency/department, and routes the entities to specialized processors.
What you learned here is the heart of the project. In the streaming, composition, and retry capsules, you'll add more layers.
Troubleshooting
Problem 1: "The model invents data that isn't in the text"
Symptom: You define email: Optional[str] and the model returns a made-up email.
Cause: The field description doesn't say it should be None when nothing is found.
Solution: Be explicit in the description:
email: Optional[str] = Field(
description="The person's email. ONLY if explicitly mentioned. "
"Return null if it doesn't appear — never invent an email."
)
Problem 2: "List extraction returns only one entity"
Symptom: A text mentions 4 people but result.people only has 1.
Cause: The prompt doesn't say it must extract every entity.
Solution: Be explicit in both the field description and the prompt:
people: List[Person] = Field(
description="EVERY person mentioned. If there are 5, the list must have 5."
)
# And in the prompt:
result = extractor.invoke("Extract EVERY person. Don't skip any.\n\n" + text)
Problem 3: "Dates come back in inconsistent formats"
Symptom: Sometimes "2026-03-15", sometimes "March 15", sometimes "15/03/2026".
Solution: Specify the format in the description and validate with Pydantic:
from pydantic import field_validator
class EventInfo(BaseModel):
date: str = Field(description="Date in STRICT YYYY-MM-DD format. Example: 2026-03-15")
@field_validator("date")
@classmethod
def validate_date_format(cls, v: str) -> str:
from datetime import datetime
datetime.strptime(v, "%Y-%m-%d")
return v
Problem 4: "with_structured_output fails with certain models"
Symptom: NotImplementedError or inconsistent responses.
Cause: Not every model supports function calling. Local or older models may not have it.
Solution: Use compatible models (GPT-4.1, Claude 3.5/4, Gemini 2). As a fallback, try method="json_mode":
structured_model = model.with_structured_output(MySchema, method="json_mode")
Problem 5: "It fails with long texts — it skips entities in the middle"
Symptom: Extraction works fine on short texts but skips data in long documents.
Solution: Split into chunks, extract from each one, and deduplicate:
def extract_from_long_text(text, extractor, chunk_size=2000):
chunks = [text[i:i+chunk_size] for i in range(0, len(text), chunk_size)]
all_entities = []
for chunk in chunks:
result = extractor.invoke(f"Extract entities:\n{chunk}")
all_entities.extend(result.people)
seen = set()
return [e for e in all_entities if e.name not in seen and not seen.add(e.name)]
Exercises
Exercise 1: Email and phone extractor (Easy)
Define a ContactExtraction schema with emails (list) and phones (list, with a specified format). Use with_structured_output to extract from: "You can reach me at ana@techcorp.com or at +52 55 1234 5678. support@techcorp.com also answers."
See solution
from pydantic import BaseModel, Field
from typing import List
from langchain.chat_models import init_chat_model
class ContactExtraction(BaseModel):
emails: List[str] = Field(description="Every email address")
phones: List[str] = Field(description="Phone numbers in international format (+XX XXX XXXX XXXX)")
model = init_chat_model("openai:gpt-4.1-mini")
extractor = model.with_structured_output(ContactExtraction)
text = (
"You can reach me at ana@techcorp.com or at +52 55 1234 5678. "
"support@techcorp.com also answers."
)
result = extractor.invoke(f"Extract emails and phone numbers:\n{text}")
print(f"Emails: {result.emails}") # ['ana@techcorp.com', 'support@techcorp.com']
print(f"Phones: {result.phones}") # ['+52 55 1234 5678']
Exercise 2: Sentiment classifier with reasoning (Easy)
Create SentimentAnalysis with reasoning (str), sentiment (Literal: positive/negative/neutral/mixed), and confidence (float 0-1). Classify 3 product reviews.
See solution
from pydantic import BaseModel, Field
from typing import Literal
from langchain.chat_models import init_chat_model
class SentimentAnalysis(BaseModel):
reasoning: str = Field(description="Reasoning BEFORE giving the sentiment")
sentiment: Literal["positive", "negative", "neutral", "mixed"] = Field(
description="Overall sentiment of the text"
)
confidence: float = Field(description="Confidence from 0.0 to 1.0", ge=0.0, le=1.0)
model = init_chat_model("openai:gpt-4.1-mini")
analyzer = model.with_structured_output(SentimentAnalysis)
reviews = [
"Excellent product, arrived earlier than expected. Incredible quality.",
"The product is fine but shipping took 3 weeks. I wouldn't buy again.",
"It does its job. Nothing more, nothing less. Fair price.",
]
for review in reviews:
result = analyzer.invoke(f"Analyze the sentiment:\n{review}")
print(f" {result.sentiment} ({result.confidence:.0%}) — {result.reasoning[:60]}...")
Exercise 3: Multi-type professional profile extractor (Medium)
Extract from a text: technologies (name + type: language/framework/tool/database), skills (name + level: beginner/intermediate/expert), and experience_years (Optional[int]). Text: "Fullstack developer with 7 years. I'm an expert in Python and TypeScript. I work daily with FastAPI, React and PostgreSQL. Basic knowledge of Kubernetes."
See solution
from pydantic import BaseModel, Field
from typing import List, Optional, Literal
from langchain.chat_models import init_chat_model
class Technology(BaseModel):
name: str = Field(description="Technology name")
tech_type: Literal["language", "framework", "tool", "database", "platform"] = Field(
description="Type of technology"
)
class Skill(BaseModel):
name: str = Field(description="Technology or skill")
level: Literal["beginner", "intermediate", "expert"] = Field(
description="expert in=expert, work daily with=intermediate, basic knowledge=beginner"
)
class ProfessionalProfile(BaseModel):
technologies: List[Technology] = Field(description="Every technology mentioned")
skills: List[Skill] = Field(description="Skills with the level inferred from context")
experience_years: Optional[int] = Field(description="Years of experience if mentioned")
model = init_chat_model("openai:gpt-4.1-mini")
extractor = model.with_structured_output(ProfessionalProfile)
text = (
"Fullstack developer with 7 years of experience. I'm an expert in Python and TypeScript. "
"I work daily with FastAPI, React and PostgreSQL. Basic knowledge of Kubernetes."
)
result = extractor.invoke(f"Extract the professional profile:\n{text}")
print(f"Experience: {result.experience_years} years")
for tech in result.technologies:
print(f" Tech: {tech.name} ({tech.tech_type})")
for skill in result.skills:
print(f" Skill: {skill.name} → {skill.level}")
Exercise 4: Pipeline extraction → classification → action (Medium)
Build a 3-step pipeline for a complaint email: (1) Extract data (order_id, issue, previous_contacts, threat, monetary_value), (2) Classify urgency and department, (3) Create a ticket with an action tool. Email: "I've been a customer for 5 years. Order #ORD-4521 was supposed to arrive Monday and today is Thursday. I called 3 times. If it doesn't arrive tomorrow, I'm cancelling my premium subscription ($299/year)."
See solution
from pydantic import BaseModel, Field
from typing import Optional, Literal
from langchain_core.tools import tool
from langchain.chat_models import init_chat_model
class ComplaintData(BaseModel):
order_id: Optional[str] = Field(description="Order number")
issue: str = Field(description="Concise description of the problem")
previous_contacts: Optional[int] = Field(description="Times they have made contact")
threat: Optional[str] = Field(description="The customer's threat, if any")
monetary_value: Optional[str] = Field(description="Monetary value at risk")
class ComplaintClassification(BaseModel):
department: Literal["shipping", "billing", "technical", "general"] = Field(
description="Responsible department"
)
urgency: Literal["low", "medium", "high", "critical"] = Field(
description="Urgency based on the threat, previous contacts, customer value"
)
@tool
def create_support_ticket(department: str, urgency: str, summary: str) -> str:
"""Create a support ticket with automatic routing."""
return f"Ticket → {department.upper()} [{urgency}]: {summary}"
model = init_chat_model("openai:gpt-4.1-mini")
email = (
"I've been a customer for 5 years. Order #ORD-4521 was supposed to arrive Monday and today is Thursday. "
"I called 3 times. If it doesn't arrive tomorrow, I'm cancelling my premium subscription ($299/year)."
)
# Step 1: Extraction
data = model.with_structured_output(ComplaintData).invoke(f"Extract the data:\n{email}")
# Step 2: Classification
classification = model.with_structured_output(ComplaintClassification).invoke(
f"Classify: Issue={data.issue}, Contacts={data.previous_contacts}, "
f"Threat={data.threat}, Value={data.monetary_value}"
)
# Step 3: Action
result = create_support_ticket.invoke({
"department": classification.department,
"urgency": classification.urgency,
"summary": data.issue,
})
print(result)
# Ticket → SHIPPING [critical]: Order not delivered on time
Exercise 5: Extraction with validation and automatic retry (Hard)
Implement extract_with_retry that: (1) Tries to extract with with_structured_output(include_raw=True), (2) If there's a parsing_error, retries including the error in the prompt, (3) 3 attempts max. Use a schema with a field_validator that validates the YYYY-MM-DD format. Test it with: "The event is on the fifteenth of March, two thousand twenty-six, in Guadalajara."
See solution
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from langchain.chat_models import init_chat_model
class EventExtraction(BaseModel):
event_name: str = Field(description="Event name")
date: str = Field(description="Date in STRICT YYYY-MM-DD format")
location: Optional[str] = Field(description="Location if mentioned")
@field_validator("date")
@classmethod
def validate_date(cls, v: str) -> str:
from datetime import datetime
try:
datetime.strptime(v, "%Y-%m-%d")
except ValueError:
raise ValueError(f"'{v}' is not YYYY-MM-DD")
return v
def extract_with_retry(model, schema, text: str, max_retries: int = 3):
extractor = model.with_structured_output(schema, include_raw=True)
last_error = None
for attempt in range(max_retries):
prompt = f"Extract the information:\n{text}"
if last_error:
prompt += f"\n\nPREVIOUS ATTEMPT FAILED: {last_error}\nFix the format."
result = extractor.invoke(prompt)
if result["parsing_error"] is None:
return result["parsed"]
last_error = str(result["parsing_error"])
print(f" Attempt {attempt + 1} failed: {last_error[:80]}...")
raise ValueError(f"Failed after {max_retries} attempts")
model = init_chat_model("openai:gpt-4.1-mini")
text = "The launch will be on the fifteenth of March, two thousand twenty-six, in Guadalajara."
event = extract_with_retry(model, EventExtraction, text)
print(f"{event.event_name} | {event.date} | {event.location}")
# Launch | 2026-03-15 | Guadalajara
Summary
In this capsule you learned:
- Function calling has two uses: actions (execute something) and extraction (get structured data out of text). Extraction is just as important in production
with_structured_outputis the clean API: you pass a Pydantic model, you get back a validated Pydantic object- Simple extraction: one schema → one object. Ideal for contacts, invoices, events
- Multiple extraction: a wrapper schema with
List[Entity]to extract N entities from a document - Classification is a special case: use
LiteralorEnumto restrict the categories - Reasoning in extraction: put
reasoningbefore the classification fields to improve quality (chain-of-thought) - Extraction + Actions: extract data with
with_structured_output, act with@tool— each tool for what it does best - Field descriptions are instructions to the model: format, constraints, when to return
null
Next capsule: Streaming tool calls — showing progress to the user while the model generates tool calls, accumulating ToolCallChunk, and building a "working..." UX for production agents.
Additional Resources
- LangChain — Structured Output — Official
with_structured_outputguide with per-provider examples - LangChain — Extraction Tutorial — Complete extraction tutorial with function calling
- Pydantic Field Types —
Field()reference with validators and constraints - Pydantic Validators —
field_validator,model_validatorfor custom validation - OpenAI Structured Outputs — OpenAI's native implementation
- Anthropic Tool Use — Extraction via tool use at Anthropic
- Instructor Library — Alternative library for structured extraction with LLMs