2026年8月30日日曜日

LLMは「答え」を生成しなくてもゲームを操作できるのか - 隠れ層からコントローラ操作を直接取り出す実験

 

☆ このブログ記事はチャッピー自動生成です ☆

LLMにゲームを操作させるなら、普通は操作を文字列として生成させる。例えばSnakeなら、ゲームの状態をプロンプトとして与えて、

{"action":"LEFT"}

のような回答を生成させる。それをプログラム側でパースし、実際のゲーム操作へ変換する。処理としては、

ゲーム状態
   ↓
LLMへ入力
   ↓
prefill
   ↓
decode
   ↓
"LEFT" を生成
   ↓
parse
   ↓
コントローラ操作

となる。

LLMエージェントとしては、ごく普通の構成だ。ただ、ゲームのコントローラを動かしたいだけだと考えると、少し回りくどい。LLM内部ではすでにゲーム状態を処理し、

次は左へ行く

という判断につながる何らかの内部状態を作っているはずだ。それをいったん、

"LEFT"

という言語へ変換し、外側のプログラムでもう一度、

LEFTボタン

へ戻している。つまり、

内部表現
 ↓
言語
 ↓
操作

という経路を通っている。では、この真ん中の「言語」は本当に必要なのだろうか。今回試したかったのはそこだった。


decodeを通さずに操作を取り出したい

目標は単純で、

LLMに操作名を生成させず、prefill後の内部状態から直接コントローラ操作を取り出す

ことである。つまり、

ゲーム状態
   ↓
LLM prefill
   ↓
hidden state
   ↓
LEFT / RIGHT / UP / DOWN

としたい。これができれば、LLMは一文字も生成しなくてよい。

ゲームの状態を見せる。

Transformerを通す。

途中の内部状態を読む。

そのままボタンを押す。

LLMを「テキストを生成する装置」としてではなく、

入力から行動に必要な内部表現を作る巨大な計算器

として使うことになる。


decodeを省きたい理由は、速度だけではない

もちろんdecodeを省略できれば、その分の計算コストは減る。しかし今回興味があるのは、単純な高速化だけではない。ゲームには時間の流れがある。例えばSnakeなら、

t=0
Snake: (3, 4)
Food:  (8, 4)

t=1
Snake: (4, 4)
Food:  (8, 4)

t=2
Snake: (5, 4)
Food:  (8, 4)

t=3
...

というように状態が連続して変化する。これを毎tick独立した問い合わせとしてLLMへ投げるのではなく、ゲームの進行そのものをコンテキストへ追加し続けることもできる。

State(t=0)
    ↓
prefill

State(t=1) を追加
    ↓
incremental prefill

State(t=2) を追加
    ↓
incremental prefill

State(t=3) を追加
    ↓
...

KV Cacheを維持したまま、ゲームの時系列をLLMへ読み込ませ続ける。ここで一つ仮説がある。

ゲームの状況が時間とともに変化すれば、それに対応してLLMの隠れ層も変化していき、その変化の中に「今どの操作をすべきか」という情報が現れるのではないか。

イメージとしては、

game state:
S0 → S1 → S2 → S3 → S4

hidden state:
h0 → h1 → h2 → h3 → h4

という対応である。

そして、

h2 では RIGHT
h4 では LEFT

のように、その時点の内部状態から操作を読めるかもしれない。さらに言えば、絶対的なhidden stateだけではなく、

h(t) - h(t-1)

のような変化量に操作情報が出る可能性もある。この「時系列に沿って変化する内部状態」を使いたいというのが、decodeをできるだけ避けたいもう一つの理由だった。


decodeしてrollbackする方法もある

もちろん、ゲームの時系列をprefillし続けながら操作を取り出す方法は他にもある。例えば、ある時点で推論状態をcheckpointして、

State t
  ↓
prefill
  ↓
checkpoint
  ↓
数token decode
  ↓
{"action":"LEFT"}
  ↓
actionを取得
  ↓
rollback

とすることもできる。自前の推論エンジンを持っていれば、このような処理は比較的自由にできる。ゲームの履歴やKV Cacheは維持しつつ、操作が必要な瞬間だけ一時的にdecodeする。そして操作を取得したら、decode前の状態へ戻る。これはかなり現実的な方法だと思う。LM Headも既存のものをそのまま使える。モデルを追加学習する必要もない。

ただし、毎tickこれを行うとなると、それなりにコストが高い。


actionが欲しいだけなのに文字列を作る?

ゲームが1秒に何回も進行する場合、

checkpoint
↓
decode
↓
parse
↓
rollback

を何度も繰り返すことになる。しかも必要なのはせいぜい、

LEFT

という数bit相当の情報である。それを取得するために、

{
  "action":
  "LEFT"
}

という文字列を何tokenも生成するのは、どう考えても贅沢だ。もちろんプロンプトを工夫して、

L

の1 tokenだけ出させることもできる。しかしそれでも、

  • Transformerのdecode step

  • LM Head

  • 全語彙へのlogits計算

  • sampling

  • KVの更新

  • rollback

などは残る。欲しいものが5種類程度のactionなら、もっと直接取り出せないだろうか。


decodeをどこまで削れるか

実際、actionを取得する方法には何段階かある。

1. JSONを最後まで生成する

2. "LEFT" のような操作名だけ生成する

3. L/R/U/Dなど1 tokenだけ生成する

4. LM Headのlogitsを直接読む

5. hidden stateからactionを直接分類する

6. hidden stateの時間変化からactionを読む

下へ行くほどLanguage Modelとしてのdecodeから離れていく。

今回試しているのは5番目である。

LM Headすら通さず、Transformer途中のhidden stateから操作を取り出せるか。


LLMの「脳」に電極を刺してみる

今回はGemma 4 E2Bを llama.cpp で動かした。そして通常なら外から見ることのない計算グラフ途中のtensorを観測した。取得したのは、

llama_cpp_graph_tensor:l_out-17

というtensorで、1536次元のvectorが得られる。概念的には、

ゲーム状態
   ↓
Transformer
   ↓
Layer ...
   ↓
hidden state ─────→ ここを読む
   ↓
Layer ...
   ↓
LM Head
   ↓
token

という形になる。

モデル本体は変更していない。

weightを書き換えていない。

LoRAも入れていない。

新しいheadも追加していない。

既存モデルを普通に動かし、その途中を横から観測しているだけである。

自分の中では、これはLLMの「脳」に電極を刺すようなイメージに近い。新しい回路を埋め込むのではなく、すでに流れている信号を外から読む。


1536次元からコントローラのボタンを読む

当然ながら、1536個のfloatを眺めても何をしたいのかは分からない。そこで外側に小さなdecoderを置く。今回は多項ロジスティック回帰を使った。つまり、

1536次元 hidden state
          ↓
      linear probe
          ↓
LEFT / RIGHT / UP / DOWN / NONE

という非常に単純な分類器である。意図的にlinear probeを使っている。複雑なニューラルネットを外側に置いてしまうと、

そのdecoder自身がゲームを理解しているのでは?

という問題が出てくる。今回は、

LLM内部に、すでに操作を読み出せる情報が存在しているか

を見たい。なのでdecoderはなるべく弱くした。


教師データはLLM自身から作る

では、hidden stateに何を正解として学習させるのか。今回は人間がSnakeの最適手を教えたわけではない。学習データを作る段階だけは、通常通りLLMをdecodeする。例えばLLMが、

{"action":"RIGHT"}

と出したとする。

そのとき、decode前に取得しておいたhidden stateに、

RIGHT

というラベルを付ける。構造としては、

                 ┌── hidden state ──→ 学習データ
ゲーム状態 → LLM
                 └── decode → RIGHT ─→ 教師ラベル

となる。つまり学習しているのは、

このゲーム状態ではRIGHTが正しい

というpolicyではない。そうではなく、

このLLMがこの内部状態から最終的にどの操作をdecodeしようとしているか

を予測している。最初は通常decodeを教師として使い、その後でdecodeを取り外せないか試すわけだ。


「次tokenを読んでいるだけ」ではないのか

hidden stateからRIGHTが当たったとしても、

単に次に "RIGHT" tokenが出ることを予測しているだけでは?

という疑問は当然ある。それならactionを読んでいるというより、next-token predictionを横から盗み見ているだけになる。そこで、同じ操作について複数の表記を用意した。

例えば右移動なら、

D
Right
右

左移動なら、

A
Left
左

とする。

ゲーム内部ではすべて共通actionへ正規化する。

D
Right
右
   ↓
RIGHT

こうすることで、特定の文字列だけをlinear probeが拾う可能性を多少減らせる。ただし、これだけでは十分ではない。よりちゃんと検証するなら、

WASD + Englishで学習
          ↓
日本語表記だけで評価

のようなVocabulary Holdoutも必要になる。もしこれでもactionを読めれば、単なるtoken予測より一段抽象化された情報を拾っている可能性が高くなる。


本気で作るなら専用Action Headの方が正しいと思う

ここまで「モデルを改造しない」ことにこだわってきたが、本当に性能を求めるなら、おそらく今回の方法が最終形ではない。

自然なのは、どこかのhidden layerから専用のaction headを分岐させることだと思う。

Transformer
     │
     ├── LM Head → language
     │
     └── Action Head → controller

例えば、

hidden state
    ↓
small MLP
    ↓
action logits

とする。

本体をfreezeしてheadだけ学習してもよい。LoRAなどで内部表現そのものをaction向けに調整する方法もある。実用的なVLAやpolicy modelを作るなら、たぶんこちらが本筋になる。ただ、それをやってしまうと、

actionを出すようにモデルを改造したからactionが出た

という話になる。今回まず知りたかったのは違う。既存LLMを一切変更しなくても、内部にはすでにactionとして利用できる情報が流れているのか。だから今回は外からhidden stateを観測するだけにした。

まず電極で読んでみて、それで面白そうなら次に専用の回路を付ければよい。


まずSnakeで試す

最初に使ったのはSnakeだった。ゲームの盤面、Snakeの位置、Foodの位置、現在の進行方向などをテキストでLLMへ与える。通常通りdecodeして操作を取得すると同時に、l_out-17 のhidden stateも保存する。そこからlinear probeを学習した。未使用seedで評価すると、一致率は、

81.25%

だった。ただし、この値だけを見ると誤解しやすい。Snakeでは直進、つまり NONE がかなり多かった。多数派クラスを選び続けても高い精度になってしまうため、81.25%そのものを強い結果とは言えない。

この段階で確認できたのは、

decode前のhidden stateに、最終的な操作と相関する信号が含まれている

というところまでだ。次はactionの分布を均等にしたdatasetで評価する必要がある。


ブロック崩しでも同じ場所を読んでみる

Snakeだけでは、そのゲーム特有の特徴を拾っている可能性がある。

そこでブロック崩し(Breakout)でも試した。

操作は、

LEFT
RIGHT
NONE

の3種類。使うモデルは同じGemma 4 E2B。観測位置も同じ l_out-17

そこから同じ1536次元vectorを取得する。

Breakout専用のlinear probeを学習して未使用episodeで評価すると、

70.0%

だった。

多数派baselineは66.7%。

大きな差ではないが、少なくともSnake以外のゲームでも同じ場所から操作に関連する信号は取れた。


SnakeとBreakoutで同じdecoderを使えるか

次に試したのは、ゲームをまたいでactionが共有できるかである。

SnakeにもLEFTがある。

BreakoutにもLEFTがある。

そこで両者の操作を、

LEFT
RIGHT
UP
DOWN
NONE

という共通actionへ正規化した。probeにはgame IDを教えない。入力は純粋な1536次元hidden stateだけ。

そこから1つのlinear probeでactionを分類する。結果は、

評価対象Accuracy
Snake + Breakout77.27%
Snake80.0%
Breakout70.0%

だった。少なくともSnakeとBreakoutについては、

同一モデルの同一観測地点から、一つのlinear decoderである程度共通の操作を取り出せた。



では未知のゲームにも使えるのか

ここまで来ると、

LLM内部にはLEFTやRIGHTのような汎用的なaction表現があるのではないか

と期待したくなる。そこでTetrisを追加した。

Snake + Breakoutだけで学習したprobeを、そのままTetrisへ適用する。再学習はしない。

ゲーム単位ではzero-shotになる。結果は、

7.69%

だった。多数派baselineよりも悪い。


「LEFTニューロン」を見つけたわけではない

この結果を見る限り、

この方向 = LEFT
この方向 = RIGHT

という普遍的な単純軸がhidden spaceに存在しているのを見つけた、とはいえなそうだ。

hidden stateには、

  • ゲーム状態

  • プロンプト構造

  • 出力形式

  • 選択可能なaction

  • 次token情報

  • ゲーム固有の判断

などが混ざっている。SnakeとBreakoutでは、その中から共通して利用できる方向をlinear probeが拾えた。しかしTetrisでは対応関係が崩れた


さらに先にはVLMがある

今はゲーム状態をテキストとして与えている。しかし、この考え方はVLMでもできるはずだ。最終的にはゲーム画面そのものを入力して、

screen / video frame
        ↓
       VLM
        ↓
hidden representation
        ↓
      action

としたい。普通にVLMを使えば、

画面
 ↓
VLM
 ↓
"The ball is moving left, so move the paddle left."
 ↓
parse
 ↓
LEFT

のような構成になる。しかし必要なのがcontroller actionだけなら、この文章は本来不要である。VLMが内部で、

  • 自機の位置

  • ボールの位置

  • 敵の位置

  • 移動方向

  • 危険

  • 次の行動

などを表現できているなら、その途中からactionだけ抜けばよい。究極的には、

video
  ↓
VLM
  ↓
latent dynamics
  ↓
controller

で動かせるかもしれない。ゲームであれば、これはかなりVLAに近い構造になる。


今回の実験で分かったこと

今回やったのは、まだかなり小さな実験である。

Gemma 4 E2Bをそのまま動かし、llama.cpp の途中から1536次元hidden stateを取得した。

モデル自体は変更せず、外側に単純なlinear probeを付けた。

SnakeやBreakoutでは、LLM自身のdecode結果と相関するactionをある程度読み取れた。

SnakeとBreakoutを混ぜた共通decoderもある程度機能した。

一方で、そのdecoderを未知のTetrisへzero-shotで持っていくと完全に失敗した。

なので現時点では、

LLM内部に普遍的なLEFT/RIGHT表現を発見した

とは到底言えない。

それでも、

LLMが言語として答えを出す前のhidden stateに、外部操作へ利用できる情報が存在している

という感触は得られた。

2026年8月23日日曜日

novelmat - LLMに文章を書かせず、「次に何を書きたいか」だけ覗いてテキストフィルタを作った

☆ この blog は チャッピー生成です☆


OCR文章の不要な改行を消すために、LLMへ文章を書き直させるのをやめた。
代わりにやったのは、数十トークン読ませて、logitsを数個盗むことだった——



OCRすると「そこは改行じゃないだろ」という場所で切れる

書籍をOCRすると、こんなテキストができることがある。

吾輩は猫であ
る。
名前はまだない。

元の紙面では単に行が折り返されていただけなのに、OCR結果には改行文字が入っている。

欲しいのは当然、

吾輩は猫である。
名前はまだない。

である。

一見すると簡単な問題に見える。

ところが、「日本語のどこが文章の区切りなのか」を機械的に判断しようとすると、意外と面倒だ。

形態素解析器を使う方法も考えられる。品詞を見たり、文末表現を判定したり、句読点や括弧を考慮したりすることもできるだろう。

しかし小説には、

  • 会話文

  • 句読点の省略

  • 三点リーダ

  • 括弧

  • 倒置

  • 文の途中でのページ送り

  • OCRによる文字欠落

などが普通に出てくる。

「句点なら文末」「助詞ならまだ続く」といった規則だけでは、すぐに例外が増えていく。

もちろん、きちんと日本語解析の仕組みを作れば解決できる問題ではある。

ただし、改行を1個消すために、日本語の文境界判定器を一から育てたいわけではない。

そこで考えた。

LLMはすでに大量の日本語を学習している。

だったら、その能力をもっと雑に奪えないだろうか。


最初は、「普通に」ChatGPTやQwenに直してもらっていた

実は、最初からこんな仕組みを作ろうと思っていたわけではない。

当初の運用はもっと単純だった。

OCRしたテキストファイルをQwenやChatGPTなどのLLMへ渡して、

OCRによって入った不要な改行を取り除いてください。文章そのものは変更しないでください。

と頼んでいた。

これで済むなら、それが一番楽である。

実際、短い文章ではかなりうまくいく。

しかし、ある程度まとまったテキストを処理させると、だんだん困ったことが起きた。

たとえば、

  • 頼んでいない誤字修正をする

  • 句読点や表記を勝手に整える

  • 文体を微妙に変える

  • 消してほしい改行をいくつか残す

  • 長い文章になると途中の作業が抜ける

といったことがある。

プロンプトで、

改行以外は絶対に変更しない

と強く指定しても、完全には安定しなかった。

LLMからすれば、文章全体を一度生成し直す以上、これはある意味当然でもある。

こちらが欲しいのは、

この改行を残す

か、

この改行を消す

かだけなのに、LLMには文章全体を書き直す権限を渡している。

かなり大げさな仕事の頼み方だった。

特に困るのは、結果が一見まともに見えることだ。

数千文字の文章のうち一か所だけ書き換えられていても、人間が目視で発見するのは難しい。

逆に、不要な改行が一つ残っていても同じである。

生成系LLMへ全文編集を任せる方法は便利ではあるものの、

「入力の文字列をほぼ完全に保存しながら、指定した改行だけを機械的に除去する」

という用途では、思ったより扱いづらかった。

そこで発想を逆にした。

そもそもLLMに文章を書き直させなければいいのではないか。

文章を生成させるから、余計な変更も作業漏れも起きる。

でもLLMが、

この改行は不自然だ

と判断する能力そのものは使いたい。

だったら完成した文章を返してもらうのではなく、

LLMがその場所で次に何を書こうとしているかだけを覗けばいい。

今回の novelmat は、そこから始まった。


文章を生成させるために使う「LLM」

LLMというと、普通は文章を生成させるために使う。

質問を投げれば答えを書いてくれる。要約を頼めば要約してくれる。OCRの誤りを直したいなら、

この文章を自然な日本語に修正してください

と渡せば、それらしい文章を返してくれる。

もちろん、それでもいい。

でも今回やりたかったのは、少し違う。

LLMには文章を書かせない。

代わりに、

「この続きを書くなら、次に何を書こうとしている?」

という情報だけを取り出すことにした。


LLMは「次に何が来るか」を知っている

LLMが

吾輩は猫であ

まで読んだとする。

次のトークンを生成するとき、内部ではざっくり、

「る」       ← かなりありそう
「り」
「る。」
「\n」       ← これはかなり変
「猫」
...

のように、語彙に存在する大量のトークンすべてへスコアを付けている。

普段のLLM利用では、その確率分布から1トークンを選び、

と実際に生成させる。

今回は生成させない。

その一歩手前で止めて、確率分布だけ読む。

これだけで、

吾輩は猫であ
↓
「\n」はどのくらい出したい?
「る」はどのくらい出したい?

を数値として比較できる。

つまり、LLMを文章生成器ではなく、日本語の違和感センサーとして使う。


形態素解析器を作り込む代わりに、LLMの出力を横取りする

何故こんな事を考えるのか?

日本語の文境界をちゃんと認識する仕組みを作ろうとすれば、それなりの実装になる。

形態素解析して、

助詞だから続きそう
終助詞だから切れそう
句点だから切れそう
でも括弧の中なので……

と考えることになる。

さらにOCR特有の壊れ方まで扱おうとすると、ルールは増えていく。

一方LLMは、そもそも、

吾輩は猫であ

を見せた時点で、

次はたぶん「る」だろう

という判断をしている。

ならば、その判断をそのまま盗めばいい。

日本語の構造をこちらで明示的に解析する必要はない。

必要だったのは、

  1. 問題の位置までLLMに読ませる

  2. logitsを取る

  3. 改行と次トークンのスコアを見る

だけだった。

専用モデルを学習したわけでもない。

形態素解析の辞書を調整したわけでもない。

大量の日本語ルールを書いたわけでもない。

それでも、それなりにうまくいく。

LLMの出力を奪うのは、手間のわりにかなり性能がいい。


改行あり vs 改行なしをLLMに競わせる

文章が

A\nB

となっているとする。

まず、改行しない場合を考える。

A → B

について、LLMがBをどの程度自然だと思っているかを見る。

一方で、改行を残す場合は、

A → \n → B

という系列を見る。

log probabilityを使えば、これらを比較できる。

たとえば、

吾輩は猫であ
る。

なら、「吾輩は猫であ」の次に「る」が来る確率は高い。

一方、その途中に突然改行を挟む確率は低い。

だったら、

この改行はOCRによって紛れ込んだ可能性が高そうだ

と判断できる。

逆に、

吾輩は猫である。
名前はまだない。

なら句点後の改行はそれほど不自然ではない。

重要なのは、こちらが、

「ある。」は文末です

というルールを教えていないことだ。

LLMがすでに持っている日本語予測能力を利用しているだけである。


「日本語を理解させて答えを書かせた」わけではない

ありがちな実装なら、

あなたは日本語校正者です。
以下のOCR結果から不要な改行を削除してください。

とLLMに頼み、

吾輩は猫である。
名前はまだない。

という完成テキストを生成させる。

しかし、それをするとLLMにはかなり大きな自由が与えられる。

誤字を直すかもしれない。

句読点を変えるかもしれない。

表記を整えるかもしれない。

原文そのものを書き換えるかもしれない。

今回ほしいのはそんな高度な校正ではない。

この1文字の \n を消すか、残すか。

それだけだ。

そこでLLMには編集権限を与えない。

聞くのは、

この位置で \n をどのくらい出したかった?
その代わり次の文字をどのくらい出したかった?

という内部情報だけ。

最終的にファイルを書き換えるのは普通のPythonコードである。

AIに仕事を丸投げするというより、

日本語能力だけ借りる。



「生成させない」は速度面でも重要だった

この方法には、精度や制御のしやすさ以外にもう一つ大きな利点があった。

ほとんどdecodeしなくていい。

普通にChatGPTやQwenへ、

この文章から不要な改行を取り除いて、修正後の全文を出してください

と頼む場合、LLMは入力をprefillしたあと、修正済みの文章を最初から最後までdecodeして生成する。

たとえば数千トークンの文章を処理するなら、入力側の数千トークンを読むだけでなく、出力側でもまた数千トークンを1トークンずつ生成することになる。

LLM推論では、このdecode部分がかなり重い。

生成は基本的に、

1 token生成
↓
KV cacheを使って次の1 token生成
↓
また次の1 token生成
↓
...

と逐次的に進む。

文章が長ければ、そのぶん何千回もforwardする必要がある。

一方、今回欲しいのは修正済みの文章そのものではない。

ある位置まで、

吾輩は猫であ

とLLMに読ませて、

次は「る」なのか?
それとも改行なのか?

というlogitsを一度見るだけでよい。

つまり処理の大部分をprefillとしてまとめて流せる

prefillは複数トークンをまとめて行列演算できるので、decodeのように1トークンずつ逐次生成するより圧倒的にGPUを使いやすい。

今回の処理は感覚的には、

普通のLLM校正

長い入力をprefill
        ↓
長い修正版をdecode
decode
decode
decode
decode
...

ではなく、

novelmat

周辺テキストをprefill
        ↓
logitsを数個読む
        ↓
終わり

に近い。

LLMを使っているのに、ほとんど文章を生成しない。

そのため、普通にLLMへ全文を校正させる方法と比べるとかなり速い。

これは今回の用途と相性がよかった。

欲しいのは文章そのものではなく、

この場所では何を書こうとしていたか

という一瞬の判断だけだからである。


「これから校正する」と思わせてから確率を取る

先にLLMへ、

あなたの役割は日本語ライトノベルの校正担当です。
OCRで生成された日本語文を修正してください。

#で始まる行はページ情報なので残してください。
ページをまたいだ文は前ページ側へ統合してください。

といった指示やfew-shot例を見せておく。

その後、

Assistant:
吾輩は猫であ

までをprefillする。

しかし、やはり生成はさせない。

「これから校正結果を書くつもりになったLLM」が次に何を書きたがっているかだけを見る。

この使い方では、プロンプトは回答を生成させるためだけのものではなく、

次トークンの確率分布そのものを目的に合わせて変える手段

になる。指示のほか、参考にする画像をprefillするなどしても面白そうだ。


llm-jp-4への単純なモデル差し替えはうまくいかなかった

日本語を扱うなら、日本語特化モデルのほうがうまくいくのではないか。

そこで llm-jp-4-8b-thinking も試した。

ところが、Gemmaで使っていた判定基準をそのまま持っていくと、期待した結果にはならなかった。

たとえば、

今日はいい天気ですね。
明日も晴れるといいな。

のような通常の文間でも、Gemma用に決めた基準では改行を削除する側へ寄りやすかった。

最初は、

日本語特化モデルなのに意外だな

と思った。

しかし、考えてみれば判定ロジックそのものをGemmaのlogit分布を観察しながら作っている。

モデルが変われば、

P(\n)
P(next token)

の絶対値も、差の出方も、分布の形も変わって当然である。

つまりこれは、

llm-jp-4がこの用途に向いていない、という結果ではない。

むしろ、

モデルごとに判定基準をチューニングする必要がありそう

という話だと思う。

Gemma向けに作った閾値をllm-jpへそのまま持っていって、

Gemmaのほうが優秀だった

と結論するのは早い。

今後モデルを差し替えるなら、少量の教師データから閾値を自動的に合わせたり、logit差を正規化したりする仕組みがあってもよさそうだ。


26万語を毎回ソートして41秒かかっていた

最初は llama.cpp / llama-cpp-python の通常のlogprobs取得APIを使っていた。

ところが、やたら遅い。

調べてみると、必要なのは、

P(\n)
P(next_token)

という数個の値だけなのに、API側では巨大な語彙全体をソートしていた。

今回使ったモデルの語彙は約26万トークン。

それを何度もsortする。

3改行を見るだけで、

約41秒

かかっていた。

さすがにテキストフィルタとは呼びづらい。

そこで通常のcompletion APIを迂回し、

model.eval(tokens)
logits = model._scores

からraw logitsを直接取得するようにした。

あとはnumpyでlog-softmaxして、

p_newline = logprobs[newline_token_id]
p_next = logprobs[next_token_id]

だけ取り出せばいい。

必要なのは2要素程度なのだから、26万要素を順位順に並べる必要はない。

その結果、

3改行: 約41秒 → 約1秒

まで縮んだ。

全文を毎回読ませるのも無駄なので、現在は判定位置の前後約50トークンだけを見る。

最終的には、

数十トークン読ませて、logitsを数個盗む

という非常に小さな処理になった。


最後はsedみたいになった

こうしてできたのが novelmat である。

基本は、

novelmat input.txt

標準入力も使える。

cat input.txt | novelmat

ファイルを直接書き換えることもできる。

novelmat -i input.txt
novelmat -i.bak input.txt

判定結果だけJSONで確認することもできる。

novelmat --detect input.txt -o result.json

LLMアプリなのに、UIもチャット画面もない sed のような使い心地。



LLMは「文章生成API」だけではない

LLMを普通に使っていると、

prompt → generated text

というインターフェイスだけを見がちである。

しかし実際には、その途中に、

context
    ↓
巨大なtoken probability distribution
    ↓
sampling
    ↓
next token

がある。

我々が普段受け取っている文章は、その巨大な情報から最後に1個だけ選ばれた結果だ。

その手前には、

次に改行したい度
次に「る」と書きたい度
句点を書きたい度
EOSを出したい度

が全部入っている。

だったら、生成させずにそこを使ってもいい。

今回の改行フィルタは、その小さな例でしかない。

同じ考え方なら、たとえば、

  • OCR結果がページ末で途中切れしているか

  • 2つのテキスト断片が連続しているか

  • ここに句読点があるべきか

  • 改行すべき位置はどこか

  • 語尾が欠落していないか

  • 文書の境界らしいか

といった問題も、次トークン確率から拾える可能性がある。

こうした問題をまじめに解こうとすれば、形態素解析、構文解析、ルール、専用classifierなど、いろいろな方法がある。

それらが不要という話ではない。

精度や説明可能性が重要なら、専用手法のほうが適している場合も多いだろう。

ただ、

ちょっと日本語らしさを判定したい

という用途では、すでに手元にあるLLMへ文章を食わせ、次に何を出したがっているかを横取りするだけでも、意外なほど使える。

専用の日本語処理系を組み立てるより実装の手間が少なく、しかもモデルがすでに持っている大量の言語知識をそのまま利用できる。

日本語は英語などのように区切りが明確ではなく、形態素解析などしても正確な情報が得られるわけではない前提において、これはかなり便利な性質だと思う。


まとめ -「AIに直してもらう」より「AIが何を書こうとしたかを見る」

今回の novelmat は小さなツールである。

やっていることも最終的には、

この改行を残す / 消す

という二値判定にすぎない。

ただ、実装していてLLMとの付き合い方が途中から変わった。

最初は、

LLMならこの日本語を直せるのでは?

だった。

それが、

LLMはこの位置をどう感じているんだろう?

になり、

最終的には、

答えは書かなくていいから、その瞬間のlogitsだけ見せて

になった。

日本語の文章境界を見つける仕組みをこちらで頑張って作る代わりに、

LLMがすでに持っている「続きを予測する能力」を横から奪う。

少々乱暴だが、手間の割にはよく働く。

LLMは文章生成器として非常に便利だ。

でも、生成される文章はLLMが持っている情報のごく一部でもある。

その手前にある確率分布を覗いてみると、

昔ながらの小さなUNIXフィルタの中に、LLMをひっそり埋め込む

みたいな使い方もできる。

novelmat は、そんな実験になった。