Rodando via llama.cpp (bug de tokenização)

#2
by Henriik2 - opened

Nota: Quando eu ia publicar isso, vi que sulfierry/manaca-1b-instruct-GGUF já tinha documentado uma conversão GGUF corrigida, chegando à mesma causa raiz que eu (SPM vs UGM). Publico mesmo assim porque os achados são complementares: ele foi além e mapeou um segundo problema ligado (\n sendo tratado diferente entre tokenizer.json e tokenizer.model), que eu não tinha identificado. Recomendo ler os dois.

Rodei este modelo via llama.cpp/Open WebUI e encontrei um bug de tokenização na conversão pra GGUF. Deixando documentado aqui, com evidências, caso ajude alguém.

Sintomas observados

Convertendo com convert_hf_to_gguf.py (fluxo padrão), o GGUF resultante apresenta comportamento inconsistente:

  • Prompts com maiúscula em qualquer posição ("Qual", "Explique", ou até no meio da frase como "a Capital") geram respostas piores ou incoerentes comparado ao mesmo prompt em minúsculas.
  • Em alguns casos especificamente maiúscula combinada com ? no final o modelo trava completamente, gerando espaço em branco em loop infinito, sem nunca parar sozinho.

Isso acontece de forma consistente e reproduzível.

Exemplo reproduzível:

$ ./llama-cli -m manaca-1b-instruct.f16.gguf --temp 0 -p "Qual a capital do brasil?"
Qual a capital do brasil?
[trava aqui só gera espaço em branco, indefinidamente]

$ ./llama-cli -m manaca-1b-instruct.f16.gguf --temp 0 -p "qual a capital do brasil?"
qual a capital do brasil? brasília? rio de janeiro? são paulo? não, a capital do país do futebol é a cidade...
[gera normalmente]

Diagnóstico

Passo 1 - Comparar a tokenização entre o modelo original (Python) e o GGUF.

Usando o mesmo texto ("abaixo está uma instrução"), via transformers:

>>> tok("abaixo está uma instrução")["input_ids"]
[1, 884, 340, 295, 2872]

Via llama.cpp (--verbose-prompt):

tokens: [ '<s>':1, ' abaixo':884, ' está':340, ' uma':295, ' in':531, 'stru':13115, 'ção':1370 ]

A palavra "instrução" é um único token (2872) no modelo original. No llama.cpp ela vira três tokens (' in', 'stru', 'ção'). A tokenização diverge do modelo real, mesmo com o tokenizer.model correto presente.

Passo 2 - Checar qual algoritmo de tokenizador o llama.cpp carregou.

$ ./llama-cli -m manaca-1b-instruct.f16.gguf --verbose-prompt -p "teste" 2>&1 | grep -E "tokenizer.ggml.model|init_tokenizer"
tokenizer.ggml.model str = llama
init_tokenizer: initializing tokenizer for type 1

No cabeçalho oficial do llama.cpp (include/llama.h):

enum llama_vocab_type {
    LLAMA_VOCAB_TYPE_SPM = 1, // LLaMA tokenizer based on byte-level BPE with byte fallback
    LLAMA_VOCAB_TYPE_UGM = 4, // T5 tokenizer based on Unigram
};

O llama.cpp carregou o vocabulário como SPM/BPE clássico, tipo 1.

Passo 3 - Checar o algoritmo real do tokenizer original, direto do binário .model:

from sentencepiece import sentencepiece_model_pb2 as model_pb2
m = model_pb2.ModelProto()
m.ParseFromString(open("tokenizer.model", "rb").read())
print(m.trainer_spec.model_type)      # → 1
print(m.normalizer_spec.name)         # → nmt_nfkc_cf

model_type = 1 no protobuf do SentencePiece corresponde a Unigram, não BPE. Existe também um normalizador nmt_nfkc_cf (NFKC + case-folding) configurado o tokenizer original deveria normalizar tudo pra minúsculas automaticamente antes de tokenizar.

Causa raiz

Cruzando os três passos: o tokenizer original é Unigram, com normalização de minúsculas embutida. O convert_hf_to_gguf.py gravou o GGUF como se fosse SPM/BPE clássico (tipo 1), que é o caminho de código usado por qualquer modelo com arquitetura LlamaForCausalLM.

É uma lacuna real no script de conversão. A função set_vocab() da classe usada para arquitetura Llama (conversion/llama.py) chama self._set_vocab_sentencepiece() incondicionalmente, sem checar o model_type do SentencePiece:

try:
    self._set_vocab_sentencepiece()  # sempre grava tokenizer.ggml.model = "llama"
except FileNotFoundError:
    ...

Compare com a classe usada para modelos T5 (conversion/t5.py), que já faz a checagem certa:

if sentencepiece_model.trainer_spec.model_type == 2:  # BPE
    return self._set_vocab_sentencepiece()
else:
    assert sentencepiece_model.trainer_spec.model_type == 1  # UNIGRAM
    ...
    self.gguf_writer.add_tokenizer_model("t5")  # ativa o tipo UGM correto
    ...
    self.gguf_writer.add_precompiled_charsmap(precompiled_charsmap)

No código-fonte do runtime (src/llama-vocab.cpp), as duas implementações mostram por que isso importa:

  • llm_tokenizer_spm_session::tokenize() (tipo SPM=1): reedy merge de pares de bytes por pontuação, sem nenhuma etapa de normalização.
  • llm_tokenizer_ugm_session::tokenize() (tipo UGM=4): chama normalize(text, &normalized) antes de tokenizar, usando o precompiled_charsmap o mecanismo que aplicaria o nmt_nfkc_cf.

Sem essa normalização, maiúsculas nunca viram minúsculas e a segmentação diverge do original. Combinado com a repeat_penalty desligada por padrão no llama.cpp, o modelo pode cair num estado degenerado e travar repetindo espaço em branco indefinidamente.

Correção

Usei a mesma lógica de checagem que a classe T5 já tem, aplicada na classe usada pela arquitetura Llama. Patch completo aqui:

--- a/conversion/llama.py
+++ b/conversion/llama.py
@@ -11,7 +11,7 @@
 if TYPE_CHECKING:
     from torch import Tensor
 
-from .base import ModelBase, TextModel, gguf, logger
+from .base import ModelBase, SentencePieceTokenTypes, TextModel, gguf, logger
 
 
 @ModelBase.register(
@@ -103,6 +103,118 @@
             logger.info(f"EAGLE-3: norm_before_fc = {norm_before_fc}")
             self.gguf_writer.add_norm_before_fc(norm_before_fc)
 
+    def _sentencepiece_is_unigram(self, tokenizer_path) -> bool:
+        # Checa o model_type real gravado dentro do binário SentencePiece,
+        # em vez de assumir sempre BPE/SPM clássico (comportamento padrão do llama.cpp
+        # para arquitetura "llama", que ignora esse campo).
+        # model_type: 1 = UNIGRAM, 2 = BPE, 3 = WORD, 4 = CHAR
+        import os
+        os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python"
+        from sentencepiece import sentencepiece_model_pb2 as model_pb2
+
+        m = model_pb2.ModelProto()  # ty: ignore[unresolved-attribute]
+        with open(tokenizer_path, "rb") as f:
+            m.ParseFromString(f.read())
+        return m.trainer_spec.model_type == 1
+
+    def _set_vocab_sentencepiece_ugm(self, tokenizer_path):
+        # Mesma lógica usada por T5Model.set_vocab() para tokenizers SentencePiece
+        # do tipo Unigram real -- inclui o precompiled_charsmap (normalizador,
+        # ex: NFKC + minúsculas), que o caminho SPM/BPE clássico não escreve
+        # e o llama.cpp não aplica para o tipo de vocabulário "llama" (SPM=1).
+        import os
+        os.environ["PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION"] = "python"
+        from sentencepiece import SentencePieceProcessor
+        from sentencepiece import sentencepiece_model_pb2 as model_pb2
+
+        sentencepiece_model = model_pb2.ModelProto()  # ty: ignore[unresolved-attribute]
+        sentencepiece_model.ParseFromString(open(tokenizer_path, "rb").read())
+
+        add_prefix = sentencepiece_model.normalizer_spec.add_dummy_prefix
+        remove_whitespaces = sentencepiece_model.normalizer_spec.remove_extra_whitespaces
+        precompiled_charsmap = sentencepiece_model.normalizer_spec.precompiled_charsmap
+
+        tokenizer = SentencePieceProcessor()
+        tokenizer.LoadFromFile(str(tokenizer_path))
+
+        vocab_size = self.hparams.get('vocab_size', tokenizer.vocab_size())
+
+        tokens: list[bytes] = [f"[PAD{i}]".encode("utf-8") for i in range(vocab_size)]
+        scores: list[float] = [-10000.0] * vocab_size
+        toktypes: list[int] = [SentencePieceTokenTypes.UNUSED] * vocab_size
+
+        for token_id in range(tokenizer.vocab_size()):
+            piece = tokenizer.IdToPiece(token_id)
+            text = piece.encode("utf-8")
+            score = tokenizer.GetScore(token_id)
+
+            toktype = SentencePieceTokenTypes.NORMAL
+            if tokenizer.IsUnknown(token_id):
+                toktype = SentencePieceTokenTypes.UNKNOWN
+            elif tokenizer.IsControl(token_id):
+                toktype = SentencePieceTokenTypes.CONTROL
+            elif tokenizer.IsUnused(token_id):
+                toktype = SentencePieceTokenTypes.UNUSED
+            elif tokenizer.IsByte(token_id):
+                toktype = SentencePieceTokenTypes.BYTE
+
+            tokens[token_id] = text
+            scores[token_id] = score
+            toktypes[token_id] = toktype
+
+        added_tokens_file = self.dir_model / 'added_tokens.json'
+        if added_tokens_file.is_file():
+            with open(added_tokens_file, "r", encoding="utf-8") as f:
+                added_tokens_json = json.load(f)
+                for key in added_tokens_json:
+                    token_id = added_tokens_json[key]
+                    if token_id >= vocab_size:
+                        logger.warning(f'ignore token {token_id}: id is out of range, max={vocab_size - 1}')
+                        continue
+                    tokens[token_id] = key.encode("utf-8")
+                    scores[token_id] = -1000.0
+                    toktypes[token_id] = SentencePieceTokenTypes.USER_DEFINED
+
+        tokenizer_config_file = self.dir_model / 'tokenizer_config.json'
+        if tokenizer_config_file.is_file():
+            with open(tokenizer_config_file, "r", encoding="utf-8") as f:
+                tokenizer_config_json = json.load(f)
+                added_tokens_decoder = tokenizer_config_json.get("added_tokens_decoder", {})
+                for token_id, token_data in added_tokens_decoder.items():
+                    token_id = int(token_id)
+                    token: str = token_data["content"]
+                    if token_id >= vocab_size:
+                        logger.warning(f'ignore token {token_id}: id is out of range, max={vocab_size - 1}')
+                        continue
+                    if token_data.get("special") or self.does_token_look_special(token):
+                        toktypes[token_id] = SentencePieceTokenTypes.CONTROL
+                    else:
+                        token = token.replace(b"\xe2\x96\x81".decode("utf-8"), " ")
+                        toktypes[token_id] = SentencePieceTokenTypes.USER_DEFINED
+                    scores[token_id] = -1000.0
+                    tokens[token_id] = token.encode("utf-8")
+
+        if vocab_size > len(tokens):
+            pad_count = vocab_size - len(tokens)
+            logger.debug(f"Padding vocab with {pad_count} token(s) - [PAD1] through [PAD{pad_count}]")
+            for i in range(1, pad_count + 1):
+                tokens.append(bytes(f"[PAD{i}]", encoding="utf-8"))
+                scores.append(-1000.0)
+                toktypes.append(SentencePieceTokenTypes.UNUSED)
+
+        self.gguf_writer.add_tokenizer_model("t5")
+        self.gguf_writer.add_tokenizer_pre("default")
+        self.gguf_writer.add_token_list(tokens)
+        self.gguf_writer.add_token_scores(scores)
+        self.gguf_writer.add_token_types(toktypes)
+        self.gguf_writer.add_add_space_prefix(add_prefix)
+        self.gguf_writer.add_remove_extra_whitespaces(remove_whitespaces)
+        if precompiled_charsmap:
+            self.gguf_writer.add_precompiled_charsmap(precompiled_charsmap)
+
+        special_vocab = gguf.SpecialVocab(self.dir_model, n_vocab=len(tokens))
+        special_vocab.add_to_gguf(self.gguf_writer)
+
     def set_vocab(self):
         # eagle3: use tokenizer from target model if provided
         original_dir_model = None
@@ -132,13 +244,17 @@
                 if tokenizer_config_json.get("tokenizer_class") == "HybridDNATokenizer":
                     return self._set_vocab_hybriddna()
 
-        try:
-            self._set_vocab_sentencepiece()
-        except FileNotFoundError:
-            try:
-                self._set_vocab_llama_hf()
-            except (FileNotFoundError, TypeError):
-                # Llama 3
-                self._set_vocab_gpt2()
+        tokenizer_model_path = self.dir_model / 'tokenizer.model'
+        if tokenizer_model_path.is_file() and self._sentencepiece_is_unigram(tokenizer_model_path):
+            self._set_vocab_sentencepiece_ugm(tokenizer_model_path)
+        else:
+            try:
+                self._set_vocab_sentencepiece()
+            except FileNotFoundError:
+                try:
+                    self._set_vocab_llama_hf()
+                except (FileNotFoundError, TypeError):
+                    # Llama 3
+                    self._set_vocab_gpt2()

Depois de aplicar e reconverter:

tokenizer.ggml.model str = t5        (antes: llama)
init_tokenizer: initializing tokenizer for type 4   (antes: type 1)
tokenizer.ggml.precompiled_charsmap arr[u8,244410]  (antes: ausente)

A palavra "instrução" passa a tokenizar como um único token, idêntico ao Python. Os travamentos com maiúscula + ? somem. Testei em llama-cli e via Open WebUI, com resultado consistente nos dois.

image

Como aplicar

git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
git apply manaca-ugm-tokenizer-fix.patch 
pip install -r requirements/requirements-convert_hf_to_gguf.txt
python3 convert_hf_to_gguf.py /caminho/para/manaca-1b-instruct --outfile manaca-1b-instruct.f16.gguf --outtype f16

Importante: precisa reconverter do zero.

Esse é um bug genérico do llama.cpp afeta qualquer modelo com arquitetura Llama e tokenizer SentencePiece Unigram, não é peculiaridade deste modelo. Por enquanto não abri PR upstream, já que é um caso relativamente raro, mas deixo o patch documentado aqui.

Bônus: chat_template ausente

Vale mencionar um segundo problema, sem relação com o bug acima, que também afeta o uso via Open WebUI/chat.

O tokenizer_config.json deste modelo não tem o campo chat_template. Sem ele, ferramentas como llama-cli -chat ou o /v1/chat/completions do Open WebUI caem num fallback genérico (ChatML), que não bate com o formato real usado no treino Alpaca-PT (### Instrução: / ### Resposta:, conforme documentado no README).

O sintoma é bem diferente do bug de tokenização: respostas vazias ou a pergunta ecoada de volta, especificamente em modo chat, enquanto completion crua com o prompt montado manualmente funciona bem.

Solução local: passar um chat_template customizado em Jinja via --chat-template-file:

Abaixo está uma instrução que descreve uma tarefa. Escreva uma resposta que atenda adequadamente ao pedido.

{% for message in messages %}{% if message['role'] == 'user' %}### Instrução:
{{ message['content'] }}

{% elif message['role'] == 'assistant' %}### Resposta:
{{ message['content'] }}

{% endif %}{% endfor %}{% if add_generation_prompt %}### Resposta:
{% endif %}
./llama-server -m manaca-1b-instruct.f16.gguf --chat-template-file alpaca.jinja

Ressalva: o Alpaca original foi treinado só pra um turno (uma instrução, uma resposta). Esse template é uma aproximação pra conversas com múltiplos turnos o modelo nunca viu esse padrão no treino, então a qualidade tende a cair conforme a conversa cresce.

Sign up or log in to comment