背景
個人開発中のIT資格試験学習アプリ(Laravel + React + Docker)で、ローカル環境からAIによる問題自動生成機能を実行したところ「生成に失敗した」という報告を受け、原因調査をしたところ、実は無関係な2つの不具合が重なっていたことが分かりました。
同じセッション内で立て続けに踏んだので、まとめて記録しておきます。
環境
- Laravel 13 / PHP 8.4(
php:8.4-fpmイメージ) - Docker Compose(app / nginx / queue / scheduler構成)
- Anthropic Messages API(Claudeモデル)をLaravelの
Httpファサードから直接呼び出し
1つ目: PHP-FPMのdisplay_errors未設定で、401が500クラッシュに化ける
ログを確認すると、未認証リクエストへの応答時に以下のような致命的エラーが記録されていました。
Cannot modify header information - headers already sent by (output started at /path/to/vendor/some-lib/generated/xxx.php:86)
原因は、docker/php/Dockerfileでdisplay_errorsを明示的に設定していなかったことです。PHP-FPMはdisplay_errorsを設定しないとデフォルトでOnのまま動きます。その結果、vendor配下のライブラリが出すDeprecated警告がHTTPレスポンスボディに直接出力されてしまい、Laravel(Symfony HttpFoundation)が後から本来のステータスコード(今回は401)のヘッダーを送ろうとした際に「すでに出力が始まっている」状態と衝突し、致命的エラーになっていました。
つまり、本来は普通の401(未ログイン)で済むはずのレスポンスが、この不具合のせいで500クラッシュのように見えてしまっていたわけです。
対処
docker/php/Dockerfile(本番用のDockerfileも同様に)にconf.dファイルを追加しました。
RUN { \
echo 'display_errors=Off'; \
echo 'log_errors=On'; \
echo 'error_reporting=E_ALL & ~E_DEPRECATED'; \
} > /usr/local/etc/php/conf.d/error-reporting.ini
display_errors=Offにしてもlog_errors=Onは維持しているので、エラー自体はログには残り、調査能力は落ちません。~E_DEPRECATEDは、vendorライブラリ由来の大量のDeprecated警告をログからも除外し、本当に見るべきエラーだけが残るようにする狙いです。
修正後、意図的に未認証リクエストを送って401が正常に返ることと、該当エラーがログに出なくなったことを確認しました。
2つ目: AIの応答に説明文が混じり、JSONデコードに失敗する
もう1つの不具合は、AIによる問題生成そのものでした。「JSONのみ・説明不要」とシステムプロンプトで指示していたにもかかわらず、モデルが「〜の問題を生成します」といった説明文をJSONの前に出力することがあり、以下のような正規表現ベースの抽出ロジックが失敗していました。
// Before: 正規表現でコードフェンスを抜き出してからjson_decode
if (preg_match('/```(?:json)?\s*([\s\S]+?)\s*```/', $text, $m)) {
$text = $m[1];
}
$data = json_decode(trim($text), true, 512, JSON_THROW_ON_ERROR);
プロンプトで「JSONのみ返して」と指示するだけの方式は、モデルが指示に従わなかった瞬間に壊れるという構造的な弱さがあります。
対処: Structured Outputsで応答形式そのものを強制する
Anthropic Messages APIには、レスポンスの形式をJSON Schemaで強制できるStructured Outputsという仕組みがあります。リクエストにoutput_config.formatとしてスキーマを渡すと、モデルの出力がそのスキーマに準拠したJSONそのものになります(正規表現で頑張って抜き出す必要がなくなります)。
$response = Http::withHeaders([...])->post('https://api.anthropic.com/v1/messages', [
'model' => 'claude-sonnet-5', // Structured Outputs対応モデル
'max_tokens' => 8000,
'output_config' => [
'format' => [
'type' => 'json_schema',
'schema' => [
'type' => 'object',
'properties' => [
'questions' => [
'type' => 'array',
'items' => [/* ... */],
],
],
'required' => ['questions'],
'additionalProperties' => false,
],
],
],
// ...
]);
// After: 正規表現を撤廃し、json_decodeのみ
$data = json_decode(trim($text), true, 512, JSON_THROW_ON_ERROR);
return $data['questions'] ?? [];
注意点として、Structured Outputsは全モデルが対応しているわけではありません。今回使っていたモデルは非対応の旧世代だったため、対応している現行世代モデルへの切り替えも合わせて行いました。Web検索ツールなど他の機能と併用できるかも事前にドキュメントで確認してから進めています。
余談: この不具合がテストで検知できなかった理由
既存のテストはQueue::fake()でJobの投入だけを検証しており、Job内部のhandle()(API呼び出し・JSONパース)は一度も実行されていませんでした。今回、Http::fake()でAPI応答をモックした上でhandle()を直接呼ぶテストを追加し、このバグの再発を防ぐようにしています。
まとめ
- PHP-FPMは
display_errorsを明示しないとOnのまま動く。放置すると、vendorのDeprecated警告が本番相当の環境でもレスポンスに混入し、正常なエラーレスポンスすら壊すことがある - LLMに「JSONだけ返して」とプロンプトで指示する方式は、指示に従わなかった瞬間に壊れる。可能ならStructured Outputsのような、API側で出力形式を強制できる仕組みに寄せた方が堅牢
-
Queue::fake()だけのテストは、Job内部のロジックまでは検証できていないことに注意する
参考
- Anthropic Messages API ドキュメント(Structured Outputs / output_config.format)
- PHP公式ドキュメント(
display_errors/error_reporting)