壁紙アプリの素材を仕分ける自分用のスキルを呼び出したとき、まだ画像を1枚も見ていないのに、最初の返答までの間が以前より長く感じられました。仕分けの手順そのものは変えていません。増やしたのは、混入が多いカテゴリの判定ルールと、マージ後の API 反映手順だけです。
そのスキルを測ってみたら、SKILL.md 1本で約1万トークンありました。仕分けを始める前、つまり画像の話を1つもしていない時点で、それが文脈に載っていたことになります。
8月17日の Claude Code v2.1.234 で、組み込みの claude-api スキルを読み込むときの文脈コストが約20万トークン超から約2.5万トークンへ下がりました。参照ドキュメントを、必要になった時点で読み込む方式へ変えたためです。同じことを自分のスキルでもやるべきだと考えたところまでは、たぶん多くの方と同じだと思います。
問題は、どの節を外に出すかでした。私は最初、一番大きい節から順に出そうとして、間違えました。
呼び出した時点で、まだ何もしていない
Claude Code のスキルは、description だけが常時読み込まれ、SKILL.md の本体は呼び出されたときに読み込まれます。つまり本体のサイズは「常時コスト」ではなく「起動コスト」です。ここを取り違えると、「使っていないスキルを減らせば軽くなる」という方向に対策が向かってしまいます。実際に効くのは、呼び出したときに載る量のほうです。
個人開発で複数のアプリとサイトを並行して回していると、スキルは自然に長くなります。私の場合、壁紙のカテゴリ分類スキルは30カテゴリぶんの判定ルールを抱えていて、そこに「混入が多いカテゴリの厳格ルール」が後から積み上がりました。書いた本人としては、どれも必要だから書いています。だからこそ、感覚で削る判断ができません。
そこで、節ごとの数値を出すところから始めました。
節ごとに測る、小さな監査スクリプト
やることは単純です。SKILL.md を H2 / H3 で分解し、節ごとにトークン数を数え、大きい順に並べます。それだけで、どこに文脈が消えているかは一目で分かります。
Claude のトークナイザは公開されていないため、ここでは tiktoken の cl100k_base を使っています。絶対値としては正確ではありません。節と節の比率を見るための物差しとして使う、という位置づけです。tiktoken が入っていない環境でも動くように、文字種別の概算にフォールバックさせています。
#!/usr/bin/env python3
"""SKILL.md の文脈コストを節単位で測り、切り出し候補を出す。
使い方:
python3 skill_context_audit.py path/to/SKILL.md
python3 skill_context_audit.py path/to/SKILL.md --split out_dir
節の見出し直後に reach 注釈(HTML コメント)を書いておくと、
到達率別の集計と切り出しを行う。注釈がない節は always 扱い。
"""
import argparse, os, re, sys
try :
import tiktoken
_ENC = tiktoken.get_encoding( "cl100k_base" )
def count_tokens (text: str ) -> int :
return len ( _ENC .encode(text))
TOKENIZER = "cl100k_base"
except ImportError :
# tiktoken が無い環境向けの概算。日本語1文字≒0.7token、ASCII 4文字≒1token
def count_tokens (text: str ) -> int :
cjk = sum ( 1 for c in text if ord (c) > 0x 2E80 )
return int (cjk * 0.7 + ( len (text) - cjk) / 4 )
TOKENIZER = "approx"
REACH_RE = re.compile( r "<!-- \s * reach: \s * ([ a-z0-9_ ] + )\s * -->" )
HEADING_RE = re.compile( r " (?m) ^( # {2,3} . * )$ " )
def split_sections (md: str ):
"""(見出し, 見出し込みブロック) の列に分解する。先頭は __head__。"""
parts = HEADING_RE .split(md)
sections = [( "__head__" , parts[ 0 ])]
for i in range ( 1 , len (parts), 2 ):
body = parts[i + 1 ] if i + 1 < len (parts) else ""
sections.append((parts[i].strip(), parts[i] + " \n " + body))
return sections
def reach_of (block: str ) -> str :
m = REACH_RE .search(block)
return m.group( 1 ) if m else "always"
def audit (path: str ):
md = open (path, encoding = "utf-8" ).read()
sections = split_sections(md)
rows = [(h, reach_of(b), count_tokens(b)) for h, b in sections]
total = sum (r[ 2 ] for r in rows)
print ( f "file : { path } " )
print ( f "tokenizer : { TOKENIZER } " )
print ( f "total : { total } tok / { len (md) } chars \n " )
print ( f " { 'tok' :>7 } { 'share' :>6 } reach heading" )
for h, reach, t in sorted (rows, key =lambda r: - r[ 2 ]):
print ( f " { t :7d } { t * 100 / total :5.1f } % { reach :<9 } { h[: 56 ] } " )
by_reach = {}
for _, reach, t in rows:
by_reach[reach] = by_reach.get(reach, 0 ) + t
print ( " \n -- 到達率別 --" )
for reach, t in sorted (by_reach.items(), key =lambda kv: - kv[ 1 ]):
print ( f " { reach :<10 } { t :6d } tok ( { t * 100 / total :4.1f } %)" )
resident = by_reach.get( "always" , 0 )
print ( f " \n 常駐(always)だけを本体に残した場合の起動時コスト: { resident } tok"
f "( { ( 1 - resident / total) * 100 :.1f } % 削減)" )
return rows, total
出力はこうなります。実際に運用している壁紙のカテゴリ分類スキルに通した結果です。
file : .claude/skills/wallpaper-category/SKILL.md
tokenizer : cl100k_base
total : 10048 tok / 14372 chars
tok share reach heading
980 9.8% always ### 1. バッチ処理(レートリミット対策付き)
849 8.4% always ### 自動マージ(100枚ごと)
755 7.5% always __head__
596 5.9% always ## カテゴリの相互排他ルール(重複禁止の組み合わせ)
586 5.8% always ### 3. APIカテゴリへの自動適用(マージ後に必ず実行)
481 4.8% always ### ⚠️ 3D — 厳格ルール(混入多発カテゴリ)
458 4.6% always ### ⚠️ タイポグラフィ — 厳格ルール(重複・混入多発カテゴリ)
14,372 文字で約 10,048 トークンです。日本語主体の文書なので、文字数から受ける印象よりトークンは重くなります。1文字あたり 0.7 トークン前後という比率は、日本語で SKILL.md を書いている方なら覚えておいて損はありません。行数で管理していると、この重さは見えません。
行数を基準にした管理については、以前 SKILL.md の後半が読まれていなかった話 でも書きました。今回はその続きにあたります。行数で上限を決めたあと、では具体的にどの節を外へ出すのか、という段です。
一番大きい節は、切り出してはいけない節でした
計測結果を見て、私が最初に手を伸ばしたのは判定ルール群でした。30カテゴリの分類基準が27個の節に分かれていて、合計 4,151 トークン。全体の 41.3% です。相互排他ルールの 596 トークンを足せば、半分近くがここに集まっています。
ここを references に出せば、起動コストは半分になる計算でした。実際に出してみて、すぐに戻しました。
分類作業は、画像を1枚ずつ見て、そのつどカテゴリを決める作業です。判定ルールは、その全区間で参照されます。外に出すと、Claude は結局それを読みます。しかも一度で終わりません。私のスキルは100枚ごとにマージを挟むので、バッチをまたぐたびに読み直しが起きます。起動時に1回載せるはずだった 4,151 トークンが、読み込みの往復に分散して、合計では増えるだけでした。
体感としても、最初の応答が速くなった代わりに、作業中の「少し待つ」が増えました。合計時間はむしろ延びていたと思います。
ここで気づいたのは、切り出しの判断基準に大きさを使っていたこと自体が間違いだった ということです。大きさは、切り出したときの効果の上限を示すだけで、効果そのものを示していません。
到達率と参照回数、2つの軸で分ける
代わりに使うことにしたのは、次の2つの軸です。
軸 問い 判定の目安
到達率 この節は、今回の実行で必ず読む段に入るか 条件付きの段・別セッションの段は到達率が低い
参照回数 その段に入ったあと、何回参照するか 1回読んで終わるか、作業中ずっと見るか
この2軸で4つに分かれます。
参照回数が多い 参照回数が少ない
必ず到達する 本体に残す(切り出すと悪化する) 本体に残す(切り出しても効果が小さい)
到達しないことがある 切り出す(読む条件を明記する) 切り出す(最も効果が大きい)
判定ルール群は「必ず到達する × 参照回数が多い」なので、本体に残すのが正解でした。逆に、マージと API 反映の手順は「100枚たまったときだけ」入る段です。1回の実行で到達しないことがあり、到達しても手順を1回読めば足ります。ここが本命でした。
節の見出しの直後に、次のように到達率の注釈を書いておきます。
## マージ(結合)ルール
<!-- reach: merge -->
100枚たまったら、既存のカテゴリリストへ結合する。
注釈のない節は always として本体に残ります。既存の SKILL.md にあとから足せる形にしたかったので、注釈を書いた節だけが動く仕様にしています。
実際に切り分けた結果
切り出しは、同じスクリプトの --split で行います。本体から該当節を抜き、references/{reach}.md へ書き出し、本体には「いつ読むか」だけを残します。
def split_out (path: str , out_dir: str ):
md = open (path, encoding = "utf-8" ).read()
sections = split_sections(md)
os.makedirs(os.path.join(out_dir, "references" ), exist_ok = True )
body, buckets = [], {}
for h, block in sections:
reach = reach_of(block)
if reach == "always" :
body.append(block.rstrip())
else :
buckets.setdefault(reach, []).append(block.rstrip())
for reach, blocks in buckets.items():
ref_path = os.path.join(out_dir, "references" , f " { reach } .md" )
with open (ref_path, "w" , encoding = "utf-8" ) as f:
f.write( " \n\n " .join(blocks) + " \n " )
# 本体には「いつ読むか」だけを残す。これを書かないと参照は読まれない
body.append(
f "## 参照: { reach }\n\n "
f " { reach } の段に入ったら、その時点で次を読み込んでください。 \n\n "
f "```bash \n cat references/ { reach } .md \n ```"
)
main_path = os.path.join(out_dir, "SKILL.md" )
with open (main_path, "w" , encoding = "utf-8" ) as f:
f.write( " \n\n " .join(body) + " \n " )
before = count_tokens(md)
after = count_tokens( open (main_path, encoding = "utf-8" ).read())
print ( f " \n{ main_path } : { before } -> { after } tok "
f "(起動時 { ( 1 - after / before) * 100 :.1f } % 減)" )
for reach in buckets:
p = os.path.join(out_dir, "references" , f " { reach } .md" )
print ( f " references/ { reach } .md: { count_tokens( open (p, encoding = 'utf-8' ).read()) } tok" )
2本のスキルに通した結果です。
スキル 切り出し前 本体(切り出し後) 参照へ移した量 起動時の削減
壁紙カテゴリ分類 10,048 tok 8,448 tok merge 1,530 / rare 178 16.0%
浮世絵素材の一括処理 5,252 tok 4,597 tok postupload 705 12.5%
正直なところ、期待したほどの数字ではありません。半分にできると思っていたものが、16.0% と 12.5% です。
けれども、この 16% は「今回の実行では絶対に使わない部分」を落とした 16% です。読み直しの往復が増えないぶん、削った量がそのまま効きます。一方、最初にやろうとした 41.3% の切り出しは、削減量がそのまま往復コストへ移し替わるだけでした。数字の大きさと、効き方は別物だと考えるようになりました。私はこの結果を見てから、切り出す前に必ず到達率のほうを先に決めるようにしています。
参照ファイルは、読む条件を書かないと読まれません
切り出しで最初に失敗したのはここでした。本体から節を消して references/merge.md に移し、本体には「詳細は references/merge.md を参照」とだけ書きました。結果、Claude はその参照を読まないまま、マージの手順を推測で進めました。100枚ごとという条件も、API 反映を必ず行うという前提も、参照の中にしか書いていなかったからです。
本体に残すべきなのは、参照ファイルの場所ではなく、読むべき状態の説明 でした。
## 参照: merge
分類済みの画像が100枚たまった段に入ったら、その時点で次を読み込んでください。
マージと API 反映の手順は、この参照の中にしかありません。
```bash
cat references/merge.md
```
「詳細はこちら」ではなく「どういう状態になったら読むのか」を書く。これだけで挙動が安定しました。progressive disclosure という言葉は「分割」に意識が向きがちですが、実際に効くのは分割ではなく、残したほうに条件を書くこと だと感じています。
切り出しを実際にやってみると、細かいところでいくつかつまずきました。三つだけ書いておきます。
見出しの階層ごとにタグが要る
私のスクリプトは H2 と H3 を同列の節として扱います。そのため ## マージ(結合)ルール にだけ到達率の注釈を書いても、その下にぶら下がる ### 自動マージ(100枚ごと) や ### API への自動適用 は本体に残ります。壁紙のスキルでは結局、関係する見出し4つすべてに注釈を書きました。
階層をたどって子見出しも一緒に動かす実装のほうが親切ですが、私はあえてそうしていません。親の見出しだけを外に出して子を残す、という分け方をしたい場面が実際にあったからです。手間は増えますが、意図しない移動が起きないほうを選びました。
参照ファイルの冒頭に、その段の目的を1行だけ書く
本体から切り離された参照ファイルは、読まれた時点では前後の文脈を持っていません。手順だけが並んでいると、なぜその手順なのかが失われます。私は各参照ファイルの先頭に「この段は何を終わらせるための段か」を1行だけ置くようにしました。1行ぶんのトークンで、参照を読んだあとの振る舞いが安定します。
同じ参照を2回読んだら、本体へ戻す
これが今のところ一番はっきりした運用ルールです。ある参照が1回の実行で2回以上読まれているなら、その節は「到達しないことがある節」ではなく「作業中ずっと必要な節」だったということです。判定ルール群を本体へ戻したのは、まさにこの理由でした。切り出しは一度決めたら終わりではなく、実際の読み込み回数を見て戻す判断も込みで運用することをお勧めします。
複数のスキルを組み合わせて動かす場合の受け渡し設計は、Claude Code Skills を組み合わせる設計術 のほうにまとめています。参照ファイルの読み込みは、その最小版だと考えると整理しやすいかもしれません。
セッションが分かれる作業は、スキルごと分ける
浮世絵の素材処理スキルでは、もう一段別の判断が出てきました。
このスキルは、素材を受け取って派生画像を生成し、カテゴリと API を更新し、通知文を用意して私に引き渡すところまでが前半です。後半は、私が実際にサーバーへアップロードしたあとで、本ツリーへ統合し、実応答を確認し、ステージングを削除します。あいだに人間の作業が挟まるので、前半と後半は必ず別のセッションになります。
つまり後半の 705 トークンは、前半のセッションでは「到達率ゼロ」です。references に出せば起動コストからは消えますが、後半のセッションでは今度は毎回読むことになります。それなら、はじめから2本のスキルに分けたほうが素直です。
分け方 向いている状況
references に切り出す 同じセッション内で、条件が成立したときだけ入る段
スキルごと分ける 人間の作業や外部の処理を挟み、セッションが必ず切れる段
次の素材投入から、この2本立てに切り替える方針にしました。references への切り出しを覚えると何でも参照にしたくなりますが、セッションが切れる境目は、スキルの境目でもあるのだと思います。
明日から測るなら
まず、いま一番よく呼び出しているスキル1本を、このスクリプトに通してみてください。上位5節だけ眺めれば、自分が何に文脈を使っていたかは分かります。
そのうえで、切り出す候補を選ぶときは「大きいから」ではなく「今回の実行で到達しないことがあるか」で選んでみてください。私の場合、その基準に変えただけで、切り出して失敗する節と、切り出して効く節がはっきり分かれました。
文脈の上限そのものへの対処は、Claude Code のコンテキスト上限に実用的に対処する5つのアプローチ にまとめてあります。スキル側を軽くしても足りない場合は、そちらと組み合わせることになります。
私自身、スキルを長くしすぎる癖はまだ直っていません。測る手段を持ったぶん、次に長くなったときは気づけるようになった、という段階です。お読みいただきありがとうございました。