AI Frameworks · LangChain 1.x

LangChain: Complete Interactive Notes

⏱ 25 min read🎯 Beginner to intermediate🖼 2 diagrams🧪 3 live demos🐍 Python code

LangChain is the most widely used Python framework for building LLM apps. It gives you one standard way to talk to any model, plus ready parts for prompts, tools, retrieval, agents and memory. These notes cover LangChain 1.x end to end, and every example runs without an API key, so you can copy, run and change them right away.

In this session

  • The package map: what to install and why
  • Chat models and messages: invoke, stream, batch
  • Prompt templates and LCEL pipes
  • Structured output with Pydantic
  • Tools and agents with create_agent
  • RAG: split, embed, retrieve, answer
  • Memory with checkpointers, middleware, streaming and LangSmith

01The big picture and the package map

Think of LangChain as a standard plug board for LLM apps. Every piece (a model, a prompt, a retriever, a tool, a parser) follows the same interface, so pieces snap together and providers can be swapped without rewriting your app.

PrompttemplatesModelany providerToolsAPIs, search, DBRetrieveryour documentsOutputtext / JSON
The usual parts of a LangChain app. Each one is a “Runnable” with the same methods.
PackageWhat is insideInstall when
langchain-corethe building blocks: messages, prompts, runnables, tools, output parsersalways (installed by the others)
langchaincreate_agent, init_chat_model, middlewarebuilding agents and apps
langchain-openai, -anthropic, -google-genai, -ollama…one package per model or vector-store providerpick the providers you use
langchain-text-splitterschunking documentsRAG
langchain-communityhundreds of community integrations (loaders, tools, stores)when no official package exists
langchain-classiclegacy chains and retrievers from 0.x (e.g. RetrievalQA, MultiQueryRetriever)maintaining older code
langgraphthe agent runtime underneath create_agentcustom workflows (see the LangGraph notes)
langsmithtracing, debugging and evaluationevery serious project
install.sh
pip install -U langchain langchain-openai langchain-text-splitters
export OPENAI_API_KEY="sk-..."          # Windows PowerShell: $env:OPENAI_API_KEY="sk-..."
Version note. LangChain 1.0 (October 2025) simplified the library: agents are built with create_agent, old chains such as RetrievalQA and ConversationChain moved to langchain-classic, and memory is handled by LangGraph checkpointers. Tutorials written for 0.1–0.3 often need small import changes.

02Chat models and messages

A chat model takes a list of messages and returns an AIMessage. Message types: SystemMessage (rules), HumanMessage (user), AIMessage (model, may contain tool calls) and ToolMessage (a tool result).

fake_llm.py
"""Stand-ins so every example runs without an API key.
In real code replace ScriptedModel(...) with e.g. init_chat_model("openai:gpt-4o-mini")."""
import re, math
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
from langchain_core.embeddings import Embeddings
from langchain_core.messages import AIMessage

class ScriptedModel(GenericFakeChatModel):
    """Replies with the messages you give it, one per call (text or tool calls)."""
    def __init__(self, replies):
        super().__init__(messages=iter([r if isinstance(r, AIMessage) else AIMessage(content=r) for r in replies]))
    def bind_tools(self, tools, **kwargs):
        return self

class KeywordEmbeddings(Embeddings):
    """A tiny bag-of-words embedding: similar words -> similar vectors. Real apps use OpenAIEmbeddings etc."""
    def __init__(self, dim=256): self.dim = dim
    def _vec(self, text):
        v = [0.0] * self.dim
        for w in re.findall(r"[a-z]+", text.lower()):
            w = w[:5]                                   # crude stemming: 'withdrawal' ~ 'withdraw'
            v[sum(map(ord, w)) % self.dim] += 1
        n = math.sqrt(sum(x * x for x in v)) or 1
        return [x / n for x in v]
    def embed_documents(self, texts): return [self._vec(t) for t in texts]
    def embed_query(self, text): return self._vec(text)
Runs without an API key. A small ScriptedModel plays the part of the LLM and replies with fixed answers, so every output below is from a real run. In your own code, replace it with a real model in one line, e.g. init_chat_model("openai:gpt-4o-mini").
lc1_models.py
from langchain_core.messages import SystemMessage, HumanMessage
from fake_llm import ScriptedModel
# real:  from langchain.chat_models import init_chat_model
#        model = init_chat_model("openai:gpt-4o-mini", temperature=0)
model = ScriptedModel(["UPI lets you send money instantly from your phone.", "Use NEFT for scheduled transfers.", "IMPS works 24x7."])

messages = [SystemMessage("You are a helpful banking tutor. Answer in one line."),
            HumanMessage("What is UPI?")]
reply = model.invoke(messages)                 # 1. invoke: one input -> one AIMessage
print(type(reply).__name__, "->", reply.content)

for chunk in model.stream("When should I use NEFT?"):   # 2. stream: tokens as they arrive
    print(chunk.content, end="|")
print()

print([r.content for r in model.batch(["What is IMPS?"])])   # 3. batch: many inputs at once
AIMessage -> UPI lets you send money instantly from your phone. Use| |NEFT| |for| |scheduled| |transfers.| ['IMPS works 24x7.']
MethodDoesUse for
invoke(x)one input → one outputnormal calls
stream(x)yields chunks as they are generatedchat UIs: text appears word by word
batch([x1, x2])many inputs, run in parallelbulk jobs such as classifying 1,000 tickets
ainvoke, astream…async versionsweb servers (FastAPI)
real_models.py
from langchain.chat_models import init_chat_model

gpt    = init_chat_model("openai:gpt-4o-mini", temperature=0)
claude = init_chat_model("anthropic:claude-sonnet-4-5")        # needs langchain-anthropic
local  = init_chat_model("ollama:llama3.1")                    # free, runs on your laptop
# all three have the same invoke / stream / batch methods

03Prompt templates

A prompt template is a reusable prompt with blanks in curly braces. MessagesPlaceholder reserves a slot for chat history; partial fills some blanks early.

lc2_prompts.py
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a {bank} support assistant. Reply in {language}."),
    MessagesPlaceholder("history"),                       # slot for earlier chat messages
    ("human", "{question}"),
])
print("variables:", sorted(prompt.input_variables))

hindi = prompt.partial(language="Hindi")                  # fill some blanks now, the rest later
value = hindi.invoke({"bank": "NovaBank", "history": [("human", "Hi"), ("ai", "Hello! How can I help?")],
                      "question": "How do I block my card?"})
for m in value.to_messages():
    print(f"{m.type:6s}| {m.content}")
variables: ['bank', 'history', 'language', 'question'] system| You are a NovaBank support assistant. Reply in Hindi. human | Hi ai | Hello! How can I help? human | How do I block my card?
Tip: keep rules in the system message and user data in the human message. Never paste user text into the system message: that makes prompt injection easier.

04LCEL: joining steps with the pipe

LCEL (LangChain Expression Language) joins runnables with |. The output of each step becomes the input of the next, exactly like a Unix pipe.

Demo · What happens inside prompt | model | parser
lc3_lcel.py
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableLambda, RunnableParallel, RunnablePassthrough
from fake_llm import ScriptedModel

model = ScriptedModel(["Fixed deposits pay a fixed interest rate for a fixed period."])
explain = ChatPromptTemplate.from_template("Explain {topic} in one line.") | model | StrOutputParser()
print("1.", explain.invoke({"topic": "FD"}))

# RunnableParallel: run branches on the same input at the same time
sentiment = ChatPromptTemplate.from_template("Sentiment of: {text}") | ScriptedModel(["positive"]) | StrOutputParser()
simple = ChatPromptTemplate.from_template("Explain {text} to a child") | ScriptedModel(["An FD is like a locked savings box that pays you extra."]) | StrOutputParser()
both = RunnableParallel(sentiment=sentiment, for_kids=simple)
print("2.", both.invoke({"text": "FD"}))

# RunnablePassthrough.assign adds new keys; RunnableLambda wraps any Python function
pipeline = RunnablePassthrough.assign(words=RunnableLambda(lambda d: len(d["text"].split())))
print("3.", pipeline.invoke({"text": "my fixed deposit matured today"}))
print("4. input keys:", list(explain.input_schema.model_json_schema()["properties"]))
1. Fixed deposits pay a fixed interest rate for a fixed period. 2. {'sentiment': 'positive', 'for_kids': 'An FD is like a locked savings box that pays you extra.'} 3. {'text': 'my fixed deposit matured today', 'words': 5} 4. input keys: ['topic']
RunnableWhat it doesExample
RunnableParallelrun branches on the same input at once; returns a dictsentiment + summary together
RunnablePassthroughpass the input through unchanged (or .assign new keys)keep the question while adding context
RunnableLambdawrap any Python functionclean text, count words
.with_retry()retry on errorsflaky APIs
.with_fallbacks([b])use b if a failsswitch to a backup model

05Structured output

Apps need data, not paragraphs. Define the shape with Pydantic; LangChain validates the reply and rejects anything that does not fit:

lc4_structured.py
from typing import Literal
from pydantic import BaseModel, Field
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import PydanticOutputParser
from langchain_core.exceptions import OutputParserException
from fake_llm import ScriptedModel

class Ticket(BaseModel):
    category: Literal["card", "upi", "loan", "other"]
    priority: Literal["low", "medium", "high"]
    summary: str = Field(description="one short sentence")

parser = PydanticOutputParser(pydantic_object=Ticket)
prompt = ChatPromptTemplate.from_template("Classify the complaint.\n{format}\n\nComplaint: {text}").partial(
    format=parser.get_format_instructions())

good = prompt | ScriptedModel(['{"category": "upi", "priority": "high", "summary": "UPI debit without credit."}']) | parser
t = good.invoke({"text": "Money left my account via UPI but the shop never got it!"})
print(type(t).__name__, t.category, t.priority, "|", t.summary)

bad = prompt | ScriptedModel(['{"category": "atm", "priority": "urgent"}']) | parser
try:
    bad.invoke({"text": "ATM ate my card"})
except OutputParserException as e:
    print("rejected:", str(e).split(". Got:")[1].split("\n")[0].strip()[:80])

# with a real model you can skip the parser:  model.with_structured_output(Ticket)
Ticket upi high | UPI debit without credit. rejected: 3 validation errors for Ticket
Demo · Three ways to get JSON

06Tools and agents with create_agent

A tool is a Python function the model can ask to call. The @tool decorator reads the function name, type hints and docstring to build the schema the model sees, so write clear docstrings.

User questionModeldecides: answer or call a tool?Toolruns your PythonModelreads the resultAnswer
The agent loop. create_agent repeats model → tool → model until the model replies without a tool call.
lc5_agent.py
from langchain_core.tools import tool
from langchain_core.messages import AIMessage
from langchain.agents import create_agent
from fake_llm import ScriptedModel

@tool
def get_balance(account: str) -> str:
    """Get the current balance of a bank account (last 4 digits)."""
    return {"1234": "₹12,500", "9876": "₹3,200"}.get(account, "account not found")

@tool
def convert_currency(amount_inr: float, to: str) -> str:
    """Convert an amount in Indian rupees to another currency."""
    rates = {"USD": 0.0105, "EUR": 0.0098}          # example rates
    return f"{amount_inr * rates[to]:.2f} {to}"

print("tool schema:", get_balance.name, get_balance.args)

# The scripted model plays the LLM: call a tool, call another, then answer.
model = ScriptedModel([
    AIMessage("", tool_calls=[{"name": "get_balance", "args": {"account": "1234"}, "id": "c1"}]),
    AIMessage("", tool_calls=[{"name": "convert_currency", "args": {"amount_inr": 12500, "to": "USD"}, "id": "c2"}]),
    "Your balance is ₹12,500, which is about 131.25 USD.",
])
agent = create_agent(model, tools=[get_balance, convert_currency],
                     system_prompt="You are NovaBank's assistant. Use tools; never guess numbers.")

result = agent.invoke({"messages": [{"role": "user", "content": "How much is my 1234 account worth in dollars?"}]})
for m in result["messages"]:
    detail = m.tool_calls[0]["name"] + str(m.tool_calls[0]["args"]) if getattr(m, "tool_calls", None) else m.content
    print(f"{m.type:5s} -> {detail}")
tool schema: get_balance {'account': {'title': 'Account', 'type': 'string'}} human -> How much is my 1234 account worth in dollars? ai -> get_balance{'account': '1234'} tool -> ₹12,500 ai -> convert_currency{'amount_inr': 12500, 'to': 'USD'} tool -> 131.25 USD ai -> Your balance is ₹12,500, which is about 131.25 USD.

Read the output top to bottom: the model called get_balance, got ₹12,500, called convert_currency, then answered from real tool results instead of guessing. This message list is exactly what you see in a LangSmith trace.

Demo · Agent or chain?

07RAG with LangChain

Retrieval-augmented generation in LangChain uses four parts: a text splitter, an embedding model, a vector store and a retriever. Here the whole pipeline runs in memory:

lc6_rag.py
from langchain_core.documents import Document
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_text_splitters import RecursiveCharacterTextSplitter
from fake_llm import KeywordEmbeddings, ScriptedModel
# real: from langchain_openai import OpenAIEmbeddings; from langchain_chroma import Chroma

policy = Document(page_content="""Fixed Deposit Policy.
Eligibility: any resident above 18 years can open an FD.
Premature withdrawal: breaking an FD early reduces the interest rate by 1%.
Senior citizens get an extra 0.50% interest on all tenures.
Loan against FD: you can borrow up to 90% of the FD amount.""", metadata={"source": "fd_policy.pdf"})

chunks = RecursiveCharacterTextSplitter(chunk_size=90, chunk_overlap=0).split_documents([policy])
print(len(chunks), "chunks")
store = InMemoryVectorStore.from_documents(chunks, KeywordEmbeddings())
retriever = store.as_retriever(search_kwargs={"k": 2})

for d in retriever.invoke("What happens if I withdraw my FD early?"):
    print("  found:", d.page_content)

prompt = ChatPromptTemplate.from_template("Answer only from the context.\nContext:\n{context}\n\nQuestion: {question}")
format_docs = lambda docs: "\n".join(d.page_content for d in docs)
rag = ({"context": retriever | format_docs, "question": RunnablePassthrough()}
       | prompt | ScriptedModel(["Breaking an FD early reduces your interest rate by 1% [fd_policy.pdf]."]) | StrOutputParser())
print("answer:", rag.invoke("What happens if I withdraw my FD early?"))
4 chunks found: Premature withdrawal: breaking an FD early reduces the interest rate by 1%. found: Loan against FD: you can borrow up to 90% of the FD amount. answer: Breaking an FD early reduces your interest rate by 1% [fd_policy.pdf].

08Memory, middleware and streaming

LLMs remember nothing between calls. In LangChain 1.x, conversation memory comes from a checkpointer: it saves the message history per thread_id.

lc7_memory.py
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver
from fake_llm import ScriptedModel

agent = create_agent(ScriptedModel(["Nice to meet you, Asha!", "Your name is Asha.", "I don't know your name yet."]),
                     tools=[], checkpointer=InMemorySaver())       # the checkpointer saves each conversation

asha = {"configurable": {"thread_id": "asha-1"}}                   # one thread_id per conversation
agent.invoke({"messages": [{"role": "user", "content": "Hi, I am Asha"}]}, asha)
r = agent.invoke({"messages": [{"role": "user", "content": "What is my name?"}]}, asha)
print("asha thread:", len(r["messages"]), "messages ->", r["messages"][-1].content)

ravi = {"configurable": {"thread_id": "ravi-1"}}                   # a new thread starts empty
r = agent.invoke({"messages": [{"role": "user", "content": "What is my name?"}]}, ravi)
print("ravi thread:", len(r["messages"]), "messages ->", r["messages"][-1].content)
asha thread: 4 messages -> Your name is Asha. ravi thread: 2 messages -> I don't know your name yet.

Middleware hooks into the agent loop before or after each model or tool call. Built-ins include PII redaction, summarising long histories, human approval, call limits, retries and model fallbacks:

lc8_middleware.py
from langchain.agents import create_agent
from langchain.agents.middleware import PIIMiddleware, ModelCallLimitMiddleware
from fake_llm import ScriptedModel

agent = create_agent(
    ScriptedModel(["I have noted your request."]), tools=[],
    middleware=[
        PIIMiddleware("email", strategy="redact"),              # hide emails before the model sees them
        ModelCallLimitMiddleware(run_limit=5),                  # stop runaway loops
    ])
r = agent.invoke({"messages": [{"role": "user", "content": "Send the statement to priya@example.com"}]})
print("model saw:", r["messages"][0].content)
model saw: Send the statement to [REDACTED_EMAIL]
MiddlewareSolves
PIIMiddlewareemails, cards and other PII reaching the model
SummarizationMiddlewarelong chats overflowing the context window
HumanInTheLoopMiddlewareapproval before risky tool calls (payments, emails)
ModelCallLimitMiddleware / ToolCallLimitMiddlewarerunaway loops and surprise bills
ModelFallbackMiddlewareprovider outages: switch to a backup model
ToolRetryMiddlewareflaky tools
streaming.py
for step in agent.stream({"messages": [("user", "Balance in USD?")]}, stream_mode="updates"):
    print(step)          # one dict per finished step: model call, tool call, final answer

09Debugging with LangSmith

When an agent gives a wrong answer, you need to see every prompt, tool call and token. LangSmith records this as a trace. Turn it on with environment variables only, no code changes:

.env
LANGSMITH_TRACING=true
LANGSMITH_API_KEY=lsv2_...
LANGSMITH_PROJECT=novabank-assistant
MistakeFix
Copying a 2023 tutorial (LLMChain, initialize_agent)use LCEL pipes and create_agent
Vague tool docstringssay what the tool does, its inputs and when to use it
Building an agent when a fixed chain would dostart with a chain; add agency only when needed
No tracingturn on LangSmith from day one
Unlimited agent loopsadd call limits and timeouts

10Summary

11Quick quiz

1. Which package holds prompts, messages and runnables?

2. What does prompt | model | parser do?

3. Best way to get validated JSON from a real model?

4. How does LangChain 1.x give an agent conversation memory?

5. Why do tool docstrings matter?

12References

Created by Ajeetkumar