Parallele Chains, LLM Context und Tools mit LangChain

Im letzen Artikel haben wir einen ersten Blick auf LangChain geworfen. In diesem Artikel gehen wir nun einen Schritt weiter. Zunächst schauen wir uns an, wie man mehrere Chains parallel ausführt und deren Ergebnisse kombiniert. Danach zeigen wir, wie man den LLM Context mit LangChain verwaltet — sowohl ohne als auch mit Tools. Abschliessend fügen wir alles zu einem einfachen Chatbot mit Benutzeroberfläche zusammen.
Parallele Chains
Im letzten Artikel haben wir gesehen, wie sich ein Prompt Template, ein Modell und ein Output Parser zu einer Chain (Kette) verbinden lassen. Doch LangChain bietet noch mehr: Wir können mehrere Chains parallel ausführen und ihre Ergebnisse anschliessend kombinieren und weiterverarbeiten.
Ein Beispiel in LangChain
Ein konkretes Beispiel verdeutlicht das Prinzip gut: Wir fragen drei verschiedene LLMs – Claude, OpenAI und Gemini – gleichzeitig, welche Libraries für den Zugriff auf LLMs am beliebtesten sind. Danach führen wir die einzelnen Antworten in einer übersichtlichen Markdown-Tabelle zusammen.
Ablauf im Überblick
Prompt ──┬──> Claude ──> Parser ──┐
├──> OpenAI ──> Parser ──┼──> RunnableParallel ──> Consolidation Prompt ──> Claude ──> .md file
└──> Gemini ──> Parser ──┘
Welche Schritte führen uns zum Ziel?
- Zuerst definieren wir die drei LLMs/Modelle, ein Prompt Template und einen Output Parser.
- Dann erstellen wir pro LLM eine Chain aus Prompt Template, Modell und Parser.
- Anschliessend führen wir alle drei Chains mithilfe eines
RunnableParallel-Objekts parallel aus. - Zum Schluss konsolidieren wir die Ergebnisse und schicken sie in einem weiteren Request an Claude – mit der Anweisung, die Daten in einer Markdown-Tabelle mit je einer Spalte pro LLM-Anbieter zusammenzufassen.
Alle Schritte im Detail
Schritt 1 – Strukturiertes Ausgabeschema definieren
Mit Pydantic legen wir exakt fest, wie die Antwort aussehen soll: eine Liste von LibraryOutput-Objekten, jedes mit Name, Anbieter, URL, Programmiersprache und Version. Dadurch zwingen wir alle drei LLMs, die Daten im gleichen Format zurückzugeben – anstatt Freitext zu liefern.
Da wir die Datenstrukturen bereits im letzten Artikel für den Pydantic Output Parser erstellt haben, können wir sie hier direkt wiederverwenden:
class LibraryOutput(BaseModel):
name: str = Field(description="name of a library")
provider: str = Field(description="provider of the library")
url: str = Field(description="URL of the library")
language: str = Field(description="programming language of the library")
version: str = Field(description="version of the library")
class LibrariesOutput(BaseModel):
libraries: list[LibraryOutput] = Field(description="list of libraries")
Schritt 2 – Prompt-, Modell- und Parser-Pipeline aufbauen
Wir definieren drei Komponenten: die Modelle, einen Output Parser und ein Prompt Template.
- Modelle (LLMs): Wir verwenden je ein Modell für Claude (
claude-sonnet-4-5), OpenAI (gpt-4o-mini) und Google (gemini-2.5-flash-lite).
MODEL_CLAUDE = "claude-sonnet-4-5-20250929"
llm_claude = ChatAnthropic(
model_name=MODEL_CLAUDE,
temperature=0.3,
verbose=True
)
MODEL_OPENAI = "gpt-4o-mini"
llm_openai = ChatOpenAI(
model_name=MODEL_OPENAI,
temperature=0.3,
verbose=True
)
MODEL_GOOGLE = "gemini-2.5-flash-lite"
chat_model = ChatGoogleGenerativeAI(
model=MODEL_GOOGLE,
temperature=0.3,
verbose=True
)
PydanticOutputParser: Wir verknüpfen ihn mit den zuvor definiertenLibraryOutput-Datenstrukturen.
parser = PydanticOutputParser(pydantic_object=LibrariesOutput)
ChatPromptTemplate: Wir befüllen es mit der eigentlichen Frage sowie den Formatierungsanweisungen desPydanticOutputParser. Diese Anweisungen legen das erwartete JSON-Format für alle LLMs präzise fest.
prompt_template = ChatPromptTemplate.from_messages(messages=messages).partial(format_instructions=parser.get_format_instructions())
Schritt 3 – Alle drei LLMs parallel ausführen
Hier setzen wir die oben definierten Bausteine – Modelle, Parser und Prompt – konkret zusammen:
- Wir geben jedem LLM eine eigene Chain nach dem Schema:
Prompt → LLM → Parser. - Dann verpacken wir alle Chains in ein
RunnableParallel-Objekt und führen sie gleichzeitig aus. Das ist deutlich schneller als eine sequenzielle Verarbeitung.
Als Ergebnis erhalten wir ein Dictionary mit den Keys 'claude', 'openai' und 'google'.
claude_chain = prompt_template | llm_claude | parser
openai_chain = prompt_template | llm_openai | parser
google_chain = prompt_template | chat_model | parser
map_chain = RunnableParallel(
claude=claude_chain,
openai=openai_chain,
google=google_chain
)
inputs = {
"programming_language": PROGRAMMING_LANGUAGE,
"library_count": NUMBER_OF_LIBRARIES
}
result: dict[str, LibrariesOutput] = map_chain.invoke(input=inputs)
result_as_string = ""
for llm_name, llm_result in result.items():
result_as_string += to_string(llm_name, llm_result) + "\n"
Schritt 4 – Resultate mit einem zweiten Claude-Aufruf konsolidieren
Nun wandeln wir die Ergebnisse aller drei LLMs in einen String um und speisen sie in einen zweiten Prompt ein. Darin bitten wir Claude, alles in einer übersichtlichen Markdown-Tabelle zusammenzufassen. Schliesslich schreiben wir die Ausgabe in eine .md-Datei.
template_consolidate_responses = """
The following data represents the responses of several LLMs to the question:
What are the most popular libraries in {programming_language} for accessing LLMs?
Can you summarize these providers' responses in one table, with a column for each LLM provider?.
{responses}
"""
prompt_consolidate_responses = ChatPromptTemplate.from_template(template_consolidate_responses)
output_parser_consolidate_responses = StrOutputParser()
chain_consolidate_responses = prompt_consolidate_responses | llm_claude | output_parser_consolidate_responses
response_consolidated = chain_consolidate_responses.invoke({
"programming_language": PROGRAMMING_LANGUAGE,
"responses": result_as_string
})
tools.write_data_to_file(response_consolidated, f"libraries_{PROGRAMMING_LANGUAGE.lower()}.md")
Bei meinen Tests habe ich folgende Tabelle erhalten:

Ein paar interessante Beobachtungen:
- Alle drei LLMs schlagen LangChain und Transformers vor.
- Ausserdem empfiehlt erwartungsgemäss jeder LLM-Hersteller seine eigene Library.
- Interessanterweise unterscheiden sich die angegebenen Versionsnummern je nach Modell – was vermutlich daran liegt, dass die Modelle zu unterschiedlichen Zeitpunkten trainiert wurden.
PS: Für die Konsolidierung haben wir Claude verwendet – selbstverständlich können wir dafür aber auch jedes andere LLM (OpenAI, Google etc.) einsetzen.
Vollständiger Code mit LangChain
Den vollständige Code für das Beispiel mit den Parallelen Chains ist im GitHub Repo zum Artikel zu finden: 4_chain_parallel.py
LLM Context mit LangChain
LLMs haben out-of-the-box kein Gedächtnis (siehe Artikel LLM Context). Wenn wir also eine Anfrage schicken, die sich auf eine frühere bezieht, müssen wir den Kontext selbst verwalten — und genau dasselbe gilt auch für LangChain.
Ausgangssituation
Wir starten mit einem Beispiel einer chat()-Funktion, die folgende Bausteine kombiniert:
- ein Modell (llama3.1)
- ein OutputParser
- ein Prompt Template für die Anfrage
- eine Chain, die mittels
invoke()die Anfrage an das LLM schickt
MODEL = "llama3.1"
def chat(question: str) -> str:
llm = ChatOllama(model=MODEL)
output_parser = StrOutputParser()
prompt = ChatPromptTemplate.from_template(question)
chain = prompt | llm | output_parser
return chain.invoke({})
print("---- First Question ----")
response = chat("What is the capital of France.")
print(response)
print("\n\n---- Second Question ----")
response = chat("And Sweden?")
print(response)
Das funktioniert gut, solange man unabhängige Einzelanfragen stellt. Sobald aber eine Anfrage auf eine frühere Bezug nimmt, erkennt das LLM den Gesprächsablauf nicht und behandelt jede Frage isoliert.
Der obige Code liefert für die beiden Fragen “What is the capital of France?” und “And Sweden?” folgenden Output:
---- First Question ---- The capital of France is Paris. ---- Second Question ---- You're asking about the topic of Sweden! What would you like to know about Sweden? ...
LangChain und Context
Eine einfache Lösung besteht darin, den LLM-Kontext manuell zu pflegen — also die Anfragen und Antworten selbst zu verwalten.
chat_history: list[BaseMessage] = []
def chat(user_message: str) -> str:
llm = ChatOllama(model="llama3.1")
chat_history.append(HumanMessage(content=user_message))
response = llm.invoke(chat_history)
chat_history.append(AIMessage(content=response.content))
return response.content
if __name__ == "__main__":
print("---- First Question ----")
print(chat("What is the capital of France?"))
print("\n\n---- Second Question ----")
print(chat("And Sweden?"))
Dazu benutzen wir zwei Nachrichtentypen:
- HumanMessage — enthält die Anfrage des Benutzers
- AIMessage — enthält die Antwort der LLM
chat_history = [] initialisiert eine leere Liste, die den Gesprächsverlauf als Abfolge von Nachrichten speichert. Die chat-Funktion verarbeitet dann einen einzelnen Gesprächsdurchlauf:
chat_history.append(HumanMessage(...))fügt die Benutzernachricht zum Verlauf hinzu.llm.invoke(chat_history)sendet den gesamten Gesprächsverlauf an das LLM, sodass das Modell nicht nur die letzte, sondern alle vorherigen Nachrichten kennt.chat_history.append(AIMessage(...))speichert die Modellantwort, damit zukünftige Durchläufe den vollständigen Kontext kennen.
Damit liefern die Anfragen das erwartete Ergebnis:
---- First Question ---- The capital of France is Paris. ---- Second Question ---- The capital of Sweden is Stockholm.
LangChain mit Chain und Context
Der bisherige Code ist kompakt und funktioniert gut — allerdings rufen wir invoke() direkt auf dem Modell auf und nicht mehr auf einer Chain. Es gibt jedoch eine Möglichkeit, beide Ansätze zu kombinieren: Anfragen laufen über eine Chain und profitieren gleichzeitig vom LLM-Kontext-Management.
MODEL = "llama3.1"
chat_history: list[BaseMessage] = []
def chat(question: str) -> str:
chain = _create_chain()
response = chain.invoke({
"chat_history": chat_history,
"question": question,
})
chat_history.append(HumanMessage(content=question))
chat_history.append(AIMessage(content=response))
return response
def _create_chain():
prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant."),
MessagesPlaceholder(variable_name="chat_history"), # history slot
("human", "{question}"), # current question
])
llm = ChatOllama(model=MODEL)
output_parser = StrOutputParser()
chain = prompt | llm | output_parser
return chain
if __name__ == "__main__":
print("---- First Question ----")
print(chat("What is the capital of France?"))
print("\n\n---- Second Question ----")
print(chat("And Sweden?"))
Der Schlüssel dazu ist MessagesPlaceholder: Es dient als Verbindungspunkt und fügt die vollständige chat_history-Liste (mit HumanMessage/AIMessage-Objekten) direkt an der richtigen Stelle in den Prompt ein, sodass das Modell die gesamte Konversation sieht. Die Chain selbst bleibt dabei sauber — prompt | llm | output_parser bleibt unverändert, und die Historie ist lediglich eine weitere Eingabevariable, die an chain.invoke() übergeben wird.
Nach jedem Durchlauf aktualisieren wir die Historie, indem wir sowohl die Benutzerfrage als auch die KI-Antwort anhängen — damit hat der nächste Aufruf wieder den vollständigen Kontext.
Die Ausgaben sind wie erwartet:
---- First Question ---- The capital of France is Paris. ---- Second Question ---- The capital of Sweden is Stockholm.
Tipp: Die Länge der History lässt sich begrenzen. Wird sie zu lang, reicht es, nur die letzten MAX_HISTORY-Nachrichten in die Anfrage zu packen — ein sogenanntes Sliding Window. Im Code sieht das dann so aus: chat_history[-MAX_HISTORY:]
MODEL = "llama3.1"
chat_history = []
MAX_HISTORY = 4 # keep last 4 messages (2 turns)
def chat_with_sliding_window(question: str, debug: bool = True) -> str:
chain = _create_chain()
response = chain.invoke({
"chat_history": chat_history[-MAX_HISTORY:], # sliding window
"question": question,
})
chat_history.append(HumanMessage(content=question))
chat_history.append(AIMessage(content=response))
return response
def _create_chain():
prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful assistant."),
MessagesPlaceholder(variable_name="chat_history"), # history slot
("human", "{question}"), # current question
])
llm = ChatOllama(model=MODEL)
output_parser = StrOutputParser()
chain = prompt | llm | output_parser
return chain
Vollständiger Code mit LangChain
Den vollständige Code für die LLM Context Beispiele ist im GitHub Repo zum Artikel zu finden:
- chat() ohne LLM Context
- chat() mit LLM Context
- chat() mit LLM Context und Chain
- chat() mit LLM Context, Chain und Sliding Window
LLM Context und Tools
Rückblick
Aus dem Artikel LLM Context und Tools wissen wir, dass der User einem LLM eine Liste von Funktionsbeschreibungen mitgeben kann. Das LLM entscheidet dann selbst, ob diese Funktionen für eine Antwort nützlich sein könnten. Falls ja, teilt das LLM dem User in der Antwort mit, welche Funktionen mit welchen Parameterwerten aufgerufen werden sollen. Der User ruft daraufhin die Funktionen lokal mit den gewünschten Parameterwerten auf und gibt die Resultate beim nächsten LLM-Aufruf mit. Das hilft der LLM, qualitativ bessere Antworten zu generieren.
Wir haben bereits gesehen, wie man das alleine mit Python-Bordmitteln umsetzt. Nun schauen wir uns an, wie man dasselbe mit LangChain eleganter lösen kann.
LangChain und LLM-Kontext mit Tools
Stell dir vor, du planst einen Ausflug in eine europäische Stadt und fragst dich, was du einpacken sollst. Genau dieses Szenario nutzen wir als Beispiel: Wir stellen einem LLM aktuelle Wetterdaten verschiedener Städte zur Verfügung – und lassen es daraus eine Packliste ableiten.
Um dieses Beispiel mit LangChain umzusetzen, schauen wir uns folgende Punkte im Detail an:
- Wetterdaten als Kontext – Wie können wir einem LLM Wetterdaten bereitstellen? Dazu verwenden wir LangChain-Tools.
- Tools in die
chat()-Funktion integrieren – Wie lässt sich eine bestehendechat()-Funktion um Tool-Funktionalität erweitern? - Das Aufwärmphasen-Problem – Welches Problem kann dabei auftreten, und wie lässt es sich beheben?
Abschliessend werfen wir einen Blick zurück und fragen uns: Was lässt sich mit den Möglichkeiten rund um den LLM-Kontext – mit und ohne Tools – alles umsetzen?
LangChain-Tools
Wir schreiben eine Funktion get_forecast, die für eine Stadt Wetterdaten als einfachen String zurückgibt. Um das Beispiel übersichtlich zu halten, verwenden wir ein fest kodiertes Dictionary mit Wetterdaten für 5 europäische Städte. In einer echten Anwendung würde diese Funktion hingegen eine Live-Wetter-API aufrufen.
@tool ist ein Decorator aus LangChain, der eine normale Python-Funktion in ein LangChain-Tool umwandelt. Damit teilen wir dem LLM mit, welche Funktionen wir bereitstellen und wie man sie aufruft. Das LLM entscheidet jedoch selbst, ob sie diese Funktionen für die Beantwortung einer Anfrage nutzen möchte.
Zwei wichtige Punkte sind dabei zu beachten:
- Der Docstring ist entscheidend, weil er dem LLM erklärt, wofür das Tool gedacht ist und wann es verwendet werden soll. Ein schlechter oder fehlender Docstring führt dazu, dass das LLM das Tool falsch oder gar nicht einsetzt.
- Typhinweise helfen dem LLM, die Parameter besser zu verstehen.
@tool
def get_forecast(city: str) -> str:
"""Get weather forecast for a specified city.
Args:
city: The name of the city to get the forecast for
Returns:
Weather forecast information as a string
"""
forecasts = {
"Paris": "Temperature: 28°C, Conditions: Sunny, Wind: 10 km/h",
"Stockholm": "Temperature: 12°C, Conditions: Rainy, Wind: 15 km/h",
"London": "Temperature: 15°C, Conditions: Rain, Wind: 8 km/h",
"Berlin": "Temperature: 16°C, Conditions: Partly cloudy, Wind: 12 km/h",
"Madrid": "Temperature: 24°C, Conditions: Clear skies, Wind: 5 km/h"
}
forecast = forecasts.get(city, f"Sorry, no forecast available for {city}")
print(f' weather forcast for {city}: {forecast}')
return forecast
Erweitrung der Chat-Funktion
Dies ist die zentrale Chat-Schleife. Der Ablauf gliedert sich in folgende Schritte:
Schritt 1 – Setup
llm = ChatOllama(model=MODEL, temperature=0)
llm_with_tools = llm.bind_tools([get_forecast])
Ein lokales llama3.1-Modell wird über Ollama geladen und anschließend mit dem Forecast-Tool „verknüpft” — das teilt dem LLM mit, dass get_forecast existiert und wie es aufgerufen werden kann.
Schritt 2 – Nachricht senden
chat_history.append(HumanMessage(content=user_message))
response = llm_with_tools.invoke(chat_history)
chat_history.append(response)
Die Nachricht des Benutzers wird zur History hinzugefügt, dann sendet der Code die gesamte History an das LLM. Da wir die vollständige History mitsenden, merkt sich das LLM den Gesprächsverlauf für mehrteilige Konversationen. Die Antwort des LLM fügen wir ebenfalls zur History hinzu.
Schritt 3 – Tool-Calling-Schleife
while response.tool_calls:
# Tools ausführen, Ergebnisse anhängen, LLM erneut aufrufen
Entscheidet das LLM, dass es Wetterdaten benötigt, gibt es eine strukturierte tool_calls-Anfrage zurück — anstatt einer einfachen Textantwort. Der Code führt daraufhin das Tool aus, hängt das Ergebnis als ToolMessage an und ruft das LLM erneut auf, diesmal mit dem Tool-Ergebnis im Kontext. Das wiederholt sich so lange, bis das LLM eine abschliessende Klartextantwort liefert.
Der gesamte Ablauf sieht so aus:
Benutzer: "Was für Kleidung brauch ich für eine Kurzreise nach Paris?"
↓
LLM erkennt, dass es Daten braucht → gibt tool_call zurück
↓
User Code führt get_forecast("Paris") lokal aus → erhält Ergebnis
↓
Ergebnis wird zur History hinzugefügt → LLM wird erneut aufgerufen
↓
LLM antwortet: "In Paris sind es 28°C und es ist sonnig! ……"
Im Code sieht das ganze Beispiel dann so aus:
chat_history = []
def chat_with_tools(user_message: str) -> str:
llm = ChatOllama(model=MODEL, temperature=0)
llm_with_tools = llm.bind_tools([get_forecast])
chat_history.append(HumanMessage(content=user_message))
response = llm_with_tools.invoke(chat_history)
chat_history.append(response)
# check if the model wants to use a tool
while response.tool_calls:
# execute each tool call
for tool_call in response.tool_calls:
tool_name = tool_call["name"]
tool_args = tool_call["args"]
print(f"\n🔧 Calling tool: {tool_name} with args: {tool_args}")
# execute the tool
if tool_name == "get_forecast":
tool_result = get_forecast.invoke(tool_args)
# add tool result to history
chat_history.append(
ToolMessage(
content=str(tool_result),
tool_call_id=tool_call["id"]
)
)
# get final response from LLM with tool results
response = llm_with_tools.invoke(chat_history)
chat_history.append(response)
return response.content
Einige Erklärungen zur Tool-Calling-Schleife:
response.tool_callswird aus der LLM-Antwort extrahiert und enthält eine Liste vonToolCall-Objekten.- Ist die Liste leer, wünscht das LLM keine Tool-Aufrufe — wir sind fertig und geben die LLM-Antwort direkt zurück.
- Enthält die Liste Einträge, rufen wir die Tools mit den gewünschten Parametern auf und fügen die Ergebnisse der
chat_historyhinzu. Sobald alle Funktionsaufrufe abgeschlossen sind, stellen wir erneut eine LLM-Anfrage mit der erweitertenchat_history. - Die gewünschten Funktionsnamen und Parameterwerte stecken in einer Liste von
ToolCall-Objekten. EinToolCall-Objekt ist ein Python-Dictionary, über dessen Schlüssel'name'und'args'wir auf Name und Parameterwerte zugreifen. Handelt es sich um die Funktionget_forecast— die einzige Funktion, die wir anbieten — rufen wir sie lokal auf und fügen das Resultat alsToolMessagezurchat_historyhinzu.
Problem: Aufwärmphase bei der Tool-Nutzung
Manchmal ruft das LLM das Tool beim ersten Aufruf nicht auf. Das ist ein bekanntes Problem bei LLMs und Tools — sie benötigen gelegentlich eine „Aufwärmphase”.
Mögliche Lösungen
Lösung 1 – Gleiche Anfrage zweimal senden
Ein einfacher Ansatz (für unser lokales Ollama-Beispiel) besteht darin, die Anfrage einmal als „Aufwärmphase” an das LLM zu schicken und die Antwort zu ignorieren, da das LLM das Tool dabei vermutlich noch nicht genutzt hat. Sendet man dieselbe Anfrage danach ein zweites Mal, sollte das Tool zuverlässig verwendet werden.
Lösung 2 – System Prompt definieren
Ein anderer Ansatz ist die Definition eines System Prompts. Darin weist man das LLM an, für Anfragen zu Wettervorhersagen das Tool get_forecast zu verwenden. Wichtig dabei: Den System Prompt fügen wir zu Beginn einer Chat-Konversation in die chat_history ein.
SYSTEM_PROMPT = """You are a helpful travel assistant.
IMPORTANT: When users ask about:
- What to pack for a trip
- What clothes to bring
- Weather conditions
- Temperature in a city
You MUST use the get_forecast tool to check the current weather before providing advice. Never give generic packing advice without checking the actual weather forecast first."""
# chat history with system prompt
chat_history = [SystemMessage(content=SYSTEM_PROMPT)]
def chat_with_tools(user_message: str, show_message_history: bool = False) -> str:
...
Wir haben nun die Grundlagen für einen Chatbot implementiert, der sich via LLM-Context die Chat-History merkt und dem LLM zusätzlich Tools für Wettervorhersagen zur Verfügung stellt.
Vollständiger Code mit LangChain
Den vollständige Code für den LLM Context mit Tools Beispiele ist im GitHub Repo zum Artikel zu finden:
Ein Chatbot im Eigenbau
Als abschliessendes Beispiel implementieren wir einen einfachen Chatbot mit einem UI. Dazu nutzen wir die Code-Grundlagen der letzten Kapitel sowie die Python-Library Streamlit.
Was ist Streamlit?
Streamlit ist eine Open-Source-Python-Bibliothek, mit der man interaktive Webanwendungen für Data-Science- und Machine-Learning-Projekte mit minimalem Codeaufwand erstellt – HTML, CSS oder JavaScript sind dabei nicht erforderlich. Die Grundidee ist simpel: Man schreibt ein normales Python-Skript, und Streamlit wandelt es automatisch in eine Webanwendung um.
Hauptmerkmale:
- Einfache Syntax – UI-Elemente lässt man sich mit einzeiligen Befehlen wie
text_input()erstellen. - Integrierte Widgets wie Buttons, Text-Input-Boxen und vieles mehr.
- Automatisches Neuladen – ein technisch wichtiger Punkt: Die Anwendung lädt sich bei jeder Benutzerinteraktion von oben nach unten neu.
Ein „Hello World” in Streamlit macht das Ganze klarer:
import streamlit as st
name = st.text_input("Enter your name")
st.write(f"Hello, {name}!")
Das Skript hello_streamlit.py startet man mit:
> streamlit run hello_streamlit.py
Im Browser erscheint dann ein einfaches Eingabefeld.

Sobald ich meinen Namen “JC” eingebe und Enter drücke, begrüsst mich die Anwendung mit „Hello, JC!”.

Ein simpler Chatbot
Wir starten mit einem simplen Chatbot, der den Chatverlauf bereits über den LLM-Kontext speichern kann. Wenn man zum Beispiel nach den Hauptstädten von Frankreich und Schweden fragt, sieht der Chatverlauf folgendermassen aus:

Am unteren Rand befindet sich ein Texteingabefeld für Fragen an das LLM. Darüber zeigt die Anwendung den Chatverlauf an, wobei User-Anfragen ein rotes und LLM-Antworten ein oranges Icon erhalten.
Umsetzung – Chat History
Zunächst importiert man Streamlit:
import streamlit as st
Damit steht über st.session_state eine Session zur Verfügung. Diese nutzt man, um alle Meldungen zu verwalten. Wie bereits erwähnt, lädt die Anwendung bei jeder Benutzerinteraktion neu – der Inhalt der Session, also die Meldungen, bleibt jedoch erhalten. Beim Start der Anwendung legt man zunächst eine leere Liste für die Meldungen in der Session an:
def init_chat_history() -> None:
if "messages" not in st.session_state:
st.session_state.messages = []
Um den Chatverlauf anzuzeigen, iteriert man anschliessend durch die Liste der Meldungen in der Session und gibt sie mit st.write() aus. Je nach Meldungstyp (User-Anfrage oder LLM-Antwort) lässt sich das Ganze zusätzlich mit einem Icon grafisch aufwerten:
def display_chat_history() -> None:
for message in st.session_state.messages:
if isinstance(message, HumanMessage):
with st.chat_message("user"):
st.write(message.content)
elif isinstance(message, AIMessage):
with st.chat_message("assistant"):
st.write(message.content)
Die Erstellung der Anfragen und das Handling der LLM-Antworten lagert man in die Klasse Conversation aus:
class Conversation:
def __init__(self):
self.llm = ChatOllama(model="llama3.1")
def ask(self, user_message: str) -> None:
self._append_to_chat_history(HumanMessage(content=user_message))
response = self.llm.invoke(st.session_state.messages)
self._append_to_chat_history(response)
def _append_to_chat_history(self, message) -> None:
st.session_state.messages.append(message)
User-Anfragen und LLM-Antworten legt man über _append_to_chat_history() in der Session ab.
Umsetzung – Der Main Flow
Damit alles zusammenspielt, braucht der Main Flow folgende Schritte:
init_chat_history()initialisiert die Datenstruktur für den Chatverlauf.display_chat_history()zeigt den aktuellen Chatverlauf an – zu Beginn leer, füllt er sich aber mit der Zeit.run_conversation()zeigt ein Texteingabefeld für die LLM-Anfrage an, schickt diese über einConversation-Objekt ab und speichert Anfrage sowie Antwort in der Session.st.rerun()ist der entscheidende Schritt: Er löst einen Refresh der Applikation aus, führtmain()erneut aus und stellt so den vollständigen Chatverlauf sowie ein neues, leeres Eingabefeld dar.
def run_conversation(conversation: Conversation) -> None:
prompt = st.chat_input("Ask me about ...")
if prompt:
conversation.ask(prompt)
st.rerun() # to refresh the chat display
if __name__ == "__main__":
init_chat_history()
display_chat_history()
run_conversation(Conversation())
Der gesamte Code passt in knapp 50 Zeilen – beeindruckend, was sich mit LangChain und ein bisschen Code alles umsetzen lässt.
MODEL = "llama3.1"
class Conversation:
def __init__(self):
self.llm = ChatOllama(model=MODEL)
def ask(self, user_message: str) -> None:
self._append_to_chat_history(HumanMessage(content=user_message))
response = self.llm.invoke(st.session_state.messages)
self._append_to_chat_history(response)
def _append_to_chat_history(self, message) -> None:
st.session_state.messages.append(message)
def init_chat_history() -> None:
if "messages" not in st.session_state:
st.session_state.messages = []
def display_chat_history() -> None:
for message in st.session_state.messages:
if isinstance(message, HumanMessage):
with st.chat_message("user"): # icon for user
st.write(message.content)
elif isinstance(message, AIMessage):
with st.chat_message("assistant"): # icon for assistant
st.write(message.content)
def run_conversation(conversation: Conversation) -> None:
prompt = st.chat_input("Ask me about ...")
if prompt:
conversation.ask(prompt)
st.rerun() # to refresh the chat display
if __name__ == "__main__":
init_chat_history()
display_chat_history()
run_conversation(Conversation())
Ein Chatbot mit Tools Support
Wir können den Chatbot noch einen Schritt weiter entwickeln und ihn so erweitern, dass er Tools Support für wetterbezogene Anfragen bietet. Dafür müssen wir nur das Wissen aus den vorherigen Abschnitten anwenden.
Zunächst definieren wir für die Wetterinformationen eine forecast() Funktion und annotieren sie mit @tool.
@tool
def get_forecast(city: str) -> str:
"""Get weather forecast for a specified city.
Args:
city: The name of the city to get the forecast for
Returns:
Weather forecast information as a string
"""
forecasts = {
"Paris": "Temperature: 28°C, Conditions: Sunny, Wind: 10 km/h",
"Stockholm": "Temperature: 12°C, Conditions: Rainy, Wind: 15 km/h",
"London": "Temperature: 15°C, Conditions: Rain, Wind: 8 km/h",
"Berlin": "Temperature: 16°C, Conditions: Partly cloudy, Wind: 12 km/h",
"Madrid": "Temperature: 24°C, Conditions: Clear skies, Wind: 5 km/h"
}
return forecasts.get(city, f"Sorry, no forecast available for {city}")
Anschließend verknüpfen wir im Konstruktor der Klasse Conversations das Modell mit dem Forecast-Tool. Um das Problem mit der Aufwärmphase zu lösen, definieren wir einen System Prompt.
class Conversation:
def __init__(self, use_tools: bool = True):
self.llm = ChatOllama(model=MODEL)
self.llm = self.llm.bind_tools([get_forecast])
self._set_system_prompt()
Danach ersetzen wir in der ask() Methode den einfachen LLM-Aufruf durch eine Tool-Calling-Schleife.
def ask(self, user_message: str):
# add user message
self._append_to_chat_history(HumanMessage(content=user_message))
# get response from LLM
response = self.llm.invoke(st.session_state.messages)
self._append_to_chat_history(response)
# check if the model wants to use a tool
while response.tool_calls:
# execute each tool call
for tool_call in response.tool_calls:
self._make_tool_call_and_add_result_to_history(tool_call)
# get final response from LLM with tool results
response = self.llm.invoke(st.session_state.messages)
self._append_to_chat_history(response)
return response.content
Als Bonus konfigurieren wir den Chatbot über eine Konstante USE_TOOLS: Mit dem Wert False erstellen wir einen Chatbot ohne Tools-Support, mit True hingegen einen mit Tools-Support. So entsteht ein abschliessendes Beispiel, das alles Gelernte vereint.
Vollständiger Code mit LangChain
Den vollständige Code für die Chatbot Beispiele ist im GitHub Repo zum Artikel zu finden:
Fazit
In diesem Artikel haben wir Schritt für Schritt gezeigt, wie man mit LangChain LLM-Anwendungen aufbaut.
Zunächst haben wir mehrere Chains parallel ausgeführt. Dadurch konnten wir drei LLMs gleichzeitig befragen: Claude, OpenAI und Gemini. Dabei übernimmt das RunnableParallel-Objekt genau diese Aufgabe — und arbeitet zudem deutlich effizienter als eine sequenzielle Lösung. Anschliessend haben wir die Antworten der drei LLMs in einer übersichtlichen Tabelle zusammengefasst.
Danach haben wir uns dem Thema LLM Context gewidmet. Da LLMs von Haus aus kein Gedächtnis besitzen, muss man den Gesprächsverlauf explizit verwalten. Dazu baut man die Chat-History mit HumanMessage und AIMessage auf. Man schickt sie dann bei jedem LLM-Aufruf mit — entweder direkt auf dem Modell oder via MessagesPlaceholder in einer Chain. Falls das Gespräch zu lang wird, kann man ausserdem einen Sliding-Window-Ansatz einsetzen, bei dem man nur die letzten N Nachrichten berücksichtigt.
Darüber hinaus haben wir gezeigt, wie der @tool-Decorator normale Python-Funktionen in LLM-Tools verwandelt. Das LLM entscheidet dabei selbst, ob es Tools nutzen möchte. Damit das zuverlässig funktioniert, muss man einen guten Docstring sowie klare Typhinweise definieren.
Abschliessend haben wir alles mit Streamlit zu einem Chatbot mit Benutzeroberfläche zusammengeführt. Je nach Bedarf kann man den Chatbot mit oder ohne Tool-Support betreiben.
Insgesamt zeigt LangChain, wie man komplexe LLM-Workflows mit überschaubarem Code umsetzt — modular, lesbar und erweiterbar.
Links
Artikel
Ollama REST AP
LLM Context
LLM Context und Tools
LangChain Teil 1
LangChain
Dokumentation LangChain
Installation LangChain Python Libraries
Generative KI mit Python | Bert Gollnick
Python
Installation uv
Streamlit
Repository
llm_langchain