LangGraph4j

Last update: 2026-04-22 22:26:49 +0800 +0800
Available translations: 日本語 / 中文(简体) / 中文(繁體)

LangGraph4j is a Java library for building stateful, graph-based AI applications. A graph consists of nodes that perform work, edges that select the next node, and shared state that changes as the graph runs. It is inspired by LangGraph and integrates with LangChain4j and Spring AI, but a graph node can also be ordinary Java code.

text
                    +------------+
                    | call model |
                    +------+-----+
                           |
                    tool requested?
                     /           \
                   yes            no
                   /               \
          +-------v-------+      +--v--+
          | execute tool  |      | END |
          +-------+-------+      +-----+
                  |
                  +----------> call model

Cycles are intentional. They let an application reason, act, inspect a result, and reason again. LangGraph4j makes that control flow and its state explicit; it also provides reducers, conditional routing, checkpoints, breakpoints, streaming, graph visualization, and subgraphs.

This tutorial uses LangGraph4j 1.8.24 and Java 17. It focuses on the core module so that its examples do not depend on a particular model provider or web framework.

What LangGraph4j Solves #

A small AI feature can be a single request:

text
request -> prompt -> model -> response

An agentic application may need to:

  1. classify a request;
  2. retrieve private documents;
  3. ask a model to select a tool;
  4. validate and execute that tool;
  5. retry a recoverable failure;
  6. pause before a risky action;
  7. retain progress across requests;
  8. resume the same run; and
  9. report progress while it works.

Loops, conditionals, queues, and database tables can implement this without a graph library. The difficult part is making the execution understandable, interruptible, recoverable, and safe when a model’s decisions are non-deterministic.

LangGraph4j supplies an execution model; it does not choose the workflow, prompt, tools, authorization rules, or business policy. Those remain application responsibilities.

Graphs Are Not Limited to DAGs #

A directed acyclic graph, or DAG, suits a one-way pipeline:

text
load -> transform -> validate -> publish

An agent loop is usually cyclic:

text
model -> tool -> model -> tool -> model -> answer

LangGraph4j supports both forms. A graph can branch, loop, run independent branches in parallel, join results, invoke a subgraph, pause at a configured node, and finish. Routing may be deterministic Java code or the validated result of a model decision.

The runtime follows a Pregel-like superstep model. Nodes activated in one step may run concurrently, then their updates are merged before later work runs. That makes the merge rules for shared state important.

Core Concepts #

State #

AgentState is the shared data carried through a graph run. It wraps a Map<String, Object> and is normally extended with typed accessors:

java
import java.util.List;
import java.util.Map;
import org.bsc.langgraph4j.state.AgentState;

final class SupportState extends AgentState {

    static final String REQUEST = "request";
    static final String DOCUMENTS = "documents";
    static final String PROPOSED_ACTION = "proposed_action";
    static final String APPROVED = "approved";
    static final String ATTEMPTS = "attempts";
    static final String ANSWER = "answer";

    SupportState(Map<String, Object> data) {
        super(data);
    }

    String request() {
        return value(REQUEST).map(Object::toString).orElse("");
    }

    List<String> documents() {
        return this.<List<String>>value(DOCUMENTS).orElse(List.of());
    }

    boolean approved() {
        return this.<Boolean>value(APPROVED).orElse(false);
    }
}

Nodes return updates rather than replacing a complete state object:

java
Map<String, Object> incrementAttempt(SupportState state) {
    int attempts = state.<Integer>value(SupportState.ATTEMPTS).orElse(0);
    return Map.of(SupportState.ATTEMPTS, attempts + 1);
}

State is application data, not just chat history. Keeping decisions, identifiers, artifacts, and status in named fields is easier to validate than recovering them from prose messages.

Do not place open connections, executors, request objects, or secrets in checkpointed state. A checkpoint must be serializable and may be inspected by operators or persisted outside the process.

Channels and Reducers #

A state schema maps a key to a Channel<?>. A channel defines the key’s default value and how an update is merged. With a base channel, a new value replaces the old one. Channels.appender accumulates values in a list.

java
import java.util.ArrayList;
import java.util.Map;
import org.bsc.langgraph4j.state.Channel;
import org.bsc.langgraph4j.state.Channels;

static final Map<String, Channel<?>> SCHEMA = Map.of(
        "status", Channels.base(() -> "new"),
        "audit", Channels.appender(ArrayList::new));

If a node returns Map.of("audit", "classified") and a later node returns Map.of("audit", "retrieved"), the appender channel retains both entries. The standard appender disallows duplicates; use Channels.appenderWithDuplicate when repeated values are meaningful.

Reducers are essential when parallel branches write the same key. They should be deterministic and free of side effects because checkpoint recovery and replay may apply the same state transitions again.

For chat messages, use the message-channel facilities supplied by the appropriate LangChain4j or Spring AI integration rather than treating message editing as blind list concatenation.

Nodes #

A node receives the current state and returns a map of updates. It can call a model, query a vector store, invoke a tool, validate data, emit an audit entry, or call a subgraph.

NodeAction<S> is synchronous. AsyncNodeAction<S> returns a CompletableFuture, which is useful for non-blocking model, database, and HTTP clients. The node_async adapter turns a synchronous action into an asynchronous node action.

Keep nodes small. A node that classifies, retrieves, invokes several tools, writes a row, and generates an answer hides the workflow inside one method and makes checkpoints and failures hard to interpret.

Edges #

An ordinary edge always selects the same destination:

java
graph.addEdge("retrieve", "generate");

A conditional edge evaluates state and returns a routing label. The graph maps that label to a declared destination:

java
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;

graph.addConditionalEdges(
        "review",
        edge_async(state -> state.approved() ? "publish" : "reject"),
        Map.of("publish", "publish", "reject", "reject"));

The mapping is an allowlist. Do not use a model-generated string as an unrestricted destination or function name. Validate model output, then map a small set of accepted decisions to graph nodes.

StateGraph.START and StateGraph.END mark graph entry and termination. Call compile() after defining nodes and edges. Compilation validates the structure and produces a runnable CompiledGraph.

Installing LangGraph4j #

LangGraph4j 1.8.x requires Java 17 or later. Import its BOM to keep core and optional module versions aligned:

xml
<properties>
  <langgraph4j.version>1.8.24</langgraph4j.version>
</properties>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.bsc.langgraph4j</groupId>
      <artifactId>langgraph4j-bom</artifactId>
      <version>${langgraph4j.version}</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <dependency>
    <groupId>org.bsc.langgraph4j</groupId>
    <artifactId>langgraph4j-core</artifactId>
  </dependency>
</dependencies>

Add an integration module only when the application needs it. LangGraph4j can orchestrate a node that uses a provider SDK directly, LangChain4j, Spring AI, a database client, or ordinary application code.

A Complete Java Graph #

The following deterministic graph drafts a release announcement, reviews it, and either publishes or rejects it. Its simple drafting logic keeps the graph mechanics visible; production code can replace the draft node with a model call.

java
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import org.bsc.langgraph4j.GraphStateException;
import org.bsc.langgraph4j.StateGraph;
import org.bsc.langgraph4j.action.NodeAction;
import org.bsc.langgraph4j.state.AgentState;
import org.bsc.langgraph4j.state.Channel;
import org.bsc.langgraph4j.state.Channels;

import static org.bsc.langgraph4j.StateGraph.END;
import static org.bsc.langgraph4j.StateGraph.START;
import static org.bsc.langgraph4j.action.AsyncEdgeAction.edge_async;
import static org.bsc.langgraph4j.action.AsyncNodeAction.node_async;

final class ReleaseState extends AgentState {

    static final String RELEASE_ID = "release_id";
    static final String NOTES = "notes";
    static final String DRAFT = "draft";
    static final String APPROVED = "approved";
    static final String STATUS = "status";
    static final String AUDIT = "audit";

    static final Map<String, Channel<?>> SCHEMA = Map.of(
            STATUS, Channels.base(() -> "new"),
            AUDIT, Channels.appender(ArrayList::new));

    ReleaseState(Map<String, Object> data) {
        super(data);
    }

    String releaseId() {
        return value(RELEASE_ID).map(Object::toString).orElseThrow();
    }

    String notes() {
        return value(NOTES).map(Object::toString).orElse("");
    }

    String draft() {
        return value(DRAFT).map(Object::toString).orElseThrow();
    }

    boolean approved() {
        return this.<Boolean>value(APPROVED).orElse(false);
    }

    List<String> audit() {
        return this.<List<String>>value(AUDIT).orElse(List.of());
    }
}

public final class ReleaseGraph {

    private static final NodeAction<ReleaseState> DRAFT = state -> Map.of(
            ReleaseState.DRAFT,
            "Release %s%n%n%s".formatted(state.releaseId(), state.notes()),
            ReleaseState.STATUS, "awaiting_review",
            ReleaseState.AUDIT, "drafted");

    private static final NodeAction<ReleaseState> REVIEW = state -> Map.of(
            ReleaseState.AUDIT, "reviewed");

    private static final NodeAction<ReleaseState> PUBLISH = state -> {
        // Call a publication API here with releaseId() as an idempotency key.
        return Map.of(
                ReleaseState.STATUS, "published",
                ReleaseState.AUDIT, "published");
    };

    private static final NodeAction<ReleaseState> REJECT = state -> Map.of(
            ReleaseState.STATUS, "rejected",
            ReleaseState.AUDIT, "rejected");

    static StateGraph<ReleaseState> definition() throws GraphStateException {
        return new StateGraph<>(
                ReleaseState.SCHEMA,
                ReleaseState::new)
                .addNode("draft", node_async(DRAFT))
                .addNode("review", node_async(REVIEW))
                .addNode("publish", node_async(PUBLISH))
                .addNode("reject", node_async(REJECT))
                .addEdge(START, "draft")
                .addEdge("draft", "review")
                .addConditionalEdges(
                        "review",
                        edge_async(state ->
                                state.approved() ? "publish" : "reject"),
                        Map.of(
                                "publish", "publish",
                                "reject", "reject"))
                .addEdge("publish", END)
                .addEdge("reject", END);
    }

    public static void main(String[] args) throws GraphStateException {
        var graph = definition().compile();

        var input = Map.<String, Object>of(
                ReleaseState.RELEASE_ID, "2026.08",
                ReleaseState.NOTES, "Adds resumable exports and audit reports.",
                ReleaseState.APPROVED, true);

        for (var output : graph.stream(input)) {
            System.out.println(output);
        }
    }
}

The graph starts at draft, moves to review, and then evaluates the conditional edge. A real review flow should not accept approved directly from an untrusted browser request. It must bind an authenticated approver to a specific proposed action and validate that decision on the server.

The PUBLISH node is deliberately separate from the model-facing work. It must treat release_id as an idempotency key, validate the final payload, and perform authorization before it creates an external side effect.

Conditional Routing and Bounded Loops #

Conditional edges make a retry or tool loop explicit:

text
call model -> tool needed? -- yes --> execute tool --+
      |                                              |
      no                                             |
      v                                              |
     answer <----------------------------------------+

The route action should return one of a small number of labels. For a retry loop, store an attempt counter in state and route to a failure node once the limit is reached. CompileConfig.builder().recursionLimit(...) also sets a positive upper bound on graph iterations; its default is 25.

Do not rely on a model to stop an expensive or dangerous loop. Track model calls, tool calls, elapsed time, token use, and retries in application state or observability data. Every cycle needs a success route, a bounded retry route, and a failure route.

Checkpoints, Breakpoints, and Resumption #

A checkpoint saver stores snapshots under a RunnableConfig thread ID. This allows an application to inspect state, stop at a configured node, and continue the same thread later.

java
import java.util.Map;

import org.bsc.langgraph4j.CompileConfig;
import org.bsc.langgraph4j.RunnableConfig;
import org.bsc.langgraph4j.checkpoint.MemorySaver;

var compiled = ReleaseGraph.definition()
        .compile(CompileConfig.builder()
                .checkpointSaver(new MemorySaver())
                .interruptBefore("publish")
                .build());

var thread = RunnableConfig.builder()
        .threadId("release-2026.08")
        .build();

for (var output : compiled.stream(
        Map.of(
                ReleaseState.RELEASE_ID, "2026.08",
                ReleaseState.NOTES, "Adds resumable exports and audit reports.",
                ReleaseState.APPROVED, true),
        thread)) {
    System.out.println(output);
}

With interruptBefore("publish"), execution stops before the named node. MemorySaver is useful for local development only: it loses all state when the process ends. LangGraph4j also provides saver modules for databases and stores such as PostgreSQL, SQLite, MySQL, Redis, DynamoDB, CockroachDB, Hazelcast, and Oracle Database. Select a backend based on the application’s availability, retention, transaction, and operational requirements.

To edit a paused thread, persist an authenticated approval decision and update the checkpointed state. updateState returns a configuration that identifies the updated checkpoint; supplying the node that made the decision lets the runtime calculate the next route:

java
RunnableConfig resumed = compiled.updateState(
        thread,
        Map.of(ReleaseState.APPROVED, true),
        "review");

for (var output : compiled.stream(null, resumed)) {
    System.out.println(output);
}

Passing null to this stream overload resumes the saved graph input. In a service, store the thread ID server-side with its tenant and owner. Never load an arbitrary client-provided thread ID without verifying access to it.

An interruption is a node-level breakpoint, not an authorization system. The approval screen must show the concrete, validated action and its arguments. “Continue?” is not meaningful consent if the action deploys a service, deletes data, or changes money.

Durable Does Not Mean Exactly Once #

Checkpoints can resume computation, but a checkpoint and an external side effect are not automatically one transaction:

text
1. publish node calls an external API
2. process fails before the following checkpoint
3. the node runs again during recovery

Without an idempotency key or authoritative transaction record, the effect may occur twice. Make nodes that send email, change infrastructure, or mutate a business system idempotent. Treat model calls as repeatable but non-deterministic: replay can cost money and return different output.

Streaming and State Inspection #

CompiledGraph.stream yields a sequence of NodeOutput values as the graph runs. The complete example prints each value; a web application can translate safe progress events into Server-Sent Events or a WebSocket message.

By default, graph streaming uses CompiledGraph.StreamMode.VALUES. streamSnapshots or a RunnableConfig with StreamMode.SNAPSHOTS provides checkpoint-oriented state snapshots when a saver is configured.

java
import org.bsc.langgraph4j.CompiledGraph;
import org.bsc.langgraph4j.RunnableConfig;

var config = RunnableConfig.builder()
        .threadId("release-2026.08")
        .streamMode(CompiledGraph.StreamMode.SNAPSHOTS)
        .build();

for (var output : compiled.streamSnapshots(
        Map.of(ReleaseState.APPROVED, true),
        config)) {
    System.out.println(output);
}

Do not serialize every state field to a browser. State may contain retrieved documents, tool arguments, internal identifiers, or data that a node added by mistake. Define an explicit event DTO with only the fields a particular recipient may see.

CompiledGraph.getState, stateOf, lastStateOf, and getStateHistory inspect saved state for a thread. Treat these interfaces as privileged operational tools and apply the same tenant isolation, retention, deletion, and encryption rules used for other application data.

Parallel Nodes and Merge Rules #

Multiple outgoing edges can activate independent work in the same superstep:

text
                   +-> search internal docs -+
question -> route  +-> search issue tracker --+-> combine -> answer
                   +-> search source code ----+

Parallelism is useful only when branches are independent. Their writes must be mergeable: a channel can append candidates, a custom base-channel reducer can merge namespaced maps, and a single owner can write a final answer.

Parallel model requests cost more and can exhaust provider limits. Bound concurrency with an application executor, propagate cancellation, and decide what should happen when one branch fails while another succeeds. A parallel node cannot itself have incompatible multiple downstream routes, so design a join node that makes the next deterministic decision.

Subgraphs and Multi-Agent Workflows #

A StateGraph or CompiledGraph can be added as a node of another graph. Subgraphs are useful for a reusable research flow, a specialized tool loop, a team-owned module, or a separately testable workflow.

java
StateGraph<ReleaseState> child = new StateGraph<>(
        ReleaseState.SCHEMA,
        ReleaseState::new);

StateGraph<ReleaseState> parent = new StateGraph<>(
        ReleaseState.SCHEMA,
        ReleaseState::new)
        .addNode("prepare_release", child);

The parent and this form of child graph share the same state type. Define clear state ownership and narrow interfaces before composing graphs. Do not create a separate model-mediated agent for an ordinary deterministic function; doing so adds latency, cost, and non-determinism without improving control flow.

An in-process subgraph is not an independent remote agent. A2A addresses communication across an agent-service boundary. MCP exposes tools and resources to agents. LangGraph4j orchestrates the Java workflow that may use either protocol.

LangGraph4j, LangChain4j, and Spring AI #

These components occupy different layers:

ComponentPrimary Role
LangGraph4jJava state graphs, routing, checkpoints, breakpoints, and execution
LangChain4jJava model, message, tool, retrieval, and agent abstractions
Spring AISpring model clients, tools, RAG, vector stores, MCP, and observability
LangGraph4j StudioGraph visualization, execution, and debugging UI

LangGraph4j is an independent project inspired by LangGraph; it is not a drop-in implementation of another runtime and does not share checkpoint or deployment compatibility guarantees.

Use LangChain4j or Spring AI inside graph nodes when their provider adapters, message abstractions, tools, or retrieval support fit the application. Use ordinary Java clients when that is simpler. A graph runtime is useful when the application needs explicit cycles, stateful routing, durable checkpoints, or controlled pauses; basic chat and retrieval do not require one.

Deployment and Operations #

LangGraph4j is a library embedded in the Java service that the team builds and operates. The team owns:

  • checkpoint storage, migrations, retention, and deletion;
  • queueing, concurrency limits, cancellation, and timeouts;
  • authentication and thread authorization;
  • retry policy and idempotency;
  • streaming endpoints and reconnection behavior;
  • observability, alerts, and cost controls;
  • model and tool credentials; and
  • safe shutdown and recovery.

LangGraph4j includes graph-representation support for Mermaid and PlantUML and an embeddable Studio project for visual inspection. These are useful during development, but they do not replace production metrics, traces, audit logs, or authorization checks.

When registering a compiled graph as a Spring singleton, keep its nodes and dependencies stateless and thread-safe. Per-run data belongs in graph input and checkpoint storage, not mutable fields on the singleton.

Design Guidelines #

Keep Deterministic Decisions in Java #

Use a model for semantic classification and generation. Use normal Java code for authorization, arithmetic, quotas, schema validation, and invariant enforcement.

Make State Explicit #

Store important decisions in named fields rather than parsing old messages. Document which node owns each field and how concurrent updates merge.

Bound Every Cycle #

Track attempts, model calls, elapsed time, tokens, and tool invocations. Give each loop a termination condition and an error route.

Make Side Effects Idempotent #

Retries and recovery are normal. Use stable operation IDs and authoritative records for externally visible effects.

Separate Proposal From Execution #

Let a model propose an action. Validate it, authorize it, and obtain any required approval before a separate node performs the action.

Persist Only Governable Data #

Apply retention, encryption, deletion, and tenant isolation to checkpoints. Graph state may contain prompts, personal data, retrieved documents, and tool results.

Evaluate Trajectories #

A final answer can look correct even when a graph used the wrong tool, retrieved forbidden data, retried excessively, or ignored a required review. Evaluate routes, state transitions, tool arguments, evidence, cost, and latency in addition to final output.

Version Graphs and State #

Threads can survive a deployment. A later release may rename nodes or change the state schema while saved checkpoints still refer to an earlier graph. Define migration and compatibility policy before changing a production graph.

When Not to Use LangGraph4j #

Do not add a graph runtime when:

  • one model call is sufficient;
  • a short deterministic pipeline is already clear;
  • ordinary Spring AI or LangChain4j code meets the need;
  • a conventional workflow engine already owns the durable business process;
  • no state must survive between requests; or
  • the team cannot operate and evaluate an additional stateful system.

For a financial or fulfillment process, a conventional workflow engine can remain the authoritative system while a graph performs bounded AI analysis:

text
durable business workflow
  -> starts an AI analysis graph
  -> receives a proposed decision
  -> applies deterministic policy and approval
  -> performs the authoritative transaction

Choose the smallest abstraction that expresses the required control flow. A graph is valuable when the workflow genuinely branches, loops, pauses, and resumes; otherwise, it can become a verbose flowchart around code that was already clear.

Further Reading #

Comments

Read or comment on GitHub (sign-in required to comment)