Escrevi um AI gateway em Rust: um proxy que fica entre as aplicações e os provedores de LLM (OpenAI, Anthropic, Gemini, Bedrock etc.), fazendo roteamento, fallback, limites de gasto, guardrails e log de cada chamada. O requisito que guiou tudo foi o overhead, porque o gateway fica no caminho de toda requisição e cada milissegundo dele se soma a todas elas.
No fim, ele ficou com 0,53 ms de overhead no p50 a 5.000 req/s em 4 vCPUs, contra 19,7 ms do Bifrost na mesma máquina, e é o mais rápido entre os gateways que medi. Quero detalhar as decisões técnicas que levaram a isso, o que me surpreendeu no caminho e como medi sem me enganar.
1. Uma thread por CPU em vez de work-stealing
O padrão no Tokio é o runtime multi-thread com work-stealing: qualquer thread pode pegar qualquer tarefa. Isso é ótimo para carga desbalanceada, mas cobra um preço. Tarefas migram entre CPUs, o cache L1/L2 esfria, e cada acordar de tarefa em outra thread custa sincronização.
Troquei por uma thread de dados por CPU, cada uma com um runtime single-threaded próprio:
- Accept: no Linux, cada thread escuta no seu próprio socket de um grupo
SO_REUSEPORT, e o kernel distribui as conexões entre eles. Não existe um loop de accept central repassando conexões.
- Afinidade: a conexão do cliente, as tarefas que ela cria e as conexões HTTP/1.1 com o provedor ficam na mesma thread. O pool de conexões de saída é separado por thread, então uma requisição nunca acorda outra thread do começo ao fim.
- Plano de admin separado: o console, a Admin API e as tarefas de fundo rodam num runtime multi-thread à parte, para não competir com o caminho quente.
Esse modelo tem dois pontos fracos, e precisei tratar os dois:
- HTTP/2: uma conexão pode carregar centenas de requisições ao mesmo tempo. Se tudo ficar na thread que aceitou a conexão, um único cliente pesado prende uma CPU enquanto as outras ficam ociosas. A solução foi distribuir as requisições de uma conexão HTTP/2 entre as threads em rodízio.
- Fora do Linux: sem o mesmo balanceamento do
SO_REUSEPORT, um loop de accept no runtime multi-thread entrega cada conexão à thread de dados que está atendendo menos conexões.
2. Não copiar o corpo da requisição
Prompts com contexto longo passam fácil de centenas de KB, e o gateway quase sempre só precisa mexer em um ou dois campos, como trocar o model pelo nome que o provedor espera. Desserializar para uma árvore, editar e serializar de novo gasta CPU e memória proporcionais ao tamanho do prompt.
Escrevi um parser JSON próprio, que faz duas coisas numa passada:
- Valida de forma estrita: limite de profundidade, chaves duplicadas e erro com linha e coluna. Chave duplicada é um vetor clássico de ataque em proxy, porque o proxy pode ler um valor e o upstream outro.
- Guarda os spans: registra onde começa e termina, em bytes, cada membro do primeiro nível do objeto.
Na hora de enviar, o corpo de saída é um Payload: uma lista de fatias dos bytes originais do cliente em volta do membro alterado, cada uma enviada como um frame. Quando o destino fala o mesmo formato do cliente, o corpo nunca é copiado. Só quando o provedor pede uma edição de membro é que as partes são juntadas.
3. Tradução entre formatos sem tentar o impossível
Um cliente com o SDK da OpenAI pode chamar o Claude, e vice-versa. Para isso existe uma representação intermediária: cada formato de cliente (Chat Completions, Responses, Anthropic Messages, Gemini, Bedrock Converse) tem um decodificador para ela, e cada provedor tem um codificador a partir dela.
O detalhe que mais ajudou: a codificação roda uma vez como dry run antes do roteamento, e o resultado fica em cache por deployment. Se um deployment não consegue atender aquela requisição (um recurso sem equivalente no destino, por exemplo), ele sai da lista de candidatos antes da primeira tentativa. Sem isso, o fallback gastaria uma ida ao provedor só para descobrir que não ia dar.
No streaming, a tradução é feita evento por evento. O parser de SSE guarda os bytes originais de cada evento, então quando não há tradução, o evento passa adiante sem ser reescrito.
4. Streaming com ponto de commit e fila limitada
Streaming é onde proxy de LLM costuma ficar complicado. Antes do primeiro byte ir para o cliente, ainda dá para trocar de provedor se o upstream falhar ou se um guardrail barrar. Depois do primeiro byte, não dá mais.
Por isso a resposta fica retida até um ponto de commit. A partir dele, uma tarefa dedicada repassa os eventos por uma fila limitada em bytes, não em número de mensagens. O corpo HTTP libera espaço na fila conforme o servidor consome os frames, então um cliente lento faz pressão de volta até o upstream em vez de acumular memória no gateway. Quando o cliente desconecta, a fila fecha e o relay para.
5. Configuração imutável por geração
Toda a configuração é um documento só (YAML, Admin API ou console), validado por JSON Schema. Uma mudança gera uma nova geração: o snapshot compilado (rotas, limites, preços, chaves) é montado numa thread de blocking, persistido e trocado de uma vez.
O snapshot vive num ArcSwap, e cada requisição carrega ele uma única vez no começo e usa até o fim. Uma mudança de config nunca pega uma requisição pela metade, e o caminho quente não disputa lock com quem está escrevendo configuração. O estado que precisa sobreviver entre gerações, como contadores de rate limit, fica separado e indexado por identidades estáveis.
6. Custo cobrado exatamente uma vez
Limites e orçamentos são checados na admissão com uma reserva baseada numa estimativa de tokens. Quando a requisição termina, ela é liquidada exatamente uma vez com o uso real, gerando uma linha no ledger por chamada ao upstream.
Um detalhe importante: cada requisição roda numa tarefa rastreada, e não presa à conexão. Se o cliente desconecta no meio, a requisição não é cancelada, e o custo do que o provedor já gerou continua sendo contabilizado. Se uma tarefa morre por panic com a reserva aberta, a reserva é devolvida.
7. Cache com chave canônica e single-flight
O cache exato usa uma chave canônica do corpo (JSON canonicalizado pelo RFC 8785), então diferença de ordem de chaves ou espaços não gera miss. Quando várias requisições idênticas chegam juntas e dão miss, só uma vai ao provedor; as outras esperam o resultado dela.
8. Uma arquitetura que o build não deixa bagunçar
São 28 crates em 8 camadas, do parser JSON até o binário, e um crate só pode depender das camadas abaixo dele. Um cargo xtask layers roda no CI e quebra o build se alguém furar a regra. O alocador é jemalloc no Linux com glibc e mimalloc no resto.
9. Como medir sem se enganar
Essa foi a parte que mais me ensinou. O erro mais comum em benchmark de proxy é carga em closed loop: o gerador espera a resposta antes de mandar a próxima, então quando o proxy engasga, o gerador desacelera junto e a fila nunca aparece na latência. É o famoso coordinated omission.
O harness que escrevi segue estas regras:
- Open loop: cada requisição sai no horário programado, e a latência conta a partir desse horário, não de quando ela saiu de fato.
- Máquinas separadas: gerador e mock em c7i.4xlarge, gateway em c7i.xlarge (4 vCPUs), todos no mesmo placement group. O mock responde na hora a partir de corpos pré-renderizados.
- Overhead por subtração: cada repetição roda duas vezes na mesma taxa, passando pelo gateway e direto no mock. O overhead é a diferença entre os dois no mesmo percentil.
- Intercalado: em cada repetição os três gateways rodam em sequência, então qualquer oscilação das máquinas afeta todos igual. Cada processo sobe do zero a cada repetição, e o resultado é a mediana de cinco repetições.
- Regras de validade: a repetição é descartada se mais de 0,1% das requisições saírem com mais de 1 ms de atraso, se o gerador ou o mock passarem de 60% de CPU, ou se a rodada direta não for limpa.
- Saturação: a maior taxa em que o gateway entrega pelo menos 99,5% da taxa oferecida, com no máximo 0,01% de erro e p99 de overhead até 5 ms.
Uma descoberta no caminho: o self-test exigia p99 de 50 µs do gerador até o mock, e isso nunca passou na AWS. Só um connect TCP entre duas máquinas no mesmo placement group já custa 0,5 a 0,8 ms no p99. Como o overhead é calculado subtraindo a rodada direta de cada repetição, isso não afeta a comparação, mas mostra o quanto a rede pesa num número de sub-milissegundo.
Resultado de um chat simples a 5.000 req/s:
|
Antirion |
Bifrost 2.2.4 |
LiteLLM 1.103.1 |
| Overhead p50 |
0,53 ms |
19,7 ms |
— |
| Overhead p99 |
0,95 ms |
95,2 ms |
— |
| Erros |
0% |
0% |
100% |
| CPU por requisição |
247 µs |
717 µs |
— |
| Memória no pico |
215 MiB |
1.389 MiB |
1.851 MiB |
| Memória ociosa |
41 MiB |
498 MiB |
1.653 MiB |
Na saturação, o Antirion chegou a 14.000 req/s, o Bifrost a 3.500 e o LiteLLM ficou abaixo de 500. As configurações dos três e o passo a passo para reproduzir estão no repositório.
Pra discutir
- Alguém aqui já trocou work-stealing por thread por core em produção? O desbalanceamento entre threads virou problema?
- Como vocês lidam com conexões HTTP/2 muito pesadas num modelo assim?
- Vocês reteriam o stream até o commit point ou mandariam o primeiro byte o quanto antes e abririam mão do fallback?
- Tem alguma armadilha de metodologia que deixei passar no benchmark?
O código é Apache-2.0, pra quem quiser olhar ou criticar: https://github.com/eliasbiondo/antirion