自分のサイトで、LLM に日本語の記事を書かせて自動投稿している。半年動いている。
ビルドは通る。スキーマ検証も通る。文字化けもしない。リンク切れもない。
全記事をスキャンしたら、2,089本のうち1,266本に日本語以外の文字体系が混ざっていた。
この記事は、その測り方と、なぜ標準的な検査では取れないのかを書く。数字はすべて自分のログから取った。
検出スクリプトは全文載せるので、自分の環境で同じことができる。
3行で
- 日本語記事に簡体字・ハングル・キリル文字が混入する。読めてしまうので気づかない
- 簡体字は日本語漢字と同じ Unicode ブロックにいるので、コードポイント範囲による検出は原理的に不可能
- 壊れるのは専門用語や固有名詞ではなく、助詞・接続表現・見出しのテンプレート
何を測ったか
対象は自分の1リポジトリ、日本語の技術ニュース記事 2,089本。
すべて LLM が生成し、人手のレビューはほぼ入っていない。
書き込み先が2つに分かれていたので、そのまま2群として測った(この分裂自体が別の話になるので後述する)。
| ディレクトリ | 本数 |
|---|---|
src/content/blog |
315 |
content/blog |
1,774 |
コードブロック・インラインコード・URL は除外した。技術記事では
import や SELECT や中国語の製品名が正当に入るので、そこを数えても意味がない。
数字は 2026-08-18 時点のスナップショット。パイプラインは今も動いているので総数は増える。
この記事を書いている最中にも11本増えた。比率は動いていない。
最初にブラックリストで書いて、失敗した
最初はこう書いた。簡体字を列挙して、それが出たら混入とみなす。
SIMPLIFIED = set("为门问间关开发这个说话时国东车马鸟长风飞见贝页产业务书买卖…")
結果、混入率が 81.3% と出た。多すぎる。上位の文字を見たらすぐ分かった。
国 3772 U+56FD
体 2654 U+4F53
参 2604 U+53C2
会 1430 U+4F1A
医 339 U+533B
国 体 参 会 医 は正規の日本語漢字である。自分がリストに入れ間違えた。
これは凡ミスに見えるが、そうではない。ブラックリスト方式はこの問題では構造的に破綻する。
「日本語で使わない漢字」の集合を手で列挙しようとしている限り、
入れ忘れ(見逃し)と入れ間違い(誤検出)の両方が永久に残る。
なぜ範囲判定では取れないのか
簡体字は「簡体字ブロック」にいない。Unicode に簡体字専用のブロックは存在しない。
-
开U+5F00 — CJK統合漢字 -
開U+958B — CJK統合漢字
どちらも同じ CJK Unified Ideographs (U+4E00–U+9FFF) の中にいる。
Unicode の統合(Han unification)は、日本・中国・韓国・ベトナムの漢字を
同じブロックに、字形の差を残したまま入れる設計だからである。
だから [一-鿿] のような範囲正規表現では、日本語漢字と簡体字を分離できない。
これは実装の未熟さではなく、その符号化のもとでは情報が存在しないという話である。
ハングル(U+AC00–U+D7A3)やキリル(U+0400–U+04FF)は別ブロックなので範囲で取れる。
取れるものだけ検査していると、取れないものだけが残る。
Unicode のセキュリティ関連仕様(UTS #39、混同可能文字と混在スクリプト検出)も、
適用範囲を「異なるスクリプト間」と規定している。同一スクリプト内で
言語が違うケースは、仕様が自ら対象外としている領域である。
Script_Extensions プロパティを見ても、开 も 開 も等しく Han である。
ホワイトリストで測り直す
正しい問いは「これは簡体字か」ではなく、**「この文字は日本語で使えるか」**である。
日本語で使える文字の集合は、列挙する必要がない。すでに規格として存在する。
JIS X 0208 / JIS X 0213 がそれで、Python なら符号化を試すだけで判定できる。
def jp_encodable(ch):
"""日本語の符号化文字集合に入るか。cp932 と JIS X 0213 の和を許可集合とする。"""
for enc in ("cp932", "euc_jisx0213"):
try:
ch.encode(enc)
return True
except UnicodeEncodeError:
continue
return False
开.encode("cp932") は UnicodeEncodeError を出す。開.encode("cp932") は通る。
手で列挙する代わりに、既存の規格に判定を委譲する。列挙漏れも入れ間違いも起きない。
結果
| 全数 | いずれか混入 | 同一ブロック混入 (範囲判定で検出不能) |
|
|---|---|---|---|
src/content/blog |
315 | 38 (12.1%) | 29 (9.2%) |
content/blog |
1,774 | 1,228 (69.2%) | 1,176 (66.3%) |
内訳(記事単位、重複あり):
| 文字体系 |
content/blog 1,774本中 |
範囲判定で取れるか |
|---|---|---|
| 日本語外の漢字 | 1,176 (66.3%) | ❌ 取れない |
| ハングル | 796 (44.9%) | ✅ |
| キリル | 592 (33.4%) | ✅ |
| アラビア | 87 (4.9%) | ✅ |
| ギリシャ | 43 (2.4%) | ✅ |
| デーヴァナーガリー | 4 (0.2%) | ✅ |
最も多い型が、最も検出しにくい型だった。
別スクリプトの検査だけを入れていたら、66.3% を丸ごと見逃していたことになる。
壊れるのは固有名詞ではない
ここが一番の反直感だった。
「訳しにくい専門用語で崩れるのだろう」と思っていたが、逆だった。
実際の行を出す(すべて実データ)。
型1: 助詞・接続表現が別言語に置き換わる
TechCrunchの報道 따르면、ハッカーはサポートチャットの会話の流れを読み取りながら…
「報道によれば」の位置に、韓国語の 따르면(〜によれば)が入っている。
Musk씨가 Altmanさんを「詐欺師」と呼ぶと、Altmanさんは反撃した。
「Musk氏が」の位置に、韓国語の 씨가(氏+主格助詞)。
直前の Musk も直後の Altmanさん も無傷である。
トークン消費量を99%以上削減的同时に精度も向上した
「削減すると同時に」の位置に、中国語の 的同时。
针对この「構造的欠陥」に立ち向かうため、MiniMaxのチームは…
「これに対して」の位置に、中国語の前置詞 针对。
Paul Meade is Appleのメタバース戦略を影で支えてきた一人で…
「Meade は」の位置に、英語のコピュラ is。
置き換わっているのは全部機能語である。情報量が低く、言語をまたいで交換可能な位置。
型2: 見出しのテンプレートが壊れる
一番多かった語は 的意义 で 248回。文脈を見るとこうなっている。
開発者にとって的意义: 推論コストの最適化はプロダクションLLM導入の成否を分ける。
これは「開発者にとっての意義:」という定型の見出しである。
「〜にとって」までは正しい日本語で、の意義 の位置で
中国語の所有格助詞 的 と簡体字 意义 に切り替わっている。
定型句なので、崩れると248回同じ形で崩れる。
型3: 同じ語が、違う字体で書かれる
簡体字の上位は内容語だった。これは型1と機構が違う。
| 出現数 | 混入した表記 | 本来の日本語 |
|---|---|---|
| 229 | 开发者 |
開発者 |
| 189 | 企业 |
企業 |
| 124 | 报道 |
報道 |
| 111 | 训练 |
訓練 |
| 111 | 问题 |
問題 |
| 102 | 结果 |
結果 |
| 101 | 设计 |
設計 |
| 87 | 时代 |
時代 |
外国語の語彙が漏れているのではない。正しい日本語の語が、誤った字体で書かれている。
語としては合っているので、意味を検査しても引っかからない。
日本人が読んでも意味は通ってしまう。表記だけが壊れている。
固有名詞は一度も壊れていない
上の例をもう一度見てほしい。TechCrunch Musk Altman MiniMax Paul Meade Apple —
固有名詞は全部そのままである。数値も単位も無傷だった。
つまりレビュー時に人が注意を向ける場所(名前、数字、専門用語)だけが正しく、
注意を向けない場所(助詞、接続、定型句)が壊れている。
目視レビューが構造的に最も当たりにくいところに、欠陥が集中している。
ひどいものになると、文の途中から丸ごと切り替わる。
研究 따르면、사용자의 입력이 컨텍스트 윈도우를 점점 더 많이 채울수록, 모델は
사용자의 오개념이나 오해에 끌려가는 경향이 강해진다。実験では、ユーザーの…
冒頭と末尾は日本語で、中間が韓国語の文になっている。
検証者がいる場所といない場所で、3桁違った
冒頭で「書き込み先が2つに分かれている」と書いた。理由が分かってから、数字の意味が変わった。
このサイトは Astro の content collection で記事を読む。
Astro が読むのは src/content/ 配下だけで、content/blog はビルドのどこからも参照されていない。
そして content collection はスキーマ違反があるとビルドを落とす。
つまり src/content/blog には、スキーマを満たすものしか置けない。物理的に置けない。
| 必須メタデータが揃っている | |
|---|---|
src/content/blog(Astro が検証する) |
314 / 315 = 99.7% |
content/blog(誰も読まない) |
3 / 1,774 = 0.2% |
混入率も 12.1% と 69.2% で 5.7倍の差がある。
同じ生成器、同じプロンプト、同じ人間である。違うのは下流に拒否する主体がいるかどうかだけ。
(厳密には2群は時期も生成手順も違うので、この差を検証者の有無だけに帰属させることはできない。
ただしメタデータ充足率の差は因果が構造的で、検証される側は違反を含められない。)
品質は指示文ではなく、拒否できる場所があるかで決まる。
なぜ半年気づかなかったか
git の追加履歴で見ると、こうなっていた。
| 月 |
content/blog(読まれない) |
src/content/blog(読まれる) |
|---|---|---|
| 2026-02 | 13 | 231 |
| 2026-03 | 0 | 70 |
| 2026-04 | 0 | 12 |
| 2026-05 | 396 | 0 |
| 2026-06 | 535 | 6 |
| 2026-07 | 550 | 3 |
| 2026-08 | 289 | 0 |
2026-05 に流れが切り替わって、以降 1,770本が読まれない側へ、9本だけが読まれる側へ行った。
この間、エラーは一度も出ていない。
ファイル書き込みは成功する。コミットも成功する。GitHub 上にファイルは実在する。
ビルドも成功する。集合を走査するローダーにとって 0件は成功だからである。
症状は「増えているはずのものが増えていない」という不在でしか現れない。
不在を監視している人はいない。
原因を追ったら、恒久的な生成器が存在しなかった。
日付ごとの使い捨てスクリプトが、それぞれ書き込み先をリテラルで直書きしていた。
CLAUDE.md も AGENTS.md も無く、書き込み先の不変条件がどこにも書かれていなかった。
エージェントに毎回同じことをさせたいなら、それは会話ではなく
毎回再読される場所に置くしかない。置かなければ、半年かけて漂流する。
自分の環境で測る
全文載せる。標準ライブラリだけで動く。
#!/usr/bin/env python3
"""日本語のマークダウン群から、日本語以外の文字体系の混入を測る。
python3 scan.py <ディレクトリ> [...]
検出は「日本語で使える文字集合に属するか」というホワイトリスト判定で行う。
簡体字を列挙するブラックリスト方式は、日本語漢字を誤検出して破綻する。
出力は集計値と混入文字のみ。本文は出さない。
"""
import sys, glob, os, re, collections, unicodedata
# 範囲判定で取れる側(別ブロック)
FOREIGN_SCRIPTS = {
"ハングル": re.compile(r"[가-힣ᄀ-ᇿ]"),
"キリル": re.compile(r"[Ѐ-ӿ]"),
"ギリシャ": re.compile(r"[Ͱ-Ͽ]"),
"タイ": re.compile(r"[-]"),
"デーヴァナーガリー": re.compile(r"[ऀ-ॿ]"),
"アラビア": re.compile(r"[-ۿ]"),
"ヘブライ": re.compile(r"[-]"),
}
_cache = {}
def foreign_kanji(ch):
"""CJK統合漢字だが日本語の文字集合に無い = 簡体字などの混入。
範囲では判定できない(簡体字は日本語漢字と同じブロックにいる)ので、
日本語の符号化文字集合に属するかで判定する。
"""
r = _cache.get(ch)
if r is None:
o = ord(ch)
is_cjk = 0x4E00 <= o <= 0x9FFF or 0x3400 <= o <= 0x4DBF or 0xF900 <= o <= 0xFAFF
r = False
if is_cjk:
r = True
for enc in ("cp932", "euc_jisx0213"):
try:
ch.encode(enc)
r = False
break
except UnicodeEncodeError:
pass
_cache[ch] = r
return r
def strip_noise(text):
"""コードブロック・インラインコード・URL を除く(正当に外国語が入る)。"""
text = re.sub(r"```.*?```", "", text, flags=re.S)
text = re.sub(r"`[^`]*`", "", text)
return re.sub(r"https?://\S+", "", text)
for target in sys.argv[1:]:
files = glob.glob(os.path.join(target, "**", "*.md*"), recursive=True)
per = collections.Counter()
chars = collections.Counter()
runs = collections.Counter()
for path in files:
body = strip_noise(open(path, encoding="utf-8", errors="replace").read())
hit = False
for name, pat in FOREIGN_SCRIPTS.items():
m = pat.findall(body)
if m:
per[name] += 1
chars.update(m)
hit = True
k = [c for c in body if foreign_kanji(c)]
if k:
per["日本語外の漢字"] += 1
chars.update(k)
hit = True
if hit:
per["__any__"] += 1
# 混入語を拾う(漢字の連なりのうち日本語外を含むもの)
for m in re.finditer(r"[一-鿿]+", body):
s = m.group()
if len(s) <= 6 and any(foreign_kanji(c) for c in s):
runs[s] += 1
n = len(files) or 1
print(f"=== {target} {len(files)} 本")
print(f" いずれか混入 : {per['__any__']:>5} 本 ({per['__any__']/n*100:.1f}%)")
for k in sorted(per, key=lambda x: -per[x]):
if k != "__any__":
print(f" {k:<16}: {per[k]:>5} 本 ({per[k]/n*100:.1f}%)")
print(" 混入した語 上位15:")
for s, c in runs.most_common(15):
print(f" {c:>5} {s}")
まず混入した語の一覧を見てほしい。比率より先に、そこに自分の定型句が出ていないかを見る。
248回壊れていた 的意义 は、比率ではなく語の一覧で見つかった。
落とす
測るだけでは減らない。生成の下流に拒否する主体を置く。
strip_noise と FOREIGN_SCRIPTS と foreign_kanji は上のスクリプトのものをそのまま使う。
def check(path):
"""違反があればメッセージのリストを返す。呼び出し側は非0で終了する。"""
body = strip_noise(open(path, encoding="utf-8", errors="replace").read())
errors = []
for name, pat in FOREIGN_SCRIPTS.items():
found = set(pat.findall(body))
if found:
errors.append(f"{name} が混入: {''.join(sorted(found))[:40]}")
kanji = {c for c in body if foreign_kanji(c)}
if kanji:
errors.append(f"日本語外の漢字が混入: {''.join(sorted(kanji))[:40]}")
return errors
置くときに効いたことが3つある。
1. 条件付きにしない。
手順書に「必要に応じて検証する」と書くと実行されない。
条件を判断する主体がエージェントである場合、分岐はコストの低い側(実行しない)に倒れる。
無条件の必須工程にして、適用外の境界を別に明示する。
2. 会話ではなく、毎回読まれる場所に書く。
セッション中に「今後は必ず検証して」と言っても、次のセッションには存在しない。
リポジトリの CLAUDE.md / AGENTS.md に不変条件として書く。
3. 黙って直さず、落とす。
混入を見つけて自動修正させると、修正差分の中で数値や事実が一緒に書き換わる。
自己回帰生成に「この文字だけ直して」は保証できない。検出したら止める。
この測定の限界
- 1リポジトリ・1言語・1生成経路の観測である。他のモデルや他の言語で同じ比率になる保証はない
-
誤検出は残っている。 中国語の製品名や人名を正当に引用している箇所も
「日本語外の漢字」として数えている。コードブロックと URL は除いたが、
本文中の引用までは分離していない。69.2% は上限寄りの数字である - 2群(12.1% と 69.2%)は時期も生成手順も違う。差を検証者の有無だけに帰属させることはできない
-
なぜ混入するのかは測っていない。 混入位置のトークン確率が取れれば
「情報量の低い位置で滑る」という機構の主張になるが、
使っている生成経路は logprobs を返さないので、ここでは現象の分布までしか言えない - JIS X 0213 の外にある正規の日本語漢字(人名の異体字など)は誤検出になる。
実運用では例外リストが要る
まとめ
- LLM の日本語生成には他言語が混入する。今回の実測で 2,089本中 1,266本
- 最も多い型(簡体字)は日本語漢字と同じ Unicode ブロックにいるので、
コードポイント範囲による検出は原理的に不可能。ホワイトリスト(日本語の符号化文字集合への
メンバシップ判定)で実装する - 壊れるのは助詞・接続表現・定型の見出し。固有名詞と数値は無傷。
目視レビューが最も当たらない場所に集中している - 検証する消費者がいる経路といない経路で、メタデータ充足率が 99.7% 対 0.2% だった。
品質は指示ではなく、拒否できる場所があるかで決まる - 書き込み先の不変条件を書かなければ漂流する。今回は半年、1,770本、エラーゼロだった
緑のダッシュボードは、検査していないものについては何も言っていない。
関連記事
- /compact のコストを実ログ669回分で測った
- AGENTS.md と CLAUDE.md は解決規則が逆
- AIエージェントが「知っているはず」を間違える理由
関連
「品質は指示ではなく、拒否できる場所があるかで決まる」と書きました。これは規約全般に当てはまります。お願いとして書かれた規則は、守られなくても何も起きません。
どの層に何を置けば拒否されるのか、構成パターン別にまとめた本があります。
→ AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)
第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。