8分

失敗したあとに、できることが何もなかった

1.23.0は機能を増やしたリリースではありません。答えが違ったとき、作業が死んだとき、記録がまるごと消えたときに、利用者と運用者がそれぞれ何をできるかを作ったリリースです。

  • リリース
  • 1.23.0
  • 監査ログ
  • 復旧経路
会話リストと検索入力があるOpenMakeの履歴画面
会話はずっと保存されていました。ただ、探す方法も、やり直す方法もありませんでした。

SHIPPED / EVIDENCE

今回リリースしたもの

1.23.0には失敗したあとの経路が入りました。最後の回答をコピーして再生成し、失敗またはキャンセルしたAgent Taskを最初から再実行し、会話をタイトルではなく本文で探せます。運用側には、モデル役割の割り当てがまるごと消えた事故をきっかけに、変更前の値まで残す監査記録が入りました。

リリース
1.23.0
監査経路の欠陥修正
4件
管理者の会話閲覧
200件 → 全件
実測の累計トークン
7.1M

01

機能はあるのに、戻る方法がなかった

このリリースの項目は互いに無関係に見えます。メッセージのコピーボタン、作業の再試行、会話検索、監査ログ、コスト換算。しかし一つずつなぜ必要だったかをたどると、すべて同じ場所に行き着きます。何かがうまくいかなかったとき、利用者が画面からできることが何もなかったということです。

回答が気に入らなければ、質問を最初から打ち直す必要がありました。Agent Taskが失敗すると、カードに失敗と書かれたまま残りました。古い会話を探すにはタイトルを覚えている必要があり、ログインしていない利用者には履歴リストが最初から空に見えていました。どれも機能が足りなかったのではなく、戻る入口がなかったのです。

02

再生成は、添付があれば実行しない

完了したアシスタントメッセージにコピーボタンを、最後のアシスタントメッセージに再生成ボタンを付けました。コピーは画面に見えるテキストをそのまま渡すのではなく、アーティファクトのプレースホルダーを取り除いてから渡し、クリップボードAPIが使えない非セキュアコンテキストではボタン自体を出しません。

再生成はもう少し慎重でした。ソケットはComposerだけが持っているため、メッセージ側から直接再送はできません。ストアに再送要求を上げるとComposerが履歴を巻き戻して送り直し、ソケットがすでに閉じていて送信に失敗した場合は、巻き戻した会話を元に戻します。

最も重要な判断は、ボタンをどこに付けないかでした。添付があるメッセージは再生成の対象から外しました。元のファイルを保持していないため、再送するとファイル名だけがテキストとして上がり、モデルは添付を見ていない状態でもっともらしい答えを作ります。ボタンがあって誤答を返すより、ボタンがないほうがましだと判断しました。

03

再試行ボタンを付けたら、即座にキャンセルされた

失敗またはキャンセルしたAgent Taskを最初から実行し直すボタンに、新しい実行経路は必要ありませんでした。既存の実行APIに新規開始の経路がすでにあり、一覧カードからそれを呼び直すだけで済むはずでした。

ところが実測すると、再試行は開始した瞬間に中断しました。原因は開始ガードです。実行に入る時点でデータベースに残っている状態を読むのですが、直前の実行が残したキャンセル状態を、新しい実行に対するキャンセル要求と取り違えて即座に止めていました。実行に再突入する際に状態を先に待機へ戻すことで解消しました。

同時に直したものがいくつかあります。新規開始の再実行では以前のチェックポイントを消します。残すと再開できるように見えますが、実体は前の実行の残骸です。ボタンを押した直後に楽観的に待機状態を反映して二重クリックの窓を塞ぎ、ローカル実行機を使う作業は作成時と同様にデバイス接続を先に確認します。

レビュー指摘を反映するとルートファイルが618行になり、600行のCIガードを超えました。挙動を削る代わりに、所有作業の取得と公開形式への変換をヘルパーへ分離し、596行に収めました。

04

ゲストの会話は保存されていた

ログインしていない利用者の会話が履歴に残らない、という印象がありました。確認すると保存は最初からされていました。匿名セッション識別子でデータベースに正しく入っており、問題はサイドバーの最近の会話リストが、チャットが終わってもキャッシュを破棄しないことでした。再読み込みすれば現れます。

ログイン利用者はリストに以前の会話が複数あるため、一件が遅れて出ることに気づきにくいのですが、ゲストはリストが最初から空なので、保存自体がされていないと体感されていました。ストリームが終わる共通地点、つまり正常終了・中断・エラーがすべて通る場所でキャッシュを無効化して解決しました。

探す側の問題も残っていました。検索がタイトルだけを走査していたため、何を話したかは覚えていてもタイトルを覚えていない会話には到達できませんでした。いまはタイトルとメッセージ本文をあわせて検索し、一致箇所の抜粋を表示します。入力は300ミリ秒のデバウンスでサーバーへ送り、検索語が変わったら前の結果を即座に破棄して、遅れて届いた応答が画面を塗り替えないようにしました。検索語に含まれるパターンのメタ文字はエスケープし、所有者条件は値としてバインドします。

  • 検索しない場合の応答形式はそのままで、サイドバーと個人履歴には手を入れていません
  • 上位100件の外で一致したセッションも結果に統合して表示します
  • 管理者の全ユーザー表示では、本文検索の代わりにタイトルフィルタが働きます

05

上限を上げる代わりに、ページを分けた

管理者の全会話画面は、一度に200件を読み込む構造でした。それより古い会話はゲストのものを含めて閲覧できませんでした。上限の数字を増やす選択肢もありましたが、それは同じ問題を先送りするだけなので、サーバー側のオフセットページネーションに切り替え、全件をページ単位で確認できるようにしました。

応答には総件数を載せましたが、この値は全件表示のスコープにのみ付けます。サイドバーと個人履歴は同じAPIを使っているため、すべての応答にフィールドを足すと既存の利用側の契約が変わります。画面は50件ずつ区切り、前へ・次へのボタンで移動しながら、総件数と現在のページを表示します。

06

割り当てがまるごと消えたのに、履歴がなかった

8月8日に、モデル役割の割り当てがユーザー分も全体分も0行になる事故がありました。復旧自体は難しくありませんでしたが、なぜ消えたかは最後までわかりませんでした。ユーザー側の割り当て変更は監査記録をまったく残しておらず、管理者側は残していても変更前の値を書いていなかったためです。次に同じことが起きたとき、誰が何をどの値からどの値に変えたかを読めるようにすることが、今回の変更の目的でした。

最初の実装には欠陥があり、コードレビューが4件を指摘しました。最も悪かったのは、変更前の値を得るために入れた事前照会です。この照会が投げた例外がそのまま伝播し、データベースが一時的に不調なとき、本来なら成功していたはずの割り当てが500で失敗していました。記録を残そうとして、記録の対象を殺していたわけです。

変更前の値を別照会で得る代わりに書き込みの副産物にすることで、この経路自体をなくしました。更新はトランザクションを開いてキー単位のアドバイザリロックを取り、照会と挿入を一つの単位で処理します。削除は削除しながら消えた値をそのまま返します。同時に二つの要求が同じ役割を変更しても、監査に書かれる変更前の値が実際の直前値とずれることはありません。

当初はロックを共通テーブル式に入れる方式を試しましたが、同じ文の挿入と衝突して変更前の値が空で返ることを実測で確認し、破棄しました。監査の書き込みは応答を返す前にコミットされるよう待機し、監査が失敗しても割り当て自体は止めない一方で、黙って流さず変更前の値も含めてエラーとして残します。

07

止める仕組みは、どちらに倒れるべきか

このリリースには何かを止める仕組みが三つ入りましたが、失敗したときに倒れる方向はそれぞれ違います。

NAVER検索は、6月の告知で検索APIが新しいゲートウェイへ移管されました。新しいキーが設定されていれば新経路を、なければ従来の経路を使います。両方を同時に呼ぶことはなく、検索一回につき呼び出しも一回で、環境変数を消すだけで戻せます。日次の無料枠に達したら呼び出しを作る前に止めますが、カウンタの保存先が応答しないときは検索を止めず通します。枠を超えれば相手側でどのみち拒否されるので、こちらのカウンタ故障で検索ができなくなるほうが悪いと考えました。

監査記録は逆で、失敗しても割り当ては通し、失敗のほうを記録します。記録が目的なのに記録の失敗が機能を止めてはならず、かといって黙って消えるなら、そもそも監査ではありません。

HTTPSの強制が三つ目です。Nextが直接配信するHTML経路にはバックエンドのポリシーヘッダーが届かず欠けており、フレームワーク名を知らせるヘッダーもそのまま出ていました。値はバックエンドと揃えつつ、ブラウザの一覧に事前登録するオプションは入れていません。あのオプションは一度入れると戻すのが非常に難しいからです。

08

コストは請求書ではない

使用量画面に日・月・年単位のコスト換算を入れました。セルフホストなので実際の請求は発生しません。同じ使用量を商用APIで処理していたらいくらだったかを比べるための値です。

集計対象には会話だけでなくAgent Taskのトークンも入れました。会話だけを数えると大きく過小評価になります。実際には作業側のトークンが支配的です。基準単価は環境変数で変更でき、既定値はQwen3.8-Maxの公示価格である100万トークンあたり入力2ドル・出力6ドルを使います。当初は30B級モデルの単価を既定にしていましたが、実際に動かしているモデルの階級に合わせて差し替えました。実測では、あるアカウントの累計710万トークンが約4.26ドル(画面の既定通貨では約5,970ウォン)という結果でした。

数字そのものより気を配ったのは、誤読を防ぐ側です。トークン記録は2026年7月以降のものしか存在しません。会話が30日のローリングで削除されるうえ、トークンの永続化を始めた時点がそこだからです。そこで応答に最初の記録日を載せ、画面の脚注に集計範囲を明示しました。この値が全期間の使用履歴として読まれるなら、数字が不正確であることより悪い結果になります。

09

いまあるのは、失敗したあとの入口です。作り直し、実行し直し、探し直し、何が変わったかをあとから読む経路です。まだないのは、添付があるメッセージの再生成です。元のファイルを保持しない限り開けない扉であり、保持は保存ポリシーとあわせて決める問題です。

監査側も同じです。今回閉じたのはモデル役割の割り当て一箇所です。同じ問いを他の設定項目に投げれば、ほとんど同じ答えになるでしょう。どこから閉じるかは、実際に消えてから知るのではなく、設定ごとの影響範囲を先に整理して決めるつもりです。

根拠資料

根拠資料

Engineering Logへ戻る