What LangChain is for
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
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
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.
Nested objects and lists
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
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 style | You get | Use it when |
|---|---|---|
| Pydantic BaseModel | An object, validated | A bad value must fail |
| TypedDict | A plain dict, no checks | You only need the keys |
| dataclass | An object, no Pydantic checks | You 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
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: boolandclause_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
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. Preferlangchain_corepipes orcreate_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"toTicketand see the default when the user never gives a name. - Ask about order
B22and read the status the tool forces. - Print
result["structured_response"].model_dump(). - Rewrite
ContactInfoas a TypedDict and compare what you get back.