Generative AI has gone from research curiosity to production reality at remarkable speed. Large language models can now summarise documents, write code, answer questions, and—when given tools—autonomously take actions in the world. But getting from "I've heard of ChatGPT" to "I can build my own agentic system" requires bridging a lot of conceptual gaps.
This post contains an interactive Jupyter notebook that walks you through that entire journey—from spinning up your first LLM call to orchestrating a multi-agent pipeline—all in one place, with runnable Python code at every step.
Who is this for? Developers with Python experience who are new to LLMs and want a practical, end-to-end foundation rather than scattered tutorials. The notebook targets beginner → intermediate level and supports four providers: Ollama (fully local), OpenAI, Google Gemini, and DeepSeek—so you can follow along regardless of which API keys you have.
What's Inside
The notebook is structured as eleven self-contained sections that build on each other:
Table of Contents
- Setup & Provider Configuration — install dependencies, pick your LLM provider, and configure credentials in one place.
- LLM Basics — your first
invoke()call, understanding messages, roles, and the chat model interface. - Prompt Engineering — system prompts, few-shot examples, temperature, and structured output with
Pydantic. - Chains — composing steps with LangChain Expression Language (LCEL): prompt → model → parser pipelines.
- Memory & Conversation History — stateful chat,
ConversationBufferMemory, and trimming strategies. - RAG (Retrieval-Augmented Generation) — embedding documents into
Chroma, semantic search, and grounded Q&A. - Tools & Function Calling — define tools, bind them to the model, and handle tool invocation responses.
- Agents —
ReActloop,create_react_agent, and letting the model decide which tools to call. - Multi-Agent Systems — routing between specialist agents with a supervisor pattern.
- Agentic Patterns — planning, reflection, self-critique, and iterative refinement loops.
- Mini Agentic App — everything wired together into one small but complete application.
How to Run It Yourself
You can clone or download the notebook and run it locally. Prerequisites:
- Python 3.10 or later
- A running Ollama instance or an API key for OpenAI / Gemini / DeepSeek
- Run the first cell (
%pip install ...) to install all dependencies - Set your provider and credentials in the Section 1 config cell—the rest of the notebook picks them up automatically
The Notebook
The full notebook is below. Each section opens with a short explanation followed by runnable Python code cells. Download it to run locally, or read through it here as a reference.
GenAI, Agents & Agentic AI — Complete Learning Notebook
Level: Beginner → Intermediate
Providers supported: Ollama (local) · OpenAI · Google Gemini · DeepSeek
Learning Path
| # | Topic | Concepts Covered |
|---|---|---|
| 1 | Setup & Provider Config | Install libs, configure keys, pick a provider |
| 2 | LLM Basics | Tokens, temperature, inference, completion vs chat |
| 3 | Prompt Engineering | Zero-shot, few-shot, CoT, system prompts, templates |
| 4 | Chains | Sequential chains, prompt → LLM → parser |
| 5 | Memory | Conversation history, buffer, summary memory |
| 6 | RAG (Retrieval-Augmented Generation) | Embeddings, vector stores, retrieval, Q&A |
| 7 | Tools & Function Calling | Defining tools, structured output, tool use |
| 8 | Agents | ReAct agent, tool selection, reasoning loop |
| 9 | Multi-Agent Systems | Orchestrator/worker, handoffs, parallelism |
| 10 | Agentic Patterns | Planning, reflection, self-critique, loops |
Tip: Run cells top-to-bottom. Set your provider once in Section 1 and everything else adapts.
Section 1 — Setup & Provider Configuration
# Install all required packages
# Re-run this cell any time you start a fresh environment
# Remove -v if you want quiet output; add -q for silent mode
%pip install -v \
langchain \
langchain-ollama \
langchain-openai \
langchain-google-genai \
langchain-chroma \
langchain-core \
langgraph \
chromadb \
ollama \
google-generativeai \
openai \
tiktoken \
faiss-cpu \
sentence-transformers \
pypdf \
httpx \
ipywidgets
print("All packages installed.")
from IPython.display import display, Markdown
# ============================================================
# CONFIGURE YOUR PROVIDER HERE — change PROVIDER and fill in
# the relevant key/model. Everything else in the notebook
# uses `llm` and `embeddings` defined below.
# ============================================================
import os
# Choose: "ollama" | "openai" | "gemini" | "deepseek"
PROVIDER = "ollama"
# --- Ollama (runs locally, no key needed) ---
OLLAMA_BASE_URL = "http://localhost:11434"
OLLAMA_MODEL = "llama3.2:latest" # change to any model you have pulled
OLLAMA_EMBED = "nomic-embed-text:latest"
# --- OpenAI ---
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "sk-...")
OPENAI_MODEL = "gpt-4o-mini"
# --- Google Gemini ---
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "...")
GEMINI_MODEL = "gemini-1.5-flash"
# --- DeepSeek (OpenAI-compatible endpoint) ---
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "sk-...")
DEEPSEEK_MODEL = "deepseek-chat"
DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1"
print(f"Provider set to: {PROVIDER}")
# Build the shared `llm` and `embeddings` objects used throughout
from langchain_core.language_models.chat_models import BaseChatModel
if PROVIDER == "ollama":
from langchain_ollama import ChatOllama, OllamaEmbeddings
llm = ChatOllama(model=OLLAMA_MODEL, base_url=OLLAMA_BASE_URL, temperature=0.7)
embeddings = OllamaEmbeddings(model=OLLAMA_EMBED, base_url=OLLAMA_BASE_URL)
elif PROVIDER == "openai":
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
llm = ChatOpenAI(model=OPENAI_MODEL, api_key=OPENAI_API_KEY, temperature=0.7)
embeddings = OpenAIEmbeddings(api_key=OPENAI_API_KEY)
elif PROVIDER == "gemini":
from langchain_google_genai import ChatGoogleGenerativeAI, GoogleGenerativeAIEmbeddings
llm = ChatGoogleGenerativeAI(model=GEMINI_MODEL, google_api_key=GOOGLE_API_KEY, temperature=0.7)
embeddings = GoogleGenerativeAIEmbeddings(model="models/embedding-001", google_api_key=GOOGLE_API_KEY)
elif PROVIDER == "deepseek":
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
llm = ChatOpenAI(
model=DEEPSEEK_MODEL,
api_key=DEEPSEEK_API_KEY,
base_url=DEEPSEEK_BASE_URL,
temperature=0.7
)
# DeepSeek does not expose embeddings; fall back to Ollama local embeddings
from langchain_ollama import OllamaEmbeddings
embeddings = OllamaEmbeddings(model=OLLAMA_EMBED, base_url=OLLAMA_BASE_URL)
else:
raise ValueError(f"Unknown provider: {PROVIDER}")
print(f"LLM ready: {llm}")
print(f"Embeddings ready: {embeddings}")
Section 2 — LLM Basics
Key concepts: - Token: smallest unit of text the model processes (~0.75 words) - Temperature: controls randomness (0 = deterministic, 1+ = creative) - Context window: max tokens the model can see at once - Completion vs Chat: older models do text completion; modern models use a chat (message) format - System / Human / AI messages: the three roles in a conversation
# 2a. Simple single-turn call
from IPython.display import display, Markdown
from langchain_core.messages import HumanMessage
response = llm.invoke([HumanMessage(content="What is a large language model? Explain in 3 sentences.")])
display(Markdown(response.content))
# 2b. System + Human messages
from IPython.display import display, Markdown
from langchain_core.messages import SystemMessage, HumanMessage
messages = [
SystemMessage(content="You are a concise technical tutor. Always answer in bullet points."),
HumanMessage(content="What are tokens in the context of LLMs?")
]
response = llm.invoke(messages)
display(Markdown(response.content))
# 2c. Temperature effect — compare deterministic vs creative output
from IPython.display import display, Markdown
from langchain_core.messages import HumanMessage
if PROVIDER == "ollama":
from langchain_ollama import ChatOllama
llm_cold = ChatOllama(model=OLLAMA_MODEL, base_url=OLLAMA_BASE_URL, temperature=0.0)
llm_hot = ChatOllama(model=OLLAMA_MODEL, base_url=OLLAMA_BASE_URL, temperature=1.2)
elif PROVIDER == "openai":
from langchain_openai import ChatOpenAI
llm_cold = ChatOpenAI(model=OPENAI_MODEL, api_key=OPENAI_API_KEY, temperature=0.0)
llm_hot = ChatOpenAI(model=OPENAI_MODEL, api_key=OPENAI_API_KEY, temperature=1.2)
elif PROVIDER == "gemini":
from langchain_google_genai import ChatGoogleGenerativeAI
llm_cold = ChatGoogleGenerativeAI(model=GEMINI_MODEL, google_api_key=GOOGLE_API_KEY, temperature=0.0)
llm_hot = ChatGoogleGenerativeAI(model=GEMINI_MODEL, google_api_key=GOOGLE_API_KEY, temperature=1.0)
else:
llm_cold = llm_hot = llm
question = [HumanMessage(content="Give me one creative name for an AI assistant.")]
cold_names = [llm_cold.invoke(question).content.strip() for _ in range(3)]
hot_names = [llm_hot.invoke(question).content.strip() for _ in range(3)]
display(Markdown("### Cold (deterministic, temperature=0)\n" + "\n".join(f"- {n}" for n in cold_names)))
display(Markdown("### Hot (creative, temperature=1.2)\n" + "\n".join(f"- {n}" for n in hot_names)))
# 2d. Streaming output — see tokens arrive in real-time
from IPython.display import display, Markdown
from langchain_core.messages import HumanMessage
chunks = []
for chunk in llm.stream([HumanMessage(content="Tell me a very short joke about AI.")]):
print(chunk.content, end="", flush=True)
chunks.append(chunk.content)
print()
display(Markdown("---\n**Rendered:**\n\n" + "".join(chunks)))
Section 3 — Prompt Engineering
Key concepts: - Zero-shot: ask without examples - Few-shot: give examples in the prompt to guide output format - Chain-of-Thought (CoT): ask the model to reason step-by-step before answering - Prompt templates: reusable prompts with variables - Output parsers: extract structured data from model responses
# 3a. PromptTemplate — reusable prompt with variables
from IPython.display import display, Markdown
from langchain_core.prompts import ChatPromptTemplate
template = ChatPromptTemplate.from_messages([
("system", "You are an expert in {domain}."),
("human", "Explain {concept} to a {audience}.")
])
chain = template | llm
result = chain.invoke({
"domain": "machine learning",
"concept": "gradient descent",
"audience": "10-year-old"
})
display(Markdown(result.content))
# 3b. Few-shot prompting — guide output format with examples
from IPython.display import display, Markdown
from langchain_core.prompts import ChatPromptTemplate
few_shot_template = ChatPromptTemplate.from_messages([
("system", "You classify customer feedback as Positive, Negative, or Neutral. Reply with only the label."),
("human", "The product broke after one day."),
("ai", "Negative"),
("human", "It's okay, nothing special."),
("ai", "Neutral"),
("human", "Absolutely love it, best purchase ever!"),
("ai", "Positive"),
("human", "{feedback}")
])
chain = few_shot_template | llm
tests = [
"Delivery was late but the item was fine.",
"Terrible customer service, would not recommend.",
"Exceeded my expectations!"
]
rows = ["| Feedback | Sentiment |", "|----------|-----------|"]
for t in tests:
label = chain.invoke({"feedback": t}).content.strip()
rows.append(f"| {t} | **{label}** |")
display(Markdown("\n".join(rows)))
# 3c. Chain-of-Thought (CoT) — ask to reason before answering
from IPython.display import display, Markdown
from langchain_core.prompts import ChatPromptTemplate
cot_prompt = ChatPromptTemplate.from_messages([
("system", "You are a careful reasoner. Think step-by-step before giving your final answer. Format: REASONING: ... ANSWER: ..."),
("human", "{question}")
])
chain = cot_prompt | llm
result = chain.invoke({"question": "If I have 3 boxes with 4 apples each, and I give away 5 apples, how many do I have left?"})
display(Markdown(result.content))
# 3d. Structured output with JSON — output parser
from IPython.display import display, Markdown
from langchain_core.output_parsers import JsonOutputParser
from langchain_core.prompts import ChatPromptTemplate
import json
json_prompt = ChatPromptTemplate.from_messages([
("system", """You extract structured information. Always respond with valid JSON only.
Schema: {{"name": string, "country": string, "founded_year": int, "main_product": string}}"""),
("human", "Tell me about: {company}")
])
chain = json_prompt | llm | JsonOutputParser()
result = chain.invoke({"company": "Tesla"})
display(Markdown(f"```json\n{json.dumps(result, indent=2)}\n```"))
Section 4 — Chains
Key concepts:
- LCEL (LangChain Expression Language): pipe | operator to compose components
- Sequential chain: output of one step feeds into the next
- Parallel chain: run multiple LLM calls simultaneously
- Branching: route to different chains based on content
# 4a. Simple LCEL chain: prompt → llm → string parser
from IPython.display import display, Markdown
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
summarize_chain = (
ChatPromptTemplate.from_template("Summarize the following text in one sentence:\n\n{text}")
| llm
| StrOutputParser()
)
text = """The James Webb Space Telescope (JWST) is a space telescope designed to conduct infrared
astronomy. Its high resolution and sensitivity allow it to view objects too old, distant, or faint
for the Hubble Space Telescope. It is the largest optical telescope in space."""
result = summarize_chain.invoke({"text": text})
display(Markdown(result))
# 4b. Sequential chain — translate then explain
from IPython.display import display, Markdown
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
translate_prompt = ChatPromptTemplate.from_template(
"Translate the following text to French:\n{text}"
)
explain_prompt = ChatPromptTemplate.from_template(
"Explain why this French sentence is grammatically correct:\n{translated}"
)
parser = StrOutputParser()
full_chain = (
translate_prompt | llm | parser
| (lambda translated: {"translated": translated})
| explain_prompt | llm | parser
)
result = full_chain.invoke({"text": "The sun rises in the east."})
display(Markdown(result))
# 4c. Parallel chains using RunnableParallel
from IPython.display import display, Markdown
from langchain_core.runnables import RunnableParallel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
pros_chain = ChatPromptTemplate.from_template("List 3 pros of {topic}") | llm | StrOutputParser()
cons_chain = ChatPromptTemplate.from_template("List 3 cons of {topic}") | llm | StrOutputParser()
parallel = RunnableParallel(pros=pros_chain, cons=cons_chain)
result = parallel.invoke({"topic": "working from home"})
display(Markdown(f"### Pros\n{result['pros']}\n\n### Cons\n{result['cons']}"))
Section 5 — Memory & Conversation History
Key concepts:
- Stateless LLMs: by default, each call is independent — no memory
- Conversation buffer: store the full chat history and pass it each turn
- Summary memory: summarize older turns to stay within context window
- LangGraph persistence: the modern way to add memory — graph state is checkpointed per thread_id
# 5a. Manual message history — see what "memory" means at the raw level
from IPython.display import display, Markdown
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
history = [SystemMessage(content="You are a helpful assistant. Remember details the user shares.")]
def chat(user_input):
history.append(HumanMessage(content=user_input))
response = llm.invoke(history)
history.append(AIMessage(content=response.content))
return response.content
display(Markdown(f"**User:** Hi, my name is Alex and I love hiking.\n\n**AI:** {chat('Hi, my name is Alex and I love hiking.')}"))
display(Markdown(f"**User:** What do I love doing?\n\n**AI:** {chat('What do I love doing?')}"))
display(Markdown(f"**User:** What is my name?\n\n**AI:** {chat('What is my name?')}"))
# 5b. LangGraph persistent memory — modern replacement for RunnableWithMessageHistory
from IPython.display import display, Markdown
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, MessagesState, StateGraph
from langchain_core.messages import HumanMessage, SystemMessage
# Define a single node that calls the LLM with full message history
def call_llm(state: MessagesState):
system = SystemMessage(content="You are a friendly assistant.")
response = llm.invoke([system] + state["messages"])
return {"messages": response}
# Build a minimal graph: START → llm node
builder = StateGraph(MessagesState)
builder.add_node("llm", call_llm)
builder.add_edge(START, "llm")
# MemorySaver checkpoints the full message history per thread_id in-memory
graph = builder.compile(checkpointer=MemorySaver())
cfg = {"configurable": {"thread_id": "user_1"}}
def chat_lg(user_input):
result = graph.invoke({"messages": [HumanMessage(content=user_input)]}, config=cfg)
return result["messages"][-1].content
r1 = chat_lg("My favourite colour is blue.")
r2 = chat_lg("What is my favourite colour?")
display(Markdown(f"**Turn 1:** My favourite colour is blue.\n\n**AI:** {r1}"))
display(Markdown(f"**Turn 2:** What is my favourite colour?\n\n**AI:** {r2}"))
# 5c. Summary memory concept — summarise old turns to save tokens
from IPython.display import display, Markdown
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
old_conversation = """
User: I'm planning a trip to Japan in October.
AI: Great choice! October is beautiful in Japan — autumn foliage begins.
User: I want to visit Tokyo and Kyoto.
AI: Perfect itinerary. Tokyo for modern culture, Kyoto for temples and history.
User: I have 10 days total.
AI: Suggested split: 5 days Tokyo, 4 days Kyoto, 1 day Nara as a day trip.
"""
summarize = (
ChatPromptTemplate.from_template(
"Summarize this conversation history into 2-3 bullet points for context:\n{history}"
) | llm | StrOutputParser()
)
summary = summarize.invoke({"history": old_conversation})
display(Markdown(f"### Summary stored as context\n{summary}"))
Section 6 — RAG (Retrieval-Augmented Generation)
Key concepts: - Embeddings: numerical vector representations of text (similar texts → similar vectors) - Vector store: database for storing and searching embeddings (ChromaDB, FAISS) - Semantic search: find relevant text by meaning, not keyword match - RAG pipeline: Retrieve relevant docs → Augment prompt → Generate answer - Why RAG? Extends LLM knowledge with your own data without retraining
# 6a. Embeddings — visualise what they are
from IPython.display import display, Markdown
import numpy as np
sentences = [
"The cat sat on the mat.",
"A feline rested on the rug.",
"The stock market crashed today.",
]
vecs = embeddings.embed_documents(sentences)
def cosine(a, b):
a, b = np.array(a), np.array(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
sim_1_2 = cosine(vecs[0], vecs[1])
sim_1_3 = cosine(vecs[0], vecs[2])
display(Markdown(f"""
**Embedding dimension:** {len(vecs[0])}
| Pair | Cosine Similarity |
|------|-------------------|
| cat / feline (similar) | `{sim_1_2:.4f}` |
| cat / stock market (unrelated) | `{sim_1_3:.4f}` |
> Higher score = more semantically similar
"""))
# 6b. Build a vector store from sample documents
from langchain_chroma import Chroma
from langchain_core.documents import Document
docs = [
Document(page_content="Python is a high-level, interpreted programming language known for readability.", metadata={"source": "python_wiki"}),
Document(page_content="Python supports multiple programming paradigms including procedural, object-oriented, and functional.", metadata={"source": "python_wiki"}),
Document(page_content="Rust is a systems programming language focused on safety, speed, and concurrency.", metadata={"source": "rust_wiki"}),
Document(page_content="Rust's borrow checker prevents memory bugs at compile time without a garbage collector.", metadata={"source": "rust_wiki"}),
Document(page_content="JavaScript is the primary language for web frontend development, running in browsers.", metadata={"source": "js_wiki"}),
Document(page_content="Node.js allows JavaScript to run on the server side, enabling full-stack JS development.", metadata={"source": "js_wiki"}),
Document(page_content="Go (Golang) is designed for simplicity and high-performance concurrent server applications.", metadata={"source": "go_wiki"}),
]
vectorstore = Chroma.from_documents(docs, embedding=embeddings, collection_name="programming_langs")
print(f"Vector store created with {vectorstore._collection.count()} documents.")
# 6c. Semantic search
from IPython.display import display, Markdown
query = "What language prevents memory bugs at compile time?"
results = vectorstore.similarity_search(query, k=2)
rows = [f"**Query:** {query}\n", "| # | Source | Content |", "|---|--------|---------|"]
for i, doc in enumerate(results):
rows.append(f"| {i+1} | `{doc.metadata['source']}` | {doc.page_content} |")
display(Markdown("\n".join(rows)))
# 6d. Full RAG pipeline — retrieval + generation
from IPython.display import display, Markdown
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
rag_prompt = ChatPromptTemplate.from_messages([
("system", """You are a helpful assistant. Answer the question using ONLY the context provided.
If the answer is not in the context, say 'I don't know based on the provided information.'
Context:
{context}"""),
("human", "{question}")
])
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| rag_prompt
| llm
| StrOutputParser()
)
questions = [
"Which language is best for web frontend development?",
"What makes Rust special compared to other languages?",
"What is the capital of France?"
]
for q in questions:
answer = rag_chain.invoke(q)
display(Markdown(f"**Q:** {q}\n\n**A:** {answer}\n\n---"))
# 6e. Text splitter — handling large documents
from langchain_text_splitters import RecursiveCharacterTextSplitter
long_text = """Artificial intelligence (AI) is intelligence demonstrated by machines, as opposed to the natural
intelligence displayed by animals including humans. AI research has been defined as the field of study of
intelligent agents, which refers to any system that perceives its environment and takes actions that maximize
its chance of achieving its goals. The term artificial intelligence had previously been used to describe
machines that mimic and display human cognitive skills associated with the human mind, such as learning and
problem-solving. This definition has since been rejected by major AI researchers who now describe AI in terms
of rationality and acting rationally, which does not limit how intelligence can be articulated.
AI applications include advanced web search engines, recommendation systems (used by YouTube, Amazon and Netflix),
understanding human speech (such as Siri and Alexa), self-driving cars (such as Waymo), generative or creative
tools (ChatGPT and AI art), automated decision-making and competing at the highest level in strategic game
systems (such as chess and Go). As machines become increasingly capable, tasks considered to require intelligence
are often removed from the definition of AI, a phenomenon known as the AI effect."""
splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=30)
chunks = splitter.create_documents([long_text])
print(f"Split into {len(chunks)} chunks:")
for i, c in enumerate(chunks):
print(f" Chunk {i+1} ({len(c.page_content)} chars): {c.page_content[:80]}...")
Section 7 — Tools & Function Calling
Key concepts: - Tools: functions the LLM can choose to call (calculator, search, API) - Function calling: the model outputs a structured JSON call instead of plain text - Tool schema: name + description + input schema — the model reads descriptions to decide when to use a tool - Structured output: force the model to return a specific schema
# 7a. Define tools using @tool decorator
from langchain_core.tools import tool
import math
import datetime
@tool
def calculator(expression: str) -> str:
"""Evaluate a mathematical expression. Input must be a valid Python math expression.
Examples: '2 + 2', 'math.sqrt(144)', '15 * 7 / 3'"""
try:
result = eval(expression, {"__builtins__": {}}, {"math": math})
return str(result)
except Exception as e:
return f"Error: {e}"
@tool
def get_current_date(timezone: str = "UTC") -> str:
"""Get the current date and time. Optionally specify a timezone name."""
return datetime.datetime.now().strftime(f"%Y-%m-%d %H:%M:%S ({timezone})")
@tool
def word_count(text: str) -> str:
"""Count the number of words in a text string."""
return str(len(text.split()))
print("Tools defined:")
for t in [calculator, get_current_date, word_count]:
print(f" - {t.name}: {t.description[:60]}...")
# 7b. Bind tools to the LLM and handle a tool call manually
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
tools = [calculator, get_current_date, word_count]
llm_with_tools = llm.bind_tools(tools)
messages = [HumanMessage(content="What is 15% of 240? Also what is today's date?")]
# First call — model may return tool calls instead of a final answer
ai_msg = llm_with_tools.invoke(messages)
messages.append(ai_msg)
print(f"Tool calls requested: {len(ai_msg.tool_calls)}")
for tc in ai_msg.tool_calls:
print(f" Tool: {tc['name']}, Args: {tc['args']}")
# Execute each tool
tool_map = {t.name: t for t in tools}
for tc in ai_msg.tool_calls:
result = tool_map[tc["name"]].invoke(tc["args"])
messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
# Second call — model now has tool results, produces final answer
final = llm_with_tools.invoke(messages)
print(f"\nFinal answer: {final.content}")
# 7c. Structured output — force model to return a specific schema
from pydantic import BaseModel, Field
from typing import List
class MovieReview(BaseModel):
title: str = Field(description="Movie title")
year: int = Field(description="Release year")
sentiment: str = Field(description="Overall sentiment: positive, negative, or mixed")
score: float = Field(description="Score out of 10")
pros: List[str] = Field(description="List of positive aspects")
cons: List[str] = Field(description="List of negative aspects")
structured_llm = llm.with_structured_output(MovieReview)
review_text = """
Interstellar (2014) is a visually stunning sci-fi epic. The space sequences are breathtaking,
Hans Zimmer's score is unforgettable, and McConaughey's performance is moving. However,
the third act gets overly convoluted and the science is sometimes stretched too thin.
Overall a great film, 8/10.
"""
review = structured_llm.invoke(f"Extract a structured review from this text:\n{review_text}")
print(review.model_dump_json(indent=2))
Section 8 — Agents
Key concepts: - Agent: an LLM that can choose which tools to use, call them, observe results, and repeat - ReAct pattern: Reason → Act → Observe → (repeat until done) - Reasoning loop: the agent keeps calling tools until it has enough info to answer - Agent executor: runtime that manages the tool-call loop - Stopping condition: agent decides when it has a final answer
# 8a. ReAct agent — see the reasoning loop step by step
from IPython.display import display, Markdown
from langchain.agents import create_agent
from langchain_core.tools import tool
import math, datetime
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression. Example: '2 ** 10' or 'math.sqrt(144)'"""
try:
return str(eval(expression, {"__builtins__": {}}, {"math": math}))
except Exception as e:
return f"Error: {e}"
@tool
def get_current_year(dummy: str = "") -> str:
"""Returns the current year as a string."""
return str(datetime.datetime.now().year)
@tool
def string_reverse(text: str) -> str:
"""Reverses a string. Input: any text string."""
return text[::-1]
agent_tools = [calculator, get_current_year, string_reverse]
# create_agent builds a StateGraph with a tool-calling loop internally
agent = create_agent(llm, agent_tools)
result = agent.invoke({
"messages": [("human", "What is 2 to the power of 10, and what is that number reversed as a string?")]
})
answer = result["messages"][-1].content
display(Markdown(f"**Answer:** {answer}"))
# Show the reasoning steps
display(Markdown("---\n**Reasoning trace:**"))
for msg in result["messages"]:
role = msg.__class__.__name__.replace("Message", "")
content = msg.content or str(getattr(msg, "tool_calls", ""))
display(Markdown(f"- **{role}:** {str(content)[:200]}"))
# 8b. create_agent with a custom system prompt
from IPython.display import display, Markdown
from langchain.agents import create_agent
# Pass a system prompt string directly
agent = create_agent(
llm,
agent_tools,
system_prompt="You are a helpful assistant. Use tools when needed to answer accurately."
)
result = agent.invoke({
"messages": [("human", "What year is it, and what is sqrt(year) rounded to 2 decimal places?")]
})
answer = result["messages"][-1].content
display(Markdown(f"**Answer:** {answer}"))
# 8c. RAG-enabled agent — agent can choose to retrieve from the knowledge base
from IPython.display import display, Markdown
from langchain.agents import create_agent
from langchain_core.tools import tool
import math
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression. Example: '2 ** 10' or 'math.sqrt(144)'"""
try:
return str(eval(expression, {"__builtins__": {}}, {"math": math}))
except Exception as e:
return f"Error: {e}"
@tool
def search_programming_knowledge(query: str) -> str:
"""Search the programming languages knowledge base. Use for questions about Python, Rust, JavaScript, or Go."""
results = vectorstore.similarity_search(query, k=2)
return "\n".join(doc.page_content for doc in results)
rag_tools = [calculator, search_programming_knowledge]
rag_agent = create_agent(
llm,
rag_tools,
system_prompt="You are a knowledgeable assistant. Always search the knowledge base before answering."
)
result = rag_agent.invoke({
"messages": [("human", "Compare Python and Rust. Which one prevents memory bugs at compile time?")]
})
display(Markdown(f"**Answer:** {result['messages'][-1].content}"))
Section 9 — Multi-Agent Systems
Key concepts: - Orchestrator: a central agent that plans and delegates tasks - Worker/Specialist agent: focused on one domain (code, research, math) - Handoff: passing a task from one agent to another - Why multi-agent? Complex tasks benefit from specialisation and parallelism - Agent-as-tool: wrap one agent as a callable tool for another agent
# 9a. Specialist agents used as tools by an orchestrator
from IPython.display import display, Markdown
from langchain.agents import create_agent
from langchain_core.tools import tool
# --- Specialist: Code Explainer ---
code_agent = create_agent(
llm, [],
system_prompt="You are an expert programmer. Explain code clearly and concisely."
)
# --- Specialist: Writer ---
writer_agent = create_agent(
llm, [],
system_prompt="You are a professional writer. Produce clear, engaging content."
)
# --- Wrap specialists as tools for the orchestrator ---
@tool
def ask_code_agent(task: str) -> str:
"""Delegate a coding or code-explanation task to the specialist code agent."""
result = code_agent.invoke({"messages": [("human", task)]})
return result["messages"][-1].content
@tool
def ask_writer_agent(task: str) -> str:
"""Delegate a writing or content-creation task to the specialist writer agent."""
result = writer_agent.invoke({"messages": [("human", task)]})
return result["messages"][-1].content
# --- Orchestrator ---
orchestrator = create_agent(
llm,
[ask_code_agent, ask_writer_agent],
system_prompt="""You are an orchestrator. Break down complex tasks and delegate to the right specialist:
- ask_code_agent: for coding, code explanation, technical topics
- ask_writer_agent: for writing, summarising, or creative content
Combine their outputs into a final cohesive answer."""
)
result = orchestrator.invoke({
"messages": [("human", "Explain what a Python list comprehension is and then write a short blog intro about why Python is beginner-friendly.")]
})
display(Markdown(f"### Final Answer\n{result['messages'][-1].content}"))
# 9b. Parallel multi-agent execution (manual)
import concurrent.futures
from IPython.display import display, Markdown
from langchain_core.messages import HumanMessage
def run_agent(role, system_prompt, question):
from langchain_core.messages import SystemMessage, HumanMessage
response = llm.invoke([
SystemMessage(content=system_prompt),
HumanMessage(content=question)
])
return role, response.content
agents_config = [
("Optimist", "You are an optimist. Find the positive aspects of any topic.", "What is the impact of AI on jobs?"),
("Pessimist", "You are a pessimist. Find the risks and downsides of any topic.", "What is the impact of AI on jobs?"),
("Neutral", "You are a balanced analyst. Give a factual, neutral assessment.", "What is the impact of AI on jobs?"),
]
with concurrent.futures.ThreadPoolExecutor() as pool:
futures = [pool.submit(run_agent, *cfg) for cfg in agents_config]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
for role, answer in sorted(results):
display(Markdown(f"### {role}\n{answer}"))
Section 10 — Agentic Patterns
Key concepts: - Planning: agent decomposes a goal into sub-tasks before acting - Reflection / self-critique: agent reviews and improves its own output - Tool-use loops: repeat until a stopping condition is met - Human-in-the-loop: pause for human approval before proceeding - Evaluator-Optimizer: one LLM generates, another scores/critiques, loop until quality threshold - Map-Reduce: process many items in parallel (map), synthesise results (reduce)
# 10a. Planning pattern — decompose a goal before acting
from IPython.display import display, Markdown
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser
plan_prompt = ChatPromptTemplate.from_messages([
("system", """You are a planning agent. Break down the user's goal into a numbered list of concrete sub-tasks.
Return JSON: {{"goal": string, "tasks": [{{"id": int, "task": string, "depends_on": []}}]}}"""),
("human", "Goal: {goal}")
])
planner = plan_prompt | llm | JsonOutputParser()
plan = planner.invoke({"goal": "Build a simple weather app that shows current temperature"})
rows = [f"**Goal:** {plan['goal']}\n", "| # | Task | Depends On |", "|---|------|------------|"]
for task in plan["tasks"]:
deps = ", ".join(str(d) for d in task["depends_on"]) if task["depends_on"] else "—"
rows.append(f"| {task['id']} | {task['task']} | {deps} |")
display(Markdown("\n".join(rows)))
# 10b. Reflection pattern — generate → critique → improve
from IPython.display import display, Markdown
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
generate_prompt = ChatPromptTemplate.from_template(
"Write a short paragraph explaining {topic} for a beginner."
)
critique_prompt = ChatPromptTemplate.from_messages([
("system", "You are a strict writing critic. Identify specific weaknesses: unclear terms, missing context, poor flow. Be concise."),
("human", "Critique this explanation:\n\n{draft}")
])
improve_prompt = ChatPromptTemplate.from_messages([
("system", "You are a writing improver. Fix the issues identified in the critique."),
("human", "Original:\n{draft}\n\nCritique:\n{critique}\n\nWrite an improved version:")
])
parser = StrOutputParser()
topic = "neural networks"
draft = (generate_prompt | llm | parser).invoke({"topic": topic})
critique = (critique_prompt | llm | parser).invoke({"draft": draft})
improved = (improve_prompt | llm | parser).invoke({"draft": draft, "critique": critique})
display(Markdown(f"### Draft\n{draft}"))
display(Markdown(f"### Critique\n{critique}"))
display(Markdown(f"### Improved\n{improved}"))
# 10c. Evaluator-Optimizer loop — iterate until quality score >= threshold
from IPython.display import display, Markdown
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser, StrOutputParser
write_prompt = ChatPromptTemplate.from_template(
"Write a haiku about {subject}. Just the haiku, no commentary."
)
eval_prompt = ChatPromptTemplate.from_messages([
("system", "Score the haiku 1-10 for creativity and adherence to 5-7-5 syllable structure. Return JSON: {{\"score\": int, \"feedback\": string}}"),
("human", "{haiku}")
])
improve_prompt = ChatPromptTemplate.from_template(
"Improve this haiku based on this feedback: {feedback}\n\nOriginal:\n{haiku}\n\nImproved haiku (just the haiku):"
)
writer = write_prompt | llm | StrOutputParser()
evaluator = eval_prompt | llm | JsonOutputParser()
improver = improve_prompt | llm | StrOutputParser()
THRESHOLD = 7
MAX_ROUNDS = 4
haiku = writer.invoke({"subject": "machine learning"})
display(Markdown(f"**Round 0 (initial):**\n\n{haiku}"))
for round_num in range(1, MAX_ROUNDS + 1):
evaluation = evaluator.invoke({"haiku": haiku})
score, feedback = evaluation["score"], evaluation["feedback"]
display(Markdown(f"**Round {round_num} score:** {score}/10 — {feedback}"))
if score >= THRESHOLD:
display(Markdown(f"**Accepted! Final haiku:**\n\n{haiku}"))
break
haiku = improver.invoke({"haiku": haiku, "feedback": feedback})
display(Markdown(f"**Improved:**\n\n{haiku}"))
else:
display(Markdown(f"**Max rounds reached. Best haiku:**\n\n{haiku}"))
# 10d. Map-Reduce pattern — summarise multiple documents in parallel
import concurrent.futures
from IPython.display import display, Markdown
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
documents = [
"Python was created by Guido van Rossum and first released in 1991. It emphasises code readability and simplicity.",
"JavaScript was created by Brendan Eich at Netscape in 1995. Originally designed for browser scripting, it now runs on servers via Node.js.",
"Rust was designed by Graydon Hoare at Mozilla Research and announced in 2010. It guarantees memory safety without a garbage collector.",
"Go was created at Google by Robert Griesemer, Rob Pike, and Ken Thompson, released in 2009. It prioritises simplicity and fast compile times.",
]
map_chain = (
ChatPromptTemplate.from_template("Summarise in one sentence: {doc}")
| llm | StrOutputParser()
)
reduce_chain = (
ChatPromptTemplate.from_template(
"Given these summaries of programming languages:\n{summaries}\n\nWrite a 2-sentence overall synthesis."
) | llm | StrOutputParser()
)
with concurrent.futures.ThreadPoolExecutor() as pool:
summaries = list(pool.map(lambda d: map_chain.invoke({"doc": d}), documents))
display(Markdown("### MAP (individual summaries)\n" + "\n".join(f"- {s}" for s in summaries)))
synthesis = reduce_chain.invoke({"summaries": "\n".join(f"- {s}" for s in summaries)})
display(Markdown(f"### REDUCE (synthesis)\n{synthesis}"))
# 10e. Human-in-the-loop — agent pauses for approval before taking an action
from langchain_core.messages import HumanMessage, AIMessage
from langchain_core.tools import tool
@tool
def send_email(recipient: str, subject: str, body: str) -> str:
"""Send an email. Requires human approval before sending."""
return f"Email sent to {recipient} with subject '{subject}'"
def agent_with_approval(user_request: str):
"""An agent loop that pauses for human approval before calling dangerous tools."""
APPROVAL_REQUIRED = {"send_email"}
llm_tools = llm.bind_tools([send_email])
messages = [HumanMessage(content=user_request)]
for step in range(5):
response = llm_tools.invoke(messages)
messages.append(response)
if not response.tool_calls:
print(f"\nAgent: {response.content}")
break
for tc in response.tool_calls:
print(f"\nAgent wants to call: {tc['name']}({tc['args']})")
if tc["name"] in APPROVAL_REQUIRED:
approval = input("Approve? (yes/no): ").strip().lower()
if approval != "yes":
result = "Action cancelled by user."
else:
result = send_email.invoke(tc["args"])
else:
result = send_email.invoke(tc["args"])
from langchain_core.messages import ToolMessage
messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
agent_with_approval("Please draft and send an email to alice@example.com about our meeting next Monday at 10am.")
Section 11 — Putting It All Together: Mini Agentic App
A complete mini-application combining: RAG + Tools + Memory + Reflection
This is a Research Assistant that: 1. Retrieves relevant knowledge from a vector store (RAG) 2. Can do calculations 3. Remembers the conversation (Memory) 4. Reflects and refines answers before responding
# 11. Mini Agentic Research Assistant — LangGraph memory + tools + reflection
from IPython.display import display, Markdown
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, SystemMessage
import math
@tool
def knowledge_base_search(query: str) -> str:
"""Search the programming languages knowledge base for factual information."""
results = vectorstore.similarity_search(query, k=3)
if not results:
return "No relevant information found in the knowledge base."
return "\n".join(f"[{r.metadata['source']}] {r.page_content}" for r in results)
@tool
def calculator(expression: str) -> str:
"""Evaluate a math expression like '2 ** 10' or 'math.sqrt(144)'."""
try:
return str(eval(expression, {"__builtins__": {}}, {"math": math}))
except Exception as e:
return f"Error: {e}"
@tool
def self_reflect(draft_answer: str) -> str:
"""Critically evaluate a draft answer and suggest improvements."""
response = llm.invoke([
SystemMessage(content="You are a critical reviewer. Evaluate for accuracy, completeness, and clarity. Suggest 2-3 improvements."),
HumanMessage(content=draft_answer)
])
return response.content
assistant_tools = [knowledge_base_search, calculator, self_reflect]
llm_with_tools = llm.bind_tools(assistant_tools)
SYSTEM = SystemMessage(content="""You are an intelligent research assistant with access to:
1. knowledge_base_search — look up factual information
2. calculator — numerical computation
3. self_reflect — critique and improve your own draft answers before finalising
Workflow: search → draft → reflect → final answer.
Always ground answers in retrieved facts when possible.""")
def agent_node(state: MessagesState):
response = llm_with_tools.invoke([SYSTEM] + state["messages"])
return {"messages": [response]}
builder = StateGraph(MessagesState)
builder.add_node("agent", agent_node)
builder.add_node("tools", ToolNode(assistant_tools))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition) # routes to "tools" or END
builder.add_edge("tools", "agent") # after tools, back to agent
graph = builder.compile(checkpointer=MemorySaver())
cfg = {"configurable": {"thread_id": "research_1"}}
def ask(question):
display(Markdown(f"---\n**User:** {question}"))
result = graph.invoke({"messages": [HumanMessage(content=question)]}, config=cfg)
answer = result["messages"][-1].content
display(Markdown(f"**Assistant:** {answer}"))
return answer
ask("What is Rust and why is it special?")
# Follow-up — tests memory
ask("Which language we discussed is best for web frontend?")
# Math + knowledge combined
ask("If Python was released in 1991 and Go in 2009, how many years apart were they released?")
Summary & Next Steps
What you covered:
| Concept | Key Takeaway |
|---|---|
| LLM Basics | Tokens, temperature, streaming, roles |
| Prompt Engineering | Templates, few-shot, CoT, structured output |
| Chains (LCEL) | Compose components with |, parallel/sequential |
| Memory | Manual history; LangGraph MemorySaver + thread_id |
| RAG | Embed → store → retrieve → augment → generate |
| Tools | Define with @tool, bind to LLM, handle tool calls |
| Agents | ReAct loop, tool-calling agent, RAG agent |
| Multi-Agent | Orchestrator + specialists, parallel agents |
| Agentic Patterns | Planning, reflection, eval-optimizer, map-reduce, HITL |
| LangGraph | StateGraph + MemorySaver for stateful agentic workflows |
Intermediate next steps:
- LangGraph advanced — branching, human-in-the-loop interrupts, subgraphs, streaming state
- Fine-tuning — adapt a base model to your domain
- Evals — measure agent quality with LangSmith or RAGAS
- Production patterns — caching, observability, rate limiting, cost tracking
- Custom retrievers — BM25 hybrid search, reranking with cross-encoders
- Multimodal — vision-enabled models (llava via Ollama, Gemini Vision, GPT-4o)
Useful references:
Key Takeaways
After working through the notebook you should be comfortable with:
- Swapping LLM providers without rewriting your application logic
- Writing prompts that reliably produce structured, typed output
- Building RAG pipelines that ground model responses in your own data
- Defining and binding tools so a model can interact with external systems
- Reasoning about the ReAct loop and when an agent approach is—and isn't—the right choice
- Sketching multi-agent architectures with routing and specialist sub-agents
The notebook is intentionally opinionated in its use of LangChain as the orchestration layer, since it offers a consistent API across providers and a rich ecosystem of integrations. That said, the concepts translate directly to other frameworks (LlamaIndex, Semantic Kernel, raw API calls) once you understand the underlying patterns.
Questions, corrections, or suggestions? Leave a comment below—feedback is how this kind of resource improves over time.
No comments:
Post a Comment
Please post your valuable suggestions