AI EngineeringLLM

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.

TT
James Carter
7 min read
LangChain Fundamentals: Chains, Models & Prompts

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

bash
pip install langchain langchain-openai langchain-core python-dotenv
python
# .env
OPENAI_API_KEY=sk-proj-...
python
# 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:

python
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

python
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:

python
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:

python
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:

python
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 wrapper

Why 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
python
# 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:

python
from langchain_core.output_parsers import StrOutputParser

chain = prompt | llm | StrOutputParser()
result = chain.invoke({"question": "..."})
# result is a plain str

JsonOutputParser

Parses the model's output as JSON:

python
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:

python
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:

python
# 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:

python
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:

python
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:

python
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