LangChain Fundamentals: Chains, Models & Prompts
Build your first LangChain pipeline: ChatOpenAI, PromptTemplate, LCEL chains, output parsers, and LangChain Expression Language. Lesson 4 of the LLM Engineering & RAG course.

LangChain is a framework for building applications powered by language models. Where the OpenAI SDK gives you a single API call, LangChain gives you composable building blocks — models, prompts, chains, memory, retrievers, and tools — that you can wire together into pipelines. This lesson covers the core components and LangChain Expression Language (LCEL), the modern syntax for constructing chains.
Previous: Lesson 3 — Prompt Engineering: Techniques & Best Practices
Installation and Setup
pip install langchain langchain-openai langchain-core python-dotenv# .env
OPENAI_API_KEY=sk-proj-...# verify the install
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
response = llm.invoke("What is LangChain in one sentence?")
print(response.content)LangChain reads OPENAI_API_KEY from the environment automatically via the langchain-openai integration package.
The ChatOpenAI Model
ChatOpenAI is LangChain's wrapper around the OpenAI chat completions API. It accepts the same parameters as the raw SDK but returns LangChain message objects:
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0.2,
max_tokens=500
)
# Invoke with a list of messages
messages = [
SystemMessage(content="You are a concise technical writer."),
HumanMessage(content="Explain what a context window is.")
]
response = llm.invoke(messages)
print(response.content)
print(f"Model: {response.response_metadata['model_name']}")
print(f"Tokens: {response.response_metadata['token_usage']}")Streaming with ChatOpenAI
for chunk in llm.stream("List five benefits of using LangChain."):
print(chunk.content, end="", flush=True)
print()Prompt Templates
Rather than constructing prompts by hand with f-strings, ChatPromptTemplate provides typed, reusable prompt definitions:
from langchain_core.prompts import ChatPromptTemplate
# Define a reusable template
code_review_prompt = ChatPromptTemplate.from_messages([
("system", "You are a {language} code reviewer. Focus on {focus}."),
("human", "Review this code:\n\n```{language}\n{code}\n```")
])
# Inspect the input variables
print(code_review_prompt.input_variables)
# → ['language', 'focus', 'code']
# Format the template
messages = code_review_prompt.format_messages(
language="python",
focus="security and error handling",
code="""
def get_user(user_id):
query = f"SELECT * FROM users WHERE id = {user_id}"
return db.execute(query)
"""
)PromptTemplate for Single-String Prompts
For non-chat models or simple string construction:
from langchain_core.prompts import PromptTemplate
summarise_prompt = PromptTemplate.from_template(
"Summarise the following text in {num_sentences} sentences:\n\n{text}"
)
formatted = summarise_prompt.format(
num_sentences=3,
text="LangChain is a framework for developing applications powered by large language models..."
)LangChain Expression Language (LCEL)
LCEL is the modern, composable syntax for building LangChain chains. It uses the | pipe operator to connect components, similar to Unix pipes:
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
# Define components
prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant."),
("human", "{question}")
])
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
parser = StrOutputParser()
# Compose the chain
chain = prompt | llm | parser
# Invoke the chain
response = chain.invoke({"question": "What is RAG in LLM applications?"})
print(response) # clean string, no LangChain message wrapperWhy LCEL Over Legacy Chains
LCEL chains are:
- Streaming by default — call
.stream()on any chain without extra configuration - Async-ready — call
.ainvoke()and.astream()without modification - Composable — chains themselves are valid LCEL runnables, so they can be nested
- Observable — automatic LangSmith tracing integration
# Streaming a chain
for chunk in chain.stream({"question": "Explain embeddings step by step."}):
print(chunk, end="", flush=True)
print()
# Async invocation
import asyncio
async def main():
result = await chain.ainvoke({"question": "What is a vector database?"})
print(result)
asyncio.run(main())Output Parsers
Output parsers transform the raw model response into a structured Python object. The most common parsers:
StrOutputParser
Extracts the string content from a AIMessage:
from langchain_core.output_parsers import StrOutputParser
chain = prompt | llm | StrOutputParser()
result = chain.invoke({"question": "..."})
# result is a plain strJsonOutputParser
Parses the model's output as JSON:
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate
json_prompt = ChatPromptTemplate.from_messages([
("system", "You extract structured data. Always respond with valid JSON only."),
("human", "Extract the name, role, and company from: {text}")
])
chain = json_prompt | ChatOpenAI(model="gpt-4o-mini", temperature=0) | JsonOutputParser()
result = chain.invoke({
"text": "Hi, I'm Sarah Chen, a senior engineer at Stripe."
})
print(result) # → {"name": "Sarah Chen", "role": "senior engineer", "company": "Stripe"}PydanticOutputParser
Combines schema validation with parsing — the most robust option for structured extraction:
from langchain_core.output_parsers import PydanticOutputParser
from pydantic import BaseModel, Field
from typing import List
class TechStack(BaseModel):
languages: List[str] = Field(description="Programming languages mentioned")
frameworks: List[str] = Field(description="Frameworks and libraries mentioned")
databases: List[str] = Field(description="Databases mentioned")
cloud: List[str] = Field(description="Cloud services mentioned")
parser = PydanticOutputParser(pydantic_object=TechStack)
tech_prompt = ChatPromptTemplate.from_messages([
("system", "Extract technology stack information. {format_instructions}"),
("human", "{job_description}")
]).partial(format_instructions=parser.get_format_instructions())
chain = tech_prompt | ChatOpenAI(model="gpt-4o-mini", temperature=0) | parser
stack = chain.invoke({
"job_description": "We use Python, FastAPI, PostgreSQL, Redis, and deploy on AWS ECS."
})
print(stack.languages) # → ['Python']
print(stack.frameworks) # → ['FastAPI']
print(stack.databases) # → ['PostgreSQL', 'Redis']
print(stack.cloud) # → ['AWS ECS']Building a Reusable Chain Module
A well-structured pattern for production use:
# chains/summariser.py
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
from functools import lru_cache
SUMMARISE_PROMPT = ChatPromptTemplate.from_messages([
("system", (
"You are a technical writer. Summarise the following text in {style} style. "
"Length: {length}. Focus on technical accuracy."
)),
("human", "{text}")
])
@lru_cache(maxsize=1)
def get_llm(model: str = "gpt-4o-mini") -> ChatOpenAI:
return ChatOpenAI(model=model, temperature=0.2)
def build_summarise_chain(model: str = "gpt-4o-mini"):
return SUMMARISE_PROMPT | get_llm(model) | StrOutputParser()
# Usage
chain = build_summarise_chain()
summary = chain.invoke({
"text": open("technical_document.txt").read(),
"style": "bullet-point",
"length": "5 bullets"
})Chaining Multiple Steps
LCEL supports multi-step pipelines by passing the output of one chain as input to another:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)
parser = StrOutputParser()
# Step 1: Generate a first draft
draft_prompt = ChatPromptTemplate.from_template(
"Write a short technical explanation of {topic} for a developer audience."
)
# Step 2: Improve the draft
improve_prompt = ChatPromptTemplate.from_template(
"Improve this technical explanation. Make it more concise and add a code example:\n\n{draft}"
)
# Build the two-step chain
draft_chain = draft_prompt | llm | parser
improve_chain = improve_prompt | llm | parser
# Connect them: output of draft_chain flows into improve_chain
full_chain = draft_chain | (lambda draft: {"draft": draft}) | improve_chain
result = full_chain.invoke({"topic": "Python decorators"})
print(result)RunnableParallel for Concurrent Execution
Run multiple chains in parallel and merge results:
from langchain_core.runnables import RunnableParallel
analysis_chain = RunnableParallel(
summary = draft_prompt | llm | parser,
key_concepts = ChatPromptTemplate.from_template(
"List the 3 most important concepts in: {topic}"
) | llm | parser,
difficulty = ChatPromptTemplate.from_template(
"Rate the difficulty of {topic} as: beginner/intermediate/advanced. One word only."
) | llm | parser
)
result = analysis_chain.invoke({"topic": "transformer self-attention"})
print(result["summary"])
print(result["key_concepts"])
print(result["difficulty"])RunnablePassthrough and RunnableLambda
Two utilities that fill common gaps in LCEL pipelines:
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
# RunnablePassthrough — passes input unchanged (useful for preserving original input)
chain_with_passthrough = RunnableParallel(
original = RunnablePassthrough(),
summary = draft_prompt | llm | parser
)
result = chain_with_passthrough.invoke({"topic": "embeddings"})
print(result["original"]) # → {"topic": "embeddings"}
print(result["summary"]) # → the generated summary
# RunnableLambda — wraps a Python function as a chain step
def add_word_count(text: str) -> str:
words = len(text.split())
return f"{text}\n\n[Word count: {words}]"
chain_with_count = draft_prompt | llm | parser | RunnableLambda(add_word_count)
result = chain_with_count.invoke({"topic": "cosine similarity"})
print(result) # includes word count at end