PHPでClaude APIのtool_useを実装する際の注意点3つ

PHPでClaude APIのtool_useを実装する際の注意点3つ

2026/10/07

LLMに日付計算や社内マスタの参照をさせず、コード側の関数を呼ばせる仕組みをPHP(Laravel)で実装しました。Claude APIの tool_use / tool_result を使った、いわゆるツール呼び出し(function calling)の実装です。

公式ドキュメントに沿って書けば動くのですが、いくつか「読み飛ばすと静かに壊れる」箇所がありました。特に空のスキーマをJSONに変換する箇所は、PHPでしか起きない問題です。

同じような構成を検討されている方の参考になれば幸いです。

検証環境は次のとおりです。

  • PHP 8.3
  • Laravel 12
  • Claude API(Messages API、anthropic-version: 2023-06-01)
  • モデル:claude-sonnet-4-6
  • 検証日:2026年9月29日

なお、本記事のコードは実際のコードを単純化・一般化したものです。

背景|なぜLLMに計算させなかったのか

弊社で開発しているシステムでは、利用者との対話の中で「回答期限は10月2日です」といった案内を返す必要がありました。

当初はこの日付の計算もLLMに任せていました。しかし、土日祝日を除いた営業日の計算を安定して行わせるのは難しく、同じ条件でも結果がぶれることがあります。日付や金額のように1文字違えば事故になる値を、確率的に出力される文章の一部として扱うのは危険だと判断しました。

そこで方針を変え、次のように役割を分けました。

  • LLMの役割:いま何を確定させる場面かを判断し、自然な言い回しで伝える
  • コードの役割:日付・営業日数・マスタの値など、確定した値を計算して返す

この橋渡しをするのが tool_use と tool_result です。

tool_use と tool_result の基本

tool_use とは、Claudeが「この関数を、この引数で実行してほしい」と依頼するために返すブロックです。 そして tool_result は、その実行結果をこちらから返すためのブロックです。

前提として、Claude自身はコードを実行できません。DBも引けませんし、今日の日付も正確には知りません。できるのは「これを呼んでほしい」と依頼するところまでで、実際に動かすのは常にこちら側です。

やり取りは次の2往復になります。

  1. tools(呼べる関数の一覧)を載せてリクエストを送る
  2. レスポンスの stop_reason が "tool_use" で返る(=依頼が来た)
  3. こちらで関数を実行する
  4. 実行結果を tool_result として送り返す
  5. stop_reason が "end_turn" になり、発話テキストが返ってくる

stop_reason は「Claudeがなぜ出力を止めたか」を示す値です。"tool_use" は「言いたいことはあるが、その前に呼んでほしいものがある」という中断だと捉えると分かりやすいと思っています。

実際に返ってくる tool_use ブロックは次のような形です。

{
  "type": "tool_use",
  "id": "toolu_01A...",
  "name": "calculate_reply_date",
  "input": {}
}

これに対して返す tool_result は次の形になります。

{
  "type": "tool_result",
  "tool_use_id": "toolu_01A...",
  "content": "{\"date\":\"2026-10-02\"}"
}

id と tool_use_id が同じ値になっている点が重要です。依頼に振られた伝票番号を、結果にもそのまま書いて返すことで、どの依頼に対する結果なのかが決まります。

注意点1|tool_result は1つの user メッセージにまとめる

Claudeは1回のレスポンスで複数の tool_use を同時に返すことがあります。並列ツール呼び出しと呼ばれる挙動で、既定で有効になっています。

このとき、実行結果を返す方法に決まりがあります。

  • 症状:複数の tool_result を別々の user メッセージに分けて送ると、以降Claudeが並列でツールを呼ばなくなり、1件ずつしか依頼してこなくなる
  • 原因:会話履歴の形そのものが「このAPIでは同時に頼めない」という例示として働くため。エラーは出ず、警告も出ない
  • 教訓:LLMに送る会話履歴は、指示であると同時に「振る舞いの見本」でもある。壊れた形を一度でも履歴に混ぜると、以降の応答がその形に引きずられる

この点は公式ドキュメントにも明記されています(英語原文の拙訳です)。

全ての tool_result ブロックを単一の user メッセージで返すこと。複数のメッセージに分割すると、Claudeが並列呼び出しをやめるよう静かに学習してしまう

弊社では実装前にこの記述を拾えたため事故には至りませんでしたが、エラーにならない以上、踏んでも気づけない類の問題です。実装としては、次のようにループの外で1つの配列にまとめてから追加します。

$toolResults = [];

foreach ($response['content'] as $block) {
    if (($block['type'] ?? '') !== 'tool_use') {
        continue;
    }

    $output = $this->dispatcher->execute(
        (string) $block['name'],
        (array) ($block['input'] ?? []),
    );

    $toolResults[] = [
        'type'        => 'tool_result',
        'tool_use_id' => (string) $block['id'],
        'content'     => json_encode($output, JSON_UNESCAPED_UNICODE),
    ];
}

// ループの中で $messages[] してはいけない。必ず1つにまとめる
$messages[] = ['role' => 'user', 'content' => $toolResults];

なお、ツールの実行に失敗した場合でも tool_result を返さずに握りつぶしてはいけません。is_error を付けて、失敗したという事実を返します。

$toolResults[] = [
    'type'        => 'tool_result',
    'tool_use_id' => (string) $block['id'],
    'content'     => 'マスタの参照に失敗しました',
    'is_error'    => true,
];

注意点2|assistant の content は加工せずそのまま返す

2回目のリクエストでは、1回目のレスポンスを assistant の発言として履歴に積み直します。このとき、レスポンスの content を加工してはいけません。

  • 症状:発話テキストだけを抜き出して assistant の履歴に積むと、tool_result に対応する tool_use が履歴から消え、リクエストが通らなくなる
  • 原因:tool_use_id は tool_use ブロックとの対応で意味を持つ。片方だけを残すと伝票番号の宛先が存在しなくなる
  • 教訓:LLM APIのレスポンスは「表示するためのテキスト」ではなく「次のリクエストの一部」である。表示用に整形した文字列と、APIに送り返す構造体は、別物として持つ

実装では、レスポンスの content を配列のまま積み直します。

// text ブロックだけを取り出して積むのはNG
$messages[] = ['role' => 'assistant', 'content' => $response['content']];

テキストが必要な場面では、履歴とは別に取り出します。

private function textOf(array $content): string
{
    $texts = [];

    foreach ($content as $block) {
        if (($block['type'] ?? '') === 'text' && isset($block['text'])) {
            $texts[] = (string) $block['text'];
        }
    }

    return trim(implode("\n", $texts));
}

注意点3|PHPでは空の properties が [] になる

ここがPHP特有の落とし穴で、今回いちばん気づきにくかった点です。

弊社のツールには引数を1つも受け取らないものがあります。日付の計算をLLMに一切させないため、あえて引数を持たせず、必要な値はすべてコード側が握る設計にしたためです。

このとき input_schema の properties は空になります。素直に空配列を書くと、次のようになります。

// NG例
'input_schema' => [
    'type'       => 'object',
    'properties' => [],
],
  • 症状:JSON Schemaとして不正な形のままリクエストが送られる(弊社では送信前に気づいたため、APIがどう応答するかまでは確認していません)
  • 原因:PHPの json_encode() は、空配列 [] をJSONの配列 [] に変換する。JSON Schemaの properties はオブジェクト {} でなければならない。PHPは連想配列と配列を同じ型で表現するため、中身が空のときに区別がつかなくなる
  • 教訓:PHPからJSON APIを叩くときは「空の連想配列」を疑う。要素が1つでもあれば {} になるため、開発中は正常に動き、値が空になる境界でだけ壊れる

対処は、空のオブジェクトを明示することです。

// OK例
'input_schema' => [
    'type'       => 'object',
    'properties' => new \stdClass(),
],

JSON_FORCE_OBJECT を使う方法もありますが、こちらはペイロード全体の配列をすべてオブジェクトに変換してしまうため、messages などの本来配列であるべき箇所まで壊れます。該当箇所だけ new \stdClass() を置くのが安全です。

実際のツール定義は次のような形になります。

private function replyDateTool(): array
{
    return [
        'name'        => 'calculate_reply_date',
        'description' => '回答期限を伝える場面になったときに呼ぶ。'
            . '日付や営業日数は渡さないこと。営業日の計算はこちらが行う。'
            . '返ってきた値をそのまま読み上げること。',
        'input_schema' => [
            'type'       => 'object',
            'properties' => new \stdClass(),
        ],
    ];
}

description に「いつ呼ぶか」を書いている点も、意図的なものです。Claudeはこの説明文を読んで呼ぶかどうかを判断するため、機能の説明だけでなく発動条件を書いた方が、狙った場面で呼ばれやすくなります。

往復処理を1つのクラスに閉じ込める

最後に設計の話です。

tool_use と tool_result を扱うコードは、1つのクラスの中だけに閉じ込めるようにしました。呼び出し側からは「入力を渡すと、確定値と発話テキストが返ってくる」ようにしか見えません。

class ToolCallRunner
{
    /** 往復の上限。1往復で終わる想定だが、続けて呼ばれる場合に備える */
    private const MAX_ROUNDS = 3;

    public function run(string $prompt, array $tools): array
    {
        $messages = [['role' => 'user', 'content' => $prompt]];
        $results  = [];

        for ($round = 0; $round < self::MAX_ROUNDS; $round++) {
            $response = $this->claude->send([
                'model'      => 'claude-sonnet-4-6',
                'max_tokens' => 1024,
                'messages'   => $messages,
                'tools'      => $tools,
            ]);

            $content = $response['content'] ?? [];

            if (($response['stop_reason'] ?? null) !== 'tool_use') {
                return ['text' => $this->textOf($content), 'results' => $results];
            }

            $messages[] = ['role' => 'assistant', 'content' => $content];

            // (tool_result を1つにまとめる処理。注意点1を参照)
            $messages[] = ['role' => 'user', 'content' => $toolResults];
        }

        return ['text' => '', 'results' => $results];
    }
}

このクラスには、実装していて必要になった2つの歯止めが入っています。

1つめは往復回数の上限です。 通常は1往復で終わりますが、モデルが続けてツールを呼ぶ可能性があるため、MAX_ROUNDS で上限を設けています。LLMの応答を条件にループを回す以上、終了条件をLLM側に委ねるべきではないと考えました。

2つめは、stop_reason が "tool_use" なのに tool_use ブロックが1つも無い場合の脱出です。 この状態でループを続けても進みようがないため、その時点で打ち切ります。

また、セキュリティの観点ではLLMが生成した引数をそのまま実行系に渡さないことが重要です。input の中身は利用者の入力が形を変えて届いたものと考え、受け取り側で必ず検証します。ファイルパスやSQL断片、コマンド文字列を引数に取るツールは、それ自体が攻撃経路になり得ます。弊社では引数を持たないツールを多くしていますが、これは事故を減らす意味でも結果的に有効でした。

まとめ

Claude APIのツール呼び出しをPHPで実装する際の要点は次の4つです。

  • 複数の tool_result は必ず1つの user メッセージにまとめる。分けて送るとエラーは出ないまま、並列呼び出しが行われなくなる
  • レスポンスの content は加工せずそのまま履歴に積む。テキストだけ抜き出すと tool_use と tool_result の対応が切れる
  • 引数なしのツールでは properties に new \stdClass() を置く。PHPの json_encode() は空配列を {} にしてくれない
  • ループの終了条件をLLMに委ねない。往復回数の上限と、異常系での脱出を必ず用意する

より一般化すると、LLMに送る会話履歴は、指示であると同時に振る舞いの見本でもあるという点に尽きると思っています。壊れた形を履歴に混ぜると、エラーではなく「品質の低下」という形で表れます。エラーが出ないぶん気づきにくく、原因の特定にも時間がかかります。

そしてもう1つ、値の正しさが求められる処理はLLMに任せないという判断も、結果的に正解でした。日付や金額の計算をコード側に持たせたことで、テストが書けるようになり、同じ入力からは必ず同じ結果が出ることを保証できるようになりました。

おわりに

ここまで読んでいただき、ありがとうございました。
私たちは、できることを誠実にやる会社です。

Web・システム・AIのことで気になることがあれば、
「何から相談すればいいか分からない」段階でも大丈夫です。
お気軽にご相談ください。

▶ お問い合わせ

本記事は、Claudeでのやり取りから本記事を作成し、加筆・修正しております。

  • #PHP
  • #Claude API

サニージェム公式note

アーカイブ記事

すべて

contact us お問い合わせ

Contact お問い合わせ・ご相談

「何から相談すればいいかわからない」
そんな段階でも、お気軽にご連絡ください。

Recruit 求人へのご応募

サニージェムの考え方に共感してくれる方と、
ぜひ一緒に働きたいと思っています。