Agents Honestly
Part VII · Agent & Graph Engineering

Node or Function?

When a node earns its overhead, and when you should have written a plain function.

Exercise

Every chapter in Part VII has described what a graph gives you. This one is the invoice, and it closes the part with the question the part exists to answer.

There is a rule underneath it, and it is short enough to state before the argument:

A node is a boundary. Draw one only where you want something to happen at it.

Four things happen at a node boundary and nowhere else. If a step needs none of them, it is a function that some node calls.

The four things a boundary buys

At the boundaryWhat you getWhat it costs
A checkpoint is writtenResume, replay, time travelA serialize plus a store write, per superstep
Execution can pauseHuman gate, interruptThe node must be re-entrant, because resume re-runs it from the top
A retry policy appliesPer-node attempts and backoffThe node must be idempotent
An event is emittedA stream update and a named trace spanA name your consumers now handle

There is a fifth, structural rather than runtime. A node is on the path by construction. compile() can tell you that every route to END passes through it. That is not something a function call inside another node can promise, and it is the reason Atlas's policy check is a node despite containing no model call and no side effect.

Those five are the entire case for a boundary. The test is to name which one you want. If the answer is "it feels like a step," you have described a function.

Checkpoint cost is a dial, but a graph-wide one

Per-superstep writes are the default, not a law. LangGraph exposes a durability setting with three positions: "sync" persists before each step starts (strongest, slowest), "async" persists while the next step runs (a small window where a crash loses the write), and "exit" checkpoints only at the end (fastest, and it gives up mid-run resumption).

The catch is that it is one setting for the whole graph. You cannot make one chatty node cheap without making the pause you actually needed cheap too. And at "exit" there is no mid-run state to resume from at all. So the dial does not rescue an over-noded graph; it just lets you choose which guarantee to lose.

Two failure modes, and they are not symmetric

Too many nodes is the common one. A node per step, unconditional edges between all of them, a diagram that renders as a straight line. The costs compound quietly: write amplification on every superstep, a trace where the signal is buried in spans that only assign a field, and checkpoints that differ from their predecessor by one key.

The cost people miss is that node names are a schema. Position is data, which was the whole point of the graph, so the names are written into every checkpoint. Rename a node and a checkpoint written before the rename refers to a position that no longer exists. Splitting a step into two nodes is a code change; splitting a node is a migration on anything in flight. Fine-grained graphs are expensive to refactor for exactly the reason they were attractive.

Too few nodes is rarer and sharper. One node containing the whole pipeline gets you a trace with a single span called run, a retry that re-runs everything, and no pause point, because you cannot interrupt inside a node. That last one is what actually bites. A coarse node forecloses the human gate you will want in Part XII, and discovering that later means splitting a node, which is the migration above.

The upper bound is where the two failure modes stop being aesthetic:

A node that can pause must contain exactly one side-effecting call.

Resume re-executes the node from the top. Not from the interrupt() line. From the top. Anything that ran before the interrupt runs again, which is why the human gate topology is the shape where this rule earns its keep. That is a correctness constraint, and it is the only hard limit in this chapter. Everything else is cost.

The option most people skip

"Node or function" reads as binary, and it is not. LangGraph's functional API is a third tier: ordinary control flow with if, for, and while, plus checkpointing and interrupts, and no state schema, no reducers, no edges.

ts/src/atlas-functional.ts
import { task, entrypoint, interrupt } from '@langchain/langgraph';

const gather = task('gather', async (ticket: Ticket) => {
  // the whole agent loop, as an ordinary bounded `while`
});

const checkPolicy = task('check_policy', async (f: Findings) => { /* ... */ });

const atlas = entrypoint(
  { name: 'atlas', checkpointer },
  async (ticket: Ticket) => {
    const findings = await gather(ticket);
    const decision = await checkPolicy(findings);

    if (decision.needsHuman) {
      const approval = interrupt(decision.summary); // pauses, persists, resumes
      if (!approval.granted) return escalate(decision, approval.reason);
    }

    return finalize(decision);
  },
);

Same durability, same pause, none of the graph. What you give up is real: no topology to enumerate or draw, state scoped to each function instead of shared through a schema, and time travel, which is worth understanding as a consequence rather than a gap. A @task updates the checkpoint belonging to its @entrypoint instead of writing a new one, so there is no per-step history to rewind into. That is how the API is built, not a feature waiting to land. If replaying a run from an arbitrary step is something you need, it is an argument for StateGraph that will not expire.

So the actual decision has three tiers, not two:

Control flowSurvives the processCan pauseTopology you can enumerate
Plain functionYoursNoNoNo
@task / @entrypointYoursYesYesNo
Node in a StateGraphDataYesYesYes

The middle row is the one teams skip, and skipping it is how you end up with a twelve-node graph built to obtain resumption. You pay for a state schema, reducers, and an edge list to get a property the middle row hands over for a decorator.

The decision, per step

If the step…ThenBecause
Makes a bounded model call and returns fieldsNodeYou want the retry policy and a named span
Purely transforms state: parse, format, mapFunction, called by a nodeNothing at the boundary applies to it
Performs a side effectNode, aloneSo a retry or a resume repeats exactly one thing
Waits for a humanNode, alonePause points only exist between nodes
Is expensive and deterministic in its inputsNode, with a cache policyThe boundary is what a cache key can hang off
Must be provably unskippableNodecompile() can verify every path crosses it
Assigns one fieldFunction, merged into its neighbourYou would be checkpointing a rename
Loops over items with no pause insideFunction, or a fan-out if you need per-item retryA for loop does not need an edge

Retry and cache policies attach to the node, which is the mechanical reason those rows say node:

g.add_node("triage", triage, retry_policy=RetryPolicy(max_attempts=3))
g.add_node("embed_query", embed, cache_policy=CachePolicy(ttl=120))

The TypeScript API mirrors this

Same options on addNode, camelCased, as with the rest of the graph API in State, Nodes, Edges. The policies are the transferable idea; the spelling is not.

The row that gets argued about is the pure transform, and the argument is always "I want to see it." Visibility is a tracing concern. You get it from a span, and a span costs a decorator; a node costs a checkpoint, a stream event, and a name in your position schema. Instrument it. Don't checkpoint it.

And should it be a graph at all?

Part VII opened by deferring this. Three questions, all yes-or-no:

  1. Is there more than one run shape? If every run visits the same nodes in the same order, there is one shape.
  2. Does anything need to survive the process? A crash, a deploy, a three-day wait for a person.
  3. Does someone who is not the author need to know what the system can do?

Zero yeses is a function, and Part VI is the chapter for it. One yes is usually the functional API. Two or three is a graph.

The instructive failure is adopting a graph for question 3 alone. Generated legibility is a genuine purchase, since a diagram derived from the system does not rot the way a hand-maintained one does. But if the answers to 1 and 2 are no, what gets generated is a picture of a straight line, and you have bought per-step checkpointing to render it.

And the ceiling is worth naming here rather than in Part X. Graph checkpointing persists your graph's position. It does not make the API call inside a node exactly-once, and it does not survive a node that succeeded remotely and failed locally. When that gap starts costing money, the answer is not a finer-grained graph. For Atlas it does, at issue_credit. It is durable execution, and Part X argues the escalation properly.

Atlas, audited

Running the test against the six nodes:

NodeBoundary earns it viaVerdict
triageRetry policy, named spanNode
gatherThe cycle needs an edge; per-iteration stream eventsNode
check_policyStructural, since every path must cross itNode
composeRetry policy, named spanNode
escalateSide effect, aloneNode
finalizeSide effect, and every path ends hereNode, with a caveat

The caveat is worth stating plainly, because it is a flaw in this book's own earlier example. finalize does three things: cite, log, send. It never pauses, so the exactly-one-effect rule does not bite. But it carries a retry policy, and a retry re-runs all three: a second send on a transient failure in the logging call. The fix is not a fourth node. It is that each of the three is idempotent, keyed on the ticket. Node count is the wrong lever for a property that belongs to the effects themselves.

What is not in that table is the more useful half. Entity extraction is a function inside triage, not a node beside it. Result truncation is a function inside gather. Citation formatting is a function inside finalize. Each was a node in an earlier draft of Atlas, and each was deleted for the same reason: nothing was supposed to happen at the boundary.

Six nodes for nine steps. The gap between those two numbers is this chapter.

References


Part VII ends here. Next: Tools Are APIs for Models, Part VIII, and the design of what an agent is permitted to do.

Takeaways

  • A node is a boundary. Draw one only when you can name what happens there: a checkpoint, a pause, a retry policy, an emitted event, or the structural guarantee that every path crosses it.
  • "It feels like a step" describes a function. Steps are free; boundaries are not.
  • Durability is a graph-wide dial (sync / async / exit), so it cannot make one noisy node cheap without weakening the guarantee you wanted elsewhere.
  • Node names are part of your position schema. Splitting a node is a migration for anything in flight, which makes fine-grained graphs expensive to refactor.
  • The one hard rule: a node that can pause must contain exactly one side-effecting call, because resume re-runs the node from the top rather than from the interrupt().
  • The functional API is the tier most teams skip. You get ordinary control flow, with checkpointing and interrupts, and no schema, reducers, or edges. You trade away an enumerable topology, and time travel. The second one is structural, because tasks update the entrypoint's checkpoint rather than creating their own.
  • Visibility is a tracing concern. Instrument a pure transform with a span; do not promote it to a node to see it.
  • A graph is worth it at two of three: more than one run shape, something must survive the process, or a reader who is not the author. Adopting one for legibility alone generates a picture of a straight line.
  • Idempotency fixes repeated effects, not node count. Atlas's finalize is the example.

On this page