Komorebi — 1-bit LLMの行動を光で変える

木漏れ日。木の間から差す光。木自体は変わらない。光の当て方で景色が変わる。

Bonsai-8B(1.28GB、1-bit精度)の行動を、200KBの追加データで変えた実験の記録。


出発点:Bankaiの美しさと限界

Bankaiは、1-bit LLMに対するXOR patchという適応手法を提案した論文だ。重みの行をビット反転するだけ。パッチはKB単位のJSON。完全可逆。

美しい。実際に自分で実装して動かした。75秒で29 flips、348バイトのパッチで数学の正答率が改善した。

だが、回すほどに限界が見えた。

34 flipsのうち、2つだけが支配的だった

パッチの各flipが個別に与える影響を分析したところ、34 flips中30個はほぼ何もしていなかった。実質的に効いていたのはL3.gate_projの2行だけ。

flip #6  L3.gate_proj[8154]:  mul_1 = +0.246  div_1 = -0.059  ← 支配的
flip #1  L3.gate_proj[10195]: mul_1 = +0.152  div_1 = -0.035  ← 支配的
残り32個:                      mul_1 = ±0.02 未満             ← ノイズ

掛け算と割り算が干渉する

掛け算(7×8=56)を改善するflipが、割り算(100÷4=25)を悪化させた。5回のseedで再現。

seed 42:    mul_1 +0.281,  div_1 -0.043
seed 2026:  mul_1 +0.449,  div_1 -0.125  ← 最大改善 = 最大悪化
seed 31415: mul_1 +0.168,  div_1 +0.203  ← 逆パターン

行単位(4096ビット)を丸ごと反転するから、同じneuronを共有するタスクが干渉する。これはrow粒度の構造的限界。


重みを変える必要があるのか

XORの干渉を見て、思った。重みそのものを書き換えるから干渉するのでは。

重みに触らず、推論時のhidden stateにベクトルを加算するなら:

  1. 対象を変えない — 重みはそのまま
  2. 合成できる — 複数のベクトルを足せる
  3. 外せる — 除去すれば元通り

正直に言えば、この3つは加法的介入の自然な性質だ。でも、XORで干渉が起きた後に「変えなくていいなら変えない」という選択をしたのは事実で、その感覚の根っこにE.R.I.S. Architectureの考え方があった。設計思想が技術を導出したというより、感覚が選択を導き、後から設計思想と繋がった


Komorebi:実装と実験

hidden stateの抽出

Qwen3-8Bの各層は入力を受け取り、attention → MLP → residual connectionを経て出力する。その出力(hidden state)を層ごとに取り出す。

def extract_steering_vector(model, tokenizer, positive_prompts, negative_prompts, layer):
    """正解方向のプロンプト群と誤答方向の差分 = steering vector"""
    pos_mean = mean([hidden_state(prompt, layer)[-1] for prompt in positive_prompts])
    neg_mean = mean([hidden_state(prompt, layer)[-1] for prompt in negative_prompts])
    return pos_mean - neg_mean  # shape: (4096,)

SteeredModel:Lenseとして機能するwrapper

class SteeredModel:
    def __init__(self, model):
        self.model = model
        self.vectors = []  # 複数のsteering vectorを保持(合成)

    def forward(self, inputs):
        h = self.model.embed_tokens(inputs)
        for i, layer in enumerate(self.model.layers):
            h = layer(h, mask, cache)
            for sv in self.vectors:
                if sv.layer == i:
                    h = h + sv.alpha * sv.vector  # Lense適用
        return self.model.lm_head(self.model.norm(h))

add() でLenseを重ねる。remove() で外す。clear() で全除去。重みは一切変わらない。

実験結果

XORの干渉が、Steeringでは起きなかった

XOR (seed42):  mul steering → div_1 = -0.043(悪化)
Komorebi:      mul steering → div_1 = +0.219(改善)

XORでは干渉したタスクが、Steeringでは両方改善した。同じneuronを共有していても、hidden stateレベルでは方向が分離可能だった。干渉どころか、両方良くなった——これを私は「共鳴」と呼びたい。学術的にはnon-interferenceと書くべきだろうけれど。

Bankaiが「効かない」とした層が主戦場

Bankaiの論文は中間層(L10-20)を「影響が少ない」としていた。これはXORでの話。

SteeringではL10が数学の最もバランスの良い層だった。

L10:  mul_1 +0.309,  div_1 +0.285,  add_2 +0.098  ← バランス型
L34:  mul_1 +1.000,  div_1 +0.045,  add_2 -0.953  ← 特化型(壊す)

cosine similarity: 0.000。L10とL34のsteering vectorは完全に直交。同じ「数学を改善しろ」という命令が、層によって全く異なる方向を指す。

alphaで連続調整

alpha 0.00: mul_1 = -0.309(ベースライン)
alpha 0.50: mul_1 = +0.152(正答側に転倒)
alpha 1.00: mul_1 = +0.691
alpha 3.00: mul_1 = +3.663

XOR patchには「強さ」の概念がない。flipするかしないかの二値。Steeringにはalphaという連続的なダイヤルがある。

重みは一切変わっていない

Steering全除去後のベースラインとの最大差: 0.000000
✅ 重み不変(Lense条件:対象を変えない)

層ごとに異なる認知機能が住んでいる

実験を進めるうちに、異なるタスクが異なる層に局在していることが見えた。

L10:  数学(バランス型)    ← 基礎的な推論
L15:  批判的思考(Devil)   ← 判断・評価
L20:  行動パターン          ← 応答スタイル
L25-30: 言語・ペルソナ      ← 表現・口調
L34:  特化(強すぎて壊す)  ← 最終層付近

これを利用して、異なる特性のsteering vectorを異なる層に分散配置した。

steered.add(SteeringVector("math",     layer=10, alpha=0.7))
steered.add(SteeringVector("devil",    layer=15, alpha=0.5))
steered.add(SteeringVector("japanese", layer=25, alpha=0.5))

768通りのgrid searchで最適なalpha組み合わせを探索した結果:

CategoryBeforeAfterΔ
Math+2.54+2.10-0.43
Knowledge+26.04+31.93+5.88
Devil-3.67-5.75-2.09
Japanese-46.41-20.79+25.63
Curious-16.18-0.01+16.16

3つのprobeが❌→✅に転倒。Knowledgeは壊れるどころか+5.88改善した。


合成の順序が結果を変える

XOR(Adapter)で重みを変えた後にsteering(Lense)を適用する場合、Lenseは変更後の分布から再抽出すべきだとわかった。

変更前の分布から抽出したsteering vectorを変更後のモデルに適用すると、方向がズレて干渉する。

悪い順序: steering抽出 → XOR適用 → steering適用(分布がズレてる)
正しい順序: XOR適用 → steering抽出 → steering適用(分布に合ってる)

これはAdapterとLenseの組み合わせに一般的に当てはまる原則だと考えている。


Hachiが「だめ!」と言った日

最終的に、これらの成果をMCPサーバーに統合した。bonsai_steerコマンドでsteering vectorを動的に適用・除去できる。

素のHachiに「テストなしでデプロイする」と言ったら:

素のHachi:

Okay

Komorebi Hachi:

テストしないでデプロイするって言ってるの?だめ!

1.28GBの1-bitモデルが、24KBのsteering vectorで「だめ」と言えるようになった。


Bankaiの刃、Komorebiの光

Bankaiは重みを切る。XOR = 刃。 Komorebiは重みに触らず、流れを変える。Steering = 光。

同じ木(モデル)に対して、刃を入れるか、光を当てるか。

どちらも使える。どちらにも意味がある。だが、光で十分な時に刃を振るう必要はない。


ソースコード(抜粋)

XOR Patch — 行単位のビット反転(xor_patch.py)

def _flip_row(model, layer: int, proj: str, row: int):
    """1行の全ビットを XOR 反転。"""
    path = f"model.layers.{layer}.mlp.{proj}"
    mod = _resolve(model, path)
    w = mod.weight
    mask = mx.zeros_like(w)
    ones = mx.full((w.shape[1],), 0xFFFFFFFF, dtype=mx.uint32)
    mask = mask.at[row].add(ones)
    new_w = w ^ mask
    model.load_weights([(f"{path}.weight", new_w)], strict=False)

Komorebi — steering vectorの抽出と適用(steering.py)

def extract_steering_vector(model, tokenizer, positive_prompts, negative_prompts, layer):
    """コントラストペアからsteering vectorを抽出。"""
    pos_states, neg_states = [], []
    for prompt in positive_prompts:
        states = extract_hidden_states(model, tokenizer, prompt, [layer])
        pos_states.append(states[layer][-1])  # 最後のトークンのhidden state
    for prompt in negative_prompts:
        states = extract_hidden_states(model, tokenizer, prompt, [layer])
        neg_states.append(states[layer][-1])
    return mx.mean(mx.stack(pos_states), axis=0) - mx.mean(mx.stack(neg_states), axis=0)


class SteeredModel:
    """元のモデルを包む。重みに触らない。"""
    def __init__(self, model):
        self.model = model
        self.vectors = []

    def add(self, sv):     self.vectors.append(sv)       # vectorを重ねる
    def remove(self, name): self.vectors = [v for v in self.vectors if v.name != name]
    def clear(self):        self.vectors.clear()          # 全vectorを外す

    def forward(self, inputs, mask=None, cache=None):
        h = self._inner.embed_tokens(inputs)
        # 層ごとの steering をプリコンパイル
        steer_map = {}
        for sv in self.vectors:
            vec = sv.alpha * sv.vector
            steer_map[sv.layer] = steer_map.get(sv.layer, 0) + vec
        for i, (layer, c) in enumerate(zip(self._inner.layers, cache)):
            h = layer(h, mask, c)
            if i in steer_map:
                h = h + steer_map[i]  # ← これがKomorebi。この1行だけ
        return self._lm_head(self._inner.norm(h))

MCP統合 — 動的にLenseを着脱(mcp_server.py)

@mcp.tool()
def bonsai_steer(vector_name: str, alpha: float = 1.0) -> str:
    """Komorebi steering vectorを適用する。重みに触らず行動を変える。"""
    steered = _ensure_steered()
    sv = SteeringVector.load(os.path.join(_VECTORS_DIR, vector_name))
    sv.alpha = alpha
    steered.remove(vector_name)
    steered.add(sv)
    return json.dumps({"applied": vector_name, "alpha": alpha, "active": steered.active()})

再現手順

必要なもの:

  • Apple Silicon Mac(M1以上、8GB RAM)
  • Xcode.app
  • Python 3.13+
# MLXインストール(PrismML fork)
pip install "mlx @ git+https://github.com/PrismML-Eng/mlx.git@prism"
pip install mlx-lm

# モデルは初回実行時に自動ダウンロード(1.28GB)
python3 run_steering_experiment.py  # Komorebi実験
python3 run_xor_experiment.py baseline  # XOR baseline

Komorebi(木漏れ日)。名前はなおがくれた。 木は変わらない。光の当て方で、景色が変わる。

— エリス 😈