Composing LangChain Pipelines with LCEL: Linear, Parallel and Branching
Learn to wire prompts, models and parsers into linear, multi-stage, parallel and conditional LangChain pipelines with the pipe operator, RunnableParallel and RunnableBranch.
This article is published in English.
Sources: Chains in LangChain: Simple, Sequential, Parallel, and Conditional — Part 7
Real LLM features almost never stop at one model call. You format a prompt, call a model, clean up what comes back, feed that result into another prompt, sometimes run several requests side by side, and occasionally pick a different path depending on the input. Wiring each hand-off by hand quickly turns into glue code that is hard to read and harder to change. This guide walks through four composition patterns in LangChain, built with the LangChain Expression Language (LCEL): straight pipelines, multi-stage pipelines, fan-out with RunnableParallel, and routing with RunnableBranch. Every pattern is shown twice, once on Google Gemini and once on OpenAI, so you can see exactly which parts change when you switch providers and which parts stay put.
What a chain actually is
A chain is a pipeline of LangChain components in which each step receives whatever the previous step produced. The typical building blocks are prompt templates, chat or completion models, and output parsers, but anything that implements LangChain's Runnable interface can take part. The smallest useful pipeline looks like this:
Prompt → LLM → Output Parser → Output
Each stage has one job. The prompt template takes your input variables and turns them into instructions for the model. The model reads those instructions and generates a response. The output parser reshapes that response into the form your application wants, for example a plain string or a validated object. What leaves the parser is the result of the whole pipeline.
The value of the abstraction is that you no longer shuttle data between those stages yourself. You declare the order once, and LangChain takes care of calling each component with the right input. Conceptually, you are snapping small blocks together into a larger unit:
Input
↓
Component 1
↓
Component 2
↓
Component 3
↓
Output
That matters little for a two-step script, but it becomes essential once an application grows to a dozen steps. A declared pipeline keeps the data flow visible in one place instead of scattering it across helper functions. The mental model is a repeated rhythm of input, processing, output, and then processing again on that output, until the final value falls out of the end.
Why composition beats manual wiring
When every intermediate value is passed around explicitly, code becomes noisy and brittle. Chains address three practical problems.
Data flows without glue code
The core promise is that the result of one step automatically becomes the argument of the next. Take the familiar sequence again:
Prompt → LLM → Output Parser → Output
Written by hand, you would format the prompt, call the model with the formatted value, pull the text out of the response object, and hand it to the parser, keeping a variable for each hop. With LCEL the same flow collapses into a single expression built with the pipe operator:
chain = prompt | llm | parser
The | operator works because prompts, models and parsers all implement the same Runnable interface, so each one knows how to accept the previous component's output. Fewer temporary variables means fewer places to pass the wrong value, and the pipeline reads left to right in the same order it executes.
Multi-step logic stays readable
Production features often need several model calls with different prompts. A common shape is to generate something from a topic and then post-process that intermediate result with a second prompt:
Topic
↓
Prompt 1
↓
LLM
↓
Output Parser
↓
Prompt 2
↓
LLM
↓
Output Parser
↓
Final Output
Managed manually, this kind of flow sprawls as more steps are added. Expressed as a chain, the stages stay in one declaration, and the rule that each stage feeds the next is enforced by the framework rather than by discipline.
Components can be swapped
Because every stage is an independent component, you can replace one without touching the others. Suppose a pipeline currently runs on Gemini:
chain = prompt | gemini | parser
Moving to OpenAI means changing only the model component:
chain = prompt | openai | parser
The prompt and the parser stay as they are. This is useful when requirements shift, when a team compares providers on cost or quality, or when you want a cheaper model in development and a stronger one in production.
The benefits at a glance
- Less glue code: outputs flow into the next step automatically.
- Structured multi-step workflows: several prompts, models and parsers live in one declared pipeline.
- Easier evolution: components can be reused or replaced, including the model provider, without redesigning the flow.
Four composition patterns
It helps to group LangChain pipelines into four shapes. Strictly speaking these are not separate classes in modern LangChain; they are patterns you build from the same Runnable primitives. The names are still a handy vocabulary:
- Simple chain: a single straight line from prompt to parser.
- Sequential chain: several such lines joined end to end.
- Parallel chain: several independent sub-chains running on the same input at once.
- Conditional chain: a decision point that selects which sub-chain handles the input.
Together they cover everything from a one-shot prompt to workflows that fan out and make decisions. Each is covered below with a working example on two providers.
Simple chains: one straight pass
The simplest pipeline connects its components in a straight line. The output of each component is the input of the next, and there are no branches or decisions along the way:
Input
↓
Prompt Template
↓
LLM
↓
Output Parser
↓
Output
The running example is a study helper: given a topic, the model should produce a handful of approachable facts without diving into heavy technical detail. The chain for that is as short as it gets:
prompt → llm → parser
In words, a topic value enters the prompt template, the formatted prompt goes to the model, and the model's reply passes through an output parser before it is returned. The parser is technically optional, but including it gives you a predictable return type.
Concretely, the example passes cricket as the topic and asks for five interesting facts. The data moves like this:
{'topic': 'cricket'}
↓
PromptTemplate
↓
LLM
↓
StrOutputParser
↓
Five interesting facts about cricket
The Gemini version
Here is the full program using Google's Gemini through the langchain_google_genai integration:
from langchain_google_genai import GoogleGenerativeAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv
load_dotenv()
llm = GoogleGenerativeAI(model="gemini-2.5-flash")
prompt = PromptTemplate(
template='Generate 5 interesting facts about {topic}',
input_variables=['topic']
)
parser = StrOutputParser()
chain = prompt | llm | parser
result = chain.invoke({'topic':'cricket'})
# print(result)
chain.get_graph().print_ascii()
It builds a GoogleGenerativeAI model on gemini-2.5-flash, a one-variable prompt, and a StrOutputParser, then composes them with prompt | llm | parser. The last line prints an ASCII diagram of the chain, which is covered further down. Note that the print(result) line is commented out in this version, so the script shows only the graph unless you re-enable it.
Walking through the Gemini code
The imports bring in the four pieces the script needs:
from langchain_google_genai import GoogleGenerativeAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv
GoogleGenerativeAI is the wrapper around the Gemini model. PromptTemplate defines a prompt with a {topic} slot to be filled at run time. StrOutputParser turns the model output into a plain Python string. load_dotenv reads a local .env file into environment variables.
Calling load_dotenv makes the API key and any other settings from .env available to the process before any client is created:
load_dotenv()
The Gemini integration reads its key from the environment, so this call has to run before the model is instantiated. Keeping keys in .env (and out of version control) is the usual practice.
Next the model itself is created, pinned to gemini-2.5-flash:
llm = GoogleGenerativeAI(model="gemini-2.5-flash")
The prompt template declares both the text and the variables it expects:
prompt = PromptTemplate(
template='Generate 5 interesting facts about {topic}',
input_variables=['topic']
)
At invocation time the {topic} placeholder is filled from the input dictionary. Passing this input:
{'topic': 'cricket'}
produces the following concrete prompt:
Generate 5 interesting facts about cricket
The parser is created with no arguments:
parser = StrOutputParser()
Its job is to hand back the model's answer as a string. With a completion-style model such as GoogleGenerativeAI, which already returns text, it is close to a pass-through. With a chat model, which returns a message object, it extracts the text content. Either way, the next consumer receives a plain str.
Then comes the line that defines the whole pipeline:
chain = prompt | llm | parser
The pipe operator links the three components so that the formatted prompt feeds the model and the model's reply feeds the parser:
PromptTemplate → Gemini 2.5 Flash → StrOutputParser
Finally, the chain is run with a dictionary containing the topic:
result = chain.invoke({'topic':'cricket'})
That single call triggers every stage in order, with no intermediate variables in your code:
cricket
↓
PromptTemplate
↓
Gemini 2.5 Flash
↓
StrOutputParser
↓
Final Result
What comes back
The return value is a string holding five facts about cricket, something along these lines:
1. Cricket originated in England and has been played for several centuries.
2. A cricket team consists of 11 players.
3. Test cricket matches can last up to five days.
4. The Cricket World Cup is one of the major international cricket tournaments.
5. Cricket is especially popular in countries such as India, Australia, England, and New Zeakand.
Since the text is generated, wording and specific facts differ from run to run, and the sample output even contains a typo ("Zeakand"). Treat sample outputs as illustrations of shape, not as fixed results. If you need deterministic formatting, that is the job of a stricter parser or structured output, not of hoping the model repeats itself.
The same pipeline on OpenAI
Now the identical study helper, running on OpenAI. The notable thing is how little changes:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv
load_dotenv()
llm = ChatOpenAI(model="gpt-4o-mini")
prompt = PromptTemplate(
template='Generate 5 interesting facts about {topic}',
input_variables=['topic']
)
parser = StrOutputParser()
chain = prompt | llm | parser
result = chain.invoke({'topic':'cricket'})
print(result)
chain.get_graph().print_ascii()
The model is now ChatOpenAI with gpt-4o-mini. The prompt, the parser and the composition are exactly as before, and this version prints the result as well as the graph.
The only new piece is the model object:
llm = ChatOpenAI(model="gpt-4o-mini")
ChatOpenAI from langchain_openai stands in for GoogleGenerativeAI. It is a chat model, so it returns a message object rather than a raw string, which is precisely why keeping the parser in the chain is worthwhile.
The prompt template is unchanged:
prompt = PromptTemplate(
template='Generate 5 interesting facts about {topic}',
input_variables=['topic']
)
So is the parser:
parser = StrOutputParser()
And so is the composition:
chain = prompt | llm | parser
The structure is the same three-stage line, with OpenAI in the middle:
PromptTemplate → OpenAI → StrOutputParser
Invoking the chain and printing the result looks like this:
result = chain.invoke({'topic':'cricket'})
print(result)
The OpenAI run also returns a string of five cricket facts, for example:
1. Cricket is believed to have originated in England.
2. Each cricket team has 11 players.
3. A Test match can be played over a maximum of five days.
4. The Cricket World Cup is one of the most important international cricket tournaments.
5. Cricket has a particularly large following in India and several other countries.
Again, the exact phrasing will vary between runs and between models.
Printing the chain's structure
Both scripts end with a call that renders the pipeline as a text diagram:
chain.get_graph().print_ascii()
get_graph() returns a graph representation of the Runnable, and print_ascii() draws it in the terminal. For the Gemini chain you get something like this:
PromptInput
*
*
PromptTemplate
*
*
GoogleGenerativeAI
*
*
StrOutputParser
*
*
StrOutputParserOutput
The PromptInput and StrOutputParserOutput nodes are the schema-derived entry and exit points; the three nodes between them are your components. This is a quick sanity check that a chain is wired the way you think it is. In most environments print_ascii() depends on the optional grandalf package, so install it if the call raises an import error.
Only the model changed
Place the two pipelines side by side and the difference is a single node. With Gemini:
Input
↓
PromptTemplate
↓
GoogleGenerativeAI
↓
StrOutputParser
↓
Output
With OpenAI:
Input
↓
PromptTemplate
↓
ChatOpenAI
↓
StrOutputParser
↓
Output
In code, the entire difference comes down to which model object you construct:
# Google Gemini
llm = GoogleGenerativeAI(model="gemini-2.5-flash")
# OpenAI
llm = ChatOpenAI(model="gpt-4o-mini")
Everything around it can stay the same. That is the practical benefit of building on standardized components: provider choice becomes a configuration decision rather than a rewrite. In practice there are still small differences worth watching, such as chat versus completion models, parameter names, and how each provider handles system instructions, but the pipeline itself is portable.
The full data path
In general terms, a simple chain is this:
Input
↓
PromptTemplate
↓
LLM
↓
StrOutputParser
↓
Output
Traced with the cricket input, each intermediate value becomes visible, from the dictionary to the rendered prompt, the raw model response and the final string:
{'topic': 'cricket'}
↓
PromptTemplate
↓
"Generate 5 interesting facts about cricket"
↓
LLM
↓
Raw LLM Response
↓
StrOutputParser
↓
Final String Output
That is the whole idea. Each component does one thing, and its result becomes the next component's input, in a single straight pass.
Sequential chains: feeding one model call into the next
A sequential chain extends the same idea across several stages. A simple chain usually contains one run of this shape:
Prompt → LLM → Output Parser
A sequential chain can contain several prompt templates, several model calls and several parsers, all joined together. The governing rule does not change: whatever the current stage produces is what the next stage consumes. There is still no branching; the stages always run in the same fixed order. A two-stage version looks like this:
Input
↓
Prompt Template 1
↓
LLM 1
↓
Output Parser
↓
Prompt Template 2
↓
LLM 2
↓
Output Parser
↓
Output
Example: a detailed report, then a summary
The example task has two phases. First, generate a detailed report on a topic. Second, condense that report into a five-point summary. The second phase never sees the original topic directly; it works only from the report the first phase produced:
Input
↓
Explanation Prompt
↓
LLM
↓
StrOutputParser
↓
Detailed Report
↓
Summary Prompt
↓
LLM
↓
StrOutputParser
↓
5-Point Summary
Two prompt templates carry the work. explaination_prompt (the misspelling is in the code, so keep it if you copy the snippets) asks for the detailed report. summary_prompt takes that report as input and asks for five summary points.
The Gemini version
Here is the complete program on Gemini:
from langchain_google_genai import GoogleGenerativeAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv
load_dotenv()
llm = GoogleGenerativeAI(model="gemini-2.5-flash")
# Prompt 1 : Explanation
explaination_prompt = PromptTemplate(
template='Generate a detailed report on {topic}',
input_variables=['topic']
)
# Prompt 2 : Summary
summary_prompt = PromptTemplate(
template='Generate a 5 pointer summary from the following text \n {text}',
input_variables=['text']
)
parser = StrOutputParser()
chain = explaination_prompt | llm | parser | summary_prompt | llm | parser
result = chain.invoke({"topic": "Generative AI"})
# print(result)
chain.get_graph().print_ascii()
One GoogleGenerativeAI instance serves both stages, and the pipeline is a single expression with six components: two prompts, the model twice and the parser twice.
Walking through the Gemini code
The imports match the simple chain:
from langchain_google_genai import GoogleGenerativeAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv
GoogleGenerativeAI for the model, PromptTemplate for the two reusable prompts, StrOutputParser to turn each response into a string, and load_dotenv for configuration.
Environment variables are loaded first, exactly as before:
load_dotenv()
The model is created once:
llm = GoogleGenerativeAI(model="gemini-2.5-flash")
Reusing a single model object for both stages is perfectly fine. Model wrappers in LangChain are stateless with respect to individual calls, so the same instance can be referenced any number of times in a chain.
The first prompt accepts a topic and asks for a detailed report:
explaination_prompt = PromptTemplate(
template='Generate a detailed report on {topic}',
input_variables=['topic']
)
Given this input:
{"topic": "Generative AI"}
it renders as:
Generate a detailed report on Generative AI
The second prompt is where the interesting hand-off happens:
summary_prompt = PromptTemplate(
template='Generate a 5 pointer summary from the following text \n {text}',
input_variables=['text']
)
It expects a variable named text, but nothing in the input dictionary is called text. The value arrives from the first stage: the report generated by the first model call is what fills that slot.
That works because of the parser sitting between the stages:
parser = StrOutputParser()
After the first model call, the parser produces a plain string. When a PromptTemplate with exactly one input variable receives a bare string instead of a dictionary, LCEL maps that string to the single variable. So the report lands in {text} without any explicit mapping. If the second prompt had two variables, you would need to build the dictionary yourself, for example with a small dict of Runnables or RunnablePassthrough.assign.
The full pipeline is one line:
chain = explaination_prompt | llm | parser | summary_prompt | llm | parser
Reading it left to right gives the execution order:
Explanation Prompt
↓
Gemini
↓
StrOutputParser
↓
Summary Prompt
↓
Gemini
↓
StrOutputParser
↓
Final Output
Each component receives the previous component's output. Nothing in your code touches the intermediate report.
Running it takes a single topic:
result = chain.invoke({"topic": "Generative AI"})
Behind that call, both stages execute back to back:
"Generative AI"
↓
Explanation Prompt
↓
Gemini
↓
StrOutputParser
↓
Detailed Report
↓
Summary Prompt
↓
Gemini
↓
StrOutputParser
↓
Final 5-Point Summary
What comes back
The final value is a five-point summary of Generative AI, for example:
1. Generative AI is a branch of artificial intelligence that creates new content.
2. It can generate text, images, audio, video, and code.
3. Foundation models are an important part of modern Generative AI systems.
4. Generative AI is used across areas such as education, software development, and customer support.
5. It is enabling applications that can automate and simplify many creative and knowledge-based tasks.
The point to notice is that this summary is not written from the topic directly. Gemini first writes a full report, and only that report is sent to the summary prompt. That two-step approach tends to give the second call richer material to compress, at the cost of an extra model call and the latency and tokens that come with it. As always, the concrete wording varies from run to run.
The same pipeline on OpenAI
The OpenAI variant keeps the workflow intact:
from langchain_openai import ChatOpenAI
from dotenv import load_dotenv
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv
load_dotenv()
llm = ChatOpenAI()
explaination_prompt = PromptTemplate(
template="Generate a detailed report on {topic}",
input_variables=["topic"]
)
summary_prompt = PromptTemplate(
template="Generate a 5 pointer summary from the following text \n {text}",
input_variables=["text"],
)
parser = StrOutputParser()
chain = explaination_prompt | llm | parser | summary_prompt | llm | parser
result = chain.invoke({"topic": "Generative AI"})
print(result)
chain.get_graph().print_ascii()
It is the same two-stage structure: report prompt, model, parser, summary prompt, model, parser. The script also imports load_dotenv twice, which is harmless but can be cleaned up.
The model is constructed without arguments this time:
llm = ChatOpenAI()
With no model argument, ChatOpenAI falls back to the library's default model, which has changed across langchain_openai releases. Pin a model name explicitly in real projects so that an upgrade does not silently change behavior or cost.
The report prompt is identical to the Gemini version:
explaination_prompt = PromptTemplate(
template="Generate a detailed report on {topic}",
input_variables=["topic"]
)
So is the summary prompt, which again expects the report in {text}:
summary_prompt = PromptTemplate(
template="Generate a 5 pointer summary from the following text \n {text}",
input_variables=["text"],
)
This is where the sequential pattern pays off: the second prompt is filled automatically from the first stage's output.
The parser is the same StrOutputParser:
parser = StrOutputParser()
And the composition is character for character the same:
chain = explaination_prompt | llm | parser | summary_prompt | llm | parser
As a flow, it reads:
Prompt 1
↓
OpenAI
↓
StrOutputParser
↓
Prompt 2
↓
OpenAI
↓
StrOutputParser
↓
Final Output
Only the model node differs from the Gemini version.
The invocation passes the topic once more. Note that the snippet as shown is missing its closing parenthesis; the full program above has the correct line:
result = chain.invoke({"topic": "Generative AI"}
The chain then runs both stages on its own.
What comes back
The result is again a five-point summary derived from the intermediate report:
1. Generative AI creates new content by learning patterns from existing data.
2. It is powered by large foundation models trained on diverse datasets.
3. Generative AI can produce text, images, audio, video, and code.
4. It is being used in many industries and applications.
5. It is becoming an important part of modern AI-powered software.
The details will differ between runs.
Printing the chain's structure
The graph helper works on longer chains too:
chain.get_graph().print_ascii()
For the Gemini sequential chain, the diagram shows both stages stacked vertically:
PromptInput
*
*
PromptTemplate
*
*
GoogleGenerativeAI
*
*
StrOutputParser
*
*
PromptTemplate
*
*
GoogleGenerativeAI
*
*
StrOutputParser
*
*
StrOutputParserOutput
The model node appears twice because the same model object is used at two positions in the pipeline, and the graph reflects positions, not unique objects.
Comparing the two implementations
Both versions follow the same sequential structure; the provider is the only real difference. The Gemini pipeline:
Input
↓
Explanation Prompt
↓
GoogleGenerativeAI
↓
StrOutputParser
↓
Summary Prompt
↓
GoogleGenerativeAI
↓
StrOutputParser
↓
Output
The OpenAI pipeline:
Input
↓
Explanation Prompt
↓
ChatOpenAI
↓
StrOutputParser
↓
Summary Prompt
↓
ChatOpenAI
↓
StrOutputParser
↓
Output
Prompts, parser and chain layout are shared. The model construction is the only line that changes:
# Google Gemini
llm = GoogleGenerativeAI(model="gemini-2.5-flash")
# OpenAI
llm = ChatOpenAI()
The composed chain itself stays identical across providers:
chain = explaination_prompt | llm | parser | summary_prompt | llm | parser
Put abstractly, a sequential chain looks like this:
Prompt 1
↓
LLM 1
↓
Parser
↓
Prompt 2
↓
LLM 2
↓
Parser
↓
Output
The difference from a simple chain is that there are multiple stages, and each stage after the first depends on the one before it.
The full data path
In general form, the sequential chain moves data like this:
Input
↓
PromptTemplate 1
↓
LLM 1
↓
StrOutputParser
↓
Detailed Report
↓
PromptTemplate 2
↓
LLM 2
↓
StrOutputParser
↓
Final Output
Traced with the Generative AI input, including the rendered prompts:
{"topic": "Generative AI"}
↓
Explanation Prompt
↓
"Generate a detailed
report on Generative AI"
↓
LLM
↓
StrOutputParser
↓
Detailed Report
↓
Summary Prompt
↓
"Generate a 5 pointer
summary from the following text"
↓
LLM
↓
StrOutputParser
↓
5-Point Summary
The first model call is an intermediate step rather than the answer. Its output becomes raw material for the next operation. You can keep stacking stages when a workflow needs more of them:
Prompt
↓
LLM
↓
Parser
↓
Prompt
↓
LLM
↓
Parser
↓
Prompt
↓
LLM
↓
Output
However long the pipeline gets, the order is fixed, and every stage waits for the previous one. That also means latency adds up: three model calls in sequence cost roughly the sum of their response times. It is also worth adding a guard for runaway intermediate output, because a very long report feeds straight into the next prompt and inflates its token count.
Parallel chains: fanning out over the same input
A parallel chain runs several sub-chains side by side. Where a sequential chain waits for each stage to finish before starting the next, a parallel chain launches independent sub-chains together:
┌──→ Sub-Chain 1 ──→ Output 1
│
Input ──────────────┼──→ Sub-Chain 2 ──→ Output 2
│
└──→ Sub-Chain 3 ──→ Output 3
↓
Combined Output
Because the sub-chains are independent, none of them sees another's output. The mechanics are straightforward:
- Every sub-chain receives the same input and produces its own output.
- All sub-chains are started concurrently.
- The parallel step waits until every sub-chain has finished.
- Their results are collected into one combined value.
That combined value is a dictionary. Each key is the name you gave a sub-chain, and each value is that sub-chain's output:
{
"facts": "...",
"definition": "...",
"use_case": "..."
}
Example: three views of one topic
Given a topic such as Artificial Intelligence, the goal is to produce three things from the same input: five interesting facts, a concise definition, and a real-world use case. None of these depends on the others, so running them one after another would only waste time. Instead they run in parallel:
Input
↓
RunnableParallel
/ | \
/ | \
↓ ↓ ↓
Facts Definition Use Case
↓ ↓ ↓
LLM LLM LLM
↓ ↓ ↓
Parser Parser Parser
\ | /
\ | /
↓ ↓ ↓
Combined Result
Three sub-chains do the work: facts for the five facts, definition for the short definition, and use_case for the practical application. They are combined with RunnableParallel. The first implementation uses Gemini for all three; the second mixes OpenAI and Anthropic and adds a merge step.
Why run sub-chains in parallel
There are two reasons to reach for this pattern.
The first is speed. Model calls are dominated by network and generation time, so independent calls benefit greatly from concurrency. Imagine three sub-chains that each take about two seconds. Run one after another, they add up:
Chain 1 → 2 seconds
Chain 2 → 2 seconds
Chain 3 → 2 seconds
Total ≈ 6 seconds
Run concurrently, the total is roughly the duration of the slowest one:
Chain 1 ─┐
Chain 2 ─┼→ execute together → ≈ 2 seconds
Chain 3 ─┘
The real saving depends on provider rate limits and how evenly the calls finish, but for independent tasks the improvement is usually substantial.
The second reason is structural: the tasks share an input but do not need one another's results. For instance, from a single document you might want a summary, a list of key topics, and a set of study questions. Each task can take the document directly:
Document
↓
┌───────┼───────┐
↓ ↓ ↓
Summary Topics Questions
The summary does not have to flow into the topic extraction or the question generator. When tasks relate like this, forcing them into a sequence adds latency and couples steps that have no real dependency. When they do depend on each other, parallelism is the wrong tool, and a sequential chain is the honest representation.
The Gemini version
Here is the three-way fan-out on Gemini:
from langchain_google_genai import GoogleGenerativeAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableParallel
from dotenv import load_dotenv
load_dotenv()
llm = GoogleGenerativeAI(model="gemini-2.5-flash")
parser = StrOutputParser()
# Prompt 1: Facts
fact_prompt = PromptTemplate(
input_variables=["topic"],
template="Give five interesting short facts about: {topic}"
)
# Prompt 2: Definition
definition_prompt = PromptTemplate(
input_variables=["topic"],
template="Give a concise definition of: {topic}"
)
# Prompt 3: Use Case
usecase_prompt = PromptTemplate(
input_variables=["topic"],
template="What is a real-world use case of: {topic}?"
)
# Run all chains in parallel
parallel_chain = RunnableParallel({
"facts": fact_prompt | llm | parser,
"definition": definition_prompt | llm | parser,
"use_case": usecase_prompt | llm | parser
})
# Invoke parallel chain
result = parallel_chain.invoke({"topic": "Artificial Intelligence"})
# print(type(result))
# print(result)
# print("\n📌 Facts:\n", result["facts"])
# print("\n📘 Definition:\n", result["definition"])
# print("\n🛠️ Use Case:\n", result["use_case"])
parallel_chain.get_graph().print_ascii()
Three prompt templates, one model and one parser are combined into three small chains, which RunnableParallel then runs together on the input Artificial Intelligence. Several print lines are commented out; enabling them shows the result type and each field.
Walking through the Gemini code
The imports add one new component:
from langchain_google_genai import GoogleGenerativeAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableParallel
Besides the model wrapper, PromptTemplate and StrOutputParser, the script imports RunnableParallel from langchain_core.runnables, which is the piece that runs the sub-chains concurrently.
A single Gemini 2.5 Flash model is created:
llm = GoogleGenerativeAI(model="gemini-2.5-flash")
All three sub-chains share it. Sharing one model object across parallel branches is safe; each call is independent.
One parser is shared as well:
parser = StrOutputParser()
The first prompt asks for five short facts:
fact_prompt = PromptTemplate(
input_variables=["topic"],
template="Give five interesting short facts about: {topic}"
)
The second asks for a concise definition:
definition_prompt = PromptTemplate(
input_variables=["topic"],
template="Give a concise definition of: {topic}"
)
The third asks for a real-world application:
usecase_prompt = PromptTemplate(
input_variables=["topic"],
template="What is a real-world use case of: {topic}?"
)
All three prompts use the same variable name:
{topic}
That shared name is what allows a single input dictionary to feed every branch. The tasks themselves remain completely separate.
Now the central piece, the parallel chain:
parallel_chain = RunnableParallel({
"facts": fact_prompt | llm | parser,
"definition": definition_prompt | llm | parser,
"use_case": usecase_prompt | llm | parser
})
RunnableParallel takes a dictionary whose values are Runnables. The keys become the keys of the output:
facts
definition
use_case
Each value is a complete little chain of its own:
fact_prompt → LLM → parser
definition_prompt → LLM → parser
usecase_prompt → LLM → parser
Since none of them relies on another, they can all run at the same time. With a synchronous invoke, LangChain runs the branches on a thread pool; with ainvoke they run as concurrent async tasks.
The chain is invoked once:
result = parallel_chain.invoke({"topic": "Artificial Intelligence"})
with a single input:
{"topic": "Artificial Intelligence"}
That dictionary is handed to every branch. Conceptually:
Artificial Intelligence
↓
RunnableParallel
/ | \
↓ ↓ ↓
Facts Definition Use Case
↓ ↓ ↓
LLM LLM LLM
↓ ↓ ↓
Parser Parser Parser
\ | /
\ | /
Combined Result
What comes back
The result is a dictionary with one entry per sub-chain:
{
"facts": "1. Artificial Intelligence enables machines to perform tasks that normally require human intelligence.\n2. ...",
"definition": "Artificial Intelligence is the field of creating systems that can perform tasks requiring human-like intelligence.",
"use_case": "A real-world use case of AI is customer support chatbots that can understand and respond to user queries."
}
The text inside each field will differ from run to run, but the shape is stable, and that is what your code depends on:
result["facts"]
result["definition"]
result["use_case"]
Each key returns the output of the matching branch, so downstream code can pick exactly the piece it needs.
Mixing providers and merging the results
The second implementation shows two further ideas: different branches can use different providers, and a parallel step can itself be one stage of a larger sequential pipeline. Here one branch uses OpenAI, the other uses Anthropic, and a final OpenAI call merges both results into a single document:
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from dotenv import load_dotenv
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain.schema.runnable import RunnableParallel
load_dotenv()
model1 = ChatOpenAI()
model2 = ChatAnthropic(model_name='claude-3-7-sonnet-20250219')
prompt1 = PromptTemplate(
template='Generate short and simple notes from the following text \n {text}',
input_variables=['text']
)
prompt2 = PromptTemplate(
template='Generate 5 short question answers from the following text \n {text}',
input_variables=['text']
)
prompt3 = PromptTemplate(
template='Merge the provided notes and quiz into a single document \n notes -> {notes} and quiz -> {quiz}',
input_variables=['notes', 'quiz']
)
parser = StrOutputParser()
parallel_chain = RunnableParallel({
'notes': prompt1 | model1 | parser,
'quiz': prompt2 | model2 | parser
})
merge_chain = prompt3 | model1 | parser
chain = parallel_chain | merge_chain
text = """
Support vector machines (SVMs) are a set of supervised learning methods used for classification, regression and outliers detection.
The advantages of support vector machines are:
Effective in high dimensional spaces.
Still effective in cases where number of dimensions is greater than the number of samples.
Uses a subset of training points in the decision function (called support vectors), so it is also memory efficient.
Versatile: different Kernel functions can be specified for the decision function. Common kernels are provided, but it is also possible to specify custom kernels.
The disadvantages of support vector machines include:
If the number of features is much greater than the number of samples, avoid over-fitting in choosing Kernel functions and regularization term is crucial.
SVMs do not directly provide probability estimates, these are calculated using an expensive five-fold cross-validation (see Scores and probabilities, below).
The support vector machines in scikit-learn support both dense (numpy.ndarray and convertible to that by numpy.asarray) and sparse (any scipy.sparse) sample vectors as input. However, to use an SVM to make predictions for sparse data, it must have been fit on such data. For optimal performance, use C-ordered numpy.ndarray (dense) or scipy.sparse.csr_matrix (sparse) with dtype=float64.
"""
result = chain.invoke({'text': text})
print(result)
chain.get_graph().print_ascii()
The input is a passage about support vector machines. One branch writes study notes from it, the other writes a five-question quiz, and the merge chain combines the notes and quiz into one document.
Two details in this listing deserve a caveat. It imports RunnableParallel from langchain.schema.runnable, an older path from before the langchain_core split; current releases expose it from langchain_core.runnables, as in the Gemini example, and the legacy path may not exist in newer versions. It also pins claude-3-7-sonnet-20250219, a dated model identifier; providers retire old snapshots over time, so check the current Anthropic model list before reusing it.
Two model objects are created:
model1 = ChatOpenAI()
model2 = ChatAnthropic(model_name='claude-3-7-sonnet-20250219')
ChatOpenAI writes the notes and later performs the merge. ChatAnthropic writes the quiz. Nothing in RunnableParallel requires every branch to use the same model, which is handy when one provider is better or cheaper for a particular subtask.
The notes prompt turns the input text into short, simple notes:
prompt1 = PromptTemplate(
template='Generate short and simple notes from the following text \n {text}',
input_variables=['text']
)
The quiz prompt produces five short question-and-answer pairs from the same text:
prompt2 = PromptTemplate(
template='Generate 5 short question answers from the following text \n {text}',
input_variables=['text']
)
Neither task needs the other's output, so they are natural parallel branches.
The third prompt is different. It runs after the fan-out and needs both results:
prompt3 = PromptTemplate(
template='Merge the provided notes and quiz into a single document \n notes -> {notes} and quiz -> {quiz}',
input_variables=['notes', 'quiz']
)
It declares two input variables:
notes
quiz
Those names are not arbitrary. They must match the keys of the parallel step, because the dictionary produced by the fan-out is exactly what this prompt receives.
The parallel step defines the two branches:
parallel_chain = RunnableParallel({
'notes': prompt1 | model1 | parser,
'quiz': prompt2 | model2 | parser
})
Its shape is a two-way split that joins into a dictionary:
Input Text
↓
RunnableParallel
/ \
↓ ↓
Notes Chain Quiz Chain
↓ ↓
OpenAI Anthropic
↓ ↓
Parser Parser
\ /
↓ ↓
Dictionary
Conceptually the intermediate value looks like this:
{
"notes": "...",
"quiz": "..."
}
The merge chain is a regular simple chain:
merge_chain = prompt3 | model1 | parser
It consumes the dictionary from the parallel step, fills {notes} and {quiz}, and asks OpenAI to assemble one document.
Finally, the two stages are joined with another pipe:
chain = parallel_chain | merge_chain
The result is a hybrid workflow: parallel at the start, sequential at the end:
Input Text
↓
RunnableParallel
/ \
↓ ↓
Notes Quiz
↓ ↓
OpenAI Anthropic
↓ ↓
Parser Parser
\ /
↓ ↓
Combined Dictionary
↓
Merge Prompt
↓
OpenAI
↓
Parser
↓
Final Output
The key behavioral detail is that the two branches run independently, but the merge stage cannot begin until both have returned. The slowest branch therefore sets the pace for the whole fan-out. If one branch fails, the parallel step raises and the merge never runs, so wrap flaky providers with retries or fallbacks when this matters.
The whole pipeline is invoked with the text:
result = chain.invoke({'text': text})
The notes and quiz branches process the passage concurrently. When both finish, their outputs flow into the merge chain, and its string result is what invoke returns.
Printing the chain's structure
The graph helper visualizes the fan-out as well:
parallel_chain.get_graph().print_ascii()
For the three-branch Gemini chain, the diagram shows the split and the join:
Parallel<...>Input
/ | \
/ | \
PromptTemplate PromptTemplate PromptTemplate
↓ ↓ ↓
LLM LLM LLM
↓ ↓ ↓
StrOutputParser StrOutputParser StrOutputParser
\ | /
\ | /
Parallel<...>Output
The Parallel<...>Input and Parallel<...>Output nodes mark where the input is distributed and where the results are gathered back into a dictionary.
Comparing the two implementations
Both versions demonstrate the same idea: independent sub-chains running at the same time. The Gemini version is a pure fan-out with three branches on one model:
Input
↓
RunnableParallel
├── Facts → Gemini → Parser
├── Definition → Gemini → Parser
└── Use Case → Gemini → Parser
↓
Dictionary Output
The mixed-provider version splits into two branches on different providers and then adds a merge stage:
Input
↓
RunnableParallel
├── Notes → OpenAI → Parser
└── Quiz → Anthropic → Parser
↓
Dictionary Output
↓
Merge Chain
↓
OpenAI
↓
Output
Underneath both lies the same construct:
RunnableParallel({
"chain_1": chain_1,
"chain_2": chain_2,
"chain_3": chain_3
})
The keys name the sub-chains; the values are the sub-chains themselves. As a shortcut, when a plain dictionary of Runnables appears inside a pipe expression, LCEL converts it to a RunnableParallel automatically, so you will often see the dictionary written without the explicit class.
The full data path
In general form, a parallel chain distributes one input and gathers a dictionary:
Input
↓
RunnableParallel
/ | \
↓ ↓ ↓
Sub-chain Sub-chain Sub-chain
↓ ↓ ↓
Output Output Output
\ | /
\ | /
↓ ↓ ↓
Dictionary
For the Artificial Intelligence example on Gemini:
{"topic": "Artificial Intelligence"}
↓
RunnableParallel
/ | \
↓ ↓ ↓
Facts Definition Use Case
↓ ↓ ↓
Gemini Gemini Gemini
↓ ↓ ↓
Parser Parser Parser
\ | /
\ | /
Dictionary
The final value has one key per branch:
{
"facts": "...",
"definition": "...",
"use_case": "..."
}
That is the essential contrast with a sequential chain. In a sequential chain, each stage consumes the previous stage's output:
A → B → C → D
In a parallel chain, independent stages run side by side on the same input and their results are merged:
┌→ A ─┐
Input ─┼→ B ─┼→ Combined Output
└→ C ─┘
Use parallelism for work that shares inputs but not results, and keep sequential composition for work that genuinely builds on earlier steps. Mixing the two, as the notes-and-quiz example does, is common and perfectly idiomatic.
Conditional chains: routing input to the right sub-chain
A conditional chain picks its next step at run time. A sequential chain always executes every stage in a fixed order, and a parallel chain executes all branches together. A conditional chain evaluates a condition on the input and runs only the branch that matches:
Input
↓
Condition Check
↓
┌───────────────┐
│ │
Yes No
↓ ↓
Chain 1 Chain 2
↓ ↓
Output 1 Output 2
\ /
\ /
Final Output
In LangChain this is expressed with RunnableBranch. You give it a list of pairs, each pairing a condition with a Runnable, plus a default Runnable at the end. The conditions are tested in order, the first one that returns True decides which Runnable runs, and if none match, the default handles the input.
Example: route by text length
The example handles text differently depending on its length:
- If the text has more than 50 words, produce a short summary.
- Otherwise, expand on the idea in the text.
The resulting workflow:
Input Text
↓
Condition Check
↓
Is word count > 50?
↓
┌───────┴───────┐
Yes No
↓ ↓
Summary Extend
Chain Chain
↓ ↓
LLM LLM
↓ ↓
Parser Parser
\ /
\ /
Final Output
Two prompts back the branches: summary_prompt asks for a brief summary, and extend_prompt asks the model to build on the idea. A small function, is_long_text(), counts the words, and RunnableBranch uses its answer to choose the chain. As with the other patterns, there is a Gemini version and an OpenAI version.
In generic form, a two-way conditional pipeline looks like this:
User Input
↓
Condition Check
↓
┌───────┴───────┐
Yes No
↓ ↓
Prompt 1 Prompt 2
↓ ↓
LLM LLM
↓ ↓
Parser Parser
\ /
└──────┬───────┘
↓
Final Output
For any given input, exactly one branch runs.
Tracing a concrete input
The sample input is a single one-sentence definition of machine learning, describing it as a branch of AI in which statistical methods let computers learn from data.
That sentence is 22 words long, well below the 50-word threshold, so the condition returns False and the extend chain is chosen:
Input Text
↓
Condition Check
↓
22 > 50?
↓
No
↓
Extend Chain
↓
Gemini 2.5 Flash
↓
StrOutputParser
↓
Extended Text
Swap in a passage longer than 50 words and the condition flips to True, sending the input to the summary chain instead.
When conditional routing helps
Routing is useful whenever the same kind of input needs different treatment depending on some property:
- Length: condense long text, elaborate on short text.
- Sentiment: send positive and negative feedback to different response chains.
- Request type: direct each category of request to a specialized chain.
The important property is that you do not pay for branches you do not need. Only the selected path runs:
Input
↓
Condition
↓
Choose one branch
↓
Execute selected chain
↓
Output
That makes the workflow adaptive in a way a fixed sequence cannot be, and it saves model calls compared with running every branch and discarding the results.
The Gemini version
Here is the complete routing program on Gemini:
from langchain_google_genai import GoogleGenerativeAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableBranch
from dotenv import load_dotenv
load_dotenv()
llm = GoogleGenerativeAI(model="gemini-2.5-flash")
parser = StrOutputParser()
# Prompt 1 : Summary
summary_prompt = PromptTemplate(
input_variables=["text"],
template="Summarize the following text briefly:\n\n{text}"
)
# Prompt 2 : Extend
extend_prompt = PromptTemplate(
input_variables=["text"],
template="Extend the idea in the following long text:\n\n{text}"
)
# Chain 1 : Summary
summary_chain = summary_prompt | llm | parser
# Chain 2 : Extend
extend_chain = extend_prompt | llm | parser
def is_long_text(input: dict) -> bool:
return len(input["text"].split()) > 50
conditional_chain = RunnableBranch(
(lambda x: is_long_text(x), summary_chain),
extend_chain
)
text_input = {
"text": "Machine learning is a field of artificial intelligence that uses statistical techniques to give computer systems the ability to learn from data."
}
result = conditional_chain.invoke(text_input)
# print("\nOutput:\n")
# print(result)
conditional_chain.get_graph().print_ascii()
It defines two small chains, summary_chain and extend_chain, and a word-count predicate, then lets RunnableBranch pick between the chains based on that predicate.
Walking through the Gemini code
The model is Gemini 2.5 Flash once again:
llm = GoogleGenerativeAI(model="gemini-2.5-flash")
A StrOutputParser turns each response into a string:
parser = StrOutputParser()
The summary prompt asks for a brief summary:
summary_prompt = PromptTemplate(
input_variables=["text"],
template="Summarize the following text briefly:\n\n{text}"
)
The extend prompt is used on the other path:
extend_prompt = PromptTemplate(
input_variables=["text"],
template="Extend the idea in the following long text:\n\n{text}"
)
Look closely at its wording: it refers to "the following long text", yet this branch only runs for short inputs. The mismatch does not break anything, but it sends the model a misleading cue. Rewording it to something like "Expand on the idea in the following text" is a small fix worth making in your own version.
Each prompt is combined with the model and parser into its own chain:
summary_chain = summary_prompt | llm | parser
extend_chain = extend_prompt | llm | parser
That yields two independent paths:
Summary Prompt → Gemini → Parser
Extend Prompt → Gemini → Parser
The conditional step decides which one runs.
The condition is an ordinary Python function:
def is_long_text(input: dict) -> bool:
return len(input["text"].split()) > 50
It splits input["text"] on whitespace and counts the pieces. Its contract is simple:
True → if word count > 50
False → otherwise
Splitting on whitespace is a rough word count; punctuation stays attached to words and hyphenated terms count as one. That is fine for routing by length, but if you actually care about model limits, count tokens rather than words. Also note that the parameter name input shadows a Python built-in; it works, but a name like data is cleaner.
With the pieces in place, RunnableBranch assembles the router:
conditional_chain = RunnableBranch(
(lambda x: is_long_text(x), summary_chain),
extend_chain
)
The first argument is a tuple that reads as a rule:
If is_long_text(x) is True
↓
Execute summary_chain
The final argument, which is not wrapped in a tuple, is the default:
Otherwise
↓
Execute extend_chain
The lambda wrapper is not required. RunnableBranch accepts any callable as a condition, so passing is_long_text directly works just as well. Put together, the routing looks like this:
Input
↓
is_long_text()
/ \
True False
↓ ↓
summary_chain extend_chain
↓ ↓
Gemini Gemini
↓ ↓
Parser Parser
\ /
\ /
Output
Running it takes the input dictionary:
result = conditional_chain.invoke(text_input)
The condition is checked first, and only the chosen branch makes a model call.
What comes back
With the 22-word sample input, the predicate returns:
is_long_text() → False
so extend_chain handles the input. Conceptually:
Input Text
↓
Word Count Check
↓
False
↓
extend_chain
↓
Gemini 2.5 Flash
↓
StrOutputParser
↓
Extended Text
Feed it text longer than 50 words and the predicate returns:
is_long_text() → True
which changes the path to:
Input Text
↓
Word Count Check
↓
True
↓
summary_chain
↓
Gemini 2.5 Flash
↓
StrOutputParser
↓
Short Summary
One pipeline, two possible routes, decided by the input itself.
The same router on OpenAI
The OpenAI version keeps the routing logic and swaps only the model:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableBranch
from dotenv import load_dotenv
load_dotenv()
llm = ChatOpenAI(model="gpt-4o-mini")
parser = StrOutputParser()
# Prompt 1 : Summary
summary_prompt = PromptTemplate(
input_variables=["text"],
template="Summarize the following text briefly:\n\n{text}"
)
# Prompt 2 : Extend
extend_prompt = PromptTemplate(
input_variables=["text"],
template="Extend the idea in the following long text:\n\n{text}"
)
# Chain 1 : Summary
summary_chain = summary_prompt | llm | parser
# Chain 2 : Extend
extend_chain = extend_prompt | llm | parser
def is_long_text(input: dict) -> bool:
return len(input["text"].split()) > 50
conditional_chain = RunnableBranch(
(lambda x: is_long_text(x), summary_chain),
extend_chain
)
text_input = {
"text": "Machine learning is a field of artificial intelligence that uses statistical techniques to give computer systems the ability to learn from data."
}
result = conditional_chain.invoke(text_input)
print(result)
conditional_chain.get_graph().print_ascii()
Two chains, one predicate, one RunnableBranch, the same as with Gemini.
The model is gpt-4o-mini:
llm = ChatOpenAI(model="gpt-4o-mini")
The two prompt templates are unchanged, one for summarizing and one for extending:
summary_prompt = PromptTemplate(
input_variables=["text"],
template="Summarize the following text briefly:\n\n{text}"
)
extend_prompt = PromptTemplate(
input_variables=["text"],
template="Extend the idea in the following long text:\n\n{text}"
)
The chains are composed exactly as before:
summary_chain = summary_prompt | llm | parser
extend_chain = extend_prompt | llm | parser
Now both paths run through OpenAI:
Summary Prompt → OpenAI → StrOutputParser
Extend Prompt → OpenAI → StrOutputParser
The predicate is the same 50-word check:
def is_long_text(input: dict) -> bool:
return len(input["text"].split()) > 50
The router is built the same way:
conditional_chain = RunnableBranch(
(lambda x: is_long_text(x), summary_chain),
extend_chain
)
A True result sends the input to summary_chain; anything else goes to extend_chain.
The chain is invoked and the result printed:
result = conditional_chain.invoke(text_input)
print(result)
For the sample sentence, the predicate again evaluates to:
is_long_text() → False
so the extend chain runs and returns an expanded explanation of the input, similar to this:
Machine learning enables computer systems to learn patterns
from data and improve their performance without being explicitly
programmed for every task. It is widely used in applications
such as recommendation systems, image recognition, and predictive
analytics.
The exact text depends on the model and will change between runs.
Printing the chain's structure
The same graph helper works for routers:
conditional_chain.get_graph().print_ascii()
The notable element of the rendered diagram is the Branch node. It is the decision point where LangChain evaluates the conditions and hands the input to one of the candidate chains.
How the two branches differ
Each branch has its own responsibility. The summary branch:
summary_prompt
↓
Gemini
↓
StrOutputParser
↓
Short Summary
The extend branch:
extend_prompt
↓
Gemini
↓
StrOutputParser
↓
Extended Text
The predicate chooses between them:
Input
↓
is_long_text()
/ \
True False
↓ ↓
Summary Chain Extend Chain
This is the key contrast with a parallel chain: the branches do not both run. Only the path whose condition is met executes, which keeps cost and latency proportional to the work actually needed.
Routing on a model's own classification
Conditions are not limited to cheap checks such as word counts. A common and more powerful pattern is to let a first chain classify the input and then route on that classification. In a sentiment example, incoming feedback is labeled as one of two values:
positive
negative
and RunnableBranch forwards it to the response chain for that label:
Feedback
↓
Sentiment Classifier
↓
┌───────────────┐
│ │
Positive Negative
│ │
↓ ↓
Positive Negative
Response Response
│ │
└───────┬───────┘
↓
Output
A robust way to build the classifier is to have the model return structured output validated by a Pydantic model, for instance a field constrained to positive or negative. The branch conditions then check that field rather than parsing free-form text, which avoids surprises when a model answers "Positive." with a capital letter and a period. Always keep a sensible default branch, because a classifier will eventually return something you did not anticipate.
This shows that a condition can depend on information that an earlier chain produced, not only on the raw input. For more complex routing, the current LangChain documentation also describes routing with a custom function wrapped in RunnableLambda that returns the chain to run; it is worth checking which approach the docs recommend for your version.
The full data path
The general conditional pattern is:
Input
↓
Condition Check
↓
┌───────┴───────┐
↓ ↓
Condition Condition
True False
↓ ↓
Chain 1 Chain 2
↓ ↓
LLM LLM
↓ ↓
Parser Parser
\ /
\ /
└─────┬───────┘
↓
Output
For the text-length example:
Machine Learning Text
↓
is_long_text()
↓
Word Count > 50?
/ \
Yes No
↓ ↓
Summary Extend
Chain Chain
↓ ↓
Gemini Gemini
↓ ↓
Parser Parser
\ /
\ /
Output
Short inputs take one route:
Word Count ≤ 50
↓
Extend Chain
Long inputs take the other:
Word Count > 50
↓
Summary Chain
Stepping back, here are the four shapes side by side:
Simple Chain
A → B → C
Sequential Chain
A → B → C → D → E
Parallel Chain
┌→ B ─┐
A ────┼→ C ─┼→ Output
└→ D ─┘
Conditional Chain
↓
A
↓
Condition
/ \
B C
A conditional chain gives an LLM workflow the ability to make decisions, running different logic depending on the input or on a value computed earlier in the pipeline.
Practical notes before you ship
A few recurring issues are worth keeping in mind as you move from examples to real code:
- Pin model names. Constructors without a model argument inherit whatever default the installed integration uses, and dated identifiers eventually get retired.
- Keep the parser. Chat models return message objects;
StrOutputParsergives every downstream step a predictablestr. - Watch the hand-offs. A bare string only fills a prompt automatically when that prompt has exactly one variable. For more, build the dictionary explicitly.
- Match keys to variables. In a fan-out followed by a merge, the parallel keys must match the merge prompt's variable names.
- Plan for failure. A failing branch in a parallel step fails the whole step; add retries or fallbacks for unreliable calls.
- Use modern imports. Take Runnables from
langchain_core.runnables, not legacylangchain.schemapaths. - Visualize.
get_graph().print_ascii()(withgrandalfinstalled) is a cheap way to confirm a complex chain is wired as intended.
For a broader tour that puts these chains alongside retrieval, tools and agents running on local models, see LangChain 1.x in practice: chains, RAG, tools and agents locally.
Key takeaways
- A chain is a declared pipeline in which each component's output feeds the next; the
|operator builds it from any Runnables. - Simple chains are one straight pass; sequential chains stack several passes, adding latency with every stage.
RunnableParallelruns independent sub-chains concurrently and returns a dictionary keyed by branch name, which later stages can consume by variable name.RunnableBranchevaluates conditions in order and runs only the first matching branch, falling back to a default.- Conditions can be cheap checks on the input or decisions based on an earlier model's structured classification.
- Because the model is just one component, switching between Gemini, OpenAI or Anthropic usually touches a single line, while prompts, parsers and structure stay the same.
All four patterns rest on the same foundation, the Runnable interface, which is what makes prompts, models, parsers and your own functions composable in the first place. Understanding that interface directly is the natural next step once these shapes feel familiar.