DeepSeekマルチターンで欠落した reasoning_content を常に送信するよう修正
DeepSeek の思考対応モデルがマルチターンリクエストで reasoning_content が欠如するとエラーになる問題を、プロバイダー側のフォーマットロジックで常にキーを出力するように修正しました。
背景
DeepSeek の thinking‑capable モデル(deepseek-reasoner・deepseek-v4-flash・deepseek-v4-pro など)は、次回リクエスト時に reasoning_content キーが必須であることが API 仕様で求められています。従来の OpenAI 互換実装 format_thinking は、thinking.text が空の場合に reasoning_content を省略し、結果としてマルチターンで直前のアシスタントメッセージに思考テキストが無いと API がリクエストを拒否していました。この挙動は DeepSeek 固有のバリデーションに起因し、他のプロバイダーでは問題になりませんでした。
この欠落は、ツール呼び出しだけを返したターンや思考テキストがキャプチャされなかったケースで顕在化し、実運用でのマルチターン対話が失敗する根本原因となっていました。そこで、DeepSeek 用プロバイダーに限定した修正を加えることで、全モデルで一貫した動作を保証する方針が取られました。
技術的な変更
RubyLLM::Providers::DeepSeek::Chat に format_thinking メソッドが新たに実装され、アシスタントメッセージで常に reasoning_content キーを出力します。テキストが空でも空文字列を返し、reasoning と reasoning_signature は従来通り条件付きで追加されます。実装は以下の通りです。
def format_thinking(msg)
return {} unless msg.role == :assistant
thinking = msg.thinking
text = thinking&.text.to_s
payload = { reasoning_content: text }
payload[:reasoning] = text unless text.empty?
payload[:reasoning_signature] = thinking.signature if thinking&.signature
payload
end
この変更により、thinking が nil でも reasoning_content: "" が必ず含まれ、DeepSeek のバリデーションを通過します。reasoning と reasoning_signature は元のロジックと同様に、テキストが非空の場合またはシグネチャが存在する場合にのみ付与されます。結果として、非思考モデル(例: deepseek-chat)は空キーを無視し、既存の動作に影響を与えません。
設計判断
本修正は プロバイダー単位でのオーバーライド という選択肢を取っています。OpenAI 互換実装はそのまま残し、DeepSeek 用にだけ format_thinking を再定義することで、共通コードの侵食を防ぎつつ後方互換性を保ちました。モデル ID に依存しない実装とすることで、将来追加される DeepSeek 系列モデルすべてに自動的に適用される設計となっています。
代替案としては reasoning_content 用の新規設定キーを導入する案が議論されましたが、既存の API 仕様に沿うためにはキー名自体を必須にする必要があり、設定散在のリスクが高まります。そのため、既存キー reasoning_content を拡張し、空文字列でも必ず送出するシンプルなアプローチが選ばれました。
結果的に、最小限のコード変更で DeepSeek のマルチターン制御を安定化させ、他プロバイダーへの影響を回避するというトレードオフが実現されています。
まとめ
DeepSeek 系列の思考対応モデルが求める reasoning_content を必ず送信するよう format_thinking をオーバーライドし、空文字列でもキーを出力することでマルチターンリクエストの失敗を防止しました。この変更はプロバイダー単位の限定的な実装で、既存コードとの互換性と将来のモデル拡張性を両立させています。