LLMが返したJSONは、保存できても安全とは限らなかった話

当ページのリンクには広告が含まれています。
この記事の結論
LLMが返したJSONは、データベースへ保存できた時点では安全と言えないと感じた話です。新しいデータを保存する前の検証に加えて、過去のデータを読み出すときにも型を確かめ、壊れたデータを修復できる入口まで用意する必要がありました。

お疲れ様です!IT業界で働くアライグマです!

個人開発している開発関連ニュースのキュレーションサービス「DevPick」で、LLMが作った分析結果を原因にフィード全体が表示できなくなる不具合を直しました。DevPickは、開発関連のニュースをAIが分析し、その人の興味に合った記事を届けるWebアプリです。今回は、JSONとして保存できていたデータの内側で何が壊れていたのか、なぜ保存前の検証だけでは足りなかったのかを書きます。

目次

JSONとして保存できたので、安全だと思っていた

DevPickでは、LLMが記事ごとに要約文、要点の一覧、日本語タイトル、カテゴリ、難易度などを返します。その一部をJSONに変換し、データベースへ保存していました。JSONは、複数の値を名前付きでひとまとめにできるデータ形式です。たとえば要約なら、本文を表す text と、箇条書きを表す points を1つのまとまりとして持てます。

不具合の原因は、JSONの書式と、その中身のを同じものとして扱っていたことでした。正しい書式のJSONでも、text に文字列ではなく配列が入ることはあります。points も、本来は文字列の配列であるはずなのに、単なる文字列やオブジェクトになりえます。カッコや引用符の組み合わせが正しければJSONとしては保存できますが、アプリが期待する形とは限りません。

DevPickが期待していた要約は、次のような形です。text は文字列、points は文字列を並べた配列になっています。

{
  "text": "LLMが生成した記事の要約です",
  "points": [
    "保存前に型を検証する",
    "読み出し時にも型を検証する"
  ]
}

一方、次のデータもJSONの書式としては正しいため、構文チェックを通って保存できます。しかし、text はオブジェクト、points は単なる文字列です。画面が points を配列だと思って処理すると、ここで前提が崩れます。

{
  "text": {
    "ja": "LLMが生成した記事の要約です"
  },
  "points": "保存前に型を検証する"
}

この2つはどちらもJSONとして読み取れます。違いは、アプリが必要としている型の約束を満たしているかどうかです。

当時のDevPickは、LLMが返した summarysummary_pointstitle_ja の型を確かめないまま保存していました。画面側もJSONを読み取れた時点で正しいデータだとみなし、要点は配列として扱っていました。そのため、想定外の型を持つ記事が1件混ざるだけでニュースカードの描画中に例外が起き、正常な記事を含むフィード全体が表示できなくなっていました。

外から見れば「JSONは壊れていない」「データベースにも入っている」状態です。それでも、利用するコードとの約束が崩れていればアプリは止まります。保存に成功したことは、あくまで入れ物に収まったというだけで、中身を安全に使える証明にはならないと気づきました。

IT女子 アラ美
箱の形が合っていても、中身まで正しいとは限らないってことね。

ITアライグマ
はい。JSONとして読めることと、期待した型であることは別でした。

まず保存前に、LLMの出力を正規化した

最初に入れた対策は、LLMから応答を受け取った直後に値を正規化する処理です。正規化とは、受け取ったデータをアプリが扱える決まった形へそろえることです。要約本文は文字列だけを採用し、文字列でなければ空文字にします。要点は配列であることを確認したうえで、文字列の要素だけを残します。日本語タイトルも、文字列のときだけ保存するようにしました。

要点を正規化する考え方だけを単純化すると、次のような処理です。配列でなければ空の配列へ変え、配列でも文字列ではない要素を取り除きます。実際の実装では、これに空文字の除外、文字数、件数の上限も加えています。

def normalize_summary_points(value: object) -> list[str]:
    if not isinstance(value, list):
        return []

    return [
        item
        for item in value
        if isinstance(item, str) and item.strip()
    ]

ここで大切だったのは、データベースへ書き込む直前だけで検査するのではなく、LLMの応答を受け取った直後にそろえたことです。DevPickでは、保存するまでに要約がタイトルのコピーになっていないかを判定し、問題があれば該当記事だけを再生成する処理があります。正規化が遅いと、この途中処理も想定外の型を受け取って止まる可能性があります。

そこで通常の応答と再生成した応答の両方を同じ入口へ通し、それ以降は「要約本文は文字列、要点は文字列の配列」という同じ前提で動かせるようにしました。長すぎる文字列や要点の件数にも上限を設けています。LLMはアプリの外部にある非決定的な入力元なので、「いつもこの形を返してくれるはず」ではなく、使う側で形を確定させる考え方へ変えました。

さらに画面側でも、要点が本当に配列か、各要素が文字列かを確認します。Webhookやブラウザ拡張など、同じ要約を読む別の入口も、本文が文字列のときだけ採用する形にそろえました。保存側を直したから表示側は無防備でよい、とは考えませんでした。1件の異常値を空の表示へ縮退させ、フィード全体は動かし続けるためです。

IT女子 アラ美
受け取った瞬間に形をそろえれば、後ろの処理も迷わずに済むのね。

ITアライグマ
はい。入口で約束を決めると、途中の処理まで同じ型で守れます。

それでも、過去の不正データには効かなかった

保存前の正規化を入れたことで、これからLLMが作る分析結果は守れるようになりました。しかし、その後に別の問題が見つかりました。データベースには、対策を入れる前に保存された分析結果が残っています。手動で投入したデータや、過去の移行処理を経たデータもあり、そこには新しい保存処理が一度も通りません。

つまり、保存時の検証は未来の書き込みにしか効きません。すでに保存されている行は、自動的に正しい形へ変わるわけではないのです。しかも今回の分析データは、JSONの構文自体は正しくても、最上位が配列や文字列だったり、カテゴリが本来のオブジェクトではなく配列だったりしました。JSONの読み取りで起きる構文エラーだけを捕まえても、この違いは検出できません。

読み出す側は、保存済みのデータなら型も正しいという前提でスコア計算へ渡していました。その結果、不正な分析結果を持つ記事が1件候補に入るだけで、ログイン後のフィードやダイジェストの処理が例外で止まりました。最初の対策と同じく、壊れた1件だけを表示しないのではなく、正常な記事まで巻き込んでしまう状態です。

このとき、保存前の正規化そのものが間違っていたわけではありませんでした。守る範囲が片側だけだったのです。新しいデータの入口を閉じても、データベースという別の入口から古い値が入ってきます。データベースは自分たちで管理しているので安全な内部に見えますが、長く運用すれば過去の仕様や手作業を抱えた外部入力のような存在になると考え直しました。

IT女子 アラ美
入口を直しても、倉庫に残った古い荷物までは勝手に直らないのね。

ITアライグマ
そのとおりです。保存済みだから安全、という前提が二度目の穴でした。

読み出し時の境界と、既存データの修復を追加した

二度目の対策では、保存済みの分析結果を読み出すための共通の入口を作りました。スコア計算、いいねした記事の傾向、興味なしにした記事の傾向など、分析結果を使う処理はすべてこの入口を通します。それぞれの処理が個別にJSONを読み取るのをやめ、「保存済みデータをアプリで使える形にする責任」を1か所へ集めました。

その入口では、まずJSON全体の構造を確認します。最上位がオブジェクトでない、カテゴリがオブジェクトでない、といった復旧できない形なら、例外を投げず「分析データ不正」として扱います。その記事のスコアは中立値へ縮退させ、正常な記事の処理は続けます。一方、難易度だけが不正、カテゴリの点数だけが不正という場合は、その値を既定値へ置き換えたり、使えない値だけを落としたりして、残りのデータを生かします。

正規化で何でも現在の仕様へ合わせればよいわけではない点も、今回悩んだところです。たとえば、現在の定義にないカテゴリ名は読み出し時に削除しないようにしました。読むたびに最新のカテゴリ一覧で絞ると、カテゴリ定義を変えた瞬間に、過去の記事のスコアが気づかないまま変わるからです。値の範囲調整や NaN の除外も保存側と読み出し側で共通化し、同じ値を片方では許し、もう片方では捨てる食い違いを避けました。

ただし、読み出し時に安全に縮退できても、不正なデータがデータベースに残り続ける問題は解決しません。そこで check-analysis という管理用コマンドも追加しました。通常実行では対象の記事IDを一覧にし、--repair を付けたときだけ不正な分析結果を消して再分析待ちへ戻します。分析結果から作られたスコアのキャッシュやカテゴリの行も一緒に消し、古い計算結果だけが残らないようにしています。

読み出し側の防御はサービスを止めないためのもの、修復コマンドは保存済みの原因を取り除くためのものです。この2つを分けたことで、壊れた1件を抱えていてもフィードを動かしながら、対象を確認して安全に直せる状態になりました。

IT女子 アラ美
表示を守るだけでなく、壊れた元データを直す道具も必要なのね。

ITアライグマ
はい。縮退と修復を分けることで、止めずに原因を片付けられます。

今は「保存時」と「利用時」の二重チェックで考えている

今回の修正を通じて、私はバリデーションを「保存する前に一度通す処理」ではなく、データが境界を越えるたびに必要なものとして考えるようになりました。LLMからアプリへ入るときには、これから扱う値の型を確定させます。データベースから利用処理へ戻すときには、過去の仕様や手作業を含む値が、今のコードで安全に使えるかを改めて確かめます。

保存時の検証には、不正なデータを増やさない役割があります。早い段階で型をそろえるので、その後の判定や保存処理を単純にできます。一方、読み出し時の検証には、すでに存在するデータでサービス全体を止めない役割があります。どちらか一方で代用するのではなく、扱う時間軸が違う対策です。

そして、検証に失敗したときの動きも同じくらい重要でした。すべてを例外にして処理を止めると、1件の異常が正常な記事まで巻き込みます。反対に、何も記録せず空の値へ変えるだけでは、壊れたデータの存在に気づけません。画面やスコア計算は安全な値へ縮退させつつ、警告ログと検査コマンドで追跡できるようにする。この組み合わせが、今回のDevPickには合っていました。

これはLLMに限った話でもないと思っています。外部APIの応答、過去に保存した設定、手動で取り込んだCSVなど、現在のコードが作っていないデータには同じ問題が起こりえます。「自分たちのデータベースにあるから信頼する」のではなく、利用する場所へ入ってくる時点で契約を確認する。今はその考え方で、保存する側と読む側の両方に境界を置いています。

IT女子 アラ美
未来のデータと過去のデータでは、守る場所も役割も全然違うのね。

ITアライグマ
はい。二重チェックは重複ではなく、異なる時間軸への備えです。

まとめ

LLMが返した値は、JSONとして正しく保存できても、アプリが期待する型とは限りません。DevPickでは要約やカテゴリの内側に想定外の型が混ざり、1件の不正データでフィード全体が止まる状態になっていました。

最初に保存前の正規化を入れましたが、それだけでは対策前の行や手動投入したデータを守れませんでした。そこで、保存済みデータを読む共通の境界を設け、復旧できる値は正規化し、できない値は安全に縮退させました。さらに検査・修復コマンドを用意し、原因となるデータと派生した計算結果を片付けられるようにしています。

今の私の結論は、保存できたことを安全性の証明にしないことです。新しい不正データを増やさない保存時の検証と、過去の不正データで止まらない読み出し時の検証は、役割が異なります。外部から来たデータも、長く保存してきた内部データも、使う境界で型を確かめるところまでを1つの設計として考えています。

IT女子 アラ美
保存成功をゴールにせず、実際に使う瞬間まで見届けるってことね。

ITアライグマ
はい。データの入口ごとに確認することが、今回いちばんの学びです。

作者が開発したサービス「DevPick」

この記事をシェアする
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

ITアライグマのアバター ITアライグマ ITエンジニア / PM

都内で働くPM兼Webエンジニア(既婚・子持ち)です。
AIで作業時間を削って実務をラクにしつつ、市場価値を高めて「高年収・自由な働き方」を手に入れるキャリア戦略を発信しています。

目次