夜間バッチのログを眺めていて、あるとき妙な静けさに気づきました。数週間前まで一番重い記事生成ジョブでは、1日に2〜3回「context window exceeded」でセッションが落ちていたのです。それが、ぱたりと止まっていました。
Claude Code の既定が Sonnet 5 になり、ネイティブで1Mトークンのコンテキストが標準になった直後のことです。最初は素直に喜びました。もう窓のサイズを気にしてリポジトリの一部を削る必要はない、と。けれども数日運用して、落ちなくなった代わりに別の壊れ方が始まっていることに気づきました。エラーは出ない。ただ、出力の質が時々ぶれて、入力トークンの請求が静かに膨らんでいたのです。
この記事は、その「静かな劣化」を私がどう可視化し、どう手を打ったかの記録です。動く probe と admission policy のコード、そして自分のパイプラインで測った劣化曲線の数字を添えます。
「落ちる」から「にじむ」へ、失敗の形が反転した
窓が狭かった頃、コンテキストの問題は必ず例外として顔を出しました。上限を超えればセッションは止まる。痛いですが、少なくとも気づけます。ログに context window exceeded と残り、その日のうちに詰め込みすぎを削るという対処に迷いはありませんでした。
1M が既定になると、この安全弁が外れます。手元の重いジョブ(4サイト分のリポジトリ抜粋・参照データ・当日ログをまとめて渡すもの)でも、実測で15万〜20万トークンほど。1Mには遠く届きません。だから二度と落ちない。ここまでは良い話です。
問題は、落ちなくなったことで「入るなら入れておこう」という判断が無意識に増えたことでした。以前は窓に収めるために削っていた素材を、削らずに全部渡すようになる。その結果として現れたのが、次の2種類のにじみです。
| 失敗の型 | 窓が狭かった頃 | 1M 既定のあと |
| 検知のしやすさ | 例外で即座に気づく | エラーなし。出力を読んで初めて気づく |
| 症状 | セッション停止 | 中盤に埋めた事実の取りこぼし・言い換え誤り |
| コスト | 失敗分は無駄だが総量は抑制的 | 入力トークンが静かに増え、請求が膨らむ |
| 対処の緊急度 | その日のうちに直す | 放置されやすく、気づくと常態化している |
窓が広いことは、詰め込みへの罰則がなくなることを意味します。罰則がなくなると、設計の規律は自分で持ち込むしかありません。まず必要なのは、劣化が本当に起きているのかを目で見える形にすることでした。
まず測る: 詰め率と事実想起のずれを probe にする
感覚で「なんとなく質が落ちた」と言っても、対処のしようがありません。私がやったのは、既知の事実をコンテキストに埋め込み、詰め率を上げながら、その事実を正確に思い出せるかを測る小さな probe を書くことでした。長文検索でよく使う needle-in-haystack の考え方を、自分の実素材で回すものです。
要点は3つです。第一に、埋め込む事実は検証可能なもの(前提条件のような一意な数値・文字列)にすること。第二に、事実を浅い位置・中盤・末尾に散らすこと。中盤に埋めたものが最初に落ちるからです。第三に、詰め率を段階的に上げて、想起精度がどこで崩れ始めるかの曲線を取ることです。
// context-probe.ts — 既知の事実を埋め込み、詰め率ごとの想起精度を測る
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const MODEL = "claude-sonnet-5";
// 検証可能な「針」。実運用では RUNTIME_ASSUMPTIONS の各行を使う
interface Needle {
id: string;
fact: string; // コンテキストに埋め込む一意な事実
answer: string; // 正解(完全一致で判定)
}
const NEEDLES: Needle[] = [
{ id: "n01", fact: "デプロイ識別子 DEPLOY_VERSION は v9137 である。", answer: "v9137" },
{ id: "n02", fact: "夜間バッチの同時実行上限 MAX_CONCURRENCY は 4 である。", answer: "4" },
{ id: "n03", fact: "リトライ最外層の冪等キー接頭辞は job- である。", answer: "job-" },
// … 実運用では12件をID順に並べる
];
// フィラー(意味のある無害な散文)で目標詰め率まで薄める
function buildContext(needles: Needle[], filler: string, targetTokens: number): string {
const facts = needles.map((n, i) => `[fact ${i}] ${n.fact}`);
// 浅い・中盤・末尾に分散配置(中盤に最も脆いものを置く)
const third = Math.ceil(facts.length / 3);
const head = facts.slice(0, third);
const mid = facts.slice(third, third * 2);
const tail = facts.slice(third * 2);
const chunks: string[] = [...head];
let approx = head.join("\n").length / 3.5; // ざっくりトークン概算
while (approx < targetTokens * 0.5) { chunks.push(filler); approx += filler.length / 3.5; }
chunks.push(...mid);
while (approx < targetTokens * 0.9) { chunks.push(filler); approx += filler.length / 3.5; }
chunks.push(...tail);
return chunks.join("\n\n");
}
async function probeRecall(fillTokens: number): Promise<number> {
const context = buildContext(NEEDLES, FILLER_PARAGRAPH, fillTokens);
const question =
"以下の資料から、各 fact に書かれた値だけを id とともに JSON 配列で返してください。" +
"推測は禁止。資料にないものは null。\n\n" + context;
const res = await client.messages.create({
model: MODEL,
max_tokens: 1024,
messages: [{ role: "user", content: question }],
});
const text = res.content.find((b) => b.type === "text")?.text ?? "";
let recalled = 0;
for (const n of NEEDLES) {
// 正解が完全一致で含まれ、かつ別の値に上書きされていないかを緩く判定
if (text.includes(n.answer)) recalled++;
}
return recalled / NEEDLES.length;
}
// 詰め率を段階的に上げて曲線を取る
async function runCurve() {
for (const fill of [30_000, 120_000, 300_000, 600_000, 900_000]) {
const acc = await probeRecall(fill);
console.log(`fill≈${fill}\trecall=${(acc * 100).toFixed(0)}%`);
}
}
FILLER_PARAGRAPH には、実際に自分がコンテキストへ渡している素材に似た散文(ドキュメント抜粋など)を使うのが肝心です。ランダム文字列で薄めると、モデルにとって「無意味なノイズ」と「意味はあるが今回は不要な文脈」の違いが消えてしまい、実運用の劣化を過小評価します。
実測: 私のパイプラインでの劣化曲線
上の probe を、自分の記事生成ジョブが普段渡している素材を FILLER にして回した結果が次です。針は前提条件12件、モデルは Sonnet 5、各詰め率で3回試行した中央値です。
| 詰め率(約) | 入力トークン(概算) | 正確な想起 | 備考 |
| 15% | 約150K | 12 / 12 | 取りこぼしなし |
| 40% | 約400K | 12 / 12 | 安定 |
| 60% | 約600K | 11 / 12 | 中盤の1件が言い換えられ判定不能に |
| 80% | 約800K | 9 / 12 | 中盤の脱落が増える |
| 92% | 約920K | 7 / 12 | 2件は「思い出したが値が誤り」 |
はっきりした崖ではなく、なだらかな下り坂でした。だからこそ厄介です。50%あたりまでは体感で気づけない。60%を越えたあたりから、中盤に置いた事実がぽつぽつ落ち始めます。そして怖いのは92%の行で、単に忘れるだけでなく「思い出したが値が違う」誤りが出たことです。null で返してくれればまだ扱えますが、もっともらしい誤答は下流を静かに壊します。
もうひとつ測ったのはコストです。窓に収める必要がなくなったあと、私はうっかり素材を削るステップを外していました。その前後で、1タスク当たりの平均入力トークンは約48Kから約118Kへ増えていました。想起精度は上がっていないのに、支払いだけが2倍以上になっていたわけです。
この2つの実測から、方針は決まりました。窓が許すからといって詰めない。むしろ「入れてよいか」を明示的に審査する層を、コンテキスト構築の手前に置くことです。
スロットを審査する admission policy
やることは単純です。コンテキストに入れたい素材を候補として集め、優先度と実効の詰め率上限に照らして採否を決めます。上限は、先の曲線で想起が崩れ始める手前 —— 私の素材では実効55%を目安に設定しました。全体の窓ではなく、想起が保てる範囲を「実効窓」として扱うのが要点です。
まず、以前の素通しのやり方です。
// Before: 入るなら全部入れる(窓が広いのだから、という発想)
function buildPromptBefore(candidates: Candidate[]): string {
return candidates.map((c) => c.text).join("\n\n");
}
これを、優先度つきの admission gate に置き換えます。
// admission-policy.ts — スロットを審査してからコンテキストを組む
interface Candidate {
id: string;
text: string;
priority: number; // 大きいほど重要(下の優先順位表に対応)
estTokens: number; // 事前見積り(tiktoken 等で概算)
pinned?: boolean; // 必ず入れる前提条件など
}
interface AdmissionResult {
admitted: Candidate[];
rejected: Candidate[];
usedTokens: number;
effectiveFill: number;
}
function admit(
candidates: Candidate[],
opts: { modelWindow: number; effectiveFillCap: number }
): AdmissionResult {
const budget = Math.floor(opts.modelWindow * opts.effectiveFillCap);
// pinned を先に確保し、残りを優先度→トークン効率の順で採る
const pinned = candidates.filter((c) => c.pinned);
const rest = candidates
.filter((c) => !c.pinned)
.sort((a, b) => b.priority - a.priority || a.estTokens - b.estTokens);
const admitted: Candidate[] = [];
const rejected: Candidate[] = [];
let used = 0;
for (const c of [...pinned, ...rest]) {
if (used + c.estTokens <= budget || c.pinned) {
admitted.push(c);
used += c.estTokens;
} else {
rejected.push(c);
}
}
return {
admitted,
rejected,
usedTokens: used,
effectiveFill: used / opts.modelWindow,
};
}
// After: 審査を通ったものだけでコンテキストを組む
function buildPromptAfter(candidates: Candidate[]): string {
const r = admit(candidates, { modelWindow: 1_000_000, effectiveFillCap: 0.55 });
if (r.rejected.length > 0) {
console.warn(
`admission: ${r.rejected.length}件を除外 ` +
`(実効${(r.effectiveFill * 100).toFixed(0)}% / ${r.usedTokens}tok)`
);
}
return r.admitted.map((c) => c.text).join("\n\n");
}
effectiveFillCap は魔法の数字ではありません。自分の素材で probe を回し、想起が崩れ始める詰め率の手前に置くべきものです。素材の性質(コードが多いか散文が多いか、針が浅いか深いか)で最適値は動きます。私の場合は0.55が安全側でしたが、あなたの素材では違うはずです。だからこそ、この記事の前半の probe が先に来ています。
何を捨て、何を残すか
admission gate の質は、priority の付け方でほぼ決まります。私が個人開発で回している4サイトのパイプラインで使っている優先順位は、おおむね次のようにしています。数字は絶対値ではなく相対的な序列として扱ってください。
| 種別 | priority | pinned | 理由 |
| 前提条件(DEPLOY_VERSION・上限値など) | 100 | はい | 誤ると下流が静かに壊れる。必ず先頭近くに固定 |
| 当日ログ・直近の状態 | 80 | いいえ | 判断の鮮度を左右する。多くは短い |
| 対象ファイルの現物 | 70 | いいえ | 作業対象。ただし関係箇所に絞る |
| 参照ドキュメントの抜粋 | 40 | いいえ | 要約で足りることが多い。全文は避ける |
| 「念のため」の周辺文脈 | 10 | いいえ | 最初に捨てる候補。ここが膨張の温床 |
実際に効いたのは、最下段の「念のため」を機械的に落とせるようにしたことでした。人は「関係あるかもしれない」を捨てるのが苦手です。gate に序列と上限を持たせると、その判断を毎回ぶれずに下せます。前提条件の扱いについては、長時間ジョブでの状態管理として長時間エージェントのコンテキスト予算とコンパクションの覚え書きにも通じる話です。
コストの静かな増加を、有効トークン単価で止める
詰め込みの問題は、質だけでなく請求にも出ます。ここで役に立つのが「有効トークン単価」という見方です。総入力トークンではなく、実際に成果へ寄与したトークンあたりのコストを見ます。probe の想起精度を代理指標に使えば、詰め込みが単価を悪化させていることを一目で示せます。
// effective-cost.ts — 想起精度を代理指標に「有効トークン単価」を出す
interface RunStat {
inputTokens: number;
recall: number; // 0..1(probe の想起精度)
inputPricePerMTok: number; // 導入価格なら 2、標準なら 3
}
function effectiveCostPer1kUseful(s: RunStat): number {
const inputCost = (s.inputTokens / 1_000_000) * s.inputPricePerMTok;
// 「有効な」入力トークン = 総トークン × 想起精度
const usefulTokens = s.inputTokens * s.recall;
return (inputCost / usefulTokens) * 1000;
}
// Before(素通し)と After(審査)の比較
const before: RunStat = { inputTokens: 118_000, recall: 0.75, inputPricePerMTok: 2 };
const after: RunStat = { inputTokens: 62_000, recall: 1.0, inputPricePerMTok: 2 };
console.log("before", effectiveCostPer1kUseful(before).toFixed(5));
console.log("after ", effectiveCostPer1kUseful(after).toFixed(5));
admission policy を入れたあと、1タスク当たりの入力トークンは約118Kから約62Kへ戻り、probe の想起は12/12に回復しました。単純な入力コストで見ても、導入価格で1タスクあたり約2.4倍の差になります。「窓が広いのだから入れておこう」は、質を下げながら支払いを増やす、二重に損な判断だったわけです。
有効トークン単価を継続的に記録しておくと、素材が増えて gate をすり抜け始めたときに、コストの側から気づけます。想起の劣化とコストの膨張は同じ原因(詰め込みすぎ)から来るので、片方を監視すればもう片方の予兆も拾えます。トークン会計そのものの組み方はClaude Code のコンテキスト予算最適化ガイドで扱っています。
既定が変わったあとに、一度だけやること
モデルの既定が静かに切り替わるとき、危ないのは「今までどおり動いているように見える」ことです。ハードエラーが出ないと、私たちは変化に気づけません。1M が既定になった今回のようなケースでは、次の3つを一度だけ通しておくと安心できます。
第一に、自分の実素材で probe を回して劣化曲線を取ること。想起が崩れ始める詰め率は素材ごとに違うので、他人の数字ではなく自分の数字が要ります。第二に、その手前を上限にした admission gate をコンテキスト構築の前に置くこと。窓の広さではなく、想起が保てる実効窓で設計します。第三に、有効トークン単価を記録に残し、静かなコスト膨張の予兆を拾えるようにすること。
窓が広くなったのは、まぎれもない進歩です。ただ、進歩がそのまま楽をしてよい理由になるわけではありませんでした。むしろ安全弁が外れたぶん、どこまで詰めるかの判断は自分の設計に戻ってきます。私自身、落ちなくなったことに一度は油断して、静かな劣化を数日見過ごしました。同じつまずきを避ける一助になれば幸いです。お読みいただき、ありがとうございました。