📦

Vercel AI SDKでGoogle Vertex AI(Gemini)のコンテキストキャッシュを使う

に公開

Vercel AIとVertex AIって空見するよね、ごめんな

はじめに

スマートラウンドで野生のAIエンジニアをやっている福本です🐈
普段はLangChainMastraを使ったAIワークフロー/エージェントの新規開発や運用をやっています。

スマートラウンドでは、MastraのワークフローからVercel AI SDKを呼び出しております。また、モデルはGoogle Vertex AIでGemini 2.5 Proを使うことが多いです 🧠

今回はその開発の中でVertex AIのコンテキストキャッシュを利用するに至ったので、調査した内容や実装方法について共有しようと思います!

コンテキストキャッシュとは

概要

コンテキストキャッシュとは、Geminiへのリクエストで同じコンテンツを繰り返し利用する際に、事前にコンテキストをキャッシュすることです(長くなるので、以下「キャッシュ」と呼びます)。名前の通りですね 💡

https://cloud.google.com/vertex-ai/generative-ai/docs/context-cache/context-cache-overview?hl=ja

キャッシュを利用すればコンテンツのトークン化を繰り返さなくて済むので、使用するトークン量を削減できます。これにより、LLMを利用する際のコストとレイテンシを削減できます。公式ドキュメントでは以下のように表現されています👇️

コンテキスト キャッシュ保存を使用すると、入力テキストまたはメディアのコンテキスト部分を Gemini モデルにキャッシュ保存することで、Gemini 入力トークン処理の費用を 75% 削減し、コンテンツ生成のレイテンシを短縮できます。

https://cloud.google.com/vertex-ai/generative-ai/pricing?hl=ja#context-caching

2種類のキャッシュ

本筋ではないのですが、整理のための補足としてキャッシュの種類についても触れておきます。Geminiには暗黙的・明示的の2種類のキャッシュがあります。以下、公式ドキュメントの文章をそのまま引用します↓

  • 暗黙的なキャッシュ保存: デフォルトで有効になっている自動キャッシュ保存。キャッシュヒットが発生した場合に費用を削減できる
  • 明示的なキャッシュ保存: Vertex AI API を使用して有効にする手動キャッシュ保存。ユーザーがキャッシュに保存するコンテンツと、プロンプトがキャッシュを参照するかどうかを明示的に宣言する

今回は、APIで意図的に作成し利用する”明示的キャッシュ” が該当します。暗黙的キャッシュは偶発的に発生するものであり、(無効化の設定を除き)コントロールできるものではありません 🙅

キャッシュ利用の流れ

「意図的にキャッシュを作るという点」まで整理したところで、キャッシュを作成しLLMでそれを利用する流れを以下のように整理しておきます。

料金の比較

続いて、コストの話について深堀りをしていきます。先ほど「キャッシュ利用によりコストが削減される」と記載しましたが、具体的には以下のように削減されると料金表に書かれています。キャッシュ利用分だけコスト削減されるので、動作確認でコスト感を把握するようにするのが良いです💸

※いずれもGemini 2.5 Proで、非バッチAPIの利用を前提としています

通常時(キャッシュ未使用)

通常時は以下です。こちらの方がシンプルで把握しやすいですね。

  1. 入力: (200kトークン以下) $1.25/[1M Token], (200kトークン超過) $2.50[1M Token]
  2. 出力: (200kトークン以下) $10/[1M Token], (200kトークン超過) $15[1M Token]

※公式ドキュメント: https://cloud.google.com/vertex-ai/generative-ai/pricing?hl=ja#gemini-models-2.5

キャッシュ利用

キャッシュ利用時はのコスト感はこちら。キャッシュ利用の流れに記載した全体像が理解できていないと把握しづらいので注意してください⚠️

  1. キャッシュ作成: (200kトークン以下) $1.25/[1M Token], (200kトークン超過) $2.50[1M Token] ※通常の入力と同じ
  2. キャッシュのストレージ費用: $4.5 [1M Token/1h)
  3. 入力
    3. キャッシュからの入力("C->E キャッシュからの入力"の箇所): (200kトークン以下) $0.31[1M Token], (200kトークン超過) $0.625[1M Token]}
    4. 非キャッシュ入力("D-F: 通常のプロンプト入力"の箇所) : (200kトークン以下) $1.25/[1M Token], (200kトークン超過) $2.50[1M Token] ※通常の入力と同じ
  4. 出力: (200kトークン以下) $10[1M Token], (200kトークン超過) $15[1M Token]

ポイントとしては、 「①キャッシュからの入力トークン(太字箇所)は75%割引」「②キャッシュ保持時間に応じて課金される」 の2点となります。

※公式ドキュメント: https://cloud.google.com/vertex-ai/generative-ai/pricing?hl=ja#context-caching
※料金表には75%割引された価格が載っていましたが、90%割引されると書かれたドキュメントもあります。本記事では料金表の方を正としています 🙏

注意点

以下に、キャッシュを作成/利用する際の注意点を記載しておきます⚠️

  • キャッシュに保存するコンテンツのトークン量は、最低2,048トークンが必要
  • 最大キャッシュサイズは10MB
    • 10MBを超える場合は、Cloud Storageを使う必要がある
  • キャッシュの最小有効期間は1分
  • モデルをまたいでキャッシュを利用できない

所感

上記のキャッシュの仕様を踏まえると、以下のようなユースケースには当てはまるケースではコストの大幅減が期待できます🎉

  • 長大なトークン容量となるコンテキストを処理する(ファイルや長文テキストなど)
  • 同じコンテキストを何度も参照してLLMを実行するワークフローを持つ

逆に、1回のコンテンツ容量が大きくなかったり、決まったコンテンツを処理せず臨機応変に対応する必要があるワークフロー(ex. AIエージェントのチャットなど)では、効果が薄いかもしれません。

実装

それでは、具体的な実装を見ていこうと思います 🙆

今回実装したファイルの全体は以下です(長くなるのでトグルにしています)🎞️
記事で実装全体が見渡しやすいように、意図的に1ファイルにすべてを記述するようにしています。

実装したindex.ts

コマンド引数で textを選ぶとテキスト処理、pdfを選ぶとPDFファイル処理になります(後述)

#!/usr/bin/env tsx
/**
 * VertexAI + ai-sdk コンテキストキャッシュサンプルコード
 * テックブログ記事用のサンプル実装
 *
 * 使用方法:
 *
 * 環境変数設定:
 * export GOOGLE_VERTEX_PROJECT="your-project-id"
 * export GOOGLE_VERTEX_LOCATION="your-location"  # オプション(デフォルト: us-central1)
 * export GOOGLE_APPLICATION_CREDENTIALS="path/to/service-account-key.json"
 *
 * 前提条件:
 * - 同じディレクトリに bocchan.txt ファイルを配置してください
 *
 * 実行:
 * npx tsx index.ts text   # bocchan.txtを読み込んでキャッシュ処理
 * npx tsx index.ts pdf    # PDFファイルをダウンロードしてキャッシュ処理
 */

import { createVertex } from '@ai-sdk/google-vertex';
import { generateObject } from 'ai';
import { z } from 'zod';
import * as fs from 'fs';
import * as path from 'path';
import dotenv from 'dotenv';
import iconv from 'iconv-lite';
// google-auth-libraryは@ai-sdk/google-vertexの依存関係として既にインストール済み
import { GoogleAuth } from 'google-auth-library';

// .envファイルを読み込み(プロジェクトルートから)
const projectRoot = path.resolve(process.cwd(), '../..');
const envPath = path.join(projectRoot, '.env');
dotenv.config({ path: envPath });

// 環境変数の設定
const GOOGLE_VERTEX_PROJECT = process.env.GOOGLE_VERTEX_PROJECT;
const GOOGLE_VERTEX_LOCATION = process.env.GOOGLE_VERTEX_LOCATION || 'us-central1';

if (!GOOGLE_VERTEX_PROJECT) {
  throw new Error('GOOGLE_VERTEX_PROJECT環境変数が設定されていません');
}

// 認証は以下の環境変数で自動的に処理されます:
// - GOOGLE_APPLICATION_CREDENTIALS: サービスアカウントキーファイルのパス

// 章の情報を定義するZodスキーマ
const ChapterSchema = z.object({
  index: z.number().min(1).describe('章の順番(1から開始)'),
  name: z.string().describe('章の名前'),
  summary: z.string().max(140).describe('章の概要(140文字以内)'),
});

const ChaptersSchema = z.object({
  chapters: z.array(ChapterSchema).describe('抽出された章の配列'),
});

// VertexAIクライアント初期化
const vertex = createVertex({
  project: GOOGLE_VERTEX_PROJECT!,
  location: GOOGLE_VERTEX_LOCATION,
});

// Google Cloud認証クライアント
const authClient = new GoogleAuth({
  scopes: ['https://www.googleapis.com/auth/cloud-platform'],
}).getClient();

/**
 * テキストからコンテキストキャッシュを作成
 */
async function createTextContextCache(text: string): Promise<string | null> {
  try {
    const response = await (await authClient).request({
      url: `https://${GOOGLE_VERTEX_LOCATION}-aiplatform.googleapis.com/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/cachedContents`,
      method: 'POST',
      data: {
        contents: [
          {
            role: 'user',
            parts: [
              {
                text: `以下のテキストを分析対象として準備しています:\n\n${text}`,
              },
            ],
          },
        ],
        model: `projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/publishers/google/models/gemini-2.5-pro`,
        ttl: '900s',
        displayName: `Text Cache - ${new Date().toISOString()}`,
      },
    });
    const cacheData = response.data as { name?: string };
    console.log('✅ テキストキャッシュ作成完了:', JSON.stringify(cacheData, null, 2));
    return cacheData.name ?? null;
  } catch (error: unknown) {
    console.error('❌ テキストキャッシュ作成エラー:', error);
    return null;
  }
}

/**
 * PDFファイルからコンテキストキャッシュを作成
 */
async function createPdfContextCache(pdfBuffer: Buffer): Promise<string | null> {
  try {
    const response = await (await authClient).request({
      url: `https://${GOOGLE_VERTEX_LOCATION}-aiplatform.googleapis.com/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/cachedContents`,
      method: 'POST',
      data: {
        contents: [
          {
            role: 'user',
            parts: [
              {
                text: 'このPDFファイルを分析対象として準備しています。',
              },
              {
                inlineData: {
                  mimeType: 'application/pdf',
                  data: pdfBuffer.toString('base64'),
                },
              },
            ],
          },
        ],
        model: `projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/publishers/google/models/gemini-2.5-pro`,
        ttl: '900s',
        displayName: `PDF Cache - ${new Date().toISOString()}`,
      },
    });
    const cacheData = response.data as { name?: string };
    console.log('✅ PDFキャッシュ作成完了:', JSON.stringify(cacheData, null, 2));
    return cacheData.name ?? null;
  } catch (error: unknown) {
    console.error('❌ PDFキャッシュ作成エラー:', error);
    return null;
  }
}

/**
 * コンテキストキャッシュを削除
 */
async function deleteContextCache(cacheName: string): Promise<void> {
  try {
    const cacheId = cacheName.split('/').pop()!;
    const response = await (await authClient).request({
      url: `https://${GOOGLE_VERTEX_LOCATION}-aiplatform.googleapis.com/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/cachedContents/${cacheId}`,
      method: 'DELETE',
    });
    console.log('✅ キャッシュ削除完了, レスポンス:', JSON.stringify(response, null, 2));
  } catch (error: unknown) {
    console.error('❌ キャッシュ削除エラー:', error);
  }
}

/**
 * ファイルをダウンロード
 */
async function downloadFile(url: string, outputPath: string): Promise<void> {
  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`ファイルのダウンロードに失敗: ${response.statusText}`);
  }

  const arrayBuffer = await response.arrayBuffer();
  const buffer = Buffer.from(arrayBuffer);
  fs.writeFileSync(outputPath, buffer);
}

/**
 * テキストファイルを読み込む(ShiftJISエンコーディング対応)
 */
function loadTextFile(filePath: string): string {
  if (!fs.existsSync(filePath)) {
    throw new Error(`テキストファイルが見つかりません: ${filePath}`);
  }

  // ShiftJISエンコーディングでファイルを読み込み
  const buffer = fs.readFileSync(filePath);
  return iconv.decode(buffer, 'shift_jis');
}

/**
 * テキストのみでLLMを呼び出す関数
 */
async function generateObjectWithText<T extends z.ZodType>(
  schema: T,
  prompt: string
): Promise<z.infer<T>> {
  const { object, usage } = await generateObject({
    model: vertex('gemini-2.5-pro'),
    schema,
    temperature: 0,
    topK: 1,
    topP: 1,
    seed: 1,
    maxRetries: 3,
    providerOptions: {
      google: {
        candidateCount: 1,
      },
    },
    messages: [
      {
        role: 'user',
        content: [
          {
            type: 'text',
            text: prompt,
          },
        ],
      },
    ],
  });

  console.log('📊 トークン使用量:', JSON.stringify(usage, null, 2));
  return object as z.infer<T>;
}

/**
 * PDFファイル付きでLLMを呼び出す関数
 */
async function generateObjectWithPdf<T extends z.ZodType>(
  schema: T,
  prompt: string,
  pdfBuffer: Buffer
): Promise<z.infer<T>> {
  const { object, usage } = await generateObject({
    model: vertex('gemini-2.5-pro'),
    schema,
    temperature: 0,
    topK: 1,
    topP: 1,
    seed: 1,
    maxRetries: 3,
    providerOptions: {
      google: {
        candidateCount: 1,
      },
    },
    messages: [
      {
        role: 'user',
        content: [
          {
            type: 'text',
            text: prompt,
          },
          {
            type: 'file',
            mediaType: 'application/pdf',
            data: pdfBuffer,
          },
        ],
      },
    ],
  });

  console.log('📊 トークン使用量:', JSON.stringify(usage, null, 2));
  return object as z.infer<T>;
}

/**
 * コンテキストキャッシュを使用してLLMを呼び出す関数
 */
async function generateObjectWithCache<T extends z.ZodType>(
  schema: T,
  prompt: string,
  cacheName: string
): Promise<z.infer<T>> {
  const { object, usage } = await generateObject({
    model: vertex('gemini-2.5-pro'),
    schema,
    temperature: 0,
    topK: 1,
    topP: 1,
    seed: 1,
    maxRetries: 3,
    providerOptions: {
      google: {
        candidateCount: 1,
        cachedContent: cacheName,
      },
    },
    messages: [
      {
        role: 'user',
        content: [
          {
            type: 'text',
            text: prompt,
          },
        ],
      },
    ],
  });

  console.log('📊 トークン使用量:', JSON.stringify(usage, null, 2));
  return object as z.infer<T>;
}

/**
 *章を抽出(コンテキストキャッシュあり)
 */
async function extractChaptersWithCache(cacheName: string): Promise<void> {
  console.log('🔍 コンテキストキャッシュを使用して章を抽出中...');

  const prompt = '提供されたテキストまたはPDFファイルから章の構造を分析し、各章の情報を抽出してください。章番号、章名、そして各章の概要(140文字以内)を提供してください。';

  const result = await generateObjectWithCache(
    ChaptersSchema,
    prompt,
    cacheName
  );

  console.log('📖 抽出された章:', JSON.stringify(result.chapters, null, 2));
}

/**
 * 章を抽出(コンテキストキャッシュなし・テキスト)
 */
async function extractChaptersWithoutCacheText(text: string): Promise<void> {
  console.log('🔍 コンテキストキャッシュを使わずに章を抽出中...');

  const prompt = `以下のテキストから章の構造を分析し、各章の情報を抽出してください。章番号、章名、そして各章の概要(140文字以内)を提供してください。\n\n${text}`;

  const result = await generateObjectWithText(
    ChaptersSchema,
    prompt
  );

  console.log('📖 抽出された章:', JSON.stringify(result.chapters, null, 2));
}

/**
 * 章を抽出(コンテキストキャッシュなし・PDF)
 */
async function extractChaptersWithoutCachePdf(pdfBuffer: Buffer): Promise<void> {
  console.log('🔍 コンテキストキャッシュを使わずに章を抽出中...');

  const prompt = 'このPDFファイルから章の構造を分析し、各章の情報を抽出してください。章番号、章名、そして各章の概要(140文字以内)を提供してください。';

  const result = await generateObjectWithPdf(
    ChaptersSchema,
    prompt,
    pdfBuffer
  );

  console.log('📖 抽出された章:', JSON.stringify(result.chapters, null, 2));
}

/**
 * テキストファイル処理のメイン関数
 */
async function processTextFile(): Promise<void> {
  const textFilePath = path.join(process.cwd(), 'bocchan.txt');

  try {
    console.log('📖 テキストファイルを読み込み中...');
    const text = loadTextFile(textFilePath);

    console.log(`✅ テキスト読み込み完了(${text.length}文字)`);

    // コンテキストキャッシュありでの処理
    console.log('\n🚀 === コンテキストキャッシュありでの処理 ===');
    const cacheName = await createTextContextCache(text);
    if (cacheName) {
      await extractChaptersWithCache(cacheName);
      await deleteContextCache(cacheName);
    }

    // コンテキストキャッシュなしでの処理(比較用)
    console.log('\n🚀 === コンテキストキャッシュなしでの処理(比較用) ===');
    await extractChaptersWithoutCacheText(text);

  } catch (error: unknown) {
    console.error('❌ テキスト処理エラー:', error);
    process.exit(1);
  }
}

/**
 * PDFファイル処理のメイン関数
 */
async function processPdfFile(): Promise<void> {
  const pdfUrl = 'https://tatsu-zine.com/samples/aozora/bocchan.pdf';
  const pdfPath = path.join(process.cwd(), 'bocchan.pdf');

  try {
    console.log('📥 青空文庫のPDFファイルをダウンロード中...');
    await downloadFile(pdfUrl, pdfPath);

    const pdfBuffer = fs.readFileSync(pdfPath);

    // コンテキストキャッシュありでの処理
    console.log('\n🚀 === コンテキストキャッシュありでの処理 ===');
    const cacheName = await createPdfContextCache(pdfBuffer);
    if (cacheName) {
      await extractChaptersWithCache(cacheName);
      await deleteContextCache(cacheName);
    }

    // コンテキストキャッシュなしでの処理(比較用)
    console.log('\n🚀 === コンテキストキャッシュなしでの処理(比較用) ===');
    await extractChaptersWithoutCachePdf(pdfBuffer);

  } finally {
    // 一時ファイルの削除
    if (fs.existsSync(pdfPath)) {
      fs.unlinkSync(pdfPath);
    }
  }
}

/**
 * メイン処理
 */
async function main(): Promise<void> {
  const args = process.argv.slice(2);
  const mode = args[0];

  if (!mode || !['text', 'pdf'].includes(mode)) {
    process.exit(1);
  }

  try {
    if (mode === 'text') {
      await processTextFile();
    } else if (mode === 'pdf') {
      await processPdfFile();
    }

  } catch (error: unknown) {
    console.error('❌ エラーが発生しました:', error);
    process.exit(1);
  }
}

// スクリプトが直接実行された場合のみメイン処理を実行
if (process.argv[1]?.endsWith('index.ts')) {
  main();
}

使用バージョン,モデル

今回利用したライブラリなどのバージョンやLLMmのモデル名を記載します。ざっくり、vercel/ai-sdkのバージョンが5系であればOKかと思います👌

  • TypeScript: 5系
  • Node.js: 22.20.0
  • vercel/ai-sdk: 5.0.0
    • @ai-sdk/google-vertex: 3.0.0
  • yarn: 1.22.22
  • Vertex AI: Gemini 2.5 Pro

前提

Vertex AIの利用に必要な以下環境変数が設定されていること:

export GOOGLE_VERTEX_PROJECT="your-project-id"
export GOOGLE_VERTEX_LOCATION="us-central1" 
export GOOGLE_APPLICATION_CREDENTIALS="path/to/service-account-key.json"

題材

テーマ

コンテキストキャッシュの効果を体感するために、青空文庫から夏目漱石の『坊っちゃん』を例に取って、テキストやPDFから後述するデータの抽出を試みます➿

抽出するスキーマ

今回は、テキストやPDFの中身から、以下のZodスキーマを用いて、本の章の情報を配列で抽出します:

const ChapterSchema = z.object({
  index: z.number().min(1).describe('章の順番(1から開始)'),
  name: z.string().describe('章の名前'),
  summary: z.string().max(140).describe('章の概要(140文字以内)'),
});

const ChaptersSchema = z.object({
  chapters: z.array(ChapterSchema).describe('抽出された章の配列'),
});

キャッシュ作成

AI SDK公式のドキュメントにもVertrex AIのキャッシュについて記載があるのですが、Anthropicのモデルを使う際のコード例なので、Vertex AIでGeminiのAPIに関するドキュメントも合わせて読む必要があります。

https://ai-sdk.dev/providers/ai-sdk-providers/google-vertex#cache-control

具体的には、以下のようにキャッシュを作成します。コストに関わるので、キャッシュを後から削除するとしても、ttlは破棄されても問題ない範囲で、ある程度短い時間を設定するのが良いです。

**
 * テキストからコンテキストキャッシュを作成
 */
async function createTextContextCache(text: string): Promise<string | null> {
  try {
    const response = await (await authClient).request({
      url: `https://${GOOGLE_VERTEX_LOCATION}-aiplatform.googleapis.com/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/cachedContents`,
      method: 'POST',
      data: {
        contents: [
          {
            role: 'user',
            parts: [
              {
                text: `以下のテキストを分析対象として準備しています:\n\n${text}`,
              },
            ],
          },
        ],
        model: `projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/publishers/google/models/gemini-2.5-pro`,
        ttl: '900s',
        displayName: `Text Cache - ${new Date().toISOString()}`,
      },
    });
    const cacheData = response.data as { name?: string };
    console.log('✅ テキストキャッシュ作成完了:', JSON.stringify(cacheData, null, 2));
    return cacheData.name ?? null;
  } catch (error: unknown) {
    console.error('❌ テキストキャッシュ作成エラー:', error);
    return null;
  }
}

キャッシュ作成に成功すると、以下のようなレスポンスが返却されます↓
(これはテキストデータに対してキャッシュを作成した例)

トークン数69,212がキャッシュされています。

{
  "name": "projects/{projectId}/locations/us-central1/cachedContents/{cacheId}",
  "model": "projects/{projectId}/locations/{location}/publishers/google/models/gemini-2.5-pro",
  "createTime": "2025-10-26T12:57:15.177960Z",
  "updateTime": "2025-10-26T12:57:15.177960Z",
  "expireTime": "2025-10-26T13:12:15.122259Z",
  "displayName": "Text Cache - 2025-10-26T12:57:12.895Z",
  "usageMetadata": {
    "totalTokenCount": 69212,
    "textCount": 104358
  }
}

ちなみに、PDFのキャッシュはこちら↓
キャッシュする箇所にinlineDataにファイルが含まれています。

/**
 * PDFファイルからコンテキストキャッシュを作成
 */
async function createPdfContextCache(pdfBuffer: Buffer): Promise<string | null> {
  try {
    const response = await (await authClient).request({
      url: `https://${GOOGLE_VERTEX_LOCATION}-aiplatform.googleapis.com/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/cachedContents`,
      method: 'POST',
      data: {
        contents: [
          {
            role: 'user',
            parts: [
              {
                text: 'このPDFファイルを分析対象として準備しています。',
              },
              {
                inlineData: {
                  mimeType: 'application/pdf',
                  data: pdfBuffer.toString('base64'),
                },
              },
            ],
          },
        ],
        model: `projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/publishers/google/models/gemini-2.5-pro`,
        ttl: '900s',
        displayName: `PDF Cache - ${new Date().toISOString()}`,
      },
    });
    const cacheData = response.data as { name?: string };
    console.log('✅ PDFキャッシュ作成完了:', JSON.stringify(cacheData, null, 2));
    return cacheData.name ?? null;
  } catch (error: unknown) {
    console.error('❌ PDFキャッシュ作成エラー:', error);
    return null;
  }
}

キャッシュ利用

そして、作成したキャッシュをLLMのリクエストに含めることで、明示的にキャッシュを使用します。公式ドキュメントは以下。

https://cloud.google.com/vertex-ai/generative-ai/docs/context-cache/context-cache-create?hl=ja

テキストをキャッシュしたコードは以下です。providerOptionscachedContent: cacheNameを指定すればOKです👌

/**
 * コンテキストキャッシュを使用してLLMを呼び出す関数
 */
async function generateObjectWithCache<T extends z.ZodType>(
  schema: T,
  prompt: string,
  cacheName: string
): Promise<z.infer<T>> {
  const { object, usage } = await generateObject({
    model: vertex('gemini-2.5-pro'),
    schema,
    temperature: 0,
    topK: 1,
    topP: 1,
    seed: 1,
    maxRetries: 3,
    providerOptions: {
      google: {
        candidateCount: 1,
        cachedContent: cacheName, // キャッシュを指定する部分
      },
    },
    messages: [
      {
        role: 'user',
        content: [
          {
            type: 'text',
            text: prompt,
          },
        ],
      },
    ],
  });

  console.log('📊 トークン使用量:', JSON.stringify(usage, null, 2));
  return object as z.infer<T>;
}

キャッシュ作成のレスポンスのうちname全体を渡す必要がある点に注意(cacheIdだけではダメ)

得られるレスポンスのうち、キャッシュに関するものは以下です↓
(テキストをキャッシュした場合のレスポンスを記載)

AI SDK v5系ではLanguageModelV2Usageが、generateObject返却値に含まれます。

{
  "inputTokens": 69291,
  "outputTokens": 908,
  "totalTokens": 73296,
  "reasoningTokens": 3097,
  "cachedInputTokens": 69212 
}

上記のうちポイントは**cachedInputTokensで、名前の通り「キャッシュから読み出されたトークン数」を指します**。今回の例では、入力トークン数であるinputTokens69,291のうち、キャッシュが占めるトークン数が69,212であり、大部分がキャッシュされていることがわかります✅

キャッシュ削除

最後に、余計な費用を抑えるためにキャッシュを削除しましょう。公式ドキュメントは以下。

https://cloud.google.com/vertex-ai/generative-ai/docs/context-cache/context-cache-delete?hl=ja

以下のようにキャッシュのIDを指定してDELETEのリクエストを投げれば🆗

/**
 * コンテキストキャッシュを削除
 */
async function deleteContextCache(cacheName: string): Promise<void> {
  try {
    const cacheId = cacheName.split('/').pop()!;
    const response = await (await authClient).request({
      url: `https://${GOOGLE_VERTEX_LOCATION}-aiplatform.googleapis.com/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}/cachedContents/${cacheId}`,
      method: 'DELETE',
    });
    console.log('✅ キャッシュ削除完了, レスポンス:', JSON.stringify(response, null, 2));
  } catch (error: unknown) {
    console.error('❌ キャッシュ削除エラー:', error);
  }
}

キャッシュ削除時のレスポンスは以下で、dataが空になります。

{
  "size": 0,
  "data": {},
  "config": {
    "url": "https://us-central1-aiplatform.googleapis.com/v1/projects/{projectId}/locations/{location}/cachedContents/{cacheId}",
    "method": "DELETE",
    "headers": {},
    "responseType": "unknown"
  }
}

動作確認

テキスト

テキストをキャッシュしてLLM実行->削除、およびキャッシュせずに実行した結果を以下に示します↓
(全体ログは長くなるので折りたたんでいます)

全体の実行ログ
$ yarn exec npx tsx index.ts text
yarn workspace v1.22.22
yarn exec v1.22.22
The `fromStream` method is deprecated. Please use the `JWT` constructor with a parsed stream instead. For more details, see https://cloud.google.com/docs/authentication/external/externally-sourced-credentials.
:book: テキストファイルを読み込み中...
:white_check_mark: テキスト読み込み完了(105638文字)

:rocket: === コンテキストキャッシュありでの処理 ===
The `fromJSON` method is deprecated. Please use the `JWT` constructor instead. For more details, see https://cloud.google.com/docs/authentication/external/externally-sourced-credentials.
:white_check_mark: テキストキャッシュ作成完了: {
  "name": "projects/{projectId/locations/{location}/cachedContents/{cacheId}}",
  "model": "projects/{projectId}/locations/{location}/publishers/google/models/gemini-2.5-pro",
  "createTime": "2025-10-26T12:44:54.503533Z",
  "updateTime": "2025-10-26T12:44:54.503533Z",
  "expireTime": "2025-10-26T12:59:54.441496Z",
  "displayName": "Text Cache - 2025-10-26T12:44:52.130Z",
  "usageMetadata": {
    "totalTokenCount": 69212,
    "textCount": 104358
  }
}
:mag: コンテキストキャッシュを使用して章を抽出中...
:bar_chart: トークン使用量: {
  "inputTokens": 69291,
  "outputTokens": 859,
  "totalTokens": 73193,
  "reasoningTokens": 3043,
  "cachedInputTokens": 69212
}
:book: 抽出された章: [
  {
    "index": 1,
    "name": "一",
    "summary": "主人公「坊っちゃん」の無鉄砲な幼少期と、彼を唯一可愛がる下女の清との交流が描かれる。両親の死後、兄と別れ、物理学校を卒業した坊っちゃんは、四国の中学校へ数学教師として赴任することを決意する。"
  },
  {
    "index": 2,
    "name": "二",
    "summary": "四国に到着した坊っちゃんは、野蛮な土地柄に辟易しつつ宿屋に入る。学校では校長の「狸」や教頭の「赤シャツ」らと面会し、同僚に「山嵐」「のだいこ」などのあだ名を付ける。山嵐の世話で下宿先が決まる。"
  },
  {
    "index": 3,
    "name": "三",
    "summary": "教壇に立った坊っちゃんは、生徒たちの生意気な態度に苦戦する。天ぷら蕎麦や団子を食べたことを黒板に書かれるなど、執拗ないたずらに悩まされる。下宿先の骨董好きの主人にも辟易し、学校も下宿も嫌になる。"
  },
  {
    "index": 4,
    "name": "四",
    "summary": "初めての宿直で、坊っちゃんは生徒たちから布団にバッタを入れられるいたずらを受ける。さらに寄宿生たちの騒ぎに激怒し、大立ち回りを演じる。駆け付けた校長は、事を穏便に済まそうとするのだった。"
  },
  {
    "index": 5,
    "name": "五",
    "summary": "赤シャツとのだいこに釣りに誘われた坊っちゃん。船上で二人は、山嵐が生徒を煽動しているかのような謎めいた会話を交わす。坊っちゃんは山嵐に不信感を抱き始めるが、赤シャツたちの態度にも不快感を覚える。"
  },
  {
    "index": 6,
    "name": "六",
    "summary": "山嵐を疑う坊っちゃんは彼と口論になる。しかし、宿直事件に関する会議で、山嵐は意外にも坊っちゃんを擁護し、生徒の厳罰を主張する。赤シャツと山嵐の対立、そして「マドンナ」を巡る関係が明らかになる。"
  },
  {
    "index": 7,
    "name": "七",
    "summary": "新しい下宿の婆さんから、うらなりと「マドンナ」の婚約話に赤シャツが割り込んでいると聞かされ、坊っちゃんは赤シャツが黒幕だと悟る。清からの手紙に心を温める一方、赤シャツとマドンナの密会を目撃する。"
  },
  {
    "index": 8,
    "name": "八",
    "summary": "赤シャツがうらなりを田舎へ追いやり、その給料を自分に回そうとしていると知った坊っちゃんは激怒。赤シャツの家へ乗り込み、増給を断固として断る。赤シャツの策略と偽善的な態度が浮き彫りになる。"
  },
  {
    "index": 9,
    "name": "九",
    "summary": "山嵐と和解した坊っちゃんは、赤シャツの悪事を暴くことを誓う。うらなりの送別会で、山嵐は痛烈な皮肉を込めた送別の辞を述べ、赤シャツを非難する。会は荒れ、坊っちゃんはのだいこを殴りつけてしまう。"
  },
  {
    "index": 10,
    "name": "十",
    "summary": "祝勝会で中学生と師範学校生が乱闘騒ぎを起こし、止めに入った坊っちゃんと山嵐が首謀者だと新聞に書かれる。赤シャツの策略で山嵐のみが辞職を迫られるが、坊っちゃんは義憤にかられ、自分も辞めると宣言する。"
  },
  {
    "index": 11,
    "name": "十一",
    "summary": "新聞に悪者にされた山嵐は辞職。坊っちゃんも辞表を叩きつけ、二人で赤シャツへの復讐を決意する。赤シャツと野だの密会現場を押さえて二人を叩きのめし、町を去る。東京に戻り、清と暮らすが、やがて清は亡くなる。"
  }
]

:rocket: === コンテキストキャッシュなしでの処理(比較用) ===
:mag: コンテキストキャッシュを使わずに章を抽出中...
:bar_chart: トークン使用量: {
  "inputTokens": 69278,
  "outputTokens": 816,
  "totalTokens": 73003,
  "reasoningTokens": 2909
}
:book: 抽出された章: [
  {
    "index": 1,
    "name": "一",
    "summary": "主人公「坊っちゃん」は無鉄砲な子供時代と下女の清との思い出を語る。両親の死後、物理学校を卒業し、衝動的に四国の中学教師の職を引き受け、清と涙の別れをする。"
  },
  {
    "index": 2,
    "name": "二",
    "summary": "四国に到着した坊っちゃんは、野蛮な土地に辟易する。学校では校長「狸」や教頭「赤シャツ」、同僚の「山嵐」らと出会い、彼らにあだ名を付ける。山嵐の世話で下宿先を見つける。"
  },
  {
    "index": 3,
    "name": "三",
    "summary": "教師生活が始まるが、生徒たちは坊っちゃんを天ぷらや団子のことでからかい、あだ名をつけてくる。下宿先の骨董責めにもうんざりし、温泉での振る舞いまで監視され、坊っちゃんの不満は募る。"
  },
  {
    "index": 4,
    "name": "四",
    "summary": "学校の宿直当番になった坊っちゃん。生徒たちに寝床へバッタを入れられるいたずらをされ、激怒する。さらに生徒たちは二階で騒ぎ立て、坊っちゃんは一晩中翻弄される。翌朝、校長も巻き込む騒動となる。"
  },
  {
    "index": 5,
    "name": "五",
    "summary": "赤シャツに釣りに誘われた坊っちゃん。船上で赤シャツは、生徒のいたずらの裏には山嵐がいると匂わせ、注意を促す。坊っちゃんは山嵐に不信感を抱き始める。土地の美人「マドンナ」の噂も耳にする。"
  },
  {
    "index": 6,
    "name": "六",
    "summary": "山嵐を疑う坊っちゃんは彼と口論になる。しかし、宿直事件の会議で山嵐は意外にも坊っちゃんを擁護し、生徒の厳罰を主張。赤シャツは寛大な処分を求める。坊っちゃんは誰を信じるべきか混乱する。"
  },
  {
    "index": 7,
    "name": "七",
    "summary": "新しい下宿に移った坊っちゃんは、うらなり君とマドンナの婚約を赤シャツが邪魔していると聞かされ、赤シャツが本当の悪人だと確信する。清からの手紙に心温まる。夜、赤シャツとマドンナの密会を目撃する。"
  },
  {
    "index": 8,
    "name": "八",
    "summary": "赤シャツこそが悪人だと確信した坊っちゃん。赤シャツがうらなり君を田舎に追いやり、その給料を自分に回そうとしていると知り激怒。増給を断るため赤シャツの家へ乗り込み、激しく口論する。"
  },
  {
    "index": 9,
    "name": "九",
    "summary": "山嵐と和解した坊っちゃんは、赤シャツへの復讐を誓う。うらなり君の送別会で、山嵐は赤シャツを痛烈に批判する演説をする。会は乱れ、坊っちゃんと山嵐は裸で踊る野だを殴りつけて会場を去る。"
  },
  {
    "index": 10,
    "name": "十",
    "summary": "祝勝会の日、中学と師範学校の生徒が衝突。止めに入った坊っちゃんと山嵐は喧嘩に巻き込まれ、警察に捕まる。清への返事が書けず悩む坊っちゃん。山嵐と赤シャツを懲らしめる計画を練る。"
  },
  {
    "index": 11,
    "name": "十一",
    "summary": "新聞に喧嘩を煽ったと書かれ、山嵐は辞職に追い込まれる。坊っちゃんも辞職を決意。二人は温泉宿で赤シャツを待ち伏せ、芸者と会っていた赤シャツと野だを叩きのめす。坊っちゃんは東京へ帰り、清と再会するが、清はその後亡くなる。"
  }
]
:sparkles:  Done in 95.00s.
:sparkles:  Done in 95.10s.

抽出結果

以下が抽出された章の情報です(章の名前が漢数字なのでnameのありがたみが薄い...)。昔読んだときのかすかな記憶と概要を照らし合わせると、うまく抽出できている気がしますね📖

:book: 抽出された章: [
  {
    "index": 1,
    "name": "一",
    "summary": "主人公「坊っちゃん」の無鉄砲な幼少期と、彼を唯一可愛がる下女の清との交流が描かれる。両親の死後、兄と別れ、物理学校を卒業した坊っちゃんは、四国の中学校へ数学教師として赴任することを決意する。"
  },
  {
    "index": 2,
    "name": "二",
    "summary": "四国に到着した坊っちゃんは、野蛮な土地柄に辟易しつつ宿屋に入る。学校では校長の「狸」や教頭の「赤シャツ」らと面会し、同僚に「山嵐」「のだいこ」などのあだ名を付ける。山嵐の世話で下宿先が決まる。"
  },
  {
    "index": 3,
    "name": "三",
    "summary": "教壇に立った坊っちゃんは、生徒たちの生意気な態度に苦戦する。天ぷら蕎麦や団子を食べたことを黒板に書かれるなど、執拗ないたずらに悩まされる。下宿先の骨董好きの主人にも辟易し、学校も下宿も嫌になる。"
  },
  {
    "index": 4,
    "name": "四",
    "summary": "初めての宿直で、坊っちゃんは生徒たちから布団にバッタを入れられるいたずらを受ける。さらに寄宿生たちの騒ぎに激怒し、大立ち回りを演じる。駆け付けた校長は、事を穏便に済まそうとするのだった。"
  },
  {
    "index": 5,
    "name": "五",
    "summary": "赤シャツとのだいこに釣りに誘われた坊っちゃん。船上で二人は、山嵐が生徒を煽動しているかのような謎めいた会話を交わす。坊っちゃんは山嵐に不信感を抱き始めるが、赤シャツたちの態度にも不快感を覚える。"
  },
  {
    "index": 6,
    "name": "六",
    "summary": "山嵐を疑う坊っちゃんは彼と口論になる。しかし、宿直事件に関する会議で、山嵐は意外にも坊っちゃんを擁護し、生徒の厳罰を主張する。赤シャツと山嵐の対立、そして「マドンナ」を巡る関係が明らかになる。"
  },
  {
    "index": 7,
    "name": "七",
    "summary": "新しい下宿の婆さんから、うらなりと「マドンナ」の婚約話に赤シャツが割り込んでいると聞かされ、坊っちゃんは赤シャツが黒幕だと悟る。清からの手紙に心を温める一方、赤シャツとマドンナの密会を目撃する。"
  },
  {
    "index": 8,
    "name": "八",
    "summary": "赤シャツがうらなりを田舎へ追いやり、その給料を自分に回そうとしていると知った坊っちゃんは激怒。赤シャツの家へ乗り込み、増給を断固として断る。赤シャツの策略と偽善的な態度が浮き彫りになる。"
  },
  {
    "index": 9,
    "name": "九",
    "summary": "山嵐と和解した坊っちゃんは、赤シャツの悪事を暴くことを誓う。うらなりの送別会で、山嵐は痛烈な皮肉を込めた送別の辞を述べ、赤シャツを非難する。会は荒れ、坊っちゃんはのだいこを殴りつけてしまう。"
  },
  {
    "index": 10,
    "name": "十",
    "summary": "祝勝会で中学生と師範学校生が乱闘騒ぎを起こし、止めに入った坊っちゃんと山嵐が首謀者だと新聞に書かれる。赤シャツの策略で山嵐のみが辞職を迫られるが、坊っちゃんは義憤にかられ、自分も辞めると宣言する。"
  },
  {
    "index": 11,
    "name": "十一",
    "summary": "新聞に悪者にされた山嵐は辞職。坊っちゃんも辞表を叩きつけ、二人で赤シャツへの復讐を決意する。赤シャツと野だの密会現場を押さえて二人を叩きのめし、町を去る。東京に戻り、清と暮らすが、やがて清は亡くなる。"
  }
]

トークン使用量の比較

キャッシュあり

テキストをキャッシュしてLLMにリクエストした結果です。先述の通り大部分がキャッシュになっていることがわかります 💰

🔍 コンテキストキャッシュを使用して章を抽出中...
📊 トークン使用量: {
  "inputTokens": 69291,
  "outputTokens": 859,
  "totalTokens": 73193,
  "reasoningTokens": 3043,
  "cachedInputTokens": 69212
}

キャッシュなし

こちらはキャッシュしなかった場合です。キャッシュからの入力をしていないので、cachedInputTokensがJSONに含まれていないことがわかります。

🔍 コンテキストキャッシュを使用して章を抽出中...
📊 トークン使用量: {
  "inputTokens": 69278,
  "outputTokens": 816,
  "totalTokens": 73003,
  "reasoningTokens": 2909
}

ちなみに、何度も抽出を繰り返していると、キャッシュしていないにも関わらず以下のようにcachedInputTokensが表示されることがあります👀 これが先述の**”暗黙的キャッシュ”**です。cachedInputTokensが作成済のキャッシュのトークン数(69,212)と合致しておらず、トークン数やキャッシュヒットと共にコントロールできていないことがわかります。

📊 トークン使用量: {
  "inputTokens": 69278,
  "outputTokens": 666,
  "totalTokens": 72744,
  "reasoningTokens": 2800,
  "cachedInputTokens": 65624
}

PDFファイル

テキストだけでなくPDFファイルも試してみます。流れはテキストと同じく、キャッシュしてLLM実行->削除、およびキャッシュせずに実行した結果を以下に示します↓
(全体ログは長くなるので折りたたんでいます)

全体の実行ログ
$ yarn exec npx tsx index.ts pdf
yarn workspace v1.22.22
yarn exec v1.22.22
[dotenv@17.2.3] injecting env (9) from ../../.env -- tip: ⚙️  specify custom .env file path with { path: '/custom/path/.env' }
The `fromStream` method is deprecated. Please use the `JWT` constructor with a parsed stream instead. For more details, see https://cloud.google.com/docs/authentication/external/externally-sourced-credentials.
📥 青空文庫のPDFファイルをダウンロード中...
The `fromJSON` method is deprecated. Please use the `JWT` constructor instead. For more details, see https://cloud.google.com/docs/authentication/external/externally-sourced-credentials.

🚀 === コンテキストキャッシュありでの処理 ===
✅ PDFキャッシュ作成完了: {
  "name": "projects/{projectId}/locations/{location}/cachedContents/{cacheId}}",
  "model": "projects/{projectId}/locations/{location}/publishers/google/models/gemini-2.5-pro",
  "createTime": "2025-10-26T14:22:48.894272Z",
  "updateTime": "2025-10-26T14:22:48.894272Z",
  "expireTime": "2025-10-26T14:37:48.753888Z",
  "displayName": "PDF Cache - 2025-10-26T14:22:41.344Z",
  "usageMetadata": {
    "totalTokenCount": 68380,
    "textCount": 25,
    "imageCount": 265
  }
}
🔍 コンテキストキャッシュを使用して章を抽出中...
📊 トークン使用量: {
  "inputTokens": 68459,
  "outputTokens": 988,
  "totalTokens": 73521,
  "reasoningTokens": 4074,
  "cachedInputTokens": 68380
}
📖 抽出された章: [
  {
    "index": 1,
    "name": "一",
    "summary": "主人公「坊っちゃん」は、幼少期からの無鉄砲な性格と、唯一の理解者である下女の清との思い出を語る。両親の死後、兄と別れ物理学校を卒業した彼は、兄から渡された600円を元手に新たな生活へ踏み出す。"
  },
  {
    "index": 2,
    "name": "二",
    "summary": "物理学校を卒業した坊っちゃんは、校長の勧めで四国の中学校に数学教師として赴任する。初めての土地で、船頭や宿屋の対応に戸惑いながらも、持ち前の気性で新生活に臨む。"
  },
  {
    "index": 3,
    "name": "三",
    "summary": "中学校に着任した坊っちゃんは、校長を「狸」、教頭を「赤シャツ」とあだ名をつけ、同僚たちを品定めする。生徒たちの生意気ないたずらに腹を立てつつも、教師としての日々が始まる。山嵐の世話で下宿に移る。"
  },
  {
    "index": 4,
    "name": "四",
    "summary": "学校では教頭の赤シャツと数学教師の山嵐が対立していた。坊っちゃんは宿直の夜、生徒に寝床へバッタを入れられるいたずらを仕掛けられる。生徒たちの卑劣なやり方に憤慨し、学校の人間関係に不信感を募らせる。"
  },
  {
    "index": 5,
    "name": "五",
    "summary": "赤シャツに誘われ、画学の野だと三人で釣りに出かける。その道中、赤シャツが山嵐やうらなりの悪口を言うのを聞き、彼がうらなりの婚約者マドンナを奪うためにうらなりを左遷させようと企んでいることを知る。"
  },
  {
    "index": 6,
    "name": "六",
    "summary": "赤シャツの策略に嫌悪感を抱いた坊っちゃんは、山嵐に相談し、赤シャツの悪巧みを確認する。坊っちゃんは正義感の強い山嵐と気弱なうらなりに味方することを決意。下女の清からの手紙に心を慰められる。"
  },
  {
    "index": 7,
    "name": "七",
    "summary": "うらなり君の左遷が決まり、坊っちゃんは彼の婚約者であるマドンナが赤シャツに横恋慕されているという噂の真相を知る。下宿の婆さんから事情を聞き、坊っちゃんは赤シャツへの怒りを一層募らせる。"
  },
  {
    "index": 8,
    "name": "八",
    "summary": "坊っちゃんと山嵐は、赤シャツと野だいが自分たちの悪評を流していると疑う。ある夜、二人が料亭で祝杯をあげているところを目撃し問いただすが、はぐらかされてしまう。赤シャツへの対決姿勢を強めていく。"
  },
  {
    "index": 9,
    "name": "九",
    "summary": "赤シャツと野だいの悪事を暴くため、坊っちゃんと山嵐は二人がうらなりの後任の歓迎会から帰るのを待ち伏せする計画を立てる。正義を貫くため、二人は直接対決を決意する。"
  },
  {
    "index": 10,
    "name": "十",
    "summary": "祝勝会で中学生と師範学校生との間に喧嘩が勃発する。この事件を、坊っちゃんと山嵐が煽動したかのように新聞に書かれてしまう。赤シャツが裏で糸を引いていると確信し、二人の怒りは頂点に達する。"
  },
  {
    "index": 11,
    "name": "十一",
    "summary": "ついに坊っちゃんと山嵐は赤シャツと野だいを待ち伏せし、天誅として二人を殴り倒す。翌日、二人は学校に辞表を提出し、町を去る。東京に戻った坊っちゃんは、清を引き取り、彼女が亡くなるまで共に暮らした。"
  }
]
✅ キャッシュ削除完了, レスポンス: {
  "size": 0,
  "data": {},
  "config": {
    "url": "https://us-central1-aiplatform.googleapis.com/v1/projects/{projectId}/locations/{location}/cachedContents/{cacheId}",
    "method": "DELETE",
    "headers": {},
    "responseType": "unknown"
  }
}

🚀 === コンテキストキャッシュなしでの処理(比較用) ===
🔍 コンテキストキャッシュを使わずに章を抽出中...
📊 トークン使用量: {
  "inputTokens": 68447,
  "outputTokens": 641,
  "totalTokens": 71871,
  "reasoningTokens": 2783
}
📖 抽出された章: [
  {
    "index": 1,
    "name": "第一章",
    "summary": "主人公「坊っちゃん」の幼少期からの無鉄砲な性格と、唯一の理解者である下女の清との交流が語られる。両親の死後、物理学校を卒業するまでを描く。"
  },
  {
    "index": 2,
    "name": "第二章",
    "summary": "物理学校を卒業した坊っちゃんは、四国の中学校に数学教師として赴任する。下女の清との別れを経て、未知の土地での新生活が始まる。"
  },
  {
    "index": 3,
    "name": "第三章",
    "summary": "中学校に着任した坊っちゃんは、校長(狸)や教頭(赤シャツ)ら同僚と出会う。しかし、赴任早々生徒たちから手荒い歓迎を受け、波乱の幕開けとなる。"
  },
  {
    "index": 4,
    "name": "第四章",
    "summary": "宿直を命じられた坊っちゃんは、生徒たちに寝床へバッタを入れられるという悪質ないたずらを仕掛けられる。激怒した彼は生徒と対立を深めていく。"
  },
  {
    "index": 5,
    "name": "第五章",
    "summary": "教頭の赤シャツと画学の野だに誘われ釣りに出かける。道中、赤シャツの気取った言動や、うらなりの婚約者マドンナへの執着に不信感を抱く。"
  },
  {
    "index": 6,
    "name": "第六章",
    "summary": "山嵐から、赤シャツがうらなりの婚約者であるマドンナを奪うために、うらなりを左遷させようと画策していることを聞かされ、赤シャツへの反感を強める。"
  },
  {
    "index": 7,
    "name": "第七章",
    "summary": "山嵐の勧めで下宿を移るが、そこはうらなりの家だった。うらなりの母から赤シャツの策略について詳しく聞き、彼への義憤にかられる。"
  },
  {
    "index": 8,
    "name": "第八章",
    "summary": "坊っちゃんと山嵐の間に誤解が生じ、赤シャツと野だの策略で二人の仲は険悪になる。坊っちゃんは学校内の陰湿な人間関係に孤立感を深めていく。"
  },
  {
    "index": 9,
    "name": "第九章",
    "summary": "うらなりの送別会が開かれる。坊っちゃんは山嵐と和解し、赤シャツと野だを懲らしめることを決意。二人の友情が復活し、共通の敵に立ち向かう。"
  },
  {
    "index": 10,
    "name": "第十章",
    "summary": "中学生と師範学校生の喧嘩が起こる。赤シャツの策略により、坊っちゃんと山嵐が事件の首謀者であるかのような記事が新聞に掲載されてしまう。"
  },
  {
    "index": 11,
    "name": "第十一章",
    "summary": "堪忍袋の緒が切れた坊っちゃんと山嵐は、赤シャツと野だを待ち伏せして殴り、制裁を加える。その後、二人とも辞職して町を去り、坊っちゃんは東京で清と再会する。"
  }
]
✨  Done in 210.44s.
✨  Done in 210.53s.

※抽出結果はテキストとほぼ同じなので省略します

トークン使用量の比較

キャッシュあり

テキストをキャッシュしてLLMにリクエストした結果です。先述の通り大部分がキャッシュになっていることがわかります 💰

🔍 コンテキストキャッシュを使用して章を抽出中...
📊 トークン使用量: {
  "inputTokens": 68459,
  "outputTokens": 988,
  "totalTokens": 73521,
  "reasoningTokens": 4074,
  "cachedInputTokens": 68380
}

キャッシュなし

こちらはキャッシュしなかった場合です。キャッシュからの入力をしていないので、cachedInputTokensがJSONに含まれていないことがわかります。

🔍 コンテキストキャッシュを使わずに章を抽出中...
📊 トークン使用量: {
  "inputTokens": 68447,
  "outputTokens": 641,
  "totalTokens": 71871,
  "reasoningTokens": 2783
}

まとめ

  • Vercel AI SDKでもシュッとVertex AIのコンテキストキャッシュできるぞ
  • デカいコンテキストを使い回してるなら導入の余地あるぞ
  • 課金ポイントを整理して理解するの大事だぞ
    • 作ったキャッシュはちゃんと消そう!おじさんとの約束だよ
  • モデルをまたいで使えないので、コンテキストを使い回すユースケースやワークフロー内ではモデルをある程度統一したほうが良さそう
スマートラウンド テックブログ

Discussion