0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code の履歴管理——「送らない」設計思想

0
Posted at

Claude Code を使っていると、巨大なコードベースでも快適に動作する。Cursor や gemini-cli のような他のコーディングエージェントと比較して、何が違うのか。ソースコードを読んで分かったのは、最初から何も送らないという設計思想だった。


他のツールは「最初に全体像を送る」

Cursor のアプローチ

Cursor はコードベース全体を静的解析してインデックスを作成する。セッション開始時に:

  • ファイル&フォルダ構造の完全なマップを構築
  • クラス、メソッド、変数の接続関係を解析
  • @Folders でディレクトリ全体の構造とコンテンツを参照可能

これは「プロジェクト全体の地図を最初に渡す」戦略だ。モデルは最初から全体像を把握できるが、その代償としてコンテキストウィンドウの一部が常に地図で占有される。

gemini-cli のアプローチ

gemini-cli も似た戦略を取る:

Session start → ディレクトリスナップショットを取得
             → ファイル&フォルダツリーを初期コンテキストとして送信
             → ReadFile, ReadManyFiles, ReadFolder ツールで探索

ディレクトリツリーは静的に送信され、ツールで詳細を補完する。機能リクエストでは、さらに高度なインデックス化が議論されている。


Claude Code は「最初は何も送らない」

Claude Code にはディレクトリツリーの自動送信も、静的解析もない

モデルが知るのは:

  • 現在のワーキングディレクトリ(パス文字列のみ)
  • ツールの実行結果(Read、Glob、Grep が返したもの)

それだけだ。

オンデマンドツールで探索する

src/tools/GlobTool/GlobTool.ts の実装を見ると:

async call(input, { abortController, getAppState, globLimits }) {
  const limit = globLimits?.maxResults ?? 100
  const { files, truncated } = await glob(
    input.pattern,
    GlobTool.getPath(input),
    { limit, offset: 0 },
    abortController.signal,
    appState.toolPermissionContext,
  )
  const filenames = files.map(toRelativePath)
  return { data: { filenames, durationMs: Date.now() - start, numFiles: filenames.length, truncated } }
}

Glob ツールはファイルパスのリストだけを返す。ディレクトリツリー構造も、ファイル内容も含まれない。

Grep ツールも同様に、パターンマッチした行だけを返す。Read ツールは明示的に指定されたファイルの内容だけを読む。

必要な情報だけを、必要なタイミングで取得する。


ツール結果のストレージ戦略——「退避」と「プレビュー」

オンデマンドで取得した結果が巨大な場合、どう扱うか。Claude Code の答えは「ファイルに退避してプレビューだけを送る」だ。

二段階の閾値

src/utils/toolResultStorage.ts には二段階の閾値がある:

// 個別ツールの閾値(デフォルト 50k 文字)
const threshold = getPersistenceThreshold(toolName, declaredMaxResultSizeChars)

// 1 ターンの合計予算(200k 文字)
const MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000
  1. 個別ツール閾値: ツール単体の出力が 50k 文字を超えるとディスクに書き出す
  2. メッセージ合計予算: 1 ターンの全ツール出力合計が 200k 文字を超えると、大きい順に退避

退避の実装

src/utils/toolResultStorage.ts:137-184persistToolResult 関数:

async function persistToolResult(content, toolUseId) {
  await ensureToolResultsDir()
  const filepath = getToolResultPath(toolUseId, isJson)
  const contentStr = isJson ? jsonStringify(content, null, 2) : content
  
  await writeFile(filepath, contentStr, { encoding: 'utf-8', flag: 'wx' })
  
  const { preview, hasMore } = generatePreview(contentStr, PREVIEW_SIZE_BYTES)
  return { filepath, originalSize: contentStr.length, isJson, preview, hasMore }
}

ファイルに書き出し、プレビュー(2000 バイト)だけをモデルに返す。モデルへのメッセージは:

Output too large (512 KB). Full output saved to: /path/to/result.txt

Preview (first 2000 bytes):
[プレビュー内容]
...

モデルは「詳細が必要なら Read ツールでファイルを読め」と理解する。

決定の凍結——Prompt Cache のための一貫性

重要なのは、一度退避したら、その決定を凍結する点だ。

src/utils/toolResultStorage.ts:960-988reconstructContentReplacementState 関数:

export function reconstructContentReplacementState(
  messages: Message[],
  records: ContentReplacementRecord[],
  inheritedReplacements?: ReadonlyMap<string, string>,
): ContentReplacementState {
  const state = createContentReplacementState()
  // セッション再開時、過去の退避決定を復元
  for (const r of records) {
    if (r.kind === 'tool-result' && candidateIds.has(r.toolUseId)) {
      state.replacements.set(r.toolUseId, r.replacement)
    }
  }
  return state
}

セッションをまたいでも、同じツール結果には同じプレビューを返す。これにより、履歴の再構築時にバイト単位での一致を保証し、Prompt Cache のヒット率を最大化する

docs/history.md:69-70 の記述通り:

一度退避した結果は将来のターンでも退避されたまま(プレビュー表示)で維持され、キャッシュミスを防ぎます。


ファイルバージョンの混在防止——「読んだ内容」の記憶

コーディングエージェントが陥りやすい罠は、モデルが古いファイル内容を基に編集してしまうことだ。

Claude Code は src/utils/fileStateCache.ts でこれを防ぐ。

読み取り時の記録

export type FileState = {
  content: string
  timestamp: number  // mtime
  offset: number | undefined
  limit: number | undefined
  isPartialView?: boolean  // 部分的な読み取りフラグ
}

Read ツールでファイルを読むたびに、その時の内容と mtime(修正時刻)をキャッシュに記録する。

編集時のチェック

Edit ツールや Write ツールで書き込む際、現在のディスク上の mtime とキャッシュを比較する。モデルが読み取った後に外部で変更されていないかを厳格にチェックする(Staleness Check)。

docs/history.md:44-46 の記述:

FileEditTool 等で書き込みを行う際、現在のディスク上の mtime とキャッシュを比較し、モデルが読み取った後に外部で変更されていないかを厳格にチェックします(Staleness Check)。

部分読み取りの保護

isPartialView フラグは重要だ。CLAUDE.md の自動読み込みなどで、ファイルの一部のみをモデルに見せた場合、このフラグを立てる。

src/utils/fileStateCache.ts:9-14

// True when this entry was populated by auto-injection (e.g. CLAUDE.md) and
// the injected content did not match disk (stripped HTML comments, stripped
// frontmatter, truncated MEMORY.md). The model has only seen a partial view;
// Edit/Write must require an explicit Read first.
isPartialView?: boolean

フラグが有効なファイルへの編集は制限され、モデルに明示的な「全文読み取り(Read)」を要求する。誤った推測に基づく破壊的変更を防ぐ

ファイル履歴のスナップショット

さらに、src/utils/fileHistory.ts により、編集の直前状態をスナップショットとして自動保存する。

src/utils/fileHistory.ts:80-92

/**
 * Tracks a file edit (and add) by creating a backup of its current contents (if necessary).
 *
 * This must be called before the file is actually added or edited, so we can save
 * its contents before the edit.
 */
export async function fileHistoryTrackEdit(
  updateFileHistoryState,
  filePath: string,
  messageId: UUID,
): Promise<void>

メッセージ UUID と紐付いたスナップショットにより、セッションのレジューム時や「巻き戻し(Rewind)」の際にも、各時点のファイル状態を正確に特定・復元可能だ。

docs/history.md:50-52

メッセージ UUID と紐付いたスナップショットにより、セッションのレジューム時や「巻き戻し(Rewind)」の際にも、各時点のファイル状態を正確に特定・復元可能です。


Read ツールの是非——「ファイルに出力して読み込む」戦略

ツール結果をファイルに退避し、Read ツールで読み込む戦略にはトレードオフがある。

メリット

  1. コンテキストウィンドウの節約: 巨大な出力を全て送信せず、プレビューだけで済む
  2. Prompt Cache のヒット率向上: 同じプレビューを再利用することでキャッシュが効く
  3. 柔軟な探索: モデルは必要な部分だけを Read で取得できる(offset/limit パラメータ)

デメリットと議論

  1. ラウンドトリップの増加: 「プレビューを見る → 詳細が必要 → Read で読む」というステップが増える
  2. ディスクI/Oのオーバーヘッド: 大量のツール実行があると、ファイル書き込み/読み込みが頻繁に発生
  3. モデルの負担: モデル自身が「どの情報が必要か」を判断しなければならない

Cursor のような「最初に全体像を送る」戦略は、このラウンドトリップを減らす代わりに、常に大きなコンテキストを占有する。

Claude Code はトークン効率とキャッシュ効率を重視し、「必要な時に必要な分だけ」というオンデマンドアプローチを選んだ。

Read ツールは特別扱いされている。src/utils/toolResultStorage.ts:54-63

// Infinity = hard opt-out. Read self-bounds via maxTokens; persisting its
// output to a file the model reads back with Read is circular.
if (!Number.isFinite(declaredMaxResultSizeChars)) {
  return declaredMaxResultSizeChars
}

Read ツールの出力を再びファイルに退避することはない(無限ループを防ぐため)。Read ツール自身が maxTokens でサイズを制限する。


コンテキスト圧縮——「推論スペース」の確保

大きなセッションでは、履歴自体がコンテキストウィンドウを圧迫する。Claude Code は動的に履歴を圧縮する。

圧縮の閾値

src/services/compact/autoCompact.ts:72-91

export function getAutoCompactThreshold(model: string): number {
  const effectiveContextWindow = getEffectiveContextWindowSize(model)
  return effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS  // 13k トークン
}

export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000

200k コンテキストウィンドウの場合:

  • 警告表示(UI が黄色): 約 147,000 トークン付近(約 73.5%
  • 自動圧縮の実行: 約 167,000 トークン付近(約 83.5%

docs/history.md:82-86

具体的な数値例(200k コンテキストの場合):

  • 警告表示(UIが黄色くなる): 約 147,000 トークン付近(約 73.5%
  • 自動圧縮の実行: 約 167,000 トークン付近(約 83.5%

推論スペースの重要性

単にコンテキストを使い切るのではなく、モデルが回答を生成するための「推論スペース(MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20k)」を常に予約する。

src/services/compact/autoCompact.ts:28-49

const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000

export function getEffectiveContextWindowSize(model: string): number {
  const reservedTokensForSummary = Math.min(
    getMaxOutputTokensForModel(model),
    MAX_OUTPUT_TOKENS_FOR_SUMMARY,
  )
  return contextWindow - reservedTokensForSummary
}

このスペースが不足すると、複雑な思考や詳細な要約ができなくなる。余裕を持った段階(75% 付近)での警告と、80% 強での強制的な圧縮が設計されている。


まとめ——「送らない」「退避する」「記憶する」

Claude Code の履歴管理は、以下の 3 つの原則で設計されている:

  1. 送らない: ディレクトリツリーや静的解析を最初から送らず、オンデマンドで取得
  2. 退避する: 巨大なツール結果をファイルに退避し、プレビューだけを送る(決定を凍結してキャッシュを効かせる)
  3. 記憶する: ファイルの mtime と内容をキャッシュし、古い情報による編集を防ぐ(部分読み取りも保護)

Cursor や gemini-cli のような「最初に全体像を送る」戦略と比較すると、トークン効率とキャッシュ効率を重視した設計だ。

ラウンドトリップは増えるが、巨大なコードベースでも快適に動作する理由は、この「送らない」設計思想にある。


参考資料:

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?