Showing posts with label JSON. Show all posts
Showing posts with label JSON. Show all posts

Thursday, 6 March 2025

Building a Strongly-Typed API for Large Language Models

 In this post, we'll explore a Java interface designed to interact with Large Language Models (LLMs)  in a type-safe manner. We'll break down the GenerativeAIService interface and its supporting classes to understand how it provides a structured approach to AI interactions.

The Problem: Unstructured LLM Responses

When working with LLMs, responses typically come as unstructured text. This presents challenges when you need to extract specific data or integrate AI capabilities into enterprise applications that expect structured data.

For example, if you want an LLM to generate JSON data for your application, you'd need to:

  1. Parse the response text
  2. Extract the JSON portion
  3. Deserialize it into your application objects
  4. Handle parsing errors appropriately

This process can be error-prone and verbose when implemented across multiple parts of your application.

Enter GenerativeAIService

The GenerativeAIService interface provides a clean solution to this problem by offering methods that not only communicate with LLM APIs but also handle the parsing of responses into Java objects.

Let's look at the core interface:

java
public interface GenerativeAIService {
ChatMessageReply chat(ChatRequest conversation);

default ChatRequest prepareRequest(ChatRequest conversation, Map<String, Object> params) {
return ParamPreparedRequest.prepare(conversation, params);
}



default <T> T chat(ChatRequest conversation, Class<T> returnType) {
return chat(conversation, returnType, (jsonContent, e) -> {
throw new RuntimeException("Failed to parse JSON: " + jsonContent, e);
}).get();
}

default <T> Optional<T> chat(ChatRequest conversation, Class<T> returnType, BiConsumer<String, Exception> onFailedParsing) {
var reply = chat(conversation);
return ChatMessageJsonParser.parse(reply, returnType, onFailedParsing);
}

//Other methods
}

The interface provides three key capabilities:

  1. Basic Chat Functionality: The chat(ChatRequest) method handles direct communication with the LLM and returns raw responses.
  2. Type-Safe Responses: Overloaded chat() methods accept a Class<T> parameter to specify the expected return type, allowing the service to automatically parse the LLM response into the desired Java class.
  3. Robust Error Handling: Options to provide custom error handling logic when parsing fails.

How It Works

Behind the scenes, the ChatMessageJsonParser class does the heavy lifting:

java
public static <T> Optional<T> parse(ChatMessageReply reply, Class<T> returnType, BiConsumer<String, Exception> onFailedParsing) {
var message = reply.message().trim();
var jsonContent = _extractMessage(message);
return _cast(returnType, onFailedParsing, jsonContent);
}

It:

  1. Extracts JSON content from the LLM's response (which may be wrapped in markdown code blocks)
  2. Uses Gson to deserialize the JSON into the requested type
  3. Handles parsing errors according to the provided error handler

Parameterised Prompts

The interface also supports parameterised prompts through the ParamPreparedRequest class:

java
default ChatRequest prepareRequest(ChatRequest conversation, Map<String, Object> params) {
return ParamPreparedRequest.prepare(conversation, params);
}

This allows you to:

  1. Create template prompts with placeholders like {{parameter_name}}
  2. Fill those placeholders at runtime with a map of parameter values
  3. Validate that all required parameters are provided

Code Example: Using the Typed API

Here's how you might use this API in practice:

java
// Define a data class for the structured response
public static class ProductSuggestion {
public String productName;
public String description;
public double price;
public List<String> features;



}
// Create a parameterised prompt
String prompt = """
Suggest a {{product_type}} product with {{feature_count}} features. Reply in JSON format

<example>
{
"productName": "product name",
"description": "product description",
"price": 100.0,
"features": ["feature 1", "feature 2", "feature 3", "feature 4", "feature 5"]
}
</example>
""";
var request = new ChatRequest(
"gemini-2.0-flash",
0.7f,
List.of(new ChatRequest.ChatMessage("user",
prompt))
);

// Prepare with parameters
Map<String, Object> params = Map.of(
"product_type", "smart home",
"feature_count", 5
);
request = service.prepareRequest(request, params);


// Get typed response
var suggestion = service.chat(request, ProductSuggestion.class);

System.out.println(suggestion);

Benefits of a Typed LLM API

  1. Type Safety: Catch type mismatches at compile time rather than runtime.
  2. Clean Integration: Seamlessly incorporate AI capabilities into existing Java applications.
  3. Reduced Boilerplate: Consolidate JSON parsing and error handling logic in one place.
  4. Parameter Validation: Ensure all required prompt parameters are provided before making API calls.
  5. Flexible Error Handling: Customize how parsing errors are handled based on your application's needs.

Implementation Considerations

When implementing this interface for different AI providers, consider:

  • JSON Mode/Structure Mode: Now a days LLM support JSON or Structure mode and that can used as compared to Prompt instruction. 
  • Response formats: Ensure your parser can handle the specific output formats of each provider.

Conclusion

By creating a strongly-typed interface for LLM interactions, we bridge the gap between the unstructured world of AI and the structured requirements of enterprise applications. This approach enables developers to leverage the power of large language models while maintaining the type safety and predictability.

The GenerativeAIService interface provides a foundation that can be extended to work with various AI providers while providing a consistent interface for application code. It represents a step toward making AI capabilities more accessible and manageable in traditional software development workflows.


Code for this post is available @ TypeSafety

Friday, 10 July 2020

Data encoding and storage

Data encoding and storage format is evolving field, it has seen so many changes starting from naive text based encoding to advance compact nested binary format.

Encoder and decoder
Encoding/Decoding

Selecting correct encoding/storage format has big impact on application performance and how easily it can evolve. Data encoding has big impact on whether application is backward/forward compatible.  
Selecting right encoding format can be one of the important factor for data driven application agility. 

Application developer tends to makes default choice of text(xml, csv or json) based encoding because it is human readable and language agonist. 
Text format are not very efficient, they are take time time/space and also struggle to evolve. If someone care about efficiency then binary format is the way to go. 

In this post i will compare text vs binary encoding and build simple persistent storage that supports flexible encoding.

We will compare popular text/binary encoding like csv , json  , avro , chronicle and sbe



I will use above Trade object as example for this comparison. 

CSV

It is one of the most popular textual format, it has no support for types and makes no distinction between different type of numbers. One of the major restriction is that it only supports scalar types, if we have to store nested or complex object then custom encoding is required. Column and rows values are separated by deliminator and special handling is required when deliminator is part of column value.

Reader application has to parse text and convert into proper type at read time, it produces garbage and is also CPU intensive.

Best thing is that it can be edit in any text editor. All programming language can read and write CSV.


JSON

This is what drives Web today. Majorities of micro services that are user facing are using JSON for REST APIs.
This address some of the issues with CSV by making distinction between string and number, also support nested types like Map,Array, Lists etc. It is possible to have schema for JSON message but it is not in practice because it takes ways flexible schema. This is new XML these days. 
One of major drawback is size, size of JSON message is more as it has to keep key/attribute name as part of message. I have heard in some document based database attribute names takes up more than 50% of the space, so be careful when you select attribute name in json document.  

Both of these text format are very popular inspite of all the inefficiency. Across team if you need any friction less data format interface then go for text based one.

Chronicle/Avro/SBE

These are very popular binary format and very efficient for distributed or trading systems.

SBE is very popular in financial domain and used as replacement of FIX protocol. I shared about it in post inside-simple-binary-encoding-sbe.

Avro is also very popular and it is built by taking lots of learning from protobuffer and thrift. For row based and nested storage this is very good choice. It supports multiple languages. Avro applies some cool encoding tricks to reduce size of message, you can read about it in post integer-encoding-magic

Chronicle-Wire is picking up and i came across this very recently. It has nice abstraction over text and binary message with single unified interface. This library allows to choose different encoding based on usecase. 


Lets look at some number now. This is very basic comparison just on size aspect of message. Run your benchmark before making any selection.





We will try to save above 2 records in different format and compare size.


Chronicle is most efficient in this example and i have used RawWire format for this example and it is the most compact option available in library because it only stores data, no schema metadata is stored. 

Next one is Avro and SBE, very close in terms of size but sbe is more efficient in terms of encoding/decoding operation.

CSV is not that bad, it took 57 bytes for single row but don't select CSV based on size. As expected JSON takes up more bytes to represent same message. It is taking around 2X more than Chronicle.

Lets look at some real application of these encoding. These encoding can be used for building logs , queues , block storage, RPC message etc.

To explore more i created simple storage library that is backed by file and allows to specific different encoding format.

public interface RecordContainer<T> extends Closeable {
boolean append(T message);

void read(RecordConsumer<T> reader);

void read(long offSet, RecordConsumer<T> reader);

default void close() {
}

int size();

String formatName();

}

This implementation allow to append records at the end of buffer and access the buffer from starting or randomly from given message offset. This can seen as append only unbounded message queue, it has some similarity with kafka topic storage.

RandomAccessFile form java allow to map file content as array buffer and after that file content can be managed like any array.

All the code used in this post is available @ encoding github