個人開発で4つの技術ブログを自動運用していると、エージェントに「公開済みの記事一覧を見せて」と頼む場面が一日に何度も訪れます。あるとき、その一覧を返すMCPツールが400件あまりのタイトルとスラッグをまるごと返し、たった一回の呼び出しでコンテキストウィンドウの体感2割ほどを食い潰しました。
会話のまだ序盤です。これから記事を生成し、品質ゲートをかけ、pushまで走らせなければなりません。にもかかわらず、最初のツール呼び出しだけで予算の入り口がふさがれてしまった。手が止まりました。
原因ははっきりしています。ツールが「全部」を返していたからです。エージェントが本当に必要としていたのは、最新の数十件と「まだ続きがある」という事実だけでした。以下では、その落とし穴を埋めるカーソルベースのページングを、署名付き不透明カーソルの実装まで含めて組み立てます。
なぜ「全件返す」ツールがエージェントを壊すのか
通常のWeb APIなら、レスポンスが多少大きくてもクライアントが受け取って捨てるだけです。ところがエージェントの場合、ツールの戻り値はそのまま次のターンの入力トークンになります。一覧が大きいほど、以降の全ターンでその重みを背負い続けることになります。
実測してみました。私の記事一覧ツールは、1件あたりタイトル・スラッグ・カテゴリ・公開日・タグを返します。日本語タイトルを含むので、1件あたりおよそ45トークン。400件で約18,000トークンです。これが会話の冒頭に居座ると、後続のシステムプロンプト・生成指示・コード片と合わさって、まだ何も作っていないのに予算の足元が崩れます。
さらに厄介なのは、この出力がプロンプトキャッシュと相性が悪い点です。一覧は記事を追加するたびに変わるため、巨大かつ揮発性の高いブロックになります。キャッシュの安定ブロックと揮発ブロックを分離する設計をしていても、この一覧だけは毎回キャッシュミスを生み続けます。
オフセット方式では足りない理由
「ではページングすればよい」と考えて、まずオフセット方式(offset と limit)を試しました。実装は最も単純です。
def list_articles_offset (offset: int = 0 , limit: int = 20 ):
rows = db.query(
"SELECT slug, title, category, published_at "
"FROM articles WHERE status = 'published' "
"ORDER BY published_at DESC LIMIT ? OFFSET ?" ,
(limit, offset),
)
return { "items" : rows, "next_offset" : offset + limit}
これで1ページ20件、約900トークンに収まりました。18,000トークンからおよそ95%の削減で、トークン量だけ見れば成功です。
ところが運用に乗せてすぐ、エージェントが同じ記事を二度処理する事故が起きました。エージェントが1ページ目を見て検討している最中に、別のスケジュールタスクが新しい記事を1本publishしたのです。published_at DESC の並びでは、新記事が先頭に割り込みます。その結果、もともと20番目だった記事が21番目へ押し出され、offset=20 の2ページ目で再び現れました。逆に、削除が起きれば取りこぼしが発生します。
オフセット方式は「並びが固定されている」という前提に立っています。エージェントの世界では、その前提がしばしば成り立ちません。複数のタスクが同じデータを並行して触るからです。
カーソル方式の考え方
カーソルは「位置」ではなく「直前に返した最後の要素はこれです」という指し示しです。次のページは「その要素より後ろ」を取りに行きます。途中で挿入や削除があっても、基準点そのものが動かないので、重複も取りこぼしも起きません。
並び順のキーには、単調かつ一意な値を選びます。私の場合は (published_at, id) の複合キーにしました。公開日時が同じ記事が複数あっても、id で最終的な順序が一意に決まるためです。日時だけをカーソルにすると、同一秒に2本公開された記事の境界で取りこぼしが起きます。これは実際に踏んだ罠でした。
def list_articles_cursor (after_published_at = None , after_id = None , limit = 20 ):
if after_published_at is None :
rows = db.query(
"SELECT id, slug, title, category, published_at "
"FROM articles WHERE status = 'published' "
"ORDER BY published_at DESC, id DESC LIMIT ?" ,
(limit + 1 ,),
)
else :
rows = db.query(
"SELECT id, slug, title, category, published_at "
"FROM articles WHERE status = 'published' "
"AND (published_at, id) < (?, ?) "
"ORDER BY published_at DESC, id DESC LIMIT ?" ,
(after_published_at, after_id, limit + 1 ),
)
return rows
ポイントは LIMIT ? に limit + 1 を渡している箇所です。要求された件数より1件だけ多く取得し、その余剰が存在するかどうかで「次のページがあるか(has_more)」を判定します。別途 COUNT(*) を打つよりも安く、確実です。
不透明カーソル(opaque cursor)で内部構造を隠す
ここで一つ判断が要ります。カーソルとして published_at と id を生のままエージェントに返してよいか、という問いです。
私は生では返さない方を推奨します。理由は二つあります。第一に、内部のカラム名やソートキーが外に漏れると、エージェント(や、それを誘導しようとする入力)がカーソルを書き換えて、想定外のクエリを撃てる余地が生まれます。第二に、将来ソートキーを (updated_at, id) に変えたくなったとき、生カーソルを外に出していると後方互換が壊れます。
そこで、カーソルの中身をエンコードし、署名を付けて不透明な文字列にします。エージェントから見ればただの「次を取るための合言葉」であり、中身を解釈する必要はありません。
import base64
import hmac
import hashlib
import json
CURSOR_SECRET = b "replace-with-env-secret" # 実際は環境変数から読み込む
def encode_cursor (published_at: str , item_id: int ) -> str :
payload = json.dumps(
{ "p" : published_at, "i" : item_id, "k" : "published_at_id_v1" },
separators = ( "," , ":" ),
).encode()
sig = hmac.new( CURSOR_SECRET , payload, hashlib.sha256).digest()[: 8 ]
return base64.urlsafe_b64encode(payload + sig).decode()
def decode_cursor (cursor: str ):
raw = base64.urlsafe_b64decode(cursor.encode())
payload, sig = raw[: - 8 ], raw[ - 8 :]
expected = hmac.new( CURSOR_SECRET , payload, hashlib.sha256).digest()[: 8 ]
if not hmac.compare_digest(sig, expected):
raise ValueError ( "cursor signature mismatch" )
data = json.loads(payload)
if data.get( "k" ) != "published_at_id_v1" :
raise ValueError ( "cursor scheme mismatch — 古いカーソルです" )
return data[ "p" ], data[ "i" ]
k(キースキームのバージョン)を埋め込んでおくと、ソートキーを変更したときに「古いカーソルです」と明示的に弾けます。改竄されたカーソルは署名検証で落ちるため、decode_cursor の例外を捕まえてツール側で「カーソルが無効です。先頭から取り直してください」と返すのが運用上は安全です。
エージェントが自分で続きを取りに行ける結果オブジェクト
ページングの本当の難しさは、データを小分けにすることそのものではなく、エージェントに「いま自分が一覧の一部しか見ていない」と気づかせることにあります。素朴に20件だけ返すと、エージェントはそれが全件だと思い込んで判断を下します。
そこで、ツールの戻り値に「続きの有無」と「全体像の要約」を同梱します。
def list_articles_tool (cursor: str | None = None , limit: int = 20 ):
after_p, after_i = (decode_cursor(cursor) if cursor else ( None , None ))
rows = list_articles_cursor(after_p, after_i, limit)
has_more = len (rows) > limit
page = rows[:limit]
next_cursor = (
encode_cursor(page[ - 1 ][ "published_at" ], page[ - 1 ][ "id" ])
if has_more and page else None
)
total = db.scalar(
"SELECT COUNT(*) FROM articles WHERE status = 'published'"
)
return {
"items" : [
{ "slug" : r[ "slug" ], "title" : r[ "title" ],
"category" : r[ "category" ], "published_at" : r[ "published_at" ]}
for r in page
],
"page_size" : len (page),
"has_more" : has_more,
"next_cursor" : next_cursor,
"total_published" : total,
"hint" : (
f "全 { total } 件中の最新 { len (page) } 件です。"
"さらに古い記事が必要な場合のみ next_cursor を渡して再呼び出ししてください。"
),
}
hint フィールドは地味ですが効きます。エージェントは「全400件中の最新20件」と明示されれば、たいていの場合そこで止まります。続きを取りに行くのは、本当に古い記事を探しているときだけになります。実際にこのヒントを入れてから、平均のページ取得回数は1呼び出しあたり2.1回から1.3回に下がりました。無駄なフェッチが減った分、コンテキストもさらに軽くなります。
MCPツールとして公開するときのスキーマ
Claude にツールとして見せるときは、入力スキーマを最小限にします。cursor は任意、limit には上限を設けて、エージェントが limit: 5000 のような無茶を投げてもサーバ側で頭打ちにします。
const listArticlesTool = {
name: "list_articles" ,
description:
"公開済み記事を新しい順に返します。1回の呼び出しは最大50件です。" +
"続きが必要なときだけ、前回の応答の next_cursor を cursor に渡してください。" ,
input_schema: {
type: "object" ,
properties: {
cursor: {
type: "string" ,
description: "前回応答の next_cursor。最初の呼び出しでは省略します。" ,
},
limit: {
type: "integer" ,
minimum: 1 ,
maximum: 50 ,
default: 20 ,
},
},
},
} as const ;
function clampLimit ( requested ?: number ) : number {
if ( ! requested || requested < 1 ) return 20 ;
return Math. min (requested, 50 );
}
description に「続きが必要なときだけ」と書いておくのも、地味ながら効果のある誘導です。ツールの説明文はエージェントにとって最も近い指示なので、ここでページングの作法を一言添えるだけで、不要な全件走査をかなり抑えられます。
導入して見えた運用上の勘どころ
数週間運用して、ドキュメントには書かれていない気づきがいくつかありました。
第一に、limit のデフォルトは「1ページで判断が一区切りつく最小値」に寄せるのが正解でした。最初は50件にしていましたが、エージェントが一覧を眺めて重複候補を弾く用途では、20件で十分に文脈が足りていました。デフォルトを下げるほど、毎回のトークンが軽くなります。
第二に、total を返すかどうかは慎重に決める価値があります。COUNT(*) は件数が増えると地味に重く、巨大テーブルではページ取得そのものより遅くなる場面があります。私は概算で構わない一覧では total を省き、has_more だけで運用しています。正確な総数が要る画面用APIとは、ツールを分けました。
第三に、カーソルの寿命です。署名付きカーソルは原理上いつまでも有効ですが、ソートキーのスキーマを変えた瞬間に k の不一致で全カーソルが無効になります。スケジュールタスクが古いカーソルを握ったまま再開すると、そこで例外が出ます。私は decode_cursor の失敗時に黙って先頭から取り直す挙動にして、エージェントが止まらないようにしました。例外を握りつぶすのは普段は避けたい判断ですが、ここは「止まらないこと」を優先しています。
第四に、これはAdMob収益のダッシュボードを集計するツールでも同じ設計が効きました。日次の収益行を全部返すのではなく、直近7日分だけをカーソル付きで返すようにしたところ、エージェントが「今朝の数字を確認する」用途では一度も続きを取りに行かなくなりました。一覧系・時系列系のツールは、ほぼ例外なくこのページング設計の恩恵を受けます。
どこから手を付けるか
既存のエージェントでコンテキストが早々に膨らむなら、まず疑うべきは「一覧をまるごと返しているツール」です。ツール呼び出しのログを開き、戻り値が最も大きいものを一つ見つけてください。多くの場合、犯人は一つか二つです。
その一つを、limit + 1 で has_more を判定し、署名付きカーソルで続きを返す形に置き換える。返却オブジェクトには has_more と短い hint を必ず添える。まずこの最小構成だけでも、会話の冒頭で予算が溶ける問題はほぼ解消します。私自身、最初に直したのはたった一つのツールでしたが、それだけで一日の生成パイプライン全体の安定感がはっきり変わりました。
一覧を返すツールを一つ見直すだけで、エージェントの一日はずいぶん軽くなります。どこか一箇所からでも、試していただけたら嬉しいです。