|
| 1 | +--- |
| 2 | +key: llm-gateway-open-source |
| 3 | +slug: llm-gateway-open-source |
| 4 | +title: Warteschlange statt Warteschleife — unser LLM Gateway ist Open Source |
| 5 | +teaser: >- |
| 6 | + Lokale Modelle lösen die Datenschutzfrage, aber nicht die Kapazitätsfrage. Wir haben ein |
| 7 | + Gateway gebaut, das Anfragen an lokale Modelle annimmt, speichert und der Reihe nach |
| 8 | + abarbeitet — und stellen es unter MIT-Lizenz zur Verfügung. |
| 9 | +published_at: 2026-07-29 |
| 10 | +published: false |
| 11 | +author: sebastian.buergin@codebar.ch |
| 12 | +hero: images/templates/cover-template.jpg |
| 13 | +hero_alt: Platzhaltergrafik zum LLM Gateway |
| 14 | +tags: [Open Source, KI] |
| 15 | +featured: false |
| 16 | +--- |
| 17 | + |
| 18 | +Wir arbeiten täglich mit lokalen Sprachmodellen. Der Grund ist unspektakulär: Sobald ein Prompt |
| 19 | +Kundendaten, Verträge oder interne Dokumente enthält, ist die Frage nicht mehr, welches Modell am |
| 20 | +besten antwortet, sondern wer die Anfrage zu sehen bekommt. Ein Modell, das auf der eigenen |
| 21 | +Hardware läuft, beantwortet diese Frage von selbst — nichts verlässt das Haus, es gibt keine |
| 22 | +Verarbeitung im Ausland, keine Vertragsanhänge zur Auftragsverarbeitung, keine Diskussion darüber, |
| 23 | +ob Eingaben irgendwann in ein Training fliessen. |
| 24 | + |
| 25 | +Was lokale Modelle nicht mitliefern, ist Kapazität. |
| 26 | + |
| 27 | +## Das eigentliche Problem ist Gleichzeitigkeit |
| 28 | + |
| 29 | +Ein lokales Modell ist günstig im Betrieb und teuer in der *Parallelität*. Eine GPU — oder der |
| 30 | +gemeinsame Speicher eines Macs — trägt realistisch ein bis zwei gleichzeitige Anfragen, bevor das |
| 31 | +Umschalten zwischen Modellen mehr kostet, als es bringt. Ollamas `OLLAMA_NUM_PARALLEL` existiert |
| 32 | +genau deshalb: Die Grenze ist hart, nicht weich. |
| 33 | + |
| 34 | +Darüber hinaus stauen sich Anfragen. Ob man eine Warteschlange gebaut hat oder nicht, ändert daran |
| 35 | +nichts — es ändert nur, *wo* sie entsteht. Entweder an einer Stelle, die man sieht, oder in Ollamas |
| 36 | +eigenem Scheduler, wo eine Anfrage, die schlicht wartet, exakt gleich aussieht wie eine, die |
| 37 | +hängt. |
| 38 | + |
| 39 | +Für einen einzelnen Entwickler am Notebook ist das kein Thema. Für interne Werkzeuge, die Rechnungen |
| 40 | +auslesen, Protokolle zusammenfassen oder Dokumente klassifizieren, wird daraus schnell ein |
| 41 | +Betriebsproblem: Ein Batchlauf blockiert alle anderen, ein Timeout im aufrufenden System verwirft |
| 42 | +eine Antwort, für die das Modell noch zwei Minuten rechnet, und niemand kann sagen, wie lange etwas |
| 43 | +dauern wird. |
| 44 | + |
| 45 | +## Was wir stattdessen gebaut haben |
| 46 | + |
| 47 | +Das LLM Gateway ist eine Warteschlange vor den lokalen Modellen — und sonst bewusst nichts. |
| 48 | + |
| 49 | +Der Ablauf ist einfach: Sie senden Ihre Anfrage genau so, wie Sie sie an das Modell senden würden. |
| 50 | +Das Gateway nimmt sie an, schreibt sie in die Datenbank und antwortet sofort mit einer ID. Sobald |
| 51 | +Kapazität frei ist, nimmt ein Worker den Eintrag, spielt ihn unverändert ans Modell weiter und legt |
| 52 | +die Antwort zurück in denselben Eintrag. Abgeholt wird sie später über die zweite Anfrage. |
| 53 | + |
| 54 | +```bash |
| 55 | +# absenden — antwortet in Millisekunden mit einer ID |
| 56 | +ID=$(curl -s https://gateway.example/v1/responses \ |
| 57 | + -H "Authorization: Bearer $MY_API_KEY" \ |
| 58 | + -H "Content-Type: application/json" \ |
| 59 | + -d '{"model":"qwen3-vl:30b-a3b","input":"Rechnungsnummer und Total als JSON."}' | jq -r .id) |
| 60 | + |
| 61 | +# abholen — sobald der Status auf «completed» steht |
| 62 | +curl -s https://gateway.example/v1/responses/$ID \ |
| 63 | + -H "Authorization: Bearer $MY_API_KEY" | jq -r '.output[0].content[0].text' |
| 64 | +``` |
| 65 | + |
| 66 | +Die API ist die von OpenAI definierte: `POST /v1/chat/completions` und `POST /v1/responses` |
| 67 | +funktionieren so, wie es jedes SDK erwartet. Der Unterschied liegt in der Antwort — statt auf das |
| 68 | +Modell zu warten, kommt ein `202` mit einer ID zurück. Auch eine als synchron gedachte |
| 69 | +Chat-Completion wird dabei in einen asynchronen Auftrag umgewandelt. Das ist kein Nebeneffekt, |
| 70 | +sondern der Zweck: Eine Warteschlange, die man umgehen kann, ist keine. |
| 71 | + |
| 72 | +## Alles geht 1:1 durch |
| 73 | + |
| 74 | +Der Punkt, auf den wir beim Bauen am meisten geachtet haben: Das Gateway interpretiert die Anfrage |
| 75 | +nicht. |
| 76 | + |
| 77 | +- **Der Body** geht unverändert nach oben. Unbekannte Felder werden nicht entfernt, ein fehlendes |
| 78 | + `model` ist kein Fehler, es gibt keine eigene Validierung, die irgendwann hinter der API des |
| 79 | + Modells zurückbleibt. |
| 80 | +- **Die Header** werden aus einer festen Positivliste gespeichert und exakt so wieder gesendet, wie |
| 81 | + sie ankamen — inklusive `Authorization` und der `X-Litellm-`-Tags, mit denen wir Kosten pro |
| 82 | + Anwendungsfall auswerten. Alles andere wird verworfen statt weitergereicht; ein `Cookie` aus einem |
| 83 | + Browser hat beim Modell nichts zu suchen. |
| 84 | + |
| 85 | +Daraus folgt der zweite Punkt: **Das Gateway besitzt keine Zugangsdaten.** Es gibt keinen |
| 86 | +Master-Key und keine Benutzertabelle. Was den Aufruf beim Modell legitimiert, ist der Schlüssel des |
| 87 | +Aufrufenden — so wie vorher auch, als noch direkt gerufen wurde. |
| 88 | + |
| 89 | +Weil der Aufruf nach oben erst stattfindet, wenn die ursprüngliche HTTP-Verbindung längst geschlossen |
| 90 | +ist, müssen die Header zwischengelagert werden. Sie liegen verschlüsselt beim Eintrag, werden von der |
| 91 | +API nie zurückgegeben, im Dashboard nur mit *Namen* angezeigt und gelöscht, sobald der Eintrag einen |
| 92 | +Endzustand erreicht. Zurück bleibt ein SHA-256-Hash des Tokens — genug, um einen Eintrag seinem |
| 93 | +Urheber zuzuordnen, zu wenig, um damit etwas anzustellen. |
| 94 | + |
| 95 | +:::callout{type="info" title="Wem ein Eintrag gehört"} |
| 96 | +Lesen, abbrechen und löschen darf nur, wer denselben Schlüssel schickt, mit dem der Eintrag erstellt |
| 97 | +wurde. Passt er nicht, antwortet die API mit `404` statt mit `403` — sie bestätigt eine ID nicht, die |
| 98 | +sie ohnehin nicht ausliefern würde. |
| 99 | +::: |
| 100 | + |
| 101 | +## Mehrere Modelle, mehrere Gateways |
| 102 | + |
| 103 | +Wer lokal arbeitet, hat selten genau eine Maschine. Bei uns sind es ein Mac mit Ollama, eine |
| 104 | +LiteLLM-Instanz vor mehreren Modellen und, für Aufgaben ohne schützenswerten Inhalt, ein |
| 105 | +kommerzieller Anbieter. |
| 106 | + |
| 107 | +Jedes dieser Ziele wird im Dashboard als «Gateway» erfasst und bekommt eine ID, die vor dem `/v1` |
| 108 | +in der URL steht: |
| 109 | + |
| 110 | +```bash |
| 111 | +curl https://gateway.example/v1/chat/completions # das Standard-Gateway |
| 112 | +curl https://gateway.example/9f3c8b52-…/v1/chat/completions # ein bestimmtes |
| 113 | +``` |
| 114 | + |
| 115 | +Ein SDK, das mit `base_url = https://gateway.example/{id}/v1` konfiguriert ist, spricht damit ohne |
| 116 | +weitere Anpassung genau ein Ziel an — absenden und abholen. Drei Protokolle werden unterstützt: |
| 117 | +OpenAI-kompatibel (also LiteLLM, vLLM, Ollamas eigener Shim und alles andere in dieser Form), |
| 118 | +Anthropic und Ollama nativ. Bei den letzten beiden übersetzt das Gateway hin und zurück; bei |
| 119 | +OpenAI-kompatiblen Zielen bleibt es reiner Durchgang. |
| 120 | + |
| 121 | +| Protokoll | Endpunkt beim Ziel | Body | |
| 122 | +| --- | --- | --- | |
| 123 | +| OpenAI-kompatibel | `{base}/v1/chat/completions` | unverändert durchgereicht | |
| 124 | +| Anthropic | `{base}/v1/messages` | übersetzt hin und zurück | |
| 125 | +| Ollama | `{base}/api/chat` | übersetzt hin und zurück | |
| 126 | + |
| 127 | +Ein Gateway zu deaktivieren stoppt nur *neue* Anfragen. Bereits eingereihte Einträge sind fest mit |
| 128 | +dem Ziel verbunden, das sie bei der Annahme zugewiesen bekommen haben — ein Wechsel des Standards |
| 129 | +lenkt also nie eine Antwort um, auf die jemand gerade wartet. |
| 130 | + |
| 131 | +## Warten mit Anzeige |
| 132 | + |
| 133 | +Eine Warteschlange, die nicht sagt, wie lange es dauert, ist auch nur eine Blackbox. Jede Antwort zu |
| 134 | +einem wartenden Eintrag enthält deshalb einen eigenen Block: |
| 135 | + |
| 136 | +```json |
| 137 | +"queue": { |
| 138 | + "position": 3, |
| 139 | + "depth": 7, |
| 140 | + "workers": 2, |
| 141 | + "estimated_seconds": 84 |
| 142 | +} |
| 143 | +``` |
| 144 | + |
| 145 | +`position` zählt den Eintrag selbst mit, `depth` ist der gesamte Rückstand, und `estimated_seconds` |
| 146 | +rechnet bis zur *eigenen* Antwort, nicht bis zum Beginn der Bearbeitung — das ist die Zahl, die man |
| 147 | +einem wartenden Menschen zeigen kann. |
| 148 | + |
| 149 | +Sie ist eine Hochrechnung, keine Zusage: der Median der letzten tatsächlichen Antwortzeiten für |
| 150 | +dasselbe Modell, multipliziert mit der Anzahl Runden, die vor einem liegen. Was sie nicht sehen |
| 151 | +kann, ist ein Modellwechsel — eine Anfrage, die Ollama zwingt, ein anderes Modell zu laden, dauert |
| 152 | +deutlich länger, und kein Median über vergangene Antworten weiss davon im Voraus. |
| 153 | + |
| 154 | +## Was das Gateway bewusst nicht tut |
| 155 | + |
| 156 | +Wir halten die Grenzen für den ehrlicheren Teil einer Ankündigung: |
| 157 | + |
| 158 | +- **Kein Streaming.** Ein Rückstand und ein Token-Strom widersprechen sich. `stream: true` wird |
| 159 | + zwar durchgereicht, die Antwort landet dann aber als Rohstrom im Eintrag. Wer Streaming braucht, |
| 160 | + ruft das Modell direkt. |
| 161 | +- **Abbrechen nur, solange etwas wartet.** Eine laufende Ollama-Anfrage lässt sich nicht sauber |
| 162 | + stoppen — ein Abbruch im Zustand `in_progress` wäre ein Versprechen, das die Anwendung nicht |
| 163 | + halten kann. |
| 164 | +- **Keine Übersetzung von Tool-Calls.** Die Abbildung zwischen OpenAI-`tools` und Anthropic-`tool_use` |
| 165 | + ist ein Thema für sich und ist nicht umgesetzt. |
| 166 | +- **Bilder fallen auf den nativen Routen weg.** Auf den Wegen zu Anthropic und Ollama werden |
| 167 | + multimodale Inhalte auf ihren Text reduziert, weil beide Bilder unterschiedlich genug |
| 168 | + transportieren, dass Raten schlechter wäre als eine sichtbare Lücke. |
| 169 | + |
| 170 | +:::callout{type="warning" title="Ein Worker pro paralleler Anfrage"} |
| 171 | +Mehr Worker zu starten, als das Modell gleichzeitig beantworten kann, verschiebt den Rückstand |
| 172 | +lediglich aus der eigenen Datenbank in Ollamas undurchsichtige Warteschlange — also genau dorthin, |
| 173 | +wo man ihn nicht mehr sieht. Bei Ollama entspricht die richtige Zahl `OLLAMA_NUM_PARALLEL`, in der |
| 174 | +Regel zwei. |
| 175 | +::: |
| 176 | + |
| 177 | +## Verfügbar unter MIT-Lizenz |
| 178 | + |
| 179 | +Das Gateway ist eine Laravel-Anwendung mit PostgreSQL, getestet mit Pest und statisch geprüft auf |
| 180 | +PHPStan-Level 10. Es läuft bei uns auf einem Mac unter Herd, hinter einem Cloudflare Tunnel; die |
| 181 | +Konfiguration dafür — launchd-Agents und Ingress-Regeln mit Default-Deny — liegt im Repository, |
| 182 | +statt in der Shell-History von jemandem zu verschwinden. |
| 183 | + |
| 184 | +Wir veröffentlichen es so, wie wir es einsetzen. Es löst kein grosses Problem, sondern ein |
| 185 | +konkretes: Es macht lokale Modelle für interne Werkzeuge planbar, ohne dass jedes dieser Werkzeuge |
| 186 | +seine eigene Warteschlange erfinden muss. |
| 187 | + |
| 188 | +- [codebar-ag/llm-gateway.codebar.ai auf GitHub](https://github.com/codebar-ag/llm-gateway.codebar.ai) |
| 189 | + |
| 190 | +Fehlerberichte und Pull Requests sind willkommen. Sicherheitslücken bitte nicht über ein |
| 191 | +öffentliches Issue, sondern auf dem in `SECURITY.md` beschriebenen Weg. |
0 commit comments