Aktuelle LLMs wie ChatGPT erzeugen selbstständig Text-Adventures und übernehmen die Rolle des Spielleiters. Mit Python und der Bibliothek Streamlit lässt sich daraus ein Webspiel machen.
“Du befindest dich in einem ausgedehnten Höhlensystem unter der Erde, umgeben von Monstern, auf der Suche nach einem Schatz. Was tust du? Nach links oder rechts gehen?” So oder so ähnlich beginnen viele klassische Text-Adventures aus den Anfangstagen der Home-Computer, bei denen man die Spielfigur mit Texteingaben steuert und der Rechner als Spielleiter ebenfalls mit Texten antwortet. Heutzutage gibt es ChatGPT und andere Large Language Models (LLM), die in die Rolle des Spielleiters und Erzählers schlüpfen können. Immerhin sind die Systeme dafür geschaffen, mit dem Anwender zu kommunizieren.
Das Beispiel-Adventure für diesen Artikel wurde in Python erstellt und verwendet für die Weboberfläche die Bibliothek Streamlit [1]. Während Sie einfach Ihr Python-Programm entwickeln, kümmert sich die Bibliothek um den Rest. Deswegen taucht die Kombination häufig bei Dashboards oder Datenanalysen auf: Dort spielt eine schnelle und innovative Entwicklung die Hauptrolle. Streamlit ermöglicht, eine Python-Anwendung im Internet ausführbar zu machen. Dazu gehört unser Beispiel-Adventure “Ratisbona Dungeons” [2].

Abbildung 1: ChatGPT und andere LLMs jonglieren nicht nur Milliarden Fakten, sondern versuchen sich daneben im kreativen Spinnen von Geschichten.
Einerseits ist es interessant zu sehen, wie gut sich ChatGPT in der Rolle des Erzählenden schlägt. Andererseits dient die in diesem Artikel vorgestellte Einbindung eines LLM in ein Python-Programm zusammen mit einer Weboberfläche als Blaupause für andere, eher seriöse Anwendungen. Das Beispielprogramm konzentriert sich bei der Kommunikation mit dem LLM auf die Schnittstelle von OpenAI, dem Unternehmen hinter ChatGPT. Das soll helfen, keine Verwirrung entstehen zu lassen, denn für die Aufgabe gibt es die unterschiedlichsten Bibliotheken. Außerdem vorteilhaft: Andere Hersteller unterstützen ebenfalls die OpenAI-API. Ollama [3] bindet über dieselbe Schnittstelle sogar kostenfreie Modelle wie LLaMA3 von Meta oder Google Gemma an.
Für Python gibt es nicht die eine Oberflächenbibliothek, wie das bei anderen Programmiersprachen der Fall ist. Streamlit verwendet den Webbrowser als Oberfläche, ist dementsprechend plattformunabhängig und lässt sich auf jedem Rechner einsetzen. Zusätzlich glänzt die Bibliothek damit, dass Sie lediglich ein Python-Programm schreiben, Javascript-Kenntnisse benötigen Sie nicht.
Sie installieren das freie Modul Streamlit über den Aufruf »pip install streamlit« mithilfe des Werkzeugs Pip. Beim Import ins eigene Programm ist es sinnvoll, einen Alias »st« zu definieren, da Python immer wieder Bezug auf dieses Modul nimmt: »import streamlit as st«. Ein Python-Programm mit Streamlit starten Sie nicht einfach mit dem Python-Interpreter, sondern mittels des Befehls »streamlit run app.py«. Danach kümmert sich die Bibliothek darum, dass ein Webdienst zur Verfügung steht, mit dem sich ein Browser per http://localhost:8501 verbinden kann.
Unser Adventure-Spiel umfasst drei Programmteile. Neben dem eigentlichen Spiel- und Hauptprogramm »app.py« (Listing 1) gehören dazu zwei weitere Module: Das erste, »common.py«, enthält alle Funktionen und lässt sich durchaus bei anderen Programmen mit LLMs wiederverwenden. Das zweite, »prompts.py«, enthält die Texte zur Eingabeaufforderung für die Text- und Bildgenerierung.
Listing 1
Adventure-Game-Webapp (app.py)
import streamlit as st
from common import *
TITLE = "ChatAdventure -- Ratisbona Dungeons"
st.session_state.ollama_path = 'http://localhost:11434/v1'
st.session_state.image_model = 'dall-e-3'
st.set_page_config(
page_title=TITLE,
layout="centered",
initial_sidebar_state="auto",
menu_items=None,
)
st.logo("logo512.png")
st.title(TITLE)
if "model" not in st.session_state:
choose_llm()
if "model" in st.session_state:
create_client()
if "client" in st.session_state:
if "messages" not in st.session_state:
st.session_state.messages = [
{"role": "user",
"content": PROMPT}
]
do_sidebar(TITLE)
ask_user()
get_answer()
Oberfläche
Die Oberfläche legen Sie in Streamlit fest, indem Sie Methoden im Programm »app.py« aufrufen. Eine zusätzliche beschreibende Datei gibt es nicht. In Listing 1 platziert Streamlit ein Title-Element im Browser (Zeilen 7 bis 12).
Für die meisten HTML-Eingabeelemente existiert eine passende Methode wie »st.button« für einen Button oder »st.text_input« für Texteingaben. Die Methode »st.set_page_cong« des Moduls »streamlit« bestimmt das grundsätzliche Layout der Anwendung, also das Aussehen des Titels und so weiter. Da Streamlit sich darum kümmert, brauchen Sie sich nicht selbst mit Layout und Styles in HTML oder CSS auseinanderzusetzen.
Zentrale Speicherung
Das Ausführen des Python-Programms durch Streamlit funktioniert ebenfalls weborientiert: Bei einer Aktion mit dem Streamlit-Dienst ruft es das Python-Programm »app.py« erneut auf. Das entspricht dem bei einer Webseite üblichen Reload. Bitte behalten Sie das beim Erstellen des Programms stets im Hinterkopf.
Außerdem sollten Sie eine andere Eigenheit von Webanwendungen beachten: Bei jedem Neuladen und damit erneuten Ausführen der Python-App gehen die Inhalte der globalen Variablen verloren. Python setzt sie daraufhin erneut entsprechend der Abfolge im Programm. Damit Werte den Reload überstehen, legen Sie sie in der zentralen Datenstruktur »st.session_state« ab (Listing 1, ab Zeile 17). Sie lässt sich durch das Programm beliebig erweitern. In unserem Kontext ist es beispielsweise sinnvoll, den Namen des vom Anwender ausgewählten Chat-Modells einmal zu definieren und in »st.session_state« abzulegen.
Die Datenstruktur »st.session_state« verhält sich im Wesentlichen wie ein Python-Dictionary. Der If-Befehl in Zeile 17 fragt ab, ob das Element »model« bereits in der Datenstruktur »st.session_state« vorhanden ist; falls nicht, darf der Anwender ein Chat-Modell in der Funktion »choose_llm« auswählen. Sie setzt dann mit der Zuweisung »st.session_state.model = model« den neuen Wert. Die alternative Syntax sieht so aus: »st.session_state[“model”] = model«.
Führt nun Streamlit das Programm »app.py« erneut aus, gibt es das Modell bereits in »st.session_state«, sodass sich die Funktion »choose_llm« erübrigt.
Sprachmodell auswählen
Zum Auswählen des Chat-Modells zu Beginn des Adventures stehen in der Funktion »choose_llm« Modelle von OpenAI wie GPT-4 und GPT-3.5-turbo sowie lokal durch die Software Ollama ausgeführte Varianten zur Verfügung. Auf Ollama gehen wir später näher ein.
Die Box zum Auswählen des LLM und der Button Weiter sind in einem Formular zusammengefasst (Abbildung 2). Im Programm sehen Sie das an der Python-Struktur »with st.form« (Listing 2). Was eingerückt darunter steht, gehört automatisch zum Formular. Für die Auswahlbox greifen Sie in Streamlit auf die Methode »st.selectbox« mit zwei Parametern zurück, einem beschreibenden Text und der Liste der Auswahlmöglichkeiten. Der Rückgabewert »model« enthält die vom Anwender getroffene Auswahl.
Listing 2
Auswahl des LLMs
def choose_llm():
with st.form("Model"):
model = st.selectbox(
"Bitte wählen Sie das gewünschte LLM aus",
("gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo", "llama3", "gemma2")
)
submitted = st.form_submit_button("Weiter")
if submitted:
st.session_state.model = model
st.rerun()
An dieser Stelle kommt nicht die Standardmethode für Buttons »st.button« zum Zug. Stattdessen gewährleistet die Methode »st.form_submit_button« in Zeile 7, dass der Bezug zum Formular zu erkennen ist. Sobald der Anwender Weiter drückt, liefert die Variable »submitted« den Wert »True« zurück.
Die Funktion setzt die zentrale Variable »st.session_state.model« (Zeile 9) und löst mit »st.rerun« ein erneutes Starten von »app.py« aus. Der Streamlit-Dienst führt das Programm also von vorne aus. Da die Variable »st.session_state.model« bereits gesetzt ist, erscheint die Auswahl des Chat-Modells nicht mehr.
Chat-Client
Um mit OpenAIs ChatGPT per Schnittstelle arbeiten zu können, braucht es für die Abrechnung eine Kennung [4]. Danach können Sie einen Schlüssel (API-Key) erzeugen, der sicherstellt, dass nur autorisierte Anwendungen den Dienst nutzen können – schließlich kostet jeder Aufruf Geld.
Die OpenAI-Schnittstelle für Python installieren Sie wie gewohnt mit dem Werkzeug Pip: »pip install openai«. Das Adventure-Spiel benötigt für die Kommunikation mit dem Dienst von ChatGPT lediglich ein Objekt »client« des Typs »OpenAI« (Listing 3).
Listing 3
Kommunikation mit dem Chatbot
from openai import OpenAI st.session_state.client = OpenAI(base_url='...', api_key='...')
Beim Erzeugen des Objekts »client« geben Sie Ihren API-Key »api_key« und die Stelle »base_url« an, an der ChatGPT im Netz zu finden ist. Da das Adventure-Spiel mit dem OpenAI-Standardserver kommuniziert, können Sie auf den Parameter »base_url« verzichten. Falls der API-Schlüssel von OpenAI nicht vorhanden ist, fordert die Anwendung dazu auf, einen einzugeben. Das stellt sicher, dass ausschließlich autorisierte Benutzer auf die KI-Dienste zugreifen.
Die Funktion »create_client« in Listing 4 generiert im Adventure-Spiel das Objekt »client«. Der Einfachheit halber erlaubt Streamlit, existierende API-Keys über die Direktive »openai_key = “sk-…”« in einer speziellen Datei ».streamlit/secrets.toml« abzulegen. Mit der Datei sollten Sie besonders sorgfältig umgehen: Wer die Schlüssel hat, erzeugt damit Kosten für das Benutzerkonto.
Listing 4
Chatbot-Client erzeugen
def create_client():
if st.session_state.model.startswith("gpt"):
try:
st.session_state.client = OpenAI(
api_key=st.secrets.openai_key
)
except:
with st.form("API key"):
api_key = st.text_input("Bitte API key eingeben")
submitted = st.form_submit_button("Weiter")
if submitted:
st.session_state.client = OpenAI(
api_key=api_key
)
else:
st.session_state.client = OpenAI(
base_url=st.session_state.ollama_path,
api_key='ollama' # required, but unused
)
Falls der Anwender des Spiels sich für ChatGPT als LLM entschieden hat, versucht die Funktion »create_client«, den API-Schlüssel aus der Datei »secret.toml« zu lesen (Zeile 5). Das klappt mit der Streamlit-Datenstruktur »st.secrets«. Technisch ähnelt sie der Datenstruktur »st.session_state«.
Bei einem lokalen Chat-Modell mit der Software Ollama muss das Programm angeben, wo es sich befindet. Ein Schlüssel wäre nicht erforderlich, da es bei lokalen Installationen keinen gibt. Zahlreiche Beispiele im Web füllen ihn trotzdem aus, damit alle Felder einen Inhalt aufweisen.
Lokale Chat-Modelle
Mit der freien Software Ollama führen Sie verschiedene LLMs auf dem eigenen Rechner aus. Ollama unterstützt derzeit die Plattformen Linux, MacOS und Windows mit passenden Installationspaketen.
Für einen einfachen Funktionstest können Sie nach der Installation mit dem Befehl »ollama run llama3.1« direkt mit dem gewünschten Modell chatten. In unserem Fall handelte es sich dabei um LLaMA 3.1. Unter https://ollama.com/library erhalten Sie eine Liste der unterstützten LLMs. Um explizit ein Modell zu laden, beispielsweise Google Gemma, nutzen Sie das Kommando »ollama pull gemma«. Ollama läuft lokal auf dem eigenen Rechner. Die Kommunikation erfolgt normalerweise per HTTP über den Port 3000, das lässt sich aber anpassen:
st.session_state.ollama_path = 'http://localhost:11434/v1'
Der Vorteil des lokalen Chat-Modells liegt auf der Hand: Es entstehen keinerlei Kosten bei IT-Service-Firmen. Achten Sie jedoch darauf, dass Sie über einen Rechner mit geeigneter Ausstattung an Speicher und Rechenleistung verfügen. Sonst warten Sie womöglich ziemlich lange auf eine Antwort.
Bei großen kostenfreien Modellen wie LLaMA oder Gemma waren im Adventure-Spiel bei den Antworten im Vergleich zu kommerziellen Anbietern keine Unterschiede in der Qualität festzustellen.
Die erste Anfrage
Im Wesentlichen steckt hinter dem Adventure-Spiel ein Chatbot. Der Anwender stellt eine Anfrage, das Chat-Modell antwortet darauf. Danach beginnt das Ganze von vorne. Damit das Chat-Modell den kompletten Ablauf des Dialogs kennt, überträgt das Python-Programm bei jeder Kommunikation sämtliche Anfragen des Anwenders und die Antworten darauf.
Alle Anfragen und Antworten speichert das Programm in der Liste »st.session_state.messages« (Listing 5). Damit das Chat-Modell unterscheiden kann, wer was kommuniziert hat, besteht jede Nachricht »message« aus einem Python-Dictionary mit den Attributen »role« und »content«. Dabei zeigt »role« an, ob die Nachricht vom Anwender »user« oder vom Chat-Modell »assistant« stammt. Das Attribut »content« enthält die Anfrage des Anwenders respektive die Antwort des Chat-Modells.
Listing 5
Anfragen und Antworten speichern
st.session_state.messages = [
{"role": "user",
"content": PROMPT}
]
Bei der ersten Anfrage des Anwenders handelt es sich um die vom Programm vordefinierte Konstante »PROMPT«. Sie erklärt dem Chat-Modell, was man von ihm erwartet und wie es sich verhalten soll: “Agiere als Game-Engine, die die Geschichte ‘Ratisbona Dungeons’ kreiert und in ein Text-Adventure-Game verwandelt.” Achten Sie unbedingt darauf, die Beschreibung möglichst konkret zu formulieren.
Daran schließt sich die Beschreibung der ersten Szene an. Sie gibt den grundsätzlichen Inhalt des Adventures vor: “Eröffne das Spiel mit dieser Beschreibung: Willkommen bei ‘Ratisbona Dungeons’. Du lebst seit 10 Jahren in Regensburg, einer Stadt im Osten von Bayern am Zusammenfluss von Donau, Naab und Regen.” Mit etwas Feintuning lässt sich der grobe Plot der Geschichte skizzieren: “Der alte Buchhändler soll als Figur mitspielen. Ein Wolf soll als Begleiter irgendwann in Erscheinung treten. Die Geschichte soll einen Bezug zu Computern und Hackern haben.”
Aus diesen wenigen Vorgaben generiert ChatGPT daraufhin den kompletten Ablauf. Indem Sie die Konstante »PROMPT« verändern, schaffen Sie nach Belieben ein komplett anderes Adventure, beispielsweise ein Weltraumabenteuer auf einem einsamen Planeten.
Antworten abholen
Die Funktion »get_answer« in Listing 6 übernimmt die Kommunikation mit dem Chat-Modell. Sie tritt stets in Aktion, sobald der letzte Eintrag in der Liste »messages« vom Anwender stammt (Zeile 2). Logischerweise erwartet der dann eine Antwort. Hinter dem Streamlit-Steuerelement »st.chat_message« (Zeile 3) verbirgt sich ein Container für die Antwort des LLMs. Dort ist alles zu sehen, was unter dem Konstrukt »with« steht. Manchmal verstreichen einige Augenblicke, bis eine Antwort erscheint. In diesem Zeitraum zeigt Streamlit mit »st.spinner« den berüchtigten Kringel in der Fenstermitte an.
Listing 6
Antwort von ChatGPT lesen
def get_answer():
if st.session_state.messages[-1]["role"] != "assistant":
with st.chat_message("assistant"):
with st.spinner("Loading..."):
stream = st.session_state.client.chat.completions.create(
model=st.session_state.model,
messages=[
{"role": m["role"], "content": m["content"]}
for m in st.session_state.messages
],
stream=True,
max_tokens=MAX_TOKEN
)
response = st.write_stream(stream)
if st.session_state.show_image:
show_image(response)
st.session_state.messages.append({"role": "assistant",
"content": response})
Die Kommunikation mit dem Chat-Modell zum Abruf einer Antwort übernimmt die Methode »chat.completions.create« des OpenAI-Objekts »clients« (Zeilen 4 bis 12). Der erste Parameter »model« entspricht dem Namen des zu verwendenden Chat-Modells, die Liste »messages« enthält den bisherigen Dialog mit dem LLM.
Für »stream« (Zeile 10) gilt: Beim Wert »True« gibt das LLM die Antwort stückchenweise in einem Stream zurück. Bei »False« wartet es, bis die Antwort komplett ist, und liefert sie dann zurück. Hinsichtlich »max_tokens« entspricht der Wert ungefähr der maximalen Anzahl von Silben, die bei der Kommunikation zugelassen sind.
Das Streamlit-Steuerelement »st.write_stream« (Zeile 13) gibt automatisch die Teile der Antwort aus, die das LLM bereits generiert hat. Der Rückgabewert »response« enthält die komplette Antwort, die das Python-Programm zu guter Letzt in die Liste der Nachrichten übernimmt (ab Zeile 18).
Interaktive Anfragen
Um die Eingabe des Anwenders kümmert sich die Funktion »ask_user« (Listing 7). Hinter dem Streamlit-Steuerelement »st.chat_input« in Zeile 3 steckt die von ChatGPT und anderen LLMs bekannte Eingabezeile. Die Eingabe »prompt« fügt der Code dann wieder der Liste »messages« hinzu.
Am Ende gibt die Funktion alle Nachrichten mit der Streamlit-Funktion »st.chat_message« aus (Zeile 10). Sie sorgt dafür, dass die Nachrichten das passende Design bekommen: ein Roboter-Emoji für die Antworten, ein Icon eines Menschen für die Anfrage.
Listing 7
Eingabe des Anwenders
def ask_user():
# -- ask user
if prompt := st.chat_input("Deine Antwort"):
st.session_state.messages.append(
{"role": "user",
"content": prompt}
)
for message in st.session_state.messages[1:]:
with st.chat_message(message["role"]):
st.write(message["content"])
Bilder generieren
Im Spiel kann der Anwender für jede Antwort des LLMs ein passendes Bild (Abbildung 3) erzeugen lassen. Damit wirkt das Adventure interessanter und lebendiger, denn Bilder ziehen den Spieler stärker in die Geschichte. Entsprechende Textbeschreibungen setzt der Webservice DALL-E 3 von OpenAI in Abbildungen um. Aktuell verwendet die Bildgenerierung kein lokales Modell, denn das erfordert mehr Rechenleistung, als durchschnittliche Computer momentan bereitstellen.
Die Funktion »show_image« (Listing 8) lässt im ersten Schritt eine Beschreibung der aktuellen Situation von ChatGPT generieren. Die verwendet sie im zweiten Schritt als Vorgabe zum Erzeugen eines Bilds mit dem Service DALL-E. Die Methode »images.generate« des Objekts »client« (ab Zeile 11) liefert das Bild.
Listing 8
Bild zur aktuellen Spielsituation
def show_image(prompt):
with st.spinner("Loading..."):
description = st.session_state.client.chat.completions.create(
model=st.session_state.model,
messages=[
{"role": 'user',
"content": PICTURE_PROMPT.format(text=prompt)}
]
)
picture = st.session_state.client.images.generate(
model=st.session_state.image_model,
prompt="{0}".format(description.choices[0].message.content
),
size="1024x1024",
quality="standard",
n=1
)
image_url = picture.data[0].url
st.image(image_url, width=360)
Der Parameter »model« enthält in unserem Fall den Wert »dall-e-3«. Der Parameter »prompt« beschreibt das gewünschte Bild; »size« bezieht sich auf dessen Größe (hier 1024 x 1024 Pixel). Welche Qualität das Bild besitzen soll, definiert »quality«. Die Variable »n« gibt die Anzahl der zu generierenden Bilder vor.
Allerdings ist die Bildgenerierung nicht immer von Erfolg gekrönt. Eine mögliche Fehlermeldung verrät, wo es unter Umständen hakt: “Your request was rejected as a result of our safety system. Image descriptions generated from your prompt may contain text that is not allowed by our safety system.”
Dabei bezieht sich das Sicherheitssystem keineswegs auf technische Aspekte, sondern auf potenziell “gefährliche” Inhalte. Gerade in Adventure-Spielen kommen häufig Waffen wie Schwerter, Dolche oder Armbrüste vor. Doch es braucht nicht einmal den eindeutigen Gewaltbezug; bereits die “nackte Statue eines Stiers” ist mitunter zu viel für das Sicherheitssystem.
TIPP
Das komplette Adventure-Spiel sowie einige Erweiterungen finden Sie auf Github [5].
Fazit und Ausblick
Das Adventure-Programm demonstriert, wie leicht es fällt, mit einem Chat-Modell in Verbindung mit Streamlit Anwendungen für das Web selbst zu entwickeln. Die Kombination aus Text- und Bildgenerierung eröffnet neue Möglichkeiten für interaktive Geschichten und Anwendungen.
Auf dem nächsten Level wäre es wünschenswert, dass das Chat-Modell unterschiedliche Figuren im Spiel selbstständig steuert oder den kompletten Inhalt generiert. Wie das gelingt, zeigt der zweite Teil dieser Artikelserie in der nächsten Ausgabe. (csi)
Infos
- Streamlit: https://streamlit.io
- “Ratisbona Dungeons”: https://ratisbona-adventure.streamlit.app
- Ollama: https://ollama.com
- API-Kennung für OpenAI: https://openai.com/index/openai-api/
- “Ratisbona Dungeons” auf Github: https://github.com/gkvoelkl/python-llm-ratisbona-adventure








