夜間バッチのログを流し読みしていて、生成された記事の手触りがいつもと少し違うことに気づきました。誤りはありません。事実も正確です。それでも、文末が妙にきびきびしていて、普段なら柔らかく余韻を残す箇所が、断定で閉じている。コードは一文字も変えていません。
原因を追うと、エイリアスで呼んでいた経路だけ、応答していたモデルの世代が上がっていました。2026年6月8日、Gemini Enterprise では既定モデルが 3.5 Flash に切り替わり、無効化トグルも廃止されています。良し悪しの問題ではありません。正しさは保たれたまま、文体の癖だけが静かにズレる ——これが、文章を量産する自動化にとって最も見つけにくい事故です。
正答率を見張るゲートは、この種のズレを素通りさせます。答えは合っているからです。ここでは、個人開発で複数のサイトの日本語記事の自動生成を運用してきた現場で実際に効いた、「文体の癖」そのものを数値で見張る仕組みをまとめます。私自身が運用の中で確かめた範囲に絞っています。依存ライブラリは使いません。標準ライブラリだけで、明日から自分のパイプラインに差し込める形にしています。
なぜ「正しさ」のゲートでは取りこぼすのか
生成文の品質ゲートは、たいてい二系統で組まれます。一つは事実性や指示追従を測るもの。LLM-as-judge や golden データセットがここに当たります。もう一つはスキーマ検証で、JSON 構造や必須フィールドの有無を機械的に弾きます。
どちらも「内容が正しいか」を見ています。ところが既定モデルの差し替えで起きるのは、内容ではなく表現の分布の移動 です。敬体で柔らかく書いていた文末が断定調に寄る。一文が短く詰まる。体言止めの余韻が減る。いずれも、judge から見れば「正しい良い文章」で通ってしまいます。
文章を一定のトーンで届けることが価値の核にあるメディアでは、このズレは読者の離脱に直結します。「いつもの人が書いた感じがしない」という違和感は、説明できなくても伝わってしまうからです。だからこそ、正しさとは独立した軸で、文体そのものを観測する必要がある と考えています。
文体を数えられる特徴に分解する
文体は曖昧な概念ですが、観測可能な特徴に分解すれば数えられます。日本語の生成文に対して、私が実運用で効くと判断したのは次の特徴量です。いずれも一文単位、あるいは記事単位で機械的に取れます。
敬体率 : 文末が「です・ます・ました・でしょう」等で終わる文の割合。トーンの土台です
平均文長 : 一文あたりの文字数。世代が上がると詰まる傾向が出ます
文長の標準偏差 : 文の長短のリズム。単調化すると下がります
体言止め率 : 名詞で閉じる文の割合。余韻の量に相当します
冒頭接続詞率 : 「しかし・そのため・また」で始まる文の割合。論理の運び方の癖です
読点密度 : 一文あたりの読点数。息継ぎの細かさです
テンプレ語出現率 : 「この記事では・いかがでしたか・徹底解説」等の禁止語が単位長あたり何回出るか
これらをベクトルにまとめたものを、その出力の文体フィンガープリント と呼ぶことにします。重要なのは、各特徴が「正しさ」とは無関係であることです。事実が合っていても、これらは動きます。
フィンガープリント抽出器を実装する
標準ライブラリだけで書きます。文の分割は句点ベースの素朴なもので十分です。完全な形態素解析は不要で、むしろ依存を増やさないことが運用上の利点になります。
import re
import statistics
POLITE_ENDINGS = ( "です" , "ます" , "ました" , "ません" , "でしょう" ,
"ください" , "ましょう" , "でした" , "います" )
LEAD_CONJUNCTIONS = ( "しかし" , "そのため" , "また" , "さらに" , "つまり" , "ただし" )
TEMPLATE_WORDS = ( "この記事では" , "本記事では" , "いかがでしたか" ,
"徹底解説" , "完全ガイド" , "決定版" , "について解説します" )
# 体言止め判定: 末尾が動詞・助動詞・終助詞でないことを近似する
VERB_TAIL = re.compile( r " ( る | た | だ | ない | です | ます | ました | でしょう | ください | う | く | す )$ " )
def split_sentences (text: str ) -> list[ str ]:
# コードブロックと見出し記号を落としてから句点で分割する
text = re.sub( r "``` . *? ```" , "" , text, flags = re.S)
text = re.sub( r " ^ # + \s. * $ " , "" , text, flags = re.M)
parts = re.split( r " (?<= 。 ) " , text)
return [s.strip() for s in parts if len (s.strip()) >= 4 ]
def extract_fingerprint (text: str ) -> dict[ str , float ]:
sents = split_sentences(text)
n = max ( len (sents), 1 )
total_chars = sum ( len (s) for s in sents)
polite = sum ( 1 for s in sents if s.rstrip( "。" ).endswith( POLITE_ENDINGS ))
taigen = sum ( 1 for s in sents
if not VERB_TAIL .search(s.rstrip( "。" )) and not s.rstrip( "。" ).endswith( POLITE_ENDINGS ))
lead = sum ( 1 for s in sents if s.startswith( LEAD_CONJUNCTIONS ))
commas = sum (s.count( "、" ) for s in sents)
template_hits = sum (text.count(w) for w in TEMPLATE_WORDS )
lengths = [ len (s) for s in sents]
return {
"polite_ratio" : polite / n,
"mean_len" : statistics.fmean(lengths),
"len_stdev" : statistics.pstdev(lengths) if len (lengths) > 1 else 0.0 ,
"taigen_ratio" : taigen / n,
"lead_conj_ratio" : lead / n,
"comma_density" : commas / n,
"template_rate" : template_hits / (total_chars / 1000 + 1e-9 ), # 1000字あたり
}
template_rate だけは「率」ではなく1000字あたりの出現回数にしています。テンプレ語はゼロが理想なので、長さで正規化して比較可能にしておくと、長い記事と短い記事を同じ物差しで見られます。
ベースラインを「分布」として持つ
一本の見本と比べても意味はありません。文体は記事ごとに揺れるからです。そこで、自分が「これは自分の文体だ」と確信できる過去記事を30〜50本集め、各特徴の平均と標準偏差 を取ってベースライン分布にします。
import json
def build_baseline (texts: list[ str ]) -> dict[ str , dict[ str , float ]]:
fps = [extract_fingerprint(t) for t in texts]
keys = fps[ 0 ].keys()
baseline = {}
for k in keys:
vals = [fp[k] for fp in fps]
baseline[k] = {
"mean" : statistics.fmean(vals),
"stdev" : statistics.pstdev(vals) or 1e-6 , # ゼロ割回避
}
return baseline
# 既定モデルが変わる前の安定した時期の記事で作る
baseline = build_baseline(reference_texts)
with open ( "style_baseline.json" , "w" , encoding = "utf-8" ) as f:
json.dump(baseline, f, ensure_ascii = False , indent = 2 )
ここで一つ実務的な注意点があります。本番運用では、ここを外すと検知器が静かに無力化されます。ベースラインは既定モデルが変わる前 の出力で作ってください。差し替え後の出力を混ぜると、ズレた状態が「正常」として学習され、検知器が永久に沈黙します。私は最初これをやってしまい、ドリフトが出ているのに緑のまま通り続ける一週間を過ごしました。
z スコアで逸脱を判定する
新しい出力のフィンガープリントを取り、各特徴がベースライン分布から何標準偏差ずれているか(z スコア)を見ます。単一特徴のわずかな揺れで止めると誤検知だらけになるので、複数特徴が同時に大きくずれたときだけ 警告する設計にします。
def style_drift (text: str , baseline: dict , z_warn: float = 2.5 ,
min_flags: int = 2 ) -> dict :
fp = extract_fingerprint(text)
z_scores, flags = {}, []
for k, v in fp.items():
b = baseline[k]
z = (v - b[ "mean" ]) / b[ "stdev" ]
z_scores[k] = round (z, 2 )
if abs (z) >= z_warn:
flags.append(k)
# 集約スコア: 各 z の二乗和の平方根(多次元のズレの大きさ)
aggregate = round (( sum (z * z for z in z_scores.values())) ** 0.5 , 2 )
return {
"drifted" : len (flags) >= min_flags,
"flags" : flags,
"z_scores" : z_scores,
"aggregate" : aggregate,
}
z_warn=2.5、min_flags=2 は出発点の値です。私の運用では、敬体率と平均文長の二つが同時に動いたときが最も「モデルが変わった」と相関しました。閾値は最初の二週間ログを眺めて、自分の記事の自然な揺れがどの程度かを見てから締めるのが現実的です。緩すぎる方が、止まらないより安全です。
文体ドリフトと実効モデルを突き合わせる
文体が動いたとき、本当にモデルのせいなのかを切り分けたいはずです。Gemini の google-genai SDK では、レスポンスに実際に応答したモデルの版 が入っています。これを文体スコアと一緒に記録しておくと、原因特定が一手で済みます。
from google import genai
client = genai.Client()
def generate_with_audit (prompt: str , model: str , baseline: dict ) -> dict :
resp = client.models.generate_content( model = model, contents = prompt)
text = resp.text
drift = style_drift(text, baseline)
return {
"text" : text,
"requested_model" : model, # 自分が指定したID
"served_model" : getattr (resp, "model_version" , "unknown" ), # 実際に応答した版
"style" : drift,
}
result = generate_with_audit(prompt, "gemini-flash-latest" , baseline)
if result[ "style" ][ "drifted" ] and result[ "requested_model" ] != result[ "served_model" ]:
raise SystemExit (
f "文体ドリフト検知: flags= { result[ 'style' ][ 'flags' ] } / "
f "requested= { result[ 'requested_model' ] } served= { result[ 'served_model' ] } "
)
ここが今回の肝です。requested_model と served_model が食い違っていて、かつ文体が動いている——この二つが同時に立ったとき、原因は「既定が頭越しに上がった」だと一発で言えます。エイリアス(-latest)で呼んでいる経路ほど、この食い違いは起きやすくなります。逆に版を完全固定している経路で文体だけ動いたなら、プロンプトや入力データ側を疑う、という切り分けにもなります。
パイプラインのゲートとして据える
最後に、これを生成パイプラインの最終段に据えます。私の運用では、生成 → 文体ドリフト判定 → 既存の正しさゲート、の順で並べ、文体ゲートで止まったものは公開キューに乗せず、人の目に回すレビューキューへ送っています。
def publish_gate (result: dict ) -> str :
s = result[ "style" ]
if s[ "drifted" ]:
# 公開は止め、原因メモを添えてレビュー行きにする
return f "HOLD: 文体ドリフト flags= { s[ 'flags' ] } aggregate= { s[ 'aggregate' ] } "
if s[ "aggregate" ] >= 4.0 :
# 個別フラグは立たないが全体的に遠い → 監視ログだけ残す
return f "WATCH: aggregate= { s[ 'aggregate' ] } "
return "OK"
drifted で止めるほどではないが集約スコアが大きい中間帯を WATCH として残すのは、閾値をいきなり厳しくしないための緩衝です。止めはしないが記録は残す。個人的には、この緩衝帯を最初から設けておくことをお勧めします。この帯のログがたまると、自分の文体の自然な揺れの幅が見えてきて、z_warn を自信を持って締められるようになります。
私はこの仕組みを本番運用に乗せてから、一つ実感したことがあります。それは、この仕組みは「モデルが変わったとき」だけでなく、自分がプロンプトをいじって文体が崩れたときにも鳴ってくれることです。検知器は原因を区別しません。文体が遠くなったという事実だけを淡々と返します。だからこそ、機械が頭越しに変えてきたのか、自分が壊したのか、どちらも同じ網で受け止められます。
次の一歩として、まずは手元の安定した記事30本で build_baseline を一度走らせ、直近の出力を style_drift に通してみてください。自分の文体が数字でどう表れるかを一度見ておくと、既定モデルが次に静かに上がる日が来ても、慌てずに気づけるようになります。同じように文章を量産している方の運用の助けになれば幸いです。