Jede Nachricht liest deine Config: Hermes hat den Lock von diesem Hot Path genommen


Du bittest Hermes, eine Kompressionsschwelle anzuheben. Es tut das und schreibt die Änderung zurück in config.yaml. Während dieser paar hundert Millisekunden schickst du eine Nachricht an deinen eigenen Bot auf Telegram — und es kommt nichts zurück. Es ist nicht das Modell und nicht das Netzwerk. Diese Nachricht wartet auf einen Lock.

Das klingt nach einem Randfall. Ist es nicht; es passiert täglich. Der Grund ist banal: Bevor Hermes beantworten kann, „welches Modell nutze ich gerade“, „ist dieses Plugin an“ oder „wie lang ist dieser Timeout“, müssen die meisten seiner kritischen Pfade die Config lesen. Und das Lesen der Config war schon immer eine teure Operation, die sich außerdem anstellen muss. Dieser Arbeitsblock (#118961, der #115069, #115070 und #117440 einschließt) entfernt das redundante Parsen und den globalen Lock von diesem Pfad.

Woher die Kosten tatsächlich kamen

Config ist YAML, das Lesen bedeutet also: die Datei finden, das YAML parsen, es tief über die Defaults mergen und dem Aufrufer dann eine Kopie übergeben. Um nicht eine halb geschriebene Datei zu lesen, lief all das unter einem einzigen globalen _CONFIG_LOCK.

Daraus folgen zwei Probleme:

  1. Jeder Read parste neu. Selbst wenn kein einziges Byte sich geändert hatte. Manche Pfade waren schlimmer — die Streaming-Ausgabe las die Config pro Reasoning-Token neu, um zu entscheiden, ob Deltas ausgegeben werden, und die Heartbeat-Wiederherstellung betrat jeden Profile-Scope pro Zeile erneut.
  2. Reads stellten sich hinter Writes an. Wenn ein Writer (etwa save_config()) den Lock hielt, wartete jeder Read. Auf einem Gateway sitzt dieser Read auf dem Hook-Pfad jeder eingehenden Nachricht, sodass ein einziges Speichern die Nachrichtenverarbeitung aller blockieren konnte.

Fix eins: einmal pro Stream lesen, nicht pro Token

Drei eingeschlossene Änderungen, jede entfernt eine Wiederholung:

  • Streaming-Auslieferung (agent/stream_delivery.py): plugins.stream_reasoning_deltas wird jetzt einmal pro Stream aufgelöst statt einmal pro Reasoning-Token. Die Messung im PR: ungefähr 340 µs runter auf etwa 23 µs pro Read.
  • Heartbeat-Wiederherstellung (gateway/run_heartbeat_restore.py): jeder Profile-Scope wird einmal pro Restore-Scan betreten statt einmal pro Zeile.
  • Profile-Config-Parsen (utils.load_yaml_file_readonly): Parses der config.yaml eines Profiles werden auf der Dateisignatur (mtime_ns, Größe, Inode, ctime_ns) gecacht — nur positive Ergebnisse, sodass eine geänderte Datei neu geparst wird, statt veraltet ausgeliefert zu werden.

Fix zwei: ein Cache-Treffer nimmt den Lock nicht mehr

Das ist der wichtige. load_config() in hermes_cli/config.py hat jetzt einen lock-freien Fast Path, und der Quellkommentar benennt die Tragweite unverblümt:

# Lock-freier Fast Path für Cache-Treffer — derselbe Publikationsvertrag wie
# `_read_raw_config_impl` oben (Ersetzen des ganzen Tupels, `_CONFIG_LOCK` serialisiert
# nur Rebuilds und Writer). Ein Treffer kostet ~0.024ms; hinter einem von `save_config()`
# gehaltenen Lock wurden für denselben Read 10010ms gemessen, und auf einem Gateway
# blockiert das den Hook-Pfad jeder eingehenden Nachricht. Ein verlorenes Race fällt auf den Lock durch.

Warum ist das sicher? Wegen eines expliziten Publikationsvertrags: der Cache-Eintrag wird als ein einziges ganzes Tupel ersetzt, und der Lock serialisiert nur Rebuilds und Writer. Ein Leser sieht daher entweder das vollständige alte Tupel oder das vollständige neue — niemals einen zerrissenen Zwischenzustand. Nachdem er den Snapshot genommen hat, prüft er die Dateisignatur erneut; wenn die Signatur nicht passt, wird der Read als Miss behandelt und geht den gelockten Pfad hinunter.

Anders gesagt: Reads müssen sich nicht mehr anstellen, weil sie keinen Zustand unter Schutz lesen — sie lesen einen Snapshot, der niemals in-place mutiert wird. Und das Race zu verlieren ist kein Korrektheits-Glücksspiel: Der Durchfall ist die ursprüngliche, vollständig defensive Implementierung.

Derselbe Fast Path wurde auf read_raw_config() und read_raw_config_readonly() ausgeweitet.

Die Messwerte im PR (auf der Maschine des Autors, mit einem Writer, der _CONFIG_LOCK 2 Sekunden lang hält):

Read Vorher Nachher
Gecachter load_config()-Treffer ~2.008 ms 0,3–1,9 ms
read_raw_config() ~2.001 ms 0,1–0,2 ms
Schlimmster im Quellkommentar festgehaltener Fall 10.010 ms ~0,024 ms

Außerdem wurde verifiziert, dass ein Wechsel von HERMES_HOME A → B → A keine Cache-Leckage zwischen Homes zeigt, was genau das ist, worauf es in Multi-Profile- und Multi-Gateway-Deployments ankommt.

Fix drei: eine Memoisierung löschen, die sich nicht mehr bezahlt machte

In diesem Block steckt ein schönes Detail: Der Autor hat eine Memoisierung entfernt, statt sie zu behalten.

hermes_cli/plugins.py hatte ein Memo für hook_callback_timeout. Als load_config-Treffer lock-frei wurden, sparte das Memo fast nichts mehr — der PR nennt 8,4 µs für einen Memo-Treffer gegenüber 9,0 µs für einen direkten Aufruf von load_config_readonly(), eine Differenz von 0,6 µs. Aber es hatte einen echten Nebeneffekt: Es umging die Environment-Snapshot-Prüfung von load_config, sodass eine Änderung der Variable den Timeout nicht aktualisiert hätte, wenn du ihn als Environment-Referenz ausgedrückt hättest — die ${VAR}-Form.

0,6 µs zu sparen und dafür in eine Falle zu laufen, bei der „man ändert die Env-Variable und nichts passiert“, ist ein schlechtes Geschäft. Also wurde sie gelöscht statt optimiert.

Was das für dich bedeutet

  • Gleichmäßigere Nachrichtenlatenz auf Gateways. „Config speichern“ und „Nachrichten empfangen“ stören sich nicht mehr.
  • Nichts zu konfigurieren. Das ist kein Schalter, den du umlegst; es ist Standardverhalten.
  • Multi-Profile- und Multi-Gateway-Setups profitieren am meisten, weil Heartbeat-Wiederherstellung und das Betreten von Profile-Scopes Kosten waren, die mit der Anzahl der Profiles skalierten.

Wenn du stattdessen auf „die Maschine fühlt sich langsam an, und die CPU ist beschäftigt, während sie idle ist“ gestoßen bist, ist das ein anderer Perf-Sweep — siehe die Ausarbeitung zu langsamer Disk und idle CPU. Wenn dein Schmerz lange Aufgaben sind, die mittendrin hängen bleiben, ist Tuning für lange Aufgaben die nähere Lektüre. Wie Config von der CLI gelesen und geschrieben wird, steht auf der Referenzseite hermes config; und warum es sich auszahlt, mehrere Profiles auf einem Host zusammenzulegen, steht in ein Gateway pro Host.

Zum Schluss

Keine neuen Features und keine neuen Config-Keys hier — nur eine Idee: Config lesen sollte weder anstehen noch neu parsen. Ein Cache-Treffer ging von „den Lock nehmen und neu parsen“ zu „einen Snapshot lesen, der nie in-place mutiert wird, und dann die Signatur verifizieren“, mit der ursprünglichen gelockten Implementierung als Fallback. Die Performance-Zahlen sind beeindruckend, aber wertvoller ist die Abwägung: Eine Memoisierung, die 0,6 µs wert ist und dabei stillschweigend eine Änderung der Environment-Variable ignorieren konnte, ist es nicht wert, behalten zu werden.