Re:ゼロから始めるML生活

どちらかといえばエミリア派です

LangChainでPrompt Cachingが利用されていることを確認する

この前はプロンプトキャッシュについて調べていました。

www.nogawanogawa.com

実際に各種生成AIサービスを利用する際にはLangChainを利用することが多いと思うので(諸説あり)、今回はLangChainを使いつつちゃんとプロンプトキャッシュが効いていることを確認したいと思います。

LangChainで利用トークン数を確認する

最初は何も考えずにLangChainを使って利用トークン数をカウントしてみようと思います。

使ったコードはここにおいてあります。

普通に会話する

適当にcolabでやってみるとこんな感じになりました。

# モデルを準備
llm = ChatOpenAI(
    model_name="gpt-4o"
)

response = llm.invoke("こんにちは、元気ですか?")
print(response)

print(f'token数: {response.response_metadata["token_usage"]["prompt_tokens"]}')
print(f'prompt cache利用token: {response.response_metadata["token_usage"]["prompt_tokens_details"]["cached_tokens"]}')

するとこんな感じですね。

content='こんにちは!私は常に元気です。お手伝いできることがあれば教えてくださいね。' additional_kwargs={} response_metadata={'token_usage': {'completion_tokens': 24, 'prompt_tokens': 14, 'total_tokens': 38, 'completion_tokens_details': {'accepted_prediction_tokens': 0, 'audio_tokens': 0, 'reasoning_tokens': 0, 'rejected_prediction_tokens': 0}, 'prompt_tokens_details': {'audio_tokens': 0, 'cached_tokens': 0}}, 'model_name': 'gpt-4o', 'system_fingerprint': 'fp_a288987b44', 'finish_reason': 'stop', 'logprobs': None} id='run--2b021fdb-c760-400e-9cae-a7f4c22a4cf5-0'
token数: 14
prompt cache利用token: 0

今回見たいのはPrompt Tokens Cachedってとこで、現在0になっていますね。 (初回実行だからそれはそう)

OutputParserを使って構造化した場合も試す

自分の場合は結構OutputParserを使って構造化したりするので、それらを使うとどんな感じになるのか見てみようと思います。

from pydantic import BaseModel
from langchain.output_parsers import PydanticOutputParser
from langchain.prompts import ChatPromptTemplate

# Pydanticモデルを定義
class GreetingResponse(BaseModel):
    mood: str
    reply: str

# OutputParserを作成
parser = PydanticOutputParser(pydantic_object=GreetingResponse)

# プロンプトを作成
prompt = ChatPromptTemplate.from_messages([
    ("system", "あなたは文章を分析し、mood と reply に分けてJSONで出力します。"),
    ("user", "{input_text}")
])

# 入力
input_text = "こんにちは、元気ですか?"

# フォーマット済みプロンプト
formatted_prompt = prompt.format_messages(input_text=input_text)

# API呼び出し + トークン数計測
response = llm(formatted_prompt)
parsed_output = parser.parse(response.content)

print(parsed_output)
print(f'token数: {response.response_metadata["token_usage"]["prompt_tokens"]}')
print(f'prompt cache利用token: {response.response_metadata["token_usage"]["prompt_tokens_details"]["cached_tokens"]}')

するとこんな感じですね。

mood='friendly' reply='こんにちは!はい、元気です。あなたはどうですか?'
token数: 39
prompt cache利用token: 0

こっちもちゃんとPrompt Tokens Cachedってのが見れてますね、いい感じです。

画像についてもやってみる

意外と画像とかと一緒に使ったりもするのでそのへんも使ってみたいと思います。

画像は下記で取得しました。

!wget -O example.png https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/640px-PNG_transparency_demonstration_1.png

Pythonコードはこんな感じです。

from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
import os
import base64

# Pydanticモデルを定義
class GreetingResponse(BaseModel):
    mood: str
    reply: str

# OutputParserを作成
parser = PydanticOutputParser(pydantic_object=GreetingResponse)

# 入力画像
image_path = "/content/example.png"

with open(image_path, "rb") as image_file:  # 画像ファイルまでのパス
    base64_image = base64.b64encode(image_file.read()).decode('utf-8')

# フォーマット済みプロンプト
messages = [
    SystemMessage(content="あなたは文章を分析し、mood と reply に分けてJSONで出力します。"),
    HumanMessage(content=[
        {"type": "text", "text": "この画像についてmood と reply に分けてJSONで出力します。"},
        {
                 "type": "image_url",
                 "image_url": {
                     "url": f"data:image/png;base64,{base64_image}"
                 },
        },
    ])
]

response = llm(messages)
parsed_output = parser.parse(response.content)

print(parsed_output)
print(f'token数: {response.response_metadata["token_usage"]["prompt_tokens"]}')
print(f'prompt cache利用token: {response.response_metadata["token_usage"]["prompt_tokens_details"]["cached_tokens"]}')

結果を確認してみるとこんな感じで見えています。

mood='casual' reply='この画像には、赤と青のボールペンが写っています。背景は織物のようなデザインです。'
token数: 814
prompt cache利用token: 0

ちゃんとPrompt Tokens Cachedが確認できていますね。

本題のプロンプトキャッシュが機能しているか確認する

さて、本題のプロンプトキャッシュが利用されているか確認してみたいと思います。

今回は

  • テキストのみ
  • テキスト + 画像

の2パターンで、ちゃんとキャッシュが機能しているか確認してみたいと思います。

下準備

多少長めのテキストじゃないとキャッシュが働かないので、青空文庫からテキストをダウンロードして使おうと思います。

qiita.com

from google.colab import files
import re

! wget https://www.aozora.gr.jp/cards/000035/files/1567_ruby_4948.zip
! unzip 1567_ruby_4948.zip
f = open('hashire_merosu.txt', 'r', encoding='SJIS')
data = f.read()
f.close()
data = re.sub("\u3000", "", data)
data = re.sub("\n", "", data)
data = re.sub("《[^》]+》", "", data)

data = re.sub("-------------------------------------------------------【テキスト中に現れる記号について】《》:ルビ(例)邪智暴虐|:ルビの付く文字列の始まりを特定する記号(例)疲労|困憊[#]:入力者注主に外字の説明や、傍点の位置の指定(例)[#地から1字上げ]-------------------------------------------------------", "", data)
data = re.sub("底本:「太宰治全集3」ちくま文庫、筑摩書房1988(昭和63)年10月25日初版発行1998(平成10)年6月15日第2刷底本の親本:「筑摩全集類聚版太宰治全集」筑摩書房1975(昭和50)年6月〜1976(昭和51)年6月入力:金川一之校正:高橋美奈子2000年12月4日公開2011年1月17日修正青空文庫作成ファイル:このファイルは、インターネットの図書館、青空文庫(http://www.aozora.gr.jp/)で作られました。入力、校正、制作にあたったのは、ボランティアの皆さんです。", "", data)

# dataに走れメロスの本文が入る
# data

画像については、こんな感じで適当に自分がTwitterに上げた画像を使おうと思います。 流石に4枚あればキャッシュは効くと信じてる…

!wget -O example.png https://pbs.twimg.com/media/GprDY0DaIAAeFPJ?format=jpg&name=large

OpenAI

最初はOpenAIのChatGPTでやってみようと思います。

大事な部分だけ抜粋すると、テキストのところはこんな感じ。

characters=['メロス', 'ディオニス', 'セリヌンティウス', 'メロスの妹', '老爺', '若い衆', '村の牧人', '群衆', 'フィロストラトス'] summary='メロスは妹の結婚式のために市に向かうが、暴君ディオニスの残虐さを知り、彼を討つ決意をする。'
token数: 8889
prompt cache利用token: 0

2回目はこんな感じ。

characters=['メロス', 'セリヌンティウス', 'ディオニス', '妹', '老爺', '若い衆', 'フィロストラトス'] summary='メロスは友を救うため、王のもとに戻る約束を果たすために必死で走る物語。'
token数: 8892
prompt cache利用token: 8832

画像のほうも見てみるとこんな感じ。

color='青色' answer='青い花が広がる草原と緑の木々が見える風景。人々が集まっている様子も確認できる。'
token数: 25745
prompt cache利用token: 0

2回目はこんな感じ。

color='' answer='青い花が広がる公園の風景で、多くの人々が訪れている。空は晴れ、木々が点在している。'
token数: 25745
prompt cache利用token: 1024

ちょっと画像の方はキャッシュできている量が少ない気がしますが、キャッシュされていることは確認できたので一旦良しとします。

Claude

OpenAIの要領でClaudeもやってみます。 Claudeの場合はキャッシュしたいメッセージに"cache_control": {"type": "ephemeral"}のようにcache_controlブロックを指定する必要があります。

※ちなみにこれがあるので、PromptTemplateでやってみようと思ったんですが、やり方が分からなかったのでMessageを直接作成しています。

docs.anthropic.com

なので使ってみるとこんな感じです。

大事な部分だけ抜粋すると、テキストのところはこんな感じ。

characters=['メロス', 'セリヌンティウス', 'ディオニス王', 'メロスの妹', '花婿の牧人', 'フィロストラトス'] summary='主人公メロスは暴君ディオニスを倒そうとして捕まるが、3日後に戻る約束で親友セリヌンティウスを人質に釈放される。様々な困難を乗り越え、約束を守り友を救う。'
token数: 10803
prompt cache利用token: 0

2回目はこんな感じ。

characters=['メロス', 'セリヌンティウス', 'ディオニス王', 'フィロストラトス', 'メロスの妹', '花婿の牧人'] summary='正義の味方メロスが、親友セリヌンティウスを人質に三日後の帰還を約束。様々な困難を乗り越え、ぎりぎりで刑場に到着し、友情と信実の大切さを証明した。'
token数: 10804
prompt cache利用token: 10606

ちゃんとキャッシュされていますね。

画像のほうも見てみるとこんな感じ。

color='' answer='公園や広場に一面に咲く水色のネモフィラの花。緑の木々と白い雲がある青空の下、多くの人々が花を観賞している様子。'
token数: 1766
prompt cache利用token: 0

2回目はこんな感じ。

color='' answer='ネモフィラの青い花畑が広がり、緑の木々と白い雲が浮かぶ青空の下、多くの人々が花見を楽しんでいる公園の風景'
token数: 1771
prompt cache利用token: 1661

Claudeでは画像も想定通りキャッシュされているように見えますね。

参考文献

下記の文献を参考にさせていただきました。

感想

以上、最近「プロンプトキャッシュを使ってコスト削減できないかな」と考えていたので実験してみた次第です。 LangChainを使うのが今風だと思うのでせっかくなのでLangChainでやってみました。

機能として存在するので「そりゃ動くよね」という気はしますが、LangChainで動かすときにどう書いて、キャッシュ使用量をきちんと確認できたので良かったです。

Gemini の明示的なコンテキストキャッシュも今度使ってみようと思いますが、今回はこのへんまで。