Amazon Bedrock の Prompt Caching が GA したっぽいからためそうぜ

## Amazon Bedrock に Prompt Caching 機能が GA していました

https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html

>Amazon Bedrock prompt caching is generally available with Claude 3.7 Sonnet and Claude 3.5 Haiku. Customers who were given access to Claude 3.5 Sonnet v2 during the prompt caching preview will retain their access, however no additional customers will be granted access to prompt caching on the Claude 3.5 Sonnet v2 model. Prompt caching for Amazon Nova models continues to operate in preview.

ってわけで試してみました。

## の前に prompt caching is 何？

LLM 界では有名な話ですが、LLM の推論は与えた文章の次のトークンを予測処理を行います。
例えば
「
以下の英語を日本語に翻訳してください。
This is a pen.
」
というテキストを与えたら次のトークンは高い確率で "ｋ" を予測することでしょう。
さてそのときに元の

「次の英語を日本語に翻訳してください。This is a pen.」

というテキストをトークナイズしたデータは（ GPU で推論している場合は）当然 GPU のメモリに載っている必要があります。

さて、ここで違う文章を翻訳させたいとします。

「次の英語を日本語に翻訳してください。Good morning.」

LLM を通常通りに扱うとしたら、都度全文を GPU に載せ直す必要がありますが、「以下の日本語を英語に翻訳してください。」は重複しているため、メモリに載せ直すのはエコではありません。

そこでキャッシュです。

例では短い文章でしたが、実際のワークロードではシステムプロンプトに 10k tokens を超えるプロンプトを仕込みそれを使い回すことは珍しくありません。

その使い回す 10k tokens はキャッシュとして GPU に載せ続ければ無駄がないのでは、という発想で生まれたのが prompt cacing です。

Amazon Bedrock の Prompt Caching では最大 5 分 prompt をキャッシュすることができる仕様で、コストを最大90％、レイテンシーを最大85％削減できると公式には書いてあります。

## やってみる

Prompt Caching をどうやるか、ですが以下のドキュメントに記載があり、

https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html

converse API を使う場合は メッセージやシステムプロンプト、ツールの中に `"cachePoint": {"type": "default"}` というキーバリューを入れることでキャッシュさせることができます。

今回はキャッシュが効きそうな長めの小説を system prompt に入れて問い合わせしてみます。

というわけで著作権が切れている青空文庫から皆さん大好き吾輩は猫なんだニャ。

https://www.aozora.gr.jp/cards/000148/files/789_14547.html

そのままコピーするとルビなどが邪魔なので以下の記事のコードを~~パクってき~~使います(4年以上前に私が書いたコード…)

https://aws.amazon.com/jp/builders-flash/202007/comprehend-reading-impressions/

```python
import boto3
import requests
from html.parser import HTMLParser

brt = boto3.client('bedrock-runtime',region_name = 'us-west-2')
model_id = 'us.anthropic.claude-3-7-sonnet-20250219-v1:0'

class MyHTMLParser(HTMLParser):
    def __init__(self):
        super().__init__()
        self.text = ""
        self.extract = False
        
    def handle_starttag(self, tag, attrs):
        if tag == "div":
            self.extract = True
        elif tag in ["rp","rt"]:
            self.extract = False

    def handle_endtag(self, tag):
        if tag in ["rp","rt"]:
            self.extract = True
        elif tag == "div":
            self.extract = False

    def handle_data(self, data):
        if self.extract:
            self.text += data
URL = "https://www.aozora.gr.jp/cards/000148/files/789_14547.html"
response = requests.get(URL)
response.encoding = response.apparent_encoding
response.text
parser = MyHTMLParser()
parser.feed(response.text)
text = parser.text
```

ここからシステムプロンプトを作っていきます。
RAG っぽい感じにしてみました。

```python
system_prompt = f'''AI は小説の内容を理解しユーザーの小説に関する質問に答えます。
<novel> タグで青空文庫から得た吾輩は猫であるという小説の全文を渡します。
ユーザーの吾輩は猫であるという小説に関する質問にのみ回答してください。
それ以外の質問にはわかりませんと答えてください。
<novel>{text}</novel>'''
```

ここから bedrock を呼び出してみます。

```python
response = brt.converse(
    modelId = model_id,
    system=[
        {'text': system_prompt},
        # {'cachePoint': {'type': 'default'}}
    ],
    messages = [
        {
            'role':'user',
            'content':[
                {'text':'吾輩は猫であるのオチは？'}
            ]
        }
    ],
    inferenceConfig={
        'maxTokens':256,
        'temperature':0,
    },
)
print(response)
response = brt.converse(
    modelId = model_id,
    system=[
        {'text': system_prompt},
        # {'cachePoint': {'type': 'default'}}
    ],
    messages = [
        {
            'role':'user',
            'content':[
                {'text':'結局吾輩の名前は？'}
            ]
        }
    ],
    inferenceConfig={
        'maxTokens':256,
        'temperature':0,
    },
)
print(response)
```

システムプロンプトのキャッシュが聞けば input が速そうですが、まずはキャッシュなしです。

```text:出力
{(前略) 'output': {'message': {'role': 'assistant', 'content': [{'text': '「吾輩は猫である」のオチについてお答えします。\n\nこの小説は未完のまま終わっています。夏目漱石は全11章まで書き、最後の章で主人公の猫が鰹節を食べ過ぎて胃拡張を起こし、死んでいくという結末になっています。\n\n具体的には、最終章で猫は鰹節の入った桶を見つけ、我慢できずに大量に食べてしまいます。その後、体調を崩して苦しみ、「ああ死ぬな、死ぬな、もう死ぬな」と思いながら、最後は「ありがたいありがたい」と言って息絶えます。\n\n死の直前、猫は自分の人生を振り返り、「吾輩は猫である。名前はまだ無い」という冒頭の'}]}}, 'stopReason': 'max_tokens', 'usage': {'inputTokens': 51136, 'outputTokens': 256, 'totalTokens': 51392, 'cacheReadInputTokens': 0, 'cacheWriteInputTokens': 0}, 'metrics': {'latencyMs': 16130}}
{(前略), 'output': {'message': {'role': 'assistant', 'content': [{'text': '吾輩の名前はまだありません。小説の冒頭で「吾輩は猫である。名前はまだ無い。」と述べられており、物語の最後まで名前はつけられていません。主人の家に住み着いた猫として生活していますが、正式な名前を与えられることなく、「吾輩」として自分自身を称しています。小説の最後の部分でも「名前はまだつけてくれないが、欲をいっても際限がないから生涯この教師の家で無名の猫で終るつもりだ」と述べています。'}]}}, 'stopReason': 'end_turn', 'usage': {'inputTokens': 51133, 'outputTokens': 178, 'totalTokens': 51311, 'cacheReadInputTokens': 0, 'cacheWriteInputTokens': 0}, 'metrics': {'latencyMs': 14026}}
```

2 回の推論でそれぞれ
* 1 回目  
  `'usage': {'inputTokens': 51136, 'outputTokens': 256, 'totalTokens': 51392, 'cacheReadInputTokens': 0, 'cacheWriteInputTokens': 0}, 'metrics': {'latencyMs': 16130}}`
* 2 回目  
  `'usage': {'inputTokens': 51133, 'outputTokens': 178, 'totalTokens': 51311, 'cacheReadInputTokens': 0, 'cacheWriteInputTokens': 0}, 'metrics': {'latencyMs': 14026}`
という結果でした。
50k トークン程度でぶれはあるものの 15sec 程度でした。

次にキャッシュします。`# {'cachePoint': {'type': 'default'}}` のコメントアウトを外しました。

```text:出力
{(前略) 'output': {'message': {'role': 'assistant', 'content': [{'text': '「吾輩は猫である」のオチについてお答えします。\n\nこの小説は未完のまま終わっています。夏目漱石は全部で11章まで書き、最後の章で主人公の猫が鰹節を食べ過ぎて胃拡張を起こし、死んでしまうというエンディングになっています。\n\n最終章では、猫は「ああ死ぬのはいやだ。もっと生きていたい」と思いながらも、次第に意識が遠のいていきます。そして最後の場面では、「ありがたいありがたい」と言いながら、「吾輩は猫である。名前はまだ無い」という冒頭の言葉を繰り返して息絶えます。\n\nこの結末は、生きることへの執着と死の不可避性を対比させた哲学的なものとな'}]}}, 'stopReason': 'max_tokens', 'usage': {'inputTokens': 22, 'outputTokens': 256, 'totalTokens': 51392, 'cacheReadInputTokens': 0, 'cacheWriteInputTokens': 51114}, 'metrics': {'latencyMs': 17456}}
{(前略), 'output': {'message': {'role': 'assistant', 'content': [{'text': '吾輩の名前はまだありません。小説の冒頭で「吾輩は猫である。名前はまだ無い。」と述べられており、物語の最後まで名前はつけられていません。主人の家で「無名の猫」として生涯を終えるつもりだと吾輩自身が語っています。'}]}}, 'stopReason': 'end_turn', 'usage': {'inputTokens': 19, 'outputTokens': 103, 'totalTokens': 51236, 'cacheReadInputTokens': 51114, 'cacheWriteInputTokens': 0}, 'metrics': {'latencyMs': 10275}}
```

2 回の推論でそれぞれ
* 1 回目  
  `'usage': {'inputTokens': 22, 'outputTokens': 256, 'totalTokens': 51392, 'cacheReadInputTokens': 0, 'cacheWriteInputTokens': 51114}, 'metrics': {'latencyMs': 17456}`
* 2 回目  
  `'usage': {'inputTokens': 19, 'outputTokens': 103, 'totalTokens': 51236, 'cacheReadInputTokens': 51114, 'cacheWriteInputTokens': 0}, 'metrics': {'latencyMs': 10275}`

でした。1 回目は 15 秒程度を期待しましたが、少し遅くなってます。これはもしかしたらキャッシュのオーバーヘッドなのかなんなのか（キャッシュって行ってもGPUに残すだけだからむしろ処理が不要になる気もするのですが）、 わからないですが、２回目は  50k ほどのインプットトークンのキャッシュが効いて 10 秒程度と高速化されました。

というわけで、Have a good cache life!

以上、雑検証でした。