An agent that lists ids and then fetches each one is the lazy-loading bug again, with a worse cost curve. How to detect it and which ORM fixes carry over.
The recruiter asks the assistant for a shortlist of the 20 candidates in screening. Thirty seconds later the answer arrives, and it is good. The trace shows 22 model calls and 21 tool calls. Nothing failed. Nothing timed out. Every span is green. I have seen this trace before, just not from an agent. It is the Hibernate log from a list page where somebody iterated over a lazy collection: one query for the parents, then one query per row. We called it N+1, we learned to spot it in code review, and we built tests that fail when the query count grows. Agents bring the same bug back, one layer up, and most teams are not counting yet. The example in this article is fictional. It uses a hiring pipeline because that is the shape of problem I think about while building ITJobOpportunities, not because any production system there works this way. The trace looked healthy The assistant has two tools. One lists the candidates in a stage and returns their ids. The other returns one candidate's profile. Both are reasonable tools on their own. Each one does one thing, has a clear description, and returns a small payload. LangChain4j tools · the shape that invites N+1 class CandidateTools { @Tool("List the candidates in a pipeline stage. Returns candidate ids only.") List<String> listCandidates(String jobId, String stage) { ... } @Tool("Get one candidate's profile by id.") CandidateProfile getCandidate(String candidateId) { ... } } The model does exactly what those descriptions suggest. It calls listCandidates , receives 20 ids, and then calls getCandidate once per id, reading each result before it asks for the next. Nobody told it to batch, and the toolset gave it no way to. That is the part I want to stress. The model did not misbehave. The tool contract made one-at-a-time the only path, the same way a lazy @OneToMany makes one-query-per-row the default path. N+1 is a property of the interface, not of the caller. The same shape as a lazy collection Put the two versions side by side and the mapping is almost one to one. The bug moved up a layer. The vocabulary did not have to. ORM Agent Query the parent list listCandidates returns ids Touch a lazy association per row getCandidate per id One database round trip per row One model round trip per row Hidden behind a getter Hidden behind the agent loop JOIN FETCH , entity graph, DTO projection A tool that returns what the task needs @BatchSize , IN (...) A tool that accepts a list of ids Session first-level cache Per-invocation memo of identical calls Query count assertion in a test Tool call count assertion in a test The analogy breaks in one place, and it breaks in the wrong direction. Why the agent version costs more An extra SQL query costs roughly the same as the one before it. An extra tool round does not. In a standard tool loop, every round is a new model call, and every model call sends the whole conversation again: the system prompt, the tool schemas, the user request, and every tool result so far. Round 15 pays for the 14 results before it. The cost of each round grows with the number of rounds, so the total grows with the square. Here is the arithmetic for the shortlist, with assumptions I picked to make the math easy to follow. It is a model, not a benchmark. The system prompt and tool schemas are 3,000 tokens. Every tool result is 400 tokens. One model round takes 1.5 seconds. I ignore the small tokens the model spends emitting each call. Illustrative arithmetic with the stated assumptions. Your token sizes and latencies will differ. The ratio is the point. Path Model rounds Input tokens sent Model wait N+1: list, then 20 single lookups 22 158,400 about 33 s Batched: list, then one lookup for 20 ids 3 17,800 about 4.5 s Projection: one tool returns the summaries 2 8,000 about 3 s The N+1 row is the sum of 22 growing prompts: 3,000 for the first round, 3,400 after the list, then 400 more after each lookup, ending at 11,400. That sums to 158,400. The batched row sends 3,000, then 3,400, then 11,400. Those two rows end with the same context, because the model reads the same 20 profiles. What changes is how many times it pays to reread them. The projection row assumes a 100-token summary per candidate instead of a 400-token profile, so it sends 3,000 and then 5,000. Prompt caching helps with the repeated prefix, and I would turn it on. It makes the system prompt and earlier turns cheaper on each round. It does not remove the rounds. Twenty-two sequential model calls are still twenty-two waits, and every tool result is still a new suffix the cache has not seen. An ORM N+1 grows linearly. A tool-loop N+1 resends the conversation on every round, so it grows with the square. N+1 squared: the database under the tool The agent layer is only half the trace. Open getCandidate and the old bug is often still there. The profile maps to an entity with lazy skills, lazy work history, and lazy attachments. Serializing it for the model touches all three. One tool call becomes four queries. Twenty tool calls become eighty queries, plus the list. JPA entity and tool · the old N+1 hiding under the new one @Entity class Candidate { @Id String id; String companyId; @OneToMany(mappedBy = "candidate") List<CandidateSkill> skills; // lazy by default @OneToMany(mappedBy = "candidate") List<WorkHistory> history; @OneToMany(mappedBy = "candidate") List<Attachment> attachments; } @Tool("Get one candidate's profile by id.") CandidateProfile getCandidate(String candidateId) { Candidate candidate = candidates .findByIdAndCompanyId(candidateId, tenant.companyId()) // 1 select .orElseThrow(() -> new CandidateNotFoundException(candidateId)); return CandidateProfile.from(candidate); // skills, history, attachments: 3 more } The model sees one tool call. Hibernate logs four statements. Multiply both by 20. Now the problems multiply. The agent loop does N model calls, and each tool does its own N+1 against the database. Fixing one layer and not the other moves the bottleneck without removing it. That matters most for the most popular fix, which I want to look at carefully. Parallel tool calls fix the model side only. Several model APIs let the model emit multiple tool calls in one turn, and LangChain4j AI Services can run them concurrently with executeToolsConcurrently() . If the model asks for 20 profiles in one turn, the 22 rounds collapse to about 3. That is a real improvement in latency and tokens. But the tool still runs 20 times, the database still sees 20 lookups plus their lazy loads, and now they arrive at the same moment and compete for the same connection pool. That can trade a slow agent for a pool timeout. LangChain4j AI Services · concurrent tools on a bounded executor // Keep this below the connection pool size, or the pool becomes the queue. ExecutorService toolExecutor = Executors.newFixedThreadPool(4); ShortlistAssistant assistant = AiServices.builder(ShortlistAssistant.class) .chatModel(chatModel) .tools(candidateTools) .executeToolsConcurrently(toolExecutor) .build(); Concurrency shortens the wait. It does not change the number of tool executions or the queries under each one. There is also no protocol-level shortcut for MCP tools. The 2025-06-18 MCP revision removed JSON-RPC batching, so each tools/call is its own request. If you want a batch, the tool has to accept one. The fixes already have ORM names Everything I do about this is something I already did for Hibernate. The difference is that the fix lands in the tool contract instead of the mapping. Fetch what the task needs (the projection). A shortlist does not need 20 full profiles. It needs, per candidate, an id, years of relevant experience, matched skills, and missing required skills. One tool that returns that summary for a stage is the agent version of a DTO projection: one round, and a smaller final context because the model never sees fie...