公開日 2026-04-24

PHPからローカルLLMを呼ぶ最小構成(Ollama + HTTP + JSONレスポンス処理)

Ollama のローカル API を PHP から呼び、Qwen3.5 の軽量 4B モデルに対する最小リクエスト、JSONレスポンスの取り出し、タイムアウトと失敗時処理を理解できる。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. Ollama と Qwen モデルを用意する
  4. Ollama の導入確認
  5. モデルの取得
  6. Ollama の詳細設定をいじっている場合
  7. 3. ホスト側でローカル API を先に確認する
  8. 疎通確認で詰まった場合
  9. 4. PHP デモ環境を作る
  10. 新規デモディレクトリの作成
  11. compose.yml
  12. docker/php/Dockerfile
  13. composer.json
  14. コンテナのビルドと起動
  15. phpdotenv のインストール
  16. 動作確認
  17. 詰まった場合
  18. 5. .env と最小クライアントを作る
  19. .env.example
  20. src/OllamaClient.php
  21. 詰まった場合
  22. コードのポイント
  23. 6. PHP から 1 回呼んで応答を表示する
  24. scripts/chat.php
  25. 実行
  26. 詰まった場合
  27. コードのポイント
  28. 7. 応答 JSON と失敗時処理の見方を整理する
  29. 応答 JSON の主要フィールド
  30. エラー切り分け表
  31. ログとプロンプトの扱い
  32. localhost と host.docker.internal
  33. 8. まとめと次の一歩

Ollama のローカル API を PHP から呼び、JSON 応答から本文を取り出すまでの最小構成を作ります。 モデルは qwen3.5:4b、エンドポイントは POST /api/generate、ストリーミングは無効(stream:false)に固定します。

外部 API キーや従量課金は使いません。 streaming、structured outputs、複数ターン会話、フレームワーク統合、RAG は扱いません。 確認は CLI スクリプトまでで完結し、ブラウザ UI は持ち込みません。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop(WSL 連携有効)
  • Ollama(ホスト側にインストール済み)

以降のコマンドは、次のように使い分けます。

  • ollama --version ollama pull ollama list とホスト側 API 確認は Windows 側の PowerShell
  • docker compose ... と PHP スクリプト実行は WSL 側ターミナル

Ollama をホスト側へ入れる前提なので、WSL 側に ollama コマンドがなくても問題ありません。

1. ゴールと非対象

到達する状態:

  • Ollama のローカル API(POST /api/generate)を PHP から 1 回呼べる
  • JSON 応答から response フィールドを取り出して表示できる
  • 接続失敗、タイムアウト、HTTP エラー、JSON 破損を分岐できる

扱わない内容:

  • stream:true のストリーミング受信
  • format: "json" や JSON Schema を使う structured outputs
  • 複数ターン会話や履歴管理
  • Laravel / Slim / Symfony への組み込み
  • 埋め込み、RAG、ベクトル DB
  • OpenAI などクラウド API との切り替え

今回使うモデルは qwen3.5:4b です。最近の Qwen 系で、Ollama 公式 library に軽量 4B タグがあり、最小構成の記事に載せやすいため採用しました。

2. Ollama と Qwen モデルを用意する

PHP を書く前に、ローカル側で Ollama とモデルを準備します。 先にモデルを用意しておくと、あとで PHP 側のエラーと推論環境の問題を切り分けやすくなります。

Ollama の導入確認

ここからのコマンドは Windows 側の PowerShell で実行します。

Ollama がインストール済みであることを確認します。

ollama --version

バージョンが表示されれば準備完了です。 未導入の場合は Ollama 公式サイト からインストールしてください。

モデルの取得

qwen3.5:4b を pull します。約 3.4GB のダウンロードになるため、初回は少し時間がかかります。

ollama pull qwen3.5:4b

完了したら、モデルが取得されていることを確認します。

ollama list

一覧に qwen3.5:4b が表示されれば準備完了です。

ollama list に qwen3.5:4b が表示されている様子

qwen3.5:4b は Ollama library の qwen3.5 ページに掲載されているタグの 1 つです。正確な実体を確認したい場合は、同ページの tags 一覧から確認できます。

Ollama の詳細設定をいじっている場合

最小構成の確認中は、Ollama の詳細設定や環境変数をできるだけ増やさないほうが切り分けしやすくなります。

特に GUI や環境変数でコンテキスト長を大きい値に固定すると、qwen3.5:4b の初回ロードや推論がかなり重くなり、API 確認の段階で「反応がない」ように見えることがあります。 最初の確認では既定値のまま進めるか、必要になった時点でリクエストごとに options.num_ctx を渡すほうが安全です。

ホストアドレスや同時実行数の設定は、今回の単発確認では大きな問題になりにくい項目です。

3. ホスト側でローカル API を先に確認する

PHP を書く前に、Ollama のローカル API が単体で動くことを確認します。 ここは PHP 実装ではなく、API の疎通確認です。

qwen3.5 は thinking 対応モデルで、Ollama の API では thinking が既定で有効です。 本記事では reasoning trace を扱わず response だけを取り出したいので、request body に think:false を明示します。

ホスト側(WSL ではなく Windows 側)の PowerShell から、Invoke-RestMethod で API を叩きます。 PowerShell では curl.exe に JSON を渡したときの引用ルールで詰まることがあるため、ここでは PowerShell 純正のコマンドレットを主線にします。

$body = @{
  model = 'qwen3.5:4b'
  prompt = 'Say hello in Japanese.'
  stream = $false
  think = $false
} | ConvertTo-Json -Compress

Invoke-RestMethod `
  -Uri 'http://localhost:11434/api/generate' `
  -Method Post `
  -ContentType 'application/json' `
  -Body $body

成功すると、以下のような単一 JSON オブジェクトが返ります。

{
  "model": "qwen3.5:4b",
  "response": "こんにちは! (Konnichiwa!) ...",
  "done": true,
  "done_reason": "stop",
  "total_duration": 1234567890
}

response フィールドにモデルの出力が入っています。 stream:false にしているため、応答が分割されず単一の JSON として返ります。最小構成では ndjson より単一 JSON のほうが扱いやすいためです。 また think:false にしているため、thinking 用の追加フィールドを前提にした分岐を入れずに済みます。

PowerShell から generate API を呼び、JSON 応答が返っている様子

ここでは localhost を使っていますが、後の手順でコンテナ内の PHP から呼ぶ際は host.docker.internal を使います。

疎通確認で詰まった場合

  • 接続拒否: Ollama が起動しているか確認してください。ollama serve で手動起動できます。
  • model not found: ollama pull qwen3.5:4b が完了しているか確認してください。
  • 応答が分割される: stream:false が request body に含まれているか確認してください。
  • thinking の扱いが増えて混乱する: think:false が request body に含まれているか確認してください。
  • invalid character ... looking for beginning of object key string: PowerShell から curl.exe へ JSON を渡すときに body が崩れている可能性があります。Invoke-RestMethod の例をそのまま使って確認してください。
  • 初回応答がかなり遅い: CPU 実行や初回ロードでは数分かかることがあります。配線確認だけ先に済ませたい場合は、一時的に qwen3.5:0.8bqwen3.5:2b へ切り替えて疎通確認し、最後に qwen3.5:4b へ戻すと切り分けしやすくなります。
  • GUI の設定では動くのに API 確認だけ極端に遅い: GUI や環境変数でコンテキスト長を大きく固定していないか確認してください。最小構成の確認では既定値のままにするほうが安定します。

4. PHP デモ環境を作る

以後の手順を docker compose exec app php ... に統一するため、PHP の最小実行環境を用意します。 この段階ではまだ Ollama へは呼ばず、PHP の実行基盤だけを整えます。

新規デモディレクトリの作成

mkdir -p ~/projects/php-local-llm-ollama-demo
cd ~/projects/php-local-llm-ollama-demo
mkdir -p docker/php scripts src
code .

compose.yml

services:
  app:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    working_dir: /workspace
    volumes:
      - ./:/workspace
    command: ["sleep", "infinity"]

docker/php/Dockerfile

HTTP 呼び出しに ext-curl を使うため、Dockerfile で curl 拡張を入れます。

FROM php:8.5-cli

RUN apt-get update \
    && apt-get install -y --no-install-recommends unzip libcurl4-openssl-dev \
    && docker-php-ext-install curl \
    && rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

WORKDIR /workspace

composer.json

{
    "name": "demo/php-local-llm-ollama",
    "type": "project",
    "require": {
        "php": ">=8.2"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
}

コンテナのビルドと起動

docker compose up -d --build

phpdotenv のインストール

.env 読み込みは PHP 側で完結させたいので、vlucas/phpdotenv を使います。

docker compose exec app composer require vlucas/phpdotenv

動作確認

docker compose exec app php -v
docker compose exec app composer --version
docker compose exec app php -m | grep curl

php -m | grep curlcurl が表示されれば、HTTP 呼び出しの準備は整っています。

詰まった場合

  • Dockerfile を修正したら docker compose up -d --build で再ビルドが必要です。
  • curl 拡張が見えない場合はコンテナを再ビルドしてください。
  • Composer の依存取得に失敗する場合は、コンテナ内からネットワークに出られるか確認してください。

5. .env と最小クライアントを作る

接続先 URL、モデル名、タイムアウトをコードから分離し、OllamaClient クラスに閉じ込めます。

.env.example

ホストで動く Ollama をコンテナから呼ぶため、OLLAMA_BASE_URLhttp://host.docker.internal:11434/api を指定します。

OLLAMA_BASE_URL=http://host.docker.internal:11434/api
OLLAMA_MODEL=qwen3.5:4b
OLLAMA_TIMEOUT_SECONDS=120

.env.example をコピーして .env を作成してください。

cp .env.example .env

src/OllamaClient.php

cURL で Ollama の generate エンドポイントを呼び、response フィールドだけを取り出すクラスです。接続・タイムアウト・JSON 検証をこのクラスに閉じ込め、呼び出し側はプロンプト文字列を渡すだけで済む構成にしています。

<?php

declare(strict_types=1);

namespace App;

final class OllamaClient
{
    public function __construct(
        private readonly string $baseUrl,
        private readonly string $model,
        private readonly int $timeoutSeconds,
    ) {}

    /**
     * @return array{response: string, raw: array<string, mixed>}
     * @throws \RuntimeException
     */
    public function generate(string $prompt): array
    {
        $url = rtrim($this->baseUrl, '/') . '/generate';

        $payload = json_encode([
            'model' => $this->model,
            'prompt' => $prompt,
            'stream' => false,
            'think' => false,
        ], JSON_THROW_ON_ERROR);

        $ch = curl_init($url);

        if ($ch === false) {
            throw new \RuntimeException("curl_init failed for {$url}");
        }

        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_POSTFIELDS => $payload,
            CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 10,
            CURLOPT_TIMEOUT => $this->timeoutSeconds,
        ]);

        $body = curl_exec($ch);

        if (curl_errno($ch) !== 0) {
            $error = curl_error($ch);
            throw new \RuntimeException("cURL error: {$error}");
        }

        if (!is_string($body)) {
            throw new \RuntimeException('Empty response body from Ollama API');
        }

        $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);

        if ($statusCode !== 200) {
            throw new \RuntimeException("HTTP {$statusCode} from {$url}");
        }

        try {
            /** @var array<string, mixed> $data */
            $data = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            throw new \RuntimeException("JSON decode failed: {$e->getMessage()}");
        }

        if (!isset($data['response']) || !is_string($data['response'])) {
            throw new \RuntimeException("'response' key missing or invalid in API response");
        }

        return [
            'response' => $data['response'],
            'raw' => $data,
        ];
    }
}

stream:falsethink:false を request body に固定しているため、戻り値は「単一 JSON で final answer を受け取る」流れに寄せています。 エラーメッセージには生のプロンプト全文を含めず、HTTP ステータスや接続先だけを残しています。プロンプトが機微情報を含む場合に備えた配慮です。

詰まった場合

  • host.docker.internallocalhost のままにしていないか確認してください。コンテナ内の localhost はコンテナ自身を指します。
  • Docker Desktop 前提のため、Linux サーバー上の素の Docker Engine では host.docker.internal がそのまま使えない場合があります。
  • .env.env.example から作成したか確認してください。
  • JSON_THROW_ON_ERROR で例外が出る場合は、受信した body が JSON でない可能性があります。3 章のホスト側確認に戻って API の応答を確認してください。

コードのポイント

stream:false / think:falseJSON_THROW_ON_ERROR

stream:false を付けないと応答が ndjson(1 トークンずつの改行区切り JSON)になり、単一 JSON として受け取れません。think:false は reasoning trace を無効にし、response だけで完結するレスポンスに固定します。JSON_THROW_ON_ERROR でエンコード失敗を例外に変換しています。

$payload = json_encode([
    'model' => $this->model,
    'prompt' => $prompt,
    'stream' => false,
    'think' => false,
], JSON_THROW_ON_ERROR);

CURLOPT_RETURNTRANSFERCURLOPT_TIMEOUT

CURLOPT_RETURNTRANSFER => true がないと curl_exec は結果を直接出力して true を返すため、後続の JSON デコードができません。CURLOPT_TIMEOUT には .env の値を使い、モデルの読み込み時間に応じて調整できる構成にしています。

curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $payload,
    CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CONNECTTIMEOUT => 10,
    CURLOPT_TIMEOUT => $this->timeoutSeconds,
]);

$data['response'] の型チェック

JSON デコードが成功しても response キーが存在しない、または文字列でない場合があります。stream:false の付け忘れで ndjson が返ってきたときも response の構造が変わるため、取り出し前に issetis_string で確認しています。

if (!isset($data['response']) || !is_string($data['response'])) {
    throw new \RuntimeException("'response' key missing or invalid in API response");
}

6. PHP から 1 回呼んで応答を表示する

OllamaClient を CLI スクリプトから呼び、「PHP からローカル LLM を呼ぶ」を完結させます。

scripts/chat.php

コマンドライン引数でプロンプトを受け取り、OllamaClient に渡して応答を標準出力へ表示するスクリプトです。

<?php

declare(strict_types=1);

require_once __DIR__ . '/../vendor/autoload.php';

use App\OllamaClient;
use Dotenv\Dotenv;

$dotenv = Dotenv::createImmutable(__DIR__ . '/..');
$dotenv->load();

$prompt = $argv[1] ?? null;

if ($prompt === null) {
    fwrite(STDERR, "Usage: php scripts/chat.php \"your prompt here\"\n");
    exit(1);
}

$client = new OllamaClient(
    baseUrl: $_ENV['OLLAMA_BASE_URL'],
    model: $_ENV['OLLAMA_MODEL'],
    timeoutSeconds: (int) $_ENV['OLLAMA_TIMEOUT_SECONDS'],
);

try {
    $result = $client->generate($prompt);
    echo $result['response'] . "\n";
} catch (\RuntimeException $e) {
    fwrite(STDERR, "Error: {$e->getMessage()}\n");
    exit(1);
}

実行

docker compose exec app php scripts/chat.php "PHPの特徴を1文で教えてください。"

成功すると、モデルの応答本文だけが表示されます。

PHPはサーバーサイドで動作するスクリプト言語で、Webアプリケーション開発に広く使われています。

実際の応答内容はモデルの推論によって毎回変わります。 1 回目の実行はモデルの読み込みで待つことがあります。2 回目以降は速くなるのが通常です。

php scripts/chat.php の実行結果として応答本文が表示されている様子

prompt を 1 引数で受けるだけにしています。複数ターン会話や履歴管理は別記事のスコープです。

詰まった場合

  • 引数なしで実行すると使い方が表示されます。
  • 日本語が文字化けする場合はターミナルの文字コードが UTF-8 になっているか確認してください。
  • タイムアウトする場合は .envOLLAMA_TIMEOUT_SECONDS を大きくしてみてください。CPU 実行や初回のモデル読み込みでは 120 秒を超えることがあります。
  • 応答待ちが長すぎて切り分けしにくい場合は、一時的に .envOLLAMA_MODELqwen3.5:0.8bqwen3.5:2b に切り替えて疎通確認し、配線が確認できたら qwen3.5:4b に戻してください。

コードのポイント

$_ENV で OllamaClient に設定値を渡す

vlucas/phpdotenvcreateImmutable を使うと、.env の値が $_ENV 経由で参照できます。名前付き引数で渡すことで、コンストラクタの引数順を気にせず読みやすくなります。OLLAMA_TIMEOUT_SECONDS は文字列で入るため (int) キャストを明示しています。

$client = new OllamaClient(
    baseUrl: $_ENV['OLLAMA_BASE_URL'],
    model: $_ENV['OLLAMA_MODEL'],
    timeoutSeconds: (int) $_ENV['OLLAMA_TIMEOUT_SECONDS'],
);

② エラーを STDERR へ出力して終了する

fwrite(STDERR, ...) で標準エラー出力に書くことで、エラーメッセージとモデルの応答本文が混在しません。exit(1) で非ゼロの終了コードを返すため、シェルスクリプトや CI で異常終了を検知できます。

} catch (\RuntimeException $e) {
    fwrite(STDERR, "Error: {$e->getMessage()}\n");
    exit(1);
}

7. 応答 JSON と失敗時処理の見方を整理する

動いたあとに「どのキーを見ればよいか」「どこで失敗するか」を整理します。

応答 JSON の主要フィールド

stream:false で返る単一 JSON の主要フィールドです。

フィールド内容
modelstring使用されたモデル名
responsestringモデルの出力本文
thinkingstringthinking を有効にした場合の reasoning trace
donebool生成が完了したかどうか
done_reasonstring完了理由("stop" など)
total_durationint処理全体の所要時間(ナノ秒)
load_durationintモデル読み込みの所要時間(ナノ秒)
eval_countint生成されたトークン数
eval_durationintトークン生成の所要時間(ナノ秒)

response だけ見れば最小構成としては十分です。donedone_reason を知っておくと、後で生成の打ち切り判定などに使えます。thinkingthink:true のときに別扱いしたくなった段階で見れば十分です。

stream:false を外すと、応答は ndjson(改行区切りの JSON)になり、1 トークンごとに {"response":"...","done":false} が送られてきます。扱い方がまったく異なるため、本記事では固定しています。

エラー切り分け表

症状原因の候補確認方法
cURL error: Connection refusedOllama が起動していないollama serve で手動起動する
cURL error: Operation timed outモデル読み込みや推論に時間がかかっているOLLAMA_TIMEOUT_SECONDS を増やす。初回はモデル読み込みで時間がかかる
HTTP 404エンドポイントの URL が間違っている.envOLLAMA_BASE_URL を確認する
HTTP 400 / model not foundモデル名が間違っている.envOLLAMA_MODELollama list の表記を照合する
JSON decode failed受信した body が JSON でない3 章のホスト側確認に戻り、API の応答を直接確認する
'response' key missingAPI の応答形式が想定と異なるstream:false が付いているか確認する。付け忘れると ndjson になる
thinking が前提の応答になって扱いにくいthinking を有効にしたまま呼んでいるthink:false を付ける

接続失敗とモデル出力の失敗は分けて見るのが基本です。接続できているのに応答が想定外なら、まず 3 章のホスト側確認に戻るのが近道です。

ログとプロンプトの扱い

エラーログや例外メッセージにプロンプトの全文を含めないようにしてください。プロンプトにはユーザーの入力がそのまま入るため、機微情報が含まれる可能性があります。 OllamaClient のエラーメッセージに HTTP ステータスや接続先だけを含めているのは、この配慮によるものです。

localhost と host.docker.internal

場所使う URL
ホスト側(Windows / WSL)から確認する場合http://localhost:11434/api/generate
コンテナ内の PHP から呼ぶ場合http://host.docker.internal:11434/api/generate

コンテナ内の localhost はコンテナ自身を指すため、ホスト側の Ollama にはつながりません。host.docker.internal は Docker Desktop が提供するホストマシンへの参照です。 この前提は Docker Desktop 向けなので、Linux サーバー上の素の Docker Engine へそのまま持ち込むときは接続方法を見直してください。

8. まとめと次の一歩

到達した状態:

  • Ollama に qwen3.5:4b を用意し、ローカル API が動くことを確認した
  • PHP の ext-curlPOST /api/generatestream:false)を呼び、単一 JSON として応答を受け取った
  • response フィールドから本文を取り出し、接続失敗・タイムアウト・HTTP エラー・JSON 破損を分岐した

最小構成としてはここまでで十分です。

次に広げる場合、以下が候補です。

  • Slim 4 の route へ組み込む — CLI スクリプトを Web API として公開する
  • structured outputs を足すformat: "json" や JSON Schema でモデルの出力形式を制御する
  • streaming を扱うstream:true で ndjson を逐次受信し、リアルタイム表示に対応する