Preparing your learning space...
50% through Advanced FDE Skills tutorials
A single LLM call answers a question. An agent plans, uses tools, remembers, and acts toward a goal. This tutorial takes you from single agents to systems where multiple specialized agents collaborate — the architecture behind serious production AI workflows.
An agent is an LLM wrapped in a loop with three capabilities:
Agent = LLM + Tools + Loop + Memory
A chatbot without tools is not an agent. The difference is autonomy: an agent chooses its own sequence of actions.
Every agent framework — LangGraph, OpenAI Assistants, CrewAI, smolagents — is a variation of this loop:
def run_agent(goal: str, max_steps: int = 15):
messages = [{"role": "user", "content": goal}]
for step in range(max_steps):
response = llm.chat(messages, tools=TOOLS)
if response.tool_calls:
results = [execute_tool(tc) for tc in response.tool_calls]
messages.append(tool_result(results))
else:
return response.content # final answer
raise AgentBudgetExceeded(max_steps)
The explanation: the model either requests a tool call (and the loop feeds the result back) or produces a final answer. max_steps is a hard budget — without it, a confused agent can loop forever and burn money.
Tools are functions the model can request, described by JSON schemas.
TOOLS = [
{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Get the shipping status of a customer order by order ID.",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "e.g. ORD-8842"}
},
"required": ["order_id"],
},
},
}
]
def execute_tool(call):
if call.name == "get_order_status":
return db.query("SELECT status FROM orders WHERE id = %s", call.args["order_id"])
raise UnknownTool(call.name)
The explanation: the model never runs code — it requests a tool. Your code validates inputs, executes, and returns a string result. Tool descriptions are prompts: a vague description means the model calls the wrong tool.
Tool design rules:
def compact_history(messages, keep_last=10):
if len(messages) <= keep_last:
return messages
summary = llm.chat([{
"role": "user",
"content": f"Summarize the key facts and decisions:\n{render(messages[:-keep_last])}"
}])
return [summary] + messages[-keep_last:]
The explanation: old turns are compressed into a summary so the agent keeps essential context without blowing the context window.
| Strategy | How it works | When to use |
|---|---|---|
| ReAct | Think → act → observe, repeat | Default for tool agents |
| Plan-and-execute | Draft full plan first, then run steps | Long multi-step tasks |
| Reflexion | Critique its own failed attempt and retry | Hard tasks with retries |
| Tree of Thoughts | Explore multiple reasoning branches | Complex decision problems |
ReAct trace example: Thought: I need the order status before I can refund. Action: get_order_status("ORD-8842") Observation: status = "delivered" Thought: Delivered, so refund policy allows it. I need the payment ID. Action: get_payment_id("ORD-8842") Observation: pay_771 Action: refund("pay_771", amount=49.99)
Agents fail in specific ways: loops, hallucinated tool arguments, and destructive actions. Defend in layers:
class GuardedAgent:
MAX_COST = 2.00 # dollars
BLOCKED = ["delete_all", "drop_table"]
def execute_tool(self, call):
if call.name in self.BLOCKED:
return Refusal("This tool requires human approval.")
if self.total_cost + est(call) > self.MAX_COST:
return Refusal("Budget exceeded — stopping.")
return self.sandbox.run(call)
Key guardrails:
One agent doing everything hits limits: the context window fills, the system prompt becomes contradictory, and a single failure loses all work.
Multi-agent systems split work across specialized agents, each with:
Benefits: better accuracy per task, parallelism, and failure isolation. Costs: more complexity, more latency, and new failure modes (agents talking past each other). Use multi-agent only when single-agent + tools genuinely isn't enough.
A router agent delegates to workers and aggregates results.
┌─→ Research Agent Supervisor ├─→ Writer Agent └─→ Reviewer Agent
Best when tasks decompose cleanly and you need central control.
Each agent transforms the output of the previous one: extract → summarize → format. Simple, debuggable, deterministic.
A generator agent and a critic agent iterate until the critic passes the output. Great for code review and analysis quality.
draft = writer_agent.run(task)
for _ in range(3):
critique = critic_agent.run(draft)
if critique.verdict == "approve":
break
draft = writer_agent.run(f"Improve based on: {critique.issues}")
Teams of agents, each with its own sub-supervisor. For large workflows; adds coordination overhead.
Agents share state through a common structure, not free-form chat.
from dataclasses import dataclass, field
@dataclass
class SharedState:
goal: str
facts: dict = field(default_factory=dict) # validated outputs
artifacts: dict = field(default_factory=dict) # documents, code
messages: list = field(default_factory=list) # audit trail
def researcher(state: SharedState) -> SharedState:
state.facts["competitor_pricing"] = fetch_pricing()
return state
def writer(state: SharedState) -> SharedState:
state.artifacts["report"] = llm.write(state.facts)
return state
The explanation: the researcher writes structured facts into shared state; the writer reads them. Each agent sees only what it needs, and the messages list gives you a full audit trail for debugging.
In customer-facing systems, agents hand conversations to each other:
def triage_agent(state):
intent = classify(state.last_message)
if intent == "billing":
return Handoff(to="billing_agent", context=state)
if intent == "technical":
return Handoff(to="tech_agent", context=state)
return Handoff(to="human", reason="unknown intent")
Orchestration rules:
Frameworks worth knowing: LangGraph (graph-based state machines), OpenAI Agents SDK (handoffs), CrewAI (role-based crews), AutoGen (conversational agents).
Agents are non-deterministic, so test differently:
def evaluate_run(trace, expected):
scores = {
"correct_tools": set(trace.tool_calls) == expected.tools,
"final_answer": llm.grade(trace.final_answer, expected.answer),
"steps_under_budget": len(trace.steps) <= 15,
"cost": trace.cost_usd,
}
return scores
The explanation: run this over a fixed test set on every change. Regression in tool choice or cost is as important as regression in answer quality.
Save your progress and earn XP for completing tutorials.
4 questions · Pass with 70%+
1What is the core difference between an agent and a plain LLM call?
2In a tool-calling setup, who actually executes the code for get_order_status?
3Why does every agent loop need a max_steps (step or cost) budget?
4A single agent with every tool and a huge system prompt is failing and its context fills up. When is switching to a multi-agent architecture justified?
Technology
Forward Deployed Engineer
Lesson group
Advanced FDE Skills
Progress
50% complete