Graph engineering
Flow engineeringFlow engineeringDesign the task as a flow of steps: a model where judgement is needed, plain code everywhere else.Opens in a new tab decides which steps need a model. Graph engineering is the next job: shaping the state, the nodes and the edges so the graph stays readable, repeatable and certain to stop. The vocabulary is in state graphsState graphsAn agent drawn as nodes that read and write one shared state, joined by edges that say what runs next.Opens in a new tab.
State holds raw data
The LangGraph docs give this as the key principle:
…your state should store raw data, not formatted text. Format prompts inside nodes when you need them.
Different nodes can format the same data differently. A prompt template can change without touching the state schema, and the state shows exactly what data each step received, which is what you read when something goes wrong. The prompt itself is built from that state by the context assemblerThe context assemblerThe step that builds the prompt from scratch on every loop.Opens in a new tab. Keep the state small, and when a graph’s callers should not see everything, give it a separate input and output schema.
Nodes: one job, safe to repeat
One job per node. A node that fetches, decides and sends cannot be tested, retried or traced in parts. Put each side effect in its own node. A node can run again from its start when a paused run resumes (see human in the loopHuman in the loopA pause in the agent's decision, where a person approves before a high-risk action runs.Opens in a new tab), so anything it does should be safe to do twice.
Edges: every loop needs an exit
An error the graph can recover from is better kept in the state than raised: record it, then let an edge decide whether to try again or give up. The LangGraph guidance for errors a model can fix has the same shape: store the error in state and loop back.
from typing_extensions import TypedDictfrom langgraph.errors import GraphRecursionErrorfrom langgraph.graph import StateGraph, START, END class State(TypedDict): order_id: str # raw data, not formatted prompt text order: dict | None error: str | None attempts: int def fetch(state: State) -> dict: try: return {"order": get_order(state["order_id"]), "error": None} except ValueError as exc: # recoverable: record it, let the graph decide return {"error": str(exc), "attempts": state["attempts"] + 1} def after_fetch(state: State) -> str: if state["error"] is None: return END return "fetch" if state["attempts"] < 3 else "give_up" def give_up(state: State) -> dict: return {"order": None} graph = ( StateGraph(State) .add_node("fetch", fetch) .add_node("give_up", give_up) .add_edge(START, "fetch") .add_conditional_edges("fetch", after_fetch, ["fetch", "give_up", END]) .add_edge("give_up", END) .compile()) try: graph.invoke({"order_id": "A1", "order": None, "error": None, "attempts": 0}, config={"recursion_limit": 10})except GraphRecursionError: ... # the safety net if the exit condition is wrongThe exit is the explicit counter in the state (line 20). The recursion limit is only the net: it caps the number of super-steps in a run, defaults to 1000 since langgraph 1.0.6, and raises GraphRecursionError when hit. A loop with no exit of its own ends that way.
Subgraphs
When a graph grows, or one part is reused or owned by another team, a compiled graph can be a node inside another. This is the shape behind a specialist inside a routerRouter patternA manager that only decides which specialist handles the request.Opens in a new tab or any split of a monolith agentThe monolith agent problemOne super agent that does everything breaks the way a software monolith does.Opens in a new tab.
There are two ways in, and the difference is the state.
from typing_extensions import TypedDictfrom langgraph.graph import StateGraph, START, END class Shared(TypedDict): question: str answer: str def research(state: Shared) -> dict: return {"answer": f"notes on {state['question']}"} sub = ( StateGraph(Shared) .add_node("research", research) .add_edge(START, "research") .add_edge("research", END) .compile()) parent = ( StateGraph(Shared) .add_node("research_agent", sub) # shared keys: add the graph as a node .add_edge(START, "research_agent") .add_edge("research_agent", END) .compile()) class Other(TypedDict): topic: str notes: str def call_sub(state: Other) -> dict: # different schema: map in, map out out = sub.invoke({"question": state["topic"], "answer": ""}) return {"notes": out["answer"]}Saving the run
Compile with a checkpointer and pass a thread_id, and a run can stop and pick up again. Pausing for a person and keeping a conversation both rest on that (see agent memoryAgent memoryWhat an agent remembers: the thread it is in, and what it keeps across threads.Opens in a new tab).
Sources
- Thinking in LangGraph, LangChain docs · accessed October 5, 2026
- Graph API overview, LangGraph docs · accessed October 5, 2026
- Subgraphs, LangGraph docs · accessed October 5, 2026