Your first chain, in three lines
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
load_dotenv()
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7)
resp = llm.invoke("Explain what a variable is, in one sentence.")
print(resp.content)
This prints one sentence, something like: "A variable is a name you attach to a value so you can refer to it later."
Two things worth noticing before we move on.
ChatOpenAI is a uniform wrapper around chat models. Any provider that speaks the OpenAI protocol works through it — you change the model name and the base_url, and your application code doesn't move. That's not a small thing. Model pricing and quality change every few months, and you want switching to be a config change, not a rewrite.
invoke() is the one entry point for every LangChain component. A model, a prompt template, an output parser, a whole chain — you call .invoke(input) on all of them. Learn that single method and most of the framework's surface area collapses into something you already know.
The return value is a message object, not a string. Text lives in .content. Print resp itself and you'll see the whole envelope, including response_metadata with the token counts — useful when you want to know what a call actually cost.
temperature: the randomness dial
Models pick the next token from a probability distribution. Temperature reshapes that distribution.
0— near-deterministic. Use for extraction, classification, writing code.0.7— varied but on topic. Use for conversation, explanations.1.0+— loose and surprising. Use for brainstorming, creative writing.
If you're unsure, use 0 to 0.3. A surprising number of "the model is unreliable" bug reports are just a temperature that was set too high. Temperature does not control intelligence. Turning it up doesn't make the model think harder; it makes it choose less likely words.
Passing messages instead of a string
from langchain_core.messages import SystemMessage, HumanMessage
resp = llm.invoke([
SystemMessage(content="You are a strict code reviewer. Point out problems only. No compliments."),
HumanMessage(content="def f(a,b): return a+b"),
])
print(resp.content)
You'll get back a list of complaints: the name f says nothing, there are no type hints, no docstring, and no handling for non-numeric input.
This is the exact same messages list from lesson 1, wearing objects instead of dictionaries. SystemMessage is {"role": "system", ...}. Nothing new is happening — it's just a shape LangChain can type-check.
Streaming
Waiting for a full response before showing anything feels broken to users. Streaming emits the answer as it's generated:
for chunk in llm.stream("Tell me about Python list comprehensions."):
print(chunk.content, end="", flush=True)
The text appears word by word instead of all at once after a pause. end="" stops print from adding newlines between chunks, and flush=True forces each piece to the screen immediately instead of sitting in a buffer. The AI tutor on this site works exactly this way.
Joining components with |
Here's the idea LCEL is built on:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
prompt = ChatPromptTemplate.from_template("Explain {concept} with an everyday analogy.")
chain = prompt | llm | StrOutputParser()
print(chain.invoke({"concept": "recursion"}))
This prints a plain string — no .content needed — with an analogy for recursion, probably something about mirrors facing each other or Russian dolls.
Read the chain left to right: the input fills the prompt's blanks, the filled prompt goes to the model, the model's message goes to the parser, which hands back a plain string.
The | means "left side's output becomes right side's input," same as a Unix shell pipe. It works because Python lets classes define __or__, and LangChain defines it on every component. There's no compiler magic here — it's ordinary operator overloading.
StrOutputParser() does one small job: unwrap the message object into a string so you don't write .content everywhere.
Why bother? Because the chain is also a component
This is the part that makes composition worth the abstraction. A chain has the same interface as the pieces inside it, so it gets .stream() and .batch() for free:
for chunk in chain.stream({"concept": "recursion"}):
print(chunk, end="", flush=True)
results = chain.batch([{"concept": "recursion"}, {"concept": "pointers"}])
print(len(results)) # 2
The streaming version prints the analogy progressively. The batch version returns a list of two strings, and it runs the two requests concurrently rather than one after the other — noticeably faster when you have twenty inputs instead of two.
Write that streaming logic by hand against the raw API and it's real work: you'd have to thread chunks through the prompt step, the model step, and the parsing step yourself. Here you assembled the pipeline once and got all three call styles. That's the actual value proposition — composability, not magic.
And because a chain is a component, a chain can contain another chain. You'll use that in the RAG lesson.
Reading the errors you'll hit
| Error | What it means |
|---|---|
AuthenticationError | Bad key, or load_dotenv() never found your .env |
NotFoundError: model not found | Model name typo, or that provider doesn't offer it |
RateLimitError | Too many requests, or you're out of credit |
APIConnectionError | No network, or base_url is wrong |
KeyError: 'concept' | The dict you passed to invoke doesn't have the key the template asked for |
That last one is worth a habit: when a chain misbehaves, call .invoke() on the prompt alone and print the result. You'll see exactly what text the model received, which answers most "why is it ignoring me" questions instantly.
Ten-second check
In prompt | llm | StrOutputParser(), what does invoke return if you drop the last component?
An AIMessage object. You'd have to read .content yourself to get the text.