ウォンテッドリーでバックエンドエンジニアをしている冨永(@kou_tominaga)です。ウォンテッドリーでは、LLM を活用した機能開発において Amazon Bedrock 経由で Claude を利用するケースが増えています。実際に利用してく中で、「ここは想定外だった」「これはハマった」というポイントがいくつもありました。この記事では、そうした LLM 利用時の落とし穴や学びを、具体例を交えながら共有します。
前提と課題
本記事では、以下環境で実装・運用する中で気づいた点をまとめています。
- AWS Bedrock の Claude 4.5 Sonnet と Claude Haiku 4.5 をプロダクトで利用
- 開発言語は Ruby で、gem aws-sdk-ruby を利用
- レスポンスの構造化には、Claude が提供する tools を利用
LLM の出力が不安定で困るケースがありました。具体的には、約10%の確率で「tools で指定しているスキーマを守らない」、約56%の確率で「レスポンスが空で返却される」という問題が発生していました。
これにより、後続処理でバリデーションエラーが発生したり、不自然な文章が生成されたりするなど、システム品質を低下させる要因となっていました。また、Bedrock で Claude を利用した際にレスポンスが空になる事例は情報が少なく、手探りでの調査が必要でした。
結論
以下の 3 つの対応を行い、出力の安定性を大きく改善できました。
- 適切な max_tokens の設定
- tools のスキーマをシステムプロンプトに書かない
- オプショナルな入力構造を曖昧にしない
適切な max_tokensの設定
max_tokens は LLM が生成できるトークン数の上限を指定するパラメータです。 生成されるトークン量を制御できるため、コストの予測や LLM の過剰な出力を防ぐのに役立ちます。 そのため、用途に応じて適切な値を設定する必要があります。
一方で必要な値を下回るとLLM がスキーマ制約を破ったり、レスポンスを空で返したりする場合がありました。初期実装時に十分な max_tokens を設定していても、修正により LLM のレスポンス量が増えると、設定値が不十分になるケースがあります。
そして、レスポンスが不安定になった際、その原因が max_tokens 不足であることに気がつくのは容易ではありません。これは、エラーとして明示的に通知されず、スキーマ違反やレスポンス欠落、内容の途中欠けなど、別の不具合のように見える挙動として現れるためです。そのため、アプリケーション側の実装やプロンプトの問題と切り分けが難しく、原因特定に時間がかかることがあります。
以下で output_tokens を確認できます。これをログに出力することで、常にトークン数の実測値を確認できるようになるため、おすすめです。
client = Aws::BedrockRuntime::Client.new
respons = client.converse(params)
puts respons.usage.output_tokens
レスポンススキーマの規則をシステムプロンプトに書かない
tools にスキーマの規則を記載しつつ、システムプロンプトにもスキーマの説明を記載していました。結果、スキーマの 2 重管理となり、スキーマが肥大化するとにつれて、片方のみ更新してしまい、tools のスキーマとシステムプロンプトの説明が乖離するミスが発生しやすくなります。
この状態になると、レスポンスが不安定になりました。Claude では tools を使用する際、指定した tools のスキーマや説明が自動的にシステムプロンプト内に組み込まれる仕様となっています。そのため、同じ制約をシステムプロンプトにも重ねて記載すると、モデルが重複した指示を受け取って混乱し、出力が不安定になったと推測します。
このため、制約の記述を tools のスキーマに集約し、システムプロンプトには記載しない方針に変更しました。
※Anthropicのドキュメントでは tools について以下記載があり、 tools の情報もシステムプロンプトとして渡されることが示されています。
When you use tools, we also automatically include a special system prompt for the model which enables tool use
オプショナルな入力構造を曖昧にしない
以下のようなXMLで構造化したプロンプトを与え、レスポンスとして summary / reason / action のいずれかのセクションからテキストを取得し、そのテキストをどのセクションから取得したかを返す、という要件がありました。
レスポンスのスキーマは text と section(enum: summary / reason / action)で構成されています。
プロンプト
<input>
<summary>
ユーザーの入力内容を要約したテキスト
</summary>
<reason>
判断理由や背景の説明テキスト
</reason>
<action>
次に取るべきアクションの提案テキスト
</action>
</input>
response schema
{
"type": "object",
"properties": {
"section": {
"type": "string",
"enum": ["summary", "reason", "action"]
"description": "テキストを取得したセクション",
},
"text": {
"type": "string",
"description": "選択したセクションから取得したテキスト"
}
},
"required": ["section", "text"],
"additionalProperties": false
}
この input において、summary / reason / action はオプショナルなセクションで、場合によっては以下のように summary のみが渡されることがあります。
<input>
<summary>
ユーザーの入力内容を要約したテキスト
</summary>
</input>
この状態では、LLM が存在しない reason や action を補完しようと推測し、summary から取得したテキストを reason や action から取得したことにして、レスポンスを返すことがありました。この問題は、以下のいずれかの対応で解消できることを確認しました。
スキーマに「summary / reason / action の 3 セクションが存在する」ことを明示する
{
"type": "object",
"properties": {
"section": {
"type": "string",
"enum": ["summary", "reason", "action"]
"description": <<~DESCRIPTION
ユーザーから提供されるテキストは、XMLタグで構造化されています。セクション自体が存在しない場合もあります。
セクションはsummary、reason、actionの3種類があります
DESCRIPTION
},
"text": {
"type": "string",
"description": "選択したセクションから取得したテキスト"
}
},
"required": ["section", "text"],
"additionalProperties": false
}
text が存在しない場合でも、空の XML タグを含めて input を渡すようにする
<input>
<summary>
ユーザーの入力内容を要約したテキスト
</summary>
<reason></reason>
<action></action>
</input>
このようにオプショナルな入力構造を曖昧にしないことで、LLM が「どのセクションが存在し、どれが空なのか」を正しく理解できるようになり、不要な推測を防ぐことができました。
結果
元々、空のレスポンスを返却する確率が56%に達していましたが、現在は0%まで低下しました。また、スキーマ違反や意図しない構造のレスポンスについても、10%発生していたものを0.3%程度まで抑えることができました。
LLM 側の振る舞いを完全に制御することは難しいですが、トークン制限の可視化、スキーマ定義の一元化、オプショナルな入力構造を曖昧にしないといった、基本的な設計・運用を徹底することで、プロダクション利用に耐えうる安定性は十分に確保できることが分かりました。
同様の課題に直面している方の参考になれば幸いです。