Zum Inhalt springen
Projekt besprechen

5. Oktober 2026

Bedrock aus Rails: drei Fallen mit ruby_llm

RailsAWSAI

OpenAI-kompatibel ist kein Protokollvertrag

Wer AWS Bedrock Rails über ein OpenAI-kompatibles Gateway anbindet, sieht zunächst eine vertraute Oberfläche: API-Base setzen, Key hinterlegen, Modell auswählen. Das ist praktisch, aber es verleitet zu einer falschen Annahme: OpenAI-kompatibel bedeutet nicht, dass jede Semantik identisch ist.

Bei einem Rails LLM mit ruby_llm achte ich besonders auf drei Stellen: die Rolle der System-Nachricht, die Bedeutung von Timeouts und den Umgang mit Zugangsdaten in aufgezeichneten HTTP-Tests. Dazu kommt eine Architekturfrage: Globale Konfiguration wird problematisch, sobald mehrere Endpoints parallel nötig sind.

Falle 1: Die System-Nachricht wird als developer gesendet

Ohne explizite Konfiguration sendet ruby_llm die System-Message mit der Rolle developer. Ein Bedrock-Gateway versteht diese Rolle nicht zwingend so, wie es für den gewünschten Prompt nötig wäre. Das Problem ist unauffällig: Der Request kann technisch erfolgreich sein, aber die Instruktionen wirken anders als erwartet.

Für einen OpenAI-kompatiblen Bedrock-Endpunkt gehört die Einstellung deshalb in den jeweiligen Context:

bedrock_context = RubyLLM.context do |config|
  config.openai_api_base = ENV.fetch('BEDROCK_GATEWAY_API_BASE')
  config.openai_api_key = ENV.fetch('BEDROCK_GATEWAY_API_KEY')
  config.openai_use_system_role = true
end

Damit wird die System-Nachricht als system gesendet. Das ist keine kosmetische Option. System-Prompts enthalten häufig Regeln für Sprache, Ausgabeformat oder den Umgang mit Eingaben. Wenn das Gateway die gesendete Rolle anders behandelt, verändert sich damit die fachliche Wirkung des Prompts.

Wichtig ist auch die Grenze dieses Wegs: Claude auf Bedrock ist nicht über den OpenAI-kompatiblen Weg erreichbar. Die Ähnlichkeit der API-Form ersetzt keine Unterstützung für jedes Modell und jeden Provider.

Falle 2: request_timeout ist nicht der Connect-Timeout

Die zweite Falle steckt im Namen. request_timeout begrenzt den Read-Timeout. Er regelt also, wie lange auf eine Antwort gewartet wird, nachdem die Verbindung besteht. Er regelt nicht, wie lange der Aufbau einer Verbindung dauern darf.

Beides sollte getrennt konfiguriert werden. Ein typisches Muster ist ein kurzer Connect-Timeout und ein bewusst gewählter Read-Timeout:

RubyLLM.configure do |config|
  config.request_timeout = 60
end
 
Faraday.new do |faraday|
  faraday.options.open_timeout = 5
  faraday.options.timeout = 60
end

Der relevante Faraday-Wert für den Verbindungsaufbau heißt open_timeout. Wo die Faraday-Verbindung in einer Anwendung erzeugt oder erweitert wird, muss genau dort dieser Wert gesetzt werden. Nur request_timeout zu erhöhen löst kein Problem beim Connect. Im ungünstigen Fall wartet ein Web-Request dann länger als beabsichtigt, obwohl die Gegenstelle gar nicht erreichbar ist.

Mein Rat: Diese beiden Werte getrennt dokumentieren. Sonst ist bei einem Incident nicht klar, ob gerade der Verbindungsaufbau, das Warten auf Tokens oder die eigene Infrastruktur bremst.

Falle 3: Globale Endpoint-Konfiguration und offene Keys in VCR

Eine globale openai_api_base funktioniert nur, solange es genau einen Endpoint gibt. Sobald beispielsweise ein Gateway und ein weiterer OpenAI-kompatibler Endpoint parallel verwendet werden, wird globale Konfiguration fehleranfällig. Der zuletzt gesetzte Wert gewinnt dann für alle nachfolgenden Aufrufe.

RubyLLM.context schafft hier sauberes Scoping. Jeder Context erhält seine eigene Base-URL und seine eigenen Credentials:

primary_context = RubyLLM.context do |config|
  config.openai_api_base = ENV.fetch('PRIMARY_API_BASE')
  config.openai_api_key = ENV.fetch('PRIMARY_API_KEY')
  config.openai_use_system_role = true
end
 
secondary_context = RubyLLM.context do |config|
  config.openai_api_base = ENV.fetch('SECONDARY_API_BASE')
  config.openai_api_key = ENV.fetch('SECONDARY_API_KEY')
end

Der zweite Teil betrifft Tests. VCR-Cassettes zeichnen HTTP-Requests auf. Ohne filter_sensitive_data kann der API-Key dabei im Klartext in der Cassette landen und damit im Repository.

VCR.configure do |config|
  config.filter_sensitive_data('REDACTED_API_KEY') do
    ENV.fetch('BEDROCK_GATEWAY_API_KEY')
  end
end

Den Filter sollte es für jeden verwendeten Key geben, nicht nur für den primären Endpoint. Prüfe außerdem bestehende Cassettes: Ein später hinzugefügter Filter entfernt keine Secrets aus bereits aufgezeichneten Dateien.

Grept heute einmal eure VCR-Cassettes nach Keys.