Damit LLMs ihre Aufgaben möglichst zielgerichtet erfüllen ist es manchmal sinnvoll Eingabe- und Ausgabebedingungen zu definieren und zu prüfen. Die Quarkus-Integration von LangChain4j bietet genau dafür schon länger die Guardrails-Funktionalität. Nun ist diese Funktionalität mit Version 1.1.0 auch in der Upstream-Bibliothek verfügbar und kann damit auch z.B. in JSE-, JEE- oder Spring-Anwendungen verwendet werden. Wir werfen in diesem Beitrag einen Blick auf das Konzept und lernen die Quarkus-Implementierung an einer kleinen Beispielanwendung kennen. Die Upstream-Implementierung nutzt prinzipiell die gleichen Schnittstellen und häufig ist zum Übertragen nichts weiter als ein Anpassen der import-Anweisung notwendig.
Eingabebedingungen festlegen
Unsere Beispielanwendung verwendet ein LLM um Java-spezifische Fragen zu beantworten. In diesem Fall verwenden wir der Einfachheit halber ein allgemein trainiertes LLM das vermutlich auch auf Fragen zu anderen Programmiersprachen sinnvoll antworten könnte. Wenn wir aber ein spezifisch trainiertes LLM verwenden oder zu einem spezifischen Thema Kontext bereitstellen, ist es sinnvoll die Anfragen auch auf die relevante Domäne zu beschränken. Andernfalls kann das zu verstärkten „Halluzinationen“ und faktisch falschen Aussagen führen. Hier nutzen wir ein InputGuardrail
um die Anfragen auf das Thema Java zu beschränken. Weitere mögliche Anwendungsfälle könnten die Einschränkung auf eine bestimmte Sprache (Deutsch, …), das automatische Zensieren von personenbezogenen Daten oder das Erkennen von böswilligen Anfragen sein.
Fangen wir mit einer simplen Implementierung an. Das Interface InputGuardrail
sieht die Methode validate()
vor, die im einfachsten Fall eine UserMessage
entgegen nimmt. Der Rückgabewert kann eine von drei Ausprägungen annehmen:
success
: Bei einem erfolgreichem Durchlaufen allerInputGuardrail
s wird die Anfrage an das LLM weitergeleitet. ÜbersuccessWith(String)
kann die Anfrage vor dem Weiterleiten noch angepasst werden um z.B. bestimmte Inhalte zu entfernen.failure
: Ein Fehlschlagen führt dazu, dass die Anfrage nicht an das LLM weitergeleitet wird. In diesem Zustand werden allerdings trotzdem weitereInputGuardrail
s durchlaufen um mögliche weitere Probleme festzustellen und zu sammeln.fatal
: Ein fatales Fehlschlagen führt zum direkten Abbruch und es werden auch keine weiterenInputGuardrail
s durchlaufen.
public class SimpleJavaQuestionDetectionInputGuardrail implements InputGuardrail {
public InputGuardrailResult validate(UserMessage userMessage) {
if (userMessage.hasSingleText()) {
String text = userMessage.singleText().toLowerCase();
return switch (text) {
case String s when s.contains("java") -> success();
case String s when s.contains("python") -> failure("Python questions are not supported");
case String s when s.contains("javascript") -> failure("Javascript questions are not supported");
default -> failure("Only java questions are supported");
};
} else {
return failure("No single text prompt found.");
}
}
}
src/main/java/de/gedoplan/showcase/SimpleJavaQuestionDetectionInputGuardrail.java
In unserem AiService
können wir die InputGuardrail
s einfach über die Annotation @InputGuardrails
aktivieren. Die Annotation kann auch eine Liste von Klassen entgegennehmen.
@RegisterAiService
public interface JavaQuestionsAiService {
@InputGuardrails(SimpleJavaQuestionDetectionInputGuardrail.class)
@SystemMessage("You are a programming assistant for the programming language Java.")
String chat(@UserMessage String question);
}
src/main/java/de/gedoplan/showcase/JavaQuestionsAiService.java
Wenn wir uns nun ein paar mögliche Anfragen anschauen, dann fällt relativ schnell auf, dass dieses einfache Implementierung noch nicht ausreicht.
1. Can you give me an example for the syntax of lambda expressions in Java?
2. Can you give me an example for the syntax of lambda expressions in Python?
3. What is new in JDK 21?
4. What is the weather like in Java, Indonesia?
Die ersten beiden Anfragen werden noch richtig eingeordnet, aber die dritte Anfrage wird trotz des klaren Java-Bezugs abgelehnt. Die vierte Anfrage wird durchgewunken, dabei hat sie nichtmal etwas mit Programmierung zutun.
Eine etwas komplexere Eingabebehandlung könnten wir zum Beispiel mit einem zweiten AiService
umsetzen. Die dahinterliegende LLM-Konfiguration kann von der eben verwendeten abweichen. Die Konfiguration können wir unter dem Namen topicDetectionModel
in den properties festlegen. Wir verwenden hier das Structured Output Feature von LangChain4j, das wir uns hier bereits angesehen haben.
@RegisterAiService(modelName = "topicDetectionModel")
public interface TopicDetectionAiService {
@UserMessage("""
Determine if the given prompt is a programming question.
If yes determine the programming language it relates to.
The prompt content is marked with '---':
---
{{userPrompt}}
---""")
TopicDetectionResult detectPromptInjection(String userPrompt);
}
src/main/java/de/gedoplan/showcase/TopicDetectionAiService.java
public record TopicDetectionResult (
@JsonProperty(required = true)
@Description("Is the question related to programming?")
boolean programmingQuestion,
@Description("Required if programmingQuestion is true. Which programming language does the question relate to?")
String programmingLanguage
) {}
src/main/java/de/gedoplan/showcase/TopicDetectionResult.java
Auch unser erweitertes Guardrail muss entsprechend angepasst werden und den TopicDetectionAiService
verwenden.
public class AiJavaQuestionsInputGuardrail implements InputGuardrail {
@Inject
TopicDetectionAiService topicDetectionAiService;
public InputGuardrailResult validate(UserMessage userMessage) {
if (userMessage.hasSingleText()) {
TopicDetectionResult result = topicDetectionAiService.detectPromptInjection(userMessage.singleText());
if (!result.programmingQuestion()) {
return failure("Only programming questions are supported");
} else {
if (result.programmingLanguage() == null) failure("Only Java questions are supported");
return switch (result.programmingLanguage().toLowerCase()) {
case "java" -> success();
case "python" -> failure("Python questions are not supported");
case "javascript" -> failure("Javascript questions are not supported");
default -> failure("Only java questions are supported");
};
}
} else {
return failure("No single text prompt found.");
}
}
}
Damit werden nun zumindest die 4 oben stehenden Beispiele richtig zugeordnet.
Nun zur Ausgabeüberprüfung
Auch für die Ausgabe können wir Regeln definieren, die erfüllt sein müssen, damit ein Ergebnis zurückgeliefert wird. Dafür ist das Interface OutputGuardrail
vorgesehen, welches analog zur Eingabevariante eine validate()
Methode vorsieht, die aber hier im einfachsten Fall ein AiMessage
Objekt statt einer UserMessage
verarbeitet. Auch hier nehmen die Rückgabewerte die oben für die Eingabevariante beschriebenen Ausprägungen an – mit einem Zusatz: Mithilfe von retry
und reprompt
können Anfragen automatisch zur Nachbesserung an das LLM zurückgegeben werden. Wie häufig dieser Vorgang maximal ausgeführt wird, kann in Quarkus über eine Property konfiguriert werden. In der Upstream-Bibliothek ist das beim Aktivieren der Guardrails im AiService
möglich.
Für das Structured Output Feature gibt es bereits eine vorimplementierte Guardrail AbstractJsonExtractorOutputGuardrail
, die wir auch hier anwenden können um die Wahrscheinlichkeit einer strukturell korrekten Antwort zu erhöhen. Wenn das LLM neben einer validen JSON-Struktur noch weiteren Text mitliefert wird zunächst versucht den überflüssigen Inhalt zu kürzen. Führt das nicht zum Erfolg wird das LLM gebeten die Ausgabe nochmal zu überprüfen und korrekt zu formatieren. Erst nach einem erfolgreichen Durchlaufen der Guardrail wird die Ausgabe zurückgegeben.
Da in der Guardrail für den Test der JSON-Struktur der konkrete Rückgabe-Typ zur Laufzeit bekannt sein muss, kann die vordefinierte Guardrail nicht einfach über eine Typenvariable generisch konfiguriert werden. Stattdessen müssen wir in der Quarkus-Integration aktuell eine eigene Implementierung der abstrakten Basisklasse bereitstellen und darin die Typinformation hinterlegen. Die Upstream-Implementierung nutzt zum Festlegen der Typinformation zur Laufzeit einen Konstruktoraufruf.
public class TopicDetectionJsonOutputGuardrail extends AbstractJsonExtractorOutputGuardrail {
@Override
protected Class<?> getOutputClass() {
return TopicDetectionResult.class;
}
}
Diese Guardrail können wir nun im TopicDetectionAiService
aktivieren.
public interface TopicDetectionAiService {
[...]
@OutputGuardrails(TopicDetectionJsonOutputGuardrail.class)
TopicDetectionResult detectPromptInjection(String userPrompt);
}
Für die Quarkus-Integration steht mit Release der neuen Upstream-Implementierung nun eine Integration der Neuerungen an. Es werden sich in dem Zuge vermutlich zumindest einige Paketdefinitionen ändern um auch hier zukünftig wenn möglich die Upstream-Annotationen und Klassen zu verwenden.
Einfache dynamische Ausgabeanpassung
Wenn ich garnicht prüfen möchte ob die Ausgabe valide ist, aber die Ausgabe möglicherweise dynamisch erweitern oder anderweitig verändern möchte, wäre das prinzipiell mit OutputGuardrail#successWith
bereits möglich. In der Quarkus-Integration gibt es allerdings auch die Möglichkeit einen AiResponseAugmenter
zu definieren. Trotz des Wortes „Augment“ hat das nicht direkt etwas mit dem Thema Retrieval Augmented Generation (RAG) zu tun (zum Blog-Beitrag). Das „Augment“ bezieht sich hier auf die Antwort des LLM und nicht die Anfrage. Nichtsdestotrotz kann mit einem AiResponseAugmenter
die Ausgabe z.B. derart erweitert werden, dass tatsächlich verwendete Quellen, die als Kontext zur Anfrage hinzugefügt und letztlich in der Antwort verwendet wurden, in der Antwort zusätzlich verlinkt oder verdeutlicht werden. Das Ziel wäre hier die Erklärbarkeit und Nachvollziehbarkeit von Antworten zu erhöhen.
In unserem Fall liefert das konfigurierte LLM gemma3n:e2b
leider Antworten mit mehreren nicht notwendigen Zeilenumbrüchen am Ende, die bei einer einfachen Ausgabe der Antwort die Formatierung stören. Wir definieren deshalb einen TrimResponseAugmenter
der Whitespaces am Ende der Antwort abschneidet.
public class TrimResponseAugmenter implements AiResponseAugmenter<String> {
@Override
public String augment(String response, ResponseAugmenterParams params) {
return response.stripTrailing();
}
}
src/main/java/de/gedoplan/showcase/TrimResponseAugmenter.java
Im AiService
kann der AiResponseAugmenter
dann über die Annotation @ResponseAugmenter(TrimResponseAugmenter.class)
aktiviert werden.
Das ganze Beispielprojekt gibt es wie immer auf GitHub zum Ausprobieren.