AI Systemsintermediate
Sequential Chains: Chaining Multiple Steps
Build multi-step LangChain pipelines where outputs feed into next steps. RunnableSequence, RunnablePassthrough.assign, and patterns for complex sequential workflows.
Asma Hafeez KhanMay 16, 20265 min read
LangChainSequential ChainLCELMulti-StepPipeline
Sequential Chains in LCEL
Sequential chains pass the output of one step as the input to the next. With LCEL, this is expressed with the | pipe operator:
Python
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
model = ChatOpenAI(model="gpt-4o", temperature=0)
parser = StrOutputParser()
# Step 1: Extract drug class
classify_prompt = ChatPromptTemplate.from_template(
"What drug class is {drug}? Reply with just the class name."
)
# Step 2: Explain the class mechanism
explain_prompt = ChatPromptTemplate.from_template(
"Explain the mechanism of {drug_class} drugs in 2 sentences."
)
# Sequential chain: output of step 1 becomes input for step 2
classify_chain = classify_prompt | model | parser
explain_chain = explain_prompt | model | parser
# Problem: classify_chain outputs a string but explain_chain expects {"drug_class": ...}
# Solution: wrap with RunnablePassthrough
from langchain_core.runnables import RunnablePassthrough
sequential_chain = (
classify_chain
| (lambda drug_class: {"drug_class": drug_class}) # Reshape output ā dict
| explain_chain
)
result = sequential_chain.invoke({"drug": "warfarin"})
print(result) # "Anticoagulant drugs work by..."RunnablePassthrough.assign: The Recommended Pattern
RunnablePassthrough.assign() adds new keys to the input dict, passing all existing keys through:
Python
# Pattern: progressively enrich the context dict through each step
chain = (
# Start: {"drug": "warfarin"}
RunnablePassthrough.assign(
drug_class=(
ChatPromptTemplate.from_template("What drug class is {drug}? One word.")
| model | parser
)
)
# Now: {"drug": "warfarin", "drug_class": "Anticoagulant"}
| RunnablePassthrough.assign(
mechanism=(
ChatPromptTemplate.from_template("Explain {drug_class} mechanism in one sentence.")
| model | parser
)
)
# Now: {"drug": "warfarin", "drug_class": "...", "mechanism": "..."}
| RunnablePassthrough.assign(
monitoring=(
ChatPromptTemplate.from_template(
"For {drug} ({drug_class}): what lab values need monitoring?"
)
| model | parser
)
)
# Now has: drug, drug_class, mechanism, monitoring
)
result = chain.invoke({"drug": "warfarin"})
# result is a dict with all four keys
print(result["drug"])
print(result["drug_class"])
print(result["mechanism"])
print(result["monitoring"])Real-World Sequential Chain: Clinical Report Generator
Python
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
# Generate a full clinical drug monograph in stages
# Stage 1: Drug classification and basic info
basic_info_prompt = ChatPromptTemplate.from_messages([
("system", "You are a clinical pharmacist."),
("human", "For {drug}, provide: therapeutic class, mechanism of action in 2 sentences."),
])
# Stage 2: Dosing information
dosing_prompt = ChatPromptTemplate.from_template(
"For {drug} (a {drug_class}):\n"
"Provide standard adult dosing for the most common indication. "
"Include dose, frequency, and route."
)
# Stage 3: Safety profile
safety_prompt = ChatPromptTemplate.from_template(
"For {drug}:\n"
"- Top 3 adverse effects\n"
"- Major contraindications\n"
"- Most important drug interaction"
)
# Stage 4: Compile final report
compile_prompt = ChatPromptTemplate.from_template(
"""Compile this information into a brief clinical monograph:
Drug: {drug}
Basic Info: {basic_info}
Dosing: {dosing}
Safety: {safety}
Format as a professional clinical summary."""
)
def extract_drug_class(text: str) -> str:
"""Extract the drug class from basic_info text."""
# Simple extraction ā in production use structured output
lines = text.lower()
for drug_class in ["anticoagulant", "biguanide", "beta-blocker", "ace inhibitor", "statin"]:
if drug_class in lines:
return drug_class.title()
return "Unclassified"
clinical_report_chain = (
# Stage 1
RunnablePassthrough.assign(
basic_info=basic_info_prompt | model | parser
)
# Extract class from basic_info
| RunnablePassthrough.assign(
drug_class=RunnableLambda(lambda d: extract_drug_class(d["basic_info"]))
)
# Stage 2: Dosing (has access to drug and drug_class)
| RunnablePassthrough.assign(
dosing=dosing_prompt | model | parser
)
# Stage 3: Safety
| RunnablePassthrough.assign(
safety=safety_prompt | model | parser
)
# Stage 4: Compile
| compile_prompt | model | parser
)
report = clinical_report_chain.invoke({"drug": "warfarin"})
print(report)Controlling What's Passed Forward
Sometimes you want to drop intermediate keys to keep the context clean:
Python
from operator import itemgetter
from langchain_core.runnables import RunnableLambda
# itemgetter: pick specific keys from dict
select_keys = RunnableLambda(lambda d: {
"drug": d["drug"],
"final_answer": d["answer"],
})
# Or use itemgetter for a single key
get_answer = itemgetter("answer")
chain = (
RunnablePassthrough.assign(
intermediate=some_chain
)
| RunnablePassthrough.assign(
answer=final_chain
)
| RunnableLambda(lambda d: {"drug": d["drug"], "answer": d["answer"]})
# Drop "intermediate" ā only keep what matters
)Error Handling in Sequential Chains
Python
from langchain_core.runnables import RunnableLambda
def safe_drug_lookup(inputs: dict) -> dict:
"""Safely retrieve drug info with error handling."""
drug = inputs.get("drug", "").strip()
if not drug:
return {"drug": drug, "error": "Empty drug name"}
# Validate drug name is reasonable
if len(drug) > 100 or not drug.replace(" ", "").isalpha():
return {"drug": drug, "error": "Invalid drug name format"}
return {"drug": drug}
safe_chain = (
RunnableLambda(safe_drug_lookup)
| RunnableLambda(lambda d: d if "error" not in d else {"drug": d["drug"], "basic_info": f"Error: {d['error']}"})
| RunnablePassthrough.assign(
# Only runs basic_info chain if "basic_info" not already set (no error)
basic_info=RunnableLambda(
lambda d: (basic_info_prompt | model | parser).invoke(d)
if "basic_info" not in d
else d["basic_info"]
)
)
)Sequential vs Parallel: When to Use Each
Python
from langchain_core.runnables import RunnableParallel
# Sequential: when step B needs output from step A
sequential = (
RunnablePassthrough.assign(drug_class=classify_chain)
| RunnablePassthrough.assign(mechanism=explain_chain) # Needs drug_class
)
# Parallel: when steps are independent
parallel = RunnableParallel(
mechanism=mechanism_chain, # Independent
side_effects=side_effects_chain, # Independent
interactions=interactions_chain, # Independent
)
# Hybrid: some parallel, some sequential
hybrid = (
# Parallel stage 1
RunnablePassthrough.assign(
**{key: chain for key, chain in parallel_stage_1.items()}
)
# Sequential stage 2 (needs results from stage 1)
| RunnablePassthrough.assign(
final_summary=summary_chain
)
)Performance: Minimizing LLM Calls
Python
# Bad: 3 separate LLM calls for info that could come from 1
bad_chain = (
RunnablePassthrough.assign(drug_class=classify_chain)
| RunnablePassthrough.assign(mechanism=explain_chain)
| RunnablePassthrough.assign(monitoring=monitoring_chain)
)
# Better: one structured LLM call for all three
from pydantic import BaseModel, Field
from langchain_core.output_parsers import PydanticOutputParser
class DrugBasics(BaseModel):
drug_class: str
mechanism: str = Field(description="One sentence mechanism")
monitoring: list[str] = Field(description="Key monitoring parameters")
pydantic_parser = PydanticOutputParser(pydantic_object=DrugBasics)
combined_prompt = ChatPromptTemplate.from_messages([
("system", "Extract drug information. {format_instructions}"),
("human", "Drug: {drug}"),
]).partial(format_instructions=pydantic_parser.get_format_instructions())
efficient_chain = combined_prompt | model | pydantic_parser
basics = efficient_chain.invoke({"drug": "warfarin"})
# One LLM call ā drug_class, mechanism, monitoring all at onceFound this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.