Structured Outputs
The term "Structured Outputs" is overloaded and can refer to two things:
- The general ability of the LLM to generate outputs in a structured format (this is what we cover on this page)
- The Structured Outputs feature of OpenAI, which applies to both response format and tools (function calling).
Many LLMs and LLM providers support generating outputs in a structured format, typically JSON. These outputs can be easily mapped to Java objects and used in other parts of your application.
For instance, let’s assume we have a Person
class:
record Person(String name, int age, double height, boolean married) {
}
We aim to extract a Person
object from unstructured text like this:
John is 42 years old and lives an independent life.
He stands 1.75 meters tall and carries himself with confidence.
Currently unmarried, he enjoys the freedom to focus on his personal goals and interests.
Currently, depending on the LLM and the LLM provider, there are four ways how this can be achieved (from most to least reliable):
JSON Schema
Some LLM providers (e.g., OpenAI and Google Gemini) allow specifying JSON schema for the desired output. You can view all supported LLM providers here in the "JSON Schema" column.
When a JSON schema is specified in the request, the LLM is expected to generate an output that adheres to this schema.
Please note that the JSON schema is specified in a dedicated attribute in the request to the LLM provider's API and does not require any free-form instructions to be included in the prompt (e.g., in system or user messages).
LangChain4j supports the JSON Schema feature in both the low-level ChatLanguageModel
API
and the high-level AI Service API.
Using JSON Schema with ChatLanguageModel
In the low-level ChatLanguageModel
API, JSON schema can be specified
using LLM-provider-agnostic ResponseFormat
and JsonSchema
when creating a ChatRequest
:
ResponseFormat responseFormat = ResponseFormat.builder()
.type(JSON) // type can be either TEXT (default) or JSON
.jsonSchema(JsonSchema.builder()
.name("Person") // OpenAI requires specifying the name for the schema
.rootElement(JsonObjectSchema.builder() // see [1] below
.addStringProperty("name")
.addIntegerProperty("age")
.addNumberProperty("height")
.addBooleanProperty("married")
.required("name", "age", "height", "married") // see [2] below
.build())
.build())
.build();
UserMessage userMessage = UserMessage.from("""
John is 42 years old and lives an independent life.
He stands 1.75 meters tall and carries himself with confidence.
Currently unmarried, he enjoys the freedom to focus on his personal goals and interests.
""");
ChatRequest chatRequest = ChatRequest.builder()
.responseFormat(responseFormat)
.messages(userMessage)
.build();
ChatLanguageModel chatModel = OpenAiChatModel.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o-mini")
.logRequests(true)
.logResponses(true)
.build();
// OR
ChatLanguageModel chatModel = GoogleAiGeminiChatModel.builder()
.apiKey(System.getenv("GOOGLE_AI_GEMINI_API_KEY"))
.modelName("gemini-1.5-flash")
.temperature(0.0)
.logRequestsAndResponses(true)
.build();
ChatResponse chatResponse = chatModel.chat(chatRequest);
String output = chatResponse.aiMessage().text();
System.out.println(output); // {"name":"John","age":42,"height":1.75,"married":false}
Person person = new ObjectMapper().readValue(output, Person.class);
System.out.println(person); // Person[name=John, age=42, height=1.75, married=false]
Notes:
- [1] - In most cases, the root element must be of
JsonObjectSchema
type, however Gemini allowsJsonEnumSchema
andJsonArraySchema
as well. - [2] - Required properties must be explicitly specified; otherwise, they are considered optional.
The structure of the JSON schema is defined using JsonSchemaElement
interface,
with the following subtypes:
JsonObjectSchema
- for object types.JsonStringSchema
- forString
,char
/Character
types.JsonIntegerSchema
- forint
/Integer
,long
/Long
,BigInteger
types.JsonNumberSchema
- forfloat
/Float
,double
/Double
,BigDecimal
types.JsonBooleanSchema
- forboolean
/Boolean
types.JsonEnumSchema
- forenum
types.JsonArraySchema
- for arrays and collections (e.g.,List
,Set
).JsonReferenceSchema
- to support recursion (e.g.,Person
has aSet<Person> children
field).JsonAnyOfSchema
- to support polymorphism (e.g.,Shape
can be eitherCircle
orRectangle
).
JsonObjectSchema
The JsonObjectSchema
represents an object with nested properties.
It is usually the root element of the JsonSchema
.
There are several ways to add properties to a JsonObjectSchema
:
- You can add all the properties at once using the
properties(Map<String, JsonSchemaElement> properties)
method:
JsonSchemaElement citySchema = JsonStringSchema.builder()
.description("The city for which the weather forecast should be returned")
.build();
JsonSchemaElement temperatureUnitSchema = JsonEnumSchema.builder()
.enumValues("CELSIUS", "FAHRENHEIT")
.build();
Map<String, JsonSchemaElement> properties = Map.of(
"city", citySchema,
"temperatureUnit", temperatureUnitSchema
);
JsonSchemaElement rootElement = JsonObjectSchema.builder()
.properties(properties)
.required("city") // required properties should be specified explicitly
.build();
- You can add properties individually using the
addProperty(String name, JsonSchemaElement jsonSchemaElement)
method:
JsonSchemaElement rootElement = JsonObjectSchema.builder()
.addProperty("city", citySchema)
.addProperty("temperatureUnit", temperatureUnitSchema)
.required("city")
.build();
- You can add properties individually using one of the
add{Type}Property(String name)
oradd{Type}Property(String name, String description)
methods:
JsonSchemaElement rootElement = JsonObjectSchema.builder()
.addStringProperty("city", "The city for which the weather forecast should be returned")
.addEnumProperty("temperatureUnit", List.of("CELSIUS", "FAHRENHEIT"))
.required("city")
.build();
Please refer to the Javadoc of the JsonObjectSchema for more details.
JsonStringSchema
An example of creating JsonStringSchema
:
JsonSchemaElement stringSchema = JsonStringSchema.builder()
.description("The name of the person")
.build();
JsonIntegerSchema
An example of creating JsonIntegerSchema
:
JsonSchemaElement integerSchema = JsonIntegerSchema.builder()
.description("The age of the person")
.build();
JsonNumberSchema
An example of creating JsonNumberSchema
:
JsonSchemaElement numberSchema = JsonNumberSchema.builder()
.description("The height of the person")
.build();
JsonBooleanSchema
An example of creating JsonBooleanSchema
:
JsonSchemaElement booleanSchema = JsonBooleanSchema.builder()
.description("Is the person married?")
.build();
JsonEnumSchema
An example of creating JsonEnumSchema
:
JsonSchemaElement enumSchema = JsonEnumSchema.builder()
.description("Marital status of the person")
.enumValues(List.of("SINGLE", "MARRIED", "DIVORCED"))
.build();
JsonArraySchema
An example of creating JsonArraySchema
to define an array of strings:
JsonSchemaElement itemSchema = JsonStringSchema.builder()
.description("The name of the person")
.build();
JsonSchemaElement arraySchema = JsonArraySchema.builder()
.description("All names of the people found in the text")
.items(itemSchema)
.build();
JsonReferenceSchema
The JsonReferenceSchema
can be used to support recursion:
String reference = "person"; // reference should be unique withing the schema
JsonObjectSchema jsonObjectSchema = JsonObjectSchema.builder()
.addStringProperty("name")
.addProperty("children", JsonArraySchema.builder()
.items(JsonReferenceSchema.builder()
.reference(reference)
.build())
.build())
.required("name", "children")
.definitions(Map.of(reference, JsonObjectSchema.builder()
.addStringProperty("name")
.addProperty("children", JsonArraySchema.builder()
.items(JsonReferenceSchema.builder()
.reference(reference)
.build())
.build())
.required("name", "children")
.build()))
.build();
The JsonReferenceSchema
is currently supported only by OpenAI.
JsonAnyOfSchema
The JsonAnyOfSchema
can be used to support polymorphism:
JsonSchemaElement circleSchema = JsonObjectSchema.builder()
.addNumberProperty("radius")
.build();
JsonSchemaElement rectangleSchema = JsonObjectSchema.builder()
.addNumberProperty("width")
.addNumberProperty("height")
.build();
JsonSchemaElement shapeSchema = JsonAnyOfSchema.builder()
.anyOf(circleSchema, rectangleSchema)
.build();
JsonSchema jsonSchema = JsonSchema.builder()
.name("Shapes")
.rootElement(JsonObjectSchema.builder()
.addProperty("shapes", JsonArraySchema.builder()
.items(shapeSchema)
.build())
.required(List.of("shapes"))
.build())
.build();
ResponseFormat responseFormat = ResponseFormat.builder()
.type(ResponseFormatType.JSON)
.jsonSchema(jsonSchema)
.build();
UserMessage userMessage = UserMessage.from("""
Extract information from the following text:
1. A circle with a radius of 5
2. A rectangle with a width of 10 and a height of 20
""");
ChatRequest chatRequest = ChatRequest.builder()
.messages(userMessage)
.responseFormat(responseFormat)
.build();
ChatResponse chatResponse = model.chat(chatRequest);
System.out.println(chatResponse.aiMessage().text()); // {"shapes":[{"radius":5},{"width":10,"height":20}]}
The JsonAnyOfSchema
is currently supported only by OpenAI.
Adding Description
All of the JsonSchemaElement
subtypes, except for JsonReferenceSchema
, have a description
property.
If an LLM does not provide the desired output, descriptions can be provided
to give more instructions and examples of correct outputs to the LLM, for example:
JsonSchemaElement stringSchema = JsonStringSchema.builder()
.description("The name of the person, for example: John Doe")
.build();
Limitations
When using JSON Schema with ChatLanguageModel
, there are some limitations:
- It works only with supported OpenAI and Gemini models.
- It does not work in the streaming mode yet.
JsonReferenceSchema
andJsonAnyOfSchema
are currently supported only by OpenAI.
Using JSON Schema with AI Services
When using AI Services, one can achieve the same much easier and with less code:
interface PersonExtractor {
Person extractPersonFrom(String text);
}
ChatLanguageModel chatModel = OpenAiChatModel.builder() // see [1] below
.apiKey(System.getenv("OPENAI_API_KEY"))
.modelName("gpt-4o-mini")
.responseFormat("json_schema") // see [2] below
.strictJsonSchema(true) // see [2] below
.logRequests(true)
.logResponses(true)
.build();
// OR
ChatLanguageModel chatModel = GoogleAiGeminiChatModel.builder() // see [1] below
.apiKey(System.getenv("GOOGLE_AI_GEMINI_API_KEY"))
.modelName("gemini-1.5-flash")
.responseFormat(ResponseFormat.JSON) // see [3] below
.temperature(0.0)
.logRequestsAndResponses(true)
.build();
PersonExtractor personExtractor = AiServices.create(PersonExtractor.class, chatModel); // see [1] below
String text = """
John is 42 years old and lives an independent life.
He stands 1.75 meters tall and carries himself with confidence.
Currently unmarried, he enjoys the freedom to focus on his personal goals and interests.
""";
Person person = personExtractor.extractPersonFrom(text);
System.out.println(person); // Person[name=John, age=42, height=1.75, married=false]
Notes:
- [1] - In a Quarkus or a Spring Boot application, there is no need to explicitly create the
ChatLanguageModel
and the AI Service, as these beans are created automatically. More info on this: for Quarkus, for Spring Boot. - [2] - This is required to enable the JSON Schema feature for OpenAI, see more details here.
- [3] - This is required to enable the JSON Schema feature for Google AI Gemini.
When all the following conditions are met:
- AI Service method returns a POJO
- The used
ChatLanguageModel
supports the JSON Schema feature - The JSON Schema feature is enabled on the used
ChatLanguageModel
then the ResponseFormat
with JsonSchema
will be generated automatically based on the specified return type.
Make sure to explicitly enable JSON Schema feature when configuring ChatLanguageModel
,
as it is disabled by default.
The name
of the generated JsonSchema
is a simple name of the return type (getClass().getSimpleName()
),
in this case: "Person".
Once LLM responds, the output is parsed into an object and returned from the AI Service method.
While we are gradually migrating to Jackson, Gson is still used for parsing the outputs in AI Service, so Jackson annotations on your POJOs will have no effect.
You can find many examples of supported use cases here and here.
Adding Description
If an LLM does not provide the desired output, classes and fields can be annotated with @Description
to give more instructions and examples of correct outputs to the LLM, for example:
@Description("a person")
record Person(@Description("person's first and last name, for example: John Doe") String name,
@Description("person's age, for example: 42") int age,
@Description("person's height in meters, hor example: 1.78") double height,
@Description("is person married or not, for example: false") boolean married) {
}
Limitations
When using JSON Schema with AI Services, there are some limitations:
- It works only with supported OpenAI and Gemini models.
- Support for JSON Schema needs to be enabled explicitly when configuring
ChatLanguageModel
. - It does not work in the streaming mode.
- Currently, it works only when return type is a (single) POJO or a
Result<POJO>
. If you need other types (e.g.,List<POJO>
,enum
, etc.), please wrap these into a POJO. We are working on supporting more return types soon. - POJOs can contain:
- Scalar/simple types (e.g.,
String
,int
/Integer
,double
/Double
,boolean
/Boolean
, etc.) enum
s- Nested POJOs
List<T>
,Set<T>
andT[]
, whereT
is a scalar, anenum
or a POJO
- Scalar/simple types (e.g.,
- All fields and sub-fields in the generated
JsonSchema
are automatically marked asrequired
, there is currently no way to make them optional. - When LLM does not support JSON Schema feature, or it is not enabled, or return type is not a POJO, AI Service will fall back to prompting.
- Recursion is currently supported only by OpenAI.
- Polymorphism is not supported yet. The returned POJO and its nested POJOs must be concrete classes; interfaces or abstract classes are not supported.
Tools (Function Calling)
This method assumes (mis)using tools to produce structured outputs.
A single tool is specified in the request to the LLM, and the tool parameters describe the desired structure of the output.
Once the LLM returns an AiMessage
containing a ToolExecutionRequest
,
the JSON string in ToolExecutionRequest.arguments()
is parsed into a POJO.
More info is coming soon.
In the meantime, please read this section and this article.
Prompting + JSON Mode
More info is coming soon. In the meantime, please read this section and this article.
Prompting
When using prompting, one has to specify the format of the desired output in free-form text within a system or user message and hope that the LLM will abide. This approach is quite unreliable. If LLM and LLM provider supports the methods described above, it is better to use those.
More info is coming soon.
In the meantime, please read this section and this article.