Graph engineering

2 min read
On trails:AGCTXstep 10/11

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.

Python
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 wrong

The 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.

A parent graph runs triage, then a research agent, then reply. The research agent is itself a graph of plan, search and write, and counts as one node in the parent. START triage node research_agent: ONE NODE plan search write its own graph, compiled once reply node END PARENT GRAPH
To the parent, the whole subgraph is one node.

There are two ways in, and the difference is the state.

Python
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"]}
Shared keys: add the compiled graph directly as a node. Different schemas: call it from a function and map the state in and out.

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