英語圏のユーザーから「通知の文章が途中で終わっている」という報告をいただいたのは、配信を始めて三度目の週でした。
個人開発で運用している壁紙アプリで、新作追加のお知らせを日英2言語で配信しています。文面は Gemini に作らせ、プロンプトには「40文字以内で」と明記していました。日本語の通知は毎回きれいに収まっていたので、同じ指示で英語も収まると思い込んでいたのです。
手元の端末で確認したときは日本語ロケールでした。だから気づけませんでした。
原因は Gemini の出力品質ではありませんでした。長さの単位に「文字数」を選んだ、こちら側の設計です。以下は、その勘違いを解くまでに測ったことと、最終的に落ち着いた実装です。
先に結論として、長さはモデルに守らせないことにしました
現在の構成はこうなっています。
- Gemini には「短く書け」と指示しない。代わりに lead(要旨)と detail(補足)の2フィールドに分けて返させる
- 長さの調整は、生成が終わったあとの決定的なコードで行う
- 単位は文字数ではなく表示幅。しかも言語ごとに幅の数え方を変える
「モデルに守らせる」から「モデルには壊れにくい形で返させ、詰めるのはこちらでやる」へ移した、という一点に尽きます。理由を順に書きます。
日本語と英語では、同じ内容でも文字数が倍以上違いました
まず、実際に配信していた文面に近いサンプルで数えてみました。表示幅は East Asian Width が W / F / A の文字を 2、それ以外を 1 として合計した値です。
| 言語 | 文字数 | 表示幅 | 文面 |
| ja | 34 | 67 | 新しい浮世絵の壁紙を8枚追加しました。歌川広重の東海道シリーズです。 |
| en | 81 | 81 | 8 new ukiyo-e wallpapers are now available, including Hiroshige's Tokaido series. |
| ja | 25 | 49 | 今週の追加分は葛飾北斎の富嶽三十六景から6枚です。 |
| en | 74 | 74 | This week we added 6 pieces from Hokusai's Thirty-six Views of Mount Fuji. |
同じことを伝えるのに、日本語は 34 文字、英語は 81 文字。2.4 倍です。
ここに「40文字以内」という一律の指示を当てると何が起きるか。日本語は表示幅にして 80 まで使えるのに対し、英語は 40 までしか使えません。同じ数字が、言語によって倍の差の制約になっていたわけです。英語版だけが情報を削られていたのは、モデルが指示を破ったからではなく、指示そのものが英語に厳しすぎたからでした。
さらに、モデル側の事情も重なります。Gemini はテキストをトークンで扱っており、「40文字」という単位は生成中に正確に数えられる量ではありません。response_schema に maxLength を書いても、API 側でその長さが強制されるわけではないため、超過した文字列がそのまま返ってくることがあります。長さの保証をモデルに期待した時点で、設計が不安定になります。
文字数で切ると、英語だけが半分になります
では受け取った文字列をこちらで切ればいい、と考えて最初に書いたのが素朴なスライスでした。text[:30] です。結果はこうなりました。
| 言語 | 切った結果の表示幅 | 結果 |
| ja | 59 | 新しい浮世絵の壁紙を8枚追加しました。歌川広重の東海道シリー |
| en | 30 | 8 new ukiyo-e wallpapers are n |
日本語は幅 59 を使い切っているのに、英語は幅 30 しか使っていません。通知バーには倍のスペースが余っているのに、英語だけが早々に打ち切られている状態です。
文字数という単位は、全角と半角を等価に扱います。日本語のように1文字が幅2の言語では有利に、ラテン文字のように1文字が幅1の言語では不利に働きます。多言語アプリでこれをやると、必ずラテン文字側が損をします。
表示幅で切っても、英語は語の途中で壊れます
単位を表示幅に変え、上限 60 で切り直しました。
| 言語 | 幅 | 結果 |
| ja | 59 | 新しい浮世絵の壁紙を8枚追加しました。歌川広重の東海道シリー |
| en | 60 | 8 new ukiyo-e wallpapers are now available, including Hirosh |
予算は使い切れるようになりました。しかし英語は Hirosh で切れています。固有名詞の途中です。読者から見れば、これは「途切れている」以上に「壊れている」ように映ります。
語境界まで戻す処理を足すと、今度は幅が 60 から 55 に落ちました。予算の 8% ほどが空白として捨てられる計算です。
| 言語 | 幅 | 結果 |
| ja | 59 | 新しい浮世絵の壁紙を8枚追加しました。歌川広重の東海道シリ… |
| en | 55 | 8 new ukiyo-e wallpapers are now available, including… |
ここで理解したことがあります。英語は同じ幅予算でも、実際に載せられる情報量が日本語より少ない。 語の途中で切れない以上、末尾に必ず端数が生まれるからです。日本語は文字単位で切っても読めるので、予算をほぼ使い切れます。
「英語版だけが途切れて見える」という報告は、文字数指示・幅の取り違え・語境界の端数という3つが積み重なった結果でした。
Ambiguous 幅の罠 — é を幅2で数えていました
幅計算に East Asian Width を使うとき、A(Ambiguous)をどう扱うかで結果が変わります。手元の実装では W / F / A をまとめて幅2にしていました。実際にどの文字が A に該当するのか、確認してみます。
| 文字 | east_asian_width |
| … | A |
| é | A |
| × | A |
| “ | A |
| ① | A |
é が A です。フランス語やスペイン語の通知文を扱っていたら、アクセント付き文字がすべて幅2で数えられ、その言語だけがさらに短く詰められることになります。ラテン文字のフォントでは、é の実際の描画幅は e とほとんど変わりません。
Ambiguous を幅2として扱う慣習は、日本語や中国語のフォント環境を前提にしたものです。言語ごとに Ambiguous の扱いを変える、というのが結論でした。日本語・中国語・韓国語のロケールでは 2、それ以外では 1。省略記号も同じ理屈で、ラテン文字圏では幅1として数えます。
Ambiguous をすべて幅1に倒して回避する手もありますが、その場合は日本語の全角記号が過小に数えられ、今度は日本語側が予算を超えます。どちらかに寄せる解決策はなく、ロケールで分ける以外に道はありませんでした。
これは公式ドキュメントを読んでいて気づける類の話ではなく、実際にアクセント付きの文字列を通したときに「なぜこの言語だけ短いのか」という形で表に出ました。
絵文字を1つ入れた瞬間、スライスは壊れます
通知に絵文字を添えることがあります。🎨 新作を8枚追加👨👩👧 という文字列を素朴に切ってみました。
| 操作 | 結果 |
| len() | 14 |
| UTF-16 コード単位数 | 18 |
| s[:10] | 🎨 新作を8枚追加👨 |
| s[:11] | 🎨 新作を8枚追加👨 |
s[:11] で、家族を表す絵文字が ZWJ(ゼロ幅接合子)のところで割れました。末尾に不可視の制御文字が残った文字列が通知として配信されます。端末によっては豆腐や意図しない絵文字に化けます。
さらに厄介なのは、len() が 14 なのに UTF-16 のコード単位では 18 だという点です。Kotlin の String.length や Swift の utf16.count、JavaScript の .length はいずれも UTF-16 単位を返します。サーバー側の Python で数えた長さと、クライアント側で数えた長さが一致しません。 長さのチェックをサーバーとクライアントの両方に置いていると、片方だけが通る状態が生まれます。
対策は、切る単位を「コードポイント」ではなく「書記素クラスタ」に上げることです。完全な実装には regex モジュールの \X を使うのが確実ですが、依存を増やしたくなかったので、結合文字・ZWJ・異体字セレクタ・肌の色修飾子を直前の文字に吸着させる近似で済ませました。
Gemini には「短く」ではなく「分けて」返させます
ここまでの整理を踏まえて、Gemini 側の指示を書き換えました。長さの指示をやめ、構造で返させます。
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_API_KEY")
NOTIFICATION_SCHEMA = {
"type": "object",
"properties": {
# 通知として最低限成立する一文。これは絶対に落とさない
"lead": {"type": "string"},
# 余白があれば載せる補足。丸ごと落としても意味が壊れないこと
"detail": {"type": "string"},
},
"required": ["lead", "detail"],
"propertyOrdering": ["lead", "detail"],
}
PROMPT = """次の配信内容から、プッシュ通知の文面を作ってください。
lead: 何が起きたかだけを述べる一文。固有名詞は入れない。
detail: lead を補う情報。作品名や作者名はここに入れる。
detail は削除されても lead だけで意味が通るように書くこと。
言語: {lang}
配信内容: {payload}
"""
def generate_notification(lang: str, payload: str) -> dict:
res = client.models.generate_content(
model="gemini-3.7-flash",
contents=PROMPT.format(lang=lang, payload=payload),
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=NOTIFICATION_SCHEMA,
temperature=0.4,
),
)
import json
return json.loads(res.text)
lead と detail に分けたのは、詰めるときの選択肢を増やすためです。1本の文字列で受け取ってしまうと、収まらないときにできることは「途中で切る」しかありません。2つに分かれていれば、まず detail を丸ごと落とす、という壊れ方の少ない手が使えます。
プロンプトで長さを一切指定していない点も意図的です。長さを指示すると、モデルはそれを守ろうとして情報を削ります。削る判断は、実際の幅予算を知っているこちら側で行った方が正確です。
通知に載せる直前で詰める実装
配信の直前に通す関数です。実際に運用しているものから、アプリ固有の部分を落としたかたちで載せます。
import re
import unicodedata
# 前の文字に吸着させる修飾子: ZWJ・異体字セレクタ・キーキャップ・肌の色・結合記号
_MODIFIER = re.compile(
r"[️︎⃣\U0001F3FB-\U0001F3FF̀-ͯ]"
)
# Ambiguous を幅2で数えるロケール(CJK フォント環境)
_WIDE_AMBIGUOUS_LANGS = {"ja", "zh", "zh-TW", "zh-CN", "ko"}
def grapheme_clusters(text: str) -> list[str]:
"""結合文字を直前の文字に吸着させて、切ってはいけない単位にまとめる。"""
out: list[str] = []
for ch in text:
if out and _MODIFIER.match(ch):
out[-1] += ch
elif out and _MODIFIER.match(out[-1][-1]):
# ZWJ の直後の文字は前のクラスタの一部
out[-1] += ch
else:
out.append(ch)
return out
def display_width(text: str, lang: str) -> int:
wide_ambiguous = lang.split("-")[0] in _WIDE_AMBIGUOUS_LANGS
total = 0
for cluster in grapheme_clusters(text):
head = cluster[0]
eaw = unicodedata.east_asian_width(head)
if eaw in ("W", "F"):
total += 2
elif eaw == "A":
total += 2 if wide_ambiguous else 1
elif ord(head) >= 0x1F000: # 絵文字は Neutral 判定でも 2 幅で描画される
total += 2
else:
total += 1
return total
def cut_to_width(text: str, limit: int, lang: str) -> str:
"""書記素クラスタ単位で、表示幅が limit を超えない位置まで切る。"""
used = 0
out: list[str] = []
for cluster in grapheme_clusters(text):
w = display_width(cluster, lang)
if used + w > limit:
break
out.append(cluster)
used += w
return "".join(out)
def fit(text: str, limit: int, lang: str) -> str:
"""収まっていればそのまま、超えていれば語境界を尊重して詰める。"""
if display_width(text, lang) <= limit:
return text
ellipsis = "…"
body = cut_to_width(text, limit - display_width(ellipsis, lang), lang)
# 空白区切りの言語では、語の途中で切れた場合だけ手前の語境界へ戻す
if " " in body and len(text) > len(body) and not text[len(body)].isspace():
head = body.rsplit(" ", 1)[0]
if head:
body = head
return body.rstrip(" ,.;:、。") + ellipsis
def build_notification(parts: dict, limit: int, lang: str) -> str:
"""lead は必ず残し、detail は入るときだけ足す。"""
joiner = "" if lang.split("-")[0] in _WIDE_AMBIGUOUS_LANGS else " "
terminator = "。" if lang.split("-")[0] == "ja" else "."
lead = parts["lead"].rstrip("。.")
detail = parts.get("detail", "").rstrip("。.")
full = f"{lead}{terminator}{joiner}{detail}{terminator}"
if display_width(full, lang) <= limit:
return full
# detail を丸ごと落とす。これでも収まらないときだけ lead を詰める
lead_only = f"{lead}{terminator}"
if display_width(lead_only, lang) <= limit:
return lead_only
return fit(lead_only, limit, lang)
build_notification の順序が肝です。私はこの順序に落ち着くまで、先に fit を呼ぶ実装を試していました。いきなり fit を呼ばず、まず detail を丸ごと落とす。落として収まるならそれで終わりです。実際のサンプルで確認すると、幅上限 60 に対して結合済みの文面は日本語で幅 61、英語で幅 63 でした。どちらもわずかに超過しています。detail を落とすと日本語は幅 30、英語は幅 31 に収まりました。
省略記号を出さずに済んだ、というのがここでの成果です。読者から見れば「短い通知」であって「壊れた通知」ではありません。
配信前チェックに入れた assert と、最初に書き損じた1つ
実装を直しても、次に文面のテンプレートを変えたときに同じ事故が起きます。配信バッチの直前に、機械的な検査を入れました。
最初に書いたのは、次のような3項目でした。
def assert_notification_safe(text: str, limit: int, lang: str) -> None:
w = display_width(text, lang)
assert w <= limit # 1. 幅予算に収まっている
assert not _MODIFIER.match(text[-1]) # 2. 不可視の制御文字で終わっていない
if text.endswith("…"):
prev = text[-2]
assert not (prev.isascii() and prev.isalpha()) # 3. 語の途中で切れていない
3番目が、正しい出力に対して落ちました。8 new ukiyo-e wallpapers are now available, including… という、語境界できれいに切れた文字列で AssertionError になります。
理由は考えてみれば当たり前で、英語の語は必ずラテン文字で終わります。「省略記号の直前がラテン文字か」という条件では、語の途中で切れた場合と、語の末尾で正しく切れた場合を区別できません。 検査したかったのは文字の種類ではなく、切った位置が元の文字列のどこだったか、でした。
元の文字列を渡す形に直したのが以下です。
def assert_notification_safe(text: str, original: str, limit: int, lang: str) -> None:
# 1. 幅予算に収まっていること
w = display_width(text, lang)
assert w <= limit, f"[{lang}] width {w} > {limit}: {text!r}"
# 2. 不可視の制御文字で終わっていないこと(ZWJ 割れの検出)
assert not _MODIFIER.match(text[-1]), f"[{lang}] broken grapheme at tail: {text!r}"
# 3. 切っているなら、切断点が元の文字列の語境界であること
if text.endswith("…"):
body = text[:-1]
assert original.startswith(body), f"[{lang}] not a prefix of source: {text!r}"
nxt = original[len(body):len(body) + 1]
cut_inside_word = (
nxt.isascii() and nxt.isalpha()
and body[-1].isascii() and body[-1].isalpha()
)
assert not cut_inside_word, f"[{lang}] cut inside a word: {text!r}"
切断点の直後と直前がどちらもラテン文字なら、語の内部で切っています。日本語のように語境界を持たない文字列では、直前が非 ASCII になるので条件が成立せず、素通りします。1つの検査で両方の言語を扱えます。
この書き損じは、実装を書き終えたあとに検査を通して初めて表に出ました。切り詰めの正しさは目視では判定しにくく、正常系にも異常系にも同じ形の文字列が出てくるためです。切り詰め処理を書いたら、正しく切れた文字列を入力にして検査が通ることまで確かめる、という手順を運用に足しました。
もう一点、運用として決めたことがあります。通知のプレビューを日本語ロケールだけで確認しない。 私が最初に見落とした原因はこれでした。今は配信前に両言語の最終文字列をログへ出し、幅の数字と一緒に目で確認しています。
なお、Gemini からの構造化出力そのものが期待した形で返らないケースの扱いは、以前 構造化出力のスキーマ検証エラーを直す にまとめています。本記事の fit は、スキーマ検証を通過したあとの後処理として置く前提です。
次にやること
自分のアプリで多言語の文字列を切っている箇所を1つ選び、そこで使っている単位が「文字数」か「表示幅」かを確認してみてください。text[:n] や substring(0, n) が見つかったら、そこは高い確率でラテン文字側だけが損をしています。
長さの制約をモデルに預けたくなる気持ちは、私自身よく分かります。プロンプトに一行足すだけで済むように見えるからです。ただ、守られたかどうかを確かめる手段が結局こちら側に必要になるのなら、最初から詰める処理をこちら側に置いた方が、壊れ方を選べる分だけ安全でした。
私もまだ、韓国語やアラビア語を足したときに同じ実装で足りるのか、確かめきれていません。共に学んでいけたら嬉しいです。