ツールを実装していて、いちばん見落としやすい落とし穴があります。「ツールが画像を返しているのに、Claude はその画像を見ていない」という状態です。私自身、壁紙アプリのサムネイルをエージェントに判定させるツールを書いたとき、レスポンスは返るのに判断がやけに曖昧で、よく調べたら Claude は画像ではなく「base64 の長い文字列」を読まされていた、ということがありました。
tool_result は中に何でも詰められてしまうぶん、間違った形でも動いてしまいます。動くのに高く付く、というのがこの問題のいやらしいところです。ここでは、個人開発で実際にぶつかった事例をもとに、ツールが返す画像を Claude に正しく「見せる」ための組み立て方を、コストの実数とともに整理します。
ツールが画像を返すのに Claude が見ていない、という状態
tool_use に応えて結果を返すとき、多くの実装は tool_result の content に文字列を入れます。テキストを返すツールならそれで正解です。ところが画像を返したいときに、つい次のように書いてしまいます。
# アンチパターン: 画像の base64 を「文字列」として詰める
tool_result = {
"type" : "tool_result" ,
"tool_use_id" : tool_use_id,
"content" : f "画像データ: { base64_png } " , # ← これはテキスト扱い
}
この形でも API はエラーを返しません。Claude は base64 文字列を「テキスト」として受け取り、見かけ上は処理が進みます。しかし Claude はピクセルを見ていないので、画像の中身に基づく判断はできません。さらに悪いことに、数万文字の base64 がそのまま入力トークンとして課金されます。
公式 SDK のリポジトリやコミュニティでも、ツール結果の画像が「ネイティブな画像ブロックに変換されず、テキストとして送られて 1 枚あたり 15,000〜25,000 トークンを消費する」という報告が上がっています。同じ画像をユーザーメッセージとして直接添付すれば約 1,600 トークンで済むため、差はおよそ 10〜20 倍です。動いているのに 10 倍払っている、という典型例です。
正しい形は image コンテンツブロックを tool_result に入れること
tool_result の content は、文字列だけでなくコンテンツブロックの配列 を受け取れます。ここに image ブロックを入れると、Claude はそれを画像として認識し、ビジョンモデルとしてピクセルを読み取ります。
# 正しい形: content を配列にして image ブロックを入れる
tool_result = {
"type" : "tool_result" ,
"tool_use_id" : tool_use_id,
"content" : [
{
"type" : "image" ,
"source" : {
"type" : "base64" ,
"media_type" : "image/png" ,
"data" : base64_png,
},
},
{ "type" : "text" , "text" : "現在のサムネイル候補です。視認性を評価してください。" },
],
}
ポイントは 2 つあります。content を配列にすること、そして画像を {"type": "image", ...} ブロックとして渡すことです。テキストの補足を添えたいときは、同じ配列に text ブロックを並べれば一緒に渡せます。Claude 側は、この画像をユーザーが添付した画像と同じ仕組みで処理します。つまりトークン換算も画像レートが適用され、テキストとして数万トークン課金されることがなくなります。
Before / After のコストを実数で押さえる
違いを実感するために、同じ 1024×1024 の PNG を 2 通りで渡したときの概算を並べます。
渡し方 Claude から見た種別 概算入力トークン 画像内容に基づく判断
base64 を文字列で content に詰める テキスト 約 15,000〜25,000 できない
image ブロックで content に入れる 画像 約 1,400〜1,600 できる
画像のトークン数は、おおよそ「縦 × 横 ÷ 750」で見積もれます。1024×1024 なら 1024 * 1024 / 750 ≈ 1,398 トークンです。この式を知っていると、ツールが返す画像を縮小すべきかどうかを送信前に判断できます。たとえば 2048×2048 をそのまま返すと約 5,600 トークンになりますが、判定に 1024 で足りるなら、送る前に縮小するだけで 4 分の 1 に圧縮できます。
逆に言えば、base64 を文字列で詰めるアンチパターンでは、この見積もり式がまったく効きません。テキストは 1 文字単位で課金され、base64 は元データの約 1.37 倍に膨らむためです。1 枚で 2 万トークンというのは、こうして生まれます。
エージェントループに組み込む
実際のループはこうなります。Claude が tool_use を返す → 自分のコードでツールを実行して画像を得る → その画像を image ブロックにして tool_result で返す → Claude が画像を見て次の判断をする、という往復です。
import base64, anthropic
client = anthropic.Anthropic()
def render_thumbnail (candidate_id: str ) -> bytes :
# 実際にはサムネイル生成・取得処理。ここでは PNG バイト列を返すとする
...
def run_review (candidate_id: str ):
messages = [{
"role" : "user" ,
"content" : f "候補 { candidate_id } のサムネイルを生成して、視認性を5段階で評価してください。" ,
}]
while True :
resp = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 1024 ,
tools = [{
"name" : "render_thumbnail" ,
"description" : "指定した候補IDのサムネイルPNGを生成して返す" ,
"input_schema" : {
"type" : "object" ,
"properties" : { "candidate_id" : { "type" : "string" }},
"required" : [ "candidate_id" ],
},
}],
messages = messages,
)
if resp.stop_reason != "tool_use" :
return resp # 最終評価が返ってきた
# tool_use ブロックを処理
tool_results = []
for block in resp.content:
if block.type != "tool_use" :
continue
png = render_thumbnail(block.input[ "candidate_id" ])
b64 = base64.standard_b64encode(png).decode( "ascii" )
tool_results.append({
"type" : "tool_result" ,
"tool_use_id" : block.id,
"content" : [{
"type" : "image" ,
"source" : { "type" : "base64" , "media_type" : "image/png" , "data" : b64},
}],
})
messages.append({ "role" : "assistant" , "content" : resp.content})
messages.append({ "role" : "user" , "content" : tool_results})
ここで messages.append({"role": "assistant", "content": resp.content}) を忘れると、「tool_result に対応する tool_use ブロックがない」というエラーになります。アシスタントの tool_use を会話履歴に戻してから、その直後の user ターンで tool_result を返す、という順序が必須です。
TypeScript SDK でも形は同じ
言語が変わっても構造は同じです。content を配列にして image ブロックを入れる、という点だけ守れば動きます。
import Anthropic from "@anthropic-ai/sdk" ;
const client = new Anthropic ();
const toolResult = {
type: "tool_result" as const ,
tool_use_id: toolUseId,
content: [
{
type: "image" as const ,
source: { type: "base64" as const , media_type: "image/png" as const , data: b64 },
},
{ type: "text" as const , text: "App Store スクリーンショットの最新版です。" },
],
};
私は App Store のスクリーンショットを差し替えたあと、その新しい画像をツール経由で Claude に見せて「文字が切れていないか」「端末枠からはみ出していないか」を一次チェックさせています。テキストで「こういう画像です」と説明するより、実物を見せたほうが指摘が具体的になります。ここでも base64 を文字列で渡していた頃は、毎回数万トークンを無駄にしていました。
URL や Files API という選択肢
base64 をメッセージに毎回埋めるとリクエストが重くなります。同じ画像を何度も参照するなら、source の型を変える方法があります。
source タイプ 向いている場面 注意点
base64 その場限りの一回きりの画像 リクエストが膨らむ。重複送信で帯域を消費
url 公開URLで取得できる画像 Claude 側が取得可能な到達性が必要
file_id(Files API) 同じ画像を複数回参照する 事前アップロードが必要。ベータヘッダの確認を
繰り返し同じ図版を見せるパイプラインでは、Files API に一度アップロードして file_id で参照すると、毎回 base64 を送らずに済みます。私のように毎朝バッチでサムネイルを点検するようなループでは、固定の比較用基準画像だけ先にアップロードしておき、変動する候補画像だけ base64 で送る、という使い分けにしています。
送信前に決める3ステップ
本番に載せる前に、画像を返すツールでは次の順で方針を決めると安定します。私はこの3点を最初に固めてから実装に入るようにしています。
解像度を決める : 判定に必要な最小解像度まで縮小します。原本をそのまま送らないことを推奨します。トークンも結果の再現性も、ここで決まります。
source タイプを選ぶ : 一回きりなら base64、繰り返し参照するなら file_id を選ぶ場合が多いです。個人的には、固定の比較基準だけ先にアップロードしておく形に落ち着きました。
失敗時の対処を決める : メディアタイプ不一致やサイズ超過で読み取りに失敗したときの解決手順(縮小して再送、別形式へ変換)を、本番に出す前に用意しておきます。
落とし穴チェックリスト
実装中につまずきやすい点をまとめます。
メディアタイプは正確に : image/png image/jpeg image/gif image/webp のいずれかを、実データと一致させます。PNG を image/jpeg と申告すると読み取りに失敗します。
サイズは送る前に縮小 : 長辺が大きすぎる画像はトークンも増え、内部で縮小もされます。判定に必要な解像度まで自前で落としたほうが、コストも結果の再現性も安定します。
ユーザー画面には自動表示されない : ツール結果として返した画像は、Claude は「見える」ものの、アプリ側の最終回答に自動でインライン表示されるわけではありません。チャットUIで見せたいなら、自分のフロントで明示的に描画する必要があります。
順序とペアリング : tool_use(assistant)→ tool_result(user)の対応を崩さないこと。複数ツールを並列で呼んだ場合は、すべての tool_use_id に対して結果を返します。
巨大画像の上限 : API には 1 枚あたりのサイズ上限があります。高解像度の原本をそのまま投げず、点検用の縮小版を返す設計にしておくと安全です。
まず最初に変えるべき一行
すでにツールから画像を返している実装があるなら、今日できる最小の改善は明確です。content に文字列で base64 を入れている箇所を探し、content を配列にして {"type": "image", "source": {...}} ブロックに置き換えてください。これだけで、同じ画像のトークンコストがおおよそ 10 分の 1 になり、しかも Claude が初めて中身を「見て」判断できるようになります。
実装の参考になれば幸いです。私自身まだ運用しながら最適点を探っている途中ですが、画像を返すツールを書くときは「これは Claude にテキストとして読ませているのか、画像として見せているのか」を最初に確かめる、という一点だけでも効くはずです。