Home / Articles / LangChain 1.x in Practice: Chains, RAG, Tools, and Agents Locally

This article is published in English.

LangChain 1.x in Practice: Chains, RAG, Tools, and Agents Locally

Learn to build chains, retrieval-augmented generation, tools, and agentic RAG with LangChain 1.x using a free local Ollama setup, no API keys required.

3586 words

This is the third installment in a hands-on series, following earlier work on building RAG and agents from scratch in plain Python. The approach here is intentionally different: instead of assembling every moving part yourself, you'll see how LangChain packages that same machinery into a handful of lines. Because you've already built the underlying pieces by hand, you're in a good position to understand what each abstraction actually does rather than treating it as magic. That understanding matters — it's the difference between developers who use LangChain effectively and those who constantly fight against it.

Everything in this tutorial runs locally and for free, using a local model through Ollama along with local embeddings. There's no need for API keys and no rate limits to worry about.

A note on versions: this tutorial targets LangChain 1.x, verified against langchain==1.3.11 and langchain-core==1.4.8. LangChain 1.0 introduced substantial reorganization — the current agent API centers on create_agent, while older components such as AgentExecutor and initialize_agent were moved into a separate langchain-classic package. Many tutorials circulating online still demonstrate the pre-1.0 patterns; the imports shown here are current, and each one has been checked to confirm it resolves correctly.

How to follow along: open a file named lc.py, run each code block in sequence, and complete the "Your turn" exercises as you reach them. Whenever you see the phrase "you built this," it's pointing back to the manual implementation from the earlier tutorials in this series.

Step 0 — What LangChain actually is

LangChain is best understood as a collection of standardized, interchangeable components for building LLM-powered applications — things like model wrappers, prompt templates, retrievers, vector stores, tools, and agents. These pieces all conform to a shared interface, which means you can wire them together and substitute one for another (swap in a different model, switch vector stores) without having to rewrite your application logic.

The concept that ties everything together is the Runnable. Every component exposes the same .invoke() method, and any two components can be chained together using a | pipe operator. This piping mechanism is known as LCEL, short for LangChain Expression Language. Once every part of your system speaks the Runnable interface, an entire RAG pipeline or agent can be expressed in just a few lines.

A candid trade-off worth stating up front: LangChain cuts down on repetitive code and gives you access to a broad catalog of ready-made integrations. In exchange, it introduces abstraction layers that can make debugging harder — there will be moments when you'd rather be staring at the plain loop you wrote yourself. Knowing when that abstraction is worth the cost is the real skill, and we'll revisit this trade-off in Step 7.

Setup (the free, local stack)

pip install langchain langchain-core langchain-ollama langchain-huggingface langchain-text-splitters sentence-transformers

You'll also need Ollama installed (it's free and runs locally), after which you should pull a model capable of tool-calling:

ollama pull llama3.2     # ~2 GB; needs ~8 GB RAM. qwen2.5 also works well.

If you'd rather skip Ollama, you can still run the chain and RAG sections using a local Hugging Face model via langchain-huggingface. The agent sections, however, depend on dependable tool-calling behavior, which small CPU-bound models tend to handle poorly. Using Ollama is strongly advised for Steps 4 through 6.

Step 1 — The core move: a chain with the | pipe

In the earlier RAG tutorial, you assembled a prompt manually with an f-string, passed it to the model, and cleaned up the output with .strip(). LangChain captures that identical sequence as a pipe expression. Add this to lc.py:

from langchain_ollama import ChatOllama
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

llm = ChatOllama(model="llama3.2", temperature=0)

prompt = ChatPromptTemplate.from_template(
    "Explain {topic} in exactly one sentence."
)

# The chain: prompt -> model -> plain-string parser
chain = prompt | llm | StrOutputParser()

print(chain.invoke({"topic": "retrieval-augmented generation"}))

Reading the pipe from left to right: prompt converts your input dictionary into a properly formatted message, llm turns that message into a model response, and StrOutputParser() extracts the plain text from the response object.

You built this already. This pipe is functionally identical to f"Explain {topic}..." followed by generator(prompt) followed by [0]["generated_text"].strip() from the earlier RAG walkthrough — three manual steps, now expressed as three piped Runnables. The logic hasn't changed; only the interface has been standardized.

Your turn: every Runnable also supports .batch() and .stream() out of the box. Give this a try:

for piece in chain.stream({"topic": "vector embeddings"}):
    print(piece, end="", flush=True)   # tokens arrive as they're generated
print()
print(chain.batch([{"topic": "agents"}, {"topic": "chunking"}]))  # two at once

Notice that streaming and batching came along for free simply because you used the standard Runnable interface. That "for free" moment is, in miniature, the entire case for using LangChain.

Step 2 — RAG, the LangChain way

Time to reconstruct your manual RAG pipeline using LangChain's building blocks. Each piece corresponds directly to something you already wrote by hand.

from langchain_huggingface import HuggingFaceEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_text_splitters import RecursiveCharacterTextSplitter

# Same Nimbus knowledge base from the RAG tutorial
DOCUMENTS = [
    "Nimbus is a fictional note-taking app. The free plan, Nimbus Lite, allows up to 50 notes and 1 GB of storage.",
    "Nimbus Pro costs 8 dollars per month billed annually, or 10 dollars billed monthly. It includes 50 GB of storage and collaboration for up to 5 people.",
    "Nimbus stores notes encrypted at rest with AES-256. End-to-end encryption is Pro-only and must be enabled in Settings > Security.",
    "Nimbus offers a 30-day refund policy on all paid plans. Refunds reach the original payment method within 5 business days.",
    "Nimbus live chat support is staffed for Pro customers, Monday to Friday, 9am-6pm UTC. Free users get email support with a 48-hour response time.",
]

# 1. Split (↔ your chunk_text function)
splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=40)
chunks = splitter.create_documents(DOCUMENTS)

# 2. Embed locally (↔ your sentence-transformers model)
embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")

# 3. Store + index (↔ your numpy array of vectors). No server needed.
vectorstore = InMemoryVectorStore.from_documents(chunks, embeddings)

# 4. Retriever (↔ your retrieve() with cosine top-k)
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})

for doc in retriever.invoke("How much does Pro cost?"):
    print("-", doc.page_content[:70], "...")

↔ you built this — the whole thing. RecursiveCharacterTextSplitter plays the role of your chunker, but it's more careful about it: it breaks text along paragraph and sentence boundaries rather than just counting words. HuggingFaceEmbeddings is a wrapper around the same all-MiniLM-L6-v2 model you used before. InMemoryVectorStore stands in for your numpy array of vectors, and its .as_retriever() method performs the same cosine-similarity top-k search you coded manually. Four lines here cover everything you built across Steps 2 through 4 previously.

Next, connect retrieval to generation using LCEL:

from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser

rag_prompt = ChatPromptTemplate.from_template(
    "Answer using only the context. If it's not there, say you don't know.\n\n"
    "Context:\n{context}\n\nQuestion: {question}\nAnswer:"
)

def format_docs(docs):
    return "\n\n".join(d.page_content for d in docs)

rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | rag_prompt
    | llm
    | StrOutputParser()
)

print(rag_chain.invoke("How much does Nimbus Pro cost per month?"))

The dictionary at the start of the chain runs two branches in parallel: question simply forwards the input unchanged, while context routes that same input through the retriever and formats the results. Both outputs then flow into the prompt. ↔ you built this is essentially your old rag_answer() function — retrieve, insert into a prompt, generate — condensed into a single expression.

Your turn: Try calling rag_chain.invoke("Can free users use live chat?"), then follow it with an unrelated question such as rag_chain.invoke("What's the capital of France?"). Watch for the "I don't know" response — this is the same grounding check from the earlier RAG tutorial, reinforcing the same point: the quality of retrieval determines the quality of the answer. Afterward, call retriever.invoke(...) on its own to see exactly what was pulled when a response seems off. That separation — checking retrieval independently of generation — is a debugging habit you've used before, and LangChain preserves it by keeping the two steps as distinct Runnables.

Step 3 — Tools

In the agents tutorial, you defined tools as a TOOLS dictionary and wrote a regex-based parser yourself to extract the tool name and its input from the model's raw text output. LangChain removes the need for that parser entirely through native tool calling: you describe what a tool does, the model responds with a structured call, and LangChain handles the routing. Defining a tool looks like this, using the @tool decorator:

from langchain_core.tools import tool
import ast, operator, datetime

_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
        ast.Div: operator.truediv, ast.Pow: operator.pow, ast.USub: operator.neg}
def _ev(n):
    if isinstance(n, ast.Constant): return n.value
    if isinstance(n, ast.BinOp):   return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
    if isinstance(n, ast.UnaryOp): return _OPS[type(n.op)](_ev(n.operand))
    raise ValueError("unsupported")

@tool
def calculator(expression: str) -> str:
    """Evaluate a basic arithmetic expression like '8 * 12'."""
    return str(_ev(ast.parse(expression, mode="eval").body))

@tool
def get_today(_: str = "") -> str:
    """Return today's date in ISO format."""
    return datetime.date.today().isoformat()

Here's the detail worth slowing down for. You can inspect exactly what LangChain generated from your function:

print(calculator.name)         # 'calculator'
print(calculator.description)  # the docstring
print(calculator.args)         # {'expression': {'title': 'Expression', 'type': 'string'}}

That final line is genuine, verified output. LangChain inspected your type hint (expression: str) along with the docstring and built a schema from them — this schema is precisely what the model reads to decide whether and how to invoke the tool. ↔ you built this, only previously you wrote tool descriptions manually inside your SYSTEM_PROMPT and parsed the model's output yourself. Now the docstring itself becomes the description, and parsing happens automatically. This explains why docstrings and type hints carry real weight — they aren't just documentation, they define how the model understands and uses the tool. A sloppy docstring produces a model that calls the tool incorrectly.

Your turn: Replace the calculator's docstring with something unhelpful, like """does math""", then check .description again. In Step 4 you'll see firsthand how a weaker docstring leads to worse tool-selection decisions by the model. The description you write functions as your steering wheel over the model's behavior.

Step 4 — Agents in one call

Here is where the earlier work pays off. Your hand-built agent required a loop, a scratchpad, a parser, error handling, a step cap, and a system prompt that taught the model the ReAct format. In LangChain 1.x, all of that collapses into one function call: create_agent.

from langchain.agents import create_agent

agent = create_agent(
    model=llm,                                   # your ChatOllama from Step 1
    tools=[calculator, get_today],               # the @tool functions from Step 3
    system_prompt="You are a helpful assistant. Use tools for math and dates.",
)

result = agent.invoke(
    {"messages": [{"role": "user", "content": "What is 8 times 12, and what is today's date?"}]}
)
print(result["messages"][-1].content)

That's the entire agent, start to finish. ↔ you built this — all of it. create_agent internally runs the reason-act-observe cycle, dispatches to the correct tool, feeds the observation back into the model, checks the stop condition, and enforces the step limit — every piece you assembled by hand inside run_agent. Under the hood it relies on LangGraph, which is the reason the looping behavior is so solid.

If you want to watch the reasoning unfold — the same effect your verbose=True trace gave you — stream the intermediate steps instead of just waiting for a final answer:

inputs = {"messages": [{"role": "user", "content": "How much is a year of Nimbus Pro?"}]}
for chunk in agent.stream(inputs, stream_mode="updates"):
    print(chunk)

As it runs, you'll see each decision the model makes and each result a tool returns, printed one after another. It's the identical Thought/Action/Observation pattern from your manual trace, just delivered as structured update objects rather than raw text you had to parse yourself.

Your turn: Try a question that forces the model to chain two tools together — for instance, ask it to work out the closing date of a 30-day refund window given that a trial started today. Watch to see whether it correctly calls get_today and then calculator in sequence. Afterward, go back to Step 7 of the agents material — every failure mode you documented there (drifting output format, invented tool names, infinite loops) can still show up here. The framework doesn't upgrade a weak model's reasoning; it just hides the wiring underneath. Understanding that distinction is exactly why you'll be faster at debugging these agents than someone who jumped straight to the framework without building one first.

Step 5 — Bring it together: an agent that retrieves (agentic RAG)

This is where all three earlier lessons converge. Take your retriever and wrap it as a tool, then give that tool to the agent. From this point on, the agent itself decides when a document search is needed — and it's free to search multiple times, or mix retrieval with computation as required.

@tool
def search_nimbus_docs(query: str) -> str:
    """Search the Nimbus product documentation for facts about plans, pricing, refunds, security, and support."""
    docs = retriever.invoke(query)
    return "\n\n".join(d.page_content for d in docs)

smart_agent = create_agent(
    model=llm,
    tools=[search_nimbus_docs, calculator, get_today],
    system_prompt=(
        "You answer questions about the Nimbus app. "
        "Use search_nimbus_docs for any product facts, and calculator for arithmetic. "
        "Base answers only on retrieved facts."
    ),
)

q = "How much would Nimbus Pro cost a team of 4 for a full year?"
result = smart_agent.invoke({"messages": [{"role": "user", "content": q}]})
print(result["messages"][-1].content)

To answer a question like this correctly, the agent has to first search for the monthly subscription price, and only then compute 8 * 12 * 4. That's retrieval (from the first tutorial) exposed as a tool (from the third), driven by an agent (from the second) — three separate ideas working as one system. Letting the agent choose when to retrieve is considerably more flexible than the fixed-path rag_chain from Step 2, and it's a pattern that shows up often in real production systems.

Your turn: Stream this agent's execution too, using smart_agent.stream(..., stream_mode="updates"), and confirm the order of operations — search happening before the calculation. If your local model tries to solve the arithmetic in its head instead of invoking the calculator tool (a common tendency with smaller models), tighten the system prompt with something like "You MUST use the calculator for every arithmetic step." It's the same remedy that worked in the agents tutorial.

Step 6 — A quick tour of what else is in the box

At this point you have the essential skeleton in place. A handful of additional LangChain building blocks are worth knowing about, each one mapping back to something you've already built by hand:

  • Document loaders (langchain-community) — ingest PDFs, web pages, Notion pages, and similar sources directly into the same Document objects your text splitter already consumes. This replaces manually pasting text into a list with real, structured ingestion.
  • Production-grade vector stores — swap the in-memory store for Chroma or FAISS (imported via from langchain_chroma import Chroma) to persist embeddings on disk and scale beyond what memory allows. Because both expose the same .as_retriever() interface, nothing downstream in your chain has to change — that consistency is the whole point of the swap.
  • Memory and message history — wrap a chain so it retains context from earlier turns, turning a single-shot chain into an ongoing conversation.
  • Output parsers beyond plain strings — coerce a model's response into JSON or a validated Pydantic object, rather than hoping the raw text happens to be well-formed.
  • LangGraph — once the looping logic baked into create_agent isn't sufficient (branching paths, human-in-the-loop approval steps, multiple agents cooperating), you step down to LangGraph, the lower-level graph engine that create_agent itself is built on top of.

Step 7 — When to reach for LangChain, and when not to (an honest take)

At this point you've built the same kind of system twice — once from scratch, once with the framework — which puts you in a good position to make this call yourself. That was the point of going through both versions.

LangChain pays off when you're stitching together many existing integrations — a handful of document loaders, several vector stores, more than one model provider — and you don't want to hand-roll streaming, batching, retry logic, and tracing for each of them. It also pays off when you expect to swap pieces frequently and want a stable interface to swap them behind, or when you're building an agent and would rather not maintain the reasoning loop yourself.

Writing things by hand is often the better call when the app is small enough that learning LangChain's abstractions would take longer than just writing the fifty-odd lines you already know how to write. It's also the better call when you need full visibility into what's executing — stepping through framework layers to debug an issue is a genuine source of friction, and that complaint is fair — or when adding a layer of indirection would hide logic that's actually clearer as plain Python. The RAG pipeline and the agent you built by hand in earlier tutorials are entirely valid for production use; nothing about using a framework makes hand-written code lesser.

There's no single right answer here. The reason you learned the manual version first is so that choosing to adopt the framework is a deliberate decision made with full knowledge of what it's replacing, not a default you fall back on because the internals are a mystery.

Step 8 — Where to head from here

  • If your local setup feels too slow, free hosted model tiers are available from Groq and Google Gemini. Switching is a one-line change — swap ChatOllama for ChatGroq, or use init_chat_model("gemini-...", model_provider="google_genai") — because everything else sits behind the same standard interface. You'll need an API key for either, but their free tiers don't cost anything.
  • LangSmith is LangChain's tracing and debugging tool. When a chain or agent behaves oddly, it lets you inspect every step, every input, and every output. It has a free tier, and it's the direct answer to the "I can't see inside the framework" complaint.
  • LangGraph is worth exploring once you need stateful workflows, multiple cooperating agents, or human-in-the-loop checkpoints that go beyond a single tool-calling loop.
  • The official documentation lives at docs.langchain.com. Double-check that whatever you're reading targets version 1.x — anything written before 1.0 will reference AgentExecutor, initialize_agent, or LLMChain, all of which have since moved or been deprecated.

The mental model worth keeping

LangChain is essentially your own hand-built components, standardized behind a single interface — Runnable — and connected with |. Nothing in it is a genuinely new idea once you've built the pieces yourself:

  • A chain is the prompt-to-model-to-parser flow you already wrote, just piped together.
  • A retriever is your embed-and-cosine-search logic, wrapped in a common interface.
  • A tool is a function you wrote plus an automatically generated schema, letting the model call it directly instead of you parsing its text output.
  • An agent, via create_agent, is your entire reason-act-observe loop, collapsed into a single call.

When something goes wrong, you debug it the same way you always would: isolate the component at fault. Test the retriever on its own, print a tool's .args, or stream the agent's intermediate steps. The framework only changes how much code you type — it doesn't change what's actually happening, and you already understand what's happening.

Troubleshooting

  • If you hit an ImportError on create_agent or langchain_ollama, you're likely on a pre-1.0 install or missing a package. Run pip install -U langchain langchain-ollama and check that langchain.__version__ starts with 1..
  • If a tutorial you're reading uses AgentExecutor or initialize_agent, that's the older API. In version 1.x it's been moved into langchain-classic; new code should use create_agent instead.
  • A "connection refused" error from Ollama means the server isn't running — start it with ollama serve or open the app, and confirm your model shows up under ollama list.
  • If the agent ignores a tool or tries to do arithmetic on its own, the model likely isn't tool-calling reliably, which is a common limitation of smaller models, or the docstring is too vague. Tighten up the system prompt and the tool's docstring, or try a stronger model like qwen2.5.
  • HuggingFaceEmbeddings feels slow the first time you use it because it's downloading the embedding model (roughly 80 MB) and caching it locally. Retrieval itself runs fast afterward.
  • The very first call to a model through Ollama is slow because it's loading the model into RAM; subsequent calls are quick.

You've now built RAG, agents, and the framework that wraps both of them — first by hand, then with LangChain. You understand the layer that most people only ever call from the outside. Have fun putting it to use.