Projetos / Jano github.com/a7t-ai/jano

  • [architect]
  • [autopilot]

Jano

Router e fila de LLM local-first.

Um Mac Studio, um time pequeno de agentes e muitos swaps de modelo. Jano fica na frente do llama-server e companhia, demuxa por campo de modelo e serve uma rajada de chamadas do mesmo modelo antes de trocar para o próximo. As ventoinhas ficam quietas.

src/queue.ts
1
2
3
4
5
6
7
8
9
10
11
export function takeNext<T extends { model: ModelName }>(
  q: T[],
  loaded: ModelName | null,
): T | null {
  if (loaded !== null) {
    const idx = q.findIndex((t) => t.model === loaded);
    if (idx !== -1) return q.splice(idx, 1)[0]!;
  }
  return q.shift() ?? null;
}
// the only batching policy I trust to run unattended
Jano

Tenho um Mac Studio atrás de um time pequeno de agentes que todos querem falar com um modelo de linguagem, normalmente com modelos diferentes ao mesmo tempo.

Dar a cada um uma linha direta para o llama-server deixa a máquina numa tempestade permanente de carregar e descarregar pesos, com os coolers nunca em silêncio. Pré-carregar um modelo por processo evita isso e aí estoura a RAM. O Jano é a peça pequena na frente que transforma dez dessas requests em um único swap de modelo.

O formato do problema

A maioria das histórias de rodar LLM localmente assume um cliente. Setups de produção resolvem com autoscaling e uma frota de GPUs. Eu tenho um setup de engenheiro indie: uma máquina, um desenvolvedor, vários agentes pequenos que disparam enfileiradamente.

As rajadas são a parte interessante. Um scheduler acorda para rascunhar um tweet e faz uma request. O pi revisa um diff e faz uns três. Um build dispara e manda quinze. A carga é em picos, normalmente clusterizada e homogênea (mesmo modelo, dez vezes em dois segundos).

Jano explora isso.

Como funciona

Jano é um servidor HTTP compatível com OpenAI em uma porta específica (configurável). Qualquer coisa que já fale a Chat Completions API da OpenAI pode apontar para ele sem perceber diferença.

Quando uma chamada chega, o Jano pega o modelo do request e empilha a task numa fila interna única. O dispatcher lê essa fila com fome. Se o modelo carregado ainda tem tasks esperando, ele atende essas primeiro em vez de trocar. Um modelo diferente só é ativado quando não sobra nada para o que está quente. Ativar significa chamar um script de swap que você fornece, dar health-poll no backend até estar pronto, e fazer stream da resposta direto para o cliente. O modelo fica carregado até a fila forçar uma troca.

A vitória é que uma rajada de N chamadas para o mesmo modelo custa um swap, não N. Na minha máquina é a diferença entre uma tempestade chata de três minutos e um único load de menos de vinte segundos seguido de dez respostas streamed rápidas.

A outra vitória: uma chamada de chat pode ir para um modelo pequeno e rápido e uma de código pode ir para um pesado, do mesmo cliente, porque o router demuxa pelo campo model. O cliente nem percebe.

O mesmo cenário canônico de seis requests que os testes unitários cobrem, animado de ponta a ponta. FIFO estrito forçaria três swaps de modelo aqui. Greedy reduz para um.

O que está em escopo

Chat Completions compatível com OpenAI para os dois backends llama.cpp e MLX. Streaming e payloads de tool-call passam direto, então a experiência do cliente é idêntica a falar com o backend. O trabalho de carregar fica nas mãos de um script de swap que você fornece, o que significa que “carregar esse modelo” pode ser launchctl, systemctl --user, Docker, ou o que você quiser. Swap é idempotente e protegido por health-poll, então estados meio-carregados nunca recebem tráfego. Um orçamento de falhas consecutivas de swap cai fora rápido depois de algumas, então um backend morto não come um timeout de swap por request. Um endpoint /health fica ali para outra infra consultar, do qual os demos ao vivo deste site vão depender.

O que está deliberadamente fora de escopo

Scheduling multi-GPU: uma máquina, um acelerador. Tenancy e cotas: um único usuário. Qualquer coisa parecida com “marketplace” ou “broker”.

Intencional. O router atende a minha infraestrutura, onde eu sou o único escritor. Se você tem setup parecido, Jano são algumas centenas de linhas que dá para emprestar. Se você tem uma frota, você quer vLLM ou Triton, não isso.

Pergunta razoável a essa altura: “por que não usar Ollama?” O Ollama já serve múltiplos modelos a partir de um único binário Go, com endpoints compatíveis com OpenAI e carregamento sob demanda, e se você ainda não tem uma preferência de backend, essa é a resposta certa. O Jano existe para o caso em que você já se comprometeu com llama-server, mlx_lm.server ou vLLM direto, e quer manter esse backend em vez de embrulhá-lo em outro runtime. O router é uma camada fina na frente do que você escolheu, pequena o suficiente para ler de uma sentada só, e a política de swap são três linhas que você consegue alterar.

O que eu faria diferente

Eu teria começado com a fila greedy. O primeiro corte foi FIFO estrito, e o resultado foi exatamente o thrashing que construí o Jano para evitar. A política de batching real são três linhas (takeNext em src/queue.ts) e deveria ter estado lá desde o commit um, não retrofitada depois.

A política greedy tem um caso patológico que eu ainda não vi mas vou ver. Se um modelo recebe um fluxo constante de entrada e o outro recebe requests ocasionais, o ocasional pode passar fome. Concretamente: cinquenta requests de code chegando mais rápido do que conseguem ser servidas, uma request de chat parada na fila, e o chat espera até as cinquenta de code drenarem. A correção é um contador de max-batch que força um swap depois de N atendimentos consecutivos do mesmo modelo. Vou adicionar quando ver o padrão de verdade, não antes.

Se eu fosse começar hoje, eu olharia com mais atenção para a flag --models-preset do llama.cpp, que deixa um único llama-server hospedar os dois modelos com swap LRU interno e uma URL de front-door única. A fórmula do brew não trazia essa flag na época em que escrevi o Jano, e manter um build feito da fonte era um custo que eu não queria assumir. O Jano é a solução de ponte. Também são algumas centenas de linhas que dá para auditar em uma noite, o que é um valor por si só.

Como rodar

Repo: https://github.com/a7t-ai/jano.

A maior parte do valor está em dois arquivos: o roteador greedy em src/queue.ts e o contrato de ativação do modelo em src/swap.ts para o backend que você apontar. Se quiser o mesmo setup na sua máquina, o README tem o plist do launchd e o layout de env. brain-llm coder|chat troca qual modelo está quente, e o Jano é a coisa na frente disso.

O que eu faria por você

Se você tem uma workstation ou um cluster de dev atrás de um único servidor de modelo e está esbarrando em thrashing, o mesmo router encaixa na frente do seu llama-server (ou vLLM, ou Ollama) e te dá essa propriedade de “batchar uma vez” sem mudar nada no servidor de modelo. Me conta como é seu tráfego e a gente discute o deploy.