WP AI Clientへ移行したら、エラーも出さずに会話の履歴が消えた

WP AI Clientへ移行したら、エラーも出さずに会話の履歴が消えた WordPress
この記事は約17分で読めます。

確認環境: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は、その層をプラットフォーム側に肩代わりさせる申し出でした。WP AI Clientの全体像とこのプラグインでの対応方針は、前の記事で読み解いておきました。乗せ替えたい。キーもモデルも、プラグインの外、Connectorsに預けてしまいたい。

ただ、緊張が一つありました。ユーザーの大半は、まだ7.0未満にいます。新しいプロバイダを足すことはできても、古いWordPressで一行たりとも壊してはいけません。新機能は、存在しないAI Clientの上で、静かに眠っていなければならない。

乗せ替えそのものは、申し出に乗るだけのはずでした。検出して、繋いで、古い環境では眠らせる。難しい話ではない、と思っていました。

時間を溶かすことになったのは、その予想のどれでもない、たった一点でした。

会話の履歴を with_history() に渡す、ただそれだけのところ

それは、会話の履歴をwith_history()に渡す、ただそれだけのところでした。ここで二段に転びました。

一段目は、派手に転んだ方です。このプラグインは普段、会話履歴をChatML風の生配列で持っています。['role' => 'user', 'content' => '…']という素直な形です。これをそのまま渡しました。

これは動きません。WP AI ClientのwithHistory()withHistory( Message ...$messages )という型付きの可変長引数で、Message型の列を期待しています。そこへ生の配列を1個渡すと、$messages[0]arrayを束ねようとしてTypeErrorになります(array given, Message expected)。

ここで一瞬、WordPress側のラッパーが受け止めてくれるかと期待しましたが、受け止めませんでした。WP_AI_Client_Prompt_Builder__callcatch (Exception $e)しか拾いません。TypeErrorError系でExceptionではないので、捕まらずそのまま上へ抜けて、派手に落ちます。これは、むしろ良い失敗でした。すぐ気づけるからです。

問題は二段目でした。こちらは静かに転びます。

派手に落ちた経験から、Message型を正しく組むコードを書きました。役割をuser/modelに畳み、テキストをMessagePartで包み、配列にしてUserMessage/ModelMessageに渡し、最後にwith_history()へスプレッドで展開します。

見やすさのためにuseで書いていますが、本番のコードではuseではなく文字列クラス名とclass_exists()MessagePartなどを参照しています(実際のガードはMessagePart / UserMessage / ModelMessageの3クラスすべての存在を確認しています)。WP 7.0未満のサイトでDTOクラスが存在しなくても、ファイル自体がfatalしないためです。このガードが、すぐ後で書く本当の罠に直結します。

役割の畳み方について、一つ正直に書いておきます。user以外を全部modelに倒しているのは、雑なのではなく、前提に寄りかかった割り切りです。WP AI Clientのrole enumはusermodelの二値しかなく、systemは別経路(using_system_instruction)へ行きます。しかもsystemメッセージは手前の処理で分離済みなので、apply_history()に届くのは実質userassistant/botだけです。だから二値強制で破綻しません。ただしこれは「systemがここに来ることはない」という前提の上に乗っています。もしその前提を破る入力が将来来たら、systemが黙ってmodelターンに化けます。今は安全ですが、安全の理由が前提の側にある、というのは覚えておくべきことでした。

そして、本当の罠はこの先にありました。

正しくマーシャリングするコードは書けました。Message型を組み、配列で包み、スプレッドで展開する。一段目のTypeErrorはもう出ません。それで安心してしまったのが、二段目の入口でした。

本番のコードは、いま見せたマーシャリングをそのままtry/catchで包んでいます。理由は7.0未満との互換です。古いWordPressにはWP AI ClientのDTOクラスが存在しないので、組み立ての途中で何かが投げても、サイト全体をfatalさせたくない。だからclass_exists()でDTOの有無を確かめ、組み立て全体をtry/catch(\Throwable)で囲い、何か起きたら履歴を丸ごと諦めて先へ進む。そういう防御を入れました。

設計としては筋が通っています。古い環境で落ちるより、文脈を一回諦めてでも応答を返す方がいい。そう判断しました。問題は、この防御が「諦めた」ことを誰にも告げないことでした。

apply_history()voidで、スキップ経路に入るとwith_history()が一度も呼ばれないまま戻ります。ビルダーに残るのは、システム指示と、wp_ai_client_prompt()に渡した最新の一ターンだけ。generate_text()は何事もなく200を返します。会話は成立しているように見えます。ただ、二手前を覚えていない。エラーログもない。例外もない。レスポンスは正常。欠けているのは文脈だけです。

防御パスに入ると with_history()は呼ばれず、最新の一ターンだけで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に差し替わります)。sanitizewpai_modelを通し、デフォルト値に空文字を置く。地味な、通し忘れると動かない類の対応です。

ただ、ここで一つ気づくことがあります。他のプロバイダはどれも_api_key_modelの両方を持つのに、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キーのせいで「復号に失敗しました」を食らう。警報は正しく鳴っています。ただ、鳴っている相手が間違っている。信号は出ているのに、それが指す真実がずれていました。

アクティブはOpenAIなのに、移行処理が触れた死んだClaudeキーがグローバル警報を立て、無条件に通知が出る。
アクティブはOpenAIなのに、移行処理が触れた死んだClaudeキーがグローバル警報を立て、無条件に通知が出る。

ここで腑に落ちたことがあります。この誤発火は、wpaiが持ち込んだバグではありません。複数プロバイダのキーが同時に保存される設計になった時点で、もう起きうるバグでした。アクティブは一つでも、どの古いキーでもグローバル警報を立てられたのですから。ただ、誰もその経路を踏まなかったから、誰も気づかなかった。キーを持たないwpaiを足して、「そのキーは誰のものか、それはアクティブか」を初めて真面目に問うたときに、ようやく露見したのです。

だから直し方も、wpaiの例外追加にはしませんでした。表示判定を、四段のゲートに一般化しました。

=== 'wpai'は、例外として付け足したものではなく、「アクティブなプロバイダ自身のキーが、本当に問題なのか?」という問いの、第一分岐にすぎません。並びそのものが、それを示しています。

この章の持ち帰りは、たぶんこれです。新しい抽象を足すと、新しい例外がN個要るだけではありません。「その不変条件は、実は最初から成り立っていなかった」ことを炙り出すことがあります。wpaiは、私が暗黙に信じていた二つの前提——「全プロバイダはキーを持つ」「復号失敗=唯一のキーが壊れている」——のうち、前者を破り、後者がとっくに偽だったことを教えました。

「全プロバイダはAPIキーを持つ」という前提は、最初から偽だったのです。

temperature の400を、一度だけ握ってリトライする

ここまでが重い話でした。最後は、もっと素朴な一手を記録しておきます。

Connectorsの先がGPT-5やo系のモデルだと、カスタムなtemperatureを受け付けず、400を返してきます。これらのモデルは固定の温度しか持たないので、こちらが指定した値が拒否されます。既知の挙動です。

対処は泥臭いものです。一度目の生成がエラーを返し、そのエラー本文にtemperatureという語が含まれていて、かつ自分が実際にカスタム温度を指定していたとき。この三つが揃ったときだけ、temperatureを外してもう一度だけ投げます。

この章の素朴さは、二か所にあります。

ひとつは、エラーの判定が構造化されたコードではなく、人間可読のメッセージ本文への文字列一致だということです。stripostemperatureという語を引っかけているだけです。エラーコードのような安定した契約ではなく、メッセージの文言に賭けています。三つ目のissetガードは効いていて、温度を指定してもいないのにtemperatureエラーが来ても、無駄な再送はしません。

もうひとつは、「一度だけ」を見張る仕組みが要らないことです。ループも再帰もリトライフラグもありません。再送はifブロック一個きりで、外す方はbuild_prompt()$skip_temperature = trueを渡してusing_temperature()の呼び出しごと省きます。1.0などに戻すのではなく、パラメータを完全に省いてモデルの既定に委ねる。直線のコードだから、束ねるものがないわけです。

ただし、正直に書いておきます。この判定はエラー本文の文字列マッチなので、SDK側が文言を変えたり多言語化したりしたら、黙って効かなくなります。今日は届いている信号が、ある日、誰にも告げず消える。既知で十分に安定したクセに賭けた、承知の上の割り切りです。

次にやるときの自分へ

三つの章は、別々の話です。型の話、不変条件の話、文字列マッチの話。だが読み返すと、同じ構造が通底しています。どれも、壊れたこと自体は起きている。ただ、それを知らせる信号が、真実とずれている。

with_history()に生配列を渡せば、派手に落ちます。だが互換のための防御で包んだ瞬間、失敗は履歴を黙って捨てる側に回り、それを知らせるログはWP_DEBUGの裏で誰にも届きません。信号が、無い。復号失敗の通知は逆でした。警報はちゃんと鳴ります。ただ、いま使ってもいない古いキーを指して鳴る。信号は、間違った真実を指す。temperatureの文字列マッチは、今日は効いていて、SDKが文言を変えた日に、誰にも告げず効かなくなる。信号が、いつか消える。

どれも、壊れたことが正しく伝わらない。

WP AI Clientへ移行したら、エラーも出さずに会話の履歴が消えた
信号が、無い・ずれる・消える。三つの失敗は、いずれも「壊れたことが正しく伝わらない」一点で繋がっている。

この記事を書くために、当時どうやってこれに気づいたのかを探しました。コードを読み返し、コミットログを辿り、当時のAIとの作業の記録や、自分で残した検証メモまで見ました。出てきませんでした。気づきの瞬間は、どこにも記録されていない。残っていたのは、防御コメントの書き方と、try/catchの入り方。コードに刻んだ痕跡だけでした。

過去の自分の判断を未来の自分に伝えたのは、記憶ではありませんでした。コードでした。

だとすれば、今日書く防御コードとコメントは、いつか全部忘れた自分が読む申し送りです。決意ではありません。事実として、そうなります。だから——

安全網には、鳴る警報を付けておく。ログをWP_DEBUGの裏に隠さない。既定値に逃げるとき(履歴を捨てる、roleを畳む、文字列で殴る)、逃げたことが聞こえるように印を残す。静かに壊れるものは、静かに直す機会も奪うからです。

次にここを開く自分は、たぶん何も覚えていません。覚えているのは、コードだけです。

コメント

タイトルとURLをコピーしました