More autonomy isn't always better
It's tempting to make everything an agent. But for a task like "summarize this support doc," a fixed pipeline is cheaper, faster, and never goes rogue — there's no loop where the model can wander off, call the wrong tool, or hallucinate a step. The senior skill isn't building the most autonomous system you can. It's knowing when to hand the model the wheel — and when to keep it firmly in the code.
Northwind's Aria has to handle both kinds of work: rigid, regulated flows (issue a refund within policy) and open-ended ones (research why a customer churned). The same framework should serve both. That's the bet LangGraph makes.
Two ways to control the flow
Every multi-step LLM system answers one question: who decides what happens next — your code, or the model?
- Workflow — your code controls the path. The steps are predefined and wired in advance: do A, then B, then C. The LLM is a worker called at fixed points; it never chooses the route.
- Agent — the LLM controls the path. You give it a goal and a set of tools, and it decides which tool to call, in what order, and when it's done. The loop runs until the model says "finished."
This is a reliability ↔ flexibility trade-off:
| Workflow (code drives) | Agent (LLM drives) | |
|---|---|---|
| Path | Fixed, known in advance | Decided at runtime |
| Reliability | High — same path every time | Lower — can take odd routes |
| Flexibility | Low — only the cases you coded | High — adapts to novel inputs |
| Cost / latency | Predictable, usually lower | Variable — extra LLM turns |
| Best when | Steps are known | Steps are not known |
LangGraph represents everything as a graph of nodes and edges over a shared state. A workflow is a graph whose edges you draw (static routing). An agent is a graph whose routing edge is decided by the model each turn (the tool-calling loop). Same primitive — StateGraph — so you can start with hard-wired edges and later swap one edge for a model decision without a rewrite.
Worked example: "process a refund," two ways
Same task, both control styles. First, the workflow — you know the three steps (look up the order, check the policy, issue the refund), so you wire them in code. The model is used only inside check_policy to make a judgement; it never picks the route.
from langgraph.graph import StateGraph, START, END
from langchain.chat_models import init_chat_model
from typing import TypedDict
llm = init_chat_model("claude-sonnet-4-5-20250929", model_provider="anthropic")
class RefundState(TypedDict):
order_id: str
order: dict
approved: bool
result: str
def lookup_order(state: RefundState) -> dict:
return {"order": db.get_order(state["order_id"])} # plain code
def check_policy(state: RefundState) -> dict:
verdict = llm.invoke(
f"Refundable under our 30-day policy? Order: {state['order']}. "
f"Answer only YES or NO."
).content
return {"approved": "YES" in verdict.upper()} # LLM as a worker
def issue_refund(state: RefundState) -> dict:
if state["approved"]:
return {"result": payments.refund(state["order"])} # plain code
return {"result": "Refund denied: outside policy window."}
# YOU draw the edges — the path is fixed
builder = StateGraph(RefundState)
builder.add_node("lookup", lookup_order)
builder.add_node("policy", check_policy)
builder.add_node("refund", issue_refund)
builder.add_edge(START, "lookup")
builder.add_edge("lookup", "policy")
builder.add_edge("policy", "refund")
builder.add_edge("refund", END)
workflow = builder.compile()
Now the agent. You hand the model the same capabilities as tools and let it decide the order. Notice you no longer draw edges between steps — the loop does that.
from langchain.tools import tool
from langchain.agents import create_agent
@tool
def lookup_order(order_id: str) -> dict:
"""Fetch an order record by its ID."""
return db.get_order(order_id)
@tool
def issue_refund(order_id: str) -> str:
"""Refund an order. Only call after confirming it meets the 30-day policy."""
return payments.refund(db.get_order(order_id))
agent = create_agent(
model="anthropic:claude-sonnet-4-5-20250929",
tools=[lookup_order, issue_refund],
system_prompt=(
"You are Aria, Northwind's support agent. "
"Refund an order only if it is within the 30-day policy window."
),
)
agent.invoke({"messages": [{"role": "user", "content": "Refund order A-1003."}]})
The agent might look up the order, reason about the policy, then refund — or it might ask a clarifying question, or refuse. That adaptability is the point. So is the cost: it's at least two LLM turns, and the route isn't guaranteed.
If your refund flow is always "look up → check policy → refund," an agent buys you nothing but extra latency, token cost, and a chance to misroute. Hard-coded steps that you understand should stay hard-coded. Add autonomy only where the path genuinely varies.
Your turn
For each task, decide workflow or agent, and justify in one sentence:
- Summarize an incoming support ticket into three bullet points.
- Investigate why a customer's last three orders were delayed, pulling from orders, shipping, and notes as needed.
- Translate every reply Aria sends into the customer's language before it goes out.
How you did
1 — Workflow. One known step (summarize). No routing decision exists, so don't create one. A single LLM call inside a fixed pipeline.
2 — Agent. You can't predict which sources are needed or in what order — that depends on what each lookup reveals. This is exactly the "steps aren't known" case.
3 — Workflow. "Translate, then send" is a fixed final step on every reply. Wire it in code.
Prefer the least autonomy that solves the problem. Start as a workflow. Promote a step to agent-driven only when you genuinely can't enumerate the path in advance. Because LangGraph models both as graphs, that promotion is a small edit — not a rebuild.