Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

local-inference-server

Servidor de inferência local com API compatível com a da OpenAI, para usar um SLM a partir do PyGPT rodando em outra máquina da rede.

Por que estas escolhas

A máquina que hospeda o servidor tem uma GTX 750 (2 GB, compute 5.0). Isso define quase tudo:

  • O backend padrão é o llama.cpp, não o PyTorch. As wheels do torch 2.x não trazem kernels para compute 5.0 (Maxwell), enquanto o llama.cpp compilado com -DCMAKE_CUDA_ARCHITECTURES=50 roda nela sem problema.
  • O modelo de chat padrão é o Llama-3.2-1B-Instruct em Q4_K_M (0,75 GiB), com n_ctx 4096. Era Q8_0 a 8192 até o embedder virar o Qwen3-Embedding-0.6B, que não cabe na placa ao lado dele — os pesos do embedder ficam na RAM, e mesmo assim o grafo dele precisa da VRAM que o chat liberou. Ver Modelos e VRAM.

Instalação

uv sync --no-binary-package llama-cpp-python

Para compilar o llama.cpp com CUDA (recomendado — sem isso ele roda só na CPU):

CMAKE_ARGS="-DGGML_CUDA=on -DCMAKE_CUDA_ARCHITECTURES=50 -DCMAKE_CUDA_HOST_COMPILER=/usr/bin/g++-13" \
  uv sync --no-binary-package llama-cpp-python --reinstall-package llama-cpp-python

O CMAKE_CUDA_HOST_COMPILER é necessário porque o CUDA 12.4 não aceita o gcc 14 como host.

Uso

LIS_API_KEY=umsegredoqualquer uv run local-inference-server

Os dois GGUF (chat e embeddings) são baixados automaticamente para models/ no primeiro boot (~0,8 GB + 639 MB).

Para ver o que já está em disco — inclusive o outro cache, o do transformers, que fica fora do projeto — use o inventário de modelos.

Configuração

Tudo por variável de ambiente com prefixo LIS_, ou por um arquivo .env na raiz. As opções de linha de comando (ver Linha de comando) sobrescrevem as variáveis.

Variável Padrão Descrição
LIS_API_KEY (vazio) Chave exigida em Authorization: Bearer. Sem ela o servidor fica aberto.
LIS_HOST / LIS_PORT 0.0.0.0 / 8000 Endereço de escuta
LIS_BACKEND llamacpp llamacpp ou transformers
LIS_MODEL_ALIAS llama-3.2-1b Nome anunciado em /v1/models; é o que o PyGPT deve ter configurado
LIS_GGUF_REPO / LIS_GGUF_FILE Llama-3.2-1B Q4_K_M Modelo GGUF a usar
LIS_N_GPU_LAYERS -1 -1 = tudo na GPU, 0 = só CPU, N = offload parcial
LIS_N_CTX 4096 Tamanho do contexto. Subir daqui quebra os embeddings — ver Modelos e VRAM
LIS_N_BATCH / LIS_N_UBATCH 256 / 128 Lote de ingestão do prompt. Ver a nota abaixo
LIS_MAX_MODEL_LENGTH 2048 Teto de tokens gerados por resposta (metade do LIS_N_CTX)
LIS_BUSY_TIMEOUT 30 Segundos que uma requisição espera pela GPU antes do 503 server_busy
LIS_EMBEDDING_ENABLED true false sobe o servidor sem o modelo de embeddings
LIS_EMBEDDING_MODEL_ALIAS qwen3-embedding-0.6b Nome anunciado em /v1/models
LIS_EMBEDDING_GGUF_REPO / LIS_EMBEDDING_GGUF_FILE Qwen3-Embedding-0.6B Q8_0 GGUF de embeddings
LIS_EMBEDDING_N_GPU_LAYERS 0 Mesma semântica do LIS_N_GPU_LAYERS. 0 não é escolha — ver Modelos e VRAM
LIS_EMBEDDING_N_CTX 512 Contexto do embedder. Limitado pela VRAM, não pelo modelo — ver a seção Embeddings
LIS_EMBEDDING_N_UBATCH 128 Dimensiona o buffer de cálculo do embedder. Um encoder precisa disto igual ao N_CTX
LIS_EMBEDDING_BATCH_SIZE 4 Textos por chamada ao llama.cpp; fatiar solta a GPU entre as fatias
LIS_EMBEDDING_POOLING last last, mean ou cls. Depende do modelo e errar não dá erro — ver a seção Embeddings
LIS_EMBEDDING_INPUT_PREFIX (vazio) Prefixo aplicado a toda entrada. Ver a seção Embeddings
LIS_EMBEDDING_MAX_INPUTS 64 Máximo de textos por requisição
LIS_EMBEDDING_MRL true Se o modelo é Matryoshka, e portanto se dimensions é aceito

Linha de comando

Só o essencial; todo o resto continua saindo das LIS_* / .env. Quando dada, a opção sobrescreve a variável.

Opção Equivale a
--embedding-model REPO[:ARQUIVO] LIS_EMBEDDING_GGUF_REPO / LIS_EMBEDDING_GGUF_FILE
--embedding-alias NOME LIS_EMBEDDING_MODEL_ALIAS
--embedding-pooling {last,mean,cls} LIS_EMBEDDING_POOLING
--embedding-gpu-layers N LIS_EMBEDDING_N_GPU_LAYERS
--no-embeddings LIS_EMBEDDING_ENABLED=false
--host / --port LIS_HOST / LIS_PORT
uv run local-inference-server --embedding-model dono/repo-GGUF:modelo-q8_0.gguf

Modelos e VRAM

Ocupação medida com os padrões atuais, numa placa com 1993 MiB utilizáveis:

MiB
Chat Q4_K_M — pesos (17/17 camadas) 762
Chat — KV cache (n_ctx 4096) 128
Chat — buffer de cálculo (n_ubatch 128) 136
Embedder — pesos 0 (ficam na RAM)
Total em uso / livre 1304 / 689

Por que o embedder não está na GPU. O Qwen3-Embedding-0.6B não cabe ao lado do chat nesta placa, em quantização nenhuma. Foram medidos: Q8_0 com o chat a 8192 (nem carrega — pede 303 MiB de buffer de cálculo), Q8_0 com n_ubatch 128 (falha em 151 MiB), Q8_0 com o chat a 4096 (carrega e morre na primeira requisição), Q4_K_M com o chat a 8192, e Q4_K_M com n_ctx 256 e o chat a 4096. Todos terminam em CUDA error: out of memory.

A causa é o vocabulário: o Qwen3-Embedding herda os 151669 tokens do Qwen3, e o llama.cpp reserva os logits mesmo em modo embedding — n_vocab × n_seq_max × 4 bytes, onde o n_seq_max é forçado pelo llama-cpp-python para min(n_batch, 256) quando embedding=True. São 151 MiB que não dá para baixar sem derrubar o teto de tokens por texto. Um encoder tipo e5 não tem esse problema: não tem camada de saída do tamanho do vocabulário.

E --embedding-gpu-layers 0 não tira o embedder da GPU: move só os pesos. O grafo continua no CUDA (~310 MiB de buffer, mais o staging dos pesos que vêm da RAM), e é essa sobra que obriga o chat a ficar em n_ctx 4096 — com 6144 o servidor morre no primeiro embedding de texto longo.

Os 689 MiB livres não são folga desperdiçada: é o espaço que o grafo do embedder ocupa enquanto uma requisição de embeddings está em curso.

Sobre o n_ubatch. Os logits do Llama 3.2 custam n_vocab (128256) × n_ubatch × 4 bytes. Com o n_ubatch padrão do llama.cpp (512), o buffer de cálculo pede 546 MiB e o modelo não sobe — graph_reserve: failed to allocate compute buffers. Com 128, cai para 136 MiB. Isso só torna a ingestão do prompt mais lenta; a velocidade de geração não muda. O LIS_EMBEDDING_N_UBATCH existe pelo mesmo motivo, do lado do embedder.

Modelo Quant Pesos Observação
Llama-3.2-1B-Instruct (padrão) Q4_K_M 0,75 GiB é o maior que deixa espaço para o grafo do embedder
Llama-3.2-1B-Instruct Q8_0 1,23 GiB cabe só com --no-embeddings
Llama-3.2-3B-Instruct Q4_K_M 1,88 GiB offload parcial, e só com --no-embeddings
Gemma-3-Gaia-PT-BR-4b i1-Q4_K_M 2,32 GiB offload parcial, e só com --no-embeddings

O Llama-3.2 lista o português entre os idiomas oficialmente suportados, mas 1B é 1B. Os modelos melhores em português são todos maiores, e nenhum deles convive com o embedder — para usá-los é preciso desligar os embeddings:

LIS_GGUF_REPO=mradermacher/Gemma-3-Gaia-PT-BR-4b-it-i1-GGUF \
LIS_GGUF_FILE=Gemma-3-Gaia-PT-BR-4b-it.i1-Q4_K_M.gguf \
LIS_MODEL_ALIAS=gaia-4b \
LIS_N_GPU_LAYERS=20 \
  uv run local-inference-server --no-embeddings

Endpoints

Método Rota Auth
GET /health não
GET /v1/models sim
POST /v1/chat/completions sim
POST /v1/embeddings sim

O /v1/chat/completions suporta streaming SSE ("stream": true), max_tokens e max_completion_tokens, top_p, stop e stream_options.include_usage.

Chat e embeddings são serializados: a placa é uma só, e os dois modelos disputando os 2 GB se atrapalhariam. Quem chega no meio de uma inferência espera até LIS_BUSY_TIMEOUT e, se não for atendido, recebe 503 server_busy com Retry-After — um erro explícito em vez de uma conexão pendurada até o cliente desistir.

O /v1/models anuncia os dois modelos: o de chat e o de embeddings.

Embeddings

O /v1/embeddings é servido por um segundo GGUF, carregado no boot ao lado do de chat. O padrão é o Qwen3-Embedding-0.6B em Q8_0 (639 MB no disco, 1024 dimensões), multilíngue de verdade e muito acima do que um encoder pequeno entrega em português. Os pesos dele rodam na CPU (LIS_EMBEDDING_N_GPU_LAYERS=0): não cabem na placa ao lado do chat, e isso custa velocidade — ~126 tokens/s medidos aqui. Na prática, uma consulta curta sai em ~0,3s e indexar 16 chunks de 500 tokens leva ~1 minuto. O embedder é sempre llama.cpp, mesmo com LIS_BACKEND=transformers.

curl http://localhost:8000/v1/embeddings \
  -H "Authorization: Bearer $LIS_API_KEY" -H 'Content-Type: application/json' \
  -d '{"model": "qwen3-embedding-0.6b", "input": ["gato", "cachorro"]}'

Os vetores saem normalizados (norma 1), então a similaridade cosseno é só o produto escalar. encoding_format aceita float e base64 — o segundo é o que o SDK da OpenAI usa por padrão quando tem numpy instalado.

Detalhes que valem saber:

  • Pooling. O llama.cpp produz um vetor por token; o pooling é o que reduz isso a um só. O Qwen3-Embedding é um decoder causal treinado para pooling last — o GGUF declara qwen3.pooling_type = 3. Tirar a média (mean, o certo para encoders tipo BERT/e5) sobre um modelo causal devolve vetores ruins sem erro nenhum: tudo responde 200 e as similaridades ficam todas coladas. Se trocar de modelo por --embedding-model, troque o --embedding-pooling junto.
  • Teto de 512 tokens. Aqui o limite é a VRAM, não o modelo: o Qwen3 aguenta 32768 posições. Subir o LIS_EMBEDDING_N_CTX aumenta o buffer de cálculo que o grafo reserva no CUDA, e a folga é pequena — meça antes. O boot loga o teto efetivo, que também é rebaixado automaticamente ao n_ctx_train do GGUF quando o modelo aceita menos do que foi pedido.
  • O corte respeita o token final. O Llama.embed do llama-cpp-python trunca depois de tokenizar e corta pelo fim — exatamente onde fica o EOS que o Qwen3 usa como vetor no pooling last. Por isso o corte é feito antes, no texto, deixando uma posição livre para a retokenização reanexar o EOS.
  • Prefixo. Vazio por padrão. O Qwen3 usa "Instruct: <tarefa>\nQuery: <texto>" só do lado da consulta e texto cru do lado do documento; como o /v1/embeddings não distingue os dois, o simétrico correto é não prefixar nada. Modelos tipo e5, ao contrário, exigem "query: " — daí o LIS_EMBEDDING_INPUT_PREFIX continuar existindo.
  • dimensions. Aceito: o Qwen3-Embedding é treinado com Matryoshka, então o vetor é truncado no tamanho pedido (1 a 1024) e renormalizado, para o produto escalar continuar sendo o cosseno. Índices menores custam menos memória com pouca perda; 256 é um meio-termo comum. Num modelo sem MRL, ponha LIS_EMBEDDING_MRL=false e o parâmetro volta a ser recusado com 400 — truncar ali devolveria algo errado sem o cliente perceber.
  • input já tokenizado (lista de inteiros) não é aceito — os ids seriam do tokenizador da OpenAI, não do nosso GGUF.
  • Trocar de modelo. Use --embedding-model, e lembre do --embedding-pooling. Trocar o modelo muda o número de dimensões, o que invalida qualquer índice já construído. Encoders pequenos (e5, bge-small) cabem na GPU, são muito mais rápidos e permitem voltar o chat para Q8_0 a 8192 — ao custo da qualidade em português. Neles, ponha --embedding-pooling mean e LIS_EMBEDDING_N_UBATCH igual ao LIS_EMBEDDING_N_CTX (um encoder precisa da sequência inteira num ubatch só), e note que nem todo GGUF do e5 no Hub carrega — conversões antigas não gravam o token_type_count e o llama.cpp recusa com bert model needs to define token type count.

Para subir o servidor sem embeddings: --no-embeddings.

Conectando o PyGPT

Na máquina onde o PyGPT roda, confirme que o servidor responde:

curl http://<ip-do-servidor>:8000/health

No PyGPT, em Settings → API keys / Models, configure um provedor compatível com OpenAI:

  • Endpoint / API base: http://<ip-do-servidor>:8000/v1
  • API key: o valor de LIS_API_KEY
  • Model id: o valor de LIS_MODEL_ALIAS (padrão llama-3.2-1b)

Testes

uv run pytest

Cobrem as regressões que motivaram a revisão: normalização de histórico, content em partes, max_completion_tokens, autenticação, envelope de erro e o formato do SSE. Do lado dos embeddings: o ida-e-volta do base64, o prefixo de entrada, os limites de entrada, a truncagem Matryoshka do dimensions, o pré-corte que preserva o EOS e as opções de linha de comando. Nenhum teste carrega GGUF nem usa a GPU. Há ainda as regressões do incidente de timeout: o teto de tokens colado no n_ctx_train, o fatiamento do lote, o 503 server_busy e — as duas que importam — que as rotas de inferência não são corrotinas e que o /health responde durante um embedding lento. O teste do template do Gaia (tests/test_gaia_template.py) exige rede e o extra [transformers], e é pulado sem eles.

Backend transformers

Alternativa para quando houver uma GPU que o PyTorch suporte. Instala ~3 GB a mais:

uv sync --extra transformers
LIS_BACKEND=transformers uv run local-inference-server

O LIS_MODEL_ID padrão é unsloth/Llama-3.2-1B-Instruct — o mesmo modelo que o backend llamacpp serve, só que em safetensors. É o mirror e não o meta-llama/… porque o repositório oficial é gated: exige aceitar a licença e um token do Hub.

O embedder não muda com este backend: continua sendo o mesmo GGUF no llama.cpp.

Onde os modelos ficam

Há dois caches, e eles não se falam:

Onde Quem usa
GGUF models/, na raiz do projeto llama.cpp (chat e embeddings)
Safetensors, tokenizers ~/.cache/huggingface/hub (ou $HF_HOME) backend transformers, e os testes que baixam tokenizer

Trocar de modelo deixa órfãos nos dois lugares, e o segundo fica fora do projeto — some do radar com facilidade. Para ver o estado real:

uv run local-inference-models

Ele imprime o que está configurado, quais GGUF estão em models/ (marcando os que não correspondem a nenhuma configuração atual), o conteúdo do cache do Hub, e os comandos de limpeza correspondentes. Ele não apaga nada — os comandos saem na tela para você conferir e rodar.

About

Servidor de inferência local com SLM

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages