Überspringen und zum Inhalt gehen →

30-Tage-DSPy-Challenge: Tag 4 – Modulare Programmierung mit DSPy

In DSPy stellen Module die fundamentalen Bausteine zur Erstellung komplexer Anwendungen mit Sprachmodellen (LLMs) dar. Ein Modul kapselt eine bestimmte Logik – typischerweise einen oder mehrere Aufrufe an ein Sprachmodell – und kann mit anderen Modulen zu einer mehrstufigen Pipeline kombiniert werden. Dieser modulare Ansatz ermöglicht es, komplexe Probleme in überschaubare, wiederverwendbare Komponenten zu zerlegen.

Die zentralen integrierten Module sind dspy.Predict und dspy.ChainOfThought. Während dspy.Predict für direkte Inferenzen von Eingabe zu Ausgabe verwendet wird, erweitert dspy.ChainOfThought diesen Prozess um einen expliziten Zwischenschritt des „Nachdenkens“, was die Qualität der Ausgabe bei anspruchsvollen Aufgaben verbessert.

Das Ziel dieses Tages ist es, ein Programm zu erstellen, das zwei Module miteinander verkettet, um eine Aufgabe zu lösen, die eine sequenzielle Verarbeitung erfordert.

Theorie: Die Funktionsweise von DSPy-Modulen

dspy.Predict

Das dspy.Predict-Modul ist die grundlegendste Abstraktion für eine LLM-Interaktion. Es nimmt eine Signatur entgegen, die die Eingabe- und Ausgabefelder definiert, und instruiert das Sprachmodell, die Ausgabefelder basierend auf den Eingabefeldern zu generieren. Es eignet sich für Aufgaben, die keine komplexe, schrittweise Herleitung erfordern, wie zum Beispiel Klassifizierung, Übersetzung oder einfache Fragenbeantwortung. Ich habe es in dem day1.ipynb schon verwendet.

dspy.ChainOfThought

Das dspy.ChainOfThought-Modul ist eine Erweiterung von dspy.Predict. Es nutzt ebenfalls eine Signatur, fügt jedoch implizit ein Feld für eine „Begründung“ oder einen „Gedankengang“ hinzu. Das LLM wird angewiesen, zuerst eine schrittweise Herleitung zu formulieren, bevor es die endgültige Antwort generiert. Dieser Prozess führt nachweislich zu besseren Ergebnissen bei logischen, mathematischen oder mehrdeutigen Problemstellungen. Hier eine Auswahl aus arxiv.org Papern.

Chain-of-Thought in Large Language Models: Decoding, Projection, and Activation
https://arxiv.org/abs/2412.03944

Self-Consistency Improves Chain of Thought Reasoning in Language Models
https://arxiv.org/abs/2203.11171

Verkettung von Modulen

Um komplexe Pipelines zu erstellen, werden einzelne Module innerhalb einer übergeordneten Programmstruktur, die von dspy.Module erbt, miteinander verbunden.

Deklaration
Im Konstruktor (__init__) der Programmklasse werden die benötigten Module als Instanzvariablen deklariert. Jedes Modul wird mit seiner spezifischen Signatur initialisiert.

Ausführung
In der forward-Methode wird die Logik der Pipeline definiert. Hier wird festgelegt, wie die Daten durch die deklarierten Module fließen. Typischerweise wird die Ausgabe eines Moduls als Eingabe für das nächste Modul verwendet.

    Dieser Ansatz ermöglicht die Konstruktion beliebig komplexer und tiefer Architekturen.

    Praxis: Implementierung einer mehrstufigen Aufgabe

    Die praktische Aufgabe besteht darin, ein Programm zu entwickeln, das zwei Schritte ausführt. Es generiert eine kurze, kreative Geschichte basierend auf einer Hauptfigur und einem Schauplatz. Anschließend fasst es die erstellte Geschichte in einem einzigen, prägnanten Satz zusammen.

      Umgebung konfigurieren

      Zuerst müssen die notwendigen Bibliotheken importiert und das Sprachmodell konfiguriert werden. In diesem Beispiel wird ein Modell von OpenAI verwendet.

      import dspy
      from dspy.teleprompt import BootstrapFewShot
      
      local_llm = dspy.LM(
          "openai/Qwen3-VL-8B-Instruct-Q4_K_M.gguf", 
          api_base="http://localhost:8080/v1", 
          api_key="no_key_needed"
      )
      
      dspy.configure(lm=local_llm)
      Signaturen definieren

      Für jeden Schritt der Pipeline wird eine eigene Signatur benötigt. Hier die beiden Signaturen zum Erstellen einer Geschichte und zum Schreiben einer Zusammenfassung.

      # Signatur für die Generierung der Geschichte
      class StorySignature(dspy.Signature):
          """Generiert eine kurze, kreative Geschichte basierend auf einer Figur und einem Schauplatz."""
          character = dspy.InputField(desc="Die Hauptfigur der Geschichte.")
          setting = dspy.InputField(desc="Der Schauplatz, an dem die Geschichte spielt.")
          story = dspy.OutputField(desc="Eine kurze, kreative Geschichte mit etwa 50 Wörtern.")
      
      # Signatur für die Zusammenfassung der Geschichte
      class SummarizationSignature(dspy.Signature):
          """Fasst eine gegebene Geschichte in einem einzigen Satz zusammen."""
          story = dspy.InputField(desc="Die zu zusammenfassende Geschichte.")
          summary = dspy.OutputField(desc="Eine Zusammenfassung in einem Satz.")
      • StorySignature definiert die Eingabefelder character und setting sowie das Ausgabefeld story.
      • SummarizationSignature definiert das Eingabefeld story und das Ausgabefeld summary.
      Das Gesamtprogramm erstellen

      Nun werden die beiden Module in einer neuen Klasse, die von dspy.Module erbt, miteinander verkettet.

      class StorySummarizer(dspy.Module):
          def __init__(self):
              super().__init__()
              # Deklaration der beiden Module im Konstruktor
              self.story_generator = dspy.Predict(StorySignature)
              self.summarizer = dspy.Predict(SummarizationSignature)
      
          def forward(self, character, setting):
              # Schritt 1: Geschichte generieren
              # Das erste Modul wird mit den ursprünglichen Eingaben aufgerufen
              generated_story = self.story_generator(character=character, setting=setting)
      
              # Schritt 2: Geschichte zusammenfassen
              # Die Ausgabe von Schritt 1 ('story') wird als Eingabe für Schritt 2 verwendet
              final_summary = self.summarizer(story=generated_story.story)
      
              return final_summary
      
      # Instanziieren und Ausführen des Programms
      summarizer_program = StorySummarizer()
      result = summarizer_program(character="ein neugieriger Roboter", setting="auf dem Mars")

      Ergebnis und Interpretation

      Nach der Ausführung des Programms wird das Ergebnis ausgegeben. Das result-Objekt enthält die finale Ausgabe des letzten Moduls der Kette.

      # Ausgabe des Ergebnisses
      print(f"Charakter: ein neugieriger Roboter")
      print(f"Schauplatz: auf dem Mars")
      print("-" * 20)
      print(f"Zusammenfassung: {result.summary}")
      
      Charakter: ein neugieriger Roboter
      Schauplatz: auf dem Mars
      --------------------
      Zusammenfassung: Ein neugieriger Roboter entdeckt auf dem Mars mysteriöse, verschlüsselte Signale, die darauf hindeuten, dass er nicht allein ist und der Planet mehr Geheimnisse birgt, als er dachte.

      Das Ergebnis demonstriert die erfolgreiche Verkettung der beiden Module. Das story_generator-Modul hat zuerst eine Geschichte erzeugt, welche intern an das summarizer-Modul weitergeleitet wurde. Dessen Ausgabe, das Feld summary, stellt das Endergebnis der forward-Methode dar.

      Diese Struktur ist nicht nur logisch und gut lesbar, sondern auch erweiterbar. Es könnten problemlos weitere Module hinzugefügt werden, um beispielsweise eine Stimmungsanalyse der Geschichte durchzuführen oder einen Titel dafür zu generieren.

      GPU Auslastung

      Dieses mal habe ich im Hintergrund den Task Manager mal laufen lassen und man kann gut sehen, wie schnell das Modell das Ergebnis erzeugt hat. Würde man das Modell stattdessen auf der CPU laufen lassen währe mein Computer vermutlich einige Zeit damit beschäftigt, die Prompts zu verarbeiten und die Ausgaben zu erzeugen.

      Prompt Analyse

      Da das Modul 2 Schritte ausführt, werden auch 2 Prompts an das LLM gesendet. Dies kann man sich dann mit local_llm.inspect_history(n=2) ausgeben lassen.

      System message:

      Your input fields are:
      1. `character` (str): Die Hauptfigur der Geschichte.
      2. `setting` (str): Der Schauplatz, an dem die Geschichte spielt.
      Your output fields are:
      1. `story` (str): Eine kurze, kreative Geschichte mit etwa 50 Wörtern.
      All interactions will be structured in the following way, with the appropriate values filled in.

      [[ ## character ## ]]
      {character}

      [[ ## setting ## ]]
      {setting}

      [[ ## story ## ]]
      {story}

      [[ ## completed ## ]]
      In adhering to this structure, your objective is:
      Generiert eine kurze, kreative Geschichte basierend auf einer Figur und einem Schauplatz.


      User message:

      [[ ## character ## ]]
      ein neugieriger Roboter

      [[ ## setting ## ]]
      auf dem Mars

      Respond with the corresponding output fields, starting with the field `[[ ## story ## ]]`, and then ending with the marker for `[[ ## completed ## ]]`.


      Response:

      [[ ## story ## ]]
      Ein neugieriger Roboter erkundete den Mars, wo er seltsame Steinformationen fand, die wie verschlüsselte Nachrichten aussahen. Er klickte, scannte und piepte – bis er plötzlich ein leuchtendes Signal entdeckte. Vielleicht war er nicht allein… und der Mars hatte mehr zu erzählen, als er dachte.

      [[ ## completed ## ]]
      System message:

      Your input fields are:
      1. `story` (str): Die zu zusammenfassende Geschichte.
      Your output fields are:
      1. `summary` (str): Eine Zusammenfassung in einem Satz.
      All interactions will be structured in the following way, with the appropriate values filled in.

      [[ ## story ## ]]
      {story}

      [[ ## summary ## ]]
      {summary}

      [[ ## completed ## ]]
      In adhering to this structure, your objective is:
      Fasst eine gegebene Geschichte in einem einzigen Satz zusammen.


      User message:

      [[ ## story ## ]]
      Ein neugieriger Roboter erkundete den Mars, wo er seltsame Steinformationen fand, die wie verschlüsselte Nachrichten aussahen. Er klickte, scannte und piepte – bis er plötzlich ein leuchtendes Signal entdeckte. Vielleicht war er nicht allein… und der Mars hatte mehr zu erzählen, als er dachte.

      Respond with the corresponding output fields, starting with the field `[[ ## summary ## ]]`, and then ending with the marker for `[[ ## completed ## ]]`.


      Response:

      [[ ## summary ## ]]
      Ein neugieriger Roboter entdeckt auf dem Mars mysteriöse, verschlüsselte Signale, die darauf hindeuten, dass er nicht allein ist und der Planet mehr Geheimnisse birgt, als er dachte.

      [[ ## completed ## ]]

      Analyse der generierten Prompts

      Die beiden oben gezeigten Prompts verdeutlichen, wie der DSPy-Compiler (standardmäßig unter Verwendung eines Chat-Adapters) Signaturen in strukturierte Eingabeaufforderungen umwandelt.

      Analyse Prompt 1: Die Generierung (StorySignature)

      Dieser Prompt resultiert aus dem Aufruf self.story_generator(character=..., setting=...).

      Die System-Instruktion (Signature Mapping)

      • Definition der Felder
        Der Abschnitt Your input fields are... und Your output fields are... wird direkt aus der StorySignature-Klasse abgeleitet.
        Interessant ist hierbei das Feld story: Die Beschreibung „Eine kurze, kreative Geschichte mit etwa 50 Wörtern“ stammt exakt aus dem Parameter desc="..." im dspy.OutputField. Dies zeigt, wie Python-Parameter die Feinsteuerung des Modells übernehmen.
      • Das Ziel (Objective)
        Der Satz Generiert eine kurze, kreative Geschichte basierend auf einer Figur und einem Schauplatz. ist der Docstring der Klasse StorySignature. DSPy nutzt den Docstring als primäre Handlungsanweisung (Instruction) für das Modell.

      Die Strukturierung (Formatierung)

      • DSPy erzwingt ein striktes Format mittels [[ ## field_name ## ]]. Dies dient dazu, die Antwort des Modells später wieder verlässlich in ein Python-Objekt parsen zu können.
      • Die User-Message enthält die konkreten Laufzeitwerte (ein neugieriger Roboter, auf dem Mars), die an die forward-Methode übergeben wurden.

      Die Ausgabe-Steuerung

      • Die Anweisung Respond with the corresponding output fields... zwingt das Modell, sofort mit dem Inhalt zu beginnen und keine höflichen Floskeln (wie „Hier ist Ihre Geschichte:“) voranzustellen. Dies erhöht die Effizienz und erleichtert das Parsing.

      Analyse Prompt 2: Die Zusammenfassung (SummarizationSignature)

      Dieser Prompt resultiert aus dem zweiten Schritt der Pipeline, dem Aufruf self.summarizer(story=generated_story.story).

      Datenfluss (Chaining)

      • Das entscheidende Detail ist der Inhalt des Feldes [[ ## story ## ]]. Der Text „Ein neugieriger Roboter erkundete den Mars…“ ist exakt die Ausgabe (Output) des ersten Prompts.
      • Dies demonstriert die Verkettung: Der Output von Modul 1 wurde im Python-Code extrahiert und als Input in Modul 2 injiziert. Das Sprachmodell selbst hat kein „Gedächtnis“ zwischen Prompt 1 und Prompt 2; der Kontext wird explizit durch DSPy übertragen.

      Aufgabenwechsel

      • Das Ziel (Objective) hat sich geändert zu: Fasst eine gegebene Geschichte in einem einzigen Satz zusammen. Dies entspricht dem Docstring der SummarizationSignature.
      • Das Modell fokussiert sich nun ausschließlich auf die Verdichtung von Informationen, da die Signatur nur eine summary als Output verlangt.

      Fazit zur Prompt-Struktur

      Die Analyse zeigt, dass DSPy die Prompts nicht willkürlich erstellt, sondern deterministisch aus den definierten Klassen ableitet:

      1. Klassen-Docstring –> Task-Instruktion (Objective).
      2. InputField/OutputField –> Schema-Definition.
      3. desc-Parameter –> Feingranulare Anweisungen (Constraints).
      4. Modul-Verkettung –> Datenfluss von einem Prompt zum nächsten.

      Diese Abstraktion erlaubt es Entwicklern, sich auf die Logik und die Datenstrukturen zu konzentrieren, während DSPy die formatgerechte Kommunikation mit dem LLM übernimmt.

      Zusammenfassung

      DSPy-Module sind ein gutes Konzept zur Strukturierung von LLM-basierten Anwendungen. Sie kapseln spezifische Aufgaben und ermöglichen durch ihre Verkettung die Erstellung komplexer, mehrstufiger Verarbeitungspipelines. Der hier gezeigte Ansatz, bei dem die Ausgabe eines Moduls als Eingabe für das nächste dient, ist ein zentrales Muster in der Entwicklung mit DSPy. In den folgenden Tagen der Challenge wird gezeigt, wie solche modularen Programme mithilfe von Optimizern und Metriken systematisch verbessert werden können.

      Veröffentlicht in Allgemein