1
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?

日本語で生成した記事2,089本を測ったら6割に他言語が混ざっていた — 範囲判定では原理的に取れない

1
Posted at

自分のサイトで、LLM に日本語の記事を書かせて自動投稿している。半年動いている。
ビルドは通る。スキーマ検証も通る。文字化けもしない。リンク切れもない。

全記事をスキャンしたら、2,089本のうち1,266本に日本語以外の文字体系が混ざっていた

この記事は、その測り方と、なぜ標準的な検査では取れないのかを書く。数字はすべて自分のログから取った。
検出スクリプトは全文載せるので、自分の環境で同じことができる。

3行で

  • 日本語記事に簡体字・ハングル・キリル文字が混入する。読めてしまうので気づかない
  • 簡体字は日本語漢字と同じ Unicode ブロックにいるので、コードポイント範囲による検出は原理的に不可能
  • 壊れるのは専門用語や固有名詞ではなく、助詞・接続表現・見出しのテンプレート

何を測ったか

対象は自分の1リポジトリ、日本語の技術ニュース記事 2,089本。
すべて LLM が生成し、人手のレビューはほぼ入っていない。

書き込み先が2つに分かれていたので、そのまま2群として測った(この分裂自体が別の話になるので後述する)。

ディレクトリ 本数
src/content/blog 315
content/blog 1,774

コードブロック・インラインコード・URL は除外した。技術記事では
importSELECT や中国語の製品名が正当に入るので、そこを数えても意味がない。

数字は 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.mdAGENTS.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_noiseFOREIGN_SCRIPTSforeign_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本、エラーゼロだった

緑のダッシュボードは、検査していないものについては何も言っていない。

関連記事


関連

「品質は指示ではなく、拒否できる場所があるかで決まる」と書きました。これは規約全般に当てはまります。お願いとして書かれた規則は、守られなくても何も起きません。

どの層に何を置けば拒否されるのか、構成パターン別にまとめた本があります。

AIコンテキスト設計ガイド ── 規約を配ったのに守られない理由(2,000円)

第3章「規約が効かなくなる4つのパターン」まで無料で読めます。
/compact の後もルートの CLAUDE.md は再注入されるが、サブディレクトリと paths: 付きは再注入されない ── 長いセッションで静かに落ちるのはどちらか、という章です。

1
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
1
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?