実運用アプリにおけるLLMコンテキストの管理

Dev.to / 2026/3/28

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical Usage

要点

  • 投稿では、「上限なし」のLLMチャット履歴が、特に1つのユーザ要求に対してアプリが複数のエージェント/ツールのターンを行う場合、1ターンあたりのトークン使用量を数万規模まで急速に押し上げてしまう理由を説明します。
  • Claudrielの本番運用アプローチでは、会話履歴を最大20メッセージにトリミングし、API呼び出しの前に古いアシスタントの内容を500文字に切り詰めたうえで「[truncated]」のマーカーを付けることで、コンテキスト量を制限します。
  • これは、コストと信頼性という実運用上の制約に対処するため、タスクごとのターン予算を強制し、レート制限に当たった際のモデル劣化への対応を行い、複数ターンのエージェントワークフローにおけるプロンプトの膨張(bloat)を防止します。
  • さらに、パフォーマンス最適化と可観測性にも触れています。プロンプトキャッシュにより繰り返しプロンプトのコストを削減し、ターンごとのトークン使用量のテレメトリによって、予算管理やデバッグに活用できる情報を提供します。
  • 全体として、本記事は、クライアント側のメッセージ履歴の増加に任せるのではなく、運用でLLMのコンテキストサイズ・コスト・レイテンシを制御するための実践的なパターンを示しています。

Ahnii!

この投稿では、Claudriel という Waaseyaa ベースの AI アシスタント SaaS が、プロダクションにおいて LLM のコンテキストをどう扱っているかを説明します。具体的には、会話のトリミング、タスクごとのターン予算、レート制限時におけるモデル劣化、プロンプトキャッシュ、そしてターンごとのトークン・テレメトリです。

上限のないコンテキストの問題

LLM API に送信するたびに、トークンのコストがかかります。長時間のチャットセッションでは履歴がすぐに膨れ上がります。放置すると、単一のアクティブセッションが、モデルがまだ1語も生成する前から、1ターンあたり数万トークンの入力トークン数まで押し上げることがあります。

Claudriel は、ユーザーの依頼ごとに複数のエージェント・ターンを実行します(メールを読む、カレンダーを確認する、エンティティをクエリするなど)。各ターンでは、ツール定義に加えて会話履歴全体が送信されます。ガードレールがない場合、コストは増幅し、レート制限が予測不能なタイミングで発火します。

API に到達する前に会話履歴をトリミングする

最初の防衛線は ChatStreamController::trimConversationHistory() です。API へ送る前に、履歴は最大 20 メッセージにトリミングされます。そのウィンドウを超えた古いアシスタントの応答は、[truncated] マーカー付きで 500 文字に切り詰めます。

private function trimConversationHistory(
    array $sessionMessages,
    int $maxMessages = 20,
    int $olderAssistantMaxChars = 500,
): array {
    $total = count($sessionMessages);

    if ($total <= $maxMessages) {
        return array_map(
            fn ($m) => ['role' => $m->get('role'), 'content' => $m->get('content')],
            $sessionMessages,
        );
    }

    $recentCount = min(4, $total);
    $cutoff = $total - $recentCount;
    $olderStart = max(0, $total - $maxMessages);
    $trimmedCount = $olderStart;

    // ... 古いアシスタント応答を切り詰め、トリム通知を注入する ...
}

最後の4メッセージ(2つのやり取り)は常に完全な形で保持されます。メッセージが削除される場合、最初に保持されるユーザーメッセージの先頭に通知を付けます:[Earlier conversation trimmed — N messages]。これにより、履歴全体にトークンを使い切ることなく、コンテキストが切り詰められたことをモデルが認識できるようにしています。

これだけでも長いセッションにおける入力トークン増加の上限を設けられますが、単一のエージェント・ターン内でのコストまでは対処できません。

タスクごとのターン予算

エージェントのタスクは、必要なツール呼び出しの数によってターン数が異なります。カレンダー参照は2〜3ターンで済みます。調査タスクは40ターン必要かもしれません。同じ扱いにすると、単純なタスクではトークンを無駄にし、複雑なタスクでは枯渇します。

NativeAgentClient クラスは、最初のユーザーメッセージをキーワードマッチングして各リクエストを分類し、テーブルからターン上限を参照します:

private const DEFAULT_TURN_LIMITS = [
    'quick_lookup'     => 5,
    'email_compose'    => 15,
    'brief_generation' => 10,
    'research'         => 40,
    'general'          => 25,
    'onboarding'       => 30,
];

これらはデフォルトの上限です。ワークスペースは turnLimitsOverride を使ってセッションごとに上書きできます。また、個別の呼び出しではハードな turnLimitOverride を渡せます。セッションのメタデータには適用された上限と消費済みターン数が保存されるため、複数パートの継続では中断したところから引き継げます。

エージェントが上限に近づいたとき、具体的には上限に到達する1ターン前に、黙って打ち切るのではなく onNeedsContinuation コールバックを発火します:

if ($turnLimit > 1 && $turnsWithinCall >= $turnLimit - 1) {
    if ($onNeedsContinuation !== null) {
        $onNeedsContinuation([
            'turns_consumed' => $turnsConsumed,
            'task_type'      => $taskType,
            'message'        => 'I need more turns to complete this task. Continue?',
        ]);
    }

    break;
}

フロントエンドはこれを進捗イベントとして受け取り、未完了で説明のないレスポンスを返すのではなく、ユーザーに続行を促すことができます。

ツール結果の切り詰め

モデルに返される各ツール結果にも上限があります。デフォルトではNativeAgentClientがツール結果を2,000文字に制限します。Gmailのメッセージ本文は冗長になりがちで、エージェントが本文全体を使う必要がめったにないため、より厳しい500文字の上限が設定されます。

private const TOOL_RESULT_MAX_CHARS = 2000;
private const GMAIL_BODY_MAX_CHARS  = 500;

切り詰められた場合、データが不完全というより「切られた」ことをモデルが理解できるように[truncated]が追記されます。ツール結果はターンごとにペイロードサイズを制限します。どのトークンがどれだけコスト高になるかは、モデルの選択によって決まります。

ワークスペースごとのモデル選択

モデルはグローバルではありません。ChatStreamController::resolveChatModel()はまずワークスペースエンティティのanthropic_modelフィールドを確認し、次にANTHROPIC_MODEL環境変数にフォールバックし、最後にアプリケーションのデフォルト(claude-sonnet-4-6)を使います。

private function resolveChatModel(?Workspace $workspace): string
{
    $workspaceModel = $workspace?->get('anthropic_model');
    if (is_string($workspaceModel)) {
        $trimmed = trim($workspaceModel);
        if ($trimmed !== '' && isset(self::ALLOWED_ANTHROPIC_MODELS[$trimmed])) {
            return $trimmed;
        }
    }

    return $this->resolveDefaultModel();
}

許可リストにあるモデルのみが受け付けられます。

private const ALLOWED_ANTHROPIC_MODELS = [
    'claude-opus-4-6'            => true,
    'claude-sonnet-4-6'          => true,
    'claude-haiku-4-5-20251001'  => true,
];

つまり、高ボリュームで低複雑度のワークフローを実行するワークスペースはHaikuに固定でき、研究寄りのワークスペースはOpusを使う、といったことが、コード変更なしに可能です。ワークスペースごとのモデル選択は、API側が異議を唱えるまでは機能します。

レート制限時の自動モデル劣化

ワークスペースごとのモデル選択をしていても、レート制限は発生します。APIが3回のリトライの後にレート制限エラーを返した場合、NativeAgentClientはリクエストを失敗させるのではなく、次に安価なモデルへ劣化(ダウングレード)させます。

private const MODEL_DEGRADATION = [
    'claude-opus-4-6'           => 'claude-sonnet-4-6',
    'claude-sonnet-4-6'         => 'claude-haiku-4-5-20251001',
    'claude-haiku-4-5-20251001' => null,
];

フォールバックがnullの場合(すでに最安ティアの場合)は、リクエストは失敗してエラーが表面化します。エスカレーションは逆に動作します。つまり、レート制限ではないAPIエラーの場合は諦める前に、より高機能なモデルを試します。

この2つの遷移はいずれもprogressイベントをフロントエンドへ発火するため、ユーザーにはスピナーが固まるのではなく、「claude-opus-4-6でレート制限が使い切られ、claude-sonnet-4-6へフォールバックします」のような表示が見えます。

繰り返しトークンコストを減らすためのプロンプトキャッシュ

システムプロンプトとツール定義は、すべてのAPI呼び出しで送信されます。Anthropicのプロンプトキャッシュでは、これらをキャッシュ可能としてマークできます。すると、モデルは以前に処理されたトークンを、コストの一部で再利用できます。

NativeAgentClientは、各API呼び出しの前に2つの場所へcache_control: ephemeralを適用します。

$cachedSystem = [[
    'type'          => 'text',
    'text'          => $systemPrompt,
    'cache_control' => ['type' => 'ephemeral'],
]];

// キャッシュのために最後のツール定義をマーク
$cachedTools = $toolDefinitions;
if ($cachedTools !== []) {
    $lastIdx = count($cachedTools) - 1;
    $cachedTools[$lastIdx]['cache_control'] = ['type' => 'ephemeral'];
}

システムプロンプトとツール一覧の全体という2つの最大の静的入力です。これらをキャッシュすることが、多ターンセッションにおけるコスト削減として最も効果が大きい施策です。キャッシュ済みトークンはレート制限に対しては引き続きカウントされるため、ターンの予算が不要になるわけではありませんが、長いエージェント型セッションのドルコストを大幅に下げられます。

デフォルトのキャッシュTTLは5分です。より長寿命のセッションでは、Anthropicが追加費用で1時間のTTLを提供しています:"cache_control": {"type": "ephemeral", "ttl": "1h"}。作業日全体にわたってアシスタントを稼働させ続けるなら、検討する価値があります。キャッシュはコストを下げますが、テレメトリがなければ、どれくらい下がるのかは分かりません。

ターンごとのトークン・テレメトリ

コストの可視化には、各ターンで実際に何が消費されたのかを知る必要があります。APIレスポンスの直後に、NativeAgentClient はターンごとの利用データを引数にして onTelemetry コールバックを発火し、以下のような情報を渡します:

if (is_array($usage) && $onTelemetry !== null) {
    $onTelemetry([
        'turn_number' => $turnsConsumed,
        'task_type'   => $taskType,
        'model'       => $currentModel,
        'usage'       => $usage,
        'turn_limit'  => $turnLimit,
    ]);
}

ChatStreamController がこれを受け取り、ターンごとに chat_token_usage エンティティを書き込みます。入力トークン、出力トークン、キャッシュ読み取りトークン、キャッシュ書き込みトークンをそれぞれ別々に記録します:

$entry = new ChatTokenUsage([
    'uuid'               => $this->generateUuid(),
    'session_uuid'       => $sessionUuid,
    'turn_number'        => $turnNumber,
    'model'              => $model,
    'input_tokens'       => (int) ($usage['input_tokens'] ?? 0),
    'output_tokens'      => (int) ($usage['output_tokens'] ?? 0),
    'cache_read_tokens'  => (int) ($usage['cache_read_input_tokens'] ?? 0),
    'cache_write_tokens' => (int) ($usage['cache_creation_input_tokens'] ?? 0),
    'tenant_id'          => $tenantId,
    'workspace_id'       => $workspaceUuid,
    'created_at'         => (new \DateTimeImmutable)->format('c'),
]);

$storage->save($entry);

このデータがあれば、どのタスクタイプが最も多くのトークンを消費しているか、キャッシュヒットが実際に有効化されているか、モデルの劣化イベントがコストの急騰と相関しているかを、セッション単位だけでなくターン単位で確認できます。

レイヤーがどのように連携するか

これらは独立した機能ではありません。会話のトリミングは履歴の増大を抑えます。ターンの予算はエージェントの深さを制限します。ツール結果の切り詰めは、ターンごとのペイロードサイズを制限します。プロンプトキャッシュは、繰り返しコストを削減します。モデルの劣化は、ハードな失敗に至らずにレート圧力を処理します。テレメトリは、それらすべてを観測可能にします。

各レイヤーは、同じ問題の異なる部分に対処します。LLM API のコストと信頼性は、入力を能動的に形成し、エッジケースに対処しない限り非決定的です。プロダクションのAI機能を提供するということは、ハッピーパスだけでなく、これらすべてを提供することを意味します。

Baamaapii

広告