|
This version is still in development and is not considered stable yet. For the latest stable version, please use Spring AI 2.0.1! |
Qxotic jinfer Chat
jinfer ("JVM inference") is a pure-JVM inference engine from the Qxotic AI project. It runs GGUF models inside your application’s JVM. It does not use a separate inference server, Docker container, Python runtime, ONNX runtime, or HTTP calls. Models are downloaded from Hugging Face the first time they are used and cached locally. After that, inference runs entirely on your machine.
Spring AI applications use jinfer through the JinferChatModel, which implements Spring AI’s ChatModel and StreamingChatModel interfaces.
The jinfer Spring AI integration is an external module maintained by the Qxotic AI project, not part of the Spring AI repository.
Its artifacts are published to Maven Central under the com.qxotic group.
See the jinfer Spring AI README for the latest details.
|
Prerequisites
-
Java 25 or later. jinfer uses the Foreign Function & Memory API and the incubating Vector API.
-
JVM flags. Every JVM that runs jinfer needs the following options:
--add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -
Spring Boot 4.1 and Spring AI 2.0. jinfer
0.3.1is built and tested against Spring Boot4.1.1and Spring AI2.0.1.
No API key or external service is required.
Supported Model Families
jinfer loads GGUF checkpoints. Each model architecture is a separate artifact, and jinfer-models-all bundles all of them.
These artifacts contain the architecture code only, not model weights.
Family |
Capabilities |
Artifact |
Google Gemma 4 |
chat, vision (images and video), audio, speculative decoding (MTP) |
|
Liquid AI LFM 2.5 |
chat, vision, embeddings, reranking |
|
Alibaba Qwen 3.5+ |
chat, vision, speculative decoding (MTP) |
|
Alibaba Qwen 3 |
embeddings, reranking |
|
OpenAI gpt-oss |
chat |
|
Meta Llama 3+, IBM Granite 4.1+, Mistral Ministral 3, Hugging Face SmolLM3, OpenBMB MiniCPM5 |
chat |
|
NVIDIA Nemotron-H |
chat |
|
Poolside Laguna XS 2.1 |
chat |
|
JetBrains Mellum 2 |
chat |
|
inclusionAI Ling 3 |
chat |
|
The supported quantizations are Q4_0, Q4_1, Q5_0, Q5_1, Q4_K, Q5_K, Q6_K, Q8_0, MXFP4, NVFP4, Q1_0, TQ1_0 and TQ2_0, plus the dense F32, F16 and BF16 formats.
jinfer recommends Q8_0 as a balance between quality and speed.
Model References
A model is configured either as a local .gguf path or as a model reference:
[provider.com/]owner/repository[@revision][/path][:quant]
For example:
-
LiquidAI/LFM2.5-1.2B-Instruct-GGUF:Q8_0selects theQ8_0file from a Hugging Face repository. -
unsloth/gemma-4-E2B-it-GGUF/mmproj-F32.ggufselects one specific file in a repository. -
modelscope.cn/Qwen/Qwen3-0.6B-GGUF:Q8_0selects a file from another provider.
A remote reference is resolved, and downloaded if it is not cached yet, when the application context starts.
A typo in a model name therefore fails at startup, not on the first request.
Downloaded models are cached in ~/Library/Caches/jinfer (macOS), ~/.cache/jinfer (Linux) or %LOCALAPPDATA%\jinfer (Windows).
Environment variable |
Description |
|
Overrides the model cache directory. |
|
Only uses models that are already cached and never downloads. |
Auto-configuration
The jinfer Spring Boot starter provides auto-configuration for the chat, embedding, reranking, text-to-speech and transcription models. Import the Spring AI and jinfer BOMs, then add the starter and at least one model family:
-
Maven
-
Gradle
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.qxotic</groupId>
<artifactId>jinfer-bom</artifactId>
<version>0.3.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.qxotic</groupId>
<artifactId>jinfer-spring-ai-spring-boot-starter</artifactId>
</dependency>
<!-- or a single family, e.g. jinfer-lfm2, jinfer-qwen35, jinfer-gemma4 -->
<dependency>
<groupId>com.qxotic</groupId>
<artifactId>jinfer-models-all</artifactId>
</dependency>
</dependencies>
dependencies {
implementation platform('org.springframework.ai:spring-ai-bom:2.0.1')
implementation platform('com.qxotic:jinfer-bom:0.3.1')
implementation 'com.qxotic:jinfer-spring-ai-spring-boot-starter'
implementation 'com.qxotic:jinfer-models-all'
}
The starter also brings in spring-ai-client-chat, spring-ai-rag, and the auto-configuration for ChatClient.Builder, tool calling and ChatMemory.
| For a Spring Boot project generated from start.spring.io, select Java 25 and do not add a Spring AI model starter. jinfer is not listed there, so you add the dependencies above by hand. |
Optional Matrix-Multiplication Backends
Two optional runtime artifacts speed up inference:
-
jam-nativeprovides hand-tuned native SIMD kernels. -
jam-vectorprovides a pure-Java implementation based on the Vector API.
<dependency>
<groupId>com.qxotic</groupId>
<artifactId>jam-native</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.qxotic</groupId>
<artifactId>jam-vector</artifactId>
<scope>runtime</scope>
</dependency>
When both are present, jinfer uses the native backend and falls back to the Vector API one. Without either, it uses its built-in kernels.
JVM Options
Pass the jinfer JVM flags to spring-boot:run.
Also disable the optimized launch, which limits the JIT compiler and slows inference down noticeably:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<optimizedLaunch>false</optimizedLaunch>
<jvmArguments>--add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED</jvmArguments>
</configuration>
</plugin>
Pass the same flags to java -jar, to the maven-surefire-plugin <argLine> for tests, and to your IDE run configuration.
Chat Properties
|
The chat auto-configuration is enabled in either of two cases:
To disable it, set |
The prefix spring.ai.jinfer.chat is the property prefix for the jinfer chat model.
Property |
Description |
Default |
spring.ai.model.chat |
Selects the chat provider. Set it to |
- |
spring.ai.jinfer.chat.model |
Required. A local GGUF path or a model reference, for example |
- |
spring.ai.jinfer.chat.companions.<capability> |
An auxiliary file for a capability, given as a path or a model reference. Use |
- |
spring.ai.jinfer.chat.context-capacity |
The upper bound of the conversation context, in tokens. |
|
spring.ai.jinfer.chat.retained-sessions |
The number of finished conversation states kept in memory. A follow-up request that extends one of them reuses its state instead of processing the whole history again. |
|
spring.ai.jinfer.chat.prompt-cache |
The path of a cached-prompt file ( |
- |
spring.ai.jinfer.chat.temperature |
The sampling temperature. |
The model’s recommendation |
spring.ai.jinfer.chat.top-p |
The nucleus sampling probability mass, in |
The model’s recommendation |
spring.ai.jinfer.chat.top-k |
The top-k cutoff. |
The model’s recommendation |
spring.ai.jinfer.chat.min-p |
The minimum token probability, relative to the most likely token, in |
The model’s recommendation, otherwise |
spring.ai.jinfer.chat.max-tokens |
The maximum number of completion tokens. When unset, only the context limits the reply. |
- |
spring.ai.jinfer.chat.seed |
The sampling seed. Set it to make sampling reproducible. |
A new random seed per request |
spring.ai.jinfer.chat.thinking |
Turns the model’s reasoning on or off. Templates without reasoning ignore it. Models that always reason reject |
|
spring.ai.jinfer.chat.max-reasoning-tokens |
The maximum number of tokens spent on reasoning. |
The model family’s policy |
spring.ai.jinfer.chat.reasoning-cutoff-message |
The text inserted when the reasoning limit is reached, phrased as the model’s own words. |
A paragraph break |
spring.ai.jinfer.chat.timeout |
The wall-clock time limit for one generation, for example |
- |
spring.ai.jinfer.chat.speculation-depth |
The number of draft tokens per verification step for speculative decoding. |
The engine default |
All sampling properties prefixed with spring.ai.jinfer.chat can be overridden at runtime by adding request-specific Runtime Options to the Prompt call.
|
A minimal configuration looks like this:
spring:
ai:
model:
chat: jinfer
jinfer:
chat:
model: LiquidAI/LFM2.5-1.2B-Instruct-GGUF:Q8_0
context-capacity: 8192
max-tokens: 512
Spring creates the JinferChatModel bean and closes it at application shutdown, which releases the model weights.
Runtime Options
The JinferChatOptions class holds the generation options for a request.
On startup, the options built from the spring.ai.jinfer.chat properties become the defaults.
Options passed with a request override them.
Option |
Description |
|
The standard Spring AI |
|
The standard Spring AI tool-calling options. |
|
The sampling seed. |
|
The min-p cutoff. |
|
Turns reasoning on or off. |
|
The maximum number of reasoning tokens. |
|
The text inserted when the reasoning limit is reached. |
|
The generation time limit. |
|
A JSON schema the reply must conform to. jinfer enforces it while sampling tokens. See Structured Output. |
|
A raw GBNF grammar the reply must conform to. It cannot be combined with |
ChatResponse response = chatModel.call(
new Prompt("Generate the names of 5 famous pirates.",
JinferChatOptions.builder()
.temperature(0.4)
.maxTokens(256)
.seed(42L)
.build()));
Some options are rejected instead of being silently ignored:
-
frequencyPenaltyandpresencePenaltyare not supported. -
A per-request
modelthat differs from the loaded model fails. EachJinferChatModelinstance is bound to one GGUF file. -
Out-of-range sampling values fail with a message that names the option.
Tool Calling
JinferChatModel supports tool calling, both blocking and streaming.
The model requests tool calls, and Spring AI’s ToolCallingAdvisor runs them and sends the results back.
jinfer is verified against Spring AI’s reusable tool-calling advisor test suite.
class WeatherTools {
@Tool(description = "Get the current weather for a city")
String weather(@ToolParam(description = "City name, for example Paris") String city) {
return "18C, sunny in " + city;
}
}
String answer = ChatClient.create(chatModel)
.prompt("What's the weather in Paris?")
.tools(new WeatherTools())
.call()
.content();
jinfer also works with the Tool Search Tool advisor.
For that, configure the jinfer embedding model behind a VectorStore-based tool index.
Small models vary a lot in how reliably they call tools.
In multi-step tool loops, LiquidAI/LFM2.5-8B-A1B-GGUF:Q8_0 (a mixture-of-experts model with about 1B active parameters) performs well.
A temperature of 0.0 makes the loop more repeatable.
|
For most applications, use ChatClient with the auto-registered ToolCallingAdvisor, as described in Tool Calling.
To drive the loop yourself, see ChatModel Tool Calling.
Structured Output
JinferChatOptions implements StructuredOutputChatOptions, so jinfer supports native structured output.
jinfer compiles the JSON schema into a grammar and masks every token that would break it during sampling.
As a result, the output always parses and always matches the schema, unless the token limit or a cancellation cuts the reply short.
record Incident(String severity, String summary, boolean pageOnCall) {}
Incident incident = ChatClient.create(chatModel)
.prompt("""
Extract this incident as a JSON object with severity (low, medium or high),
summary (one short sentence) and pageOnCall (a boolean).
Checkout has returned HTTP 503 for ten minutes. Page the on-call engineer.
""")
.call()
.entity(Incident.class, ChatClient.EntityParamSpec::useProviderStructuredOutput);
jinfer does not repeat the schema in the prompt.
With useProviderStructuredOutput, describe the task and the expected fields yourself.
Without useProviderStructuredOutput, Spring AI’s default BeanOutputConverter adds format instructions to the prompt instead, and the output is not grammar-constrained.
|
To constrain the reply to an arbitrary shape instead of a JSON schema, pass a GBNF grammar:
String grammar = """
root ::= "yes" | "no"
""";
String answer = ChatClient.create(chatModel)
.prompt("Is Paris the capital of France? Answer yes or no.")
.options(JinferChatOptions.builder().grammar(grammar))
.call()
.content();
Reasoning (Thinking)
For models that reason before answering, such as Qwen 3.5, gpt-oss and Nemotron-H, jinfer keeps the reasoning separate from the answer:
-
In a blocking call, the reasoning text is stored in the
AssistantMessagemetadata under thethinkingkey. When the message is sent back as conversation history, the reasoning is included again. -
In a stream, reasoning chunks are marked with the
isThoughtmetadata flag, so you can display them separately or drop them. -
The native usage reports how many completion tokens were spent on reasoning. See Usage and Response Metadata.
ChatResponse response = chatModel.call(new Prompt("How many r's are in strawberry?",
JinferChatOptions.builder().maxReasoningTokens(512).build()));
AssistantMessage message = response.getResult().getOutput();
String reasoning = (String) message.getMetadata().get("thinking");
String answer = message.getText();
Use thinking(false) to turn reasoning off for templates that support it, and maxReasoningTokens to limit it for models that always reason.
ChatClient.stream().content() returns the text of every chunk, including reasoning chunks.
With a reasoning model, stream chatResponse() instead and skip the chunks marked isThought, or turn reasoning off.
|
Flux<String> answerOnly = chatClient.prompt("How many r's are in strawberry?")
.stream()
.chatResponse()
.filter(r -> r.getResult() != null
&& !Boolean.TRUE.equals(r.getResult().getOutput().getMetadata().get("isThought")))
.map(r -> r.getResult().getOutput().getText())
.filter(StringUtils::hasLength);
Multimodal
jinfer accepts images, audio and video through Spring AI’s Media type, when the loaded model supports that input type.
Following the llama.cpp mmproj convention, a multimodal model needs its projector configured as the media companion:
spring:
ai:
jinfer:
chat:
model: LiquidAI/LFM2.5-VL-3B-GGUF:Q8_0
companions:
media: LiquidAI/LFM2.5-VL-3B-GGUF/mmproj-LFM2.5-VL-3B-Q8_0.gguf
String description = ChatClient.create(chatModel)
.prompt()
.user(u -> u.text("Describe this image in one sentence.")
.media(MimeTypeUtils.IMAGE_PNG, new ClassPathResource("/multimodal.test.png")))
.call()
.content();
Media is decoded locally and turned into model embeddings.
jinfer never fetches media from the network during inference.
Images are decoded with the platform image APIs.
Audio is decoded to 16 kHz mono PCM with javax.sound, which falls back to ffmpeg (on the PATH) for formats it cannot read, such as MP3.
Video frames are sampled through ffmpeg. To change how frames are selected, declare a VideoSampler bean; the default samples frames uniformly.
Gemma 4 supports image, audio and video input. LFM 2.5-VL and Qwen 3.5 support image input.
Prompt Caching
jinfer reuses previously computed model state (the KV cache) in three ways:
-
Shared prefixes.
withCachedPrompt(messages, tools)returns a view of the model with a fixed prefix, such as a long system prompt, tool definitions or few-shot examples. The prefix is processed once, and every request through the view starts from that saved state. -
Conversation reuse.
spring.ai.jinfer.chat.retained-sessionskeeps finished conversations in memory, so a follow-up turn only processes the new messages. -
Persistent caches.
saveCachedPrompts(path)writes the defined prefixes to a.jkvfile. Another process can load that file withspring.ai.jinfer.chat.prompt-cache(orpromptCache(path)on the builder) and skip processing the prefix entirely.
List<Message> instructions = List.of(new SystemMessage(
"You are a support assistant for AcmeCloud. Answer in one sentence."));
JinferChatModel support = jinferChatModel.withCachedPrompt(instructions, List.of());
support.call("How do I reset my password?"); // the instructions are already in the cache
jinferChatModel.saveCachedPrompts(Path.of("personas.jkv"));
Caching never changes the output: a cached request produces exactly the same tokens as an uncached one.
A view shares the lifetime of its base model, so close the base model, not the view.
When the cache is used, the number of reused tokens is reported in Usage.getCacheReadInputTokens().
Speculative Decoding
Models that come with a draft head, such as Gemma 4 and Qwen 3.5, can use it for speculative decoding.
Configure the draft head as the speculation companion:
spring:
ai:
jinfer:
chat:
model: unsloth/gemma-4-E2B-it-GGUF:Q8_0
companions:
speculation: unsloth/gemma-4-E2B-it-GGUF/MTP/mtp-gemma-4-E2B-it-Q8_0.gguf
The speed-up depends on how well the draft head predicts the text, not on the model. jinfer measured Gemma 4 E2B on a 16-core CPU at about 2.0x for lists, 1.7x for code, and 0.9x for prose. Check the gain on your own workload. The native usage reports the drafted and accepted token counts.
Usage and Response Metadata
The ChatResponse metadata contains:
-
Usage.getPromptTokens()andgetCompletionTokens(), counted by the model’s real tokenizer, not estimated. -
Usage.getCacheReadInputTokens(), the number of prompt tokens restored from the cache. It isnullfor requests that did not use the cache. -
Usage.getNativeUsage(), aJinferChatModel.JinferUsagerecord with the prompt and decode timings, the reasoning token count, the cache tier that served the prompt, and the speculative decoding counters. -
The
prompt-eval-durationandeval-durationmetadata entries. -
A finish reason of
stop,length,tool_callsorother.
Observability
JinferChatModel records Spring AI’s standard chat model observations, using the jinfer provider name.
The auto-configuration uses the application’s ObservationRegistry and an optional ChatModelObservationConvention bean.
See Observability.
Concurrency and Resources
A JinferChatModel instance processes requests one at a time: concurrent requests wait in a fair queue.
For parallel processing, load the weights yourself into an arena that your code manages (Models.load(path, arena)), and build the model with model(loaded).
Then create additional pipelines with fork().
Each fork costs only one extra context, not another copy of the weights.
A model that loaded its own weights, which includes the auto-configured bean, cannot be forked.
The model holds its weights in memory, up to retained-sessions conversation states, and a KV block cache of up to 2 GiB.
Size the JVM host accordingly.
close() blocks until in-flight requests have finished, then frees native memory.
Sample Controller
Create a new Spring Boot project with Java 25, add the dependencies described in Auto-configuration, and configure the model in application.yaml as shown in Chat Properties.
@RestController
class ChatController {
private final ChatClient chatClient;
ChatController(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
@GetMapping("/ai/generate")
String generate(@RequestParam(defaultValue = "Tell me a joke") String message) {
return this.chatClient.prompt(message).call().content();
}
@GetMapping(value = "/ai/generateStream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<String> generateStream(@RequestParam(defaultValue = "Tell me a joke") String message) {
return this.chatClient.prompt(message).stream().content();
}
}
Manual Configuration
To use jinfer without Spring Boot auto-configuration, replace the starter with the core integration and the ChatClient API:
-
Maven
-
Gradle
<dependency>
<groupId>com.qxotic</groupId>
<artifactId>jinfer-spring-ai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
dependencies {
implementation 'com.qxotic:jinfer-spring-ai'
implementation 'org.springframework.ai:spring-ai-client-chat'
}
Keep the BOMs and the model family dependencies described in Auto-configuration.
try (var chatModel = JinferChatModel.builder()
.model("LiquidAI/LFM2.5-1.2B-Instruct-GGUF:Q8_0") // or .modelPath(Path.of("model.gguf"))
.contextCapacity(8192)
.options(JinferChatOptions.builder().maxTokens(512).build())
.build()) {
ChatResponse response = chatModel.call(new Prompt("What is the capital of France?"));
// Or with streaming responses
Flux<ChatResponse> stream = chatModel.stream(new Prompt("Write a haiku about rivers."));
}
When you build the model yourself, your application owns it: use try-with-resources or close it at shutdown.
Configure companions with companion(capability, ref) or companionPath(capability, path).