生成AIのコストを1/100にした話💰💰💰

こんにちは！[@nukosuke](https://x.com/nuko_suke_dev)です！

ChatGPTやClaude、Geminiなど生成AIを使ったアプリケーションを開発する場合 **コスト** は１つボトルネックになるでしょう。
LLMの回答は決まったものでもないので、何度も試して期待する精度に近づける必要がありますし、特に個人開発のような資金が潤沢でない場合はなおさらコストには敏感になります。

私もそのうちの一人です。[私のサイト](https://chaisear.com/)でもPDFのような複数のページから表を読み取り、JSONのような構造化データとして出力するタスクをLLMにさせていますが、1回のタスクに1ドル(記事執筆時点で大体150円程度)かかっていました。
何回も試行錯誤するたびに1回1ドルお財布から消えるのはツラいです💸

さすがにツラいということで、コスト削減に本腰を入れたところ、1回のタスク実行に大体0.01ドル程度、 **1/100** ほど削減できました（もちろん精度はできるだけ落とさずに）。

この記事では **具体的にどのような取り組みでコストを1/100にできたかを紹介** します。

## 対象読者

OpenAIやClaudeなどAPI経由で利用するリモートモデルのLLMを使ったアプリケーション開発をしており、 **特に個人のような小規模で開発している人**。
大規模な開発している人も参考になるかとは思いますが、細かいチューニング例も紹介するので特に **資金が潤沢でない人向け** になるかと思います。

## 前提：LLMのタスクの処理内容

前提として、私がどのようなタスクで1回1ドルを消費していたかを紹介します。

具体的には、「 **PDFなどのドキュメントで、数ページに掲載されている複数の表を読み取る** 」タスクです。
1枚1枚の表をOCRで画像として読み取り、Claude 3.5 Sonnetに対してプロンプト一緒に投げて細かく表の情報を読み取っていました。

読み取るドキュメントにも寄りますが、大体このタスクが1回1ドルほどかかっていました。

## LLMのコストを減らす戦略

大枠はたった２つです。

1. [タスクを分割する](#1-タスクを分割する)
    a. [プログラムでこなせるタスクはプログラムで](#1-a-プログラムでこなせるタスクはプログラムで)
    b. [高コストLLMから低コストLLMへ](#1-b-高コストllmから低コストllmへ)
2. [プロンプトのトークンを見直す](#2-プロンプトのトークンを見直す)
    a. [プロンプトキャッシングを使う](#2-a-プロンプトキャッシングを使う)
    b. [プロンプトを削減する](#2-b-プロンプトを削減する)

それぞれについて詳しくお話していきます。

### 1. タスクを分割する

元々、1つの表に対してやりたいこと全てプロンプトに詰め込んで、LLMとのやりとりを1回で済ませていました。
が、実はこの1回でまとめてしまっているタスクはいくつかのタスクに分割することができます。

簡単な例として、表の読み取りにおいて次のようなプロンプトがあったとします。

````markdown
次のCSVから年齢を抽出してください。
また、国ごとで名前を分類してください。

```csv
Name,Age,Country
Alice,30,USA
Bob,25,Canada
Charlie,35,United States
```

なお、次の事柄に注意してください。

* CSVのデータが日本語ではない場合は空の配列で返してください。
* CSVのデータに名前が含まれない場合は空の配列で返してください。
````

このプロンプト例では1度の処理でLLMに色々なことをやらせようとしていますが、次のように4つにタスクを分けられそうです。

1. 対象のCSVが日本語かチェック
2. 対象のCSVに名前が含まれているかチェック
3. 対象のCSVから年齢を抽出
4. 対象のCSVから国ごとで名前を分類

:::note warn
あくまでも簡単な例ですので、実際は複雑なタスクが大盛りだと捉えてもらえれば。今回の例では特に1と2が軽い処理、3と4が重い処理と捉えていただけるとこの後の話がイメージしやすいかもしれません。
:::

タスクを分割することで何が良いのでしょうか？

#### 1-a. プログラムでこなせるタスクはプログラムで

>1.対象のCSVが日本語かチェック

よく見ると一部のタスクはプログラムで処理できそうです。
例えば対象のCSVが日本語かどうかは次のようなPythonコードで判定できます。

````python
import re

# 日本語の文字範囲 (ひらがな、カタカナ、漢字など)
JAPANESE_PATTERN = re.compile(r"[\u3040-\u30FF\u4E00-\u9FFF\uFF66-\uFF9F]")

def check_japanese_data(data: str) -> bool:
    return bool(JAPANESE_PATTERN.search(data))

csv_text = """
```csv
Name,Age,Country
Alice,30,USA
Bob,25,Canada
Charlie,35,United States
```
"""

print(check_japanese_data(csv_text))
# => False
````

このようなコードを使うことでLLMのタスクの一部をプログラムに置き換えることができます。

特に「対象のCSVが日本語かチェック」のような **フィルタリングの処理を最初にプログラムで判定させることで、そもそもNGだった場合にそもそもLLMへ問い合わせする必要もなくなります** 。

一方でプログラムで処理できないタスクはどうでしょうか？

#### 1-b. 高コストLLMから低コストLLMへ

> 2.対象のCSVに名前が含まれているかチェック

分割されたタスクの中には、精度は維持しつつ、GPT‑4o miniやClaude 3.5 Haikuのような **低コストなLLMモデルに置き換えられる** ケースもあります。
例えば対象のCSVに名前が含まれているかチェックするような真偽値による判定程度であれば低コストなLLMで十分な場合もあります。

先ほどの「[プログラムでこなせるタスクはプログラムで](#1-a-プログラムでこなせるタスクはプログラムで)」でお話したのと似たように、特に「対象のCSVに名前が含まれているかチェック」のような **フィルタリングの処理を低コストLLMで判定させることで、そもそもNGだった場合に後続の高コストLLMへ問い合わせする必要もなくなります** 。

![タスク分割.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/164159/f7542680-116b-4918-97e9-5526ec19d999.png)

このようにタスクを分割することによって、一部はプログラムや低コストなLLM置き換えたりすることができます。
特に、フィルタリングのような処理をプログラムや低コストなLLMに実行させることで、NGだった場合に後続のコストの高い処理をスキップさせることもできます。
（余談ですが、タスクを細かく分けた方が回答精度も上がります）

### 2. プロンプトのトークンを見直す

先ほどの「[1. タスクを分割する](#1-タスクを分割する)」でタスクを分割することによって、一部のタスクがそもそもLLMを使わなくて良くなったり、あるいは安価なLLMに置き換えることができました。
次はどうしてもLLMを使わないといけない部分についてコストを見直します。具体的にはLLMに投げるプロンプトの部分です。

プロンプトのコストを抑える方法の次の章から紹介していきます。

#### 2-a. プロンプトキャッシングを使う

OpenAIやClaude、GeminiのようなリモートLLMには **プロンプトキャッシング** (Geminiだとコンテキストキャッシュ)が使えます。

https://platform.openai.com/docs/guides/prompt-caching

https://docs.anthropic.com/ja/docs/build-with-claude/prompt-caching

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

定型的なプロンプトを先頭にもってくることで、OpenAIやAnthropicなどのプロバイダー側でキャッシュが働き、費用が安くなるというものです。

以下はOpenAIを使ったコード例です。

````python
import json

from openai import OpenAI

client = OpenAI()

# 長いCSV文字列
# OpenAIの場合は1024トークン以上でないとプロンプトキャッシングは機能しない。
csv_str = """
Name,Age,Country
Alice,30,USA
Bob,25,Canada
Charlie,35,United States
"""

system_message = f"""
次のCSVについてユーザーからの指示を元に回答してください。

```csv
{csv_str}
```
"""

def completion_run(user_query):
    messages = [
        {
            "role": "system",
            # プロンプトキャッシングが働くように定型文を先頭にもってくる
            "content": system_message,
        },
        # 変動的なプロンプト
        {"role": "user", "content": user_query},
    ]
    completion = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=messages,
        temperature=0.1,
    )
    usage_data = json.dumps(completion.to_dict(), indent=4, ensure_ascii=False)
    return usage_data


def main():
    print("Run 1:")
    run1 = completion_run("日本語ですか？")
    print(run1)

    print("\nRun 2:")
    run2 = completion_run("Aliceの年齢は？")
    print(run2)

main()
````

先ほど[タスクを分割することでコストを抑える](1-タスクを分割する)お話をしましたが、 **タスクを分割すると同じデータソースに対して何度もLLMへ問い合わせするケースが増えます** 。

タスク分割でお話した例を再度とりあげます。

1. 対象のCSVが日本語かチェック
2. 対象のCSVに名前が含まれているかチェック
3. 対象のCSVから年齢を抽出
4. 対象のCSVから国ごとで名前を分類

2~4については同じCSVに対してLLMへの問い合わせが必要です。
同じCSVなのでプロンプトをうまく先頭で固定すればプロンプトキャッシングの効果を得ることができます。

なお、プロンプトキャッシングを使う上では注意点があるため、OpenAIや各プロバイダー側のドキュメントを読んでおくことをおすすめします。
例えばOpenAIの場合は1024トークン以上でないとプロンプトキャッシングが効かないなどの注意点があります。

#### 2-b. プロンプトを削減する

人によりけりの部分もありますが、私が実際にやった具体例を紹介しようと思います。

##### 画像よりもテキストで

マルチモーダルに対応しているLLMは画像をインプットとして渡すことができますが、テキストよりもコストが高くなりがちです。
できればテキストでLLMにタスクをさせたいところです。

PDFのようなドキュメントで表を読み取る例をあげます。
Pythonの場合は[PyMuPDF](https://github.com/pymupdf/PyMuPDF)や[pdfplumber](https://github.com/jsvine/pdfplumber)、[unstructured](https://github.com/Unstructured-IO/unstructured)などのライブラリを使うことで、 **表のデータをCSVやJSONのような構造化されたデータに落とし込むことができるので、これをプロンプトに渡すとよさそう** です。

ただし、これだけだと画像と比べてLLMの回答精度が落ちる場合があります。
というのも、PDFのような表では必ずしも構造的でないからです（最たる例がセルの結合）。
なので、視覚的な情報も必要になってきます。

PDFの表を読み取るようなライブラリでは座標情報も取得できたりします。例えば[pdfplumber](https://github.com/jsvine/pdfplumber)では表の各セルの矩形情報も取得できます。
このような **位置情報も含めてプロンプトに渡すことで精度が落ちないようにします**。
以下はイメージです。

```csv
x0,y0,x1,y1,value
0,0,100,100,Name
100,0,200,100,Age
```

##### JSONよりもCSVで

何かしらの構造的なデータのリストをプロンプトに埋め込む時は、 **JSONよりもCSV** を使うようにします。
理由は **トークン数が少ないから** です。

```json
[
  { "Name": "Alice", "Age": 30, "Country": "USA" },
  { "Name": "Bob", "Age": 25, "Country": "Canada" },
  { "Name": "Charlie", "Age": 35, "Country": "United States" }
]
```

上記のJSONは[OpenAIのtokenizer](https://platform.openai.com/tokenizer)で調べると66トークンです。

```csv
Name,Age,Country
Alice,30,USA
Bob,25,Canada
Charlie,35,United States
```

一方でCSVは24トークンです。
同じデータ構造を表現していても、JSONの方が各アイテムごとにKey(例でいうとNameやAge, Country)を書かなければなりませんが、CSVであればその必要はありません。

このように **プロンプトに埋め込むデータもJSONよりもCSVの方がコスト的な観点ではよさそう** です。

##### 日本語よりも英語で

ご存知の方も多いかもしれませんが、 **日本語はトークン数が多くなりがち** です。
あくまで目安ですが、英語は1単語1トークンに対し、日本語は1文字に対して2~3トークンです。
なので、 **プロンプトは英語で書くように** します。

![english_speaking.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/164159/ffcaadf6-06bb-4353-bd57-1d7d19a97be2.png)

##### 全角英数字や記号を半角に、日本語は全角に

全角や半角も要注意です。 **同じ言葉を指しているのに、半角か全角かでトークン数が変わります**。

全角の英数字&記号の「ＡＢＣ１２３！？」は[OpenAIのtokenizer](https://platform.openai.com/tokenizer)で6トークン、半角の「ABC123!?」は3トークンです。

日本語では、半角の「ｺｰﾋｰ」は6トークン、全角の「コーヒー」は4トークンです。

なので、 **全角英数字や記号を半角に、日本語は全角にする** のが無難そうです。

このような正規化は例えばPythonで次のような関数で変換できます。

```python
import unicodedata

def normarize_text(text: str) -> str:
    return unicodedata.normalize("NFKC", text)

print(normarizeText("ＡＢＣ１２３！？ｺｰﾋｰ"))
# => ABC123!?コーヒー
```

##### 小数点以下を削る

プロンプトに埋め込むデータの中には、数値のデータが含まれることもあるでしょう。
**もし整数で十分であれば小数点以下は削ります**。

例えば、「3.14」という数字は[OpenAIのtokenizer](https://platform.openai.com/tokenizer)で3トークン、「3」であれば1トークンです。

「塵も積もれば山となる」という感じで、特に数値の多く大量のデータを扱う場合は地味にインパクトがあります。

##### 相対的な数値に補正

具体的なイメージがあるとわかりやすいでしょう。
例えばPDFの表をあるライブラリで処理すると、次のような結果になるとします。

* 全体的な横の長さは10,000
* 列は３つあり、それぞれの長さは次の通り
    * Aが2,000
    * Bが3,000
    * Cが5,000

上記の例では数千単位の数値で表現されており、プロンプトに埋め込むとちょっとトークン数が大きくなりそうです。
絶対的な数値ではなく、あくまでも相対的な数値で十分なケースでは下記のように補正します。

* 全体的な横の長さは10
* 列は３つあり、それぞれの長さは次の通り
    * Aが2
    * Bが3
    * Cが5

この例はイメージで、実際の計算では細かく座標を相対的な数値に補正します。
このように **相対的な数値に補正することでプロンプトに埋め込むデータのトークン数の削減** ができます。

##### 記号や空白文字は削る

記号や空白文字も特に大きな意味をなさない場合は削ります。以下はコード例です。

```py
# 不要な記号のパターンリスト
unuseful_chars_pattern = re.compile(r"[○●▲,□△★※*]")

def remove_unuseful_chars(text: str) -> str:
    return unuseful_chars_pattern.sub("", text)

# 空白文字
empty_chars_pattern = re.compile(r"\s+")

def remove_empty_chars(text: str) -> str:
    return empty_chars_pattern.sub("", text)
```

##### 特殊文字は変換する

プロンプトにHTMLのようなデータを埋め込む場合、「\&amp;」や「\&quot;」のような特殊文字が含まれることもあります。
このような特殊文字もトークン数を無駄に増やしてしまうので、「&」や「"」に変換すると良いでしょう。

##### 不要なタグは削る

HTMLの場合限定ですが、LLMの回答精度に影響を与えないノイジーなタグは削除します。

HTMLからLLMに何を読み取らせたいかによって削るタグは変わりますが、例えばHTMLの本文だけを読み取らせたい場合は `head` や `script` 、 `style` のようなタグは不要でしょう。

##### LLMにプロンプトを圧縮してもらう

もはや **LLMにプロンプトのトークン数を削減してもらいましょう** 。
自分が作ったプロンプトを使って、例えば「次のプロンプトを意味をできるだけ損なわずにトークン数を減らしてください。gpt-4o-miniへの指示へ使います。」のような指示を出します。楽ちん。

##### 組み込みの変換モジュールを信用しない

Python特有の話になってしまいますが、プロンプトへ埋め込むためにデータをJSON文字列に変換したいとします。
Pythonであれば `json` モジュールの `dump` を使うことになるでしょう。

```py
import json

score = {
    "math": 95,
    "english": 88,
    "science": 92,
}

json.dumps(score)
#=> '{"math": 95, "english": 88, "science": 92}'
```

この例では `'{"math": 95, "english": 88, "science": 92}'` というようなJSON文字列となり、問題なさそうです。
が、よく見ると `:` や `,` の後に半角スペースが含まれていそうです！

`json.dumps` に `separators` オプションを渡し、半角スペースが含まれないようにします。

```py
json.dumps(scores, separators=(",", ":"))
#=> '{"math":95,"english":88,"science":92}'
```

通常の `json.dumps` の例では[OpenAIのtokenizer](https://platform.openai.com/tokenizer)で19トークン、`separators`を使った例では14トークンになりました。
小さいデータであればたいして問題ないですが、大量のデータを扱う場合はインパクトがあります。

## まとめ

次のようなラインナップで、実際に[私のサイト](https://chaisear.com/)でLLMのコストを1/100にした取り組みを紹介しました。

1. [タスクを分割する](#1-タスクを分割する)
    a. [プログラムでこなせるタスクはプログラムで](#1-a-プログラムでこなせるタスクはプログラムで)
    b. [高コストLLMから低コストLLMへ](#1-b-高コストllmから低コストllmへ)
2. [プロンプトのトークンを見直す](#2-プロンプトのトークンを見直す)
    a. [プロンプトキャッシングを使う](#2-a-プロンプトキャッシングを使う)
    b. [プロンプトを削減する](#2-b-プロンプトを削減する)

今回の取り組みは、特に個人開発のような資金繰りが厳しい人には有効な取り組みではないかと思っています。

もし個人開発に興味があればこちらの記事もぜひ参考にしてみてください！

https://qiita.com/nuko-suke/items/652366597ce2ce0a91f9

ここまでご覧いただきありがとうございました！