1. Agent Abstraction — LLM + Memory + Tools + Planning
1.1. What Is an Agent?
Lesson 8 introduced a simple ReAct agent — an LLM that picks tools then answers. Lesson 9 expands to Agentic AI: systems where the LLM acts as the central brain — autonomously planning, selecting tools, responding to results, and coordinating with other agents.
An Agent consists of 4 core components:
- LLM (Brain) — reasoning, decision-making, text generation
- Memory — short-term (conversation buffer) and long-term (vector store, database)
- Tools — functions the agent can call: search, calculator, API, code execution
- Planning — create plans, decompose tasks, reflect when errors occur
Agent Abstraction — Core Components
══════════════════════════════════════════════════════════════
┌──────────────────────┐
│ USER │
│ (Task / Query) │
└──────────┬───────────┘
│
▼
┌────────────────────────────────────────────────────────┐
│ AGENT │
│ ┌──────────┐ ┌───────────┐ ┌──────────────────┐ │
│ │ Planning │ │ LLM Core │ │ Memory │ │
│ │ │◄─┤ (Brain) ├─►│ Short-term: chat │ │
│ │ Decompose│ │ Reasoning │ │ Long-term: VDB │ │
│ │ Reflect │ │ Decisions │ │ Episodic: logs │ │
│ └──────────┘ └─────┬─────┘ └──────────────────┘ │
│ │ │
│ ┌────────┼────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────┐┌───────┐┌────────┐ │
│ │Search ││ Code ││ API │ ◄── Tools │
│ │Engine ││ Exec ││ Calls │ │
│ └────────┘└───────┘└────────┘ │
└────────────────────────────────────────────────────────┘
1.2. Perception → Reasoning → Action → Observation Loop
Every agent runs on a basic loop:
- Perception — receive input (user query, tool output, environment feedback)
- Reasoning — LLM reasons: "What should I do next? Which tool? Do I have enough info?"
- Action — perform an action: call a tool, generate text, answer the user
- Observation — receive the result from the action, feed it back to Perception → repeat
Agent Loop — Perception → Reasoning → Action → Observation
═══════════════════════════════════════════════════════════
┌──────────────┐ ┌───────────────┐ ┌──────────────┐
│ PERCEPTION │────►│ REASONING │────►│ ACTION │
│ │ │ │ │ │
│ User query │ │ "Which tool?" │ │ Call tool │
│ Tool output │ │ "Enough info?"│ │ Generate text │
│ Error msg │ │ "Need retry?" │ │ Return answer │
└──────┬───────┘ └───────────────┘ └───────┬───────┘
▲ │
│ ┌───────────────┐ │
└───────────│ OBSERVATION │◄──────────────┘
│ │
│ Tool result │
│ Error / OK │
└───────────────┘
Loop continues until Final Answer
1.3. Levels of Agency
Not every LLM application needs a full agent. NVIDIA DLI distinguishes levels of agency:
| Level | Pattern | LLM Role | Example |
|---|---|---|---|
| L0 — No agency | Simple prompt → response | Text generator | FAQ chatbot |
| L1 — Tool use | LLM picks 1 tool | Router | Function calling API |
| L2 — Single agent | ReAct loop, multi-step | Planner + executor | RAG Agent (Lesson 8) |
| L3 — Multi-agent | Multiple agents coordinate | Coordinator | Supervisor + workers |
| L4 — Autonomous | Self-improving, long-running | Autonomous system | AI Scientist, Devin |
Exam tip: "LLM autonomously decomposes tasks, calls multiple tools, retries as needed" → Agent (L2+). "Multiple LLMs coordinate, each specializing in one task" → Multi-Agent (L3). DLI exam often asks: "What differentiates an agent from a chain?" → Agent has dynamic control flow (LLM decides the next step), chain has fixed control flow.

2. Cognitive Architectures for LLM Agents
2.1. ReAct — Reasoning + Acting
ReAct (introduced in Lesson 8) interleaves Thought (reasoning) with Action (acting). Strengths: simple, transparent. Weaknesses: no long-term planning — the agent only thinks about the next step, not the big picture.
2.2. Plan-and-Execute
Plan-and-Execute clearly separates two phases: (1) a Planner LLM creates the full plan upfront, (2) an Executor LLM carries out each step. After each step, the Planner can replan (adjust the plan).
Plan-and-Execute Architecture
══════════════════════════════════════════════════════════
User: "Analyze Q3 revenue, compare with Q2, write a report"
│
▼
┌─────────────────────────────────────────────┐
│ PLANNER LLM │
│ Plan: │
│ Step 1: Retrieve Q3 revenue data │
│ Step 2: Retrieve Q2 revenue data │
│ Step 3: Calculate Q2→Q3 change │
│ Step 4: Write comparison report │
└─────────────────────┬───────────────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Execute S1 Execute S2 Execute S3 ...
(retriever) (retriever) (calculator)
│ │ │
└─────────────┼─────────────┘
│
▼
┌───────────────┐
│ REPLAN? │──► If step fails → adjust plan
│ All done? │──► If done → Step 4: report
└───────────────┘
2.3. LATS — Language Agent Tree Search
LATS combines Monte Carlo Tree Search (MCTS) with LLM reasoning. Instead of following a single path (ReAct), LATS explores multiple solution branches, evaluates each branch using the LLM, then selects the best one. Like an LLM playing chess — thinking several moves ahead.
2.4. Reflexion — Learn from Mistakes
Reflexion adds a self-reflection step: after completing a task, the agent self-evaluates the result → if wrong, writes a "lesson learned" to memory → retries with experience from the previous attempt. This is a form of in-context learning through self-feedback.
2.5. Cognitive Architecture Comparison
| Architecture | Planning | Execution | Strength | Weakness |
|---|---|---|---|---|
| ReAct | Step-by-step (myopic) | Interleaved think+act | Simple, transparent | No global planning, can loop |
| Plan-and-Execute | Upfront full plan | Sequential execution | Global view, fewer LLM calls | Plan may become outdated after steps |
| LATS | Tree search (explore) | Best-first search | Explores alternatives, robust | Very expensive (many LLM calls) |
| Reflexion | Trial-and-error + memory | Execute → reflect → retry | Learns from mistakes | Slow convergence, needs evaluator |
Exam tip: "Agent needs to think through the entire plan before executing" → Plan-and-Execute. "Agent tries multiple solution paths, picks the best" → LATS. "Agent self-evaluates results and improves" → Reflexion. "Agent interleaves thinking and acting" → ReAct. DLI exams typically focus on ReAct and Plan-and-Execute as the two most widely used architectures.
3. LangGraph — Stateful Graph-Based Agent Orchestration
3.1. Why LangGraph?
AgentExecutor in LangChain (Lesson 8) is a "black box" — hard to customize control flow. LangGraph is a LangChain library that lets you build agents as directed graphs: each node is a processing step, edges define flow, and conditional edges allow branching based on state.
| Feature | AgentExecutor | LangGraph |
|---|---|---|
| Control flow | Fixed ReAct loop | Custom graph — you design the flow |
| State management | Hidden internal state | Explicit TypedDict state |
| Multi-agent | No native support | First-class: each agent = sub-graph |
| Human-in-the-loop | Limited | Built-in: interrupt, approve, edit |
| Persistence | No built-in | Checkpointer: save/resume state |
| Streaming | Basic | Event-by-event streaming |
| Debug | Trace via LangSmith | Graph visualization + LangSmith |
3.2. Core Concepts — StateGraph, Nodes, Edges
LangGraph is built on 3 concepts:
- State — a TypedDict holding all data passed between nodes. Each node reads/writes state.
- Nodes — Python functions. Input: state → Output: partial state update (only fields that need updating).
- Edges — connections between nodes.
add_edge(A, B)= always go A→B.add_conditional_edges(A, func)= func decides where to go.
LangGraph Concepts
══════════════════════════════════════════════════════════
State = TypedDict(messages, plan, results, ...)
────────────────────────────────────────────────
START ──► [Node: agent] ──conditional──► [Node: tools]
│ │
│ (if done) │ (tool result)
▼ │
END ◄───────────────────────────┘
Nodes: Python functions that read/write State
Edges: Static (always) or Conditional (function decides)
3.3. Code: Basic LangGraph Agent
from typing import TypedDict, Annotated, Sequence
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_nvidia_ai_endpoints import ChatNVIDIA
from langchain_core.tools import tool
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
import operator
# === 1. Define State ===
class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
# === 2. Define Tools ===
@tool
def search_docs(query: str) -> str:
"""Search internal documents for company information."""
# Simulate retrieval
docs = {
"leave": "Employees get 12 days annual leave per year.",
"refund": "Refund within 30 days with original receipt.",
}
for key, val in docs.items():
if key in query.lower():
return val
return "No relevant documents found."
@tool
def calculator(expression: str) -> str:
"""Calculate mathematical expressions."""
try:
return str(eval(expression)) # production: use safe eval
except Exception as e:
return f"Error: {e}"
tools = [search_docs, calculator]
# === 3. Define LLM with tools ===
llm = ChatNVIDIA(
model="meta/llama-3.1-70b-instruct",
temperature=0.1
).bind_tools(tools)
# === 4. Define Nodes ===
def agent_node(state: AgentState) -> dict:
"""LLM decides: call tool or respond."""
response = llm.invoke(state["messages"])
return {"messages": [response]}
tool_node = ToolNode(tools)
# === 5. Define Routing ===
def should_continue(state: AgentState) -> str:
last_message = state["messages"][-1]
if last_message.tool_calls:
return "tools" # LLM wants to call a tool
return "end" # LLM is done, return answer
# === 6. Build Graph ===
graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", should_continue, {
"tools": "tools",
"end": END,
})
graph.add_edge("tools", "agent") # after tool → back to agent
app = graph.compile()
# === 7. Run ===
result = app.invoke({
"messages": [HumanMessage(content="What is the leave policy?")]
})
print(result["messages"][-1].content)
3.4. Human-in-the-Loop
LangGraph supports interrupt before executing dangerous actions — e.g., sending emails, deleting data, executing code. The agent pauses, waits for user approval, then continues.
from langgraph.checkpoint.memory import MemorySaver
# Compile with checkpointer for interruption + resume
checkpointer = MemorySaver()
app = graph.compile(
checkpointer=checkpointer,
interrupt_before=["tools"] # pause BEFORE executing tools
)
# Run — will pause before the "tools" node
config = {"configurable": {"thread_id": "user-123"}}
result = app.invoke(
{"messages": [HumanMessage(content="Delete file report.pdf")]},
config=config,
)
# Inspect pending tool call
pending = result["messages"][-1].tool_calls
print(f"Agent wants to: {pending}")
# → Agent wants to: [{'name': 'delete_file', 'args': {'path': 'report.pdf'}}]
# Human approves → continue
final = app.invoke(None, config=config) # resume from checkpoint
3.5. Checkpointing — Save & Resume State
A Checkpointer saves state after each node, enabling:
- Resume — agent crashes mid-run → load checkpoint → continue
- Time travel — go back to any checkpoint → retry with different input
- Human-in-the-loop — pause, wait for user, resume (as shown above)
- Multi-turn — maintain conversation history across multiple turns
Exam tip: "Build agent with custom control flow, conditional branching" → LangGraph (not AgentExecutor). "Pause agent execution for human approval" → interrupt_before + checkpointer. "Save agent state, resume later" → LangGraph checkpointing. DLI C-FX-25 often asks: "Why use LangGraph over AgentExecutor?" → Custom flow, multi-agent, persistence, human-in-the-loop.
4. Multi-Agent Patterns
4.1. Why Multi-Agent?
A single agent with 20+ tools will face problems: tool selection confusion (too many tools, LLM picks wrong one), prompt too long (must include all instructions), hard to debug (unclear where the agent failed). Multi-agent solves this by decomposing: each agent specializes in one task with fewer tools.
4.2. Supervisor Pattern
A Supervisor agent (LLM) receives the task from the user, delegates to Worker agents, collects results, and synthesizes the final answer.
Supervisor Pattern
══════════════════════════════════════════════════════════
┌──────────────┐
│ USER │
└──────┬───────┘
│
▼
┌────────────────────────┐
│ SUPERVISOR AGENT │
│ (Orchestrator LLM) │
│ │
│ Decides: │
│ • Which worker next? │
│ • All done? │
│ • Need to re-route? │
└────┬──────┬──────┬─────┘
│ │ │
┌────────┘ │ └────────┐
▼ ▼ ▼
┌─────────────┐┌─────────────┐┌─────────────┐
│ Researcher ││ Coder ││ Reporter │
│ Agent ││ Agent ││ Agent │
│ ││ ││ │
│ Tools: ││ Tools: ││ Tools: │
│ • web_search││ • python ││ • write_doc │
│ • doc_search││ • shell ││ • format │
└─────────────┘└─────────────┘└─────────────┘
4.3. Hierarchical Pattern
Hierarchical extends Supervisor: each worker can itself be a supervisor of sub-workers. Suitable for complex organizations — e.g., CEO agent → Manager agents → Specialist agents.
Hierarchical Multi-Agent
══════════════════════════════════════════════════════════
┌──────────────────────┐
│ TOP SUPERVISOR │
│ (Project Manager) │
└───┬─────────────┬────┘
│ │
┌────────┘ └────────┐
▼ ▼
┌──────────────┐ ┌──────────────┐
│ RESEARCH │ │ ENGINEERING │
│ SUPERVISOR │ │ SUPERVISOR │
└──┬───────┬───┘ └──┬───────┬───┘
│ │ │ │
▼ ▼ ▼ ▼
[Web [Paper [Backend [Frontend
Searcher] Analyzer] Dev] Dev]
4.4. Swarm Pattern
Swarm (OpenAI Swarm concept) — no supervisor. Agents hand off to each other based on context. Agent A realizes "this task belongs to Agent B's expertise" → hands off automatically.
4.5. Debate Pattern
Debate — two or more agents argue about a question. Each agent presents its viewpoint and rebuts the other. Finally, a Judge agent selects the best conclusion. This pattern improves reasoning quality for complex questions.
4.6. Multi-Agent Pattern Comparison
| Pattern | Control Flow | Communication | Best For |
|---|---|---|---|
| Supervisor | Centralized — supervisor routes | Hub-and-spoke | Clear task delegation, moderate complexity |
| Hierarchical | Multi-level supervision | Tree structure | Complex orgs, many specialized sub-teams |
| Swarm | Decentralized — agents handoff | Peer-to-peer | Customer service, routing, flexible flow |
| Debate | Round-robin argumentation | Broadcast + judge | Complex reasoning, fact verification |
Exam tip: "One LLM routes tasks to specialized agents" → Supervisor. "Agents hand off to each other without central control" → Swarm. "Multiple agents argue, a judge decides" → Debate. "Nested supervisors managing sub-teams" → Hierarchical. DLI exams typically focus on the Supervisor pattern as it is the most common in production.
4.7. Code: Supervisor Multi-Agent with LangGraph
from typing import TypedDict, Annotated, Literal, Sequence
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
from langchain_nvidia_ai_endpoints import ChatNVIDIA
from langchain_core.tools import tool
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
import operator
# === State ===
class MultiAgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
next_agent: str
# === Worker Tools ===
@tool
def web_search(query: str) -> str:
"""Search the web for current information."""
return f"[Web Result] Top findings for '{query}': ..."
@tool
def run_python(code: str) -> str:
"""Execute Python code and return output."""
try:
exec_globals = {}
exec(code, exec_globals)
return str(exec_globals.get("result", "Code executed successfully."))
except Exception as e:
return f"Error: {e}"
@tool
def write_report(content: str) -> str:
"""Format content into a professional report."""
return f"=== REPORT ===\n{content}\n=== END ==="
# === Worker Agents ===
researcher_llm = ChatNVIDIA(
model="meta/llama-3.1-70b-instruct", temperature=0.1
).bind_tools([web_search])
coder_llm = ChatNVIDIA(
model="meta/llama-3.1-70b-instruct", temperature=0.0
).bind_tools([run_python])
reporter_llm = ChatNVIDIA(
model="meta/llama-3.1-70b-instruct", temperature=0.3
).bind_tools([write_report])
# === Supervisor ===
supervisor_llm = ChatNVIDIA(
model="meta/llama-3.1-70b-instruct", temperature=0.0
)
WORKERS = ["researcher", "coder", "reporter"]
def supervisor_node(state: MultiAgentState) -> dict:
"""Supervisor decides which worker to route to next."""
system_prompt = f"""You are a supervisor managing these workers: {WORKERS}.
Given the conversation, decide which worker should act next,
or if the task is complete respond with FINISH.
Respond with ONLY the worker name or FINISH."""
messages = [SystemMessage(content=system_prompt)] + state["messages"]
response = supervisor_llm.invoke(messages)
next_agent = response.content.strip().lower()
if next_agent not in WORKERS:
next_agent = "FINISH"
return {"next_agent": next_agent}
def researcher_node(state: MultiAgentState) -> dict:
system = SystemMessage(content="You are a research specialist. "
"Use web_search to find information. Be thorough.")
response = researcher_llm.invoke([system] + state["messages"])
return {"messages": [response]}
def coder_node(state: MultiAgentState) -> dict:
system = SystemMessage(content="You are a Python coding specialist. "
"Use run_python to execute code for analysis and calculations.")
response = coder_llm.invoke([system] + state["messages"])
return {"messages": [response]}
def reporter_node(state: MultiAgentState) -> dict:
system = SystemMessage(content="You are a report writer. "
"Use write_report to create formatted reports from gathered info.")
response = reporter_llm.invoke([system] + state["messages"])
return {"messages": [response]}
# === Routing ===
def route_supervisor(state: MultiAgentState) -> str:
next_agent = state.get("next_agent", "FINISH")
if next_agent == "FINISH":
return "end"
return next_agent
# === Build Graph ===
graph = StateGraph(MultiAgentState)
graph.add_node("supervisor", supervisor_node)
graph.add_node("researcher", researcher_node)
graph.add_node("coder", coder_node)
graph.add_node("reporter", reporter_node)
graph.add_node("researcher_tools", ToolNode([web_search]))
graph.add_node("coder_tools", ToolNode([run_python]))
graph.add_node("reporter_tools", ToolNode([write_report]))
graph.set_entry_point("supervisor")
# Supervisor routes to workers
graph.add_conditional_edges("supervisor", route_supervisor, {
"researcher": "researcher",
"coder": "coder",
"reporter": "reporter",
"end": END,
})
# Workers → tool nodes → back to supervisor
for worker in WORKERS:
def make_router(w):
def router(state):
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return f"{w}_tools"
return "supervisor"
return router
graph.add_conditional_edges(worker, make_router(worker), {
f"{worker}_tools": f"{worker}_tools",
"supervisor": "supervisor",
})
graph.add_edge(f"{worker}_tools", worker)
app = graph.compile()
# === Run ===
result = app.invoke({
"messages": [HumanMessage(
content="Research NVIDIA H100 GPU specs, calculate price-performance "
"ratio vs A100, and write a comparison report."
)],
"next_agent": "",
})
for msg in result["messages"]:
print(f"[{msg.type}] {msg.content[:200]}...")
5. Build Production-Ready Multi-Agent App
5.1. Research Assistant — Complete Example
Build a complete Research Assistant with 3 agents: Researcher (find information), Coder (analyze data), Reporter (write reports). Includes error handling, retry logic, and structured output.
from typing import TypedDict, Annotated, Sequence, Optional
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, AIMessage
from langchain_nvidia_ai_endpoints import ChatNVIDIA
from langchain_core.tools import tool
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.memory import MemorySaver
import operator
import json
# === Enhanced State ===
class ResearchState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
research_data: Optional[str] # collected research
analysis_result: Optional[str] # code analysis output
final_report: Optional[str] # formatted report
current_agent: str
iteration: int # track iterations to prevent loops
MAX_ITERATIONS = 10
# === Tools ===
@tool
def search_arxiv(query: str) -> str:
"""Search academic papers on arxiv for research topics."""
return json.dumps({
"papers": [
{"title": f"Paper on {query}", "abstract": f"Study of {query}...",
"year": 2025, "citations": 42},
]
})
@tool
def search_web(query: str) -> str:
"""Search the web for current news, blog posts, documentation."""
return json.dumps({
"results": [
{"title": f"Latest news: {query}", "snippet": f"Updated info on {query}..."},
]
})
@tool
def execute_analysis(code: str) -> str:
"""Run Python code for data analysis. Variable 'result' will be returned."""
exec_globals = {}
try:
exec(code, exec_globals)
return str(exec_globals.get("result", "Executed OK, no 'result' variable."))
except Exception as e:
return f"Error: {e}"
@tool
def generate_report(title: str, sections: str) -> str:
"""Generate a formatted markdown report from title and section content."""
return f"# {title}\n\n{sections}\n\n---\nGenerated by Research Assistant"
# === Agent Nodes ===
base_llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct")
def researcher_node(state: ResearchState) -> dict:
llm = base_llm.bind_tools([search_arxiv, search_web])
system = SystemMessage(content=(
"You are a research specialist. Search for papers and web results "
"to gather comprehensive information. Summarize findings clearly."
))
response = llm.invoke([system] + list(state["messages"]))
# If no tool calls, research is done — extract data
if not response.tool_calls:
return {
"messages": [response],
"research_data": response.content,
"current_agent": "supervisor",
}
return {"messages": [response], "current_agent": "researcher_tools"}
def coder_node(state: ResearchState) -> dict:
llm = base_llm.bind_tools([execute_analysis])
context = state.get("research_data", "No research data yet.")
system = SystemMessage(content=(
f"You are a data analyst. Use the research data below to perform "
f"analysis with Python code.\n\nResearch Data:\n{context}"
))
response = llm.invoke([system] + list(state["messages"]))
if not response.tool_calls:
return {
"messages": [response],
"analysis_result": response.content,
"current_agent": "supervisor",
}
return {"messages": [response], "current_agent": "coder_tools"}
def reporter_node(state: ResearchState) -> dict:
llm = base_llm.bind_tools([generate_report])
research = state.get("research_data", "N/A")
analysis = state.get("analysis_result", "N/A")
system = SystemMessage(content=(
f"You are a report writer. Create a professional report.\n"
f"Research:\n{research}\n\nAnalysis:\n{analysis}"
))
response = llm.invoke([system] + list(state["messages"]))
if not response.tool_calls:
return {
"messages": [response],
"final_report": response.content,
"current_agent": "supervisor",
}
return {"messages": [response], "current_agent": "reporter_tools"}
def supervisor_node(state: ResearchState) -> dict:
iteration = state.get("iteration", 0) + 1
if iteration > MAX_ITERATIONS:
return {
"messages": [AIMessage(content="Max iterations reached. Returning results.")],
"current_agent": "FINISH",
"iteration": iteration,
}
system = SystemMessage(content="""You are a project supervisor. Based on the current state:
- If no research data → route to "researcher"
- If research done but no analysis → route to "coder"
- If analysis done but no report → route to "reporter"
- If report is ready → respond "FINISH"
Respond with ONLY one of: researcher, coder, reporter, FINISH""")
response = base_llm.invoke([system] + list(state["messages"]))
next_agent = response.content.strip().lower()
valid = ["researcher", "coder", "reporter", "finish"]
if next_agent not in valid:
next_agent = "researcher" # default fallback
return {"current_agent": next_agent, "iteration": iteration}
# === Routing ===
def route_from_supervisor(state: ResearchState) -> str:
agent = state.get("current_agent", "FINISH")
if agent in ["researcher", "coder", "reporter"]:
return agent
return "end"
def route_from_worker(worker_name: str):
def router(state: ResearchState) -> str:
current = state.get("current_agent", "supervisor")
if current == f"{worker_name}_tools":
return f"{worker_name}_tools"
return "supervisor"
return router
# === Build Graph ===
from langgraph.prebuilt import ToolNode
graph = StateGraph(ResearchState)
graph.add_node("supervisor", supervisor_node)
graph.add_node("researcher", researcher_node)
graph.add_node("coder", coder_node)
graph.add_node("reporter", reporter_node)
graph.add_node("researcher_tools", ToolNode([search_arxiv, search_web]))
graph.add_node("coder_tools", ToolNode([execute_analysis]))
graph.add_node("reporter_tools", ToolNode([generate_report]))
graph.set_entry_point("supervisor")
graph.add_conditional_edges("supervisor", route_from_supervisor, {
"researcher": "researcher",
"coder": "coder",
"reporter": "reporter",
"end": END,
})
for worker in ["researcher", "coder", "reporter"]:
graph.add_conditional_edges(worker, route_from_worker(worker), {
f"{worker}_tools": f"{worker}_tools",
"supervisor": "supervisor",
})
graph.add_edge(f"{worker}_tools", worker)
# Compile with checkpointing
checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)
# === Execute ===
config = {"configurable": {"thread_id": "research-001"}}
result = app.invoke(
{
"messages": [HumanMessage(
content="Research the latest advances in mixture-of-experts (MoE) "
"models, analyze their parameter efficiency compared to "
"dense models, and write a summary report."
)],
"current_agent": "",
"iteration": 0,
},
config=config,
)
# Print final report
print(result.get("final_report", result["messages"][-1].content))
5.2. Production Best Practices
| Practice | Why | Implementation |
|---|---|---|
| Max iterations | Prevent infinite loops | iteration counter in state, check at supervisor |
| Error handling | Tool failures shouldn't crash agent | try/except in tools, return error message |
| Checkpointing | Resume after crash | MemorySaver (dev) / SqliteSaver (prod) |
| Structured output | Reliable routing decisions | Constrain supervisor output to valid choices |
| Observability | Debugging multi-agent is hard | LangSmith tracing, log each node entry/exit |
| Timeout per node | Single node shouldn't block | Set timeout on LLM calls and tool executions |
| Human-in-the-loop | Critical actions need approval | interrupt_before on dangerous tool nodes |
Exam tip: "How to prevent agent infinite loops?" → max_iterations + iteration counter. "How to debug multi-agent systems?" → LangSmith tracing + logging. "Agent crash recovery?" → Checkpointing with persistent storage. Production deployment → LangGraph Platform (managed) or LangServe (self-hosted).
6. DLI C-FX-25 — Agentic AI Course Overview
6.1. Course Structure
Course C-FX-25: "Building Agentic AI Applications" is an advanced module in DLI, focused on building production-ready Agentic AI systems. It complements S-FX-15 by diving deeper into agent architectures.
| Module | Topics | Hands-On |
|---|---|---|
| Module 1 | Agent fundamentals, ReAct, tool calling | Build single agent with NVIDIA NIM |
| Module 2 | LangGraph introduction, StateGraph | Implement custom agent graph |
| Module 3 | Multi-agent architectures | Build supervisor multi-agent system |
| Module 4 | Advanced: memory, planning, eval | Production deployment exercise |
6.2. Assessment Focus Areas
C-FX-25 assessment focuses on hands-on implementation:
- LangGraph StateGraph — define state, nodes, conditional edges
- Tool integration — bind tools to LLM, handle tool calls
- Supervisor routing — implement supervisor logic, route to workers
- Checkpointing — save/restore agent state
- Human-in-the-loop — interrupt_before, approve, resume
6.3. Key APIs to Memorize
| API / Concept | Usage |
|---|---|
StateGraph(State) | Create graph with typed state |
graph.add_node(name, func) | Add processing node |
graph.add_edge(A, B) | Always route A → B |
graph.add_conditional_edges(A, func, map) | Route based on function output |
graph.set_entry_point(name) | Set starting node |
graph.compile(checkpointer=...) | Compile graph, optional checkpointer |
ToolNode(tools) | Pre-built node that executes tool calls |
MemorySaver() | In-memory checkpointer (dev only) |
interrupt_before=[node] | Pause before executing node |
llm.bind_tools(tools) | Attach tools to LLM for function calling |
Exam tip: C-FX-25 assessment requires writing LangGraph code from scratch. Remember the pattern: (1) Define State TypedDict, (2) Define nodes as functions, (3) Add nodes + edges, (4) Compile + run. You don't need to memorize APIs, but must understand the flow: state travels through nodes, conditional edges route dynamically.
7. Cheat Sheet
| Concept | Key Point |
|---|---|
| Agent components | LLM + Memory + Tools + Planning |
| Agent loop | Perception → Reasoning → Action → Observation |
| Agent vs Chain | Agent = dynamic flow (LLM decides); Chain = fixed flow |
| ReAct | Interleave Thought + Action + Observation. Simple, myopic |
| Plan-and-Execute | Plan upfront → execute steps → replan if needed |
| LATS | Tree search over reasoning paths. Expensive but robust |
| Reflexion | Execute → self-reflect → retry with lessons |
| LangGraph | StateGraph: nodes + edges + conditional routing |
| LangGraph State | TypedDict shared across all nodes |
| Conditional edges | Router function decides next node |
| Checkpointing | MemorySaver (dev), SqliteSaver (prod). Enable resume |
| Human-in-the-loop | interrupt_before=[node] + compile with checkpointer |
| Supervisor pattern | Central LLM routes to specialized worker agents |
| Hierarchical | Nested supervisors — tree of agents |
| Swarm | Decentralized handoffs, no central supervisor |
| Debate | Agents argue, judge decides. Better reasoning |
| Max iterations | Always set to prevent infinite agent loops |
| ToolNode | LangGraph pre-built node to execute tool calls |
| C-FX-25 focus | LangGraph coding, multi-agent, checkpointing, HITL |
8. Practice Questions — Coding
Q1: Build a basic LangGraph ReAct agent
Build a simple LangGraph agent with 2 tools: search_docs (search documents) and calculator (perform calculations). Implement the full pipeline: State, agent node, tool node, conditional edge routing, compile and run.
Show Answer Q1
from typing import TypedDict, Annotated, Sequence
from langchain_core.messages import BaseMessage, HumanMessage
from langchain_nvidia_ai_endpoints import ChatNVIDIA
from langchain_core.tools import tool
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
import operator
# 1. State
class AgentState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
# 2. Tools
@tool
def search_docs(query: str) -> str:
"""Search internal knowledge base for relevant documents."""
return f"Found: Documentation about {query} — key facts here."
@tool
def calculator(expression: str) -> str:
"""Calculate a mathematical expression."""
return str(eval(expression))
tools = [search_docs, calculator]
# 3. LLM with tools
llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct", temperature=0.0)
llm_with_tools = llm.bind_tools(tools)
# 4. Nodes
def agent_node(state: AgentState) -> dict:
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response]}
tool_node = ToolNode(tools)
# 5. Router
def should_continue(state: AgentState) -> str:
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end"
# 6. Build graph
graph = StateGraph(AgentState)
graph.add_node("agent", agent_node)
graph.add_node("tools", tool_node)
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", should_continue, {
"tools": "tools",
"end": END,
})
graph.add_edge("tools", "agent")
app = graph.compile()
# 7. Run
result = app.invoke({
"messages": [HumanMessage(content="What is 25 * 4 + 100?")]
})
print(result["messages"][-1].content)
Q2: Implement human-in-the-loop with LangGraph checkpointing
Modify the agent from Q1 to add human-in-the-loop: the agent pauses before executing tools, and the user can approve or reject. Demonstrate: (1) compile with checkpointer + interrupt_before, (2) run and see the agent pause, (3) resume execution.
Show Answer Q2
from langgraph.checkpoint.memory import MemorySaver
# Reuse graph from Q1, compile with HITL
checkpointer = MemorySaver()
app_hitl = graph.compile(
checkpointer=checkpointer,
interrupt_before=["tools"] # Pause BEFORE tool execution
)
# Run — agent will pause before calling tools
config = {"configurable": {"thread_id": "hitl-demo-001"}}
result = app_hitl.invoke(
{"messages": [HumanMessage(content="Calculate 1000 / 4")]},
config=config,
)
# Agent paused — inspect what it wants to do
last_msg = result["messages"][-1]
print("Agent wants to call:")
for tc in last_msg.tool_calls:
print(f" Tool: {tc['name']}, Args: {tc['args']}")
# User approves → resume (pass None to continue from checkpoint)
final_result = app_hitl.invoke(None, config=config)
print("\nFinal answer:", final_result["messages"][-1].content)
# If user REJECTS → could modify state or stop here
# To reject: simply don't call invoke(None, config)
Q3: Build a Supervisor multi-agent system
Implement a Supervisor pattern with 2 workers: researcher (uses web_search tool) and writer (uses write_report tool). The Supervisor receives tasks from the user, routes to the appropriate worker, and collects results. Implement routing logic with conditional edges.
Show Answer Q3
from typing import TypedDict, Annotated, Sequence
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
from langchain_nvidia_ai_endpoints import ChatNVIDIA
from langchain_core.tools import tool
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
import operator
class SupervisorState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
next: str
@tool
def web_search(query: str) -> str:
"""Search the internet for information."""
return f"Search results for '{query}': ..."
@tool
def write_report(content: str) -> str:
"""Write and format a professional report."""
return f"=== Report ===\n{content}\n=== End ==="
llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct", temperature=0.0)
def supervisor(state: SupervisorState) -> dict:
sys = SystemMessage(content=(
"You are a supervisor. Workers: researcher, writer. "
"Route to appropriate worker or say FINISH if task complete. "
"Respond with ONLY: researcher, writer, or FINISH."
))
resp = llm.invoke([sys] + list(state["messages"]))
next_val = resp.content.strip().lower()
if next_val not in ["researcher", "writer"]:
next_val = "FINISH"
return {"next": next_val}
def researcher(state: SupervisorState) -> dict:
r_llm = llm.bind_tools([web_search])
sys = SystemMessage(content="You are a researcher. Use web_search.")
resp = r_llm.invoke([sys] + list(state["messages"]))
return {"messages": [resp]}
def writer(state: SupervisorState) -> dict:
w_llm = llm.bind_tools([write_report])
sys = SystemMessage(content="You are a report writer. Use write_report.")
resp = w_llm.invoke([sys] + list(state["messages"]))
return {"messages": [resp]}
def route(state: SupervisorState) -> str:
n = state.get("next", "FINISH")
return n if n in ["researcher", "writer"] else "end"
# Build
g = StateGraph(SupervisorState)
g.add_node("supervisor", supervisor)
g.add_node("researcher", researcher)
g.add_node("writer", writer)
g.add_node("research_tools", ToolNode([web_search]))
g.add_node("writer_tools", ToolNode([write_report]))
g.set_entry_point("supervisor")
g.add_conditional_edges("supervisor", route, {
"researcher": "researcher",
"writer": "writer",
"end": END,
})
# Researcher flow
def route_researcher(state):
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "research_tools"
return "supervisor"
g.add_conditional_edges("researcher", route_researcher, {
"research_tools": "research_tools",
"supervisor": "supervisor",
})
g.add_edge("research_tools", "researcher")
# Writer flow
def route_writer(state):
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "writer_tools"
return "supervisor"
g.add_conditional_edges("writer", route_writer, {
"writer_tools": "writer_tools",
"supervisor": "supervisor",
})
g.add_edge("writer_tools", "writer")
app = g.compile()
result = app.invoke({
"messages": [HumanMessage(content="Research AI trends 2025 and write a report")],
"next": "",
})
print(result["messages"][-1].content)
Q4: Add Plan-and-Execute to a LangGraph agent
Implement the Plan-and-Execute pattern: (1) Planner node creates a list of steps from the user query, (2) Executor node carries out each step, (3) Replanner node checks progress and adjusts the plan if needed. Store the plan in state.
Show Answer Q4
from typing import TypedDict, Annotated, Sequence, List, Optional
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
from langchain_nvidia_ai_endpoints import ChatNVIDIA
from langgraph.graph import StateGraph, END
import operator, json
class PlanExecState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
plan: List[str] # list of steps
current_step: int # index of current step
step_results: List[str] # results of each step
done: bool
llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct", temperature=0.0)
def planner_node(state: PlanExecState) -> dict:
"""Create a plan from user request."""
sys = SystemMessage(content=(
"You are a planner. Break the user's request into 3-5 concrete steps. "
"Return ONLY a JSON array of strings, e.g. [\"step1\", \"step2\"]."
))
resp = llm.invoke([sys] + list(state["messages"]))
try:
plan = json.loads(resp.content)
except json.JSONDecodeError:
plan = [resp.content]
return {"plan": plan, "current_step": 0, "step_results": []}
def executor_node(state: PlanExecState) -> dict:
"""Execute the current step of the plan."""
step_idx = state["current_step"]
plan = state["plan"]
if step_idx >= len(plan):
return {"done": True}
current = plan[step_idx]
sys = SystemMessage(content=(
f"Execute this step: {current}\n"
f"Previous results: {state['step_results']}\n"
"Provide a concise result."
))
resp = llm.invoke([sys] + list(state["messages"]))
new_results = list(state["step_results"]) + [resp.content]
return {
"step_results": new_results,
"current_step": step_idx + 1,
"messages": [resp],
}
def replanner_node(state: PlanExecState) -> dict:
"""Check progress, adjust plan if needed."""
if state["current_step"] >= len(state["plan"]):
return {"done": True}
sys = SystemMessage(content=(
f"Plan: {state['plan']}\n"
f"Completed: {state['current_step']}/{len(state['plan'])}\n"
f"Results so far: {state['step_results']}\n"
"Should the remaining plan continue as-is? "
"Reply 'CONTINUE' or provide updated remaining steps as JSON array."
))
resp = llm.invoke([sys])
if "CONTINUE" in resp.content.upper():
return {"done": False}
try:
remaining = json.loads(resp.content)
new_plan = state["plan"][:state["current_step"]] + remaining
return {"plan": new_plan, "done": False}
except json.JSONDecodeError:
return {"done": False}
def route_after_exec(state: PlanExecState) -> str:
if state.get("done", False):
return "end"
return "replanner"
def route_after_replan(state: PlanExecState) -> str:
if state.get("done", False):
return "end"
return "executor"
# Build graph
g = StateGraph(PlanExecState)
g.add_node("planner", planner_node)
g.add_node("executor", executor_node)
g.add_node("replanner", replanner_node)
g.set_entry_point("planner")
g.add_edge("planner", "executor")
g.add_conditional_edges("executor", route_after_exec, {
"replanner": "replanner",
"end": END,
})
g.add_conditional_edges("replanner", route_after_replan, {
"executor": "executor",
"end": END,
})
app = g.compile()
result = app.invoke({
"messages": [HumanMessage(
content="Analyze the pros and cons of microservices architecture "
"and recommend when to use it vs monolith."
)],
"plan": [],
"current_step": 0,
"step_results": [],
"done": False,
})
for i, res in enumerate(result["step_results"]):
print(f"Step {i+1}: {res[:150]}...")
Q5: Implement error handling and retry logic in multi-agent system
Add error handling to a multi-agent system: (1) Tool failures return error messages instead of crashing, (2) Agent receives an error → retries with a different strategy (max 2 retries), (3) Agent state tracks retry count. Implement a node wrapper pattern.
Show Answer Q5
from typing import TypedDict, Annotated, Sequence, Dict
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_nvidia_ai_endpoints import ChatNVIDIA
from langchain_core.tools import tool
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
import operator
class RobustState(TypedDict):
messages: Annotated[Sequence[BaseMessage], operator.add]
error_count: int
max_retries: int
# Tools with error handling built-in
@tool
def risky_api_call(endpoint: str) -> str:
"""Call an external API that might fail."""
import random
if random.random() < 0.5:
raise ConnectionError(f"API {endpoint} unreachable")
return f"API response from {endpoint}: success data"
@tool
def safe_search(query: str) -> str:
"""Search with built-in error handling."""
return f"Results for {query}: ..."
# Wrap tools with error handling
def safe_tool_node(tools):
"""ToolNode wrapper that catches errors and returns error messages."""
base_node = ToolNode(tools)
def wrapper(state: RobustState) -> dict:
try:
return base_node.invoke(state)
except Exception as e:
error_msg = AIMessage(content=f"Tool error: {str(e)}. Try different approach.")
return {
"messages": [error_msg],
"error_count": state.get("error_count", 0) + 1,
}
return wrapper
tools = [risky_api_call, safe_search]
llm = ChatNVIDIA(model="meta/llama-3.1-70b-instruct", temperature=0.0)
llm_with_tools = llm.bind_tools(tools)
def agent_node(state: RobustState) -> dict:
error_count = state.get("error_count", 0)
max_retries = state.get("max_retries", 2)
# If too many errors, give up gracefully
if error_count >= max_retries:
return {"messages": [AIMessage(
content="I encountered multiple errors. Here's what I could gather "
"from successful attempts: " +
" | ".join(m.content for m in state["messages"][-3:])
)]}
# Add retry context if there were errors
msgs = list(state["messages"])
if error_count > 0:
msgs.append(HumanMessage(
content=f"Previous attempt failed ({error_count}/{max_retries} retries). "
"Try a different tool or approach."
))
response = llm_with_tools.invoke(msgs)
return {"messages": [response]}
def should_continue(state: RobustState) -> str:
last = state["messages"][-1]
error_count = state.get("error_count", 0)
max_retries = state.get("max_retries", 2)
# Stop if max retries exceeded
if error_count >= max_retries and not (
hasattr(last, "tool_calls") and last.tool_calls
):
return "end"
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end"
# Build
g = StateGraph(RobustState)
g.add_node("agent", agent_node)
g.add_node("tools", safe_tool_node(tools))
g.set_entry_point("agent")
g.add_conditional_edges("agent", should_continue, {
"tools": "tools",
"end": END,
})
g.add_edge("tools", "agent") # tool result → back to agent
app = g.compile()
result = app.invoke({
"messages": [HumanMessage(content="Call the user-data API endpoint")],
"error_count": 0,
"max_retries": 2,
})
print(result["messages"][-1].content)