手元の GPU で動かす LLM に、集めた Web のページを読ませる常駐を技術検証として書いていたとき、1回の実行(以下、回)の途中で、LLM への依頼が HTTP 422 で「NUL は使えない」と断られ、回が止まりました。NUL の元をたどると、ページを取る所に行き着きました。頼んでいないのに gzip で返ってきた本文を、解かずに文字として読んでいたのです。
結論
- deepmind.google の2ページが、頼んでいないのに gzip で本文を返しました。それを解かずに UTF-8 として読んだので、3000 字の本文が文字化けし、NUL が 133〜276 個混じりました。
- LLM の受付は、文字列の引数に NUL があると 422
items_unsafe_textで断ります。常駐は受付が 4xx を返すと回全体を止める作りだったので、その回は何も出せませんでした。 - 直し方は2段です。
Content-Encodingを見て gzip・deflate を解く(元を断つ)ことと、受付に送る前に制御文字を落とす(守り)ことです。後者だけでも 422 は止まりますが、LLM に文字化けを渡し続けることになります。 - あわせて、依頼の中身を理由に断られた候補だけを飛ばす止まり方に変えました。
試した環境
- LLM を動かしたマシンは、OS が Ubuntu、GPU が Radeon RX 7900 XT です。
- その上で、受付(ジョブを並べて GPU で回す仕組み)が、HTTP API で LLM への依頼を受けていました。
- 常駐は Python 3.12 の標準ライブラリだけで書きました。候補の記事ごとにページを
urllib.requestで取って本文を抜き出し、その本文を LLM に読ませていました。
ページを取るときは User-Agent だけを付け、応答の Content-Encoding は見ずに、読んだバイト列をそのまま decode("utf-8", "replace") していました。
何が起きたか
その回、LLM に本文を渡す段に入ったところで受付が 422 を返し、回が止まりました。次の図は、そのときのやり取りです。
受付の側では、文字列の引数を次のように検査していました。
elif p.type == "text":
if not isinstance(v, str):
raise KindError("schema_invalid", f"{where}{p.name} は文字列")
if p.max_length and len(v) > p.max_length:
raise KindError("param_out_of_range", f"{where}{p.name} が長すぎる({p.max_length} 字まで)")
if "\x00" in v:
raise KindError("items_unsafe_text", f"{where}{p.name} に NUL は使えない")
返ってきた断りの要所は、次のとおりです。
HTTP 422
{"code":"items_unsafe_text","status":422,"detail":"user に NUL は使えない"}
user はプロンプトの本文を渡す引数で、そこに NUL が入っていました。
最初は「制御文字を落とす」だけを考えた
最初に考えた直し方は、受付に渡す前に制御文字を落とすことだけでした。これで 422 は止まるので、まずその版を配りました。
その後で、NUL がどこから来たのかを調べました。落ちた記事を LLM を動かしていたマシンから取り直すと、deepmind.google の2ページが、頼んでいないのに gzip で本文を返していました。常駐は、その圧縮されたバイト列を解かずに UTF-8 の文字として読んでいたのです。"replace" を付けて読んでいたので、読めないバイトがあっても例外にはなりません。文字化けした文字列が、本文としてそのまま先へ進んでいました。
ここで迷いやすいのは、422 を止めるだけなら制御文字を落とせば済んでしまうことです。しかしそれだけでは LLM に文字化けを渡し続けることになるので、元を断つ直しを別に入れました。

直し方1: Content-Encoding を見て解く
次の図は、直した後のページの取得で、応答の Content-Encoding によって扱いを分ける所です。
import zlib
MAX_BYTES = 2_000_000
def decode_body(body: bytes, encoding: str) -> bytes | None:
"""gzip と deflate(zlib の頭)は解く。読めた所までを使い、解いた後の大きさも MAX_BYTES で切る。
br など標準ライブラリで解けない物は None(取れなかった扱い)"""
if encoding in ("", "identity"):
return body
if encoding in ("gzip", "x-gzip", "deflate"):
try:
# | 32 で gzip と zlib の頭を見分ける
return zlib.decompressobj(zlib.MAX_WBITS | 32).decompress(body, MAX_BYTES)
except zlib.error:
return None
return None
# ページを取る所(抜粋)
body = decode_body(res.read(MAX_BYTES), (res.headers.get("content-encoding") or "").strip().lower())
if body is None:
return None
要所は3つです。
zlib.MAX_WBITS | 32で、gzip の頭と zlib の頭を見分けて解きます。- 解いた後の大きさも、取得と同じ上限(2,000,000 バイト)で切ります。
- br のように標準ライブラリで解けない物や、解くのに失敗した物は、文字として読まずに「取れなかった」扱いにします。その候補は材料が減りますが、文字化けを LLM に渡すことはありません。
直し方2: 制御文字を落とす
元を断った後も、守りとして制御文字を落とす処理は残しました。落とすのは、Unicode の制御文字(Cc)のうち、改行・タブ・CR 以外です。
# 制御文字(Unicode の Cc)のうち、改行・タブ・CR 以外
CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
def strip_controls(text: str) -> str:
return CONTROL_CHARS.sub("", text)
落とす所は3か所です。記事の題・説明・本文、検索の結果の題と抜粋、そして受付に送る直前の system と user です。
1件の候補で回全体を止めない
もう1つの問題は、1件の候補のせいで回全体が止まったことです。そこで止まり方を変えました。候補ごとの段で、受付が依頼の中身を理由に断ったとき(items_unsafe_text・schema_invalid)は、その候補だけを飛ばし、飛ばした数を回の知らせに出します。飛ばした候補が半分を超えたときは、回を止めます。
どう確かめたか
- テストでは、gzip で圧縮した応答、圧縮していない応答、br と名乗る応答の3つを偽物で返しました。gzip は解けて題と本文が取れること、圧縮していない物はそのまま読めること、br は「取れなかった」になることを確かめています。
- 回を通しで流して試すことはしていません。代わりに、配った版のコードで落ちた2ページを取り直し、題と本文が読めて制御文字が 0 になること、検索も返ることを確かめました。
- その次の回は、最後まで通りました。
確かめていないこと
- deepmind.google がなぜ gzip で返したのかは分かっていません。Python の urllib は既定で
Accept-Encoding: identityを送るはずですが、当時のリクエストのヘッダは記録していません。 - 同じことをするサイトがほかにどれだけあるか。落ちたのは deepmind.google の2ページだけでした。
- br で返すサイトがどれだけあるか。この直しの後も「取れなかった」扱いのままだったので、そうしたサイトの候補は材料が減りました。
おわりに
LLM への依頼が変な文字で断られたら、送る直前に掃除するだけで済ませず、その文字列がどこで文字になったのかをたどってみてください。私たちの場合、元はページを取る所で圧縮を解いていなかったことでした。
※ deepmind.google の応答の仕方や Python の urllib の既定の振る舞いは執筆時点の情報で、いまは変わっている可能性があります。

