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.
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
torch2.x não trazem kernels para compute 5.0 (Maxwell), enquanto o llama.cpp compilado com-DCMAKE_CUDA_ARCHITECTURES=50roda nela sem problema. - O modelo de chat padrão é o Llama-3.2-1B-Instruct em Q4_K_M (0,75 GiB), com
n_ctx4096. 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.
uv sync --no-binary-package llama-cpp-pythonPara 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-pythonO CMAKE_CUDA_HOST_COMPILER é necessário porque o CUDA 12.4 não aceita o gcc 14 como host.
LIS_API_KEY=umsegredoqualquer uv run local-inference-serverOs 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.
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 |
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.ggufOcupaçã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_ubatch128 (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 comn_ctx256 e o chat a 4096. Todos terminam emCUDA 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 on_seq_maxé forçado pelo llama-cpp-python paramin(n_batch, 256)quandoembedding=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 0nã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 emn_ctx4096 — 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 custamn_vocab(128256) ×n_ubatch× 4 bytes. Com on_ubatchpadrã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. OLIS_EMBEDDING_N_UBATCHexiste 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| 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.
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 declaraqwen3.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-poolingjunto. - Teto de 512 tokens. Aqui o limite é a VRAM, não o modelo: o Qwen3 aguenta
32768 posições. Subir o
LIS_EMBEDDING_N_CTXaumenta 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 aon_ctx_traindo GGUF quando o modelo aceita menos do que foi pedido. - O corte respeita o token final. O
Llama.embeddo llama-cpp-python trunca depois de tokenizar e corta pelo fim — exatamente onde fica o EOS que o Qwen3 usa como vetor no poolinglast. 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/embeddingsnão distingue os dois, o simétrico correto é não prefixar nada. Modelos tipo e5, ao contrário, exigem"query: "— daí oLIS_EMBEDDING_INPUT_PREFIXcontinuar 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, ponhaLIS_EMBEDDING_MRL=falsee o parâmetro volta a ser recusado com 400 — truncar ali devolveria algo errado sem o cliente perceber.inputjá 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 meaneLIS_EMBEDDING_N_UBATCHigual aoLIS_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 otoken_type_counte o llama.cpp recusa combert model needs to define token type count.
Para subir o servidor sem embeddings: --no-embeddings.
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ãollama-3.2-1b)
uv run pytestCobrem 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.
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-serverO 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.
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-modelsEle 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.