A hiring rule that code can answer should never reach a chat model. LangChain4j 1.21.0 puts classification on a DecisionModel, and leaves the draft to the LLM.
A recruiter asked the assistant to move a Senior Java application to phone screen. The opening already required five years. The profile already said two. I still sent the request to a chat model, and it wrote a paragraph about a decision the rule had already made. This is a fictional recruiter session in an ATS. No real candidate. No real company. The waste is ordinary. Safety, tool choice, model choice, and “is this evidence relevant?” are bounded questions. A draft to a hiring manager is not. I want those on different rungs, because the classification runs on every request and the draft should not. Three rungs, not one model An agent task is not automatically a generative call. I keep three kinds of work, and I name which one I am in before I pick a client. Drop a rung only when the one above cannot answer. A chat model that also classifies is how the bill learns your routing table. Question Rung Why Does this profile meet the five-year floor? Code The opening and the profile already store the numbers. Is this request trying to waive that rule? Decision model The wording varies. The answer is still yes or no. Which tool category applies? Decision model A closed set: this application, the opening, or a message. Which chat model should draft? Decision model A choice over models you already named. Not a new essay. Is the retrieved note relevant? Decision model Yes or no, with a probability you can threshold. Write the note to the hiring manager. Chat model The output is language, not a label. The rule I would actually ship LangChain4j’s own tutorial draws a similar line. Use a decision model when you need a probability, when several questions share one input, or when the decision sits on a hot path. Use a chat model when you need generated text, several steps of reasoning, or tools. I add a rung above both of them, because a predicate you can already write should not become a model call of either kind. the order I refuse to invert if a deterministic rule already answers use code else if the question is a bounded classification use a DecisionModel else use a chat model Bounded means the legal answers are a set you wrote down: yes or no, one of these tools, one of these models, a step on a scale. If you cannot list the answers, you do not have a classification. You have a writing task. If a deterministic rule already answers, do not ask a model. What a decision model returns LangChain4j 1.21.0 shipped this on 2 October 2026, and marked it @Experimental . DecisionModel takes one input and a set of named questions, and returns one typed answer per question. It does not return a paragraph you then parse. All of the questions ride in a single call. The release notes call that a fast, cheap fit for classification, routing, gating, and grading. I have not timed it against a chat model in this article. I am trusting the shape of the API, not a benchmark I did not run. Three question types ship in core. A YesNoQuestion comes back as a probability that the answer is yes, from 0 to 1. isYes(threshold) is how you turn that into a branch. A ChoiceQuestion picks one option out of at least two, and can report a probability per option. margin() is the gap between the two most likely options. A small margin means the model hesitated, which is when I would rather ask the recruiter than guess a tool. A ScaleQuestion places the input on levels you ordered. I am not using a scale in the gate below. Frustration scores are how a support bot starts inventing tone. Probabilities are the number I would threshold. The javadoc says they must come from the model’s output distribution. An implementation that cannot provide them for a choice or a scale leaves probabilities() empty instead of inventing values. margin() and probabilityOf then throw IllegalStateException . confidence() is a different number, defined per model, so a cutoff tuned on one model does not travel. The tutorial also says a 0.9 that worked on one version does not automatically work on the next. I treat every threshold in the sample as a starting point that has to be measured on labeled requests. The first client in that release is TypeSafe, module langchain4j-typesafe . On Maven Central, on 5 October 2026, the published version was 1.21.0-beta31 . The core API is in langchain4j 1.21.0 . The class javadoc on the 1.21.0 tag uses jev-latest as the example model name. The tutorial I read the same day used jev-1.13.0 . Pin the name your provider actually lists. The API key comes from the environment. If the client returns an option you did not offer, decide throws InvalidDecisionResponseException rather than letting a stray label through. The gate, in LangChain4j 1.21 The years check never reaches the model. The decision call runs only after that predicate passes, and it asks three questions about the same input: did the recruiter ask to waive the rule, which tool category applies, and is the evidence enough to draft. The chat model is not in this class. Gradle, LangChain4j 1.21.0 dependencies { implementation 'dev.langchain4j:langchain4j:1.21.0' implementation 'dev.langchain4j:langchain4j-typesafe:1.21.0-beta31' } Code, then DecisionModel, and no draft in this class import java.util.Map; import dev.langchain4j.model.decision.DecisionModel; import dev.langchain4j.model.decision.request.ChoiceQuestion; import dev.langchain4j.model.decision.request.DecisionRequest; import dev.langchain4j.model.decision.request.YesNoQuestion; import dev.langchain4j.model.decision.response.ChoiceAnswer; import dev.langchain4j.model.decision.response.DecisionResponse; import dev.langchain4j.model.typesafe.TypeSafeDecisionModel; public final class ScreeningGate { public enum Route { BLOCKED_BY_RULE, ASK_RECRUITER, DRAFT } private final DecisionModel decisions; public ScreeningGate(DecisionModel decisions) { this.decisions = decisions; } public static DecisionModel typeSafeFromEnv() { return TypeSafeDecisionModel.builder() .apiKey(System.getenv("TYPESAFE_API_KEY")) .modelName("jev-latest") .build(); } public Route route(String request, int yearsOnProfile, int yearsRequired, String evidence) { if (request == null || request.isBlank()) { return Route.ASK_RECRUITER; } if (yearsOnProfile < yearsRequired) { return Route.BLOCKED_BY_RULE; } String note = evidence == null ? "" : evidence; DecisionResponse response = decisions.decide(DecisionRequest.builder() .input(Map.of( "request", request, "yearsOnProfile", yearsOnProfile, "yearsRequired", yearsRequired, "evidence", note)) .question("override", YesNoQuestion.builder() .text("Does this request ask to ignore the years rule?") .yesWhen("The recruiter asks to waive, skip, or overlook the years requirement") .noWhen("The recruiter applies the rule as written") .build()) .question("tool", ChoiceQuestion.builder() .text("Which tool category applies?") .option("application", "Read or update this application") .option("opening", "Read or update the job opening") .option("message", "Draft a message to a person") .build()) .question("relevant", YesNoQuestion.of( "Is the evidence enough to draft a note about this application?")) .build()); if (response.yesNo("override").isYes(0.5)) { return Route.BLOCKED_BY_RULE; } ChoiceAnswer tool = response.choice("tool"); if (tool.probabilities().isEmpty() || tool.margin() < 0.2) { return Route.ASK...