LangChain Essentials
The LangChain mental model that survives its fast-moving API: the Runnable interface, composing with LCEL (pipe, parallel, passthrough), adding conversation memory, and building a tool-calling agent — the same loop you hand-rolled, now production-grade with streaming, batching, and tracing for free.
- Understand the one abstraction that unifies LangChain: the Runnable interface (
invoke/stream/batch). - Compose Runnables with LCEL — the pipe, plus RunnableParallel and RunnablePassthrough.
- Add conversation memory so a chain remembers prior turns.
- Build a tool-calling agent in LangChain — the loop from earlier topics, now with streaming/tracing for free.
LangChain's API moves fast — import paths and class names change between releases. Learn the mental model here (Runnables, LCEL, agents); verify exact signatures against the current docs. The concepts below have been stable across many versions even as the imports churned.
Python for JavaScript developers:
- The
|pipe — LCEL overloads|as "feed left's output into right", like a Unix pipe / RxJS.pipe(). - A
dictin a chain —{"a": x, "b": y}becomes aRunnableParallel: runxandy, collect both. .invoke/.stream/.batch— call once / stream tokens / call on a list — likeawait fn(x), an async iterator, andPromise.all(xs.map(fn)).
Every code block is commented line-by-line.
One interface: the Runnable
LangChain looks sprawling, but almost everything — prompts, models, parsers, retrievers, whole chains, agents — implements one interface: the Runnable. If something is a Runnable, you can:
.invoke(input)— run it once on one input..stream(input)— run it and yield output incrementally (token streaming)..batch(inputs)— run it over a list, concurrently.- compose it with
|into a bigger Runnable that also has all three methods.
That uniformity is the payoff: learn the interface once and every component — and every composition of components — behaves the same way, with streaming, batching, and async handled for you.
LCEL: composing Runnables
LCEL (LangChain Expression Language) is just composing Runnables with |. A prompt-model-parser chain
reads as a data flow:
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
prompt = ChatPromptTemplate.from_template("Explain {topic} in one sentence.") # a Runnable: fills the template
model = ChatOpenAI(model="gpt-4o-mini") # a Runnable: calls the LLM
parser = StrOutputParser() # a Runnable: message -> plain string
chain = prompt | model | parser # compose into ONE Runnable (prompt's output feeds model, then parser)
chain.invoke({"topic": "embeddings"}) # run once -> "Embeddings are numeric vectors that capture meaning."
for chunk in chain.stream({"topic": "RAG"}): # stream tokens as they arrive (the whole chain streams for free)
print(chunk, end="")
chain.batch([{"topic": "agents"}, {"topic": "MCP"}]) # run over a list concurrently
Because the whole chain is itself a Runnable, .stream and .batch work on the composition without any
extra code — that's the ergonomic win over hand-rolling.
↳ Exercise 1 has you build this prompt | model | parser chain and call it three ways (invoke, stream,
batch), feeling how composition inherits all three methods.
Parallel and passthrough
Two Runnables you'll use constantly for shaping data flow:
- RunnableParallel (written as a plain dict) — run several Runnables on the same input and collect a dict of results. This is how you assemble a prompt's variables from multiple sources at once.
- RunnablePassthrough — forward the input unchanged (optionally adding fields). It's the "keep the original question while I also compute context" piece — exactly the RAG shape from the Frameworks topic.
from langchain_core.runnables import RunnableParallel, RunnablePassthrough
# Run `retriever` and pass the raw question through, both from the same input, into one dict:
setup = RunnableParallel(
context=retriever, # retriever(input) -> chunks (runs on the input string)
question=RunnablePassthrough(), # forward the input string unchanged
)
rag_chain = setup | prompt | model | parser # {context, question} fills the prompt, then generate, then parse
rag_chain.invoke("What does the Pro plan cost?") # the full RAG chain as one Runnable
↳ Exercise 2 has you use RunnableParallel to build a prompt from two inputs at once (e.g. a retrieved
context and a live timestamp), seeing how dicts become parallel branches.
Memory: remembering the conversation
A bare chain is stateless — each invoke is independent. To make it conversational you wrap it so prior
messages are stored per session and re-injected on each turn. LangChain's RunnableWithMessageHistory does
this: you give it a function that returns a history store for a session id, and it prepends the history
automatically.
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.chat_history import InMemoryChatMessageHistory
store = {} # session_id -> history object (use a DB in production)
def get_history(session_id: str):
if session_id not in store: # first time we see this session…
store[session_id] = InMemoryChatMessageHistory() # …create an empty history
return store[session_id] # return the per-session message log
chat = RunnableWithMessageHistory(chain, get_history) # wrap the chain so it auto-loads + appends history
# The `configurable` session_id selects WHICH conversation this call belongs to:
chat.invoke({"topic": "agents"}, config={"configurable": {"session_id": "user-42"}})
chat.invoke({"topic": "and how do they differ from chains?"}, # this turn 'remembers' the previous one
config={"configurable": {"session_id": "user-42"}})
InMemoryChatMessageHistory lives in process memory — fine for a demo, gone on restart and not shared across
servers. Production uses a backed store (Redis, Postgres, etc.). And history grows unbounded: long
conversations need trimming or summarization — the subject of the Memory & multi-step design topic later
in this track.
↳ Exercise 3 has you wrap a chain with message history and confirm a follow-up question resolves a pronoun ("and those?") using the remembered prior turn.
A tool-calling agent in LangChain
Everything you hand-rolled in Topics 1–2 — the reason→act→observe loop with function calling — LangChain
packages as an agent. You bind tools to a model, wrap it in an executor that runs the loop (with the
bound max_iterations, error handling, and tracing you'd otherwise write yourself), and invoke it:
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get today's weather for a city.""" # the docstring BECOMES the tool description the model reads
return f"Sunny, 21°C in {city}" # (real impl would call a weather API)
# Bind tools + wrap in an executor that runs the tool-call loop for you:
agent = create_tool_calling_agent(model, [get_weather], prompt) # builds the reason/act step
executor = AgentExecutor(agent=agent, tools=[get_weather],
max_iterations=5, handle_parsing_errors=True) # the bounded, error-handled loop
executor.invoke({"input": "Should I bring an umbrella in Paris?"}) # runs the whole agent to a final answer
Notice what you didn't write: the loop, the tool_call_id plumbing, the step bound, streaming, and the
trace. That's the trade from the Frameworks topic in miniature — less control, far less boilerplate — and the
@tool docstring is the same "descriptions are prompts" rule from Topic 1.
↳ Exercise 4 has you define two @tool functions, build an AgentExecutor, and run a query needing both
— then set verbose=True and read the trace to see your hand-rolled loop reflected back.
Pitfalls
- Chasing the API, not the model. Imports churn; the Runnable/LCEL/agent concepts don't. Anchor on those and check current docs for signatures.
- In-memory history in production. It's per-process and volatile — use a backed store and trim/summarize long histories.
- Unbounded agents. Always set
max_iterations(and handle parsing errors) — the same loop-bound discipline as the hand-rolled version. - Over-abstracting simple flows. For a one-shot prompt,
prompt | model | parseris plenty; you don't need an agent. (The chain-vs-agent judgment from Topic 1 still rules.) - Weak
@tooldocstrings. The docstring is the tool description the model routes on — write it carefully.
Recap
- LangChain's unifying abstraction is the Runnable (
invoke/stream/batch); composing Runnables with|(LCEL) yields a new Runnable with all three methods for free. - RunnableParallel (a dict) fans out over one input; RunnablePassthrough forwards it — together they
build the RAG-style
{context, question}shape. - RunnableWithMessageHistory adds per-session memory; use a backed store and trim long histories in production.
- A LangChain agent (
create_tool_calling_agent+AgentExecutor) packages the reason→act→observe loop with bounds, error handling, and tracing — your hand-rolled loop, productionized. - Worked examples map to the exercises: LCEL chain (Ex 1), parallel/passthrough (Ex 2), message-history memory
(Ex 3), a tool-calling
AgentExecutor(Ex 4).
LangChain Essentials — Exercises
Install: pip install langchain langchain-openai langchain-community. APIs shift between releases — if an
import path fails, check the current docs; the Runnable/LCEL/agent shapes are what matter.
Exercise 1 — An LCEL chain, three ways
Build prompt | model | parser for "Explain {topic} in one sentence." Call it with .invoke, then
.stream (print tokens as they arrive), then .batch over three topics.
Reference solution
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
chain = (ChatPromptTemplate.from_template("Explain {topic} in one sentence.")
| ChatOpenAI(model="gpt-4o-mini")
| StrOutputParser())
print(chain.invoke({"topic": "embeddings"}))
for tok in chain.stream({"topic": "RAG"}):
print(tok, end="", flush=True)
print(chain.batch([{"topic": "agents"}, {"topic": "MCP"}, {"topic": "LCEL"}]))
The composed chain is itself a Runnable, so .stream and .batch work with zero extra code. That
inherited uniformity is the core LangChain payoff over a hand-rolled call.
Exercise 2 — Parallel & passthrough
Use RunnableParallel to build a prompt from TWO inputs computed from the same question: a retrieved
context and the current timestamp. Pass the raw question through with RunnablePassthrough.
Reference solution
from datetime import datetime
from langchain_core.runnables import RunnableParallel, RunnablePassthrough
setup = RunnableParallel(
context=retriever, # retriever(question) -> chunks
question=RunnablePassthrough(), # forward the question unchanged
now=lambda _: datetime.now().isoformat(), # any callable is a Runnable too
)
chain = setup | prompt | model | parser # prompt uses {context}, {question}, {now}
chain.invoke("What does the Pro plan cost?")
The dict runs its branches on the same input and collects them — that's RunnableParallel. Passthrough
keeps the original question available alongside the derived context, exactly the RAG shape from the
Frameworks topic.
Exercise 3 — Add conversation memory
Wrap a chain in RunnableWithMessageHistory. Ask a question, then a follow-up with a pronoun ("and how do
those differ from chains?") using the same session_id, and confirm the follow-up resolves via memory.
Reference solution
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.chat_history import InMemoryChatMessageHistory
store = {}
def get_history(sid):
store.setdefault(sid, InMemoryChatMessageHistory())
return store[sid]
chat = RunnableWithMessageHistory(chain, get_history)
cfg = {"configurable": {"session_id": "u1"}}
print(chat.invoke({"input": "What are agents?"}, config=cfg))
print(chat.invoke({"input": "And how do those differ from chains?"}, config=cfg)) # 'those' = agents, remembered
Same session_id → the second call sees the first turn and resolves "those". Swap to a different session_id
and the follow-up loses context — proving the history is per-session. In production, back get_history with
Redis/Postgres, not an in-memory dict.
Exercise 4 — A tool-calling agent
Define two @tool functions (e.g. get_weather, calculator), build an AgentExecutor with
max_iterations=5, and run a query needing both. Set verbose=True and read the trace.
Reference solution
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Get today's weather for a city."""
return f"Rainy, 12°C in {city}"
@tool
def calculator(expression: str) -> str:
"""Evaluate an arithmetic expression."""
return str(eval(expression))
agent = create_tool_calling_agent(model, [get_weather, calculator], prompt)
executor = AgentExecutor(agent=agent, tools=[get_weather, calculator],
max_iterations=5, handle_parsing_errors=True, verbose=True)
print(executor.invoke({"input": "If it's raining in Paris, what's 12 * 7?"}))
The verbose=True trace shows the reason→act→observe steps you wrote by hand in Topics 1–2 — tool selection,
arguments, observations — but the loop, step bound, and tool_call_id plumbing are the framework's job now.
The @tool docstrings are the descriptions the model routes on: keep them sharp.