Skip to content

interrupt(): human-in-the-loop interruption

源码版本1.2.9

Responsibilities

interrupt(value) is LangGraph's human-in-the-loop primitive, defined in libs/langgraph/langgraph/types.py (interrupt:811). Calling it once inside a node function raises a GraphInterrupt exception, wraps value as Interrupt(value=..., id=...) (Interrupt class:535) and delivers it to the client. The entire graph execution is paused at the current superstep boundary.

It sits between the node function and the Pregel loop: interrupt() itself does exactly one thing — raise the exception. The actual "pausing" logic lives in PregelLoop.tick: it detects the exception, writes Interrupt into pending writes, makes loop.tick() return False, and the main loop exits (raise GraphInterrupt:724). Once the client has the Interrupt, it prepares a resume value and re-invokes stream / invoke with Command(resume=...). The graph restores from the checkpoint, re-enters the interrupted node, and this time interrupt() returns the resume value provided by the client, then continues execution.

Design motivation

  • Node-level pause, not graph-level: putting the interrupt point inside the node function — rather than using node-boundary config like interrupt_before / interrupt_after — means the interrupt condition can be a runtime-computed result, e.g. if state["needs_review"]: answer = interrupt({"doc": ...}). No need to declare it statically when compiling the graph.
  • Multiple interrupts in the same node: a node can call interrupt() several times in a row; LangGraph matches resume values in order of appearance. interrupt_counter() (interrupt_counter:15) increments on each call and pulls the corresponding value from the scratchpad.resume list by idx. The resume list is task-scoped and not shared across nodes.
  • Node re-entry, not continuation: when interrupt() raises, the node function is aborted and this execution's side effects (writes aside) are discarded. On resume, execution does not pick up from the line after interrupt() — it starts over from the top of the node. The scratchpad.resume list makes the prior interrupt() calls return their values directly, skipping the branches that would have triggered interrupts, and naturally flowing to the next interrupt() or the end of the function.
  • Depends on a checkpointer: interrupt() requires a checkpointer, because pausing needs to persist the channel snapshot and pending writes so resume can restore them. Command(resume=...) without a checkpointer raises RuntimeError directly (no checkpointer error:907).
  • Multiple pending interrupts require an explicit id: when several nodes are pending an interrupt at the same time in the same graph, providing a single resume value raises RuntimeError, forcing you to specify an interrupt id (multi interrupt error:917).

Key files

  • interrupt:811 — the interrupt(value) function; uses the scratchpad internally to track consumed resume values.
  • Interrupt class:535 — the Interrupt(value, id) dataclass; from_ns uses xxh3_128_hexdigest to hash the namespace into an id.
  • interrupt body:911 — body of interrupt: pulls resume from the scratchpad, returns it if present, otherwise raises GraphInterrupt.
  • PregelScratchpad:9 — holds task-scoped mutable state such as interrupt_counter / get_null_resume / the resume list.
  • raise GraphInterrupt:724 — when preparing tasks, tick detects a pending interrupt with no resume and raises GraphInterrupt to exit the main loop.
  • _pending_interrupts:819 — computes the set of pending interrupts without a matching resume, used to decide whether an explicit multi-interrupt resume is needed.
  • Command resume mapping:904 — entry point that turns Command(resume=...) into pending writes; routes by interrupt id when resume is a dict.
  • no checkpointer error:907 — raises when Command(resume=...) is used without a checkpointer.
  • multi interrupt error:917 — raises when there are multiple pending interrupts but resume does not specify an id.
  • should_interrupt:155 — predicate for the interrupt_before / interrupt_after config; checks whether channel versions have changed.

Data flow

The core logic of interrupt() (interrupt body:911):

python
conf = get_config()["configurable"]
# track interrupt index
scratchpad = conf[CONFIG_KEY_SCRATCHPAD]
idx = scratchpad.interrupt_counter()
# find previous resume values
if scratchpad.resume:
    if idx < len(scratchpad.resume):
        conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
        return scratchpad.resume[idx]
# find current resume value
v = scratchpad.get_null_resume(True)
if v is not None:
    assert len(scratchpad.resume) == idx, (scratchpad.resume, idx)
    scratchpad.resume.append(v)
    conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
    return v
# no resume value found
raise GraphInterrupt(
    (
        Interrupt.from_ns(
            value=value,
            ns=conf[CONFIG_KEY_CHECKPOINT_NS],
        ),
    )
)

The logic has three branches: (1) scratchpad.resume already has values (meaning this node has been re-entered and prior interrupt() calls have already consumed part of resume) — return the i-th value by idx, and write (RESUME, scratchpad.resume) once to persist the entire resume list into pending writes so a subsequent interrupt can still resume; (2) no resume has been consumed yet, but a resume value was supplied externally this call — get_null_resume(True) retrieves it, appends it to scratchpad.resume, and returns it; (3) neither — raise GraphInterrupt and throw the Interrupt to the runner.

Once GraphInterrupt is raised by the node, PregelRunner.tick treats it as a subclass of GraphBubbleUp — not as a node exception, but writes the Interrupt as an (INTERRUPT, interrupts) write into task.writes (error writes:596). When loop.after_tick finishes, apply_writes sees the INTERRUPT write and does not merge it into channels; it marks this step as paused due to an interrupt. On the next tick, _first detects an unresumed INTERRUPT in pending_writes (_pending_interrupts:819) and directly raise GraphInterrupt() to exit the main loop (raise GraphInterrupt:724).

Boundaries and failure modes

  • A checkpointer is required: without one, interrupt() cannot persist pending writes after raising, and resume cannot restore state. Command(resume=...) raises directly (no checkpointer error:907).
  • Side effects of node re-entry: any IO the node function performed before interrupt() raised (writing files, calling external APIs) is not rolled back — on resume the entire node function runs from the top, so those side effects happen twice. When designing a node, either put interrupt() at the very front, or move side effects after interrupt().
  • Multiple pending interrupts require an explicit id: when a thread has multiple interrupts pending at once, Command(resume="single_value") raises (multi interrupt error:917); you must pass a dict as Command(resume={interrupt_id: value, ...}). _pending_interrupts() is used to detect this case.
  • Time-travel mode discards cached resume: when replaying a historical checkpoint, _first detects is_time_traveling and filters out RESUME-typed pending writes (drop resume on replay:899), letting interrupt() fire again instead of returning the previous resume value — that is the only way to genuinely return to the state at the interrupt moment.
  • Scratchpad scope: the resume list for multiple interrupt() calls within the same task is task-scoped and not shared across tasks. A subgraph's interrupt() uses its own scratchpad (distinguished by CONFIG_KEY_CHECKPOINT_NS); when the parent graph resumes a subgraph interrupt, it also routes by id.
  • from_ns hashes the id: Interrupt.id is computed by default with xxh3_128_hexdigest(ns) (from_ns:578) so that the interrupt id is stable for the same node in the same namespace, letting resume match it; passing an explicit id argument overrides the default.

Summary

interrupt() is the node-level human-in-the-loop primitive. It uses the scratchpad.resume list to "return resume values in order on node re-entry", and pauses execution via the GraphInterrupt exception plus checkpoint persistence. The companion Command(resume=...) is covered in /interrupt/command; the Pregel main loop's interrupt detection is in /pregel/loop. See official docs: LangGraph docs · README.