Large language models are great at reasoning over text, but on their own they can't check today's weather, query your database, or hit an internal API. Tool calling (a.k.a. function calling) is what bridges that gap: you expose plain methods to the model, and it decides when to call them.
If you live in the Java world, you might assume this requires a heavyweight stack. It doesn't. Solon AI — the AI module of the Solon framework — lets you turn an ordinary Java method into an LLM tool with a single annotation. This post walks through a complete, runnable example.
Solon is an independent, full-scenario Java application framework. It is not Spring and has its own IoC/AOP, plugins, and annotations. Nothing here depends on Spring.
What we're building
A tiny "assistant" that can answer questions like "What's the weather in Hangzhou, and what time is it there?" The model will call two Java methods we provide — a weather lookup and a clock — and weave the results into a natural-language answer.
1. Add the dependency
Solon AI ships an aggregate artifact (solon-ai) that bundles the core plus the built-in dialects. The openai dialect (the default) is compatible with a wide range of providers — DeepSeek, Qwen, GLM, Kimi, GPT, and others.
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-ai</artifactId>
<version>4.0.3</version>
</dependency>
2. Define your tools
A "tool" is just a method annotated with @ToolMapping. The description tells the model what the tool does; @Param describes each argument. Solon AI generates the JSON schema and handles the call dispatch for you.
import org.noear.solon.ai.annotation.ToolMapping;
import org.noear.solon.ai.annotation.Param;
import java.time.LocalTime;
public class AssistantTools {
@ToolMapping(description = "Get the current weather for a city")
public String getWeather(@Param(description = "City name") String city) {
// In a real app this would call a weather API.
return city + ": sunny, 14°C";
}
@ToolMapping(description = "Get the current local time")
public String getTime(@Param(description = "City name") String city) {
return city + " local time: " + LocalTime.now();
}
}
That's it — no interfaces to implement, no schema to hand-write. The method's name, parameters, and descriptions become the tool contract exposed to the model.
3. Build the ChatModel and register the tools
ChatModel.of(...) gives you a fluent builder. Point it at your provider's chat endpoint, set the model, then attach your tools with defaultToolAdd. Passing an object makes Solon AI scan it for @ToolMapping methods automatically.
import org.noear.solon.ai.chat.ChatModel;
import org.noear.solon.ai.chat.ChatResponse;
public class Demo {
public static void main(String[] args) throws Exception {
ChatModel chatModel = ChatModel.of("https://api.deepseek.com/v1/chat/completions")
.apiKey(System.getenv("DEEPSEEK_API_KEY")) // never hard-code keys
.provider("openai") // openai-compatible dialect
.model("deepseek-chat")
.defaultToolAdd(new AssistantTools()) // register both tools
.build();
ChatResponse resp = chatModel
.prompt("What's the weather in Hangzhou, and what time is it there?")
.call();
System.out.println(resp.getMessage().getContent());
}
}
Under the hood, Solon AI runs the full tool-calling loop: it sends your prompt plus the tool definitions, receives the model's request to call getWeather and getTime, invokes your Java methods, feeds the results back, and returns the final composed answer. You just call .call().
A possible output:
The weather in Hangzhou is sunny at 14°C, and the local time there is 09:42.
4. Prefer configuration over code (optional)
Hard-coding endpoints is fine for a demo, but in a real Solon app you'd externalize this to app.yml (Solon's config file — not application.yml):
solon.ai.chat:
assistant:
apiUrl: "https://api.deepseek.com/v1/chat/completions"
apiKey: "${DEEPSEEK_API_KEY}"
provider: "openai"
model: "deepseek-chat"
Then bind it with a config bean:
import org.noear.solon.annotation.Bean;
import org.noear.solon.annotation.Configuration;
import org.noear.solon.annotation.Inject;
import org.noear.solon.ai.chat.ChatConfig;
import org.noear.solon.ai.chat.ChatModel;
@Configuration
public class AiConfig {
@Bean
public ChatModel chatModel(@Inject("${solon.ai.chat.assistant}") ChatConfig config) {
return ChatModel.of(config)
.defaultToolAdd(new AssistantTools())
.build();
}
}
Now ChatModel is a managed component you can @Inject anywhere.
A few useful extras
-
Streaming: swap
.call()for.stream()to get aFlux<ChatResponse>(requiressolon-web-rx). Great for typing-effect UIs. -
Return direct: set
@ToolMapping(returnDirect = true)when a tool's result should be returned verbatim, skipping a second LLM pass. -
Reasoning control: on the builder you can call
.reasoning_effort("high")or.thinking(true)— Solon AI maps these to each provider's native format (OpenAI, Anthropic, Gemini, DashScope, and more), so your code stays provider-agnostic. -
MCP: if your tools live in an external Model Context Protocol server, register an
McpClientProvidervia the samedefaultToolAdd(...)— the model can't tell the difference.
Wrapping up
Tool calling in Java doesn't have to be verbose. With Solon AI you annotate a method, register the object, and call .prompt(...).call() — the framework handles schema generation, the multi-turn tool loop, and cross-provider quirks. From here it's a short hop to RAG pipelines, MCP servers, and multi-agent setups, all in the same lightweight framework.
Links:
- Website: https://solon.noear.org
- GitHub: https://github.com/opensolon/solon
If you're building AI features on the JVM, give it a try — and let me know what you build.
Top comments (0)