自分のサイトのビルドログを読ませるだけの、ごく小さな MCP サーバーを書きました。ツールはひとつ、指定したログファイルを読んで返すだけです。返り値が大きくなりすぎないよう、先頭400文字だけをプレビューとして履歴に残し、全文は必要になったときに読み直す設計にしました。
個人開発でいくつものサイトを回していると、こうした小さな常駐プロセスが少しずつ増えていきます。
一つひとつは数十メガバイトのつもりでした。
数時間走らせたあと、ps の出力を見て手が止まりました。常駐プロセスの RSS が 200MB を超えています。
履歴に積んであるのは400文字のプレビューだけのはずです。何百件積んでも数十キロバイトにしかなりません。それでもメモリは減りませんでした。
原因にたどり着くまで、私はまったく違う場所を疑っていました。ストリームの読み残し、Buffer の解放漏れ、イベントリスナの積み上がり。そのどれでもありませんでした。
犯人は、切り詰めるために書いたたった一行の slice でした。
400文字のプレビューが、親の全文を握って離さない
まず最小の再現から始めます。4MB の文字列を20回作り、それぞれ先頭200文字だけを配列に残す。それだけのコードです。
// exp1.mjs — node --expose-gc exp1.mjs slice
function mb(n) { return (n / 1048576).toFixed(1); }
const KEEP = 200;
const N = 20;
const SIZE = 4 * 1024 * 1024;
const kept = [];
global.gc();
const base = process.memoryUsage().heapUsed;
for (let i = 0; i < N; i++) {
const full = 'x'.repeat(SIZE) + '#' + i; // 4MB のツール出力を模擬
kept.push(full.slice(0, KEEP)); // 200文字だけ残すつもり
}
global.gc();
const after = process.memoryUsage().heapUsed;
console.log('kept =', kept.length,
'/ previewLen =', kept[0].length,
'/ heapDelta =', mb(after - base), 'MB');
global.gc() を明示的に呼んでいるのは、GC のタイミングに結果が左右されないようにするためです。--expose-gc フラグが必要になります。
実行結果です。計測環境は Node.js v22.22.3(V8 12.4.254.21)、Linux x86_64・4 vCPU・メモリ 3.9GB のサンドボックスです。
kept = 20 / previewLen = 200 / heapDelta = 80.0 MB
保持しているのは200文字の文字列が20本、合計4,000文字です。それなのに 80MB が回収されていません。4MB × 20 と、きれいに一致します。
つまり、切り詰めたはずの親文字列が20本とも生きています。
境界はちょうど13文字だった
ここで気になったのは、「では何文字までなら安全なのか」という点です。プレビュー長だけを変えて測り直しました。
// exp3.mjs — node --expose-gc exp3.mjs <KEEP>
const KEEP = Number(process.argv[2]);
const N = 10;
const SIZE = 2 * 1024 * 1024;
const kept = [];
global.gc();
const base = process.memoryUsage().heapUsed;
for (let i = 0; i < N; i++) {
const full = 'x'.repeat(SIZE) + '#' + i;
kept.push(full.slice(0, KEEP));
}
global.gc();
console.log('keep =', KEEP, '/ retained =', ((process.memoryUsage().heapUsed - base) / 1048576).toFixed(2), 'MB');
2MB × 10本 を作った場合の結果です。
| プレビュー長(文字) | 回収されずに残った量 | 解釈 |
| 1 | 2.00 MB | 親は解放されている |
| 5 | 2.00 MB | 親は解放されている |
| 11 | 2.00 MB | 親は解放されている |
| 12 | 2.00 MB | ここまでは安全 |
| 13 | 20.00 MB | ここから親を握る |
| 14 | 20.00 MB | 親を握る |
| 200 | 20.00 MB | 親を握る |
12と13のあいだで、2MB から 20MB へ跳ねます。
残り続ける 2.00 MB は、ループの最後に作った full がまだ到達可能なままである分です。呼び出し回数を1回にしても40回にしても 2.00 MB のまま動かなかったので、こちらは定数のノイズだと判断しました。逆に13文字以上では、回数に正比例して積み上がります。
| 呼び出し回数 | 残った量(プレビュー200文字・親4MB) |
| 1 | 4.00 MB |
| 2 | 8.00 MB |
| 5 | 20.00 MB |
| 10 | 40.00 MB |
| 20 | 80.00 MB |
| 40 | 160.00 MB |
きれいな直線です。長く走らせるほど確実に増えます。
この13という値は、V8 が部分文字列を「親への参照+開始位置+長さ」で表現する内部表現(sliced string)を使い始める最小長と一致します。短い切り出しはその場でコピーされるので親が残らない、というわけです。
短く切るほど安全、という感覚とは逆になります。プレビューを 12 文字に縮めれば回避できますが、12文字のプレビューには何の情報も載りません。実務では使えない回避策です。
「新しい文字列を作っている」ように見えて、作っていない書き方
では、どう書けば親を切り離せるのか。思いつく限りの書き方を並べて、同じ条件で測りました。4MB を20回、プレビュー長200文字です。
// exp2b.mjs — 平坦化の効き目を1手法ずつ別プロセスで測る
const F = {
'slice': s => s.slice(0, KEEP),
'substring': s => s.substring(0, KEEP),
'template': s => `${s.slice(0, KEEP)}`,
'String()': s => String(s.slice(0, KEEP)),
'concat-empty': s => '' + s.slice(0, KEEP),
'space-slice1': s => (' ' + s.slice(0, KEEP)).slice(1),
'split-join': s => s.slice(0, KEEP).split('').join(''),
'buffer': s => Buffer.from(s.slice(0, KEEP), 'utf8').toString('utf8'),
'json': s => JSON.parse(JSON.stringify(s.slice(0, KEEP))),
'array-join': s => Array.from(s.slice(0, KEEP)).join(''),
'repeat1': s => s.slice(0, KEEP).repeat(1),
'padEnd': s => s.slice(0, KEEP).padEnd(KEEP),
'normalize': s => s.slice(0, KEEP).normalize(),
};
手法ごとに別プロセスで走らせています。同一プロセスで順に測ると、前の手法が残したごみが次の計測に混ざるためです。
結果です。
| 書き方 | 残った量 | 親を切り離せたか |
s.slice(0, n) | 80.00 MB | いいえ |
s.substring(0, n) | 80.00 MB | いいえ |
`${s.slice(0, n)}` | 80.00 MB | いいえ |
String(s.slice(0, n)) | 80.00 MB | いいえ |
'' + s.slice(0, n) | 80.00 MB | いいえ |
s.slice(0, n).repeat(1) | 80.00 MB | いいえ |
s.slice(0, n).padEnd(n) | 80.00 MB | いいえ |
s.slice(0, n).normalize() | 80.00 MB | いいえ |
(' ' + s.slice(0, n)).slice(1) | 4.00 MB | はい |
s.slice(0, n).split('').join('') | 4.00 MB | はい |
Buffer.from(...).toString('utf8') | 4.01 MB | はい |
JSON.parse(JSON.stringify(...)) | 4.00 MB | はい |
Array.from(...).join('') | 4.00 MB | はい |
私が事前に効くだろうと踏んでいたのは、テンプレートリテラルと String() でした。どちらも「新しい文字列を作る」書き方に見えるからです。実際にはどちらも素通りでした。
'' + s も、repeat(1) も、padEnd を元の長さちょうどで呼んだ場合も同じです。エンジンから見れば、返す内容が入力と同一なら入力をそのまま返すのが正しい最適化です。私たちが期待していた「コピーの副作用」は、最適化の対象そのものでした。
効いたのは、内部表現を必ず一度崩す書き方だけです。一文字足してから外す、配列に分解して繋ぎ直す、バイト列や JSON を経由する。いずれも「無駄なことをしている」ように見えるコードで、レビューで真っ先に消されそうな見た目をしています。
速いのは、いちばん無駄に見える書き方だった
平坦化のコストも測りました。256KB の元文字列から200文字を1万回切り出し、7回計測した中央値です。
| 書き方 | 1万回あたり | 1回あたり |
slice(平坦化しない) | 0.87 ms | 0.09 µs |
(' ' + x).slice(1) | 2.17 ms | 0.22 µs |
Buffer 往復 | 5.27 ms | 0.53 µs |
JSON 往復 | 5.47 ms | 0.55 µs |
split('').join('') | 21.78 ms | 2.18 µs |
Array.from(...).join('') | 30.73 ms | 3.07 µs |
いちばん場当たり的に見える (' ' + x).slice(1) が、平坦化する手法のなかで最速でした。Buffer 往復の約2.4倍の速さです。
そして、直感的に「確実そう」に見える split('').join('') と Array.from(...).join('') が最も遅く、10倍以上の開きがあります。文字ごとの配列を経由するぶん当然ではあるのですが、確実さと速さが逆向きに並ぶとは思っていませんでした。
ただし (' ' + x).slice(1) は、なぜそう書いてあるのかがコードから読み取れません。私は速度差が効かない場所では Buffer 往復を選び、ホットパスに限って (' ' + x).slice(1) を使い、必ずコメントを添えることにしました。
ツールラッパーに入れて、常駐時のメモリを測り直す
ここまでは合成の実験です。実際のツールラッパーに近い形で確かめます。1MB の出力を返すツールを200回呼び、先頭400文字を履歴に積む構成です。
// exp10.mjs — node --expose-gc exp10.mjs [naive|flatten]
const FLATTEN = process.argv[2] === 'flatten';
const CALLS = 200;
const OUT = 1024 * 1024;
const KEEP = 400;
function truncatePreview(s, keep) {
if (s.length <= keep) return s;
const head = s.slice(0, keep);
// head は親の全文への参照を握っている。Buffer 往復で切り離す。
return FLATTEN ? Buffer.from(head, 'utf8').toString('utf8') : head;
}
async function callTool(i) {
return 'L'.repeat(OUT) + '\n#' + i; // 大きなログを返すツールを模擬
}
const history = [];
global.gc();
const t0 = Date.now();
for (let i = 0; i < CALLS; i++) {
const out = await callTool(i);
history.push({ tool: 'read_log', preview: truncatePreview(out, KEEP), fullBytes: out.length });
}
global.gc();
const m = process.memoryUsage();
console.log(FLATTEN ? 'flatten' : 'naive ',
'heapUsed =', (m.heapUsed / 1048576).toFixed(1), 'MB',
'/ rss =', (m.rss / 1048576).toFixed(1), 'MB',
'/ elapsed =', Date.now() - t0, 'ms');
4回ずつ走らせた結果です。ばらつきはほとんどありませんでした。
| 実装 | heapUsed | RSS | 所要時間 | 履歴の総文字数 |
naive(slice のまま) | 203.5 MB | 245.0 MB | 116〜124 ms | 80,000 |
flatten(Buffer 往復) | 4.6 MB | 43.6 MB | 133〜142 ms | 80,000 |
履歴に残っている情報量は完全に同じです。それでも heapUsed は約44分の1、RSS は約5.6分の1になりました。
代償は200呼び出しで16ミリ秒ほど、1回あたり 0.08 ミリ秒です。ツール本体の待ち時間に比べれば誤差の範囲でした。
fullBytes を別に持っている点も意図的です。全文を捨てても「元が何バイトだったか」は残しておくと、あとから切り詰めの妥当性を判断できます。プレビューだけを見て「これで全部だ」とモデルが誤解する事故も減ります。
疑いを確信に変える — ヒープスナップショットから親を数える
「メモリが増えている」だけでは、原因が sliced string だとは言い切れません。実際に握られている親を数えられれば、対処の前後を機械的に比べられます。
Node には v8.writeHeapSnapshot() があります。出力される .heapsnapshot は JSON なので、外部ツールなしで自前に解析できます。
ノードの種別に sliced string があり、そこから親文字列へ internal エッジが伸びています。これをたどって、一定サイズ以上の親だけを合計する監査スクリプトを書きました。
#!/usr/bin/env node
// sliced_audit.mjs
// ヒープスナップショット中の sliced string と、それが握り続けている親文字列を集計する。
// 使い方: node sliced_audit.mjs <snapshot> [親と見なす下限バイト] [許容合計MB]
import fs from 'node:fs';
const file = process.argv[2];
const PARENT_MIN_BYTES = Number(process.argv[3] || 262_144); // 256KB 以上の親だけ数える
const BUDGET_MB = Number(process.argv[4] || 8);
const snap = JSON.parse(fs.readFileSync(file, 'utf8'));
const { node_fields: nf, node_types: nt, edge_fields: ef, edge_types: et } = snap.snapshot.meta;
const NS = nf.length, ES = ef.length;
const TYPE = nf.indexOf('type'), NAME = nf.indexOf('name'),
SIZE = nf.indexOf('self_size'), ECOUNT = nf.indexOf('edge_count');
const ETYPE = ef.indexOf('type'), ETO = ef.indexOf('to_node');
const nodeTypes = nt[0], edgeTypes = et[0];
// ノード index → 先頭エッジ index を先に作る。毎回頭から数えると O(n^2) になる。
const edgeStart = new Uint32Array(snap.nodes.length / NS);
for (let i = 0, o = 0, e = 0; o < snap.nodes.length; o += NS, i++) {
edgeStart[i] = e;
e += snap.nodes[o + ECOUNT];
}
// 同じ親を複数の slice が指す場合の二重計上を Map で防ぐ
const parents = new Map();
let sliced = 0;
for (let i = 0, o = 0; o < snap.nodes.length; o += NS, i++) {
if (nodeTypes[snap.nodes[o + TYPE]] !== 'sliced string') continue;
sliced++;
const base = edgeStart[i] * ES;
for (let k = 0; k < snap.nodes[o + ECOUNT]; k++) {
const eo = base + k * ES;
if (edgeTypes[snap.edges[eo + ETYPE]] !== 'internal') continue;
const po = snap.edges[eo + ETO];
const bytes = snap.nodes[po + SIZE];
if (bytes >= PARENT_MIN_BYTES) parents.set(po, bytes);
}
}
let retained = 0;
const samples = [];
for (const [po, bytes] of parents) {
retained += bytes;
if (samples.length < 3) {
samples.push({
parentMB: +(bytes / 1048576).toFixed(2),
head: (snap.strings[snap.nodes[po + NAME]] || '').slice(0, 32),
});
}
}
const retainedMB = +(retained / 1048576).toFixed(2);
console.log(JSON.stringify({
slicedStrings: sliced, retainedParents: parents.size, retainedMB, budgetMB: BUDGET_MB, samples,
}));
process.exit(retainedMB > BUDGET_MB ? 1 : 0);
先に edgeStart を作っている部分が要点です。スナップショットのエッジは全ノード分が一本の配列に連結されているため、あるノードのエッジ位置を知るには、それ以前のノードのエッジ数を全部足す必要があります。素直に書くと二重ループになり、ノード数が数万を超えたところで監査自体が数十秒かかるようになります。
2MB の出力を50回積んだプロセスのスナップショットに対して走らせた結果です。
$ node sliced_audit.mjs naive.heapsnapshot
{"slicedStrings":54,"retainedParents":50,"retainedMB":100,"budgetMB":8,
"samples":[{"parentMB":2,"head":"LLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLL"}, ...]}
exit=1
$ node sliced_audit.mjs flatten.heapsnapshot
{"slicedStrings":5,"retainedParents":1,"retainedMB":2,"budgetMB":8,"samples":[...]}
exit=0
親50本・合計100MB がそのまま出ました。平坦化した側は1本・2MB です。samples の head に親の先頭が出るので、どのツールの出力が残っているのかも見当がつきます。
5MB のスナップショットに対して監査の所要は 0.09〜0.10 秒でした。CI に入れても待たされません。
なお slicedStrings はゼロにはなりません。ライブラリの内部でも短い切り出しは日常的に起きます。判断材料になるのは本数ではなく、下限を超える親の合計サイズのほうです。
Python では、罠の位置がひとつずれる
同じ発想で Python も測りました。結論は逆で、str のスライスは素直にコピーを作ります。
import tracemalloc
SIZE, N, KEEP = 4 * 1024 * 1024, 20, 200
tracemalloc.start()
base = tracemalloc.get_traced_memory()[0]
kept = [( 'x' * SIZE + '#' + str(i))[:KEEP] for i in range(N)]
cur = tracemalloc.get_traced_memory()[0]
print(f"retained = {(cur - base) / 1048576:.2f} MB")
Python 3.10.12 での結果は retained = 4.00 MB でした。最後の1本ぶんだけで、積み上がりはありません。
ところが、バイト列を memoryview で切り出すと同じ罠が現れます。
kept.append(memoryview(full)[:KEEP]) # full は bytes
こちらは retained = 80.01 MB になりました。memoryview は定義上ゼロコピーのビューなので、当然といえば当然です。
言い換えると、この問題は Node 固有の欠陥ではありません。「切り出しはコピーか、それともビューか」という選択が言語ごとに違う場所に置かれているだけです。ツール出力を扱うコードを書くときは、使っている切り出し操作がどちらなのかを一度確かめておく価値があります。
Anthropic 側でも、切り詰めた MCP ツール出力について、切り詰め前の全文がセッション終了までメモリに残る問題が修正されています。同じ形の落とし穴が、公式の実装でも起きていたことになります。自作のツールラッパーで踏んでいないと考える理由はありません。
常駐させる前に、一度だけ測っておく
私がいま守っている運用は、次の三つだけです。
- ツール出力を切り詰める関数はひとつに集約する。 平坦化を各所の
slice に散らすと必ず漏れます。truncateForHistory() のような関数を1本だけ用意し、そこ以外で履歴に文字列を積まない。
- 常駐プロセスに
writeHeapSnapshot() を仕込んでおく。 シグナルを受けたらスナップショットを吐く程度で十分です。疑わしくなったときに現物を取れる状態にしておくと、原因の切り分けが推測から計測に変わります。
- 監査を予算つきで CI に置く。 上のスクリプトは合計サイズが予算を超えると
exit 1 を返します。閾値は本数ではなくバイト数で持つのが実用的でした。
そして、すべての slice を平坦化する必要はありません。切り出した文字列がその場で使われて捨てられるなら、平坦化は純粋なコストです。効くのは、切り出した結果を長生きするデータ構造に積むときだけです。履歴・キャッシュ・メトリクスのラベル。
個人開発で書いたコードを見直したところ、該当したのはこの三つだけでした。
もし手元に常駐している MCP サーバーやエージェントのプロセスがあるなら、まず node --expose-gc で数百回ぶんの呼び出しを回して heapUsed を見てみてください。積み上がっていれば、切り詰めの一行から疑うと近道です。
私自身、原因にたどり着くまでにストリームと Buffer を何時間も眺めていました。同じ回り道を省けるなら嬉しく思います。お読みいただきありがとうございました。