Nodes, edges and routing
Nodes do the work; edges decide what runs next. This note is the whole control flow surface.
The smallest complete graph
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict
class State(TypedDict):
question: str
answer: str
def answer(state: State) -> dict:
return {"answer": f"echo: {state['question']}"}
builder = StateGraph(State)
builder.add_node("answer", answer)
builder.add_edge(START, "answer")
builder.add_edge("answer", END)
graph = builder.compile()
graph.invoke({"question": "hi"})You must compile before invoking. compile() validates the graph — every
node reachable, every edge target real — and returns the runnable. It is also
where a checkpointer is attached, which is why persistence is a compile-time
decision rather than a per-call one.
START and END are sentinels, not nodes. START marks the entry;
reaching END finishes the run.
Three kinds of edge
| Edge | When the target is known |
|---|---|
| Normal | at build time |
| Conditional | at run time, by a function |
Command |
at run time, by the node itself |
Normal
builder.add_edge("retrieve", "generate")Always run generate after retrieve. Two normal edges out of one node means
both targets run in parallel — that is how you fan out, and it is why
parallel writes need reducers.
Conditional
A router function reads state and returns the name of the next node:
from typing import Literal
def route(state: State) -> Literal["retry", "finish"]:
if state["score"] < 0.5:
return "retry"
return "finish"
builder.add_conditional_edges("check", route)The router is plain Python — it does not call a model. That is the point: put the branch in code where it is deterministic and testable, and reserve the model for the decisions that genuinely need judgement.
Annotate the return with Literal so the possible targets are visible to the
graph and to whoever reads it.
Command — update and route together
from typing import Literal
from langgraph.types import Command
Lanes = Literal["urgent", "normal"]
def triage(state: State) -> Command[Lanes]:
lane = classify(state["ticket"])
return Command(
update={"lane": lane},
goto="urgent" if lane == "p1" else "normal",
)One node, one return, both a state update and the next hop. Use it when the routing decision is a by-product of the work the node just did — splitting that into a node plus a router means recomputing or stashing the answer.
Loops
A cycle is just an edge pointing backwards:
builder.add_edge("generate", "check")
builder.add_conditional_edges("check", route)
# route returns "generate" to loop, or "finish"That is the agent loop. Nothing special about it — which is the insight worth carrying: an agent is a graph with a cycle and a model in the router.
Gotcha: every cycle needs a termination condition you control. The runtime enforces a recursion limit as a backstop, and hitting it is an error, not a graceful stop. Put an explicit step counter in state and route to
ENDwhen it is exceeded, so exhaustion is a decision rather than a crash. See Agent failure modes.
Next = Literal["generate", "finish"]
def route(state: State) -> Next:
# Your limit, not the runtime's.
if state["steps"] >= 6:
return "finish"
if state["score"] < 0.5:
return "generate"
return "finish"Parallel fan-out and fan-in
builder.add_edge("plan", "search_web")
builder.add_edge("plan", "search_docs")
builder.add_edge("search_web", "merge")
builder.add_edge("search_docs", "merge")Both searches run concurrently; merge runs once, after both finish. The
runtime executes in supersteps — everything ready runs together, then
state is merged, then the next wave. That is why the reducer question from
State and reducers is unavoidable here.
Node signatures
A node takes state and returns a dict. It may also take config:
def node(state: State, config) -> dict:
user = config["configurable"].get("user_id")
return {"answer": lookup(user)}Nodes can be sync or async — use async for anything doing IO, which is most of them. Keep node bodies thin and push logic into ordinary functions you can test without the graph, exactly as with a web handler.
Related
Interview angle 6
- “How do you express branching?” - a conditional edge whose router is plain Python reading state. Keeping the branch in code rather than in a model call is what makes it deterministic and testable; the model is reserved for decisions that need judgement.
- “What is
Commandfor?” - returning a state update and the next node together, when the routing decision is a by-product of the work the node just did. Splitting that into a node plus a router means recomputing or stashing the result. - “How do you build a loop?” - an edge pointing backwards. An agent is a graph with a cycle and a model in the router — there is nothing else to it.
- “How do you stop a loop?” - an explicit step counter in state, routed to
ENDwhen exceeded. The runtime’s recursion limit is a backstop that raises rather than finishing gracefully, so exhaustion should be your decision. - “How does parallelism work?” - two edges out of one node run both targets concurrently, and the runtime executes in supersteps: everything ready runs, state merges, next wave. Any key two branches write needs a reducer or it is a conflict.
- “Why must you compile?” -
compile()validates reachability and edge targets and returns the runnable. It is also where the checkpointer attaches, which makes persistence a build-time decision.