Иногда LLMки путаются в языке, на котором они отвечают (П維чему нейро考ети дел思ют так). Так почему бы не помочь им забыть про языки, в которых мы не заинтересованы.

Как мы знаем, LLMки работают с токенами: переводят куски слов в многомерные вектора, много раз перемножают их на свои веса, складывают друг с другом, а потом по последнему вектору, вобравшему в себя весь контекст, вычисляют следующий токен. Соответственно, не должно быть затруднительным запретить вычислять конкретные токены из языков, которые нам не нужны.

Давайте попробуем препарировать маленькую модельку Qwen3.5-0.8B-Q8_0.gguf и поправить её.

Исходное состояние
Исходное состояние
import gguf

reader = gguf.GGUFReader("./Qwen3.5-0.8B-Q8_0.gguf")
tokens = reader.fields.get("tokenizer.ggml.tokens").contents()
print(f"Total tokens {len(tokens)}")
types = reader.fields.get("tokenizer.ggml.token_type").contents()
print(f"Total token types {len(set(types))} : {set(types)}")

# Total tokens 248320
# Total token types 4 : {1, 3, 4, 5}

Она использует byte-pair encoding токенизатор со словарём в 248320 токенов, разделённым на 4 типа (1 - текст, 3 - <|спец|><|токены|>, 4 - <спец></токены>, 5 - паддинг). Текстовые токены начинаются с id_0=! и доходят до id_248043=ありますか . Давайте определим список токенов, которые мы хотим оставить:

import sys

keep_check = regex.compile(r"["
  r"[\p{N}\p{P}\p{S}&&\p{Script=Common}]" # Числа, знаки препинания и символы
  r"\s"                                   # Пробельные символы
  r"а-яА-ЯёЁ"                             # Кириллица
  r"a-zA-Z"                               # Латинский алфавит
r"]", regex.V1)

symbols_to_keep = set()
for cp in range(sys.maxunicode + 1):
  if keep_check.fullmatch(chr(cp)):
    symbols_to_keep.add(chr(cp))
print(f"{len(symbols_to_keep)} symbols")

# 7994 symbols

Так у нас появился список из 7994 символов. Теперь можно отфильтровать токены LLMки, чтобы они содержали только эти символы.

from gguf.vocab import bytes_to_unicode

byte_decoder = {ch: b for b, ch in bytes_to_unicode().items()}
tokens_to_rm = set()
for i, t in enumerate(tokens):
  if types[i] != 1:
    # Нас интересуют только текстовые токены
    continue

  raw = bytes(byte_decoder[ch] for ch in t)
  try:
    text = raw.decode("utf-8")
    if not all((ch in symbols_to_keep) for ch in text):
      tokens_to_rm.add(i)
  except UnicodeDecodeError:
    # Обломки utf-8, пусть будут
    continue

  print(f"Remove {len(tokens_to_rm)} tokens")
  
  # Remove 100468 tokens

Итого от изначальных 248320 токенов мы удалим 100468. Будем это делать зануляя веса в предпоследнем слое, который занимается декодированием многомерного вектора в logit токена.

for t in reader.tensors:
  print(t.name)

# output_norm.weight
# token_embd.weight
# blk.0.attn_gate.weight
# ... ещё 329 слоёв
# blk.24.nextn.shared_head_norm.weight
# blk.24.post_attention_norm.weight

Вот этот token_embd.weight нам и нужен. Это общий encode/decode слой, поэтому зануление токенов отразится и на умении LLMки читать тексты на этих языках. Ну что ж, это жертва, на которую я готов пойти.

import numpy as np

token_embd = next(t for t in reader.tensors if t.name == "token_embd.weight")
n_vocab, bytes_per_row = token_embd.data.shape
block_size = 1*2 + 32*1 # Q8_0: один fp16 масштабный коэффициент + 32 int8
n_blocks = bytes_per_row // block_size

# будем патчить файл напрямую
token_embd_data = np.memmap(
  gguf_file,
  dtype=np.uint8,
  mode="r+",
  offset=token_embd.data_offset,
  shape=(n_vocab, n_blocks, block_size)
)
# зануляем fp16 масштабный коэффициент
token_embd_data[tokens_to_rm, :, 0:2] = 0
token_embd_data.flush()
del token_embd_data

Теперь попробуем, что у нас получилось. Запустим эту модель в llama.cpp и попробуем добиться от неё иероглифов.

Ни слова
Ни слова

На этом, собственно, всё.

Код целиком
import shutil

gguf_file = shutil.copyfile(
    "./Qwen3.5-0.8B-Q8_0.gguf",
    "./Qwen3.5-0.8B-Q8_0.latcyr.gguf"
)

import gguf

reader = gguf.GGUFReader(gguf_file)
tokens = reader.fields.get("tokenizer.ggml.tokens").contents()
print(f"Total tokens {len(tokens)}")
types = reader.fields.get("tokenizer.ggml.token_type").contents()
print(f"Total token types {len(set(types))} : {set(types)}")


import sys
import regex

keep_check = regex.compile(r"["
  r"[\p{N}\p{P}\p{S}&&\p{Script=Common}]" # Общие числа, знаки препинания и символы
  r"\s"                                   # Пробельные символы
  r"а-яА-ЯёЁ"                             # Кириллица
  r"a-zA-Z"                               # Латинский алфавит
r"]", regex.V1)

symbols_to_keep = set()
for cp in range(sys.maxunicode + 1):
  if keep_check.fullmatch(chr(cp)):
    symbols_to_keep.add(chr(cp))
print(f"{len(symbols_to_keep)} symbols")


from gguf.vocab import bytes_to_unicode
BYTE_DECODER = {ch: b for b, ch in bytes_to_unicode().items()}
tokens_to_rm = []
for i, t in enumerate(tokens):
    if types[i] != 1:
        continue

    raw = bytes(BYTE_DECODER[ch] for ch in t)
    try:
        text = raw.decode("utf-8")
        if not all((ch in symbols_to_keep) for ch in text):
            tokens_to_rm.append(i)
    except UnicodeDecodeError:
        # Обломки utf-8
        continue

print(f"Remove {len(tokens_to_rm)} tokens")

import numpy as np

token_embd = next(t for t in reader.tensors if t.name == "token_embd.weight")
n_vocab, bytes_per_row = token_embd.data.shape
block_size = 1*2 + 32*1 # Q8_0: один fp16 масштабный коэффициент + 32 int8
n_blocks = bytes_per_row // block_size

token_embd_data = np.memmap(
    gguf_file,
    dtype=np.uint8,
    mode="r+",
    offset=token_embd.data_offset,
    shape=(n_vocab, n_blocks, block_size)
)
token_embd_data[tokens_to_rm, :, 0:2] = 0 # зануляем fp16 масштабный коэффициент
token_embd_data.flush()
del token_embd_data

Комментарии (1)


  1. aaabramenko
    23.09.2026 19:42

    Лоботомия XXI века