Pydantic AI 2.68のModelFallbackは、max_tokensで途切れた応答を予備のモデルへ回す|2.67との比較と元に戻す設定

Pydantic AI 2.68.0で、ModelFallbackの既定の動きが変わった

PythonでAIエージェントを作るライブラリPydantic AIの2.68.0が、2026年11月12日(日本時間)に公開されました(2.68.0のリリースノート)。リリースノートの「Compatibility Notes」(互換性に関わる変更)に、予備のモデルへ切り替える機能ModelFallbackの変更が載っています。

ModelFallbackは、エージェントに付けておくと、1つ目のモデルがエラーを返したときなどに、次に指定したモデル(予備のモデル)へ同じ依頼を送り直す機能です。2.64.0で加わりました。2.68.0からは、モデルがmax_tokens(モデルが書く量の上限)に達して途中で書くのをやめた応答も、予備のモデルへ回すのが既定になりました。2.67.0までは、途中で切れた文がそのままエージェントの出力になっていました。

この記事では、2.67.0と2.68.0を手元に入れ、同じコードで動きを比べました。モデルには、決まった応答を返す関数と、手元で動かしたOpenAI互換の検証用サーバーを使っています。検証用サーバーは、受け取った上限より長い回答を途中で切って返します。どの場合に予備へ回るか、max_tokensの書き場所で結果が変わること、元の動きに戻す設定、費用の数え方を順に説明します。

要点

2.68.0のModelFallbackは、上限で途切れた応答を予備のモデルへ回す

2.67.0と2.68.0に同じコードを渡して比べた結果です。

変わった既定の動き

途切れた応答は予備のモデルが答え直す

2.67.0では、途中で切れた文がそのまま出力になりました。

エージェント全体のmax_tokens

予備にも同じ上限が届き、予備も切れると例外

予備に長く書かせるなら、max_tokensをモデルごとのsettingsに書きます。

元の動きに戻す設定

fallback_onからOutputTokenLimitExceededを外す

fallback_on=(ModelAPIError, ContentFilterError)を渡すと、2.67.0と同じ出力になりました。

予備へ回らない呼び出し

ストリーミングで受け取ると、途切れた文が出力になる

run_streamと、event_stream_handlerを渡したrunで確かめました。

ModelFallbackは、2.67では途切れた文を出力にし、2.68では予備に答え直させる

まず、変更の中身を2つの版で比べます。モデルが書くのをやめた理由は、応答のfinish_reasonに入ります。最後まで書き終えた応答は"stop"、max_tokensに達して途中でやめた応答は"length"です。2.68.0では、ModelFallbackの既定のfallback_on(予備へ移る条件)に、新しい例外OutputTokenLimitExceededが加わりました。fallback_onにこの例外が入っていると、ストリーミングで受け取らない実行では、finish_reasonが"length"の応答が予備へ回ります。

手元の検証用サーバーでは、1つ目のモデルprimary-modelは最後まで書くのに40トークン、予備のモデルbackup-modelは20トークンが必要なように作りました。上限は、1つ目に16、予備に1000を、それぞれのモデルのsettingsで渡しています。予備の回答は、見分けやすいよう「(予備)」で始まる文にしました。

2.67.0(既定のModelFallback)

1つ目のモデルの途切れた文が出力になる

出力:「東京都の人口は、2025年の推計で約1,4」

予備のモデルは呼ばれません。finish_reasonは"length"のままです。

2.68.0(既定のModelFallback)

予備のモデルが最後まで答え直す

出力:「(予備)東京都の人口は約1,400万人です。」

1つ目の試行は、退けた試行の記録としてfailed_attemptsに残ります。

2.68.0で実際に動かしたコードと出力です。awaitを使う行は、asyncの関数の中で実行しています。OpenAIChatModelを手元の検証用サーバー(127.0.0.1)に向けています。

from pydantic_ai import Agent
from pydantic_ai.capabilities import ModelFallback
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider

provider = OpenAIProvider(base_url="http://127.0.0.1:18482/v1", api_key="dummy")
primary = OpenAIChatModel("primary-model", provider=provider, settings={"max_tokens": 16})
backup = OpenAIChatModel("backup-model", provider=provider, settings={"max_tokens": 1000})

agent = Agent(primary, capabilities=[ModelFallback(backup)])
result = await agent.run("東京都の人口は?")
print(result.output)
print(result.response.model_name, result.response.finish_reason)

# 2.68.0の出力
# (予備)東京都の人口は約1,400万人です。
# backup-model stop

同じコードを2.67.0で動かすと、出力は「東京都の人口は、2025年の推計で約1,4」、model_nameはprimary-model、finish_reasonはlengthでした。サーバーへのリクエストも1回だけで、予備のモデルは呼ばれていません。

途切れ方の違うケースは、決まった応答を返す関数をモデルにして、2つの版で比べました。2.68.0では、"length"の応答は中身にかかわらず予備へ回ります。構造化出力(output_typeにPydanticのモデルなどを指定し、決まった形のJSONで受け取る出力)でも、JSONとして最後まで読める応答が"length"なら予備へ回りました。

1つ目のモデルの応答(既定のModelFallback) 2.67.0 2.68.0
途中で切れた文 切れた文が出力 予備のモデルの文が出力
何も書かずに上限に達した(空の応答) 例外UnexpectedModelBehavior 予備のモデルの文が出力
構造化出力(PromptedOutput)のJSONが途中で切れた 例外UnexpectedModelBehavior(予備は呼ばれない) 予備のモデルのJSONが出力
JSONは最後まで読めるが"length" 1つ目のJSONが出力 予備のモデルのJSONが出力
1つ目も予備も途中で切れた 1つ目の切れた文が出力 例外FallbackExceptionGroup
最後まで書いた文("stop") 1つ目の文が出力 1つ目の文が出力(変更なし)

構造化出力は、JSONの形をプロンプトで指示するPromptedOutputで確かめました。表の「例外」は、エージェントのrunが送出した例外の名前です。最後の行のとおり、最後まで書き終えた応答の扱いは変わりません。

エージェント全体のmax_tokensは予備のモデルにも届き、予備も切れると2.68では例外になる

前の節のコードでは、max_tokensをモデルごとのsettingsに書きました。Agent(..., model_settings={"max_tokens": 16})のようにエージェント全体へ書くと、同じ上限が予備のモデルにも届きます。検証用サーバーで、モデルごとに受け取った上限を記録しました(この構成のOpenAIProviderでは、max_tokensはmax_completion_tokensという名前で送られます)。

書き方1:エージェントのmax_tokensを16

予備にも16が届き、予備も途中で切れる

2.68.0ではFallbackExceptionGroup。2.67.0では1つ目の切れた文が出力でした。

書き方2:モデルごとのsettingsに16と1000

予備に1000が届き、予備が最後まで答える

各モデルのsettingsに書いた値が、そのモデルにだけ届きます(前の節のコード)。

書き方3:エージェントのmax_tokensを16、予備のsettingsに1000

予備に16が届き、予備も途中で切れる

エージェントの値が予備のsettingsより優先され、2.68.0ではFallbackExceptionGroupでした。

書き方1を2.68.0で動かしたときのコードと出力です。予備のbackup-modelも、20トークン必要な回答を上限16で切って返したため、2つのモデルの応答がどちらも退けられ、実行全体が例外で止まりました。

from pydantic_ai.exceptions import FallbackExceptionGroup

primary = OpenAIChatModel("primary-model", provider=provider)
backup = OpenAIChatModel("backup-model", provider=provider)
agent = Agent(primary, capabilities=[ModelFallback(backup)], model_settings={"max_tokens": 16})
try:
    result = await agent.run("東京都の人口は?")
except FallbackExceptionGroup as exc:
    print(type(exc).__name__, exc)

# 2.68.0の出力(サーバーが受け取った上限: primary-model 16、backup-model 16)
# FallbackExceptionGroup All models from ModelFallback failed (1 sub-exception)

出力の「1 sub-exception」は、退けた2件の応答をまとめた1つのResponseRejected(「2 model response(s) rejected by fallback_on handler」)です。2つの試行の記録はexc.attemptsに入っていました。FallbackExceptionGroupはPythonの例外グループ(ExceptionGroup)の一種なので、except UnexpectedModelBehavior:とは別に、except FallbackExceptionGroup:で受け止めます。

公式ドキュメントも、候補のモデルのsettingsはそのモデルだけに、エージェントやrunのsettingsはすべての候補に掛かると説明しています。エージェント全体に小さな上限を掛けていると、予備の回答も上限を超えた時点で切れます。予備の回答も上限を超える呼び出しは、2.67.0では1つ目の途切れた文を返し、2.68.0では例外になります。上限が予備の回答より大きければ、予備が最後まで答えます。エージェント全体の上限を30にした場合は、20トークンで書き終える予備のモデルが、2.68.0で最後まで書いた文を返しました。予備のモデルに長く書かせたい場合は、書き方2のようにmax_tokensを各モデルのsettingsへ移します。

max_tokensで回答を短く切っているなら、fallback_onで元の動きに戻す

max_tokensを、回答を短く収めるための上限として使っているアプリもあります。たとえば、一覧画面に出す要約を200トークンまでで切っている場合です。2.68.0の既定のままだと、上限で切れるたびに予備のモデルへ同じ依頼を送り直すので、呼び出しの回数と費用が増えます。エージェント全体に上限を書いていれば、前の節のとおり例外で止まることもあります。

途切れた文をこれまでどおり受け取るには、fallback_onに、2.67.0の既定と同じ2つの例外を渡します。変更を入れたプルリクエストと公式ドキュメントの「Falling back when the output is cut off」も、この書き方を移行方法として挙げています。

from pydantic_ai.exceptions import ContentFilterError, ModelAPIError

agent = Agent(
    primary,
    capabilities=[ModelFallback(backup, fallback_on=(ModelAPIError, ContentFilterError))],
    model_settings={"max_tokens": 16},
)
result = await agent.run("東京都の人口は?")
print(result.output)
print(result.response.model_name, result.response.finish_reason)

# 2.68.0の出力
# 東京都の人口は、2025年の推計で約1,4
# primary-model length

2.68.0でも、出力・応答したモデル・finish_reasonが2.67.0と同じになりました。ModelAPIErrorはAPIのエラー(4xxや5xxの応答など)、ContentFilterErrorはプロバイダーのコンテンツフィルターで回答が止められた応答(finish_reasonが"content_filter")で、どちらも予備へ回る条件として残ります。途切れたかどうかを自分で判定したいときは、result.response.finish_reason == "length"で確かめられます。

何も書かずに上限に達した空の応答は、この設定でも例外になります。例外の名前は、2.67.0のUnexpectedModelBehaviorから、2.68.0ではOutputTokenLimitExceededに変わりました。考える過程(thinking)だけを書いて上限に達した応答と、PromptedOutputやモデルの機能でJSONの形を指示するNativeOutputでJSONが途中で切れた応答も、同じくOutputTokenLimitExceededになります。output_typeにPydanticのモデルをそのまま渡す既定の受け取り方(ツール呼び出しで受け取る)では、JSONが途中で切れたときの例外は両方の版でIncompleteToolCallのままで、2.68.0からはOutputTokenLimitExceededの子クラスになりました。OutputTokenLimitExceededはUnexpectedModelBehaviorの子クラスなので(2.68.0のissubclassでTrue)、except UnexpectedModelBehavior:と書いた既存のエラー処理は、2.68.0でもこの例外を受け止めます。

予備へ回った記録と費用は、failed_attemptsとusageで確かめる

予備へ回ると、1つ目のモデルへの依頼と予備のモデルへの依頼の両方に料金が掛かります。2.68.0では、退けた1つ目の試行の記録(モデル名、結果、トークン数など)がresult.response.failed_attemptsに残り、使ったトークン数は実行全体のresult.usageに足されます。途中で切れた本文は残りません。最初のコード(書き方2)の後ろに、次のコードを足して確かめました。

for a in result.response.failed_attempts or []:
    print(a.model_name, a.outcome, a.usage)
print(result.usage)

# 2.68.0の出力
# primary-model rejected RequestUsage(input_tokens=12, output_tokens=16)
# RunUsage(input_tokens=24, output_tokens=36, requests=1)

検証用サーバーは、1回の依頼で入力12トークンを数えます。RunUsageの入力24は2回分、出力36は1つ目の16と予備の20を足した値です。サーバーには2回の依頼が届きましたが、requestsは1のままでした。requestsは、予備へ回った分も含めて1回の依頼として数えます。費用を見積もるときは、requestsの回数より、failed_attemptsの件数とトークン数を見ます。

outcomeのrejectedは、応答は返ったがfallback_onの条件で退けたことを表します。コンテンツフィルターで止められた応答や、自分で書いた判定で退けた応答もrejectedになります。上限で切れた試行かどうかは、その試行のusageのoutput_tokensが1つ目のモデルのmax_tokensと同じ値かどうかが目安になります(上の出力では16)。Logfireなどで計装していれば、退けた試行ごとの記録(スパン)にfinish_reasonも残ると公式ドキュメントに書かれています。2.68.0へ上げたあと、この試行が増えた場合は、1つ目のモデルのmax_tokensを上げるか、fallback_onからOutputTokenLimitExceededを外して元の動きに戻します。

ストリーミングで受け取る呼び出しと既定のFallbackModelは、途切れても予備へ回らない

run_streamで応答を少しずつ受け取る場合は、2.68.0でも途切れた文がそのまま出力になりました。finish_reasonは最後まで受け取ってから分かるので、その時点では予備へ回せません。

公式ドキュメントは、実行中のイベントを受け取るものがあれば、その依頼をストリーミングとして扱うと説明しています。run_streamのほか、チャット画面とエージェントをつなぐUIアダプター(AG-UIやVercel AI SDK向け)が使うrun_stream_events()、agent.iter()でnode.stream()を使う場合、run()やrun_sync()にevent_stream_handlerを渡す場合、agent.on_eventで受け取る場合、イベントに反応するケイパビリティ(ProcessEventStreamなど)を付けた場合が当てはまります。手元では、event_stream_handlerを渡したrun()と、ProcessEventStreamを付けたエージェントのrun()でも、2.68.0で途切れた文が出力になり、予備へ回りませんでした。

# 書き方2のエージェント(primary 16、backup 1000)で実行
async with agent.run_stream("東京都の人口は?") as stream:
    output = await stream.get_output()
    if stream.response.finish_reason == "length":
        print("途中で切れた応答:", output)

async def show_events(ctx, events):
    async for event in events:
        pass  # 画面へ逐次表示する処理など

result = await agent.run("東京都の人口は?", event_stream_handler=show_events)
print(result.output)
print(result.response.model_name, result.response.finish_reason)

# 2.68.0の出力(サーバーへのリクエストは、どちらもprimary-modelへの1回だけ)
# 途中で切れた応答: 東京都の人口は、2025年の推計で約1,4
# 東京都の人口は、2025年の推計で約1,4
# primary-model length

チャット画面のように応答を逐次表示する作りでは、2.68.0へ上げても途切れた文を受け取ります。受け取り終えたあとにfinish_reasonを確かめ、"length"なら上限を上げて送り直すなどの処理を自分で書きます。

複数のモデルを1つのモデルとしてまとめ、Agent(FallbackModel(primary, backup))のようにエージェントへ渡す従来のFallbackModelも、既定のfallback_onは2.67.0と同じ(ModelAPIError,)のままで、途切れた文が出力になりました。FallbackModelで途切れた応答を予備へ回したい場合は、fallback_on=(ModelAPIError, OutputTokenLimitExceeded)のようにOutputTokenLimitExceededを名指しします。2.68.0では、この指定で予備のモデルの文が出力になりました。

2.68へ上げる前に確かめる3つのこと

ここまでの結果を、自分のコードで確かめる順番にまとめます。まず、入っている版を確かめます。

python -c "import pydantic_ai; print(pydantic_ai.__version__)"

2.68.0へ上げる前の確認

1. エージェントにModelFallbackを付けているか

付けていなければ、予備へ回る動きは変わりません。空の応答、考える過程だけの応答、PromptedOutput・NativeOutputのJSONが途中で切れた応答で出る例外の名前がOutputTokenLimitExceededに変わります。UnexpectedModelBehaviorの子クラスなので、既存のexcept UnexpectedModelBehavior:はそのまま受け止めます。付けていれば、2と3へ進みます。

2. max_tokensを、回答を短く収める上限として使っているか

使っているなら、fallback_on=(ModelAPIError, ContentFilterError)を渡して、途切れた文を受け取る動きに戻します。

3. 途切れた応答を予備に答え直させるか

答え直させるなら、max_tokensをエージェントのmodel_settingsから各モデルのsettingsへ移し、予備のモデルには普段の回答が収まる上限を渡します。予備も切れた場合に備えて、except FallbackExceptionGroup:も書きます。予備へ回るのはストリーミングで受け取らない呼び出しで、ストリーミングで受け取る呼び出しは、受け取り終えたあとにfinish_reasonを確かめます。

ストリーミングで受け取らないrunでは、2.68.0の既定により、途中で切れた文を完成した回答として後段の処理へ渡すことがなくなります。要約や構造化出力を受け取って処理を続けるアプリでは、この点が利点になります。上限で回答を切ることが仕様になっているアプリでは、上げた直後から予備のモデルの呼び出しが増え、エージェント全体に上限を書いていれば、予備も切れたときに例外が出ます。ModelFallbackとmax_tokensを一緒に書いている箇所を検索し、どちらの動きにするかを決めてから上げます。

参考資料

検証環境

  • macOS 27.0.1(Apple Silicon)、Python 3.13.14(uv 0.11.32の仮想環境)
  • pydantic-ai-slim 2.67.0と2.68.0(PyPI)、OpenAI Python SDK 3.50.0
  • 検証日: 2026年11月13日

この記事は、AIが調査と下書きを担当し、上記の環境で実際に動かして結果を確かめ、下書きとは別の担当が公開前に本文と出典を確認しています。制作と訂正の方針は編集・訂正方針で公開しています。