|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: 'Parallel voting and adaptive model selection: smarter agentic AI on a budget' |
| 4 | +date: 2026-05-18T00:00:00Z |
| 5 | +tags: ai llm agents langchain4j |
| 6 | +synopsis: 'Learn how LangChain4j 1.15.0`s voting pattern uses parallel multi-critic evaluation and runtime model switching to build smarter, cost-efficient agentic systems with Quarkus.' |
| 7 | +author: mariofusco |
| 8 | +--- |
| 9 | +:imagesdir: /assets/images/posts/agentic |
| 10 | + |
| 11 | +In a https://quarkus.io/blog/agentic-ai-patterns/[previous article] we discussed why no single agentic pattern can cover all use cases, and introduced a generic `Planner` abstraction that allows users to define their own orchestration strategies and combine them with the ones provided by LangChain4j out-of-the-box. For instance, there we demonstrated how a goal-oriented pattern could be extended with a reflection loop to iteratively refine a piece of generated content. |
| 12 | + |
| 13 | +Among other things, that example highlighted that having an agent evaluate its own output and loop until it reaches a quality threshold is a powerful technique. But this fundamental pattern also comes with a limitation: relying on a single evaluator can make the system fragile or less reliable. To overcome this issue, what if, instead of one judge, we could assemble a panel? |
| 14 | + |
| 15 | +== The voting pattern |
| 16 | + |
| 17 | +The https://docs.langchain4j.dev/tutorials/agents/#voting-agentic-pattern[voting pattern], introduced in LangChain4j 1.15.0, addresses this by dispatching the same input to multiple evaluator agents in parallel, collecting their individual assessments, and then aggregating them into a single consolidated result. This is not a simple majority vote on a boolean decision: the aggregation strategy is fully customizable, allowing the system to combine structured results in whatever way makes sense for the task at hand. |
| 18 | + |
| 19 | +To put this into practice, let's build a story evaluation system, also using the `quarkus-langchain4j-agentic` extension version 1.10.0. The pipeline works as follows: a creative writer generates a short story on a given topic, and then a panel of three critics, each specialized in a different aspect, evaluates the story in parallel. Their scores and suggestions are aggregated into a single critique, which is fed back to an editor agent that rewrites the story. This review loop repeats until the aggregated score crosses a quality threshold or a maximum number of iterations is reached. We will implement this example leveraging the fully declarative style made available by the quarkus-langchain4j extension. |
| 20 | + |
| 21 | +=== Defining the agents |
| 22 | + |
| 23 | +First, let's define the basic data types and the individual agents. Each agent is a simple Java interface with a single method having an `@Agent` annotation describing its role and an `@UserMessage` containing its prompt. In particular the `@Agent` annotation's `outputKey` attribute specifies the key under which the agent's output will be stored in the `AgenticScope`, making it available for other agents to use. |
| 24 | + |
| 25 | +All these agents return a common `CritiqueResult` record to represent scores and suggestions, while the final output of the system is a `ScoredStory` that combines the story with its aggregated critique. |
| 26 | + |
| 27 | +[source,java] |
| 28 | +---- |
| 29 | +public record CritiqueResult(double score, String suggestions) {} |
| 30 | +
|
| 31 | +public record ScoredStory(String story, double score, String suggestions) {} |
| 32 | +---- |
| 33 | + |
| 34 | +The creative writer generates a short story from a topic: |
| 35 | + |
| 36 | +[source,java] |
| 37 | +---- |
| 38 | +public interface CreativeWriter { |
| 39 | +
|
| 40 | + @UserMessage(""" |
| 41 | + You are a creative writer. |
| 42 | + Generate a short story of no more than 3 sentences around the given topic. |
| 43 | + Return only the story and nothing else. |
| 44 | + The topic is: {{topic}} |
| 45 | + """) |
| 46 | + @Agent(value = "Generate a short story based on the given topic", |
| 47 | + outputKey = "story") |
| 48 | + String generateStory(String topic); |
| 49 | +} |
| 50 | +---- |
| 51 | + |
| 52 | +The three critics evaluate different aspects of the story: style, originality, and engagement. They all follow the same structure, differing only in their prompt: |
| 53 | + |
| 54 | +[source,java] |
| 55 | +---- |
| 56 | +public interface StyleCritic { |
| 57 | +
|
| 58 | + @UserMessage(""" |
| 59 | + You are a literary style critic. |
| 60 | + Evaluate the writing style of the following story. |
| 61 | + Consider prose quality, word choice, and narrative flow. |
| 62 | + Return a JSON object with two fields: |
| 63 | + - "score": a numeric value from 0.0 to 10.0 |
| 64 | + - "suggestions": one or two very short suggestions to improve style |
| 65 | + The story is: "{{story}}" |
| 66 | + """) |
| 67 | + @Agent(value = "Evaluate the writing style of a story", |
| 68 | + outputKey = "styleCritique") |
| 69 | + CritiqueResult critique(String story); |
| 70 | +} |
| 71 | +---- |
| 72 | + |
| 73 | +The `OriginalityCritic` and `EngagementCritic` are analogous, each focusing on their respective dimension and producing their own `CritiqueResult` with `outputKey` values of `"originalityCritique"` and `"engagementCritique"`. |
| 74 | + |
| 75 | +Finally, the story editor rewrites the story based on the aggregated critique: |
| 76 | + |
| 77 | +[source,java] |
| 78 | +---- |
| 79 | +public interface StoryEditor { |
| 80 | +
|
| 81 | + @UserMessage(""" |
| 82 | + You are a professional story editor. |
| 83 | + Rewrite and improve the following story based on the provided critique. |
| 84 | + Keep the story to no more than 3 sentences. |
| 85 | + Return only the improved story and nothing else. |
| 86 | + The story is: "{{story}}" |
| 87 | + The critique is: {{critique}} |
| 88 | + """) |
| 89 | + @Agent(value = "Improve a story based on critique suggestions", |
| 90 | + outputKey = "story") |
| 91 | + String edit(String story, CritiqueResult critique); |
| 92 | +} |
| 93 | +---- |
| 94 | + |
| 95 | +=== Implementing the VotingPlanner |
| 96 | + |
| 97 | +The key component that makes this pattern work is the `VotingPlanner`. It implements the `Planner` interface and orchestrates the parallel execution of all critic subagents, then aggregates their results using a configurable `VotingStrategy`. |
| 98 | + |
| 99 | +[source,java] |
| 100 | +---- |
| 101 | +public class VotingPlanner implements Planner { |
| 102 | +
|
| 103 | + private final VotingStrategy strategy; |
| 104 | + private List<AgentInstance> subagents; |
| 105 | + private int completedCount; |
| 106 | + private final List<Object> votes = new ArrayList<>(); |
| 107 | +
|
| 108 | + public VotingPlanner(VotingStrategy strategy) { |
| 109 | + this.strategy = strategy; |
| 110 | + } |
| 111 | +
|
| 112 | + @Override |
| 113 | + public void init(InitPlanningContext initPlanningContext) { |
| 114 | + this.subagents = initPlanningContext.subagents(); |
| 115 | + } |
| 116 | +
|
| 117 | + @Override |
| 118 | + public Action firstAction(PlanningContext planningContext) { |
| 119 | + if (subagents.isEmpty()) { |
| 120 | + return done(); |
| 121 | + } |
| 122 | + return call(subagents); |
| 123 | + } |
| 124 | +
|
| 125 | + @Override |
| 126 | + public Action nextAction(PlanningContext planningContext) { |
| 127 | + votes.add(planningContext.previousAgentInvocation().output()); |
| 128 | + completedCount++; |
| 129 | +
|
| 130 | + if (completedCount < subagents.size()) { |
| 131 | + return noOp(); |
| 132 | + } |
| 133 | +
|
| 134 | + return done(strategy.aggregate(votes)); |
| 135 | + } |
| 136 | +
|
| 137 | + @Override |
| 138 | + public AgenticSystemTopology topology() { |
| 139 | + return AgenticSystemTopology.PARALLEL; |
| 140 | + } |
| 141 | +} |
| 142 | +---- |
| 143 | + |
| 144 | +The planner's logic is straightforward: on the first action it dispatches all subagents at once in parallel by returning `call(subagents)`. As each critic completes, `nextAction` is called: it collects the result and returns `noOp()` until all critics have finished, at which point it aggregates the votes and returns `done(result)`. |
| 145 | + |
| 146 | +The `VotingStrategy` is a functional interface that takes the collection of all critic outputs and reduces them to a single result: |
| 147 | + |
| 148 | +[source,java] |
| 149 | +---- |
| 150 | +@FunctionalInterface |
| 151 | +public interface VotingStrategy { |
| 152 | + Object aggregate(Collection<Object> votes); |
| 153 | +} |
| 154 | +---- |
| 155 | + |
| 156 | +For our story evaluation use case, the aggregation strategy averages the scores and concatenates the suggestions from all critics: |
| 157 | + |
| 158 | +[source,java] |
| 159 | +---- |
| 160 | +VotingStrategy critiquesAggregator = votes -> { |
| 161 | + Collection<CritiqueResult> critiques = votes.stream() |
| 162 | + .map(v -> (CritiqueResult) v) |
| 163 | + .toList(); |
| 164 | +
|
| 165 | + double averageScore = critiques.stream() |
| 166 | + .mapToDouble(CritiqueResult::score) |
| 167 | + .average() |
| 168 | + .orElse(0.0); |
| 169 | +
|
| 170 | + String allSuggestions = critiques.stream() |
| 171 | + .map(CritiqueResult::suggestions) |
| 172 | + .collect(Collectors.joining("; ")); |
| 173 | +
|
| 174 | + return new CritiqueResult(averageScore, allSuggestions); |
| 175 | +}; |
| 176 | +---- |
| 177 | + |
| 178 | +=== Composing the agentic system |
| 179 | + |
| 180 | +The LangChain4j API makes it easy to wire these agents into a coherent agentic system. First of all, we can configure the three critic agents as subagents of the voting agentic pattern, using the `critiquesAggregator` defined above to combine their results: |
| 181 | + |
| 182 | +[source,java] |
| 183 | +---- |
| 184 | +public interface VotingCritics { |
| 185 | +
|
| 186 | + @PlannerAgent( |
| 187 | + name = "votingCritics", |
| 188 | + outputKey = "critique", |
| 189 | + subAgents = {StyleCritic.class, OriginalityCritic.class, EngagementCritic.class}) |
| 190 | + CritiqueResult critique(String story); |
| 191 | +
|
| 192 | + @PlannerSupplier |
| 193 | + static Planner planner() { |
| 194 | + return new VotingPlanner(critiquesAggregator()); |
| 195 | + } |
| 196 | +} |
| 197 | +---- |
| 198 | + |
| 199 | +Then we can put the resulting `votingCritics` planner together with the story editor into a review loop that iteratively refines the story until it reaches a certain quality threshold: |
| 200 | + |
| 201 | +[source,java] |
| 202 | +---- |
| 203 | +public interface ReviewLoop { |
| 204 | +
|
| 205 | + @LoopAgent( |
| 206 | + name = "reviewLoop", |
| 207 | + outputKey = "story", |
| 208 | + maxIterations = 5, |
| 209 | + subAgents = {VotingCritics.class, StoryEditor.class}) |
| 210 | + String review(String story); |
| 211 | +
|
| 212 | + @ExitCondition |
| 213 | + static boolean shouldExit(CritiqueResult critique) { |
| 214 | + return critique.score() >= 8.5; |
| 215 | + } |
| 216 | +} |
| 217 | +---- |
| 218 | + |
| 219 | +Finally, we can chain the `CreativeWriter` agent, that generates the initial story, with the review loop in a sequence, and define an output method to assemble the final `ScoredStory` result: |
| 220 | + |
| 221 | +[source,java] |
| 222 | +---- |
| 223 | +public interface StoryEvaluator extends MonitoredAgent { |
| 224 | +
|
| 225 | + @SequenceAgent( |
| 226 | + subAgents = {CreativeWriter.class, ReviewLoop.class}) |
| 227 | + ScoredStory evaluate(String topic); |
| 228 | +
|
| 229 | + @Output |
| 230 | + static ScoredStory output(String story, CritiqueResult critique) { |
| 231 | + return new ScoredStory( |
| 232 | + story != null ? story : "", |
| 233 | + critique != null ? critique.score() : 0.0, |
| 234 | + critique != null ? critique.suggestions() : ""); |
| 235 | + } |
| 236 | +} |
| 237 | +---- |
| 238 | + |
| 239 | +The resulting system composes three different agentic patterns, a sequence, a loop, and a custom voting planner, into a single pipeline that can be injected at any point in your Quarkus application. |
| 240 | + |
| 241 | +[source,java] |
| 242 | +---- |
| 243 | +@Inject |
| 244 | +StoryEvaluator storyEvaluator; |
| 245 | +---- |
| 246 | + |
| 247 | +and then can be called with one line: |
| 248 | + |
| 249 | +[source,java] |
| 250 | +---- |
| 251 | +ScoredStory result = storyEvaluator.evaluate("a lonely robot finding friendship"); |
| 252 | +---- |
| 253 | + |
| 254 | +=== Visualizing the system |
| 255 | + |
| 256 | +Making the `StoryEvaluator` interface extend the `MonitoredAgent` interface allows us to visualize both the system topology and the execution history of a sample run, using the new LangChain4j view available in the Quarkus Dev-UI. The LangChain4j monitoring dashboard automatically detects the nested structure of the agentic system, showing how the different patterns are composed together. |
| 257 | + |
| 258 | +Giving a look at the topology of this agentic system clearly shows how the three patterns are nested: at the top, a sequential planner first invokes the `CreativeWriter` and then enters the review loop. Inside the loop, the `VotingPlanner` fans out to the three critics in parallel, collects and aggregates their results, and then the `StoryEditor` rewrites the story. The loop continues until the average score reaches 8.5 or five iterations have been completed. |
| 259 | + |
| 260 | +[.text-center] |
| 261 | +.The topology of the voting-based story evaluation system |
| 262 | +image::voting-topology.png[width=80%, align="center", alt="The topology of the voting-based |
| 263 | +story evaluation system"] |
| 264 | + |
| 265 | +The execution history of a sample run illustrates this flow in action: |
| 266 | + |
| 267 | +[.text-center] |
| 268 | +.A sample execution of the voting-based story evaluation system |
| 269 | +image::voting-execution.png[width=100%, align="center", alt="A sample execution of the |
| 270 | +voting-based story evaluation system"] |
| 271 | + |
| 272 | +The parallel execution of the three critics is clearly visible in the Gantt-like chart: in each iteration of the review loop they run concurrently, and only after all three have completed does the editor receive the aggregated critique and produce an improved version of the story. |
| 273 | + |
| 274 | +== Dynamic chat model selection |
| 275 | + |
| 276 | +The system described so far uses the same chat model for every agent. This is a reasonable default, but it doesn't have to be the only option. In a real-world scenario, you might want to use a cheaper, faster model for routine work and switch to a more capable, and more expensive, one only when it really matters: for instance, when the story is already good enough that the final polish requires a higher level of linguistic sophistication. |
| 277 | + |
| 278 | +The LangChain4j agentic framework supports this scenario through https://docs.langchain4j.dev/tutorials/agents/#dynamic-chat-model-selection[dynamic chat model selection]. The idea is simple: instead of binding an agent to a single fixed model at build time, it is possible to provide the agent with a function of the `AgenticScope` that dynamically selects the model to use for each invocation. This allows the agent to adapt its behavior based on the current state of the system. |
| 279 | + |
| 280 | +=== Applying it to the story editor |
| 281 | + |
| 282 | +In our story evaluation pipeline, the natural candidate for this optimization is the `StoryEditor`. During the first iterations, when the story is still rough and the critique score is low, a fast and inexpensive model like `gpt-4o-mini`, defined as `baseModel` in the code, is more than adequate. But once the critics start giving higher scores, indicating that the story is close to its final form, the editor can switch to a more powerful model like `gpt-5.1`, identified as `enhancedModel`, for the finishing touches. These two models can be defined in the Quarkus `application.properties` file as follows: |
| 283 | + |
| 284 | +[source,properties] |
| 285 | +---- |
| 286 | +# Base model for the story editor, used in the early iterations |
| 287 | +quarkus.langchain4j.baseModel.chat-model.provider=openai |
| 288 | +quarkus.langchain4j.openai.chat-model.baseModel=gpt-4o-mini |
| 289 | +# Enhanced model for the story editor, used in the later iterations when the critique score is high |
| 290 | +quarkus.langchain4j.enhancedModel.chat-model.provider=openai |
| 291 | +quarkus.langchain4j.openai.chat-model.enhancedModel=gpt-5.1 |
| 292 | +---- |
| 293 | + |
| 294 | +and then we can implement the following `DynamicModelSelector` to read the current critique from the `AgenticScope` and select the appropriate model based on the current score: |
| 295 | + |
| 296 | +[source,java] |
| 297 | +---- |
| 298 | +@ApplicationScoped |
| 299 | +@Unremovable |
| 300 | +public static class DynamicModelSelector { |
| 301 | +
|
| 302 | + @Inject |
| 303 | + @ModelName("baseModel") |
| 304 | + ChatModel baseModel; |
| 305 | +
|
| 306 | + @Inject |
| 307 | + @ModelName("enhancedModel") |
| 308 | + ChatModel enhancedModel; |
| 309 | +
|
| 310 | + ChatModel select(CritiqueResult critique) { |
| 311 | + return critique != null && critique.score() > 7.8 ? enhancedModel : baseModel; |
| 312 | + } |
| 313 | +} |
| 314 | +---- |
| 315 | + |
| 316 | +Finally, it is possible to rewrite the `StoryEditor` agent adding a method annotated with `@ChatModelSupplier` that uses this selector to choose at runtime, at each invocation of the agent, the model that best fits the current state of the story: |
| 317 | + |
| 318 | +[source,java] |
| 319 | +---- |
| 320 | +public interface StoryEditor { |
| 321 | +
|
| 322 | + @UserMessage(""" |
| 323 | + You are a professional story editor. |
| 324 | + Rewrite and improve the following story based on the provided critique. |
| 325 | + Keep the story to no more than 3 sentences. |
| 326 | + Return only the improved story and nothing else. |
| 327 | + The story is: "{{story}}" |
| 328 | + The critique is: {{critique}} |
| 329 | + """) |
| 330 | + @Agent(value = "Improve a story based on critique suggestions", |
| 331 | + outputKey = "story") |
| 332 | + String edit(String story, CritiqueResult critique); |
| 333 | +
|
| 334 | + @ChatModelSupplier |
| 335 | + static ChatModel chatModel(CritiqueResult critique) { |
| 336 | + return Arc.container().select(DynamicModelSelector.class).get().select(critique); |
| 337 | + } |
| 338 | +} |
| 339 | +---- |
| 340 | + |
| 341 | +In this configuration, the editor starts with `baseModel` for all iterations where the critique score is 7.8 or below. Once the critics' average score exceeds 7.8, indicating that the story has reached a level of quality where finer editorial judgment matters, the selector switches to `enhancedModel` for the subsequent iterations. |
| 342 | + |
| 343 | +Looking back at the execution log, this transition is clearly visible. During the first two iterations, the story editor is invoked with `gpt-4o-mini`. In the third iteration, after the critics' average score crosses the 7.8 threshold, the editor transparently switches to `gpt-5.1` for the remaining refinements. |
| 344 | + |
| 345 | +This approach provides a practical way to balance cost and quality. The cheaper model handles the bulk of the iterative refinement, and the more expensive one is brought in only for the final passes where its additional capabilities can make a real difference. The decision is driven entirely by the runtime state of the agentic system, with no manual intervention required. |
| 346 | + |
| 347 | +== Conclusion |
| 348 | + |
| 349 | +The voting pattern adds a new dimension to agentic orchestration by introducing collective evaluation. Instead of relying on a single critic that might be biased or inconsistent, multiple specialized agents assess the same content from different perspectives, and their judgments are aggregated into a more robust and balanced evaluation. |
| 350 | + |
| 351 | +Combined with conditional chat model selection, this creates a system that is not only more reliable in its quality assessments but also cost-efficient in its use of language models. The framework dynamically allocates more powerful, and more expensive, models only when the quality of the content justifies the investment, while routine iterations run on cheaper alternatives. |
| 352 | + |
| 353 | +Both these new features are implemented as composable building blocks within the LangChain4j agentic framework. The `VotingPlanner` is a `Planner` like any other, meaning it can be nested inside sequences, loops, or any other pattern. The conditional model selection works transparently at the agent level, requiring no changes to the surrounding orchestration logic. Together, they demonstrate how small, focused additions to the framework can enable sophisticated behaviors without increasing complexity for the user. |
| 354 | + |
| 355 | +The complete source code for this example is available in the https://github.com/langchain4j/langchain4j/tree/main/langchain4j-agentic-patterns/src/test/java/dev/langchain4j/agentic/patterns/voting/critic[langchain4j-agentic-patterns] module. |
| 356 | + |
| 357 | +In the near future we plan to further enrich the set of available https://docs.langchain4j.dev/tutorials/agents/#custom-agentic-patterns[custom agentic patterns] in order to cover even more use cases out-of-the-box, and to provide more examples of how to combine them together in complex agentic systems. Stay tuned! |
0 commit comments