エラーも警告も出ていないのに、AI が二手前の会話を忘れている。そんな壊れ方に出くわしたことはありますか。私はありました。レスポンスは 200。例外もログも無し。会話は成立しているように見える。ただ、二手前を覚えていない。欠けているのは文脈だけ。WordPress 7.0 の WP AI Client へチャットボットを移したとき、最初に踏んだのは、この「音のしない故障」でした。
先に、3つの失敗を貫く答えを置きます。どれも、壊れたこと自体は起きている。ただ、それを知らせる信号が、真実とずれている。信号が無い、信号が間違った先を指す、信号がいつか消える。そして犯人は、たいてい良かれと思って入れた防御コードでした。古い環境を守るための try/catch と、本番では off になる WP_DEBUG。この二つが、失敗を握りつぶし、警報まで黙らせていました。
確認環境:WordPress 7.0 RC3 で実装し、5月21日の安定版でも同じ挙動を再確認。PHP 8.3.30 / Rapls AI Chatbot 1.8.0。with_history() の型、role enum、復号通知の経路は RC3 と安定版で一致していました。RC 段階の実装なので、WP AI Client の仕様が今後動きうる前提で書いています。なお、このチャットボットを4社対応の自前層から、キーもモデルも Connectors に預ける形へ乗せ替えた話です。
ただ履歴を渡すだけだと思っていた
転んだのは、会話履歴を with_history() に渡す、ただそれだけのところでした。しかも二段に転びました。一段目は派手な方です。このプラグインは履歴を ChatML 風の生配列で持っていて、それをそのまま渡しました。
| 1 2 3 4 5 6 | $history = [ ['role' => 'user', 'content' => '東京の天気は?'], ['role' => 'assistant', 'content' => '晴れです。'], ]; wp_ai_client_prompt('では大阪は?')->with_history($history)->generate_text(); |
これは動きません。withHistory() は withHistory( Message …$messages ) という型付き可変長引数で、Message 型の列を期待します。生配列を1個渡すと TypeError になる。期待した WordPress 側のラッパーも受け止めませんでした。ビルダーの __call は catch (Exception) しか拾わず、TypeError は Error 系で Exception ではないので、捕まらず上へ抜けて派手に落ちます。これはむしろ良い失敗でした。すぐ気づけるからです。
問題は二段目です。派手に落ちた経験から、Message 型を正しく組むコードを書きました。role を user / model の二値に畳み、テキストを MessagePart で包み、UserMessage / ModelMessage にして、with_history() へスプレッドで展開する。これで TypeError は出ません。安心したのが、二段目の入口でした。本番では、このマーシャリング全体を、7.0 未満との互換のために try/catch で囲っています。古い WordPress には DTO クラスが無いので、組み立て途中で何か投げてもサイト全体を fatal させたくない。だから class_exists() で有無を確かめ、全体を try/catch(\Throwable) で囲い、何か起きたら履歴を丸ごと諦めて先へ進む。
| 1 2 3 4 5 6 7 8 9 | if ( ! class_exists( $message_part_class ) ) { return; // …WP_DEBUG時のみログ… 履歴を組まずに戻る } try { // …MessagePart / UserMessage / ModelMessage を組み立てて with_history()… } catch ( \Throwable $e ) { return; // …WP_DEBUG時のみログ… 履歴を組まずに戻る } |
設計は筋が通っています。古い環境で落ちるより、文脈を一回諦めてでも応答を返す方がいい。問題は、この防御が「諦めた」ことを誰にも告げないことでした。スキップ経路に入ると with_history() が一度も呼ばれず、ビルダーに残るのはシステム指示と最新の一ターンだけ。generate_text() は何事もなく 200 を返します。会話は成立して見える。ただ二手前を覚えていない。エラーログも例外もありません。

沈黙の決定打はここです。現実に履歴を落とすのは class_exists() 側ではなく try/catch(\Throwable) 側で、SDK のシグネチャがほんの少しズレる(引数形、with_history の型、role enum)と、正しく書いたはずのマーシャリングが投げ、ここで握られて履歴が静かに消えます。しかも、どちらのスキップ経路もログは WP_DEBUG の内側に書いてある。本番の WP_DEBUG は off です。安全網が失敗を飲み込み、その失敗を知らせるはずの警報まで、フラグの裏で切れている。落ちてくれる方が、ずっと親切でした。ユニットテストでは出ず、手で何ターンか会話して初めて露見する種類の壊れ方です。
全プロバイダがキーを持つと、信じて疑わなかった
次に待っていたのは、型ではなく前提の話でした。プラグイン全体が、明文化しないまま握っていた前提を、WP AI Client が踏み抜いたのです。その前提は「どのプロバイダも、必ずプラグイン側に API キーを持っている」。wpai はキーを Connectors に丸投げするので、プラグインの中に wpai のキーは存在しません。
いちばん浅い穴は設定スキーマでした。他のプロバイダは _api_key と _model の両方を持つのに、wpai は _model しか持ちません。
| 1 2 3 | openai : openai_api_key + openai_model wpai : (キー欄なし) + wpai_model |
wpai_api_key という設定はどこにも存在しない。キーを持たない事実が、スキーマに穴として可視化されています。真ん中の段は REST 事前チェックでした。呼ぶ前にキーを復号し、空なら 400 で弾く作りは、キーが無いのが正常な wpai に当てはまりません。名前で除外し、可用性の判定を「呼ぶ前にキーで」から「呼ぶ時に Connector の能力で」へ移しました。
いちばん深い段が本題です。「API キーの復号に失敗しました」という通知が、wpai ユーザーに誤発火していました。最初は対応漏れだと思いましたが、違いました。wpai は、もっと前から壊れていたものを炙り出しただけでした。設定ページを開くたびに走る移行処理が全プロバイダのキーを総なめし、失敗時にグローバルな transient を立てる。通知はその transient と権限だけを見て、どのプロバイダが失敗したのか、それが今アクティブなのかを一切見ずに表示していた。OpenAI に乗り換えた環境に、塩を替えて復号できなくなった古い Claude キーが残っている。設定ページを開くと、移行処理が死んだキーに触れて警報が立つ。OpenAI で平和に動いているのに、捨てたはずの Claude キーで「復号に失敗しました」を食らう。警報は鳴っている。ただ、鳴っている相手が間違っている。

この誤発火は wpai が持ち込んだバグではありません。複数プロバイダのキーが同時に保存される設計になった時点で、もう起きうるバグでした。誰もその経路を踏まなかったから、気づかなかっただけです。だから直し方も、wpai の例外追加にはせず、表示判定を四段のゲートに一般化しました。
| 1 2 3 4 5 | if (active === 'wpai') return; // ① キーレス=プラグイン側キーは無関係 if (active の *_api_key が空) return; // ② そもそも未設定 if (active 自身のキーが正常に復号できる) return; // ③ 失敗は別の未使用プロバイダ由来 show_notice(); // ④ ここだけ=アクティブ自身が本当に壊れている |
=== ‘wpai’ は例外の付け足しではなく、「アクティブなプロバイダ自身のキーが、本当に問題なのか」という問いの第一分岐にすぎません。新しい抽象を足すと、新しい例外が N 個要るだけではない。その不変条件が、実は最初から成り立っていなかったことを炙り出すことがあります。「全プロバイダは API キーを持つ」という前提は、最初から偽だったのです。
temperature の 400 は、一度だけ握ればいいと割り切った
最後は素朴な一手です。Connectors の先が GPT-5 や o 系だと、カスタムな temperature を受け付けず 400 を返します。固定の温度しか持たないからです。対処は泥臭い。一度目の生成がエラーを返し、そのエラー本文に temperature という語が含まれ、かつ自分が実際にカスタム温度を指定していた。この三つが揃ったときだけ、temperature を外して、もう一度だけ投げます。
| 1 2 3 4 5 6 7 8 9 10 | $text = $this->build_prompt($prompt, $system, $history, $options, false)->generate_text(); 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 一個きり。外す方は using_temperature() の呼び出しごと省いて、モデルの既定に委ねます。直線のコードだから束ねるものがない。ただし正直に書くと、この判定は 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 の挙動を実機で確かめた別の記録です。



コメント