← Back to Agentic AI map
Lesson 2.4 · Building Agents with LangChain

Chains (LCEL)

Pipe a prompt into a model into a parser with |, and run the whole thing as one.

chains

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.

request flowWhat flows through the pipestep 1 / 4

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.

type
dict
needs
topic
missing key
KeyError here
model called
not yet

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.

Chain or agent
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

The three-step chain
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)
Output - from a real run
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.
The same thing, one step at a time
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']
Two variables
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"}))
invoke, stream, and batch
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"}, ])
What we measured
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 function and a chain as steps
# 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.
A translation chain
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."}))
Output - from real runs
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: నేను పైథాన్ నేర్చుకుంటున్నాను.
Steps in the wrong order
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
invoke

Run once, get the whole result.

chain.invoke({"topic": "Docker"})
stream

Get the result in pieces as it is generated.

for chunk in chain.stream(values): ...
batch

Run a list of inputs concurrently.

chain.batch([values1, values2])
StrOutputParser

AIMessage in, text out.

from langchain_core.output_parsers import StrOutputParser
Function step

Any function becomes a RunnableLambda.

| (lambda text: text.upper())
Chain as a step

A dict maps one chain’s output to the next input.

{"text": explain} | summarise

Try it yourself

The code does not change. Swap the content string and the program does something else entirely.

Remove the parser

“Run prompt | llm and prompt | llm | StrOutputParser() on the same input and compare the types.”

Stream it

“Print the chunks of chain.stream() one per line and count them.”

Chain two chains

“Explain a topic, then turn the explanation into three bullet points with a second chain.”

Test a language you speak

“Translate a sentence into your own language and judge the result yourself.”

What usually goes wrong

Steps in the wrong order

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 | parser
Reading .content from a chain with a parser

StrOutputParser already returned the text. A string has no .content.

✗ chain.invoke(values).content
✓ chain.invoke(values)
Expecting batch to speed up a local model

batch sends inputs concurrently, but one local Ollama model worked through them one at a time - no faster than a loop.

Expecting the parser to clean up the answer

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}"
Using an agent where a chain will do

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

What does prompt | llm | parser mean?
Fill the prompt, send it to the model, then parse the model’s reply - each step’s output feeding the next.
What does .invoke() do on a chain?
Runs every step in order on one input and returns the final result.
Why use StrOutputParser()?
So the chain returns the text itself instead of an AIMessage.
What is the difference between a chain and an agent?
A chain runs a fixed sequence; an agent decides its next step in a loop, usually with tools.
You have prompt | llm. What changes when you add | parser?
The result changes from an AIMessage to a string.

Quiz

  1. 1.

    What type does each step of prompt | llm | StrOutputParser() hand to the next?

  2. 2.

    Why does llm | prompt fail?

  3. 3.

    Four inputs took the same time with batch() as with a loop. Why?

  4. 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...