はじめに
本記事は「Context Engineering 完全入門」シリーズの第 2 回です。今回は ツール設計 を取り上げます。
AI エージェントのコンテキスト管理において、ツール出力の制御は最初の防波堤となります。どれほど優れた圧縮・分離の仕組みを持っていても、ツール設計が雑では意味がありません。本記事では Microsoft や業界研究で実証された 7 つのベストプラクティスを、実装コードと共にご紹介します。
なお、本記事ではプログラムに組み込むツールのような自分たちのコントロール下にあることを前提とします。MCPのような自分たちのコントロール下にないツールについては次回(第 3 回)で取り上げます。
前回:第 1 回 総論
次回:第 3 回 結果ハンドリング🗺️ 単独でも読めます:第 1 回(総論)を読んでいなくても、本記事だけでツール設計のベストプラクティスは習得できます。
📝 本記事は、生成 AI で作成した草案をベースに、筆者が加筆・修正し、技術的な正確性を確認した上で公開しています。
📝 本記事の要点
- 問題: 長時間タスクではトークン消費の 75% がツール出力。雑な設計で context は瞬時に汚染される
- 解決: ハードリミット + メタデータ完全性 + プロンプトキャッシング (KV-Cache) 固定化 など 7 つのベストプラクティス
- 実装の要点:
[Description]属性に「いつ使うか」を書き、メソッド側でMath.Min(limit, HardLineLimit)で再強制急ぐ方は §6 アンチパターン と末尾のチェックリストだけでも実装の指針になります。
🎯 本記事の対象者
- AI エージェントのツール(function calling)を定義している
- 「ツール出力が多すぎてコンテキストからあふれた」経験がある
- C# で
[Description]属性やAIFunctionFactory.Createを使っている - プロンプトキャッシュ(KV-Cache)でコストを削減したい
- コーディング支援ではなく業務エージェントを作りたい
1. ⭐ 原則:なぜツール設計が重要か
LLM の入力の大部分はツール出力
長時間タスクでは、ユーザーとの実際のやりとり(数十行)よりも、ツール呼び出しの応答(数万トークン)の方が圧倒的に支配的 になります。Manus 社の本番運用観測では入出力比 100:1 という数値が報告されています。
つまり「ツールが何を返すか」を制御することが、コンテキスト管理の最初の防波堤になります。これを怠ると、後段で何を頑張ってもコンテキストはすぐに汚染されます。
7 つのベストプラクティス
| # | プラクティス | 起源 |
|---|---|---|
| 1 | ハードリミットを置く | 一般原則 |
| 2 | メタデータの完全性 | VS Code Issue #309747 教訓 |
| 3 |
description の書き方(マイクロプロンプト) |
runSubagent 解析 |
| 4 | 件数キャップ時の選択基準(最大剰余法) | grep_search 最適化 |
| 5 | 正規表現の安全弁(ReDoS 対策) | 一般原則 |
| 6 | キャッシュフレンドリーな順序設計(プロンプトキャッシング最適化) | 業界研究 (2025/7) |
| 7 | Mask, Don't Remove(ツールセットの固定化) | 業界研究 (2025/7) |
ベストプラクティス 6・7 は 2025 年に業界標準化された概念で、Anthropic で 10× コスト差 を生むほど重要です。
2. 📐 パターン
ベストプラクティス 1:ハードリミットを置く
「全部読めるなら全部読ませる」のではなく、明示的な上限 を置きます。
VS Code Copilot Chat の read_file ツール定義の実例:
"Read the contents of a file. Line numbers are 1-indexed. This tool will truncate its output at 2000 lines and may be called repeatedly with offset and limit parameters."
これにより:
- 1 回の呼び出しで流れ込むトークン量を 予測可能 にする
- LLM に「これは部分読み込みかもしれない」と意識させる
ベストプラクティス 2:メタデータの完全性(最重要)
部分的にしか読ませない場合、「全体何行のうち何行を読んだか」を必ず伝える ことが重要です。
過去事例:VS Code Copilot Chat の Issue #309747
2026 年 2 月のコミット 7dc4b8e で、read_file の出力からメタデータヘッダが削除されました:
- File: 'skills/my-great-skill/SKILL.md'. Lines 1 to 200 (430 lines total):
- [file content here]
+ [file content here]
その結果、Anthropic 系モデル(Claude 4.6 Sonnet 等)が 「200 行で読み切った」と誤認 し、ファイル後半を完全に無視する重大なリグレッションが報告されました。
ツール結果のトリミングにおいて、部分的にしか読み込まない場合、「全体のうちどれくらいを読み込んだか」を必ず伝える ようにメタデータを設計してください。
ベストプラクティス 3:description の書き方
description は LLM にツールを正しく使わせるための マイクロプロンプト として機能します。
以下はdescriptionの書き方のマインドマップです。これらを意識して、descriptionを記載することでLLMによるツールの選択や結果のハンドリング精度が高くなります。
ベストプラクティス 4:件数キャップ時の選択基準(最大剰余法)
全体からデータを検索し複数件返す場合、上限到達時の 選択基準 を明確にします。単純な FIFO では不十分 で、最大剰余法による比例配分 が推奨されています。
VS Code Copilot Chat の grep_search 実装が採用し、本番 A/B テストで 55% トークンが削減された という結果が公開されています(Issue #322773)。
配分計算の例
maxResults=200、5 ファイルにマッチが分布:
| ファイル | 実マッチ数 | 理想配分 | 整数部 | 剰余 | 最終配分 |
|---|---|---|---|---|---|
| fileA.ts | 500 | 99.01 | 99 | 0.01 | 99 |
| fileB.ts | 300 | 59.41 | 59 | 0.41 | 59 |
| fileC.ts | 150 | 29.70 | 29 | 0.70 | 30 ← +1 |
| fileD.ts | 50 | 9.90 | 9 | 0.90 | 10 ← +1 |
| fileE.ts | 10 | 1.98 | 1 | 0.98 | 2 ← +1 |
| 合計 | 1010 | — | 197 | — | 200 |
追加ルール:min 1 match per file
配分結果が 0 になりそうなファイルも、マッチがあれば必ず 1 件は残します。これは「あるファイルにマッチがあったという事実」自体が LLM への重要な情報となります。
ベストプラクティス 5:正規表現の安全弁
ユーザー指定の正規表現を受け付ける場合は以下を実装します:
- ReDoS(正規表現サービス拒否攻撃) タイムアウト必須
- マッチ件数の上限
- バイナリファイルの除外
ベストプラクティス 6:キャッシュフレンドリーな順序設計(プロンプトキャッシング最適化)
2025 年後半に業界で重要性が認識された原則で、プロンプトキャッシュヒット時に大幅に割引がされるのと、再思考(re-thinking)が不要になるため応答速度が改善されるという効果があります。
F5(旧 NGINX)が Azure OpenAI で独立検証した結果、固定プレフィックス(stable prefix)で 65% のレイテンシ改善が確認されています。
| LLM プロバイダ | プロンプトキャッシュ割引 |
|---|---|
| Anthropic Claude | キャッシュヒット時 90% 割引(10× 差) |
| OpenAI GPT-5 系 | 自動 prefix caching(無料) |
| Azure OpenAI | プロンプトキャッシュ対応 |
4 つの実装ルール
以下は失敗例として「ルール1」を考慮しなかった場合で、現在日時をシステムプロンプトに動的に挿入するケースです。
正しい設計としては、ContextProvider経由で日時をユーザーメッセージ末尾に付加するか、もしくはプログラム構造を変化させたくない場合は日付のみにするという緩和策を導入するという解決策がございます。
❌ よくある失敗例
// NG: タイムスタンプを System Prompt に入れる
new ChatOptions
{
Instructions = $"Today is {DateTime.Now}. You are a helpful assistant."
// 秒精度のタイムスタンプで、リクエストごとに以降の全キャッシュが無効化される
}
✅ 正しい設計
// OK: System Prompt は不変、動的値は末尾の context provider 経由
new ChatOptions
{
Instructions = "You are a helpful assistant."
}
// 動的な「今日の日付」が必要なら ContextProvider 経由で末尾に append
ベストプラクティス 7:Mask, Don't Remove(ツールセットの固定化)
2025 年に業界標準化された原則です。会話の途中で 動的にツールを追加・削除するとプロンプトキャッシングが無効化される上に、LLM が混乱します。 これはツール定義が内部的にプロンプト冒頭部の一部(システムプロンプトと同格)として扱われるために発生します。
推奨事項は以下の通りです。会話セッション開始時に利用する可能性のあるツールをユーザー(または意図推定)にて設定し、以後変更しない設計とすることを心がけてください。
- セッション(会話)内でツールセットを固定
- ツールの利用可否を制御したい場合は logit masking で(一部モデルが対応)
- どうしても動的化が必要なら、サブエージェント(第 5 回)に分離
業務系でも「メインエージェントのツールセットは固定」「動的にツールを変更する場合はサブエージェントで」が現時点(2026年6月末)での正解です。
3. 💡 実装の方針と設計判断
サンプルコードの実装方針は以下の通りです。
設計判断 1:なぜ [Description] 属性を多用するか
ツール本体だけでなく 引数一つひとつに [Description] を付けます。LLM はメソッド名と引数名だけでは「何を渡すか」を正確に判断できないからです。「offset とは 0-indexed か 1-indexed か」「limit の上限はあるか」は description がなければ LLM にとって判別不能なためです。
設計判断 2:なぜハードリミットを「メソッド内」で再強制するか
LLM が limit=10000 と指定しても、メソッド側で Math.Min(limit, HardLineLimit) で再強制します。LLM は description の上限を無視することがあるため、「入力ルールはメソッド内でも確認する」ことが重要です。
設計判断 3:なぜメタデータを「ツール結果の文字列内」に埋め込むか
LLM が見るのは文字列(content)だけです。外部のメタデータは LLM に渡されません。「Partial read。残り N 行」と書けば LLM は 追加 read_file 呼び出し を自律的に判断できます。
設計判断 4:最大剰余法のアルゴリズム選択理由
Top-N だと大ファイルに偏った結果になり、Context Confusion を引き起こします。最大剰余法は 比例分配 しつつ、「マッチ 0 の偽報告」を防ぐ min 1 ルールを組み込める点が優れています。
設計判断 5:なぜ ContextProvider で動的値を末尾に注入するか
タイムスタンプ等の動的値は Instructions ではなく ContextProvider で末尾追加します。System Prompt に動的値を入れると毎リクエストでキャッシュ無効化され、コストが 10 倍になります。
4. 💻 実装:C# / Microsoft Agent Framework (MAF)
4-1. ReadFileTool — ハードリミット + メタデータ完全性
💻 ReadFileTool サンプルコード(C#・約65行、クリックで展開)
using System.ComponentModel;
using System.Text;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace ContextEngineering.Article02;
public class ReadFileTool
{
// 🔑 ハードリミットを const で定義
private const int HardLineLimit = 2000;
[Description(
"Read the contents of a file. Line numbers are 1-indexed. " +
// 🔑 description に "ALWAYS report total line count" を含める
// → LLM が結果を見た時に「メタデータがある」と認識して活用できる
"This tool will truncate its output at 2000 lines and may be called " +
"repeatedly with offset and limit parameters to read larger files in chunks. " +
"ALWAYS report total line count in the output metadata."
)]
public string ReadFile(
// 🔑 各引数にも Description を付与
// → LLM が「filePath には絶対パスを渡せばよい」と理解できる
[Description("Absolute path to the file")] string filePath,
[Description("Starting line (1-indexed)")] int offset = 1,
[Description("Max lines (capped at 2000)")] int limit = HardLineLimit)
{
// 🔑 防衛的プログラミング:LLM が limit=10000 と指定しても 2000 に制限
// → description の上限が無視された場合のセーフティネット
limit = Math.Min(limit, HardLineLimit);
// 🔑 エラーは「文字列で返す」のが MAF のツール規約
// → 例外を投げると LLM が回復しにくいので、エラーメッセージを返す方が良い
if (!File.Exists(filePath))
return $"Error: File not found: {filePath}";
var allLines = File.ReadAllLines(filePath);
var totalLines = allLines.Length;
// 🔑 1-indexed の offset を 0-indexed の配列インデックスに変換
var start = Math.Max(0, offset - 1);
var end = Math.Min(totalLines, start + limit);
var selected = allLines[start..end];
var sb = new StringBuilder();
// 🔑 ベストプラクティス 2: メタデータヘッダを必ず先頭に付ける
// → LLM が「現在の読み込み範囲」と「全体サイズ」を即座に把握できる
// → 「200 行で読み切った」誤認を防ぐ(Issue #309747 の教訓)
sb.AppendLine($"File: '{filePath}'. Lines {start + 1} to {end} (of {totalLines} total lines).");
// 🔑 部分読み込みの場合、追加読み込みの方法を明示
// → LLM が「次は offset=2001 で再呼び出しすれば良い」と判断できる
if (end < totalLines)
{
sb.AppendLine($"⚠️ This is a partial read. {totalLines - end} more lines exist. " +
$"Call ReadFile again with offset={end + 1} to continue.");
}
sb.AppendLine();
sb.AppendLine(string.Join("\n", selected));
return sb.ToString();
}
}
4-2. 最大剰余法による比例配分
このユーティリティは 4-3 の GrepSearchTool から使用されます。単体で動作可能な設計にしておくことで、他のツールからも再利用できます。
💻 QuotaDistribution サンプルコード(C#・約65行、クリックで展開)
public static class QuotaDistribution
{
/// <summary>
/// 最大剰余法(Largest Remainder Method)による比例配分。
/// ファイルごとのマッチ数を、totalQuota 件に比例配分する。
/// </summary>
public static Dictionary<string, int> DistributeLargestRemainder(
IReadOnlyDictionary<string, int> matchesPerFile,
int totalQuota)
{
// 🔑 早期リターン:マッチが 0 なら配分も 0
var totalMatches = matchesPerFile.Values.Sum();
if (totalMatches == 0) return new();
// 🔑 早期リターン:実マッチ総数が totalQuota 以下なら比例配分は不要
// → これが無いと totalMatches < totalQuota の場合に Step 2 の floor(ideal) が
// 実マッチ数を超えて過剰配分してしまう(例: {A:3, B:2}, quota=200 で A に 120 件配分される等)
if (totalMatches <= totalQuota)
return matchesPerFile.ToDictionary(kv => kv.Key, kv => kv.Value);
// 🔑 Step 1: 理想配分(小数を含む比例値)を計算
// 例: fileA に 500/1010 * 200 = 99.01 件配分されるはず
var ideals = matchesPerFile.ToDictionary(
kv => kv.Key,
kv => (double)kv.Value / totalMatches * totalQuota);
// 🔑 Step 2: 整数部を取って暫定配分
// 小数を切り捨てるので、合計は totalQuota より少なくなる
var allocated = ideals.ToDictionary(kv => kv.Key, kv => (int)Math.Floor(kv.Value));
// 🔑 Step 3: min 1 ルール
// 「マッチがあるのに 0 件配分」を防ぐ
// → LLM に「このファイルにはマッチが無い」と誤認させない
foreach (var key in allocated.Keys.ToList())
{
if (matchesPerFile[key] > 0 && allocated[key] == 0)
allocated[key] = 1;
}
// 🔑 Step 4: 剰余(小数部)の大きい順に +1 を割り振る
// 合計が totalQuota になるまで残りを公平に配分
var remainders = ideals
.Select(kv => (Path: kv.Key, Remainder: kv.Value - Math.Floor(kv.Value)))
.OrderByDescending(t => t.Remainder)
.ToList();
var used = allocated.Values.Sum();
var remaining = totalQuota - used;
foreach (var (path, _) in remainders)
{
if (remaining <= 0) break;
// 🔑 既に「実マッチ数」を超えていないかチェック
// → 例えば実マッチが 10 件しかないファイルに 11 件配分するのを防ぐ
if (allocated[path] < matchesPerFile[path])
{
allocated[path]++;
remaining--;
}
}
return allocated;
}
}
4-3. GrepSearchTool — 比例配分 + ReDoS 対策
💻 GrepSearchTool サンプルコード(C#・約85行、クリックで展開)
using System.Text.RegularExpressions;
public record GrepResult(string FilePath, int LineNo, string Text);
public class GrepSearchTool
{
private const int MaxResultsCap = 200;
private static readonly TimeSpan RegexTimeout = TimeSpan.FromSeconds(3);
[Description(
"Search files for a pattern. Results are capped at 200 matches " +
"distributed proportionally across files (largest-remainder method). " +
"Returns grep-style output: 'path' / 'lineNo:text'."
)]
public string GrepSearch(
[Description("Pattern to search (regex supported)")] string pattern,
[Description("Glob pattern of files to search")] string includeGlob = "**/*",
[Description("Max results (capped at 200)")] int maxResults = MaxResultsCap)
{
// 🔑 ハードリミット強制(ベストプラクティス 1)
maxResults = Math.Min(maxResults, MaxResultsCap);
// 🔑 ベストプラクティス 5: ReDoS 対策
// → 悪意ある正規表現で CPU を食い尽くされないようタイムアウト指定
Regex regex;
try
{
regex = new Regex(pattern, RegexOptions.Compiled, RegexTimeout);
}
catch (ArgumentException ex)
{
return $"Error: Invalid regex pattern: {ex.Message}";
}
var allMatches = ExecuteSearch(regex, includeGlob).ToList();
// 🔑 マッチ 0 件を明示的に返す(空文字と区別し、LLM に「検索は成功したが該当なし」と伝える)
if (allMatches.Count == 0)
return "No matches found.";
// 🔑 ファイルごとにグループ化して、各ファイルのマッチ数を集計
var matchesPerFile = allMatches
.GroupBy(m => m.FilePath)
.ToDictionary(g => g.Key, g => g.Count());
// 🔑 ベストプラクティス 4: 最大剰余法で比例配分
var quota = QuotaDistribution.DistributeLargestRemainder(matchesPerFile, maxResults);
// 🔑 grep スタイル(path 行 + lineNo:text 行)で整形
var sb = new StringBuilder();
foreach (var (filePath, allowedCount) in quota.OrderBy(kv => kv.Key))
{
sb.AppendLine(filePath);
var fileMatches = allMatches
.Where(m => m.FilePath == filePath)
.Take(allowedCount);
foreach (var match in fileMatches)
{
// 🔑 長行のトリミング:頭と尻尾を残して中央を省略
// → 長すぎる行で context を圧迫しない
var trimmedText = TrimLongLine(match.Text, maxLength: 200);
sb.AppendLine($"{match.LineNo}:{trimmedText}");
}
sb.AppendLine();
}
return sb.ToString();
}
private static string TrimLongLine(string line, int maxLength)
{
if (line.Length <= maxLength) return line;
var head = line[..(maxLength / 2)];
var tail = line[^(maxLength / 2)..];
return $"{head}...{tail}";
}
private static IEnumerable<GrepResult> ExecuteSearch(Regex regex, string glob)
{
// 実装は省略(環境依存。ripgrep CLI を呼ぶか System.IO + glob マッチャーで実装)
yield break;
}
}
設計判断の要点:ReDoS 対策とバイナリファイル除外の実装
ExecuteSearch の実装(環境依存のため上記では省略)では、ベストプラクティス 5 で挙げた「ReDoS タイムアウト」と「バイナリファイルの除外」を以下のように実装します。マッチのループ内で個別に例外を捕捉することで、1 行のタイムアウトが検索全体を止めない設計にしています。このパターンにより、悪意ある正規表現が入力されても検索全体が停止することなく、可能な限りの結果を返せます。ベストプラクティス 5(正規表現の安全弁)を実装レベルで完成させる設計です。
💻 ExecuteSearch — ReDoS対策とバイナリファイル除外の実装例(C#・約20行、クリックで展開)
foreach (var file in matchedFiles)
{
// 🔑 バイナリファイルの除外(先頭バイトに NUL 文字が含まれるかで簡易判定)
if (!TryReadAllLines(file, out var lines)) continue;
for (var i = 0; i < lines.Length; i++)
{
try
{
// 🔑 ReDoS 対策: RegexTimeout で長時間実行を強制打ち切り
if (regex.IsMatch(lines[i]))
yield return new GrepResult(file, i + 1, lines[i]);
}
catch (RegexMatchTimeoutException)
{
continue; // タイムアウトした行はスキップして検索を継続
}
}
}
4-4. キャッシュフレンドリーなシステムプロンプト設計
💻 CacheFriendlyAgentBuilder サンプルコード(C#・約65行、クリックで展開)
public static class CacheFriendlyAgentBuilder
{
public static AIAgent Build(
IChatClient chatClient,
string stableSystemPrompt,
IEnumerable<AITool> tools)
{
var options = new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
// 🔑 ベストプラクティス 6: 固定プレフィックス
// ❌ NG: $"Today is {DateTime.Now}. ..."
// → タイムスタンプでキャッシュが毎回無効化される
// ✅ OK: 動的な値を入れない
Instructions = stableSystemPrompt,
// 🔑 ベストプラクティス 7: ツールセットを session 内で固定
// → session 中にツールを add/remove するとキャッシュが無効化される
Tools = tools.ToList(),
// 🔑 Anthropic 系モデルでは cache_control を明示
// → "この時点でキャッシュ可能" と Anthropic に伝える
AdditionalProperties = new AdditionalPropertiesDictionary
{
["anthropic_cache_control"] = new Dictionary<string, object?>
{
["type"] = "ephemeral", // 5分のキャッシュ
["breakpoint"] = "system_end",
},
},
},
// 🔑 動的値(タイムスタンプ等)は ContextProvider 経由で末尾追加する
// → System Prompt(Instructions) 自体は不変に保ち、キャッシュヒットを維持する
AIContextProviders = [new DynamicContextProvider()],
};
return chatClient.AsAIAgent(options);
}
}
/// <summary>
/// 動的値(タイムスタンプ、ユーザー固有情報等)を末尾追加する Provider
/// </summary>
public sealed class DynamicContextProvider : AIContextProvider
{
// 🔑 コンストラクタは base(null, null) 固定(provideInputMessageFilter/storeInputMessageFilter は非公開)
public DynamicContextProvider() : base(null, null) { }
// 🔑 ProvideAIContextAsync は「LLM 呼び出し直前」のフック(protected override)
// → ここで context を末尾追加すれば System Prompt のキャッシュは保たれる
protected override ValueTask<AIContext> ProvideAIContextAsync(
InvokingContext context,
CancellationToken cancellationToken = default)
{
// 🔑 末尾に system role で動的値を追加
// → System Prompt の冒頭は変更されないので、キャッシュヒットを維持
return new ValueTask<AIContext>(new AIContext
{
Messages = [new ChatMessage(ChatRole.System,
$"[Runtime context: timestamp={DateTime.UtcNow:O}]")],
});
}
}
4-5. 決定論的(キーの順序性維持) JSON シリアライズ
💻 DeterministicJson サンプルコード(C#・約50行、クリックで展開)
using System.Text.Json.Nodes;
public static class DeterministicJson
{
private static readonly JsonSerializerOptions Options = new()
{
// 🔑 ベストプラクティス 6 ルール 4: キー順序の決定論化
// 既定では .NET の JSON シリアライザは挿入順を保持するが
// 念のため明示的にソートする
WriteIndented = false,
};
public static string Serialize<T>(T obj)
{
// 🔑 ソートされた辞書として書き出す
// → リクエスト間で同じオブジェクトが必ず同じ JSON 文字列になる
// → キャッシュキーとして固定する
var json = JsonSerializer.Serialize(obj, Options);
var node = JsonNode.Parse(json);
var sorted = SortKeys(node);
return sorted?.ToJsonString(Options) ?? "null";
}
private static JsonNode? SortKeys(JsonNode? node)
{
switch (node)
{
case JsonObject obj:
// 🔑 再帰的にキーをソート(序数比較で決定論的な順序を保証)
var sortedObj = new JsonObject();
foreach (var key in obj.Select(p => p.Key).OrderBy(k => k, StringComparer.Ordinal))
sortedObj[key] = SortKeys(obj[key]);
return sortedObj;
case JsonArray arr:
// 🔑 配列の要素順序自体は意味を持つため変更せず、各要素の内部だけソートする
var sortedArr = new JsonArray();
foreach (var item in arr)
sortedArr.Add(SortKeys(item));
return sortedArr;
case null:
return null;
default:
// 🔑 スカラー値は DeepClone して新しいツリーに付け替え可能にする
// (JsonNode は単一の親しか持てないため)
return node.DeepClone();
}
}
}
5. 📊 ケーススタディ
read_file の設計比較
read_file に相当する機能は主要ツールで実装されており、offset/limit による範囲指定は概ね共通です。差が出るのは返却フォーマット、特に「全体何行中の何行目までを読んだか」を示すメタデータの提示方法です。
| 観点 | VS Code Copilot Chat | Codex CLI | Claude Code |
|---|---|---|---|
| ハードリミット | 2000 行 | 設定可能 | 1500 行(デフォルト) |
| メタデータ※ | リグレッションあり(下記参照) | 専用ツール未提供(shell 経由) | 行番号プレフィックス方式 |
| offset/limit | あり | あり | あり |
※「メタデータ」は、File: 'path'. Lines 1 to 200 (430 lines total): のようなヘッダ行や、cat -n 形式で先頭に付与される行番号など、LLM が「全体何行のうちどこを読んだか」を判定するための付帯情報を指します。
grep_search の最適化(Issue #322773)
VS Code Copilot Chat の Issue #322773 に 本番テレメトリ が公開されています。
記載の通り総トークン数が 約52.3% 削減されています。
| 指標 | Control(tag) | Treatment(grep) | 改善 |
|---|---|---|---|
| 平均トークン/コール | 1,160 | 516.6 | -55.5% |
| P50 | 321.2 | 137.7 | -57.1% |
| P95 | 5,275.4 | 2,123.6 | -59.7% |
| 総トークン | 421.5M | 201.0M | -52.3% |
統計的有意性も確認しており、Microsoft社はこれを COGS(Cost of Goods Sold)削減施策として位置付けています。
プロンプトキャッシング最適化の実証
主要 LLM プロバイダの通常入力単価と、プロンプトキャッシングヒット時の単価を比較します(2026年6月末時点)。
全てのプロバイダで 入力単価の約 10 分の 1(90% 割引)まで低下する仕様となっています。長い System Prompt や大規模な Retrieved Context を繰り返し使うワークロードでは、コストが約 10 倍変わる意味を持ちます。
| プロバイダ | 代表モデル | 通常入力単価 | キャッシュヒット時 | 割引率 |
|---|---|---|---|---|
| Anthropic | Claude Sonnet 4.6 | $3.00 / MTok | $0.30 / MTok | 90% |
| OpenAI | GPT-5.5 | $1.25 / MTok | $0.125 / MTok | 90% |
| Gemini 3.1 Pro | $2.00 / MTok | $0.20 / MTok | 90% | |
| Azure OpenAI / AWS Bedrock | GPT-5 系 / Claude 系 | 各ベンダー直と同等 | 各ベンダー直と同等 | 90% |
注意点:
-
Anthropic 系では、キャッシュへの初回書き込み時に約 1.25 倍の割増料金が発生します(5 分キャッシュの場合、1 時間キャッシュは 2 倍)。
リクエスト頻度が低いワークロードでは、キャッシュを使わない方が安いケースもあるため注意ください。 - OpenAI 系(GPT-5 など) と Azure OpenAI では書き込み時の割増はなく、自動でキャッシングされます
- キャッシュ TTL は Anthropic が 5 分(1 時間拡張可)、OpenAI が 5〜10 分、Gemini が 60 分(明示的キャッシュの場合)
- 最小キャッシュサイズは各社 1024 トークン以上 が原則となります
6. ⚠️ アンチパターン
本記事で扱ったツール設計の要点を、実装時のセルフレビュー用チェックリスト(アンチパターン)としてまとめました。
実装時は本表を「やってはいけないことリスト」としてご参照ください。特に #7・#8 はプロンプトキャッシング維持と LLM の混乱回避の観点で、本番コストと動作の安定性に直結する重要項目です。
| # | アンチパターン | 根拠 |
|---|---|---|
| 1 | ハードリミットを明記しない | 予測不能なトークン量が流入 |
| 2 | メタデータを省略 | Issue #309747 |
| 3 | トリミングしたことを LLM に伝えない | 第 3 回で詳述 |
| 4 | description が曖昧 | LLM が誤用 |
| 5 | 件数キャップ時に FIFO | 情報の代表性が失われる |
| 6 | ReDoS 対策なしで正規表現を受け付ける | セキュリティ |
| 7 | System Prompt にタイムスタンプ等の動的値を入れる | プロンプトキャッシング無効化 |
| 8 | Session 中にツールセットを動的変更する | プロンプトキャッシング無効化 + LLM 混乱 |
おわりに
本記事では、ツール設計における 7 つのベストプラクティスと、それぞれの実装パターンをご紹介しました。特に プロンプトキャッシング (KV-Cache) 最適化(ベストプラクティス 6・7)は、本番運用でのコストに直結する重要な設計判断です。
次回は「結果ハンドリング」として、MCP 経由の外部ツールから返ってくる巨大な出力をどう制御するかを解説します。記事 2 の設計を完璧にしても対処しきれないケースがあり、そこで登場するのが ContextProvider を使ったラッパーパターンです。
参考文献
VS Code 関連
- VS Code Issue #322773「[AHP/CLI:COGS] Grep Search Tool Output Optimization」
- VS Code Issue #309747「read_file tool regression: removing line count metadata causes Anthropic models to silently stop reading files mid-way」
- VS Code Issue #270381「Raise or make configurable the hard 200-result cap in Copilot's grep_search」
- microsoft/vscode-copilot-chat
findTextInFilesTool.tsx(2026/5/20 にアーカイブ)
ベンダー公式(プロンプトキャッシュ)
- Anthropic「Prompt Caching」公式ドキュメント (Claude Platform Docs)
-
OpenAI「Prompt Caching」公式ガイド (OpenAI Developers Docs)
- 補足: Azure OpenAI 版は Microsoft Learn「Prompt caching with Azure OpenAI in Microsoft Foundry Models」 も参照。
業界検証・解析
- Ankit Sinha (F5) (2025/8/18). "KV-Cache Aware Prompt Engineering — How Stable Prefixes Unlock 65% Latency Improvements" (Azure OpenAI GPT-4.1-mini を用いた prefix caching 実測)
- Manus AI / Yichao "Peak" Ji (2025/7/18). "Context Engineering for AI Agents: Lessons from Building Manus" (Part 1)