0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LLM APIから417万字の応答が返ってきた — MD5指紋分析で「生成ではなく複製」を特定するまで

0
Posted at

この記事は Zenn に公開した記事の再掲です(原文・最新版: https://zenn.dev/acs_developer/articles/llm-response-duplication-md5-forensics )。

自作のWordPress向けAI記事生成プラグインで、本来3,000字前後になるはずの下書きが4,169,336字で保存される障害が発生した。読了時間の推定値が8,712分と表示されて初めて気づく、という気づき方だった。

「LLMが暴走して同じ内容を延々と生成した」で片づけたくなるが、それだと課金トークン数と辻褄が合わない。この記事は、その矛盾を出発点に、セクション分割とMD5指紋分析で「生成された文字」と「複製された文字」を分離し、原因がクライアント側にないことを物証で確定させるまでの手順である。

同じ手を使えば、LLM API絡みの「なんか出力がおかしい」を、印象論ではなく数字で切り分けられる。

最初に立てた仮説は2つとも外れた

障害を見た時点で立てた仮説は次の2つだった。どちらもクライアント側(自分のコード)の疑いである。

  1. 継続ループの終了判定バグfinishReason の扱いを誤り、生成継続リクエストを止められず結果を累積した
  2. 本文結合処理の重複追記 — チャンクを結合する箇所で、毎回それまでの全文を追記していた

もっともらしい。実際こちらもここから調査を始めた。しかし、APIコンソール側の実測値を見た時点で両方が怪しくなる。

暴走した1回分のリクエストに対する実測は次のとおりだった。

リクエスト回数 : 7
入力トークン   : 13.86k
出力トークン   : 19.55k

出力19.55kトークンは、日本語ならせいぜい数万字である。417万字にはまったく足りない

仮説1(継続ループ)が正しければリクエスト回数はもっと膨らむし、仮説2(結合の重複追記)が正しければ出力トークンは正常なまま本文だけ膨らむ——後者はまだ生きている。そこで次は保存の実体を見に行った。

切り分け1: そもそも何回保存されたのか

WordPressの投稿は、REST APIで読み取り専用に叩けば datemodified が取れる。追記ループが回っていたなら、投稿は複数回更新されているはずだ。

curl -s "https://example.com/wp-json/wp/v2/posts/<ID>?context=edit" \
  | jq '{date, modified, len: (.content.raw | length)}'
{
  "date": "2026-08-22T21:42:43",
  "modified": "2026-08-22T21:42:43",
  "len": 4169336
}

datemodified が秒まで完全一致した。つまり1回の wp_insert_post() で417万字が書き込まれている。追記ループも継続取得ループも存在しない。仮説2はここで死ぬ。

さらにプラグイン側のコードを全数確認したが、レスポンス本文を自身に連結する処理は1箇所も無かった。クライアントは、受け取ったものをそのまま保存しただけという筋が濃くなった。

ただ、これは「無実の主張」にすぎない。無実を証明するには、417万字そのものの中身を見る必要がある。

切り分け2: セクションに割ってMD5を取る

ここが本題である。長大な生成テキストを目視で読むのは不可能だが、構造単位に割ってハッシュを取れば、反復のパターンは一目で出る

記事本文はh2見出しで区切られていたので、h2単位で分割してMD5指紋を取り、出現回数を数えた。

import hashlib
import re
from collections import Counter

with open("draft.html", encoding="utf-8") as f:
    body = f.read()

# h2見出しを境界にして分割(先頭の見出し前も1セクションとして残す)
sections = re.split(r"(?=<h2)", body)
sections = [s for s in sections if s.strip()]

fingerprints = [hashlib.md5(s.encode("utf-8")).hexdigest() for s in sections]
counter = Counter(fingerprints)

print(f"総セクション数     : {len(sections)}")
print(f"ユニークセクション : {len(counter)}")
for rank, (digest, n) in enumerate(counter.most_common(3), 1):
    idx = fingerprints.index(digest)
    print(f"{rank}{n:>4}回  1セクション {len(sections[idx]):>5}")

出力はこうなった(実際の指紋値は環境依存なので順位に置き換えている)。

総セクション数     : 2065
ユニークセクション : 28
  第1位   432回  1セクション  約1700字
  第2位   431回  1セクション  約1700字
  第3位   357回  1セクション  約1700字

2,065セクションのうち、ユニークなのは28個だけ。最頻セクションは432回出現し、しかもMD5が一致している以上、それは1バイトも違わない完全同一の文字列である。

並び順も調べると「セクション0 → 新規1個 → 固定サフィックス」という非周期の反復が約350回続く形だった。周期的でないので、単純なループのコピペとも違う。

なぜこれが「生成ではない」と断言できるのか

ここが決め手になる。生成時のパラメータは temperature = 0.7 だった。

温度0.7のサンプリングで、1,700字(数百トークン)のテキストがバイト単位で完全一致したまま432回再現される確率は、実質ゼロである。トークンごとに確率分布からサンプリングしている以上、同じ話題を繰り返せば表現は必ず揺れる。実際、揺れた反復(変奏)は別のハッシュとして28個の中に現れていた。

つまり、この417万字は次のように分解できる。

区分 実体
実際に生成された ユニーク28セクション ≒ 19.55kトークン
生成されていない 残り2,037セクション = 28個のバイト単位コピー

そして「実際に生成された28セクション分」が、APIコンソールの出力トークン実測19.55kときれいに一致する。冒頭の矛盾はここで解消する。課金・上限管理は生成分だけを見ており、複製はその後段で起きていた。

結論として、モデルは通常量を生成し、その応答を組み立てて返す層が同じセクションを約2,000回複製し、約4.2MBのボディとして返したmaxOutputTokens = 32768 の上限強制自体は、別途 cap を50/200に落とした再現テストで MAX_TOKENS 停止が正しく効くことを確認済みで、生成上限は遵守されていた。壊れていたのは生成ではなく応答の組み立てである。

この事象が恒常的なものかは断定しない(筆者環境で観測された1件である)。重要なのは、クライアント側が原因ではないことを、コードの主張ではなく物証で示せたことだ。

対策はリトライではなく「入口で捨てる」

原因が自分側にない場合、コードを直して解決することはできない。できるのは、壊れた応答を取り込まないことだけである。そこで3段の安全弁を、いずれもパース前・保存前に置いた。

// ① 生応答のサイズで足切り(JSONパース前)
if ( strlen( $raw_body ) > 1500000 ) {
    return new WP_Error( 'acs_oversized_response',
        '応答サイズが異常です(' . number_format( strlen( $raw_body ) ) . ' bytes)。生成を中止しました。' );
}

// ② 本文長で足切り(wp_insert_post 前)
if ( mb_strlen( $content ) > 100000 ) {
    return new WP_Error( 'acs_oversized_content',
        '本文が異常に長いため保存を中止しました(' . number_format( mb_strlen( $content ) ) . '字)。' );
}

// ③ 同一見出しの反復検知
preg_match_all( '/<h2[^>]*>(.*?)<\/h2>/s', $content, $m );
$dupes = array_filter( array_count_values( array_map( 'trim', $m[1] ) ),
    static fn( $n ) => $n >= 3 );
if ( $dupes ) {
    return new WP_Error( 'acs_repeated_headings',
        '同一見出しの反復を検出したため保存を中止しました。' );
}

設計上のポイントは3つある。

  • ①はJSONパースより前に置く。4.2MBのボディを json_decode() に渡してからでは、メモリと時間を無駄に払う。バイト長は読む前に分かる唯一の指標なので、最初の関門にする
  • 黙って握りつぶさない。3つとも明示的なエラーとして表示する。静かに切り詰めると「なぜか記事が短い」という別の障害に化ける
  • リトライ対象を分ける。まとめて生成の中で起きた場合のみ自動再試行1回とし、無条件リトライはしない。同じ壊れた応答を再取得するだけになる可能性があるため

検証は、実障害で得られた417万字のデータそのものを入力に使った。安全弁のテストは7項目すべて合格し、既存機能の回帰4本も通過。実APIでの通常生成は43.1秒 / 10,283字、長文条件で49.5秒 / 6,948字といずれも正常に完了した。あわせてタイムアウトも90秒→180秒へ引き上げている(長文条件の実測が176.5秒まで伸びたため)。

持ち帰れる手順

LLM APIを組み込んだプロダクトで出力異常に当たったとき、次の順番で見ると早い。

  1. メタ情報とコンソール実測を突き合わせる — リクエスト回数・入出力トークンが、目の前の成果物と桁で合っているか。合わないなら、そこが最短の手がかりになる
  2. 保存回数を確定させる — タイムスタンプ(date / modified 等)が一致すれば、追記ループ系の仮説はまとめて消せる
  3. 構造単位に割ってハッシュを取る — 見出し・段落・チャンクなど何でもよい。ユニーク数と最頻出現回数を数えるだけで、反復が「生成の揺れ」か「バイト複製」かが分かれる
  4. temperatureと突き合わせる — 温度が0でないのにバイト完全一致が多数あるなら、それは生成ではない
  5. 原因が外側なら、直すのではなく入口で捨てる — サイズ・長さ・構造の3層で、パース前・保存前に落とす

特に3が効く。「同じことを何度も書いている気がする」という主観を、ユニーク28 / 総数2,065 / 最頻432回という反証しづらい数字に変えられる。仮説を2つ捨てるのに必要だったのは、20行のスクリプトだった。


検証の記録や関連ツールはACS Developerで公開しています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?