エラーも警告も出ていないのに、AI が二手前の会話を忘れている。そんな壊れ方に出くわしたことはありますか。私はありました。レスポンスは 200。例外もログも無し。会話は成立しているように見える。ただ、二手前を覚えていない。欠けているのは、文脈だけ。WordPress 7.0 の WP AI Client へチャットボットを移したとき、私が最初に踏んだのは、この「音のしない故障」でした。
確認環境:WordPress 7.0 RC3 で実装・検証し、5月21日の安定版でも同じ挙動を再確認しています。PHP 8.3.30、Rapls AI Chatbot 1.8.0。RC 段階で実装したので、本文は WP AI Client の仕様が今後動きうる前提で書いていますが、ここで述べる挙動(
with_history()の型、role enum、復号通知の経路)は RC3 と安定版の双方で一致していました。検証日は RC3 が5月18日、安定版が5月21日です。
背景を先に畳んでおきます。WordPress 7.0 で、AI Client が標準に入りました。サイト管理者が Connectors にプロバイダを繋いでおけば、プラグインは自前で API キーを抱えなくても、その先のモデルへ問い合わせられます。このチャットボットは、もともと OpenAI、Claude、Gemini、OpenRouter を自分で抱えていました。各プロバイダのキーを暗号化して持ち、モデルを選ばせ、レスポンスを整える。その層を、全部自前で書いていたわけです。7.0 の AI Client は、その層をプラットフォーム側に肩代わりさせる申し出でした。この4社を1枚の層で吸収していた自前の設計は、AI チャットボットを4社対応にした話 に書きました。乗せ替えたい。キーもモデルも、プラグインの外、Connectors に預けてしまいたい。ただ、緊張が一つありました。ユーザーの大半は、まだ 7.0 未満にいます。新しいプロバイダを足すことはできても、古い WordPress で一行たりとも壊してはいけない。新機能は、存在しない AI Client の上で、静かに眠っていなければならない。乗せ替えそのものは、申し出に乗るだけのはずでした。検出して、繋いで、古い環境では眠らせる。難しい話ではない、と思っていました。時間を溶かすことになったのは、その予想のどれでもない、たった一点でした。
ただ履歴を渡すだけだと思っていた
それは、会話の履歴を with_history() に渡す、ただそれだけのところでした。ここで二段に転びました。一段目は、派手に転んだ方です。このプラグインは普段、会話履歴を ChatML 風の生配列で持っています。['role' => 'user', 'content' => '…'] という素直な形です。これをそのまま渡しました。
|
1 2 3 4 5 6 7 8 9 10 11 |
// プラグイン内部の履歴表現(ChatML風の生配列) $history = [ ['role' => 'user', 'content' => '東京の天気は?'], ['role' => 'assistant', 'content' => '晴れです。'], ['role' => 'user', 'content' => 'では大阪は?'], ]; $response = wp_ai_client_prompt( 'では大阪は?' ) ->with_history( $history ) // ← 配列を「1個の引数」として渡している ->generate_text(); |
これは動きません。WP AI Client の withHistory() は withHistory( Message ...$messages ) という型付きの可変長引数で、Message 型の列を期待しています。そこへ生の配列を1個渡すと、$messages[0] に array を束ねようとして TypeError になります(array given, Message expected)。ここで一瞬、WordPress 側のラッパーが受け止めてくれるかと期待しましたが、受け止めませんでした。WP_AI_Client_Prompt_Builder の __call は catch (Exception $e) しか拾いません。TypeError は Error 系で Exception ではないので、捕まらずそのまま上へ抜けて、派手に落ちます。これは、むしろ良い失敗でした。すぐ気づけるからです。
問題は二段目でした。こちらは静かに転びます。派手に落ちた経験から、Message 型を正しく組むコードを書きました。役割を user / model に畳み、テキストを MessagePart で包み、配列にして UserMessage / ModelMessage に渡し、最後に with_history() へスプレッドで展開します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\MessagePart; private function apply_history( array $history ): array { $messages = []; foreach ( $history as $turn ) { $text = (string) ( $turn['content'] ?? '' ); if ( '' === $text ) { continue; // 空ターンは渡さない } $part = new MessagePart( $text ); // 文字列1個でtextパート // user 以外は model に畳む(二値強制) $messages[] = ( ( $turn['role'] ?? '' ) === 'user' ) ? new UserMessage( [ $part ] ) : new ModelMessage( [ $part ] ); } return $messages; } |
|
1 2 3 4 5 |
$messages = $this->apply_history( $history ); $response = wp_ai_client_prompt( $latest_user_text ) ->with_history( ...$messages ) // スプレッド展開 ->generate_text(); |
見やすさのために use で書いていますが、本番のコードでは use ではなく文字列クラス名と class_exists() で MessagePart などを参照しています(実際のガードは MessagePart / UserMessage / ModelMessage の3クラスすべての存在を確認しています)。WP 7.0 未満のサイトで DTO クラスが存在しなくても、ファイル自体が fatal しないためです。このガードが、すぐ後で書く本当の罠に直結します。
役割の畳み方について、一つ正直に書いておきます。user 以外を全部 model に倒しているのは、雑なのではなく、前提に寄りかかった割り切りです。WP AI Client の role enum は user と model の二値しかなく、system は別経路(using_system_instruction)へ行きます。しかも system メッセージは手前の処理で分離済みなので、apply_history() に届くのは実質 user と assistant / bot だけです。だから二値強制で破綻しません。ただしこれは「system がここに来ることはない」という前提の上に乗っています。もしその前提を破る入力が将来来たら、system が黙って model ターンに化けます。今は安全です。ただ、安全の理由が前提の側にある。それは覚えておくべきことでした。
そして、本当の罠はこの先にありました。正しくマーシャリングするコードは書けました。Message 型を組み、配列で包み、スプレッドで展開する。一段目の TypeError はもう出ません。それで安心してしまったのが、二段目の入口でした。本番のコードは、いま見せたマーシャリングをそのまま try/catch で包んでいます。理由は 7.0 未満との互換です。古い WordPress には WP AI Client の DTO クラスが存在しないので、組み立ての途中で何かが投げても、サイト全体を fatal させたくない。だから class_exists() で DTO の有無を確かめ、組み立て全体を try/catch(\Throwable) で囲い、何か起きたら履歴を丸ごと諦めて先へ進む。そういう防御を入れました。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 実コードの構造(簡略化) if ( ! class_exists( $message_part_class ) ) { // …WP_DEBUG時のみログ… return; // 履歴を組まずに戻る } try { // …MessagePart / UserMessage / ModelMessage を組み立てて with_history() … } catch ( \Throwable $e ) { // …WP_DEBUG時のみログ… return; // 履歴を組まずに戻る } |
設計としては筋が通っています。古い環境で落ちるより、文脈を一回諦めてでも応答を返す方がいい。そう判断しました。問題は、この防御が「諦めた」ことを誰にも告げないことでした。apply_history() は void で、スキップ経路に入ると with_history() が一度も呼ばれないまま戻ります。ビルダーに残るのは、システム指示と、wp_ai_client_prompt() に渡した最新の一ターンだけ。generate_text() は何事もなく 200 を返します。会話は成立しているように見えます。ただ、二手前を覚えていない。エラーログもない。例外もない。レスポンスは正常。欠けているのは文脈だけです。
上の図のとおり、防御パスに入ると with_history() は呼ばれず、最新の一ターンだけで 200 が返ります。失敗を知らせる信号は出ません。しかも、本番で実際に火を噴くのは class_exists() の方ではありません。wp_ai_client_prompt() が存在する時点で AI Client はロード済みなので、DTO も普通は autoload できます。class_exists() が false になるのは、SDK が将来名前空間や構造を変えたときの、潜在的な罠です。現実に履歴を落とすのは try/catch(\Throwable) の方です。SDK 側のシグネチャがほんの少しズレる(UserMessage の引数形、with_history の型、role enum の変更)と、正しく書いたはずのマーシャリングが投げ、ここで握られて、履歴が静かに消えます。
そして、沈黙の決定打はここにあります。どちらのスキップ経路も、ログは if ( defined('WP_DEBUG') && WP_DEBUG ) の内側に書いてあります。本番の WP_DEBUG は off です。つまり防御コードはちゃんとログを書いているのに、そのログ自体が本番では沈黙します。安全網が失敗を飲み込み、その失敗を知らせるはずの警報まで、フラグの裏で切れている。善意の防御が、二重に音を消していました。失敗は起きています。ただ、それを伝える信号が、どこにも出ない。一段目と二段目の差は、結局「ガードの有無」そのものでした。ガードのない最小再現は TypeError で派手に落ちて、すぐ気づけます。ガードを付けた本番コードは、同じ失敗を静かに飲み込んで、文脈だけを抜いた応答を平然と返します。落ちてくれる方が、ずっと親切だったのです。
どうやってこの沈黙に気づいたのか、正確な経緯はもう手元の記録に残っていません。ただ、当時の検証メモには「マルチターンの会話で、過去の発言を踏まえた返答が返ってくること」が独立した確認項目として立っていました。ユニットテストでは出ず、手で何ターンか会話して初めて露見する種類の壊れ方なので、おそらくその手動の検証の途中で踏んだのだと思います。
全プロバイダがキーを持つと、信じて疑わなかった
with_history() の沈黙を抜けた先で待っていたのは、まったく別種の問題でした。型の話ではありません。プラグイン全体が、誰にも明文化されないまま握っていた前提を、WP AI Client が踏み抜いたのです。その前提とは「どのプロバイダも、必ずプラグイン側に API キーを持っている」というものでした。wpai はキーを Connectors に丸投げします。プラグインの中に wpai の API キーは存在しません。たったそれだけのことが、想像していなかった場所から順に顔を出しました。浅いところから書きます。
いちばん浅い穴は、設定スキーマでした。最初は、新しいプロバイダをスキーマに教えるだけの作業です。許可リストに wpai を足さないと、in_array で弾かれて選択がリセットされます(無効な値は直前の保存値、無ければ openai に差し替わります)。sanitize に wpai_model を通し、デフォルト値に空文字を置く。地味な、通し忘れると動かない類の対応です。ただ、ここで一つ気づくことがあります。他のプロバイダはどれも _api_key と _model の両方を持つのに、wpai は _model しか持ちません。
|
1 2 3 4 |
openai : openai_api_key + openai_model openrouter : openrouter_api_key + openrouter_model wpai : (キー欄なし) + wpai_model |
wpai_api_key という設定はどこにも存在しません。キーを持たないという事実が、設定スキーマに「穴」として可視化されています。「全プロバイダはキー欄を持つ」という前提の、いちばん静かな破れ目がここでした。このときはまだ、それが下の二つの予兆だとは思っていませんでした。
真ん中の段は、REST 事前チェックです。チャットの REST 事前チェックは、呼び出し前にアクティブなプロバイダのキーを復号し、空なら api_key_missing で 400 を返して即弾く、という作りでした。「呼ぶ前にキーの存在を確かめる」。理にかなっています。キーが無いのに AI へ投げても無駄だからです。wpai はこの前提に当てはまりません。キーが無いのが正常なので、事前チェックを名前で丸ごと囲って除外しました。能力判定ではなく、!== 'wpai' という名前ベースの exempt です。ここで起きたのは、単なる除外ではなく可用性チェックの移動でした。他のプロバイダは「呼ぶ前にキーで」可用性を確かめます。wpai は「呼ぶ時に Connector の可用性で」確かめる。is_supported_for_text_generation() に肩代わりさせました。同じ「使えるか」の判定が、キーの存在チェックから、呼び出し時の能力チェックへとずれたわけです。前提が一つ、例外を学んだ瞬間でした。
いちばん深い段が、この章の本題です。「API キーの復号に失敗しました」という管理画面通知が、wpai ユーザーに誤発火していました。最初は wpai 対応の漏れだと思いました。実際は違いました。wpai は、もっと前から壊れていたものを炙り出しただけだったのです。誤発火は三段の連鎖で起きていました。設定ページを開くたびに走る移行処理(maybe_migrate_legacy_keys())が、全プロバイダのキーを総なめします。そのループの中で、失敗時にグローバルな transient を立てる復号ラッパーを呼んでいました。そして通知の表示判定は、その transient と権限だけを見て、無条件に表示していました。どのプロバイダが失敗したのか、それが今アクティブなプロバイダなのかを、一切見ずに。
上の図が、その誤発火の構図です。OpenAI に乗り換えたユーザーの環境に、塩を替えた後で復号できなくなった古い Claude キーが残っている。設定ページを開く。移行処理がその死んだキーに触れ、グローバル警報が立つ。OpenAI で平和に動いているのに、捨てたはずの Claude キーのせいで「復号に失敗しました」を食らう。警報は正しく鳴っています。ただ、鳴っている相手が間違っている。信号は出ているのに、それが指す真実がずれていました。
ここで腑に落ちたことがあります。この誤発火は、wpai が持ち込んだバグではありません。複数プロバイダのキーが同時に保存される設計になった時点で、もう起きうるバグでした。アクティブは一つでも、どの古いキーでもグローバル警報を立てられたのですから。ただ、誰もその経路を踏まなかったから、誰も気づかなかった。キーを持たない wpai を足して、「そのキーは誰のものか、それはアクティブか」を初めて真面目に問うたときに、ようやく露見したのです。だから直し方も、wpai の例外追加にはしませんでした。表示判定を、四段のゲートに一般化しました。
|
1 2 3 4 5 6 7 |
// 通知の表示判定:「アクティブなプロバイダ自身のキーが、本当に問題か?」 if (active === 'wpai') return; // ① キーレス=プラグイン側キーは無関係 if (active の *_api_key が空) return; // ② そもそも未設定 if (active 自身のキーが正常に復号できる) return; // ③ 失敗は別の未使用プロバイダ由来 // ④ ここまで来た時だけ=アクティブ自身のキーが本当に壊れている show_notice(); |
=== 'wpai' は、例外として付け足したものではなく、「アクティブなプロバイダ自身のキーが、本当に問題なのか?」という問いの、第一分岐にすぎません。並びそのものが、それを示しています。この章の持ち帰りは、たぶんこれです。新しい抽象を足すと、新しい例外が N 個要るだけではありません。「その不変条件は、実は最初から成り立っていなかった」ことを炙り出すことがあります。wpai は、私が暗黙に信じていた二つの前提、つまり「全プロバイダはキーを持つ」と「復号失敗=唯一のキーが壊れている」のうち、前者を破り、後者がとっくに偽だったことを教えました。「全プロバイダは API キーを持つ」という前提は、最初から偽だったのです。
temperature の 400 は、一度だけ握ればいいと割り切った
ここまでが重い話でした。最後は、もっと素朴な一手を記録しておきます。Connectors の先が GPT-5 や o 系のモデルだと、カスタムな temperature を受け付けず、400 を返してきます。これらのモデルは固定の温度しか持たないので、こちらが指定した値が拒否されます。既知の挙動です。対処は泥臭いものです。一度目の生成がエラーを返し、そのエラー本文に temperature という語が含まれていて、かつ自分が実際にカスタム温度を指定していたとき。この三つが揃ったときだけ、temperature を外してもう一度だけ投げます。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
// 1回目:temperature を付けて生成 $text = $this->build_prompt($prompt, $system, $history, $options, false)->generate_text(); // エラー本文に "temperature" を含み、かつ自分が温度を指定していた時だけ、一度だけ外して再送 if (is_wp_error($text) && stripos($text->get_error_message(), 'temperature') !== false && isset($options['temperature']) ) { $text = $this->build_prompt($prompt, $system, $history, $options, true)->generate_text(); } // ループも再帰もフラグも無い。だから「一度だけ」は構造が勝手に保証する。 |
この章の素朴さは、二か所にあります。ひとつは、エラーの判定が構造化されたコードではなく、人間可読のメッセージ本文への文字列一致だということです。stripos で temperature という語を引っかけているだけです。エラーコードのような安定した契約ではなく、メッセージの文言に賭けています。三つ目の isset ガードは効いていて、温度を指定してもいないのに temperature エラーが来ても、無駄な再送はしません。もうひとつは、「一度だけ」を見張る仕組みが要らないことです。ループも再帰もリトライフラグもありません。再送は if ブロック一個きりで、外す方は build_prompt() に $skip_temperature = true を渡して using_temperature() の呼び出しごと省きます。1.0 などに戻すのではなく、パラメータを完全に省いてモデルの既定に委ねる。直線のコードだから、束ねるものがないわけです。ただし、正直に書いておきます。この判定はエラー本文の文字列マッチなので、SDK 側が文言を変えたり多言語化したりしたら、黙って効かなくなります。今日は届いている信号が、ある日、誰にも告げず消える。既知で十分に安定したクセに賭けた、承知の上の割り切りです。
次にここを開く自分は、たぶん何も覚えていない
三つの章は、別々の話です。型の話、不変条件の話、文字列マッチの話。でも読み返すと、同じ構造が通底しています。どれも、壊れたこと自体は起きている。ただ、それを知らせる信号が、真実とずれている。
上の図が、その総括です。with_history() に生配列を渡せば、派手に落ちます。だが互換のための防御で包んだ瞬間、失敗は履歴を黙って捨てる側に回り、それを知らせるログは WP_DEBUG の裏で誰にも届きません。信号が、無い。復号失敗の通知は逆でした。警報はちゃんと鳴ります。ただ、いま使ってもいない古いキーを指して鳴る。信号は、間違った真実を指す。temperature の文字列マッチは、今日は効いていて、SDK が文言を変えた日に、誰にも告げず効かなくなる。信号が、いつか消える。どれも、壊れたことが正しく伝わらない。
この記事を書くために、当時どうやってこれに気づいたのかを探しました。コードを読み返し、コミットログを辿り、当時の AI との作業の記録や、自分で残した検証メモまで見ました。出てきませんでした。気づきの瞬間は、どこにも記録されていない。残っていたのは、防御コメントの書き方と、try/catch の入り方。コードに刻んだ痕跡だけでした。過去の自分の判断を未来の自分に伝えたのは、記憶ではありませんでした。コードでした。だとすれば、今日書く防御コードとコメントは、いつか全部忘れた自分が読む申し送りです。決意ではありません。事実として、そうなります。
だから、あなたにも同じことを置いておきます。安全網には、鳴る警報を付けておく。ログを WP_DEBUG の裏に隠さない。既定値に逃げるとき(履歴を捨てる、role を畳む、文字列で殴る)、逃げたことが聞こえるように印を残す。静かに壊れるものは、静かに直す機会も奪うからです。次にここを開くあなたは、たぶん何も覚えていません。覚えているのは、コードだけです。だとしたら、そのコードに、せめて音を残しておきませんか。
関連記事
- AI チャットボットを4社対応にした話|OpenAI・Claude・Gemini・OpenRouter の差を1枚の層で吸収した設計 ── 自前の層から公式ライブラリへ移す前段の設計。
- Brainfuck のベンチで AI が満点を取った、けれど「8 8」で正体が割れた話 ── LLM の挙動を実機で確かめた別の記録。






コメント