All Posts

LangChain 101: Chains, Prompts, and LLM Integration

Build your first LangChain pipeline: LCEL, ChatPromptTemplate, and LLM-agnostic model swapping from OpenAI to Anthropic to Ollama.

Abstract AlgorithmsAbstract Algorithms
Β·Β·22 min read
Cover Image for LangChain 101: Chains, Prompts, and LLM Integration
Share
Share on X / Twitter
Share on LinkedIn
Copy link

TLDR: LangChain's LCEL pipe operator (|) wires prompts, models, and output parsers into composable chains β€” swap OpenAI for Anthropic or Ollama by changing one line without touching the rest of your code.


πŸ“– One LLM API Today, Rewrite Tomorrow: The Customer Support Bot Problem

Imagine you are three days into building a customer support bot. You hardcoded the OpenAI API, hand-rolled the request payload, parsed the JSON response yourself, and added a retry loop for rate limits. It works. Then your manager asks you to benchmark GPT-4o against Claude 3.5 Sonnet. You look at your code and realize you have to rip out the entire HTTP layer, remap the message schema, handle Anthropic's different error codes, and update every test. For a model swap.

This is the first real pain of building with LLMs directly: model-specific boilerplate owns your application logic.

It gets worse. You want to chain two prompts together β€” first extract the customer's intent, then generate a reply. With raw API calls, you are manually passing outputs from one call as inputs to the next. You want to stream the response token-by-token to the UI. Another custom implementation. You want to log every request for debugging. Another wrapper. Six weeks in, your bot is mostly plumbing.

LangChain was built to eliminate this plumbing. It provides a unified interface over every major LLM provider, a composable expression language for wiring steps together, and a rich ecosystem of integrations so you can focus on what your chain does rather than how it calls an API.

The shift in thinking is from "I am calling an OpenAI endpoint" to "I have a pipeline that takes a prompt, runs it through a model, and produces structured output β€” and I can swap any piece of that pipeline without rewiring the rest."


πŸ” LangChain's Core Primitives: Prompts, Models, and Parsers

Every LangChain pipeline β€” no matter how complex β€” is built from three building blocks. Understanding these three objects is 80% of what you need to be productive with LangChain.

PrimitiveClassWhat It Does
PromptChatPromptTemplateFormats your text template into structured messages a chat model expects
ModelChatOpenAI, ChatAnthropic, ChatOllamaSends the formatted messages to an LLM and returns a raw response object
ParserStrOutputParser, JsonOutputParserExtracts the useful output from the model's response object

ChatPromptTemplate and the {variable} Syntax

A ChatPromptTemplate is a reusable message template with named placeholders. You define it once with {curly-brace} variables, then call .invoke({"variable": "value"}) to fill them in at runtime.

The most flexible pattern is from_messages([...]), which lets you compose a list of roles explicitly:

from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful assistant that explains topics clearly."),
    ("human", "Explain {topic} in simple terms for a {audience} audience."),
])

# Fill in the variables
filled = prompt.invoke({"topic": "neural networks", "audience": "high school student"})
print(filled.messages)
# [SystemMessage(content='You are a helpful assistant...'),
#  HumanMessage(content='Explain neural networks in simple terms for a high school student audience.')]

The from_messages list accepts tuples of ("role", "content") or full message objects. The roles map directly to how the chat model interprets context.

Message Types: HumanMessage, AIMessage, SystemMessage

Chat models do not receive a single blob of text β€” they receive a conversation thread made of typed messages. Each message type signals a different speaker:

  • SystemMessage β€” Background instructions given to the model before the conversation starts. Use it to establish persona, output format rules, or constraints. The model treats this as meta-instructions rather than a turn in the conversation.
  • HumanMessage β€” A message from the user. This is the input the model is expected to respond to.
  • AIMessage β€” A previous response from the model. You include these when replaying conversation history so the model knows what it has already said.

When you write ("system", "...") inside from_messages, LangChain wraps it in a SystemMessage automatically. You can also construct them explicitly:

from langchain_core.messages import SystemMessage, HumanMessage, AIMessage

messages = [
    SystemMessage(content="You are a concise technical writer."),
    HumanMessage(content="What is a transformer model?"),
    AIMessage(content="A transformer is a neural network that uses attention mechanisms..."),
    HumanMessage(content="Can you give a one-sentence summary?"),
]

This is how you replay a multi-turn conversation to a model β€” you pass the full history so it has context for the latest turn.

Output Parsers: From Raw Response to Usable Data

When a model responds, it returns a BaseMessage object, not a plain string. An output parser is the final step that extracts what you actually want:

  • StrOutputParser β€” Pulls out the text content as a plain Python str. Use this for 99% of cases where you want a human-readable answer.
  • JsonOutputParser β€” Parses the model's response as JSON. Useful when you instruct the model to return structured data and need a Python dict or list directly.
from langchain_core.output_parsers import StrOutputParser, JsonOutputParser

# StrOutputParser: turn the AIMessage into a plain string
parser = StrOutputParser()
text = parser.invoke(ai_message)  # Returns: "A transformer is a neural network..."

# JsonOutputParser: parse structured output
json_parser = JsonOutputParser()
# Model must be prompted to return JSON β€” the parser handles the extraction

Without a parser at the end of your chain, you get back the raw model response object. With a parser, you get clean Python data ready for your application.


βš™οΈ LCEL: How the Pipe Operator Assembles a Chain

LCEL (LangChain Expression Language) is the modern way to compose LangChain components. Its key innovation is a single operator: the pipe (|).

You have already seen all three pieces β€” prompt, model, and parser. LCEL lets you connect them left to right:

chain = prompt | llm | parser

That one line creates a fully functional pipeline. When you call chain.invoke({"topic": "...", "audience": "..."}), here is what happens step by step:

  1. prompt receives your input dictionary, fills in the {variables}, and produces a list of formatted chat messages.
  2. | passes those messages to llm, which sends them to the provider's API and returns an AIMessage.
  3. | passes that AIMessage to parser, which strips out the text content and returns a plain str.
  4. The str is returned to your application.

This is not just syntactic sugar. LCEL components implement a common Runnable protocol, which means every component in the pipe supports the same interface: .invoke() for single calls, .stream() for token-by-token streaming, .batch() for parallel requests, and .ainvoke() for async. You get all of this for free β€” no custom streaming wrappers, no manual batching logic.

Why LCEL Replaced the Old LLMChain

LangChain's original LLMChain class bundled the prompt and model together in a single object and required you to learn a chain-specific API. LCEL replaced it with plain Python composition. Every component is independently testable, swappable, and inspectable. If you have seen tutorials that use LLMChain(llm=..., prompt=...), that is the legacy approach β€” use LCEL for all new code.

Here is a minimal working chain using LCEL, wired up with OpenAI:

# pip install langchain langchain-openai
import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

os.environ["OPENAI_API_KEY"] = "your-openai-api-key"

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful technical explainer."),
    ("human", "Explain {topic} in one paragraph for a {level} audience."),
])
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
parser = StrOutputParser()

chain = prompt | llm | parser

result = chain.invoke({"topic": "REST APIs", "level": "beginner"})
print(result)

Install everything you need for this post in one command:

pip install langchain langchain-openai langchain-anthropic langchain-ollama

πŸ“Š Visualizing the LCEL Data Flow

The pipe operator is easy to read left-to-right, but it helps to see what data type flows between each step. Here is the complete transformation:

graph TD
    A["Input Dictionary
{topic: '...', level: '...'}"] --> B["ChatPromptTemplate
(fills variables)"] B --> C["List of ChatMessages
[SystemMessage, HumanMessage]"] C --> D["ChatOpenAI / ChatAnthropic / ChatOllama
(calls LLM API)"] D --> E["AIMessage
(raw model response object)"] E --> F["StrOutputParser / JsonOutputParser
(extracts content)"] F --> G["Plain str or dict
(your application data)"] style A fill:#e8f4f8,stroke:#2196F3 style D fill:#fff3e0,stroke:#FF9800 style G fill:#e8f5e9,stroke:#4CAF50

Notice that each arrow represents a type transformation: a dict becomes a list of messages, messages become an AIMessage, and AIMessage becomes a str. This is why the Runnable protocol is powerful β€” each component only needs to know its input and output type, not anything about its neighbors.


🌍 Real-World Applications: LLM-Agnostic Deployment from Prototype to Production

The single biggest benefit of LCEL is that swapping a model is a one-line change. The prompt, the parser, and all your business logic stay identical. Only the object assigned to llm changes.

Here is the same explain_topic function running against three different providers without a single structural change:

from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from langchain_ollama import ChatOllama
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a concise technical explainer. Keep answers to 3 sentences max."),
    ("human", "Explain {topic} to a {level} developer."),
])
parser = StrOutputParser()

def explain_topic(topic: str, level: str, llm) -> str:
    chain = prompt | llm | parser
    return chain.invoke({"topic": topic, "level": level})

# ── OpenAI ──────────────────────────────────────────────────────
openai_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
print("OpenAI:", explain_topic("LCEL", "beginner", openai_llm))

# ── Anthropic ───────────────────────────────────────────────────
anthropic_llm = ChatAnthropic(model="claude-3-5-haiku-20241022", temperature=0)
print("Anthropic:", explain_topic("LCEL", "beginner", anthropic_llm))

# ── Ollama (local β€” no API key required) ────────────────────────
# Run: ollama pull llama3.2 first
ollama_llm = ChatOllama(model="llama3.2", temperature=0)
print("Ollama:", explain_topic("LCEL", "beginner", ollama_llm))

All three produce a well-formed plain-text answer. The prompt never knew which model it was feeding, and the StrOutputParser never knew which provider's response it was parsing.

ProviderClassAPI Key RequiredUse Case
OpenAIChatOpenAIβœ… YesCloud, best general capability
AnthropicChatAnthropicβœ… YesCloud, long-context and safety-focused tasks
OllamaChatOllama❌ NoLocal models, offline/private environments

This pattern becomes essential as soon as you have to work across environments β€” development uses a local Ollama model for free, staging uses a cheap GPT-4o-mini, and production uses Claude for compliance reasons. The application code is unchanged.


🧠 Deep Dive: How LCEL's Runnable Protocol Wires Components Together

Every component you have used β€” ChatPromptTemplate, ChatOpenAI, StrOutputParser β€” implements LangChain's Runnable protocol. This single interface contract is what makes the | pipe operator work. A Runnable exposes four methods, and you get all of them for free on every LCEL chain:

MethodBehaviourWhen to Reach For It
.invoke(input)Single synchronous callDefault starting point; scripts and notebooks
.stream(input)Yields output tokens progressivelyStreaming to a UI character-by-character
.batch([inputs])Processes a list of inputs in parallelEvaluations, bulk processing
.ainvoke(input)Async version of .invoke()async def request handlers (FastAPI, etc.)

When you call chain.invoke({"topic": "..."}), LangChain walks the pipe left to right β€” it calls .invoke() on the prompt, passes the result to .invoke() on the LLM, and passes that to .invoke() on the parser. The same walk applies to .stream(), meaning streaming is built into every LCEL chain without any extra plumbing:

# Token-by-token streaming β€” no custom iterator needed
for chunk in chain.stream({"topic": "LCEL", "level": "beginner"}):
    print(chunk, end="", flush=True)

This uniformity also means you can inject custom Python logic anywhere in the chain using RunnableLambda(fn), or add pass-through context using RunnablePassthrough. Every piece speaks the same Runnable language.


βš–οΈ Trade-offs and Failure Modes: What LangChain Does Not Fix

LangChain's abstractions deliver genuine value, but they introduce their own costs. Knowing both sides saves you from unpleasant surprises in production.

Where LangChain helps most:

  • Provider portability β€” Swap models without rewriting chains. Benchmarking GPT-4o against Claude 3.5 Sonnet goes from days of work to minutes.
  • Streaming and batching for free β€” .stream() and .batch() work on every chain without a custom implementation.
  • Ecosystem depth β€” Pre-built integrations for dozens of vector stores, document loaders, retrievers, and tools let you extend chains without reinventing wheels.

Where it can hurt:

  • Version instability β€” LangChain has refactored its import paths and class names multiple times. Pin versions precisely in requirements.txt. langchain==0.3.x behaves differently from 0.2.x.
  • Abstraction leaks β€” When a provider API changes an error schema, the failure surfaces deep inside LangChain internals. You need to know both your code and LangChain's source to debug it.
  • Overkill for simple cases β€” If you make a single API call with no composability requirement, using langchain-openai adds weight with no payoff. Use the provider SDK directly.

Most common new-user failure: Importing from the wrong subpackage. Since v0.2, LangChain split into packages: langchain-core (base classes and LCEL), langchain-openai / langchain-anthropic / langchain-ollama (model objects), and langchain (higher-level chains and agents). Install only what you need.


🧭 Decision Guide: LangChain vs. Direct API Calls

SituationRecommendation
Use LangChain whenYou need provider portability, multi-step composable chains, built-in streaming, or plan to add retrieval, tools, or agents later
Skip LangChain whenYou have a single-provider, single one-shot call where the package overhead adds no value
Better alternativeUse the provider SDK directly (openai, anthropic) for simple completions; add LangChain when composability becomes a real need
Natural upgrade pathLCEL chains β†’ add retrieval (LangChain retrievers) β†’ add state and branching (LangGraph)

πŸ› οΈ LangChain's Open-Source Architecture: Installation, LCEL, and the Runnable Protocol

LangChain (github.com/langchain-ai/langchain) is an open-source Python and TypeScript framework maintained by LangChain, Inc. It is built around a layered package structure that keeps the core abstractions separate from provider-specific integrations:

  • langchain-core β€” The foundational Runnable protocol, base classes for prompts, models, parsers, and LCEL. This package is the only dependency shared by everything. Installed automatically when you install langchain.
  • langchain β€” Higher-level chains, agents, and utility tooling built on top of langchain-core.
  • langchain-openai, langchain-anthropic, langchain-ollama β€” Thin provider integration packages. Each one implements the same BaseChatModel interface so every model is drop-in compatible with LCEL.
  • langchain-community β€” Community-maintained integrations for hundreds of additional tools, vector stores, and retrievers.

The Runnable protocol is the design contract that makes LCEL work. Any class that implements .invoke(), .stream(), .batch(), and .ainvoke() is a Runnable and can be composed with |. This includes prompts, models, parsers, retrieval steps, custom Python functions (wrapped with RunnableLambda), and even entire sub-chains.

Installation for this post:

# Core framework
pip install langchain langchain-core

# Provider integrations
pip install langchain-openai      # OpenAI and Azure OpenAI
pip install langchain-anthropic   # Anthropic Claude
pip install langchain-ollama      # Ollama local models

# Optional: for JSON parsing utilities
pip install langchain-community

Documentation: python.langchain.com β€” the LCEL section and the "How-to Guides" are the most practical starting points.

For a full deep-dive on LangChain's prompting patterns and retrieval chains, see LangChain Development Guide.


πŸ§ͺ Building a Tech Explainer Chain: A Complete Worked Example

Let us put all the pieces together into a single, fully runnable program. The goal: a Tech Explainer chain that accepts a topic and a complexity_level and produces a clear explanation tailored to that level. We will also demonstrate swapping the model mid-program.

# tech_explainer.py
# pip install langchain langchain-openai langchain-anthropic langchain-ollama

import os
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from langchain_ollama import ChatOllama
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

# ── 1. Define the prompt ────────────────────────────────────────
SYSTEM = (
    "You are an expert teacher who adapts explanations to the learner's level. "
    "Use concrete analogies and short sentences. Avoid jargon unless you define it first."
)

prompt = ChatPromptTemplate.from_messages([
    ("system", SYSTEM),
    ("human",
     "Explain '{topic}' at a {complexity_level} level.\n"
     "Structure your answer as:\n"
     "1. What it is (one sentence)\n"
     "2. Why it matters (one sentence)\n"
     "3. A concrete example\n"),
])

# ── 2. Output parser ─────────────────────────────────────────────
parser = StrOutputParser()

# ── 3. Build the chain factory ───────────────────────────────────
def make_explainer(llm):
    """Return a Tech Explainer chain wired to the given LLM."""
    return prompt | llm | parser

# ── 4. Run against three different providers ─────────────────────
inputs = {"topic": "vector databases", "complexity_level": "beginner"}

# --- OpenAI ---
print("=" * 60)
print("Provider: OpenAI (gpt-4o-mini)")
print("=" * 60)
openai_chain = make_explainer(
    ChatOpenAI(model="gpt-4o-mini", temperature=0)
)
print(openai_chain.invoke(inputs))

# --- Anthropic ---
print("\n" + "=" * 60)
print("Provider: Anthropic (claude-3-5-haiku-20241022)")
print("=" * 60)
anthropic_chain = make_explainer(
    ChatAnthropic(model="claude-3-5-haiku-20241022", temperature=0)
)
print(anthropic_chain.invoke(inputs))

# --- Ollama (no API key, runs locally) ---
print("\n" + "=" * 60)
print("Provider: Ollama (llama3.2 β€” local, no key required)")
print("=" * 60)
ollama_chain = make_explainer(
    ChatOllama(model="llama3.2", temperature=0)
)
print(ollama_chain.invoke(inputs))

Sample output (OpenAI):

Provider: OpenAI (gpt-4o-mini)
1. What it is: A vector database stores data as numerical embeddings and retrieves
   results by mathematical similarity rather than exact keyword match.
2. Why it matters: It powers semantic search in AI applications β€” finding "things
   that mean the same thing" instead of just matching words.
3. Example: When you search "cheap flights to Paris" in an AI travel app, a vector
   database returns results for "affordable airfare to France" even though no words matched.

Each provider produces slightly different wording and style, but all three follow the prompt's structured format. The chain itself β€” the prompt template, the three-part instruction, the parser β€” required zero modification between runs. This is the payoff of LLM-agnostic design.

Extending the Chain with JsonOutputParser

If your downstream code needs structured data instead of prose, swap StrOutputParser for JsonOutputParser and update the prompt instruction:

from langchain_core.output_parsers import JsonOutputParser

json_prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a technical explainer. Always respond with valid JSON only."),
    ("human",
     "Explain '{topic}' at a {complexity_level} level.\n"
     "Return a JSON object with keys: what_it_is, why_it_matters, example"),
])

json_chain = json_prompt | ChatOpenAI(model="gpt-4o-mini", temperature=0) | JsonOutputParser()

result = json_chain.invoke({"topic": "vector databases", "complexity_level": "beginner"})
print(result["what_it_is"])    # Plain string
print(result["example"])       # Plain string β€” no parsing code needed

The chain returns a Python dict directly. No json.loads(), no error handling for malformed responses β€” JsonOutputParser handles that for you.


πŸ“š Five Mistakes Every LangChain Beginner Makes

Working through the primitives is straightforward; the errors tend to appear in the gaps. Here are the five most common traps and how to sidestep them.

1. Forgetting that prompts are not strings. A ChatPromptTemplate does not produce a string β€” it produces a list of message objects. If you try to concatenate it with a plain string or pass it somewhere expecting text, you will get a confusing type error. Always let the pipe operator handle data flow between components.

2. Using LLMChain from old tutorials. Most LangChain tutorials on YouTube and Stack Overflow from before 2024 use the legacy LLMChain class. It still works but is deprecated. If you see LLMChain(llm=..., prompt=...), translate it to prompt | llm | StrOutputParser() and move on.

3. Calling .run() instead of .invoke(). The old chain API used .run(). The LCEL Runnable protocol uses .invoke() (sync), .ainvoke() (async), .stream() (streaming), and .batch() (parallel). Get comfortable with .invoke() first and add .stream() when you need progressive output.

4. Hardcoding model parameters inside the chain. Temperature, max_tokens, and model version should be set when you instantiate the LLM object (ChatOpenAI(model="gpt-4o-mini", temperature=0.2)), not buried inside the prompt or a wrapper function. This keeps the chain portable and testable β€” you can mock the LLM object in tests without touching the chain.

5. Treating the | operator as magic. The pipe operator works because every component implements the Runnable protocol. If you add a custom function to a chain, you must wrap it in RunnableLambda first. Without the wrapper, Python will try to use the bitwise OR operator on a function object β€” and fail silently in unpredictable ways.

from langchain_core.runnables import RunnableLambda

# ❌ Wrong: Python's bitwise OR applied to a function
chain = prompt | llm | (lambda x: x.upper())

# βœ… Correct: wrap with RunnableLambda
chain = prompt | llm | StrOutputParser() | RunnableLambda(lambda x: x.upper())

πŸ“Œ TLDR: Summary and Key Takeaways

  • LangChain eliminates provider lock-in. By wrapping every chat model behind the same BaseChatModel interface, you swap ChatOpenAI for ChatAnthropic or ChatOllama in a single line β€” no changes to your prompt or parser.
  • LCEL's pipe operator composes a pipeline declaratively. prompt | llm | parser is not just syntax β€” it produces a Runnable with built-in support for streaming, batching, and async execution.
  • ChatPromptTemplate.from_messages([...]) is the standard prompt pattern. Define {variables} in the template, pass a dictionary to .invoke(), and let the template handle formatting.
  • Message types carry semantic meaning. SystemMessage sets context and persona, HumanMessage carries the user turn, AIMessage carries previous model responses for multi-turn history.
  • Output parsers close the loop. Use StrOutputParser for plain text and JsonOutputParser for structured responses β€” both compose cleanly into the chain with |.
  • Install only what you need. langchain-core + one provider package is enough to build a production chain; add langchain-community only when you need specific integrations.

One sentence to remember: LangChain's LCEL turns three independent objects β€” a prompt, a model, and a parser β€” into a reusable, provider-agnostic pipeline with a single | operator.


🎯 What Comes Next: Memory, Tools, and LangGraph

The chains built in this post are single-shot and stateless: each .invoke() call is a completely isolated interaction. The chain has no memory of previous calls, no ability to loop back and refine an answer, and no mechanism to branch based on what the model returns.

For a customer support bot, that means it forgets everything the customer said the moment the function returns. For a research assistant, it cannot retry when the first answer is too shallow. For a task planner, it cannot check whether a step succeeded before moving to the next one.

LangChain provides primitives to address memory and tools β€” but the moment your agent needs conditional branching, loops, or persistent cross-turn state, you will want LangGraph. LangGraph models an agent as a directed graph where each node is a function, shared typed state persists between all nodes, and edges can be conditional. The LCEL chains you built today become nodes inside a LangGraph graph β€” everything you learned here carries forward directly.

See LangGraph 101: Building Your First Stateful Agent to take the next step.


πŸ“ Practice Quiz

  1. What does the | pipe operator do in an LCEL chain?

    • A) It runs two chains in parallel and merges their outputs
    • B) It connects Runnables so the output of one becomes the input of the next
    • C) It retries the previous step if the model returns an empty response Correct Answer: B
  2. You have a working chain that uses ChatOpenAI. Your company now requires all inference to run locally with no API calls. What is the minimum change needed to your code?

    • A) Rewrite the entire prompt and parser from scratch for the new provider
    • B) Replace ChatOpenAI(...) with ChatOllama(...) and keep everything else the same
    • C) Add a custom RunnableLambda to translate between OpenAI and Ollama message formats Correct Answer: B
  3. Which of the following produces a plain Python str from an LCEL chain?

    • A) prompt | llm β€” the model's raw response is already a string
    • B) prompt | llm | StrOutputParser() β€” the parser extracts the text content
    • C) prompt | llm | JsonOutputParser() β€” JSON parsers auto-detect string outputs Correct Answer: B
  4. You add a custom Python function to the end of a chain to post-process the model's output, but the chain crashes with a type error. What is the most likely fix?

    • A) Switch from StrOutputParser to JsonOutputParser before the function
    • B) Wrap the function in RunnableLambda so it implements the Runnable protocol
    • C) Call .compile() on the chain before adding the function Correct Answer: B


Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms