.cursor/rulesだけでスタイルを守ろうとして崩壊した — プロンプト・few-shot・lintの3層設計
AI主体で執筆ブログの文体ルールを.cursor/rules/blog-writing-style.mdcに書き足していく運用をしばらくやっていたら、記事を書くたびに守られないルールが増えていきました。rulesに書けば書くほど効きが悪くなるというのが体感で、ルールを足すほど違反も増えていく、という逆の動きです。そもそも「プロンプトに書く」というやり方だけで文体を整えようとしたのが見当違いで、プロンプト(rules)・few-shot(自分の過去記事を参照させる)・lint(機械検出)の3層に役割分担すべきだった、という結論に至りました。3層設計の中身と、それぞれの層が何を受け持つかの記録です。
rulesに書けば書くほど守られなくなった
最初のやり方はシンプルで、.cursor/rules/blog-writing-style.mdcという1ファイルに「ブログ記事の書き方」をまとめていました。想定読者・語り手の立ち位置・NG語彙・NG構文・太字の扱い・文体(です・ます調)・段落の組み立て方、といった項目を気付いたそばから追記していました。
最初の数項目のうちは機能していました。NG語彙を追加するとその語は出てこなくなる。想定読者を明記すると記事の入り方が揃う。ルールを書いた分だけ品質が上がる、という素直な感覚です。
ところが追記を重ねて本数が増えるにつれ、違反を見つける頻度が上がりました。書き出した記事にNG語彙として登録済みの漢語表現が戻ってくる。英語の箇条書きを直訳したような構文(「狙いは3つ」型)が戻ってくる。だ・である調に無断で切り替わる。ルールを足すほど違反も増えるという逆の動きで、当初の仮説「ルールを書けば守られる」が壊れました。
rulesに書くだけでは守られない3つの理由
なぜそうなるのか原因を整理したら、プロンプトに書くという手段そのものに限界があるという話でした。AIにrulesを渡せば守ってくれるはず、という前提自体がナイーブでした。
理由1:長大化するとAIが全部読めない/重みを付けられない
rulesファイルが短いうちは、冒頭から末尾までフラットに参照されます。ところが本数が増えると、AIは重要度の重み付けを自分でやらなければならず、どの項目を最優先で守るかが曖昧になります。実測で、blog-writing-style.mdcが100行を超えたあたりから「前半の原則は守られるが、後半の細則が無視される」傾向が強くなりました。
人間のレビュアーでも同じで、A4で4ページのスタイルガイドを渡されて「全部守って書いて」と言われたら、無意識に優先順位を付けて半分くらいのルールは素通ししてしまいます。AIも同じ反応です。
理由2:抽象ルールは解釈が揺れる
rulesに書ける文言には限界があります。例えば「自然な日本語で書く」「ビジネス書調を避ける」「英語の翻訳調を避ける」といった抽象ルールは、人間なら感覚で分かるのですがAIにとっては解釈が揺れます。
具体的には、「自然な日本語」を「文法的に正しい日本語」と解釈すれば、「情報を圧縮する」「材料を持つ」「問題に落ちる」のような抽象名詞×動作動詞の直訳も通ります。文法は正しいからです。本当に欲しかった「個人ブログ読者に届く日本語」という定義はrulesの文面からは読み取れません。
この差を埋めるには、抽象ルールだけでなくOK例とNG例の具体を大量に見せる必要があります。テキストの形容で定義するのではなく、事例で定義するやり方です。
理由3:AIの初期化バイアスはプロンプトでは振り切れない
モデル側の初期化デフォルトには強い引きがあります。Opus 4.7でブログ記事を書かせると、何も指定しなくても「だ・である調」「抽象名詞の多用」「エッセイ/ビジネス書寄りのトーン」に引き寄せられます。学習データの分布がその方向に厚いためだと推測しています。
この引きは、プロンプトに「個人ブログの温度感で」「です・ます調で」と書き足しても完全には打ち消せません。守られる項目と守られない項目があり、セッションによっても揺れます。rulesで守らせる前に、そもそもモデルの出力が初期化バイアスに引き戻されていないかを出力後に検出する仕組みが必要でした。
3層設計に組み替えた
プロンプト(rules)だけに頼るのをやめて、プロンプト・few-shot・lintの3層で役割分担するように運用を組み替えました。
| 層 | 実体 | 受け持つ範囲 | 限界 |
|---|---|---|---|
| L1:プロンプト | .cursor/rules/blog-writing-style.mdc |
原則・想定読者・判断基準・言い換え方針 | 抽象ルールは解釈が揺れる/長大化すると重みが散る |
| L2:few-shot | 既存の良い記事群(5〜10本) | 文末のリズム・段落の長さ・語彙選び・太字の使い方 | サンプルが古いと時代に合わないトーンを固定する |
| L3:lint | scripts/blog-lint.shのようなgrep校正 |
NG語彙・NG構文・文体混在・太字数といった機械判定できる項目 | 文型・段落リズム・反復パターンは拾えない |
3層は「1層で全部守らせる」発想ではなく、同じ違反を3つの角度から拾い直すための重ね合わせです。プロンプトを読み飛ばしても、few-shotが具体例で引き戻します。few-shotに引っ張られても、lintが機械的にNGを弾きます。どこか1層が効かなくても、残りの2層で救済できる組み方になっています。
各層の役割を具体化する
L1:プロンプト(rules)に何を書き、何を書かないか
rulesには抽象原則と判断基準だけを書きます。具体例や語彙の網羅はここでやりません。
- 書くこと:想定読者・語り手の立ち位置・lede 3要素(目的・問題・アプローチ)・文体(です・ます調)・強調の使い方の原則
- 書かないこと:NG語彙の長大リスト・全ての構文パターン・文字数ルール(これらはL3のlintに寄せる)
rulesを短く保つと、AIが全体を読んで重み付けできる範囲に収まります。60d.devでは、blog-writing-style.mdcを200〜400行に収めて、それ以上はlintへ切り出すことにしています。
L2:few-shotは「自分の過去記事」から選ぶ
few-shotには、自分が「自然だ」と判定できた記事を5〜10本並べます。外部の記事を借りてきてもトーンが合わないので、必ず自分の手元のリポジトリから選びます。
60d.devでは、.cursor/rules/blog-writing-style.mdc末尾に「few-shotサンプル記事」としてHTMLファイルへのパスを列挙しています。AIに「執筆時はこのパスを読み込んで文体を合わせて」と指示する運用です。
## few-shot サンプル
以下の記事を文体の基準として参照する(読み込んでから書き始める):
- blog/after-app-store-approval/index.html
- blog/cursor-rules-evolve-per-pr/index.html
- blog/60d-memo-v1-released/index.html
few-shotの更新は年に2〜3回が目安です。新しい記事で「自然だな」と自分が感じた記事が増えたら、古いサンプルと差し替えます。サンプルが古いまま固定されると、時代遅れのトーンが学習されて新規記事にも残ります。
サンプル選定で気を付けているのは、ジャンルを意図的に散らすことです。技術Tips系・運用ログ系・リリースノート系・メタ記事系、といった分布で選ぶと、AIが「どの記事を真似するか」をタスクに応じて自動判断してくれます。
L3:lintで機械的に拾えるものは全部拾う
lintはscripts/blog-lint.shのようなシェルスクリプトで、grepベースでNG語彙・NG構文・文体混在・太字数などを検出します。エラー0件でないとPRは開けない、というルールで運用しています。
機械で拾えるものは全てここに寄せる方針です。rulesにNG語彙の長大リストを書くのではなく、lintの配列に登録しておく。rulesに「英語直訳の箇条書き構文を避ける」と書くのではなく、lintに正規表現を登録しておく。プロンプトでお願いベースで守らせるより、出力後に機械的に弾くほうが堅牢で、しかもrulesを短く保てます。
注意点は、lintは単発パターンまでしか拾えないことです。段落単位のリズム・記事全体でのパターン反復・抽象名詞×動作動詞の組み合わせといった層はgrepでは届きません。そこはスタイルガイド(L1)+セルフチェックの推敲(L2/L3の中間)で対処します。
運用フロー:書く→few-shot参照→lint→推敲
3層を組み替えたあとの実運用はこんな順番です。
- 執筆(L1+L2):Cursor Agentが
blog-writing-style.mdc(rules)とfew-shotサンプル記事を読み込んだ状態で初稿を書く - 機械検出(L3):
./scripts/blog-lint.sh blog/my-slug/を走らせて、NG語彙・NG構文・文体混在・太字濫用をエラー0件まで潰す - セルフ推敲(独立ターン):lintでは拾えない「3症状」(抽象名詞×動作動詞の直訳・同一パターンの記事内反復・短文の切断)を別ターンのAIで洗い出して書き直す
- PRレビュー:セルフ推敲後の差分を人間が見て、最終的な読み味を確認する
ポイントは、3と1を同じターンでやらないことです。書いたAIに「ついでに推敲して」と頼むと、自分の表現を守りに入って効きが悪くなります。推敲は別ターン/できれば別セッションで走らせると、第三者の目で見直す動きに近づきます。
3層にしてから何が変わったか
運用を組み替えてから、次のような変化がありました。
- rulesのファイルが短くなった(350行前後で安定)
- NG語彙・NG構文はlintがエラーで弾くので、rulesから該当節を丸ごと削除できた
- few-shotを最新の自然な記事で入れ替えるだけで、文体の更新が効くようになった(rulesを書き換えなくてよい)
- 違反が出たときに「どの層で拾えなかったか」が切り分けられるようになった:
- 語彙・構文 → lintに追加
- 抽象な判断ズレ → rulesに原則を追記、またはfew-shotを差し替え
- 段落リズム → セルフ推敲の独立ターンで対応
- 新しいモデル(Opus 4.7から次の世代に切り替わったとき)でも、few-shotとlintが同じなら再現性が保たれる、という期待
逆にL1だけで直そうとしていた頃は、違反が出てもrulesに項目を追記するしかなく、ファイルが肥大化する一方でした。ルールを100項目書いても守られない、という状態から抜け出せたのが3層化の一番大きな効果です。
汎用的な教訓として
AIの出力を制御したいとき、最初に手が伸びるのはプロンプト(rules / instruction / system message)ですが、プロンプト1層で全部守らせる設計は必ず破綻します。原因は大きく3つあって、長大化で重み付けが散ること、抽象ルールの解釈が揺れること、モデルの初期化バイアスを振り切れないことのどれかに引っかかります。
対処は「手段を増やす」ことです。プロンプトは原則だけに絞って短く保ち、具体例はfew-shotで、機械判定できるNGはlint(後処理)で、というように役割を重ねて補い合う形にします。今回の文体制御だけでなく、AIに何かを守らせたいときの一般的な処方箋として効きそうだ、と考えています。
次に試したいのは、セルフ推敲の独立ターンをどこまで自動化できるかです。「lint後にセルフ推敲を1ターン走らせる」をscripts/blog-selfcheck.shのような形で半自動化できれば、人間のレビュー前に3症状までは機械側で潰せる、という将来像を描いています。