LLM Context

Jean-Claude Brantschen

Im Entwickleralltag haben wir die LLMs als Coding-Partner liebgewonnen. Man kann nicht nur einzelne Fragen an eine LLM stellen, sondern einen ganzen Chatverlauf führen. Ist die LLM so smart, dass sie mich und die vorherigen Fragen und Antworten kennt? Das Zauberwort heisst LLM Context. Dies wollen wir uns nun etwas genauer anschauen.

Vorbemerkungen

  • Die Codebeispiele basieren auf dem Wissen aus dem Artikel Ollama REST. Es ist sinnvoll, zuerst diesen zu lesen.
  • Alle Beispiele stehen in GitHub-Repos zur Verfügung.
  • Ollama muss installiert sein. Mehr Informationen zur Installation gibt es im Artikel Lokale LLMs.

Beispiel Hauptstadt

Wir starten mit einem Beispiel und möchten eine LLM fragen, was die Hauptstädte von Frankreich und Schweden sind. Dazu machen wir zwei Anfragen via Ollama (in Open WebUI) mit dem llama3.2 Modell.

PS: Die Anfragen könnte auch an eine beliebige andere LLM (wie Claude oder OpenAI) gemacht werden und die Antworten sollten ähnlich aussehen.

Hauptstatt Anfrage an llama3.2 via Open WebUI

Wichtig ist, dass die erste Anfrage die Informationen “Land” (Frankreich) und “Hauptstadt” enthält. Die zweite Anfrage hat nur die Information “Land” (Schweden). Aus dem Zusammenhang mit der ersten Anfrage erscheint es der LLM klar, dass es in der zweiten Frage um die Hauptstadt von Schweden geht. Und wie erwartet sind die Antworten Paris und Stockholm.

Anfrage Nr.Anfrage in Open WebUIAntwort llama3.2
1What is the capital of France?The capital of France is Paris.
2And Sweden?Stockholm is the capital of Sweden.
Haupstadt Anfragen an llama3.2 via Open WebUI

Programmatische Anfrage mit Python

Wie sieht es nun aus, wenn die Anfragen programmatisch gemacht werden? Wir machen dazu einen REST Aufruf an Ollama (und das llama3.2 Modell) mittels Python. Basis dafür ist der Code aus dem Ollama REST Artikel.

def chat(model_name: str, questions: list[str]) -> list[tuple[str, str]]:
    responses = []
    for question in questions:
        data = {
            "model": model_name,
            "messages": [
                {
                    "role": "user",
                    "content": question
                }
            ],
            "stream": False
        }

        response_data = _send_request(data)
        responses.append((question, _extract_chat_content(response_data)))

    return responses

def _send_request(data: dict) -> dict:
    url = "http://localhost:11434/api/chat"
    response = requests.post(url, json=data)
    return response.json()


def _extract_chat_content(json_data: dict) -> str:
    return json_data["message"]['content']

Ein paar Hinweise zum Code:

  • chat() bekommt als Parameter den Namen des Modells (hier llama3.2) und eine Liste mit den Anfragen ans Modell.
  • In einer Schleife wird für jede Anfrage ein REST Request erstellt und via _send_request() ans Modell gesendet.
  • Die Antworten werden in der respsonses Liste gesammelt und am Schluss zurückgegeben.

Der Aufruf von chat() für die Anfragen der Hauptstädte von Frankreich und Schweden sieht dann folgendermassen aus:

responses_chat_no_context = chat('llama3.2', ['What is the capital of France?', 'And Sweden?'])
for _response in responses_chat_no_context:
    print(_response)

Und hier die Anfragen und Antworten an das llama3.2 Modell via REST und Python:

Anfrage Nr.Anfrage in PythonAntwort llama3.2
1What is the capital of France?The capital of France is Paris.
2And Sweden?I didn’t mention a country earlier, so I’ll provide information on Sweden
Haupstadt Anfragen an llama3.2 via Python (REST)

Schon etwas ernüchternd. Das llama3.2 Modell kann die beiden Anfragen nicht in Relation setzen. Aber wieso funktioniert es denn in OpenWebUI? Das Zauberwort heisst LLM Context.

LLM Context und Token

Wenn wir den Context verstehen wollen, müssen wir uns zuerst anschauen, was Token sind.

Token

Der Text der Anfrage, der an ein Modell geschickt wird, wird in kleinere Einheiten, sogenannte Token, zerlegt. Dies können Wörter, Wortteile oder Zeichenfolgen sein. Dieser Vorgang wird auch Tokenisierung genannt.

Wie genau Text in Token zerlegt wird, ist ja nach Modell und Version verschieden. Für OpenAI gibt es eine Webseite, auf der man einen Tokenizer selbst ausprobieren kann.

Ein konkretes Beispiel macht das klarer. Der Text “What is the capital of France?” wird bei OpenAI in die folgenden 7 Token zerlegt (wobei _ hier für ein Leerzeichen steht):

What 
_is 
_the 
_capital 
_of 
_France
_?

Die Token sind aber nicht immer ganze Wörter. Der Text “model tokenization” wird von OpenAI in 3 Token zerlegt:

model 
_token
_ization

Aber warum interessieren uns Token überhaupt? Token sind die Masseinheit, in der die Grösse von LLM Anfragen und Antworten gemessen wird. Und wenn wir Anfragen per API (z. B. via REST oder eine Library) an ein Nicht-Open-Source-Modell (z. B. Claude oder OpenAI) machen, dann bezahlen wir pro Token. Und Token sind auch ein zentraler Punkt im LLM Context.

LLM Context

Der Context enthält alle Informationen, die das Modell für die Verarbeitung einer Anfrage braucht. Der Context besteht aus der aktuellen Anfrage und den vorherigen Anfragen und Antworten des Modells. Aber was hat das mit dem Beispiel mit den Hauptstädten zu tun?

Ganz einfach: das Modell bekommt via Context die ganze History des Chatverlaufs mit. Dieser Context muss manuell über die Zeit für jede weitere Anfrage erweitert werden, und zwar um die letzte Antwort und die neue Anfrage. Wir geben dem Modell (bei jeder Anfrage) quasi ein Log mit, was wir schon alles gefragt haben und wie das Modell darauf geantwortet hat.

Die folgende Tabelle zeigt für das Beispiel der Hauptstädte, wie der Context für die erste und die zweite Anfrage aussehen müsste, wenn man das gewünschte Ergebnis (Paris, Stockholm) erhalten möchte.

AnfrageAntwortContext
1. AnfrageWhat is the capital of France?What is the capital of France?
1. AntwortThe capital of France is Paris.
2. AnfrageAnd Sweden?What is the capital of France?



The capital of France is Paris.

And Sweden?
2. AntwortStockholm is the capital of Sweden.
Haupstadt Anfragen an llama3.2 mit Context

Wenn wir Anfragen über Open WebUI stellen, kümmert sich Open WebUI um den Context und das Modell scheint den ganzen Chatverlauf zu kennen. Wenn wir aber programmatisch Anfragen an ein Modell stellen, dann müssen wir uns selber um den Kontext kümmern. Wie das genau gemacht wird, schauen wir uns nun an Beispielen in Python und Java an.

Programmatische Anfrage in Python mit Context

Wir starten mit einer Context Klasse. Es ist ein Wrapper für die JSON Daten, die bei jeder Anfrage dem Modell mitgeschickt werden müssen. Die initialen JSON Daten sehen folgendermassen aus:

"model": model_name,
"messages": [
    {
        "role": "user",
        "content": question
    }
],
"stream": False

In Python kann man den Context als Klasse mit folgenden Daten und Funktionen implementieren:

Daten

  • model: der Name des Modells.
  • messages: eine Liste von Messages, wobei jede Message eine Rolle (role) und einen Inhalt (content) hat.
  • Im Konstruktor wird der Modellname gespeichert und eine initiale leere Messages Liste erstellt.

Funktionen

  • add_request_message(): fügt dem Context eine Message mit Rolle user hinzu.
  • add_response_message(): fügt dem Context eine Message mit Rolle assistant hinzu.
class Context:
    def __init__(self, model_name: str):
        self.model_name = model_name
        self.data = {
            "model": model_name,
            "messages": [
            ],
            "stream": False
        }

    def add_request_message(self, question: str):
        message = {
            "role": "user",
            "content": question
        }
        self.data["messages"].append(message)

    def add_response_message(self, response: str):
        message = {
            "role": "assistant",
            "content": response
        }
        self.data["messages"].append(message)

Um Anfragen an ein Modell zu senden, muss die chat() Funktion erweitert werden um das Context Handling. Das Resultat ist die chat_with_context() Methode:

  • Zu Beginn ist der Context leer, d.h. er enthält nur den Modellnamen.
  • In einer Schleife wird pro Anfrage die Frage via add_request_message() und die Antwort des Modells via add_response_message() dem Context hinzugefügt.
def chat_with_context(model_name: str, questions: list[str]) -> list[tuple[str, str]]:
    context = Context(model_name)
    responses = []

    for question in questions:
        context.add_request_message(question)
        response_data = _send_request(context.data)
        responses.append((question, _extract_chat_content(response_data)))
        context.add_response_message(_extract_chat_content(response_data))

    return responses

Aufrufen kann man chat_with_context() mit:

responses_chat_with_context = chat_with_context('llama3.2', ['What is the capital of France?', 'And Sweden?'])
for _response in responses_chat_with_context:
    print(_response)

Das Ergebnis ist genau das, was wir erwartet haben. Der Context für diese beiden Anfragen sieht im Detail folgendermassen aus:

  • Bei der ersten Anfrage enthält die Liste Messages nur die Request Message:
{
  "context": {
    "model": "llama3.2",
    "messages": [
      {
        "role": "user",
        "content": "What is the capital of France?"
      }
    ],
    "stream": false
  }
}
  • Bei der zweiten Anfrage sind in der Liste der Message die erste Request Meldung, die Antwort der LLM und die zweite Request Meldung. Zu beachten ist, dass die Anfrage immer die Rolle user hat und die Antwort der LLM immer die Rolle assistant:
{
  "context": {
    "model": "llama3.2",
    "messages": [
      {
        "role": "user",
        "content": "What is the capital of France?"
      },
      {
        "role": "assistant",
        "content": "The capital of France is Paris."
      },
      {
        "role": "user",
        "content": "And Sweden?"
      }
    ],
    "stream": false
  }
}

Programmatische Anfrage in Java mit Context

PS: Der folgende Java Code ist eine Erweiterung des Codes aus dem Ollama REST Artikel.

Wir schauen uns zuerst die Basis Datenstrukturen an und in einem zweiten Schritt dann die Implementierung der chatWithContext() Methode.

Basis Datenstrukturen

Startpunkt für die Umsetzung in Java ist die Klasse Context. Sie enthält den Modellnamen und eine Liste von Messages. Diese Messages enthalten alle Anfragen ans Modell und Antworten des Modells. Basierend auf den Daten dieser Klasse kann ein ChatRequest mit allen Messages erstellt werden für eine Anfrage “mit Context” an ein Modell.

public class Context {
    private final String modelName;
    private final List<Message> data;

    public Context(String modelName) {
        this.modelName = modelName;
        this.data = new ArrayList<>();
    }

    public ChatRequest createChatRequest() {
        return new ChatRequest(modelName, data, false);
    }

    public void addRequestMessage(String question) {
        var newMessage = new Message(USER, question);
        this.data.add(newMessage);
    }

    public void addResponseMessage(String response) {
        var newMessage = new Message(ASSISTANT, response);
        this.data.add(newMessage);
    }
}

Ein paar Details zu Context:

  • Context hat einen Konstruktor, der einen Context mit einem Modellnamen und einer leeren ChatRequest Liste erstellt.
  • addRequestMessage() fügt dem Context eine Message mit Rolle user hinzu.
  • addResponseMessage() fügt dem Context eine Message mit Rolle assistant hinzu.
  • createChatRequest() erstellt ein ChatRequest Objekt mit allen Messages des Contexts. Dieses Objekt kann dann verwendet werden für eine Chat-Anfrage an ein Modell.

Message ist ein Wrapper für Rolle (role) und Inhalt (content) einer Anfrage bzw. Antwort.

public record Message(String role, String content) {}

ChatRequest ist ein Java Record, der alle Informationen für eine Anfrage an ein Modell hat: Modellname, Liste von Messages und ein Flag stream (welches angibt, ob man nur am Schlussresultat oder auch an Zwischenresultaten interessiert ist).

public record ChatRequest(String model,
                          List<Message> messages,
                          boolean stream) {

    public static ChatRequest create(String model, String question) {
        return new ChatRequest(model,
                List.of(new Message(USER, question)),
                false);
    }
}

ChatResponse ist ein Java Record für die Antworten vom Modell.

public record ChatResponse(String model, Message message) {}

Chat Implementation

public List<ChatResponse> chatWithContext(String modelName, List<String> questions) {
    var context = new Context(modelName);
    var responses = new ArrayList<ChatResponse>();

    for (var question : questions) {
        context.addRequestMessage(question);
        var response = sendRequest(context.createChatRequest());
        responses.add(response);
        context.addResponseMessage(response.message().content());
    }
    return responses;
}

chatWithContext() fügt nun alle Teilstücke zusammen:

  • Wie bei der Python Implementierung wird in einer Schleife über alle Anfragen iteriert.
  • Für jede Anfrage wird die Frage mit addRequestMessage() dem Context hinzugefügt.
  • Aus dem Context wird via createChatRequest() ein ChatRequest erstellt. Dieser wird mit sendRequest() ans Modell geschickt und als Antwort kommt eine ChatResponse zurück.
  • Aus der Response wird der Antworttext des Modells extrahiert und via addResponseMessage() dem Context hinzugefügt.

Mit jeder Anfrage wächst somit der Context !!!

Das eigentliche Versenden der Daten an das Modell und das Extrahieren der Antwort in eine ChatResponse passiert in der Hilfsmethode senRequest():

  • Es wird ein HTTP POST Request gemacht an Ollama. Damit das funktioniert, muss Ollama installiert sein und auf Port 11434 hören.
  • Die Daten des POST Request werden in einen ChatRequest gepackt und mithilfe der gson Library in einen JSON String umgewandelt..
  • Das Modell schickt einen JSON String zurück, der wiederum mithilfe von gson in ein Objekt ChatResponse umgewandelt wird. Die eigentliche Textantwort des Modells befindet sich in der Message der ChatResponse. Der Text kann mittels der Methode content() extrahiert werden.
private ChatResponse sendRequest(ChatRequest chatRequest) {
    var url = "http://localhost:11434/api/chat";
    var request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(gson.toJson(chatRequest)))
            .build();
    try {
        var response = client.send(request, HttpResponse.BodyHandlers.ofString());
        if (response.statusCode() != 200) {
            throw new RuntimeException("Error: " + response.statusCode() + ": " + response.body());
        }
        return gson.fromJson(response.body(), ChatResponse.class);
    } catch (IOException | InterruptedException e) {
        throw new RuntimeException(e);
    }
}

LLM Context Einschränkungen

Wir haben gesehen, dass der Context der Ort ist, wo die Chat-Magie mit der LLM stattfindet. Wir können den Context mit zusätzlichen Informationen befüllen und die LLMs sind so schlau, dass sie Informationen aus dem Context für die Beantwortung der Frage einbeziehen. Wenn man nun Domain spezifische Fragen hat, für die die LLM nicht trainiert ist, könnte man diese Informationen via Context der LLM mitgeben … oder?

Hier stossen wir auf zwei Probleme: Grösse und Preis.

Die Grösse des Context ist beschränkt. Je nach LLM und Version ist diese unterschiedlich. Zur Veranschaulichung hier ein paar Beispiele von LLMs und Context Grössen:

  • GPT 4o: 128k Tokens
  • Llama 3.2: 128k Tokens

D.h. aktuell wird es schwierig sein, den Inhalt von ganzen PDFs etc. mitzugeben. Ein möglicher Ansatz zur Lösung dieses Problems könnte RAG sein. Sicherlich ein Thema für einen zukünftigen Artikel.

Für nicht lokal laufende LLMs kann der Preis ein Problem werden. Bei grossen LLMs von Claude, OpenAI etc. bezahlt man für den programmatischen API Zugriff auf ein Modell pro Token. Konkret bezahlt man für die Token der Anfragen und Antworten (d. h. den gesamten Context). Die Preise der Token sind verschieden. Hier die Preise für Claude und OpenAI:

VendorModellInput PriceOutput Price
AnthropicClaude Sonnet 4$3 / 1M tokens$15 / 1M tokens
OpenAIGPT-4.1$2 / 1M tokens$8 / 1M tokens
Preise von Anthropic und OpenAI im Vergleich

Input sind die Daten, die zum Modell geschickt werden (d. h. der gesamte Context).
Output sind die Daten, die das Modell zurückgibt (und dann bei der nächsten Anfrage im Context mit dabei sind).

Mehr Informationen dazu findet man unter:

Fazit

Wir haben gesehen, dass eine LLM keine User-History hat und nur die aktuelle Anfrage kennt. Falls die aktuelle Anfrage noch Informationen von vorigen Anfragen und Antworten haben soll, muss man den Context manuell pflegen. Und der Context hat Einschränkungen bzgl. Grösse und Kosten bei Anfragen via API.

Ollama

Repos

Tokenizer

Preise

Total
0
Shares
Previous Post

JAVAPRO Magazin – JCON & Java 26 Special Edition – Call for Papers

Next Post

Java trifft Zukunft: Wie Quarkus Architektur, Performance und Cloud-Native nahtlos verbindet

Related Posts

Die Zukunft von Containern – Was kommt als Nächstes?

Vielleicht haben Sie schon die Schlagworte gehört, die in aller Munde sind, wenn es um die Zukunft von Containern geht. Seltsame Namen wie "Micro-VMs"… "Unikernel"… "Sandboxes"… Haben Sie sich gefragt, was diese Dinge sind und wie Sie sie nutzen können? Oder sollten Sie diese überhaupt verwenden?
Read More