Mit KI iterieren

Von
/01.04.26
Funktionierender Code beim ersten Versuch. + Zehn Feedback-Runden, bis er stimmte.

Das Problem

Wir hatten eine Rails-App, die gescannte PDF-Dokumente über eine API hochlädt. Einige Dokumente überschritten das 10-MB-Grössenlimit, was zu einem Fehler führte.
Die Lösung: Zu grosse PDFs automatisch komprimieren und den Upload erneut versuchen – mit jeweils 10% Reduzierung pro Versuch, maximal 3-mal.
Dieser Blogbeitrag zeigt meine Iterationen zu diesem Problem mit Claude und warum ich wirklich nicht verstehe, wie Leute im März 2026 überhaupt noch denken können, dass Programmieren tot sei. Ich liebe es, bei meiner täglichen Arbeit von KI unterstützt zu werden, aber irgendwie ist die Qualität noch nicht da.
Aber ich bin sicher, ich brauche bloss noch einen weiteren Prompt oder eine bessere CLAUDE.md-Datei.
Was folgt, ist ein Praxisbeispiel für das iterative Vorgehen mit Claude Code beim Entwickeln eines Features. Die KI produzierte beim ersten Versuch funktionierenden Code, aber es brauchte mehr als 10 Feedback-Runden, um ihn in etwas zu verwandeln, womit ich zufrieden war. Jede Version des Codes ist – vereinfacht – enthalten, um zu zeigen, wie sich das Design entwickelt hat.

Version 1: Den PdfCompressor einführen

Die erste Version legte die gesamte Logik in PdfCompressor – die Wiederholungsschleife, die Fehlererkennung und die Komprimierung. Sie verwendete Ghostscript mit DPI-Downsampling.
Dies ist der ursprüngliche Code:
  # `app/models/ibm_document.rb`

def self.create(pdf_pdf_file_bytes)
  api_client.documents.create(pdf_pdf_file_bytes)
end
Und hier ist die erste – funktionierende – Lösung von Claude Code:
  # `app/models/ibm_document.rb` — v1 (create-Methode)

def self.create(pdf_file_bytes)
  PdfCompressor.with_compression_retry(pdf_file_bytes) do |bytes|
    api_client.documents.create(bytes)
  end
end
  # `app/services/pdf_compressor.rb` — v1

class PdfCompressor
  MAX_RETRIES = 3
  SCALE_FACTOR = 0.9
  EXCEEDED_MESSAGE_SIZE_ERROR = "Access Denied due to exceeded message size"

  def self.with_compression_retry(pdf_file_bytes, &block)
    new(pdf_file_bytes).with_compression_retry(&block)
  end

  def initialize(pdf_file_bytes)
    @pdf_file_bytes = pdf_file_bytes
  end

  def with_compression_retry
    current_bytes = @pdf_file_bytes
    retries = 0

    loop do
      response = yield(current_bytes)
      return response if response.success?

      break response unless exceeded_message_size?(response)
      break response if retries >= MAX_RETRIES

      retries += 1
      current_bytes = compress(current_bytes, retries)
    end
  end

  private

  def exceeded_message_size?(response)
    response.data.to_s.include?(EXCEEDED_MESSAGE_SIZE_ERROR)
  end

  def compress(pdf_file_bytes, attempt)
    scale = SCALE_FACTOR ** attempt
    dpi = (300 * scale).round

    Tempfile.create(["input", ".pdf"]) do |input|
      Tempfile.create(["output", ".pdf"]) do |output|
        input.binmode
        input.write(pdf_file_bytes)
        input.flush

        system(
          "gs", "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4",
          "-dColorImageResolution=#{dpi}", "-dGrayImageResolution=#{dpi}",
          "-dMonoImageResolution=#{dpi}",
          "-dDownsampleColorImages=true", "-dDownsampleGrayImages=true", "-dDownsampleMonoImages=true",
          "-dNOPAUSE", "-dBATCH", "-dQUIET",
          "-sOutputFile=#{output.path}", input.path,
          exception: true
        )

        File.binread(output.path)
      end
    end
  end
end
Obwohl die erste Version funktionierte, fühlte sich der Code schlicht falsch an.
Mein Hauptanliegen in dieser Phase war die Trennung von Verantwortlichkeiten: Es gefiel mir überhaupt nicht, dass der neue PdfCompressor weit mehr tat als bloss die PDF-Datei zu komprimieren.
Er kannte insbesondere die spezifische Fehlermeldung oder die Tatsache, dass es ein Response-Objekt gibt – das hat nichts mit der Aufgabe zu tun, ein PDF zu komprimieren.
Deshalb kommt nun eine zweite Version.

Version 2: Trennung von Verantwortlichkeiten – nur komprimieren

Mein Feedback: «Der pdf_compressor implementiert einen schönen Schleifen- und Wiederholungsmechanismus, sollte sich aber nur um die Komprimierung kümmern und nichts über den Fehler oder den Grund für die Komprimierung wissen.»
Die Wiederholungslogik wurde nach IbmDocument verschoben, PdfCompressor weiss jetzt nur noch etwas über die Komprimierung.
  # `app/models/ibm_document.rb` — v2 (create-Methode)

EXCEEDED_MESSAGE_SIZE_ERROR = "Access Denied due to exceeded message size"
MAX_COMPRESSION_RETRIES = 3

def self.create(pdf_file_bytes)
  current_bytes = pdf_file_bytes

  MAX_COMPRESSION_RETRIES.times do |attempt|
    response = api_client.documents.create(current_bytes)
    return response if response.success?
    break unless exceeded_message_size?(response)

    current_bytes = PdfCompressor.compress(current_bytes, attempt: attempt + 1)
  end

  response = api_client.documents.create(current_bytes)
  response
end

def self.exceeded_message_size?(response)
  response.data.to_s.include?(EXCEEDED_MESSAGE_SIZE_ERROR)
end
  # `app/services/pdf_compressor.rb` — v2

class PdfCompressor
  SCALE_FACTOR = 0.9

  def self.compress(pdf_file_bytes, attempt:)
    new(pdf_file_bytes, attempt: attempt).compress
  end

  def initialize(pdf_file_bytes, attempt:)
    @pdf_file_bytes = pdf_file_bytes
    @attempt = attempt
  end

  def compress
    dpi = (300 * (SCALE_FACTOR ** @attempt)).round

    run_ghostscript(input.path, output.path, dpi)
  end

  private

  def run_ghostscript(input_path, output_path, dpi)
    system(
      "gs", "-sDEVICE=pdfwrite", "-dCompatibilityLevel=1.4",
      "-dColorImageResolution=#{dpi}", "-dGrayImageResolution=#{dpi}", "-dMonoImageResolution=#{dpi}",
      "-dDownsampleColorImages=true", "-dDownsampleGrayImages=true", "-dDownsampleMonoImages=true",
      "-dNOPAUSE", "-dBATCH", "-dQUIET",
      "-sOutputFile=#{output_path}", input_path,
      exception: true
    )
  end
end
Wir haben nun eine klare Trennung von Verantwortlichkeiten, aber der Code ist unleserlich geworden: ein riesiges Durcheinander und ausserdem fehlerhaft.
Claude hat es vollständig verfehlt, die Verantwortlichkeiten getrennt zu halten und gleichzeitig die richtige Logik an die richtige Stelle zu extrahieren.
Ein paar konkrete Anmerkungen:
  • IbmDocument ist nun für das Schleifen und weiteres Komprimieren des Dokuments verantwortlich. Das ist genau das, worum sich der PdfCompressor kümmern sollte! In Version 1 hatten wir das mit der Methode with_compression_retry, und jetzt ist es verloren gegangen. Ich will den Kuchen und ihn gleichzeitig essen!
  • IbmDocument durchläuft die Schleife nun dreimal, aber wenn die API aus einem anderen Grund fehlschlägt und die Schleife abbricht, führt sie einen weiteren Aufruf durch.
  • Die Methode PDfCompressor#compress nimmt jetzt ein attempt-Argument. Das ist schlicht falsch! Diese Methode sollte nur wissen, wie stark sie komprimieren soll, und nicht über eine Schleifenstrategie informiert sein.
  • Ich mag es, dass der Ghostscript-Aufruf extrahiert wurde.
Versuchen wir das in der nächsten Version zu beheben.

Version 3: Schleife zurück im PdfCompressor, prozentbasierte Komprimierung

Mein Feedback: «Mir gefiel, dass der PdfCompressor auch eine Methode angeboten hat, um eine bestimmte Anzahl von Malen zu wiederholen, sodass die ‹Schleifen›-Logik dort bleibt, während die Implementierungsdetails und continue oder break beim Aufrufer liegen. Ich denke, die compress-Methode sollte pdf_file_bytes und einen Komprimierungsprozentsatz entgegennehmen, nicht ‹attempts›; die attempts-Logik bleibt in der Schleife.»
Ich habe die Schleife als progressively_compress zurückgebracht (den Namen habe ich bestimmt!!) und compress dazu gebracht, einen Prozentsatz statt einer Versuchsnummer entgegenzunehmen.
  # `app/models/ibm_document.rb` — v3 (create-Methode)

response = api_client.documents.create(current_bytes)

if !response.success? && exceeded_message_size?(response)
  PdfCompressor.progressively_compress(pdf_file_bytes, max_retries: MAX_COMPRESSION_RETRIES) do |compressed_bytes|
    response = response = api_client.documents.create(compressed_bytes)
    break if response.success?
    break unless exceeded_message_size?(response)
  end
end
response
  # `app/services/pdf_compressor.rb` — v3

class PdfCompressor
  SCALE_FACTOR = 0.9

  def self.compress(pdf_bytes, percentage:)
    new(pdf_bytes).compress(percentage: percentage)
  end

  def self.progressively_compress(pdf_bytes, max_retries: 3, step: 10, &)
    new(pdf_bytes).progressively_compress(max_retries: max_retries, step: step, &)
  end

  def initialize(pdf_bytes)
    @pdf_bytes = pdf_bytes
  end

  def compress(percentage:)
    dpi = (300.0 * percentage / 100).round

    run_ghostscript(input.path, output.path, dpi)
  end

  def progressively_compress(max_retries: 3, step: 10)
    percentage = 100

    max_retries.times do
      percentage -= step
      @pdf_bytes = compress(percentage: percentage)
      yield(@pdf_bytes)
    end
  end

  private

  def run_ghostscript(input_path, output_path, dpi)
    # ...
  end
end
Ich habe das Gefühl, wir kommen langsam ans Ziel: Mein Drang, die Kontrolle zu übernehmen, ist jetzt gross, aber ich wollte sehen, wie viele Prompts es braucht, um es richtig hinzubekommen. Bedenke, dass ich zu diesem Zeitpunkt bereits mindestens zwei manuelle Änderungen vorgenommen hatte:
  • Konstantendefinitionen über die Methoden verschieben, wo sie verwendet wurden
  • with_compression_retries in progressively_compress umbenennen
Ich wollte die Instanzmethoden des PdfCompressors loswerden und nur noch bei Klassenmethoden bleiben. Es ergab keinen Sinn, beides zu haben.
Ausserdem möchte ich keine benannten Parameter, wenn sie nicht optional sind, also sollte der benannte Parameter percentage: geändert werden.

Version 4: Nur Klassenmethoden

Mein Feedback: «Der PdfCompressor kann nur Klassenmethoden haben. Ich sehe keinen Grund, auch Instanzmethoden zu haben. Ausserdem nimmt die compress-Methode einen percentage-Parameter entgegen. Verwende keinen benannten Parameter. Er ist nicht optional.»
Die Instanzmethoden und der Konstruktor wurden entfernt. Alles ist jetzt eine Klassenmethode.
  # `app/services/pdf_compressor.rb` — v4

class PdfCompressor
  def self.compress(pdf_bytes, percentage)
    dpi = (300.0 * percentage / 100).round

    run_ghostscript(input.path, output.path, dpi)
  end

  def self.progressively_compress(pdf_bytes, max_retries: 3, step: 10)
    percentage = 100

    max_retries.times do
      percentage -= step
      pdf_bytes = compress(pdf_bytes, percentage: percentage)
      yield(pdf_bytes)
    end
  end

  def self.run_ghostscript(input_path, output_path, dpi)
    # ...
  end
end
Als Software-Ingenieur denke ich, dass unsere Aufgabe darin besteht, Dinge beim Namen zu nennen. Deshalb ergibt step in einer progressively_compress-Methode keinen Sinn. Nennen wir es beim richtigen Namen: progressive_compression_percentage.

Version 5: step in compression_percentage umbenannt

Mein Feedback: «Mir gefällt die Variable ‹step› nicht, das ist der progressive_compression_percentage.»
Einfache Umbenennung des Parameters.
  # `app/services/pdf_compressor.rb` — v5 (progressively_compress)

def self.progressively_compress(pdf_bytes, max_retries: 3, compression_percentage: 10)
  percentage = 100

  max_retries.times do
    percentage -= compression_percentage
    pdf_bytes = compress(pdf_bytes, percentage)
    yield(pdf_bytes)
  end
end
Konzentrieren wir uns jetzt auf die IbmDocument#create-Methode:
  
response = api_client.documents.create(current_bytes)

if !response.success? && exceeded_message_size?(response)
  PdfCompressor.progressively_compress(pdf_file_bytes, max_retries: MAX_COMPRESSION_RETRIES) do |compressed_bytes|
    response = response = api_client.documents.create(compressed_bytes)
    break if response.success?
    break unless exceeded_message_size?(response)
  end
end
response
Den PdfCompressor auf diese Weise zu verwenden ist schlicht hässlich. Man muss zuerst einen Aufruf ausführen und dann, wenn er fehlschlägt, mit der Komprimierung beginnen. Das war nicht meine Vorstellung: Ich möchte, dass der PdfCompressor alles in der Schleife übernimmt.

Version 6: Erste Iteration liefert die originalen Bytes

Mein Feedback: «Mir gefällt es nicht, dass der Aufrufer den Aufruf vor dem Block ausführen muss. Passe den Compressor so an, dass er in der ersten Schleifeniteration nicht sofort reduziert, damit es für den Aufrufer einfacher ist.»
Die erste Übergabe liefert jetzt die originalen Bytes, sodass der Aufrufer keinen separaten Aufruf vor dem Block benötigt.
  # `app/models/ibm_document.rb` — v6 (create-Methode, nur die Schleife)

response = nil

PdfCompressor.progressively_compress(pdf_file_bytes, max_retries: MAX_COMPRESSION_RETRIES) do |bytes|
  response = submit(bytes)
  break if response.success?
  break unless exceeded_message_size?(response)
end
response
  # `app/services/pdf_compressor.rb` — v6 (progressively_compress)

def self.progressively_compress(pdf_bytes, max_retries: 3, compression_percentage: 10)
  percentage = 100

  (max_retries + 1).times do
    yield(pdf_bytes)
    percentage -= compression_percentage
    pdf_bytes = compress(pdf_bytes, percentage)
  end
end
Und hier merke ich, dass der Begriff retries für Claude die ganze Zeit irreführend war. Ich sagte «3 Wiederholungen», meinte aber «3 Versuche». Deshalb sieht die Schleife so hässlich aus.

Version 7: Versuche statt Wiederholungen

Mein Feedback: «Ich möchte ‹attempts› und nicht retries, also wenn ich 3 sage, versucht es 3-mal, nicht 4-mal.»
Die Semantik wurde geändert: attempts: 3 bedeutet genau 3 Übergaben, nicht 3 Wiederholungen + 1 initial.
  # `app/models/ibm_document.rb` — v7

MAX_COMPRESSION_ATTEMPTS = 3
PdfCompressor.progressively_compress(pdf_file_bytes, attempts: MAX_COMPRESSION_ATTEMPTS) do |bytes|
  # ...
end
  # `app/services/pdf_compressor.rb` — v7 (progressively_compress)

def self.compress(pdf_bytes, percentage)
  return pdf_bytes if percentage.zero? # Das habe ich auch noch hinzugefügt!
  dpi = (300.0 * percentage / 100).round

  run_ghostscript(input.path, output.path, dpi)
end

def self.progressively_compress(pdf_bytes, attempts: 3, compression_percentage: 10)
  percentage = 100

  attempts.times do
    yield(pdf_bytes)
    percentage -= compression_percentage
    pdf_bytes = compress(pdf_bytes, percentage)
  end
end
Das sieht nach einer fantastischen Implementierung aus. Mit «nur» sieben Iterationen sieht der Code so aus, wie ich es wollte.
Claude war sehr schlecht darin, Dinge zu benennen, eine ordentliche Trennung von Verantwortlichkeiten vorzunehmen und eine gute Architektur zu entwerfen. Aber es hat mir zumindest die richtigen Ghostscript-Befehle geliefert, um die PDF-Grösse zu reduzieren – das hätte mich ewig gekostet, selbst herauszufinden.
Ausserdem liess ich es in der Zwischenzeit Tests schreiben, und die waren alle grün.
Ausser dass beim näheren Hinsehen nichts funktionierte.
An diesem Punkt haben die Tests ein grundlegendes Problem aufgedeckt: Ghostscripts einmaliges DPI-Downsampling komprimiert nicht zuverlässig um einen bestimmten Prozentsatz. Bei bereits komprimierten PDFs hat ein einzelner Ghostscript-Durchlauf mit 90% DPI die Ausgabe tatsächlich um mehr als 40% aufgebläht. Das pdfwrite-Gerät dekomprimiert und kodiert alles neu.
  Original: 899'685
90% (270 DPI): 1'258'729  (+40% GRÖSSER!)
80% (240 DPI): 1'072'671  (+19% GRÖSSER!)
70% (210 DPI): 893'270   (endlich kleiner)
60% (180 DPI): 722'807
50% (150 DPI): 555'228
Was mich störte, war, dass ich explizit in die Tests schauen musste, um das zu bemerken. Sie waren alle grün, weil sie nur überprüft haben, dass es funktioniert – nicht dass die tatsächlich generierte Dateigrösse 10% kleiner als die vorherige ist. Ausserdem wurde ein gefälschtes generiertes PDF verwendet, das sich völlig anders verhält als ein echtes: Das gefälschte enthielt bloss etwas Text, während ein reales ein grosses Bild enthielt – das veränderte die Ausgabe komplett.
Und das war meine gesamte Geschäftslogik!
An diesem Punkt liess ich Claude etwa 10 Minuten laufen. 10 Minuten, um das Problem zu finden.
Bis ich mich schliesslich entschied, die Kontrolle zu übernehmen und Claude zu sagen, wie es zu tun ist.

Version 8 (Final): Mehrpass-Ghostscript bis Zielgrösse erreicht

Mein Feedback: «Der Ansatz mit Ghostscript ist in Ordnung, aber der PdfCompressor sollte sich darum kümmern, Ghostscript mehrfach aufzurufen, bis die gewünschte Komprimierung erreicht ist.»
Die entscheidende Erkenntnis: Ein einzelner Ghostscript-Durchlauf kann zwar aufblähen, aber das Iterieren bei schrittweise niedrigerem DPI wird schliesslich das Ziel erreichen. Die compress-Methode läuft jetzt intern in einer Schleife und reduziert DPI bei jeder Iteration um 10, bis die Ausgabe klein genug ist.
  # `app/models/ibm_document.rb` — Finale Version (create-Methode)

MAX_COMPRESSION_ATTEMPTS = 3

def self.create(pdf_file_bytes)
  response = nil

  PdfCompressor.progressively_compress(pdf_file_bytes, attempts: MAX_COMPRESSION_ATTEMPTS) do |bytes|
    response = submit(bytes)
    break if response.success? || !exceeded_message_size?(response)
  end
  response
end
  # `app/services/pdf_compressor.rb` — Finale Version

class PdfCompressor
  def self.compress(pdf_bytes, percentage)
    return pdf_bytes if percentage.zero?

    tolerance = 1
    target_size = (pdf_bytes.bytesize * (100 - percentage + tolerance) / 100.0).round
    current_bytes = pdf_bytes
    dpi = BASE_DPI

    loop do
      dpi -= DPI_STEP
      break current_bytes if dpi < MIN_DPI

      current_bytes = run_ghostscript_on(current_bytes, dpi)
      break current_bytes if current_bytes.bytesize <= target_size
    end
  end

  def self.progressively_compress(pdf_bytes, attempts: 3, compression_percentage: 10)
    percentage = 0

    attempts.times do
      yield(pdf_bytes)
      percentage += compression_percentage
      pdf_bytes = compress(pdf_bytes, percentage)
    end
  end
end

Wichtigste Erkenntnisse

  1. Trennung von Verantwortlichkeiten braucht Gespür — Die KI hat anfangs alles in eine Klasse gepackt, dann auf Anfrage vollständig getrennt, aber die falsche Logik an die falsche Stelle extrahiert. Es dauerte mehrere Runden, um die richtige Mitte zu finden: PdfCompressor ist für die Schleife und Komprimierung zuständig, der Aufrufer für die Abbruchbedingung. Die KI hat nie widersprochen oder Alternativen vorgeschlagen – sie hat einfach getan, was verlangt wurde.
  2. Namensgebung ist wichtiger als man denkt — «step» vs. «compression_percentage», «retries» vs. «attempts», benannte vs. positionelle Argumente. Das klingt nach Haarspalterei, aber ungenaue Benennung führte zu falscher Semantik: «retries» zu sagen brachte Claude dazu, (max_retries + 1).times zu implementieren anstatt das einfachere attempts.times.
  3. Von KI geschriebene Tests können gefährlich grün sein — Die Tests bestanden, aber sie verwendeten ein gefälschtes PDF mit bloss Text und prüften nur, ob die Ausgabe gültig war – nicht, ob sie tatsächlich kleiner war. Bei einem echten gescannten Dokument hat Ghostscript die Datei um 40% aufgebläht. Die KI hat die tatsächliche Geschäftsanforderung nie getestet.
  4. Domänenwissen ist unersetzlich — Die KI wusste nicht, dass Ghostscripts pdfwrite alles dekomprimiert und neu kodiert, was bei hohen Qualitätsstufen zu Aufblähung führt. Sie verbrachte 10 Minuten damit, Alternativen auszuprobieren, bevor ich ihr die einfache Lösung sagte: Schleife mit schrittweise niedrigerem DPI, bis das Ziel erreicht ist.
  5. KI ist grossartig für das, was man lieber nicht selbst lernen möchte — Trotz all der Iteration über Architektur und Namensgebung waren die Ghostscript-Kommandozeilenflags von Anfang an korrekt. Das ist der Teil, der mich am längsten gekostet hätte, selbst herauszufinden.