[Qwen3.5] Project the refusal direction out of the residual stream at runtime

orcarouter/Qwen3.8-27B-Uncensored removes refusal by editing weights. The
Swift release we serve is AutoRound W8A16, so re-editing weights means
re-quantizing. Project one unit direction r out of every residual-stream
writer output instead: x <- x - r (r . x), which equals the weight edit
W' = W - r(r^T W) on every writer feeding that stream (Arditi et al. 2024).

Sites: attention output for both the full-attention and GDN branches, the
MLP output, and token embeddings, in Qwen3NextDecoderLayer.forward and
Qwen3NextModel.embed_input_ids, which Qwen3_5DecoderLayer and Qwen3_5Model
inherit but whose base constructors they skip, so both register there too.
The MTP drafter reuses Qwen3_5DecoderLayer and calls its own
embed_input_ids, so the draft path is covered at the same width, and
_maybe_share_embeddings at PP=1 hands the draft the target embedding
module. Sequence parallelism shards dim 0 only, so the last dim is
hidden_size at every site.

VLLM_ABLITERATION names a JSON file with key refusal_direction, a
safetensors tensor, or a bare name resolved under
/opt/qwen38/abliteration then /data/abliteration. Empty or 0 is off.
register() always creates the buffer _abliteration_r, zeros when off, so
one compiled graph serves ON and OFF and toggling the switch
(declared in envs.py, hence in compile_factors) invalidates the compile
cache. project_() is an in-place rank-1 update with no full-size
temporary, and skips tensors whose width is not hidden_size.

Co-authored-by: Hinata <hinata@idihore.id>
Signed-off-by: MOVZX <movzx@yahoo.com>
This commit is contained in:
2026-10-15 21:40:16 +07:00
co-authored by Hinata
parent 76df77c04e
commit 0076adc78b
6 changed files with 420 additions and 2 deletions
+245
View File
@@ -0,0 +1,245 @@
# SPDX-License-Identifier: Apache-2.0
"""Tests for the runtime refusal-direction projection."""
import json
import math
import pytest
import torch
from vllm import envs
from vllm.model_executor.models import abliteration
HIDDEN = 8
@pytest.fixture(autouse=True)
def clear_direction_cache():
abliteration._read.cache_clear()
yield
abliteration._read.cache_clear()
def write_direction(tmp_path, name="r.json", size=HIDDEN):
direction = [math.sin(i) for i in range(size)]
path = tmp_path / name
path.write_text(json.dumps({"hidden_size": size, "refusal_direction": direction}))
return str(path)
def registered(module, size=HIDDEN, dtype=torch.float32):
abliteration.register(module, size, dtype)
return module
def test_off_is_a_noop_and_still_registers(tmp_path, monkeypatch):
"""With the switch off the buffer exists as zeros and x is unchanged."""
monkeypatch.delenv("VLLM_ABLITERATION", raising=False)
monkeypatch.delenv("QWEN38_ABLITERATION", raising=False)
module = registered(torch.nn.Module())
assert module._abliteration_on is False
assert torch.count_nonzero(module._abliteration_r) == 0
x = torch.randn(3, HIDDEN)
expected = x.clone()
assert abliteration.project_(module, x) is x
abliteration.project_(module, x)
assert torch.equal(x, expected)
def test_projection_drops_the_component(tmp_path, monkeypatch):
"""After projection the tensor keeps no component along the direction."""
monkeypatch.setenv("VLLM_ABLITERATION", write_direction(tmp_path))
module = registered(torch.nn.Module())
assert module._abliteration_on is True
r = module._abliteration_r
x = torch.randn(4, HIDDEN)
abliteration.project_(module, x)
assert torch.allclose(x @ r, torch.zeros(4), atol=1e-5)
assert not torch.allclose(x, torch.zeros_like(x))
# Projecting an already projected tensor changes nothing.
projected = x.clone()
abliteration.project_(module, x)
assert torch.allclose(x, projected, atol=1e-6)
def test_registered_direction_is_unit(tmp_path, monkeypatch):
monkeypatch.setenv("VLLM_ABLITERATION", write_direction(tmp_path))
module = registered(torch.nn.Module())
assert torch.allclose(module._abliteration_r.norm(), torch.tensor(1.0), atol=1e-6)
def test_non_contiguous_input_is_projected(tmp_path, monkeypatch):
monkeypatch.setenv("VLLM_ABLITERATION", write_direction(tmp_path))
module = registered(torch.nn.Module())
x = torch.randn(4, HIDDEN * 2)[:, ::2]
assert not x.is_contiguous()
abliteration.project_(module, x)
assert torch.allclose(x @ module._abliteration_r, torch.zeros(4), atol=1e-5)
def test_width_mismatch_is_skipped(tmp_path, monkeypatch):
"""A tensor without the full hidden dim must not be projected."""
monkeypatch.setenv("VLLM_ABLITERATION", write_direction(tmp_path))
module = registered(torch.nn.Module())
x = torch.randn(2, HIDDEN // 2)
before = x.clone()
abliteration.project_(module, x)
assert torch.equal(x, before)
def test_hidden_size_mismatch_raises(tmp_path, monkeypatch):
"""A direction for another model fails at boot, not silently."""
monkeypatch.setenv("VLLM_ABLITERATION", write_direction(tmp_path, size=HIDDEN))
with pytest.raises(ValueError, match="holds 8 values"):
registered(torch.nn.Module(), size=HIDDEN + 1)
def test_zero_direction_raises(tmp_path, monkeypatch):
path = tmp_path / "zero.json"
path.write_text(json.dumps({"refusal_direction": [0.0] * HIDDEN}))
monkeypatch.setenv("VLLM_ABLITERATION", str(path))
with pytest.raises(ValueError, match="zero or non-finite"):
abliteration.load_direction(HIDDEN)
def test_bare_name_resolves_in_builtin_dir(tmp_path, monkeypatch):
path = write_direction(tmp_path, name="Uncensored.json")
monkeypatch.setattr(abliteration, "BUILTIN_DIRS", (str(tmp_path),))
monkeypatch.setenv("VLLM_ABLITERATION", "Uncensored")
assert abliteration.direction_path() == path
def test_missing_direction_file_raises(tmp_path, monkeypatch):
monkeypatch.setattr(abliteration, "BUILTIN_DIRS", (str(tmp_path / "nope"),))
monkeypatch.setenv("VLLM_ABLITERATION", "Uncensored")
with pytest.raises(FileNotFoundError):
abliteration.load_direction(HIDDEN)
def test_meta_device_turns_projection_off(tmp_path, monkeypatch):
"""A meta-initialized module cannot keep the direction, so it stays off."""
monkeypatch.setenv("VLLM_ABLITERATION", write_direction(tmp_path))
module = torch.nn.Module()
with torch.device("meta"):
abliteration.register(module, HIDDEN, torch.float32)
assert module._abliteration_r.device.type == "meta"
assert module._abliteration_on is False
def test_legacy_env_name_is_accepted(tmp_path, monkeypatch):
"""QWEN38_ABLITERATION still works, as existing deployments set it."""
path = write_direction(tmp_path)
monkeypatch.delenv("VLLM_ABLITERATION", raising=False)
monkeypatch.setenv("QWEN38_ABLITERATION", path)
assert path == envs.VLLM_ABLITERATION
assert abliteration.direction_path() == path
def test_compile_projects_without_a_graph_break(tmp_path, monkeypatch):
"""The projection stays traceable, so it does not split the compiled model."""
monkeypatch.setenv("VLLM_ABLITERATION", write_direction(tmp_path))
class Project(torch.nn.Module):
def __init__(self):
super().__init__()
abliteration.register(self, HIDDEN, torch.float32)
def forward(self, x):
return abliteration.project_(self, x)
module = Project().eval()
x = torch.randn(16, HIDDEN)
expected = abliteration.project_(module, x.clone())
got = torch.compile(module, fullgraph=True)(x.clone())
assert torch.allclose(got, expected, atol=1e-6)
def qwen3_5_config(dense: bool):
"""A two-layer Qwen3.5 config, one full-attention and one linear layer."""
if dense:
from transformers import Qwen3_5TextConfig as config_cls
extra = {"intermediate_size": 256, "hidden_act": "silu"}
else:
from transformers import Qwen3_5MoeTextConfig as config_cls
extra = {
"moe_intermediate_size": 32,
"shared_expert_intermediate_size": 32,
"num_experts": 2,
"num_experts_per_tok": 1,
"n_group": 1,
"topk_group": 1,
"norm_topk_prob": False,
"decoder_sparse_step": 1,
"mlp_only_layers": [],
}
return config_cls(
vocab_size=64,
hidden_size=HIDDEN,
num_hidden_layers=2,
num_attention_heads=4,
num_key_value_heads=2,
head_dim=32,
rms_norm_eps=1e-6,
layer_types=["full_attention", "linear_attention"],
linear_key_head_dim=32,
linear_value_head_dim=32,
linear_num_key_heads=2,
linear_num_value_heads=2,
num_nextn_predict_layers=1,
**extra,
)
@pytest.mark.parametrize("dense", [True, False])
def test_qwen3_5_model_projects_embeddings(tmp_path, monkeypatch, dense, dist_init):
"""A real Qwen3.5 model registers the direction and drops it from embeddings."""
from types import SimpleNamespace
from vllm.config import VllmConfig, set_current_vllm_config
from vllm.model_executor.models.qwen3_5 import Qwen3_5Model
config = qwen3_5_config(dense)
path = write_direction(tmp_path, size=HIDDEN)
monkeypatch.setenv("VLLM_ABLITERATION", path)
vllm_config = VllmConfig()
vllm_config.model_config = SimpleNamespace(
hf_config=config,
hf_text_config=config,
dtype=torch.bfloat16,
max_model_len=128,
is_diffusion=False,
head_dtype=None,
is_mm_prefix_lm=False,
multimodal_config=None,
quantization_config=None,
runner_type="generate",
is_moe=not dense,
logits_processors=None,
rswa_window=None,
)
vllm_config.quant_config = None
with set_current_vllm_config(vllm_config):
model = Qwen3_5Model(vllm_config=vllm_config)
with torch.no_grad():
for param in model.parameters():
param.data.normal_(0, 0.02)
assert model._abliteration_on is True
for layer in model.layers:
assert layer._abliteration_on is True
r = model._abliteration_r.float()
# The buffer carries the model dtype, so bf16 rounding keeps it near unit norm.
assert torch.allclose(r.norm(), torch.tensor(1.0), atol=2e-2)
embeddings = model.embed_input_ids(torch.arange(6)).float()
residual = float((embeddings @ r).abs().max())
scale = float(embeddings.abs().max())
assert residual < 1e-2 * scale
+10
View File
@@ -227,6 +227,7 @@ if TYPE_CHECKING:
VLLM_REGEX_COMPILATION_TIMEOUT_S: int = 5
VLLM_MSGPACK_ZERO_COPY_THRESHOLD: int = 256
VLLM_ALLOW_INSECURE_SERIALIZATION: bool = False
VLLM_ABLITERATION: str = ""
VLLM_DISABLE_REQUEST_ID_RANDOMIZATION: bool = False
VLLM_NIXL_SIDE_CHANNEL_HOST: str = "localhost"
VLLM_NIXL_SIDE_CHANNEL_PORT: int = 5600
@@ -1189,6 +1190,15 @@ environment_variables: dict[str, Callable[[], Any]] = {
os.environ.get("VLLM_ALLOW_RUNTIME_LORA_UPDATING", "0").strip().lower()
in ("1", "true")
),
# Path to a refusal direction (a JSON file with a "refusal_direction" list,
# or a safetensors file with that tensor) projected out of every
# residual-stream write of the served model, or a bare name resolved under
# /opt/qwen38/abliteration and /data/abliteration. Empty or "0" is off. See
# vllm/model_executor/models/abliteration.py. QWEN38_ABLITERATION is the
# older name some deployments still set.
"VLLM_ABLITERATION": lambda: (
os.getenv("VLLM_ABLITERATION") or os.getenv("QWEN38_ABLITERATION", "")
),
# We assume drivers can report p2p status correctly.
# If the program hangs when using custom allreduce,
# potantially caused by a bug in the driver (535 series),
+151
View File
@@ -0,0 +1,151 @@
# SPDX-License-Identifier: Apache-2.0
"""Optional runtime refusal-direction removal ("abliteration").
``VLLM_ABLITERATION`` (or the legacy ``QWEN38_ABLITERATION``) names a JSON file
with a ``refusal_direction`` list, or a safetensors file with that tensor, of
length ``hidden_size``. A bare name resolves to ``<name>.json`` in
``/opt/qwen38/abliteration``, then in ``/data/abliteration``. When set, every
write into the residual stream loses its component along the unit direction r:
x <- x - r (r . x)
This runs on the attention block outputs (full and linear attention), the MLP
output and the text token embeddings. For one direction it equals the weight
edit ``W' = W - r (r^T W)`` on ``self_attn.o_proj``,
``linear_attn.out_proj``, ``mlp.down_proj`` and the rows of ``embed_tokens``
(Arditi et al., 2024), so the served weights, quantized or not, stay untouched.
Unset, no projection kernel runs. The buffer always exists, holding zeros while
the switch is off, so the attribute a compiled or captured graph refers to is
always present, and the switch is part of the torch.compile cache key, so
toggling it never reuses a stale graph.
"""
import json
import math
import os
from functools import lru_cache
import torch
from vllm import envs
from vllm.logger import init_logger
logger = init_logger(__name__)
ENV = "VLLM_ABLITERATION"
BUILTIN_DIRS = ("/opt/qwen38/abliteration", "/data/abliteration")
DIRECTION = "_abliteration_r"
ENABLED = "_abliteration_on"
_shape_warned = False
def direction_path() -> str | None:
"""The configured direction file, or None when the switch is off."""
value = (envs.VLLM_ABLITERATION or "").strip()
if value in ("", "0"):
return None
if os.sep in value or value.endswith((".json", ".safetensors")):
return value
for directory in BUILTIN_DIRS:
path = os.path.join(directory, f"{value}.json")
if os.path.exists(path):
return path
return os.path.join(BUILTIN_DIRS[0], f"{value}.json")
@lru_cache(maxsize=4)
def _read(path: str) -> torch.Tensor:
"""The direction as a unit float32 CPU tensor, read once per path."""
if path.endswith(".json"):
with open(path) as f:
values = json.load(f)["refusal_direction"]
# A model built on the meta device would otherwise make this CPU tensor
# a meta one, and a meta tensor cannot be inspected or copied.
r = torch.tensor(values, dtype=torch.float32, device="cpu")
else:
from safetensors import safe_open
with safe_open(path, framework="pt", device="cpu") as f:
r = f.get_tensor("refusal_direction")
r = r.to(dtype=torch.float32, device="cpu")
r = r.flatten()
norm = float(r.norm())
if not math.isfinite(norm) or norm == 0.0:
raise ValueError(f"{ENV}: {path} holds a zero or non-finite direction")
logger.info("%s: refusal direction (%d values) from %s", ENV, r.numel(), path)
return r / norm
def load_direction(hidden_size: int) -> torch.Tensor | None:
"""The unit refusal direction on CPU, or None when the switch is off."""
path = direction_path()
if path is None:
return None
r = _read(path)
if r.numel() != hidden_size:
raise ValueError(
f"{ENV}: {path} holds {r.numel()} values; the model's hidden size "
f"is {hidden_size}"
)
return r
def register(module: torch.nn.Module, hidden_size: int, dtype: torch.dtype) -> None:
"""Attach the direction as a non-persistent buffer, from a model constructor.
Call it before CUDA-graph capture so the buffer lands on the model's device.
It holds zeros when the switch is off. A buffer that lands on the meta
device cannot hold a direction, which turns the projection off for that
module.
"""
r = load_direction(hidden_size)
buf = torch.zeros(hidden_size, dtype=dtype)
on = r is not None
if on and buf.device.type == "meta":
logger.warning(
"%s: %s is built on the meta device, which cannot hold a direction; "
"projection is off for this module",
ENV,
type(module).__name__,
)
on = False
elif on:
buf.copy_(r)
module.register_buffer(DIRECTION, buf, persistent=False)
setattr(module, ENABLED, on)
def project_(module: torch.nn.Module, x: torch.Tensor) -> torch.Tensor:
"""Remove the component of x along the direction, in place. No-op when off."""
if not getattr(module, ENABLED, False):
return x
r = getattr(module, DIRECTION)
width = x.shape[-1]
if width != r.numel():
_warn_shape(type(module).__name__, width, r.numel())
return x
if r.dtype != x.dtype:
# Activations do not always carry the model dtype, and the rank-1 update
# needs one dtype throughout.
r = r.to(x.dtype)
if x.is_contiguous():
flat = x.view(-1, width)
flat.addr_(flat @ r, r, alpha=-1.0)
else:
x.sub_(torch.outer(x.reshape(-1, width) @ r, r).view(x.shape))
return x
def _warn_shape(name: str, width: int, size: int) -> None:
global _shape_warned
if not _shape_warned:
_shape_warned = True
logger.warning(
"%s: skipped a %d-wide tensor from %s; the direction has %d values",
ENV,
width,
name,
size,
)
+3
View File
@@ -66,6 +66,7 @@ from vllm.multimodal.inputs import MultiModalFeatureSpec
from vllm.sequence import IntermediateTensors
from vllm.tokenizers.registry import cached_tokenizer_from_config
from . import abliteration
from .interfaces import (
HasInnerState,
IsHybrid,
@@ -191,6 +192,7 @@ class Qwen3_5DecoderLayer(Qwen3NextDecoderLayer):
self.post_attention_layernorm = Qwen3_5RMSNorm(
config.hidden_size, eps=config.rms_norm_eps
)
abliteration.register(self, config.hidden_size, model_config.dtype)
self.layer_scale = getattr(config, "layer_scale", False)
if self.layer_scale:
@@ -254,6 +256,7 @@ class Qwen3_5Model(Qwen3NextModel):
quant_config=self.quant_config,
prefix=f"{prefix}.embed_tokens",
)
abliteration.register(self, config.hidden_size, vllm_config.model_config.dtype)
def get_layer(prefix: str):
return Qwen3_5DecoderLayer(
+3 -1
View File
@@ -37,6 +37,7 @@ from vllm.model_executor.models.utils import sequence_parallel_chunk
from vllm.sequence import IntermediateTensors
from vllm.transformers_utils.repo_utils import try_get_local_file
from . import abliteration
from .interfaces import (
MultiModalEmbeddings,
SupportsMultiModal,
@@ -146,6 +147,7 @@ class Qwen3_5MultiTokenPredictor(nn.Module):
self.vocab_size,
config.hidden_size,
)
abliteration.register(self, config.hidden_size, model_config.dtype)
# Workaround: mtp.fc is stored as BF16 in NVFP4 checkpoints but is
# missing from hf_quant_config.json exclude_modules. Force unquantized.
@@ -217,7 +219,7 @@ class Qwen3_5MultiTokenPredictor(nn.Module):
)
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
return self.embed_tokens(input_ids)
return abliteration.project_(self, self.embed_tokens(input_ids))
def forward(
self,
+8 -1
View File
@@ -61,6 +61,7 @@ from vllm.platforms import current_platform
from vllm.sequence import IntermediateTensors
from vllm.v1.attention.backend import AttentionType
from . import abliteration
from .interfaces import (
EagleModelMixin,
HasInnerState,
@@ -537,6 +538,7 @@ class Qwen3NextDecoderLayer(nn.Module):
self.post_attention_layernorm = Qwen3NextRMSNorm(
config.hidden_size, eps=config.rms_norm_eps
)
abliteration.register(self, config.hidden_size, model_config.dtype)
self.layer_scale = getattr(config, "layer_scale", False)
if self.layer_scale:
@@ -584,6 +586,8 @@ class Qwen3NextDecoderLayer(nn.Module):
else:
raise ValueError("Invalid layer_type")
abliteration.project_(self, hidden_states)
if self.layer_scale:
if len(hidden_states.shape) == 2:
hidden_states = hidden_states * (
@@ -612,6 +616,8 @@ class Qwen3NextDecoderLayer(nn.Module):
else:
hidden_states = self.mlp(hidden_states)
abliteration.project_(self, hidden_states)
if self.layer_scale:
if len(hidden_states.shape) == 2:
hidden_states = hidden_states * (
@@ -661,6 +667,7 @@ class Qwen3NextModel(nn.Module, EagleModelMixin):
self.vocab_size,
config.hidden_size,
)
abliteration.register(self, config.hidden_size, vllm_config.model_config.dtype)
def get_layer(prefix: str):
return Qwen3NextDecoderLayer(
@@ -689,7 +696,7 @@ class Qwen3NextModel(nn.Module, EagleModelMixin):
self.aux_hidden_state_layers: tuple[int, ...] = ()
def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
return self.embed_tokens(input_ids)
return abliteration.project_(self, self.embed_tokens(input_ids))
@property
def use_sequence_parallel(self) -> bool: