個人開発で回している集計ジョブのログを朝に開いたら、テーブルの下に … 2,847 more rows と出ていました。
Claude Code 2.1.209 の描画改善が効いた瞬間です。以前なら、この規模のテーブルはターミナルの描画を止めて、メモリを食い潰していました。素直にありがたい変更です。
ただ、少し眺めているうちに落ち着かなくなりました。私の目に見えているのは 200 行です。では、モデルは何行受け取ったのでしょうか。
省略されたのは描画であって、コンテキストではない
changelog の記述は「非常に大きな markdown テーブルが描画を止めたりメモリを食い潰す不具合を修正。200 行を超えるテーブルは先頭 200 行+『… N more rows』表示になりました」というものです。直っているのはターミナル側の描画です。
ツール結果としてモデルに渡るテキストは、それとは別の経路を通ります。MCP サーバーが 3,047 行のテーブルを返せば、3,047 行分のテキストがそのまま会話履歴に積まれます。画面が 200 行で止まっているのは、あくまで人間の目に対する配慮です。
この区別を曖昧にすると、厄介なことが起きます。省略表示を見て「軽くなった」と感じ、ツールの返り値を見直す動機を失うのです。実際には、レビューする人間の手元が軽くなっただけで、請求とコンテキストの消費は何も変わっていません。むしろ、重さが目に見えなくなった分だけ気づきにくくなりました。
私自身、この省略表示を初めて見たときは素直に喜んでいました。数分後に「待てよ」と思い直したのは、ちょうどその前の週に、別のジョブでコンテキストが想定より早く埋まる件を追いかけていたからです。
テーブル 1 枚が持ち込む重さを、送る前に見積もる
まず、感覚ではなく数字にします。バイト数の見積もりは電卓で足ります。
集計ジョブが返している行の実寸を測ります。
# MCP ツールが返しているテーブルをファイルに落として実寸を見る
# (ツール側にデバッグ出力の口が無ければ、同じクエリを CLI で再現する)
wc -c /tmp/daily_rows.md
wc -l /tmp/daily_rows.md
# 1 行あたりの平均バイト数
awk 'END { printf "avg bytes/line: %.1f\n", (NR ? total/NR : 0) } { total += length($0) + 1 }' /tmp/daily_rows.md
私の集計ジョブの行は、日付・カテゴリ・件数・比率・前日差・備考の 6 列で、1 行あたり 74 バイト前後でした。3,047 行だと 74 × 3,047 ≒ 225 KB です。ヘッダと区切り行を足しても、ざっくり 225 KB。
この 225 KB がトークンでいくつになるかは、掛け算では出しません。日本語混じりのテキストはバイト数とトークン数の比が素直ではないので、公式の count_tokens に投げます。
# pip install anthropic
import anthropic
client = anthropic.Anthropic(api_key="YOUR_API_KEY")
with open("/tmp/daily_rows.md", encoding="utf-8") as f:
table = f.read()
# 全件を渡した場合
full = client.messages.count_tokens(
model="claude-sonnet-5",
messages=[{"role": "user", "content": table}],
)
# 画面に出ている 200 行だけを渡した場合(比較用)
head_200 = "\n".join(table.splitlines()[:202]) # ヘッダ + 区切り + 200 行
capped = client.messages.count_tokens(
model="claude-sonnet-5",
messages=[{"role": "user", "content": head_200}],
)
print(f"full : {full.input_tokens:,} tokens")
print(f"head200: {capped.input_tokens:,} tokens")
print(f"ratio : {full.input_tokens / capped.input_tokens:.1f}x")
# 期待する出力(行の内容によって変わります):
# full : 62,431 tokens
# head200: 4,238 tokens
# ratio : 14.7x
数字が出ると、話が変わります。画面では 200 行に収まって見えるものが、モデルの側では十数倍の重さで積まれている。しかもこれはターンごとの入力に毎回乗ります。
比率は行の内容で動きますが、比率そのものより「画面の見た目とモデルへの入力は連動していない」ことを、自分の手元の数字で一度確認しておくことに意味があります。
全件テーブルを返していたツールを、三層に組み替える
見積もりが出たら、返り値の設計に戻ります。私が使っているのは、要約・上位 N 件・参照先の三層です。
組み替える前は、素直に全件を返していました。
# Before: クエリ結果をそのまま markdown テーブルにして返す
def daily_breakdown(date: str) -> str:
rows = db.query(
"SELECT day, category, hits, ratio, delta, note "
"FROM daily_stats WHERE day = ? ORDER BY hits DESC",
(date,),
)
lines = ["| day | category | hits | ratio | delta | note |",
"|---|---|---|---|---|---|"]
for r in rows:
lines.append(f"| {r.day} | {r.category} | {r.hits} | {r.ratio} | {r.delta} | {r.note} |")
return "\n".join(lines) # 3,047 行がそのままコンテキストへ
書いた当時は、これで良いと思っていました。「全部渡しておけば、必要なところをモデルが拾ってくれる」という期待です。実際には、拾ってほしい 5 行のために 3,042 行分を毎ターン払っていました。
# After: 集約・上位N件・参照先の三層で返す
import json
from pathlib import Path
TOP_N = 20
ARTIFACT_DIR = Path("/var/run/agent-artifacts")
def daily_breakdown(date: str) -> str:
rows = db.query(
"SELECT day, category, hits, ratio, delta, note "
"FROM daily_stats WHERE day = ? ORDER BY hits DESC",
(date,),
)
# ① 集約: 全件を見なくても判断できる数字を先に置く
total = sum(r.hits for r in rows)
categories = len({r.category for r in rows})
worst = min(rows, key=lambda r: r.delta) if rows else None
# ② 参照先: 全件は必ずファイルへ落とし、パスだけ渡す
ARTIFACT_DIR.mkdir(parents=True, exist_ok=True)
artifact = ARTIFACT_DIR / f"daily_breakdown_{date}.json"
artifact.write_text(
json.dumps([r._asdict() for r in rows], ensure_ascii=False),
encoding="utf-8",
)
# ③ 上位N件: 人間もモデルも、まずここだけ見れば足りる形に
head = ["| category | hits | ratio | delta |", "|---|---|---|---|"]
for r in rows[:TOP_N]:
head.append(f"| {r.category} | {r.hits} | {r.ratio} | {r.delta} |")
summary = (
f"date={date} rows={len(rows)} total_hits={total} categories={categories}\n"
f"largest_drop={worst.category if worst else 'n/a'} "
f"delta={worst.delta if worst else 'n/a'}\n"
f"full_result={artifact} (JSON, {len(rows)} rows)\n"
)
return summary + "\n" + "\n".join(head) + f"\n\n… {max(0, len(rows) - TOP_N)} more rows in {artifact.name}"
# 期待する返り値の先頭:
# date=2026-07-16 rows=3047 total_hits=184213 categories=42
# largest_drop=wallpaper-hd delta=-18.4
# full_result=/var/run/agent-artifacts/daily_breakdown_2026-07-16.json (JSON, 3047 rows)
なぜ上位 N 件を 20 にしたか。200 ではなく 20 です。描画上限が 200 行だからといって、200 行返す理由にはならないからです。上限は「これ以上は表示しない」という線であって、「ここまでは出してよい」という許可ではありません。
私が 20 に落ち着いたのは、レビューのときに実際に目で追う行数がその程度だったからです。それ以上は、結局ファイルを開いて jq で絞っていました。だったら最初からファイルに渡せばいい、という順番です。数字は環境によって変わりますから、上限から逆算するのではなく、自分が読む行数から決めることを推奨します。
full_result にパスを書いておくと、モデルは必要になったときだけ Read や bash でその中身を取りに行きます。全件が要るケースは残りますが、毎ターン積みっぱなしにする必要はありません。
描画上限は、レビュー設計の制約として読む
200 行という数字は、ツールの返り値だけでなく、人間側の読み方にも効きます。
無人ジョブのログをあとから読むとき、テーブルが省略されているなら、その省略された部分に判断材料が入っていないことが前提になります。逆に言えば、判断に必要な情報は必ず先頭 200 行以内、できれば先頭の数行に置く必要があります。
私は集約行を必ずテーブルより前に置くようにしました。date=... rows=... total_hits=... の 3 行です。ここだけ読めば、その日のジョブが正常だったか異常だったかが分かります。テーブルは、異常だったときに初めて目を落とす場所です。
この順番を逆にしていた頃は、テーブルを上から眺めて異常を探していました。3,000 行を眺めて異常を見つけられるかというと、正直なところ、見つけられていませんでした。私自身、集約を先頭に置いてから、朝のログを開いている時間が目に見えて短くなりました。個人開発では、その数分が次の作業に回せる時間になります。
| 置く場所 | 内容 | 行数の目安 | 判断の役割 |
| 先頭 | 集約値(件数・合計・最大の変化) | 2〜4 行 | 正常か異常かをここだけで決める |
| 中段 | 上位 N 件のテーブル | 10〜20 行 | 異常だったときの当たりをつける |
| 末尾 | 全件の参照先パスと残り件数 | 1 行 | 深掘りが必要になったときの入口 |
全件が本当に要る処理は、テーブルの形をやめる
「集約では足りない、全件をモデルに見せたい」という処理は確かにあります。分類・重複検出・異常値の洗い出しなどです。
そのとき私が選ぶのは、テーブルを大きくすることではなく、バッチに割って別のターンで回すことです。
# 全件をモデルに判断させたい場合: 1 ターンに全部載せず、チャンクで回す
CHUNK = 200 # 描画上限に合わせるのではなく、1 回の判断に適した粒度で決める
def classify_all(rows: list, client) -> list:
results = []
for i in range(0, len(rows), CHUNK):
chunk = rows[i : i + CHUNK]
payload = "\n".join(f"{r.category}\t{r.hits}\t{r.delta}" for r in chunk)
# tsv にすると markdown テーブルより 1 行あたりのバイト数がはっきり減る
resp = client.messages.create(
model="claude-sonnet-5",
max_tokens=2048,
messages=[{
"role": "user",
"content": f"次の行を anomaly / normal に分類し、JSON 配列で返してください。\n{payload}",
}],
)
results.extend(json.loads(resp.content[0].text))
return results
ここで markdown テーブルではなく TSV にしているのは、罫線のパイプとスペースが 1 行あたりで無視できない量になるからです。6 列なら区切りの | と だけで 1 行あたり 20 バイト前後。先ほどの 74 バイト/行に対して約 27% が罫線とスペースで、3,047 行なら 60 KB ほどが表の枠線に消えている計算です。人間が読まない中間データを markdown で書く理由はありません。この場合は素直に TSV を選ぶことをお勧めします。
ツール結果の分割そのものについては、ツール出力が大きすぎてコンテキストを溶かす問題 — カーソルで小分けに返すページング設計とClaude エージェントのツール返り値を圧縮する — 巨大レスポンスを文脈に積まずに集約する設計で、カーソル方式と圧縮方式をそれぞれ扱っています。この記事は、その手前にある「そもそも何行を画面に出すか」の話です。
つまずいたところ
省略表示をコンテキスト削減と読み違える
一番やりがちな落とし穴で、私も数分だけやりました。省略は描画層です。count_tokens を一度通せば、勘違いは即座に解けます。
上限 200 に合わせて 200 行返す
上限は許可ではありません。レビューで実際に目を通す行数から決めるべきです。私の場合は 20 行でした。
ファイルに落としたのに、パスを返り値に書き忘れる
これをやると、モデルは全件が存在することを知らないまま推測で答えます。集約と参照先はセットです。
参照先ファイルを掃除しない
毎日 225 KB のアーティファクトを吐き続けると、長く回すセッションでは静かに効いてきます。長時間セッションで何が積み上がるかは夜間セッションの常駐メモリが朝には数GB — 蓄積の四つの出どころと、RSS を刻んで切り分けるにまとめました。私は日付きの名前にして、7 日で消しています。
ファイルが大きすぎて Read が途中で切れる
アーティファクトを渡す設計に切り替えると、今度は読み出し側の上限に当たります。offset と limit の使い分けはClaude Code の Read ツールで大きなファイルが途中で切れる — offset と limit の使い分けと bash 併用の判断基準で扱っています。
今日できる最初の一歩
自分の MCP ツールが返している一番大きなテーブルを 1 つ選び、ファイルに落として count_tokens に通してみてください。それだけです。
画面で見ていた 200 行と、実際に払っている数字が並んだとき、返り値をどう組み替えるかは自ずと決まります。私はその数字を見てから、集約行をテーブルの前に置く癖がつきました。
描画が速くなったことと、渡している情報が適量であることは別の話です。前者は 2.1.209 が解決してくれました。後者は、まだ私たちの側の設計に残っています。