【Rust × Local LLM】M3 Macでテキスト要約CLIツールをMap-Reduceで作るまでの泥臭い試行錯誤
(週末の時間潰して作ったものをAIに記事化してもらいました)
はじめに
昨今、クラウドのLLM APIを使うのが当たり前になっていますが、「システムログや機密データが含まれる長文を、手元のローカル環境だけでサクッと処理したい」と思い立ち、Rustとllama-cpp-2(C++バインディング)を用いた単一バイナリのCLIツール lfm-cmd の開発に挑戦しました。
モデルはエッジ向けで軽量なLFM 2.5(1.2B)を選び、M3 MacのMetal(GPU)でゴリゴリ回せば最強の要約パイプ(|)ができるだろう……と甘く見ていたのですが、実際に夏目漱石の『坊っちゃん』を流し込んでみると、LLM特有のじゃじゃ馬っぷりとシステムレイヤーの壁に見事に跳ね返されました。
本記事は、理想の要約ツールを夢見て沼にハマり、巨大モデルに厳しいレビューを受けるまでに至った試行錯誤の記録です。
今回構築したMap-Reduceアーキテクチャ(理想)
ローカルLLMの限られたコンテキスト長(脳のメモリ)で長大なテキストを処理するため、以下のようなパイプライン処理をRustで実装しました。
- Smart Chunking(ストリーム分割): 標準入力からのテキストを単純な行区切りではなく、モデルが文脈を失わない「賢さのスイートスポット(今回は256トークン)」に合わせて、句読点ベースで安全に分割。
-
Mapフェーズ(並列Worker): M3のユニファイドメモリ帯域を最適化するため、Workerスレッド(
-w 3)を立ち上げ、Metalオフロードで各チャンクを並列要約。 - Meta-Prompting(動的プロンプト生成): 先頭のチャンクをLLM自身に「味見(DPI)」させ、「このテキストのジャンルと最適な要約フォーマット」の指示書を自動生成させる。
- Reduceフェーズ(最終統合): 全Workerの抽出結果をガッチャンコし、Step3で作った指示書に従って最終的なMarkdownサマリを出力する。
これぞUnix哲学と最新AIの融合!完璧な「魔法の土管」ができた!……と、設計したこの時は本気で思っていました。
失敗1:無限ループ(ゲシュタルト崩壊)の沼
最初に直面したのは、AIが同じ単語を永遠に繰り返し始める現象でした。
「給を上げるため、給を上げるべきだ。給を上げるため、給を上げるべきだ……」
【学んだことと対策】
LLMの出力を単純なGreedyサンプリング(常に一番確率の高い単語を選ぶ)で実装していたため、一度ループの引力に捕まると抜け出せなくなっていました。
公式推奨のパラメータ(repetition_penalty: 1.15, top_k: 50 など)を適用しつつ、1.2Bという小さなモデルにいきなり数千トークンを読ませるのが間違いだと気づき、Map処理のチャンクサイズを極小の「256トークン」まで切り詰めることで、ようやくループから脱出できました。
失敗2:AIの幻覚だと思い込んでいた「先頭文字欠落」バグ
ループは収まりましたが、今度は出力がおかしくなりました。
[出力] 「坊っのエピソードを語る。」「は小日向の養源寺。」
「坊っちゃん」の「ちゃん」、「墓は小日向」の「墓」など、名詞の先頭の漢字だけが消滅するのです。「設定した反復ペナルティが強すぎて、プロンプト内の単語を避けているんだ」と思い込み、パラメータ調整を繰り返しましたが全く直りませんでした。
【真の原因はもっと低いレイヤーにあった】
丸一日悩んでバインディングライブラリのコードまで潜った結果、原因はAIの推論ではなく**「UTF-8のバッファ溢れ」**でした。
BPEトークナイザーは日本語の3バイト文字を平気で分割(例:「坊」を2バイトと1バイトに分割)してきます。しかし、バックエンドの token_to_piece() 関数は「受信したバイト数(1バイト)」しか文字列バッファを確保しない実装になっており、3バイトの完了形を結合しようとした瞬間に内部で OutputFull エラーが発生。それが握り潰されて、文字が静かに欠落していたのです。
AIの挙動を疑う前に、システムプログラミングの基本(メモリアロケーション)を疑うべきでした。Rust側に安全なバッファを持つ decode_token を自作して回避しました。
失敗3:AIを制御しようとして日本語を壊す(電報化)
文字は正しく出るようになりましたが、今度は文章がカタコトになりました。
「友人との交流自信をつけ。」「逃走の詳細に描かれて。」
【学んだことと対策】
無限ループを恐れるあまり設定した repetition_penalty が、日本語特有の「〜ます」「〜た」「〜ている」といった自然な語尾の繰り返しまで親の仇のように破壊していました。
「AIをパラメータでガチガチに制御しようとする」のはアンチパターンだと反省し、最終統合(Reduce)フェーズでは思い切ってペナルティを無効化(1.0)し、temperature = 0.2 の自然な揺らぎに任せることで、ようやく流暢な日本語を取り戻しました。
最大の挫折:メタプロンプトの過信と「コンテキスト崩壊」
あらゆるバグを乗り越え、いよいよ自慢のアーキテクチャ「自律型ルーティング(Meta-Prompting)」を試す時が来ました。
意気揚々と『坊っちゃん』(全150チャンク)を流し込み、出てきた最終サマリをGPT-4などの巨大モデルに評価してもらったところ、容赦ないコードレビュー(ツッコミ)を受けました。
- 巨大モデルからの指摘:
- 「『少年時代の成長を描く短編』という誤った前提で要約されている。(※先頭チャンクしか読んでいないため)」
- 「兄は死んでいない。事実誤認(ハルシネーション)がある。」
- 「赤シャツとの対立など、物語の9割にあたる核心が欠落している。」
まだ続く、次へのステップ
「ローカルでLLMを動かす」という軽い気持ちで始めましたが、結果的にメモリアクセスの最適化、UTF-8のマルチバイト境界のデバッグ、そしてAIの文脈崩壊との戦いという、総合格闘技のような週末になりました。
現在のアーキテクチャでは長大な物語を一つの美しい文章にまとめるのは難しいため、別のアプローチを取り込んで進めようと思います。
まだまだ実用には改善の余地だらけですが、依存関係ゼロの単一バイナリがローカルのGPUを唸らせてテキストをパースしていく姿を見るのは、なかなか楽しい体験でした。
