Toda mensagem lê sua config: o Hermes acabou de tirar o lock dessa rota quente


Você pede ao Hermes para subir um limiar de compressão. Ele sobe, e escreve a mudança de volta em config.yaml. Durante esses poucos cem milissegundos você dispara uma mensagem para o seu próprio bot no Telegram — e nada volta. Não é o modelo e não é a rede. Aquela mensagem está esperando um lock.

Isso parece um caso extremo. Não é; acontece diariamente. A razão é mundana: antes que o Hermes consiga responder “qual modelo estou usando”, “este plugin está ligado” ou “quanto dura este timeout”, a maioria das suas rotas críticas precisa ler a config. E ler a config sempre foi uma operação cara que também tem de entrar numa fila. Este lote de trabalho (#118961, que absorve #115069, #115070 e #117440) remove o parse redundante e o lock global dessa rota.

De onde vinha o custo, de fato

A config é YAML, então lê-la significa: localizar o arquivo, fazer o parse do YAML, mesclar em profundidade sobre os padrões e então entregar uma cópia a quem chamou. Para não ler um arquivo meio escrito, tudo isso rodava sob um único _CONFIG_LOCK global.

Dois problemas decorrem disso:

  1. Toda leitura reparseava. Mesmo quando nem um byte havia mudado. Algumas rotas eram piores: a saída em streaming relia a config por reasoning token para decidir se emitia deltas, e a restauração de heartbeat reentrava em cada escopo de profile por linha.
  2. As leituras entravam na fila atrás das escritas. Se um escritor (save_config(), por exemplo) segurava o lock, toda leitura esperava. Em um gateway, essa leitura fica no caminho de hook de toda mensagem de entrada, então um único save podia travar o processamento de mensagens de todo mundo.

Correção um: ler uma vez por stream, não por token

Três mudanças absorvidas, cada uma removendo uma repetição:

  • Entrega em streaming (agent/stream_delivery.py): plugins.stream_reasoning_deltas agora é resolvido uma vez por stream, e não uma vez por reasoning token. A medição do PR: de cerca de 340 µs para cerca de 23 µs por leitura.
  • Restauração de heartbeat (gateway/run_heartbeat_restore.py): cada escopo de profile é entrado uma vez por varredura de restauração, em vez de uma vez por linha.
  • Parse da config de profile (utils.load_yaml_file_readonly): os parses do config.yaml de um profile são cacheados pela assinatura do arquivo (mtime_ns, tamanho, inode, ctime_ns) — apenas resultados positivos, então um arquivo alterado é reparseado em vez de servido obsoleto.

Correção dois: um acerto de cache não toma mais o lock

Esta é a importante. load_config() em hermes_cli/config.py agora tem um caminho rápido lock-free, e o comentário no código declara o que está em jogo sem rodeios:

# Caminho rápido lock-free para acertos de cache — mesmo contrato de publicação que
# `_read_raw_config_impl` acima (substituição da tupla inteira, `_CONFIG_LOCK` só
# serializa reconstruções e escritores). Um acerto custa ~0,024 ms; atrás de um lock
# segurado por `save_config()` a mesma leitura media 10.010 ms, e em um gateway isso
# trava o caminho de hook de cada mensagem de entrada. Uma corrida perdida cai para o lock.

Por que isso é seguro? Por causa de um contrato de publicação explícito: a entrada de cache é substituída como uma única tupla inteira, e o lock só serializa reconstruções e escritores. Um leitor, portanto, vê ou a tupla antiga completa ou a nova completa — nunca um estado intermediário rasgado. Depois de tirar o snapshot ele reconfere a assinatura do arquivo; se a assinatura não bater, a leitura é tratada como miss e desce pelo caminho com lock.

Em outras palavras, as leituras não precisam mais entrar na fila porque não estão lendo estado sob proteção — estão lendo um snapshot que nunca é mutado in-place. E perder a corrida não é uma aposta de correção: o fall-through é a implementação original, totalmente defensiva.

O mesmo caminho rápido foi estendido a read_raw_config() e read_raw_config_readonly().

Os números das sondagens no PR (na máquina do autor, com um escritor segurando _CONFIG_LOCK por 2 segundos):

Leitura Antes Depois
Acerto de load_config() em cache ~2.008 ms 0,3–1,9 ms
read_raw_config() ~2.001 ms 0,1–0,2 ms
Pior caso registrado no comentário do código 10.010 ms ~0,024 ms

Também foi verificado que trocar HERMES_HOME A → B → A não mostra vazamento de cache entre homes, que é exatamente o que importa em implantações com múltiplos profiles e múltiplos gateways.

Correção três: apagar uma memoização que parou de se pagar

Há um detalhe interessante enterrado neste lote: o autor removeu uma memoização em vez de mantê-la.

hermes_cli/plugins.py tinha uma memoização para hook_callback_timeout. Assim que os acertos de load_config ficaram lock-free, a memoização quase não economizava nada — o PR dá 8,4 µs para um acerto de memo contra 9,0 µs para chamar load_config_readonly() direto, uma diferença de 0,6 µs. Mas ela tinha um efeito colateral real: ignorava a checagem de snapshot de ambiente do load_config, então, se você tivesse expressado esse timeout como uma referência de ambiente — a forma ${VAR} —, mudar a variável não o atualizaria.

Economizar 0,6 µs em troca de uma armadilha do tipo “mudei a variável de ambiente e nada aconteceu” é um mau negócio. Então ela foi apagada, em vez de otimizada.

O que isso significa para você

  • Latência de mensagens mais estável nos gateways. “Salvar config” e “receber mensagens” não interferem mais.
  • Nada a configurar. Isto não é um interruptor que você liga; é comportamento padrão.
  • Configurações com múltiplos profiles e múltiplos gateways são as que mais ganham, porque a restauração de heartbeat e a entrada em escopos por profile eram custos que escalavam com o número de profiles.

Se o que você estava enfrentando era outra coisa — “a máquina parece lenta, e a CPU fica ocupada enquanto está ociosa” —, essa é uma outra varredura de desempenho, veja o artigo sobre disco lento / CPU ociosa. Se a sua dor é tarefa longa travando no meio, ajuste para tarefas longas é a leitura mais próxima. Para ver como a config é lida e escrita pela CLI, a página de referência é hermes config; e para entender por que colapsar vários profiles em um único host compensa, veja um gateway por host.

Fechando

Nada de novos recursos e nada de novas chaves de config aqui — só uma ideia: ler config não deveria nem entrar na fila nem reparsear. Um acerto de cache passou de “tomar o lock e reparsear” para “ler um snapshot que nunca é mutado in-place, depois verificar a assinatura”, com a implementação original com lock como fallback. Os números de desempenho são impressionantes, mas a parte mais valiosa é o trade-off: uma memoização que valia 0,6 µs e que podia ignorar silenciosamente uma mudança de variável de ambiente não vale a pena manter.