I kept hearing the same question in Java circles: if Spring AI 2.0 covers chat clients and structured output, why bother with LangChain4j's agentic module at all? Fair question. Abstractions only earn their keep when you can feel the difference under the same workload.
I kept hearing the same question in Java circles: if Spring AI 2.0 covers chat clients and structured output, why bother with LangChain4j's agentic module at all? Fair question. Abstractions only earn their keep when you can feel the difference under the same workload. I stopped arguing from docs and built a lab. Same research scenario, same prompts, same OpenRouter models, two Spring Boot apps, one UI with an engine toggle. Planner → researcher → writer/critic loop. Watch the steps stream in. Compare elapsed time. That is the whole point of the spring-langchain4j lab in my spring-langchain4j repo — an apples-to-apples orchestration comparison, not a provider bake-off. Why this showed up in my work On ITJobOpportunities I already ship AI features that are more than a single prompt: Job Fit, résumé improve, recruiter assessments. Those flows need stages, retries, and a place to put "the critic said rewrite this." Once you have more than one role, the interesting design question is not "which LLM?" It is "who owns the sequence?" Spring AI is excellent at the chat boundary. LangChain4j agentic is opinionated about graphs and shared scope. I wanted both answers in the same repo so I could switch without rewriting the scenario. The thesis Keep the domain and the HTTP contract identical. Put the disagreement in the orchestrator. If the planner returns a ResearchPlan , the researcher returns findings, and the writer/critic loop exits on critique.passes(threshold) , then the UI and the report JSON should not care which engine produced them. That forces the comparison onto control flow, state, and observability — the parts that actually diverge. Mental model Both apps run this pipeline: Planner — topic + depth → structured research questions Researcher — answers with an OpenRouter :online model (web-grounded) Writer / Critic loop — draft, score, revise until pass or max revisions Report — same ResearchReport shape, plus SSE step events for the UI Shared pieces on purpose: shared-prompts/*.system.txt Same system text on both classpaths OPENROUTER_CHAT_MODEL / OPENROUTER_RESEARCH_MODEL Same models for both engines Package layers api → orchestration → agents → domain Structure is not the variable REST: /meta , /research , /research/stream UI stays boring Spring AI side: explicit Java owns the loop Spring AI 2.0 does not give you a dedicated multi-agent runtime. That is fine. I treated it as Anthropic's "building effective agents" advice in plain Spring: role ports, ChatClient implementations, hand-written sequence. The interesting bit lives in ResearchOrchestrator — local variables and a while (true) : Critique critique = Critique.none(); String draft = null; int revisions = 0; while (true) { draft = writer.write(topic, findings, critique); critique = critic.critique(draft); if (critique.passes(passThreshold) || revisions >= maxRevisions) { break; } revisions++; } State is stack locals: plan , findings , draft , critique . Step timing goes through a small StepTrace that also feeds the SSE consumer. Structured output uses ChatClient.call().entity(...) , the same pattern I trust for Job Fit-style records. This style is easy to debug. Breakpoints land where you expect. Exit conditions are ordinary Java. If something weird happens on revision two, you read a method, not a framework graph. LangChain4j side: declare the graph, share a scope LangChain4j 1.18's agentic module flips the emphasis. Roles are @Agent interfaces with outputKey s. Agents and the writer/critic loopBuilder are Spring beans. Per request, the orchestrator builds a sequenceBuilder and attaches a fresh listener: UntypedAgent pipeline = AgenticServices.sequenceBuilder() .subAgents(planner, researcher, reviewLoop) .outputKey("draft") .listener(listener) .build(); Map<String, Object> inputs = new HashMap<>(); inputs.put("topic", topic); inputs.put("depth", depth); inputs.put("critique", Critique.none()); // writer needs critique on first draft ResultWithAgenticScope<String> result = pipeline.invokeWithAgenticScope(inputs); A planner interface looks like this — return type is the contract, outputKey is how scope remembers it: public interface PlannerAgent { @UserMessage(""" Topic: {{topic}} Depth: {{depth}} Produce {{depth}} research questions. """) @Agent(description = "Plans research questions for a topic", outputKey = "plan") ResearchPlan plan(@V("topic") String topic, @V("depth") int depth); } The review loop is declarative too: maxIterations = maxRevisions + 1 , exit when scope critique.passes(threshold) . State lives in AgenticScope ( plan , findingsDoc , draft , critique ), and a ReportAssembler maps scope back to the shared report type. This style is faster once the graph is right. It is also easier to misconfigure: wrong outputKey , missing seed state, listener timing keyed by the wrong agent id. The lab's tests catch those. The UI makes them obvious when you expand step input/output. What the UI is for I did not want a README comparison matrix as the only deliverable. The static UI on port 8090 runs either engine or both, draws the pipeline, streams agent steps side by side, and shows elapsed time per role. That matters more than people admit. Wall-clock totals jump around with OpenRouter and :online search. What stays useful is watching planner vs researcher vs revision count on the same topic. When LangChain4j finished a run faster in my machine, I treated it as a data point for that night, not a benchmark claim. Same models, different orchestration overhead and request shape — enough to learn from, not enough to publish as a winner. Where I would pick each I would rather be specific than pretend the choice is fashion: Spring AI orchestration when the team already thinks in services and ports, when you want the revision policy in a PR reviewable if , and when step tracing should look like ordinary instrumentation. Great default if Spring AI is already your chat boundary. LangChain4j agentic when the graph is the product: sequences, loops, shared scope keys, reusable agent beans. Great when you want the orchestration vocabulary in the framework instead of reinventing it. Both behind one contract when you are still deciding. That is what this lab is. Same prompts, same report JSON, engine toggle. The provider layer stayed OpenRouter on purpose, the same habit we use in production hiring features: route models without rewriting application code. Chat roles use openai/gpt-4o-mini ; the researcher uses openai/gpt-4o-mini:online for web grounding. Swap the env vars and both engines move together. Rough edges I hit Shared prompts must actually ship. After a Docker rebuild I once forgot to copy shared-prompts/ into the image. Bundles started, chat returned empty failures that looked like model issues. They were classpath issues. Seed the critic state. Writer prompts expect a critique object even on draft one. Spring AI seeds Critique.none() in locals. LangChain4j must put the same seed into scope inputs or the first write breaks. Do not confuse :online with "smarter model." It is OpenRouter's web-search variant (legacy shortcut; they now push server tools). Planner and critic stay on the plain chat model on purpose. Timing is not identity. A faster total does not mean better findings. Compare step outputs in the timeline before you trust a stopwatch. Takeaways Force fairness first: identical prompts, models, domain types, and HTTP contract. Put the experiment in the orchestrator: imperative loop vs declarative sequence/loop builders. Expose steps over SSE early. Humans compare pipelines better than logs do. Treat pass thresholds and max revisions as product config, not magic numbers buried in prompts. Prefer a lab you can re-run over a one-off blog benchmark. Code Lab repo: Java 26, Spring Boot 4.1, Spring AI 2.0, LangChain4j 1.18.1. cd spri...