Module 1: Anatomy of an AI Agent
5. Agents vs Chains vs Workflows
Capsule description
Not every problem needs an agent. A chain is a deterministic pipeline: A → B → C, always the same. A workflow orchestrates steps with conditional logic, but the decisions are encoded in rules — the LLM doesn't make them. An agent delegates to the model: what to do, with which tools, when to stop. Choosing wrong among these three architectures is expensive — and fixing it later is worse.
Using an agent where a chain would do means paying 3-5x more in latency and tokens for zero benefit. Using a chain where you need an agent means a brittle system that breaks on any case you didn't anticipate. This capsule gives you the decision framework to choose correctly at design time, before you write a line of code.
It's one of the most important decisions in AI Engineering. Not because it's conceptually hard — the definitions are clear — but because in practice the boundaries blur, and the temptation to "use an agent for everything" is real. You'll leave here with concrete judgment: if your task has X characteristics → use Y architecture.
The Three Architectures
Chain: Deterministic Pipeline
A chain executes a fixed sequence of steps. There are no runtime decisions — it always does the same thing.
Input → Step A → Step B → Step C → Output
Characteristics:
- 📌 The flow is identical for every input
- 📌 There are no conditional branches
- 📌 The LLM calls are predefined (how many, in what order)
- 📌 Fixed cost: always N calls to the model
from dotenv import load_dotenv
load_dotenv()
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain.chat_models import init_chat_model
model = init_chat_model("openai:gpt-4.1-mini")
extract = ChatPromptTemplate.from_messages([
("system", "Extract the key points from the following text. 5 points maximum."),
("human", "{text}")
]) | model | StrOutputParser()
summarize = ChatPromptTemplate.from_messages([
("system", "Summarize these points in a 2-3 sentence paragraph."),
("human", "{key_points}")
]) | model | StrOutputParser()
tweet = ChatPromptTemplate.from_messages([
("system", "Format this summary for a tweet (280 characters max)."),
("human", "{summary}")
]) | model | StrOutputParser()
def text_to_tweet(text: str) -> str:
key_points = extract.invoke({"text": text})
summary = summarize.invoke({"key_points": key_points})
return tweet.invoke({"summary": summary})
result = text_to_tweet("Long article about AI agents and their impact on the industry...")
# Always: extract → summarize → tweet. No variation. No decisions.
When to use it: Summarizing, translating, classifying, extracting data — any task where the flow is always the same.
Workflow: Orchestration with Rules
A workflow has conditional logic, but the decisions are rules written in code, not LLM inference.
Input → Classify → Type A? → Path A
→ Type B? → Path B
→ Default → Path C
Characteristics:
- 📌 It has conditional branches (if/else, switch)
- 📌 The routing rules are code, not LLM decisions
- 📌 The number of LLM calls depends on the path, but it's predictable
- 📌 You can audit and test each branch independently
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
model = init_chat_model("openai:gpt-4.1-mini")
class TicketState(TypedDict):
content: str
category: str
response: str
def classify_ticket(state: TicketState) -> TicketState:
prompt = ChatPromptTemplate.from_messages([
("system", "Classify this ticket. Reply ONLY: billing, technical, general."),
("human", "{content}")
])
chain = prompt | model | StrOutputParser()
return {"category": chain.invoke({"content": state["content"]}).strip().lower()}
def handle_billing(state: TicketState) -> TicketState:
prompt = ChatPromptTemplate.from_messages([
("system", "You are a billing agent. Answer billing questions."),
("human", "{content}")
])
return {"response": (prompt | model | StrOutputParser()).invoke({"content": state["content"]})}
def handle_technical(state: TicketState) -> TicketState:
prompt = ChatPromptTemplate.from_messages([
("system", "You are technical support. Answer with troubleshooting steps."),
("human", "{content}")
])
return {"response": (prompt | model | StrOutputParser()).invoke({"content": state["content"]})}
def handle_general(state: TicketState) -> TicketState:
return {"response": "Your ticket has been logged. An agent will contact you."}
def route_ticket(state: TicketState) -> str:
routes = {"billing": "handle_billing", "technical": "handle_technical"}
return routes.get(state["category"], "handle_general")
graph = StateGraph(TicketState)
graph.add_node("classify", classify_ticket)
graph.add_node("handle_billing", handle_billing)
graph.add_node("handle_technical", handle_technical)
graph.add_node("handle_general", handle_general)
graph.add_edge(START, "classify")
graph.add_conditional_edges("classify", route_ticket)
graph.add_edge("handle_billing", END)
graph.add_edge("handle_technical", END)
graph.add_edge("handle_general", END)
workflow = graph.compile()
result = workflow.invoke({"content": "My card was charged twice", "category": "", "response": ""})
print(f"Category: {result['category']}, Response: {result['response']}")
# classify → route_ticket ("billing") → handle_billing. Routing = code, not the LLM.
When to use it: Ticket routing, conditional processing (premium → path A, free → path B), systems that need auditable routing decisions.
Agent: The LLM's Decisions
The agent delegates to the LLM: which tool to use, with which arguments, in what order, and when to stop.
Input → [LLM decides] → Tool A? Tool B? Answer?
→ Observe result → [LLM decides again] → ...
Characteristics:
- 📌 The LLM decides which tools to use at each step
- 📌 The order and number of steps varies by input
- 📌 The cost is variable and unpredictable
- 📌 It can adapt to inputs you didn't anticipate
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage
@tool
def search_web(query: str) -> str:
"""Searches the web for current information."""
return f"Results for '{query}': AI agents market growing 40% YoY, projected $50B by 2028."
@tool
def calculator(expression: str) -> str:
"""Calculates mathematical expressions. Example: '15 * 23 + 100'"""
try:
return str(eval(expression))
except Exception as e:
return f"Error: {e}"
@tool
def get_weather(city: str) -> str:
"""Gets the current weather for a city."""
weathers = {"Madrid": "22°C, sunny", "Barcelona": "20°C, cloudy"}
return f"Weather in {city}: {weathers.get(city, '18°C, partly cloudy')}"
model = init_chat_model("openai:gpt-4.1-mini")
tools = [search_web, calculator, get_weather]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)
def agent_loop(user_input: str, max_iterations: int = 5) -> str:
messages = [HumanMessage(content=user_input)]
for _ in range(max_iterations):
response = model_with_tools.invoke(messages)
messages.append(response)
if not response.tool_calls:
return response.content
for tc in response.tool_calls:
result = tools_by_name[tc["name"]].invoke(tc["args"])
messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
return "Iteration limit reached"
print(agent_loop("What's the AI agents market and that value divided by 5?"))
# The model: 1) search_web 2) calculator("50000000000/5"). No code dictates that order.
print(agent_loop("What's the weather in Madrid?"))
# It only calls get_weather. One iteration. Same agent, different flow per input.
When to use it: Open-ended tasks, multi-step research, assistants that must adapt to any question.
Decision Framework
Decision tree
Does the task have a predictable flow?
│
├── YES → Are there conditional branches?
│ ├── YES → Are the routing rules clear and finite?
│ │ ├── YES → WORKFLOW
│ │ └── NO → Should the LLM decide the routing? → YES → AGENT (or hybrid)
│ └── NO → CHAIN
│
└── NO → Must the model pick tools dynamically?
├── YES → How many tools?
│ ├── Just 1, always → CHAIN with that tool fixed
│ └── 2+ → AGENT
└── NO → Is it a simple LLM call? → YES → single-step CHAIN
The 5 questions that resolve 90% of cases
1. Is the order of the steps fixed? If yes for every possible input → Chain.
2. Are there branches, but the conditions are code rules?
If you can write an if/elif that covers every case → Workflow.
3. Does the LLM need to decide which tool to use? If the user can ask for varied things and the system must adapt → Agent.
4. How many tools are there, and does the user know which one they need? If there's 1 tool and it's always used → Chain. If there are 10 and the user doesn't know which → Agent.
5. Do latency and cost matter? If you need responses in <1s and predictable costs → avoid agents. If quality matters more than speed → an Agent can be justified.
Detailed Comparison
| Criterion | Chain | Workflow | Agent |
|---|---|---|---|
| Execution flow | Fixed sequence (A→B→C) | Conditional (if/else) | Non-deterministic (LLM decides) |
| Who decides the path | Nobody — always the same | Rules in code | The LLM on each iteration |
| Tool use | Fixed, predefined order | Fixed per path | LLM picks which, when, how many |
| Predictability | ⬆️ High | ⬆️ Medium | ⬇️ Low |
| Testability | ⬆️ Easy — test each step | ⬆️ Easy — test each path | ⬇️ Hard — non-deterministic output |
| Latency | ⬇️ Low — N fixed calls | ⬇️ Medium | ⬆️ High and variable |
| Cost per run | ⬇️ Fixed and predictable | ⬇️ Predictable per path | ⬆️ Variable (2-10x more) |
| Adaptability | ⬇️ None | ⬇️ Limited to defined paths | ⬆️ High — handles unanticipated inputs |
| Code complexity | ⬇️ Low (~10-20 lines) | Medium (~40-80 lines) | ⬆️ High (~50-100+ lines) |
| Debugging | ⬆️ Print each step | ⬆️ Log each path | ⬇️ Requires tracing (LangSmith) |
| Auditability | ⬆️ Total | ⬆️ Total | ⬇️ Partial |
Real costs (gpt-4.1-mini, ~500 input tokens, ~200 output tokens)
| Architecture | LLM calls | Estimated cost | Latency |
|---|---|---|---|
| Chain (3 steps) | 3 | ~$0.003 | ~1.5s |
| Workflow (classify + 1 path) | 2 | ~$0.002 | ~1.0s |
| Agent (2-4 iterations) | 3-6 | ~$0.003-$0.009 | ~2-5s |
At 10,000 runs/day, that's $30/day (chain) vs $30-$90/day (agent). At scale, the difference is real.
Real-World Examples
Notion AI Writing → Chain
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain.chat_models import init_chat_model
model = init_chat_model("openai:gpt-4.1-mini")
summarize = ChatPromptTemplate.from_messages([
("system", "Summarize the text in 2-3 sentences."),
("human", "{text}")
]) | model | StrOutputParser()
format_output = ChatPromptTemplate.from_messages([
("system", "Format with bullet points for easy reading."),
("human", "{summary}")
]) | model | StrOutputParser()
def notion_summarize(text: str) -> str:
return format_output.invoke({"summary": summarize.invoke({"text": text})})
Why a chain? The flow is always: text → summarize → format. No decisions, no external tools. An agent here would be over-engineering.
Email Classifier → Workflow
The example from the previous section (the ticket classifier) is exactly this: classify → route by rules → run the appropriate path. The routing rules are if/elif in code — deterministic, auditable, testable.
Research Assistant → Agent
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
@tool
def search_web(query: str) -> str:
"""Searches the web for current information."""
return f"AI agents market: $5.2B in 2024, projected $50B by 2028."
@tool
def calculator(expression: str) -> str:
"""Calculates mathematical expressions."""
try:
return str(round(eval(expression), 2))
except Exception as e:
return f"Error: {e}"
@tool
def write_report(topic: str, data: str) -> str:
"""Writes a structured summary given a topic and data."""
return f"# {topic}\n\n{data}\n\nAccelerated growth in the sector."
model = init_chat_model("openai:gpt-4.1-mini")
tools = [search_web, calculator, write_report]
tools_by_name = {t.name: t for t in tools}
model_with_tools = model.bind_tools(tools)
messages = [
SystemMessage(content="You are a research assistant. Search for data, calculate, and produce reports."),
HumanMessage(content="Research the AI agents market: revenue, growth rate, and produce a report.")
]
for i in range(8):
response = model_with_tools.invoke(messages)
messages.append(response)
if not response.tool_calls:
print(response.content)
break
for tc in response.tool_calls:
result = tools_by_name[tc["name"]].invoke(tc["args"])
messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
print(f"[{i+1}] {tc['name']}({tc['args']})")
# The model decides how many searches, whether to compute growth, whether to write the report. No code dictates the order.
Why an agent? The task is open-ended. How many searches does it need? Should it calculate the growth rate? Search more before summarizing? It depends on what it finds.
The Hybrid Pattern: Workflow + Agent
In production, the most common architecture is a workflow that delegates to agents in the complex subnodes.
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.tools import tool
from langchain_core.messages import HumanMessage, ToolMessage
from langgraph.graph import StateGraph, START, END
from typing import TypedDict
model = init_chat_model("openai:gpt-4.1-mini")
class RequestState(TypedDict):
user_input: str
complexity: str
response: str
def classify_complexity(state: RequestState) -> RequestState:
prompt = ChatPromptTemplate.from_messages([
("system", "Classify the complexity. Reply ONLY: simple, complex."),
("human", "{user_input}")
])
chain = prompt | model | StrOutputParser()
return {"complexity": chain.invoke({"user_input": state["user_input"]}).strip().lower()}
def handle_simple(state: RequestState) -> RequestState:
chain = ChatPromptTemplate.from_messages([
("system", "Answer briefly and directly."),
("human", "{user_input}")
]) | model | StrOutputParser()
return {"response": chain.invoke({"user_input": state["user_input"]})}
def handle_complex(state: RequestState) -> RequestState:
@tool
def search(query: str) -> str:
"""Searches for information."""
return f"Result for '{query}': relevant data."
agent_tools = [search]
agent_model = model.bind_tools(agent_tools)
messages = [HumanMessage(content=state["user_input"])]
for _ in range(5):
resp = agent_model.invoke(messages)
messages.append(resp)
if not resp.tool_calls:
return {"response": resp.content}
for tc in resp.tool_calls:
result = search.invoke(tc["args"])
messages.append(ToolMessage(content=result, tool_call_id=tc["id"]))
return {"response": "I couldn't complete the research."}
def route(state: RequestState) -> str:
return "handle_simple" if state["complexity"] == "simple" else "handle_complex"
graph = StateGraph(RequestState)
graph.add_node("classify", classify_complexity)
graph.add_node("handle_simple", handle_simple)
graph.add_node("handle_complex", handle_complex)
graph.add_edge(START, "classify")
graph.add_conditional_edges("classify", route)
graph.add_edge("handle_simple", END)
graph.add_edge("handle_complex", END)
hybrid = graph.compile()
# Simple → fast chain. Complex → agent with tools.
# 70-80% of requests land in "simple". Only 20-30% need the agent.
Anti-Patterns: Expensive Mistakes
Over-engineering: An agent for a fixed task
# ❌ BAD: An agent with 1 tool that's always called
from langgraph.prebuilt import create_react_agent
@tool
def summarize(text: str) -> str:
"""Summarizes text."""
return "Summary..."
agent = create_react_agent(model, [summarize])
result = agent.invoke({"messages": [("user", "Summarize: [text]")]})
# ~3 LLM calls: decide → tool → respond. No benefit.
# ✅ GOOD: A direct chain
chain = ChatPromptTemplate.from_messages([
("system", "Summarize in 2-3 sentences."), ("human", "{text}")
]) | model
result = chain.invoke({"text": "[text]"})
# 1 LLM call, same result, 3x faster
Under-engineering: A chain for an open-ended task
# ❌ BAD: A fixed chain for research
def research(topic: str) -> str:
result = search_tool.invoke({"query": topic}) # What if it finds nothing?
return summarize_chain.invoke({"text": result}) # It can't search again
# ✅ GOOD: An agent that adapts
agent = create_react_agent(model, [search_web, calculator, write_report])
result = agent.invoke({"messages": [("user", f"Research {topic}")]})
# The model decides: search more, calculate, or answer
A practical rule about growing workflows
If your workflow has more than 5-6 paths and they keep growing every month, consider an agent for the "long tail" of infrequent categories. Keep dedicated workflow nodes only for paths with genuinely distinct logic.
Connection with the Project
In this module's project (capsule 08):
- You'll implement a ReAct agent from scratch. You'll already be clear on why you're using an agent: research requires dynamic decisions about what to search for and when to stop
- You'll ask yourself: "could I solve this with a chain?" The answer will be no — because the number of searches depends on what the agent finds
In the evolving project (Research Agent, Modules 4-10):
- The Research Agent is an agent, not a chain, because research is inherently open-ended
- In Module 4 you'll convert the agent into a StateGraph — but the agentic nature stays
- In Module 8 (Multi-Agent), you'll see the hybrid pattern in action: a supervisor workflow that delegates to specialized agents
This capsule's decision framework applies to every design: "Does this sub-problem need an agent, a chain, or a workflow?"
Troubleshooting
Problem 1: "I build everything as an agent"
Symptom: Every feature uses create_react_agent even when the task is predictable.
Cause: Tool bias — "when you have a hammer, everything looks like a nail."
Solution: Before writing code, answer: "Does the LLM need to decide something I can't encode as a rule?" If the answer is no → you don't need an agent.
Problem 2: "My chain breaks on unexpected inputs"
Cause: Under-engineering. A chain can't adapt to varied inputs.
Solution: Assess whether the task is really predictable. If the inputs are varied and you can't anticipate the paths → workflow (finite paths) or agent (open-ended).
Problem 3: "My agent is very slow for simple questions"
Cause: An agent for tasks that don't require an iterative loop.
Solution: The hybrid pattern: a router that classifies "simple vs complex" → chain for the simple, agent for the complex.
Problem 4: "I don't know if my system is a workflow or an agent"
Cause: Your code has if/else for routing, but inside some paths the LLM decides tools.
Solution: It's a hybrid — and that's fine. Routing = workflow. Subnodes with tool decisions = agent. Call it a "workflow with sub-agents."
Problem 5: "My agent's cost is unpredictable"
Cause: The agent decides how many iterations to run. That's inherent.
Solution: max_iterations as a cost guardrail. If you need predictable costs → use a chain or workflow for those features. Reserve agents for features where variability is acceptable.
Exercises
Exercise 1: Classify tasks (Easy)
For each task, say Chain, Workflow, or Agent. Justify in one sentence.
a) Translate text from Spanish to English b) A support chatbot that routes to a human if it's "urgent" c) "Help me plan a trip to Japan" (search flights, hotels, activities) d) ETL pipeline: extract data from CSV → transform → load into DB e) A system that generates contracts based on document type
See solution
a) Chain — Fixed flow: input → translate → output. No decisions.
b) Workflow — Rule: if urgent → human, otherwise → auto-response. Conditions in code.
c) Agent — Open-ended task: the model decides what to search first, how many options to compare, when it has enough.
d) Chain — Deterministic pipeline: extract → transform → load. Always the same.
e) Workflow — Classify the type → generate with the specific template for that type. Clear routing.
Pattern: Does it always do the same thing? → chain. Finite branches? → workflow. Must the LLM adapt? → agent.
Exercise 2: Calculate the cost of the wrong decision (Easy)
A "summarize the news" system processes 10,000 articles/day. It uses an agent with 1 tool (summarize). Each run takes ~3 LLM calls at $0.001 each.
a) How much does it cost per day as an agent? b) How much would it cost as a chain (1 LLM call)? c) How much do you save per month by switching to a chain?
See solution
a) Agent: 10,000 × 3 × $0.001 = $30/day
b) Chain: 10,000 × 1 × $0.001 = $10/day
c) Savings: ($30 - $10) × 30 = $600/month
The agent isn't justified. It always calls the same tool — there's no dynamic decision. It's a chain disguised as an agent. 2 extra LLM calls (decide + confirm) that add no value.
Exercise 3: Design a hybrid system (Medium)
Design the architecture (pseudocode) for customer support:
- FAQs → RAG chain
- Complaints → escalate to a human
- Technical requests → agent with tools (search_docs, create_ticket)
- Sales requests → proposal chain
See solution
# Step 1: Classify (chain)
def classify(state) -> str:
return {"category": llm_classify(state["message"])}
# Step 2: Routing (workflow — rules in code)
def route(state) -> str:
routes = {
"faq": "faq_chain", # Chain: embed → search FAQ → respond
"complaint": "escalate", # Workflow: notify the team
"technical": "tech_agent", # Agent: search_docs, create_ticket (LLM decides)
"sales": "sales_chain", # Chain: extract needs → match → propose
}
return routes.get(state["category"], "escalate")
# Each sub-system uses the architecture that fits.
# Only the technical part justifies an agent — the rest are chains or workflow steps.
Exercise 4: Implement the decision framework as a function (Medium)
Write a function that uses an LLM to analyze a task and recommend "chain", "workflow", or "agent".
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
model = init_chat_model("openai:gpt-4.1-mini")
recommend = ChatPromptTemplate.from_messages([
("system", """Analyze the task and recommend an architecture.
OPTIONS:
- CHAIN: Fixed pipeline. The flow is always the same.
- WORKFLOW: Predefined conditional branches.
- AGENT: The LLM dynamically decides which tools to use.
Reply with:
ARCHITECTURE: [chain/workflow/agent]
REASON: [1-2 sentences]"""),
("human", "Task: {task}")
]) | model | StrOutputParser()
tasks = [
"Translate documents from English to Spanish",
"Route support tickets to the right team",
"An assistant that finds papers, extracts data, and generates reports",
]
for task in tasks:
print(f"\nTask: {task}")
print(recommend.invoke({"task": task}))
Meta-lesson: This function is a chain — 1 LLM call. It doesn't need to be an agent to recommend architectures.
Exercise 5: Refactor an agent into a chain (Hard)
This code uses an agent for a task that should be a chain. Refactor it:
from langgraph.prebuilt import create_react_agent
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
@tool
def translate_to_english(text: str) -> str:
"""Translates from Spanish to English."""
m = init_chat_model("openai:gpt-4.1-mini")
return m.invoke(f"Translate to English: {text}").content
@tool
def format_professional(text: str) -> str:
"""Formats in a professional tone."""
m = init_chat_model("openai:gpt-4.1-mini")
return m.invoke(f"Rewrite in professional tone: {text}").content
model = init_chat_model("openai:gpt-4.1-mini")
agent = create_react_agent(model, [translate_to_english, format_professional])
result = agent.invoke({"messages": [("user", "Translate and format: 'Hola, quiero trabajo'")]})
See solution
from dotenv import load_dotenv
load_dotenv()
from langchain.chat_models import init_chat_model
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
model = init_chat_model("openai:gpt-4.1-mini")
translate = ChatPromptTemplate.from_messages([
("system", "Translate from Spanish to English."), ("human", "{text}")
]) | model | StrOutputParser()
format_pro = ChatPromptTemplate.from_messages([
("system", "Rewrite in a professional tone."), ("human", "{text}")
]) | model | StrOutputParser()
def translate_and_format(text: str) -> str:
return format_pro.invoke({"text": translate.invoke({"text": text})})
print(translate_and_format("Hola, quiero trabajo"))
Change: From ~5 LLM calls (agent + 2 internal tools) to 2 LLM calls. From ~3-5s to ~1-2s. The flow is always translate → format — there's no decision. A pure chain.
Exercise 6: When to escalate from workflow to agent (Hard)
Your email workflow has 4 paths. The team wants to add 10 more, and new categories appear every month. Design the migration strategy.
See solution
# BEFORE (unmaintainable — 14 paths and growing):
def route_v1(state):
if state["cat"] == "billing": return "billing"
elif state["cat"] == "technical": return "technical"
# ... 12 more
# AFTER (scalable hybrid):
CORE_PATHS = {"billing", "technical"} # Paths with custom logic
def route_v2(state):
if state["category"] in CORE_PATHS:
return state["category"] # Dedicated nodes (workflow)
return "general_agent" # Everything else → agent with generic tools
Migration rule:
- The 2-3 paths with genuinely distinct logic → keep as workflow nodes
- Everything else → an agent with generic tools (search_kb, create_ticket, draft_response)
- If one of the agent's paths has poor quality → promote it to a dedicated node
It scales indefinitely: new categories get absorbed by the agent.
Summary
In this capsule you learned:
- A chain is a deterministic pipeline: the same steps for every input. Use it when the flow is fixed
- A workflow adds conditional logic with rules in code. Use it when there are predefined branches
- An agent delegates decisions to the LLM. Use it when the task is open-ended and requires adaptation
- The decision framework: predictable flow? → chain. Branches with rules? → workflow. LLM decides? → agent
- The hybrid pattern (workflow + agents in subnodes) is the most common in production
- Over-engineering (an agent for a fixed task) costs 2-5x more with no benefit
- Under-engineering (a chain for an open-ended task) produces brittle systems
- The cost of choosing wrong grows with scale: a $0.003 difference per request becomes hundreds of dollars a month
Next capsule: Framework Landscape 2025-2026 — you'll explore the main frameworks for building agents (LangChain/LangGraph, Pydantic AI, CrewAI, AutoGen, Semantic Kernel) with the judgment to pick the right one.
Additional Resources
- LangChain LCEL (LangChain Expression Language) — The official reference for building composable chains
- LangGraph Documentation — A framework for workflows and agents as state graphs
- Building Effective Agents (Anthropic) — A decision framework for when to use agents
- How to Think About Agent Frameworks (LangChain Blog) — A perspective on agents vs workflows
- OpenAI: Orchestrating Agents — OpenAI's guide to designing agentic systems
- Pydantic AI Documentation — A type-safe alternative framework for agents
- Cognitive Architectures for Language Agents (CoALA) — The paper that formalizes the chain vs workflow vs agent taxonomy
- LangSmith — Tracing to debug and monitor agents in production