Prompting Techniques with LangChain4j
I am a Software Engineer from Dallas, Texas, USA, developing cyber security softwares.
Search for a command to run...
I am a Software Engineer from Dallas, Texas, USA, developing cyber security softwares.
In this series I am going to explore LangChain4J for building AI applications using Java/SpringBoot.
In this blog post, we'll walk through creating your first LangChain4j application using Spring Boot. We'll build a simple interactive command-line application that demonstrates the basic setup and usage of LangChain4j in a Java environment. What is L...
So far the demo's LLM calls have been single-shot: the chat model answers a question in one step. But many real tasks need more than that — a task may require arithmetic, a lookup in a knowledge base,
In the last post we built a RAG pipeline that answers questions from indexed documents. But the pipeline is only as good as its input: real-world data lives in PDFs, mixed file types, and messy format
In the last few posts we gave our chatbot a memory and the ability to search documents by meaning. Now it's time to combine the two. A large language model knows a lot, but it doesn't know your docume
In previous posts we got LangChain4j up and running and gave our chatbot memory. But chatting only goes so far — what if you want to search through a pile of documents using meaning rather than keywor
Everything so far in this series has relied on a single well-written prompt. But prompts are code: they deserve templates, examples, and reliable output contracts. This post covers the three prompting techniques from our checklist — prompt templates, few-shot learning examples, and output parsers — and how LangChain4j makes each one a first-class, testable concept.
A prompt template is a prompt skeleton with {{placeholders}}. Instead of string-concatenating prompts in handlers all over the codebase, you define the template once and render it with variables.
LangChain4j's PromptTemplate is a pure, offline component — no model involved. It renders a template into a Prompt, which converts to a system or user message:
@Service
public class PromptService {
private static final String SYSTEM_TEMPLATE = """
You are a professional movie critic.
Always write your reviews in a {{tone}} tone.
""";
private static final String USER_TEMPLATE = """
Write a short review for the movie "{{movie}}" ({{year}}).
Include a rating out of 10 in your review.
""";
public List<ChatMessage> buildMovieReviewMessages(String movie, int year, String tone) {
Prompt systemPrompt = PromptTemplate.from(SYSTEM_TEMPLATE).apply(Map.of("tone", tone));
Prompt userPrompt = PromptTemplate.from(USER_TEMPLATE).apply(Map.of("movie", movie, "year", year));
return List.of(systemPrompt.toSystemMessage(), userPrompt.toUserMessage());
}
}
The CLI command /template prints the fully rendered messages without calling any API:
/template Inception
Rendered prompt template for "Inception" (year 2010, enthusiastic tone):
SYSTEM:
You are a professional movie critic.
Always write your reviews in an enthusiastic tone.
USER:
Write a short review for the movie "Inception" (2010).
Include a rating out of 10 in your review.
Because rendering is deterministic, it is fully unit-testable: same template + same variables → same messages. We assert exactly that in PromptServiceTest.
AI Services take templates one step further: @SystemMessage and @UserMessage accept templates whose variables are resolved from method parameters (via @V, or plain parameter names with Spring's -parameters):
@UserMessage("Write a review of {{movie}} from {{year}}")
String review(@V("movie") String movie, @V("year") int year);
Sometimes a short instruction isn't enough — the model benefits from seeing correct input/output pairs. Few-shot prompting embeds a handful of labeled examples into the prompt. Our FewShotAssistant classifies sentiment into POSITIVE / NEGATIVE / NEUTRAL using a mini dataset in the system message:
public interface FewShotAssistant {
@SystemMessage("""
You are a sentiment classifier. Classify the sentiment of the given text
as one of: POSITIVE, NEGATIVE, NEUTRAL. Reply with exactly one of these words.
Examples:
Text: "I absolutely loved this movie, best film of the year!"
Sentiment: POSITIVE
Text: "This restaurant is terrible, the food was cold and the service rude."
Sentiment: NEGATIVE
Text: "The package arrived on time. Nothing more to say."
Sentiment: NEUTRAL
""")
Sentiment classify(@UserMessage String text);
}
Three well-chosen examples do double duty: they pin down the format (exactly one word) and illustrate the decision boundary (contrasting strong opinions with a neutral, factual statement). AiServices renders the system message with the examples on every call.
The most robust prompting technique is making the model return a typed value instead of free text. In LangChain4j this is done by choosing the AI Service's return type — the framework derives a schema, requests JSON matching it, and parses the reply into an object. The internal machinery is OutputParser (EnumOutputParser, PojoOutputParser, StringListOutputParser, and friends); we never touch it directly, we just pick the return type.
Returning an enum — the sentiment above returns Sentiment, parsed into the matching constant.
Returning a POJO — MovieExtractor returns a MovieReview record:
public record MovieReview(String title, int year, String director, double rating, String summary) {}
public interface MovieExtractor {
@SystemMessage("""
Extract information about the movie from the given text.
Return the data as a JSON object with exactly these fields:
title (string), year (integer), director (string), rating (number from 1 to 10), summary (string).
""")
MovieReview extract(@UserMessage String text);
}
The handler gets an object, not a string to hand-parse:
/movie Inception is a 2010 film directed by Christopher Nolan about planting ideas in dreams.
Movie > MovieReview[title=Inception, year=2010, director=Christopher Nolan, rating=9.0, summary=...]
Returning a collection — TopicExtractor returns List<String>. (Collection-of-strings output is parsed as one item per line, so the system message tells the model that format.)
Prompt engineering is all about iteration, so offline testing is a big win. We added a second shared test helper, FakeChatModel, which mirrors FakeEmbeddingModel: it implements the ChatModel interface, captures the exact ChatRequest LangChain4j builds, and returns a canned reply. That lets tests verify two things at once:
What goes in — the generated system/user messages contain the few-shot examples and the interpolated template variables.
What comes out — the canned reply is parsed into the correct enum constant, record, or list.
@Test
void embedsFewShotExamplesInTheSystemMessage() {
FakeChatModel chatModel = new FakeChatModel("POSITIVE");
FewShotAssistant assistant = AiServices.builder(FewShotAssistant.class)
.chatModel(chatModel)
.build();
Sentiment sentiment = assistant.classify("This movie is amazing!");
assertThat(chatModel.lastSystemMessage())
.contains("Examples:")
.contains("Sentiment: NEGATIVE");
assertThat(sentiment).isEqualTo(Sentiment.POSITIVE);
}
@Test
void parsesTheModelReplyIntoTheMovieReviewRecord() {
FakeChatModel chatModel = new FakeChatModel("""
{"title": "Inception", "year": 2010, "director": "Christopher Nolan",
"rating": 9.0, "summary": "A thief enters dreams to plant an idea."}
""");
MovieReview review = AiServices.builder(MovieExtractor.class)
.chatModel(chatModel)
.build()
.extract("Tell me about Inception.");
assertThat(review.title()).isEqualTo("Inception");
assertThat(review.rating()).isEqualTo(9.0);
}
These tests exercise the real AiServices proxy generation and the real output parsers — the only substitute is the model itself. Combined with PromptServiceTest, the whole prompting layer is verified without a single network call.
Templates are the API, not an afterthought. PromptTemplate and @UserMessage/@SystemMessage templates keep prompts next to their consumers and make variables explicit.
Examples live in the system message. They ship with every call and double as both format and boundary guidance.
Output contracts beat prompt begging. Telling the model "return JSON" is unreliable; returning MovieReview makes the schema explicit and the parsing automatic and typed.
FakeChatModel closes the loop. It lets us assert on both the rendered prompt and the parsed result, which is where most prompt-engineering bugs actually live.
From our checklist: LLM integration (pluggable providers/models), integration features (REST endpoints, streaming), and evaluation — now that outputs are typed (Sentiment, MovieReview, List<String>), they become much easier to evaluate automatically.
Prompting is the part of an LLM application you will iterate on most, so it pays to make it structured. Templates keep prompts DRY and inspectable; few-shot examples teach format and boundaries with zero extra code; typed return values turn model output from a string to parse into a contract to rely on. And because all three are pure or deterministic, they test cleanly offline — which is exactly what you want when the thing you're tuning changes every day.
The full implementation lives in our GitHub repository.