EN JA

小さなAIモデルにセキュリティログを調査させる話

自分の専門分野について200億パラメータのAIモデルに説明してみよう。自分がそれをどれだけ本当には理解していないかを知ることができる。

最近あらゆるところで見かけるAIのデモは、そのほぼすべてがフロンティアモデルと呼ばれるものを使っている。一番大きくて、一番高くて、一番賢いAIだ。データをどこかのAPIに投げれば、魔法のように答えが返ってくる。それはそれでいい。しかし、IPアドレスや攻撃ペイロード、インフラへの侵入未遂の痕跡が詰まったセキュリティログを分析しなければならなくなった途端、少し不安な気持ちになる。外部にそれを送って良いのか?

これは、200億パラメータの大規模言語モデル(LLM)で動くセキュリティログの調査エージェントを作った話だ。このすぐに幻覚を見るVRAM 16GBのGPU 1枚に載るほど小さなAIを正気に引き戻すために行ったあらゆる工夫についての記録である。

なぜ作ったのか

私はAkamaiという会社で働いている。勤め先が最近力を入れているIaaSのGPUインスタンスを使って何か面白いデモを作れないかを考えていた。私たちの会社の製品カタログにはWebアプリケーションファイアウォール(WAF)とCDNのログを溜められるマネージドデータレイクがあり、そいつはClickHouse互換のクエリインターフェースを持っている。WAFもある。AIは、まあなんていうか、流行りだ。これらを組み合わせて、AIによるセキュリティ調査という可能性を顧客に見せられないだろうか。

ただしGPTやClaudeには頼らず、小さくてセルフホストできるモデルを使いたかった。理由は二つあった。第一に、さっきも書いたようにそもそも自社のGPUインスタンス上で動かすことこそがデモの目的だった。第二に、世の中のAIエージェントのデモはどれもフロンティアモデルを使ったものばかりで、小さなモデルを複雑な分析に本気で使おうとしている人が見当たらなかったから、小さいモデルがどこまで行けるか見てみたかったのだ。

OpenAIが開発した20Bパラメータのオープンウェイトモデルgpt-oss-20bを使って実験を始めたとき、せいぜい「かろうじて動く」程度だろうと思っていた。ところが実際に触ってみると、もっと面白いことがわかった。プロンプトの工夫次第で、予想をはるかに超える能力を引き出せたのだ。フロンティアモデルには及ばないが、追求する価値は十分にあった。こうして、軽い実験のつもりで始めたものが、小さなAIを本当に使えるものにするための本格的な探求になっていった。

AIはあなたのログの意味を知らない

最初にぶつかった壁は、SQLの生成でもモデルサイズでもなかった。モデルがWAFログの意味をまるで理解していないことだった。

Akamai WAFのログをモデルに渡して「攻撃はいくつ成功した?」と聞いてみた。モデルは素直にappliedActionというフィールドがmonitorという値になっているイベントを全部拾い上げ、それらを「成功した攻撃」として数えた。WAFが検知したのにブロックしなかったなら、攻撃は通り抜けたはずだ、そうだよね?

違う。モニターモードは意図的な運用方針だ。私たちは新しいWAFルールをデプロイするとき、まず「monitor」モードで走らせて誤検知がないか確認してから、実際のブロックを有効にすることが多い。検知してログは取るが、ブロックはしない。モデルは「あえてまだブロックしていない」ことを「攻撃者にやられた」ことと取り違えていた。

レピュテーションベースのブロックも同様だ。PENALTYBOXREPUTATIONルール(どちらも今のリクエストの中身ではなく、過去の振る舞いに基づいてブロックする)でトリガーされたWAFイベントを見ると、モデルは攻撃ペイロードが含まれるruleDataフィールドが空であることを理由に誤検知だと切り捨てていた。目に見えるペイロードがないなら攻撃の証拠もない、という論理だ。だがレピュテーションルールの仕組みは違う。IPアドレス203.0.113.5が昨日大量に攻撃していたなら、今日のリクエストの中身がどうであれ、AkamaiのWAFはその履歴に基づいてブロックすることがある。これらのルールでruleDataが空なのは正常であって、データ品質の問題ではない。

これらはモデルのバグではない。Akamai WAFの知識がなければ、むしろ理にかなった解釈とすら言える。入社したての新人セキュリティアナリストでも同じ間違いをするかもしれない。問題は、LLMが間違った解釈に妙な自信を持つことだ。プロンプトにドメイン知識を埋め込まなければ、フロンティアモデルですら「自信満々のでたらめ」、つまり構文は正しいが見当違いの答えを返すSQLを生成してしまう。

部族の知識を言葉にする

対策はシンプルだ。モデルがドメイン知識を持たないのであれば、それをプロンプトに書けばいい。

最小限のシステムプロンプトから始めて調査を走らせた。モデルが間違えるたびに推論過程を読み返し、「何を知らなかったからこの間違いが起きたのか」を考えた。そしてその知識をプロンプトに足して再実行する。この繰り返しだ。

この作業は、今まで言語化したことのない知識を明文化することを迫られるという体験だった。セキュリティエンジニアは誰でも膨大な暗黙知を抱えている。Akamai WAFを触ったことがある人なら、モニターモードが攻撃成功を意味しないことも、レピュテーションルールにペイロードの証拠が要らないことも「当たり前」として知っている。だがこれは部族的な知識だ。みんなの頭の中にあるだけで、ユーザーズガイドにもわざわざ書かれていない。なぜって…ええと…だってそれはそういうものだから。

同時に、自分の理解のあやふやさにも気づかされた。わかっているつもりだったことが、機械に正しく伝わるレベルで説明しようとすると、意外と曖昧だった。読み手は曖昧にした部分をことごとく誤読する、文字通りにしか受け取れない新人インターンだ。

インターンのたとえ

20BパラメータのLLMは、インターンのようだ。「appliedActiondenyruleTagASE/WEB_ATTACK/SQLI (SQL Injectionを意味するタグ)を含むイベントを全部見つけて」のように具体的で詳細な指示を出せば、見事にこなす。ステップバイステップの手順には驚くほど正確に従える。

しかし「一番危険な攻撃を見つけて」のように抽象的な指示を出すと、途端に破綻する。「危険」という言葉の解釈の中で最も表面的なものを採用し、せいぜいWAFの検知数がもっとも多いIPアドレスをソートして表示して終わりだ。同じ曖昧な質問でも、フロンティアモデルはもっと深く考える。たとえばSQL Injectionイベントの中からHTTPレスポンスが200 OKだったものを探し出すかもしれない。WAFをすり抜けて攻撃が実際に成功した可能性を示唆するからだ。小さなモデルにはそういう創造的な推論ができない。

この差はベンチマークが測るような数学の難しい問題が解けるかどうかという純粋な能力の話というよりは、むしろ曖昧さへの対処力の違いに見える。質問が明確に定義されていて、探しているものを正確に言語化できるなら、20Bモデルでも十分な仕事ができる。このプロジェクトのアーキテクチャ上の工夫はすべて、煎じ詰めれば個々のタスクをできるだけ具体的かつ曖昧さのないものにすることで、小さなモデルの弱点を回避する方法だ。隠れた才能を引き出しているのではなく、混乱の余地を一つずつ丁寧に潰しているのである。

フロンティアモデルが質的に違いを見せるのは、慎重なプロンプトエンジニアリングでは対応しきれない場面、つまり曖昧な問いに対してだ。「このログを見て何かおかしいことがあったら教えて」というシニアアナリストの指示は、小さなモデルにとっては致命的に曖昧でオープンエンドだ。フロンティアモデルはこれを処理できる。「おかしい」の合理的な解釈を自力で組み立てられるだけの世界知識と推論力を持っているからだ。「どういう意味でおかしいのか?統計的に?振る舞いとして?何と比べて?」と内部で自問しながら、その解釈を追いかけていく。それこそがフロンティアモデルがその値段分を価値を出す場面だ。

調査を小さなステップに分割する

従来のText-to-SQLシステムは、一発で必要なクエリをすべて生成しようとする。セキュリティログの調査において、これには無理がある。「偵察を行ってから攻撃を仕掛けたIPを特定して」という質問には、複数の相関クエリが必要だ。まず偵察活動を見つけ、その結果を使って後続の攻撃イベントを探す。

フロンティアモデルならこれを一気に計画できるかもしれない。20Bモデルには無理だ。そこで「賢くあれ」と求める代わりに、「一歩ずつ進め」と求めることにした。

エージェントはラウンドを繰り返しながら動く。各ラウンドでモデルはシンプルなSQLクエリをいくつか生成し、実行し、結果を見て判断する。質問に答えるのに十分な情報が揃ったか、それともまだ掘り下げが必要か。もっとデータが要るなら、前のラウンドで見つかったIPアドレスやタイムスタンプ、ルール名といった具体的な値を使って次のクエリを組み立てる。

こうすることで、小さなモデルには手に負えない一つの大きなタスクを、それぞれが簡単なタスクの連続に変換できる。嬉しい副作用もある。調査の流れが、人間のアナリストの実際の働き方にとてもよく似ているのだ。クエリを全部まとめて書くのではなく、データを眺め、気になるものを見つけ、深掘りしていく。

結果がコンテキストウィンドウに収まらないとき

セキュリティログへのクエリは膨大な結果を返しうる。「このホストの直近1週間のWAFイベントを全部見せて」と頼めば、数万行になることもある。これを丸ごとLLMのコンテキストウィンドウに押し込むのは無理だ。トークン上限を超えるか、ノイズの海にモデルが溺れるかのどちらかになる。

もっとも安易な対策は結果を切り捨てることだ。先頭N行だけ取って、重要なものがそこに入っていることを祈る。

このエージェントは別のやり方をとる。クエリが巨大な結果セットを返したとき、PythonコードがLLMに渡す前にオーバーフローを検知する。1万行をそのままLLMに渡して祈るのではなく、クエリを統計的なサマリークエリに書き直すようLLMに依頼する。分析に必要な情報は保ちつつ、コンテキストに無理なく収まるようにする。1万行の生データの代わりに、「攻撃タイプ別のイベント数トップ20ソースIP」や「時間帯別イベント分布」などをLLMに渡すイメージだ。どのサマリーが調査に一番関係あるかの判断はモデルに任せる。精密さが求められるタスクではなく判断が求められるタスクなので、これはモデルの得意分野だ。

LLMは私と同じくらい算数が苦手だ

LLMは算数がとにかく苦手だ。学習データから数値のパターンを覚えたトークン予測マシンであって、計算を実行しているわけではない。モデルが小さいほど状況は悪くなる。20Bモデルに211 / 712 * 100を暗算させると、堂々と間違った答えが返ってくることがある。

セキュリティ分析では多くの数の足し算や、比率の計算が頻繁に出てくる。「攻撃の何パーセントがブロックされたか」は割り算を伴う基本的な問いだ。

解決策は、何を計算するかの判断はモデルに任せ、実際の計算はPythonにやらせることだ。モデルは暗算しない。計算が必要なとき、こういう構造化タグを出力する:

<calc formula="blocked/total*100" expr="211 / 712 * 100" precision="1" />

エージェントのPythonコード側のポストプロセッサが安全な評価器で式を計算し、結果をコンテキストに埋め込む。モデルはどの計算が必要かを判断して式を正しくセットアップするだけでいい。実際の数値計算は決定論的に処理される。

数値の引用にも同じ考え方を適用した。モデルは数字をでっち上げることがあった。クエリの結果が483なのに「500件」と書いてしまう。これを防ぐために、数値を含む主張には参照元のクエリ結果を指す<fact>タグでの引用を義務づけた。この出典を明示するパターンは数値のハルシネーションを大幅に減らした。「この数字はクエリ2の行3から来た」と宣言させることで、それっぽい数字をでっち上げる代わりに、実際のデータを参照するように出力の確率分布を歪ませることができたのかもしれない。

同僚からの試練

技術デモ用としては、SQLの安全対策は十分だろうと思っていた。ClickHouseへの接続は読み取り専用にしてあるし、危険なパターンを弾く正規表現も入れてある。もちろん本番レベルのセキュリティを謳うつもりはなかったが、それでもデモ用プロジェクトのセキュリティ対策としてはまずまず悪くないだろうと考えていた。

そして同僚にこのエージェントを触ってもらった。

彼らは即座にエージェントを誤動作させようとし始めた。そしてあっさりClickHouseのバージョン番号を取得し、データベース内の全テーブルを一覧表示させた。すべて、正規表現パターンが想定していなかった完全に正当なSELECT文を使って。ある同僚はエージェントを猫語で話させることにも成功した。あるホストの詳細な偵察分析が、全文「ニャー」で終わる調査セッションとして出力されていた。

LLMは確率的なシステムであり、創造的な同僚からであれ、ログに仕込まれた悪意あるデータからであれ、敵対的な入力で誘導される可能性がある。正規表現ベースのSQLフィルタリングは根本的に無理がある。無限に存在しうるSQL構造を相手にモグラ叩きをしているようなものだ。

対策として、SQLGlot(Python製のSQLパーサー)を使い、LLMが生成したSQLをすべて抽象構文木(AST)にパースするようにした。クエリがデータベースに届く前にASTを検査する。SELECT以外の文は即座に却下し、ホワイトリストに載っているテーブルのみ許可し、既知のエラーパターンは自動修正する。

このASTアプローチは、まったく別の問題も一緒に解決した。モデルがSQLの方言を混ぜてしまう問題だ。

ClickHouseはSparkではない

ClickHouseは独自のSQL方言を持つカラム型データベースだ。他のDBにはない関数があり、他のDBにある関数がなかったりする。さまざまなSQL文を学習したこの20Bモデルは、ClickHouseの関数を使うべき場面でApache Sparkの方言に引っ張られがちだった。

たとえば、groupArray()(ClickHouse) の代わりにcollect()(Spark) と書き、groupUniqArray()の代わりにcollect_set()length()の代わりにsize()と書く。構文としてはもっともらしいが、どれも動かないクエリが出来上がる。

最初はプロンプトで矯正しようとした。しかし多少の効果はあったが不十分だった。学習データにSpark SQLが大量に含まれているのか、「使うな」と指示してもしばしばSpark構文に戻ってしまう。しかも「Xではなく代わりにYを使え」という注意書きを足すたびにシステムプロンプトが長く複雑になり、小さなモデルの他のタスクの性能が落ちた。

解決策は力技だった。ASTパーサーが既知の関数名の不一致を検出し、実行前に書き換える。お世辞にもエレガントとは言えないハックだし、理想を言えばLoRAなどでファインチューニングしてClickHouseの方言をしっかり覚えさせるべきだろう。だが現実には、今日動く10行のAST変換のほうが、いつか作るかもしれないファインチューニングパイプラインより価値がある。

こうした修正は、SQLエラーだけを記録する専用のログファイルを維持しながら少しずつ蓄積していった。モデルが生成したSQLエラーをすべてログに記録し、定期的に見直してエラーを分類し、プロンプトのヒントかAST変換を追加する。カンファレンストークのネタにはならない地味な作業だ。

構造化されていない構造化出力

このプロジェクトの技術的な課題の中で、最も時間を無駄にしたのは予想外の所にあった。モデルに構造化された出力を安定して生成させることだ。

今どきのLLMはFunction CallingやStructured Outputの仕組みを備えていて、モデルがスキーマに適合した正しいJSONを返すことを保証できるとされている。フロンティアモデルならおそらく十分に機能するのだろう。しかしこの20BパラメータのLLMでのそれは悪夢だった。

必須フィールドが突然なくなる。スキーマにないJSONキーが発明される。末尾カンマやエスケープ漏れで不正なJSONが出てくる。途中でぶつ切りのレスポンスが返ってくる。小さなモデルではよく知られた問題だが、実のところこれらの点においてこの20Bのモデルはそこまで悪くなかった。しかし最も重大な問題は別の所にあった。OpenAIはオープンウェイトモデルにHarmonyという新しい出力フォーマットを導入している。これはモデルがフラットなテキストを返す代わりに、出力を複数の仮想的な名前付きチャンネルに分ける仕組みで、Reasoning(Chain-of-Thought)はanalysisチャンネル、ツール呼び出しはcommentary、最終回答はfinalといった具合だ。実際のところ、この20Bモデルはチャンネルのマーカー構造が壊れたHarmony出力を頻繁に生成した。

失敗するたびにリトライが走り、LLMの推論は遅いので、リトライが増えると調査全体が果てしなく長引く。プロジェクト初期の開発時間の半分以上を、構造化出力の失敗と格闘するのに費やしたと見積もっている。

定説では、モデルのネイティブの仕組みであるFunction Callingが最も信頼性の高いアプローチのはずだった。世界でもっとも賢い人々がまさにそのためにトレーニングしたのだから。しかしこれが完全な間違いだった。少なくともこのOpenAI製の小さなモデルでは。同様の報告はネット上にも多い。

転機はBAML (Basically a Made-up Language)を導入したことだった。型安全なLLMインタラクションのためのDSLだ。BAMLのアプローチはまったく異なる。プロンプトでは型付きの疑似スキーマ表現でLLMに応答フォーマットを指示し、デコード時にトークン生成を制約するのではなく、モデルには自由に出力させてから、その結果を型付きデータ構造へパースする。多少の構文エラーはよしなにやってくれる。ネットワークエンジニアが言うところのPostelの法則(送るときは厳密に、受け取るときは寛容に)を、LLM出力に適用したようなものだ。

BAMLを入れたことで、構造化出力の成功率は劇的に向上した。同じモデル、同じプロンプトで、出力の扱い方を変えただけで信頼性が劇的に向上した。

コンテキストエンジニアリング is All You…

コンテキストエンジニアリング(あるいはプロンプトエンジニアリング、あるいは…今四半期はそれを何と呼んでいる?)が、本当に重要だということがわかった。

コンテキストエンジニアリングとは、LLMに渡す情報を制御するあらゆる営みだ。システムプロンプトの構造、クエリ結果の整形方法、過去の調査ラウンドの見せ方、指示の並び順、何を含めて何を省くか。モデルが有用な分析を出すかゴミを出すかの分かれ目はここにある。

これを実感したのは、BAMLの導入に合わせてプロンプトの設計を全面的に見直したときだった。システムプロンプトを明確なセクションに整理し、データベースのクエリ結果をLLMに見せるための出力フォーマットを丁寧に整え、過去の調査コンテキストをモデルにどう提示するかを注意深く設計した。テストの合格率が劇的に上がった。モデルが賢くなったわけではない。受け取る情報がきちんと整理されただけだ。

見落とされがちだが重要なポイントがある。小さなモデルでは特に、システムプロンプトの内部に矛盾があってはならない。4ヶ月も(そう4ヶ月もこのデモの開発に費やしていた)長いプロンプトのあちこちを繰り返し修正していると、いつの間にか微妙な矛盾を持ち込んでしまうことがある。あるセクションでは「常にXせよ」と書き、別のセクションが暗に「この場合はYせよ」と示唆し、XとYが矛盾している。研究でもLLMは矛盾した指示を与えると著しく性能が落ちることが示されているが、実際にこのエージェントを作っているときに嫌というほど痛感させられた。モデルの出力が突然おかしくなり、5000トークンのプロンプトの離れた2箇所に埋もれた意味的矛盾が原因だったと突き止めるのに何時間もかかることがあった。

学んだこと

このエージェントを作る過程で、いくつかの考え方が変わった。

小さなモデルは思ったより使える。 何にでも使えるわけではない。抽象的な推論や曖昧さへの対処には、やはり大きなモデルが必要だ。けれど、タスクが明確に定義されていて適切なプロンプトを与えれば、20Bモデルでも十分に実用的な仕事ができる。鍵はエージェントのアーキテクチャにある。複雑なタスクを単純なステップに分割し、モデルに良質なコンテキストを渡し、コードで決定論的に処理できる部分はすべてコードに任せる。

ドメイン知識こそがボトルネックだ。 このプロジェクトで一番難しかったのは、文法的に正しいSQLをLLMに書かせることではなかった。そのSQLが何を意味すべきかを私が言語化することだった。ドメイン知識を明確に言葉にできれば、小さなモデルでも驚くほど遠くまで行ける。できなければ、フロンティアモデルでも自信満々のでたらめを吐く。

コンテキスト内の矛盾に気をつけろ。 LLMは矛盾した指示があると著しくパフォーマンスが低下する。どこかを編集したら必ずプロンプト全体を見直そう。離れたセクション間の意味的な矛盾は、モデルの出力だけからデバッグするのはほぼ不可能だ。

LLMとコードの境界線が重要。 LLMが苦手なこと(算数、正確な数値の記憶、セキュリティ制約の強制)はやらせない。LLMが得意なこと(調査の計画、どのサマリーが有用かの判断、回答が十分かの見極め)はやらせる。この役割分担を明確にするほど、システム全体の信頼性が上がる。

この先について

このエージェントは概念実証であり、本番システムではない。決定論的なコードのほうが信頼性の高い場面でも、あえてLLMに作業を任せている部分がある。目的はあくまで可能性の探求であって、堅牢なものを作ることではなかったからだ。

…ここまで書いてきたことを読み返すと、ひどく当たり前のことを言っているような気もしてくる。新しい発見は何もない。ここまでに書いてきた"テクニック"をより洗練された形で書いている論文がarXivにはたくさん転がっているはずだ。ただ、大学時代の授業で聞いた言葉で、妙に印象に残っているものがある。いわく「他人の知見を再現することも科学への貢献の一つだ」と。そうだといいのだけれど。

security-investigation-agent / GitHub