Skip to main content

Understanding LLM Applications with OpenAI Without Frameworks


Greetings!

Large Language Models (LLMs) have changed the way we build applications. Today, we can build chatbots, intelligent workflows, AI assistants, and even AI agents.

There are many frameworks available to build LLM powered applications like Spring AI, Langchain. However, before jumping into a framework, it is important to understand what happens underneath.

In this article, we will explore the main concepts behind LLM applications by directly interacting with OpenAI APIs.

Calling an LLM

Let's start with a simple request.

First, export your OpenAI API key.

bash
export OPENAI_API_KEY="your-api-key"

Now, send a request to the OpenAI Responses API.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "input": "What is Java?"
  }'

At the simplest level, we send an input to the LLM and receive an output.

However, an LLM does not simply search for a stored answer. It generates a response token by token.

Understanding Tokens and Next Token Prediction

LLMs work with tokens. A token can be a word, part of a word, or another small piece of text.

Imagine we provide the following text:

text
Java is a programming

The model may predict possible next tokens:

text
language    80%
platform    10%
tool         5%
...

The model selects a token and repeats the same process.

This process continues until the model completes the response.

Controlling the Response with Inference Parameters

We can influence how the model generates a response using inference parameters.

Temperature

Temperature controls the randomness of token selection.

Let's ask the model to create a slogan with a low temperature.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "temperature": 0.2,
    "input": "Create a slogan for a chess application."
  }'

A lower temperature generally produces more focused and predictable responses.

Now, increase the temperature.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "temperature": 1.0,
    "input": "Create a slogan for a chess application."
  }'

The response may now be more varied or creative.

In simple terms: Temperature controls how adventurous the model is when selecting the next token.

Top P

top_p controls the probability range considered when selecting the next token.

Imagine the model predicts:

text
Java      50%
Python    30%
Go        10%
Rust       5%
Other      5%

With top_p set to 0.8, the model focuses on tokens covering roughly the top 80% of the probability mass.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "top_p": 0.8,
    "input": "Suggest a programming language for building a web API."
  }'

Temperature controls randomness, while top_p limits the probability range considered during selection.

Usually, we do not need to aggressively change both.

Maximum Output Tokens

We can also limit the maximum number of tokens generated by the model.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "max_output_tokens": 100,
    "input": "Explain microservices in detail."
  }'

It is useful to understand the difference between input and output tokens.

text
Input tokens  → Instructions + messages + context
Output tokens → Generated response

Tokens are important because models have context limits, and API usage is generally measured using tokens.

LLM Responses Are Not Guaranteed Facts

There is another important point about LLMs. LLM output is probabilistic.

The model generates a likely response based on the provided input and its learned patterns. This means a response may sound confident while still being incorrect.

This is commonly referred to as hallucination.

For example:

text
User: What is the status of order ORD-1001?

LLM: Your order has been delivered.

The response sounds perfectly valid. However, the model has no access to our order database.

This is why we should not treat an LLM as a database or a source of guaranteed facts. Later, we will see how external knowledge and tools help us provide real information to the model.

Getting Structured Responses

So far, the model has returned natural language responses. This is fine for a chatbot. However, applications often need structured data. Imagine we are building a movie application. We may need the model to return:

json
{
  "title": "Interstellar",
  "genre": "Science Fiction"
}

Simply asking the model to return JSON does not define a reliable application contract. OpenAI supports Structured Outputs, where we provide the expected JSON Schema.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "input": "Recommend a science fiction movie.",
    "text": {
      "format": {
        "type": "json_schema",
        "name": "movie",
        "schema": {
          "type": "object",
          "properties": {
            "title": {
              "type": "string"
            },
            "genre": {
              "type": "string"
            }
          },
          "required": ["title", "genre"],
          "additionalProperties": false
        },
        "strict": true
      }
    }
  }'

The model now generates a response that follows the defined structure. Structured output is useful when the response needs to be processed by our application, displayed in a UI, or mapped to an application object.

Messages and Roles

Real applications need more than a simple input. We need to tell the model how it should behave and what the user wants. Messages can have different roles.

text
systtem/ developer - The system, developer roles contains application level instructions.
user - The user role represents the user's request.
assistant - The assistant role represents previous model responses when we provide conversation history.

Let's create a Java assistant.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "input": [
      {
        "role": "developer",
        "content": "You are a Java expert. Explain concepts using simple examples."
      },
      {
        "role": "user",
        "content": "What is dependency injection?"
      }
    ]
  }'

Now, let's change the developer instruction.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "input": [
      {
        "role": "developer",
        "content": "Explain technical concepts to a five-year-old child."
      },
      {
        "role": "user",
        "content": "What is dependency injection?"
      }
    ]
  }'

The user question is exactly the same. However, the model responds differently because the developer instruction changes its behaviour.

This separation is important when building LLM applications.

Chat Conversations

Consider the following conversation:

text
User: What is Spring Boot?

Assistant: Spring Boot is a framework for building Java applications.

User: Explain it more simply.

How does the model know what "it" means?

Let's send the first request.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "input": "What is Spring Boot?"
  }'

The response contains an ID similar to:

text
resp_0654f03ef0ebbd98006a55cc8a9ffc819490468f200df33bde

We can continue from that response using previous_response_id.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "previous_response_id": "resp_0654f03ef0ebbd98006a55cc8a9ffc819490468f200df33bde",
    "input": "Explain it more simply."
  }'

The previous conversation context helps the model understand what "it" refers to.

Conversation Memory

A common misunderstanding is that the LLM automatically remembers our previous API requests. It does not. The model needs previous conversation context to understand earlier messages.

One option is to chain responses using previous_response_id as explained earlier.

Our application can also maintain the conversation history itself and provide the messages again.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "input": [
      {
        "role": "user",
        "content": "My favorite programming language is Java."
      },
      {
        "role": "assistant",
        "content": "Got it. Your favorite programming language is Java."
      },
      {
        "role": "user",
        "content": "What is my favorite programming language?"
      }
    ]
  }'

In this approach, our application may store messages in a database and provide the required history with each request.

OpenAI also provides conversation objects for maintaining conversation state across multiple responses.

The important point is; Conversation memory is conversation context available to the model. It is not permanent knowledge stored inside the LLM.

Application instructions may also need to be provided again depending on how we manage conversation state.

Context Windows

Can we keep sending the entire conversation forever? No. Models have a context window.

The context may contain:

text
Developer instructions
        +
Conversation history
        +
Retrieved knowledge
        +
Tool information
        +
Current user request

All of this consumes tokens. As a conversation grows, applications may need to keep only recent messages, summarize older conversations, or select only relevant information.

This is why conversation memory usually needs a memory strategy instead of storing and sending everything forever.

The LLM Does Not Know Our Data

Now, imagine a customer asks:

text
What is the status of order ORD-1001?

Let's ask the model directly.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "input": "What is the current status of order ORD-1001?"
  }'

The model does not have access to our order database. Similarly, it does not automatically know about:

text
Company policies
Internal documents
Customer accounts
Current order status
Application database records

We need to provide external information. Two common techniques are:

text
Documents / Knowledge  → RAG

Live Application Data  → Tools

Let's understand the difference.

External Knowledge with RAG

Imagine our company has an employee handbook.

The user asks:

text
How many annual leave days do employees receive?

The answer exists in the employee handbook.

Retrieval Augmented Generation, or RAG, retrieves relevant information and provides it to the model as context.

First, we split our documents into smaller chunks. Imagine one chunk contains:

text
Employees receive 21 days of annual leave per year.

We can create an embedding for this text.

bash
curl https://api.openai.com/v1/embeddings \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "text-embedding-3-small",
    "input": "Employees receive 21 days of annual leave per year."
  }'

An embedding represents the meaning of the text as a vector of numbers.

Conceptually:

text
"Employees receive 21 days of annual leave per year."

                    ↓

[0.018, -0.024, 0.091, ...]

We store the vector together with the original text in a vector store. When the user asks a question, we create another embedding.

bash
curl https://api.openai.com/v1/embeddings \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "text-embedding-3-small",
    "input": "How many annual leave days do employees receive?"
  }'

Our application performs a similarity search between the question vector and document vectors.

The relevant chunk may be:

text
Employees receive 21 days of annual leave per year.

Now, we provide the retrieved context to the model.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "input": [
      {
        "role": "developer",
        "content": "Answer the question using only the provided context."
      },
      {
        "role": "user",
        "content": "Context: Employees receive 21 days of annual leave per year.\n\nQuestion: How many annual leave days do employees receive?"
      }
    ]
  }'

The complete RAG flow looks like this:


The important point is:

RAG does not teach the model new knowledge. It retrieves relevant information and provides it as context.

Accessing Live Data with Tools

Let's return to our order question.

text
What is the status of order ORD-1001?

Order status is live application data. RAG is usually not the right solution. Instead, we can provide a tool.

Let's define a get_order_status tool.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "input": "What is the status of order ORD-1001?",
    "tools": [
      {
        "type": "function",
        "name": "get_order_status",
        "description": "Returns the current status of an order.",
        "parameters": {
          "type": "object",
          "properties": {
            "order_id": {
              "type": "string",
              "description": "The order ID."
            }
          },
          "required": ["order_id"],
          "additionalProperties": false
        },
        "strict": true
      }
    ]
  }'

The model may return a function call similar to:

json
{
  "type": "function_call",
  "name": "get_order_status",
  "arguments": "{\"order_id\":\"ORD-1001\"}",
  "call_id": "call_abc123"
}

Notice what happened. The model did not execute our database query. It requested:

text
get_order_status(
    order_id = "ORD-1001"
)

Our application executes the actual operation.

For example:

sql
SELECT status
FROM orders
WHERE order_id = 'ORD-1001';

Assume our application receives:

json
{
  "status": "IN_TRANSIT"
}

Now, we send the tool result back to the model.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "previous_response_id": "resp_0f18bff12dd43a47006a55cdf1679481959d8221e37c23aeb4",
    "input": [
      {
        "type": "function_call_output",
        "call_id": "call_GntNb1CYnOMWqvthZINOXcJt",
        "output": "{\"status\":\"IN_TRANSIT\"}"
      }
    ]
  }'

The model can now generate a natural language response:

text
Your order ORD-1001 is currently in transit.

The complete tool-calling process is:

There is one very important detail:

The LLM does not execute our Java method, database query, or REST API.

The model decides which tool should be called and provides the arguments. Our application executes the actual operation. Tools are not limited to reading data. They can also represent actions such as:

text
create_order
cancel_order
send_email
book_delivery
create_support_ticket

This is how an LLM can interact with the outside world.

Combining Memory, RAG, and Tools

A real AI application may combine all these concepts.

Consider this conversation:

text
User:
What is our refund policy?

Assistant:
You can request a refund within 30 days.

User:
What about order ORD-1001?

Assistant:
ORD-1001 is currently in transit.

User:
Can I refund it?

The final question looks simple:

text
Can I refund it?

However, answering it may require several capabilities.

text
Conversation Memory - Understand "it" means ORD-1001

RAG - Retrieve the refund policy

Tool - Retrieve the current order status

Eventually, the model needs the relevant information.

bash
curl https://api.openai.com/v1/responses \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4.1",
    "input": [
      {
        "role": "developer",
        "content": "Answer customer questions using the provided policy and order information."
      },
      {
        "role": "user",
        "content": "Refund policy: Orders can be refunded within 30 days. Orders currently in transit must be delivered before a refund request can be created.\n\nOrder ORD-1001 status: IN_TRANSIT.\n\nThe customer is asking whether order ORD-1001 can be refunded now."
      }
    ]
  }'

The architecture starts to look like this:

The LLM remains at the centre. However, our application provides the context and capabilities required to answer the user.

Are We Building an Agent?

Consider what we have built so far:

text
LLM
 +
Instructions
 +
Conversation Memory
 +
External Knowledge
 +
Tools

The model can understand a request, use available knowledge, decide when a tool is required, and continue a conversation.

Our application is now starting to show agent like behaviour.

More advanced agents may perform multiple steps, use several tools, evaluate results, and continue until a task is completed. However, the fundamental concepts remain the same.

Why Do We Need AI Frameworks?

Without a framework, our application must manage:

text
API requests
Messages
Instructions
Structured outputs
Conversation history
Context windows
Embeddings
Vector stores
Retrieval
Tool definitions
Tool execution
Tool results
Retries
Observability

We can certainly implement all of this ourselves. However, as the application grows, the amount of infrastructure code also grows. This is where AI frameworks such as Spring AI, LangChain become useful. They provide abstractions around the same concepts we explored in this article.

Frameworks do not introduce magic into LLM applications. Underneath, we still have:

text
Models
Messages
Memory
Retrieval
Structured Outputs
Tools

Understanding these fundamentals makes it much easier to understand what an AI framework is actually doing for us.

Summary

In this article, we explored the fundamental building blocks of an LLM application using OpenAI APIs directly. When we combine instructions, memory, external knowledge, and tools, we begin to build intelligent, agent like applications. Now that we understand what happens underneath, we are ready to explore how AI frameworks simplify these concepts.


Comments