Chains (LCEL)
Pipe a prompt into a model into a parser with |, and run the whole thing as one.
What you will be able to do
- Build a three-step chain with prompt | llm | parser
- Follow the data through the chain - dict, prompt, message, string
- Explain what StrOutputParser adds
- Run a chain with invoke, stream, and batch - and know when batch helps
- Put plain Python functions and other chains inside a chain
- Tell a chain from an agent
The idea, in plain English
Lesson 2.3 joined a template to a model with prompt | llm. That | is LCEL - the LangChain Expression Language - and it works like a pipe on the command line: the output of the step on the left becomes the input of the step on the right.
A typical chain has three steps. The prompt template turns your values into messages, the model turns the messages into an AIMessage, and an output parser turns the AIMessage into something your code can use directly - for StrOutputParser, plain text. chain = prompt | llm | parser builds it; chain.invoke({"topic": "Docker"}) runs all three and hands back a string.
It is exactly the three calls you could make yourself - prompt.invoke, then llm.invoke, then parser.invoke - and in our test both ways returned identical text. What the chain adds is that the three become one object: you can run it, stream it, batch it, and drop it into a bigger chain.
A chain is not an agent. It always runs the same steps in the same order. An agent, as in Module 1, decides what to do next. Every output below comes from a real run against llama3 (the code says llama3.1).
Worked example: A three-step chain.
1 - A dict goes in
chain.invoke({"topic": "Docker"}). The chain hands the dict to its first step - the prompt template - and checks it has every variable the template needs.
One call to chain.invoke. Step through it and watch the type change at every |.
The | operator
Every LangChain component - prompt templates, models, parsers - is a runnable: something with an invoke() method. | joins two runnables into a new one, a RunnableSequence, which runs the left one and feeds its result to the right one.
Order matters, because each step accepts certain inputs. llm | prompt fails immediately: the model receives the dict meant for the template and rejects it - "Invalid input type <class ’dict’>. Must be a PromptValue, str, or list of BaseMessages."
What StrOutputParser does
Without a parser, prompt | llm returns an AIMessage and your code reads .content. With StrOutputParser on the end, the chain returns the text itself. It is a small step, but it means the chain’s output is the thing you actually want - which matters more as soon as the parser does real work, such as turning text into JSON or a Pydantic object in Lesson 2.5.
A detail you may notice: printing type(result) shows TextAccessor, not str. It is a subclass of str, so it compares, slices, and serialises exactly like a string.
Same as three calls
chain.invoke(values) is parser.invoke(llm.invoke(prompt.invoke(values))). We ran both on the same input at temperature 0 and got identical text. That equivalence is useful when debugging: run the steps one at a time and look at what each produced.
The chain also knows its own interface. chain.get_input_jsonschema() reports that it needs topic, and its output is a string - the template’s variables and the parser’s output type, carried through.
invoke, stream, batch
Every chain gets three ways to run. invoke returns the whole result. stream yields it piece by piece as the model generates - 76 chunks for one short answer in our run, which joined back into exactly the invoke result. Use it to show text as it appears instead of after a pause.
batch runs a list of inputs. It sends them concurrently, so against a hosted API it can be much faster than a loop. Against one local Ollama model it was not: four topics took 16.3 seconds batched and 16.4 seconds in a loop, because the local server worked through them one at a time.
Functions and chains inside chains
A plain Python function can be a step. prompt | llm | parser | (lambda text: text.upper()) works - LangChain wraps the function in a RunnableLambda. Use it for small transformations between steps.
A whole chain can be a step too. {"text": explain} | summarise runs the explain chain, puts its output under the key text, and feeds that to the summarise chain. One input, two model calls, one sentence out.
A chain is not an agent
A chain is a fixed route: prompt, then model, then parser, every time. It cannot decide to call a tool, look at a result, or try again. An agent, as you built in Module 1, chooses its next step in a loop.
Use a chain whenever you know the steps in advance - translate, summarise, extract, format. Reach for an agent only when the steps depend on what happens along the way. Lessons 2.6 and 2.7 build agents on top of these same pieces.
ChainFixed sequence, the same steps every time. Predictable and cheap.AgentDecides the next step in a loop, usually with tools. Flexible and harder to predict.A chain will not fix the model
A chain routes text; it does not improve it. We built the translation chain and asked for "I am learning Python." in Telugu. llama3 replied with a translation, a romanisation, and a word-by-word breakdown - and the translation itself, నేను పైతోన్ నేర్వుచున్నాను, is not what a fluent speaker would write (నేను పైథాన్ నేర్చుకుంటున్నాను). The Hindi, मैं पायथन सीख रहा हूँ, was fine.
Adding "Reply with only the translation." to the prompt removed the extra text - the format problem. The Telugu stayed the same - the knowledge problem. StrOutputParser could not have helped with either: it passes on whatever text the model wrote.
Watch out: A small local model can be weak in some languages. Check its output with a fluent speaker before you ship a translation chain.
Step-by-step code
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_ollama import ChatOllama
llm = ChatOllama(model="llama3.1", temperature=0)
prompt = ChatPromptTemplate.from_template("Explain {topic} in simple English, in two sentences.")
parser = StrOutputParser()
chain = prompt | llm | parser
result = chain.invoke({"topic": "Docker"})
print(result)Here's a simple explanation of Docker in two sentences:
Docker is a way to package and run software applications in a self-contained "box"
called a container, which includes everything the app needs to run, such as code,
libraries, and dependencies. This allows developers to easily deploy and manage their
applications in a consistent and portable way, without worrying about compatibility
issues or dependencies on the underlying computer or operating system.prompt_value = prompt.invoke({"topic": "Docker"}) # ChatPromptValue
message = llm.invoke(prompt_value) # AIMessage
text = parser.invoke(message) # str
print(text == chain.invoke({"topic": "Docker"})) # True - in our run, identical
print(type(chain).__name__) # RunnableSequence
print(chain.get_input_jsonschema()["required"]) # ['topic']prompt = ChatPromptTemplate.from_template(
"Explain {topic} for a {level} developer. Use simple English. Give one real-world example."
)
chain = prompt | llm | StrOutputParser()
print(chain.invoke({"topic": "Redis", "level": "beginner"}))chain = prompt | llm | StrOutputParser()
# stream: pieces as they are generated
for chunk in chain.stream({"topic": "Redis", "level": "beginner"}):
print(chunk, end="", flush=True)
# batch: several inputs at once
answers = chain.batch([
{"topic": "Docker", "level": "beginner"},
{"topic": "Kafka", "level": "beginner"},
])stream: 76 chunks for one answer - 'Here', "'s", ' a', ' simple', ' explanation', ...
joined together == the invoke result
batch, four topics, one local Ollama model:
loop of invoke() 16.4 s
batch() 16.3 s - the local server answered them one at a time# A plain function becomes a step (a RunnableLambda)
shout = prompt | llm | StrOutputParser() | (lambda text: text.upper())
# A chain feeding a chain: explain, then summarise the explanation
explain = (
ChatPromptTemplate.from_template("Explain {topic} in simple English.")
| llm
| StrOutputParser()
)
summarise = (
ChatPromptTemplate.from_template("Summarise this in one sentence of at most 15 words:\n\n{text}")
| llm
| StrOutputParser()
)
explain_then_summarise = {"text": explain} | summarise
print(explain_then_summarise.invoke({"topic": "Kubernetes"}))
# Kubernetes is a management system for containers on nodes, enabling scaling, security, and orchestration.translate = (
ChatPromptTemplate.from_template(
"Translate the following sentence into {language}. Reply with only the translation.\n\n{text}"
)
| llm
| StrOutputParser()
)
print(translate.invoke({"language": "Telugu", "text": "I am learning Python."}))Without "Reply with only the translation.":
Here's the translation:
నేను పైతోన్ నేర్వుచున్నాను.
(Nenu Pythoṉ nerṟuḍunnaṉu)
Here's a breakdown of the translation: ...
With it:
Telugu నేను పైతోన్ నేర్వుచున్నాను. <- extra text gone; still not natural Telugu
Hindi मैं पायथन सीख रहा हूँ <- fine
A fluent Telugu speaker would write: నేను పైథాన్ నేర్చుకుంటున్నాను.bad = llm | prompt
bad.invoke({"topic": "Docker"})
# ValueError: Invalid input type <class 'dict'>.
# Must be a PromptValue, str, or list of BaseMessages.Tip: When a chain gives a strange result, run its steps one at a time - prompt.invoke, then llm.invoke, then parser.invoke - and look at each output. The chain is exactly those calls.
LCEL at a glance
|Join two steps; the left output feeds the right.
chain = prompt | llm | parser
invokeRun once, get the whole result.
chain.invoke({"topic": "Docker"})streamGet the result in pieces as it is generated.
for chunk in chain.stream(values): ...
batchRun a list of inputs concurrently.
chain.batch([values1, values2])
StrOutputParserAIMessage in, text out.
from langchain_core.output_parsers import StrOutputParser
Function stepAny function becomes a RunnableLambda.
| (lambda text: text.upper())
Chain as a stepA dict maps one chain’s output to the next input.
{"text": explain} | summariseTry it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Run prompt | llm and prompt | llm | StrOutputParser() on the same input and compare the types.”
“Print the chunks of chain.stream() one per line and count them.”
“Explain a topic, then turn the explanation into three bullet points with a second chain.”
“Translate a sentence into your own language and judge the result yourself.”
What usually goes wrong
Each step has to accept the previous step’s output. The model cannot take the template’s dict.
✗ chain = llm | prompt | parser✓ chain = prompt | llm | parserStrOutputParser already returned the text. A string has no .content.
✗ chain.invoke(values).content✓ chain.invoke(values)batch sends inputs concurrently, but one local Ollama model worked through them one at a time - no faster than a loop.
StrOutputParser passes on whatever text the model wrote, introductions included. Ask for the exact format in the prompt.
✗ "Translate into {language}:\n\n{text}"✓ "Translate into {language}. Reply with only the translation.\n\n{text}"If the steps are always the same, a chain is simpler, cheaper, and easier to test.
Key points
- LCEL joins steps with |: each output becomes the next input.
- prompt | llm | parser: dict -> messages -> AIMessage -> string.
- chain.invoke() is exactly prompt.invoke, llm.invoke, and parser.invoke in turn.
- StrOutputParser turns the model’s AIMessage into plain text.
- Every chain can invoke, stream, and batch - but batch did not help against one local model.
- Functions and whole chains can be steps.
- A chain is a fixed route; an agent chooses its next step.
- A chain routes text - it cannot make the model better at the task.
Quick check before you move on
Quiz
- 1.
What type does each step of prompt | llm | StrOutputParser() hand to the next?
- 2.
Why does llm | prompt fail?
- 3.
Four inputs took the same time with batch() as with a loop. Why?
- 4.
How do you send one chain’s output into another chain?
Interview questions
What is LCEL?
The LangChain Expression Language: composing runnables - prompts, models, parsers, functions, other chains - with the | operator, so each step’s output feeds the next. The result is itself a runnable with invoke, stream, and batch.
When would you use a chain instead of an agent?
Whenever the steps are known in advance - extraction, summarisation, translation, formatting. Chains are predictable, cheaper, and easier to test. Agents are for tasks whose steps depend on intermediate results.
How would you debug a chain that returns a wrong result?
Run the steps individually - the formatted prompt, the raw model message, the parsed output - and find where it goes wrong. A chain is exactly those calls in sequence.
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...
AI
System Design
Backend
- GraphQL8 modules · 69 lessons planned
- Core Python13 modules · 75 lessons planned
- FastAPI5 sections · 20 lessons
- Node.js14 modules · 206 lessons planned
- Node.js Performance7 chapters · 36 topics
- Event Loop Lifecycle6 phases · 3 scenarios
- Docker & Containerization11 modules · 144 lessons planned
- AWS for Developers14 modules · 219 lessons planned
- CI/CD & DevOps Automation10 modules · 134 lessons planned