長い会話のループを書いていると、ログに並ぶ送信量が気になる夜があります。ターンが進むたびに、同じ履歴を一からすべて送り直しているからです。11 月 2 日のリリースノートに message threads の一行を見つけたとき、私はドキュメントを三往復して、ようやく仕組みを飲み込みました。
この記事は、その読み込みの記録です。毎ターン全履歴を送る実装を、threads に移すときに書き換えた点と、あえて書き換えなかった点を残します。
先に断っておくこと
内容は公式ドキュメントの記述に基づいています。threads はベータ(ベータヘッダーは message-threads-2026-08-12)で、私自身は本番の会話ループでまだ使っておりません。
手元で動かしたのは、記事の後半に載せる再送ロジックだけです。本物の API ではなく、404 や 400 を返す偽のクライアントを相手に、期待どおりの順で create と continue が呼ばれることを確かめました。応答の実際の速さや、本番でのエラーの出方は、まだ確かめられていません。
何が変わるのか
これまでは、毎ターン messages に会話の全部を入れて送っていました。threads では、最初の 1 回だけ全体を送り、次からは新しいメッセージと、直前の応答の id だけを送ります。
- create: 全会話と
system、toolsを送り、threadに{"type": "create"}を付けます。 - continue: 新しいメッセージだけを送り、
threadに{"type": "continue", "previous_message_id": "..."}を付けます。 - replay: スレッドは最後のリクエストから 5 分で期限が切れ、遅くとも 1 時間以内に削除されます。続きが送れないと、continue は 404 を返します。
注意したいのは、スレッドが「ベストエフォート」だという点です。応答のどれが保存されたかは、その場では分かりません。次の continue で初めて分かります。
書き換えた 3 か所
書き換えた場所は、結局三つでした。
- 会話の正本を自分で持つ。API が覚えていてくれるから、と履歴を捨てると、404 のときに復元できません。
- 送るフィールドを三つに仕分ける。create だけで送るもの、毎回同じ値で送るもの、毎回変えてよいものです。
- 404 や 400 を、やり直してよいものと、直すべきものに分ける。
仕分けの表は次のとおりです。ドキュメントの内容を、私の言葉で並べ直しました。
| 分類 | フィールド | ルール |
|---|---|---|
| create のみ | system、tools | continue で送ると(null でも)400 になります |
| 固定 | model、tool_choice、thinking、mcp_servers、context_management、output_config、トップレベルの cache_control、ベータヘッダーの組 | 毎回 create と同じ値。違うと thread_fingerprint_mismatch の 400 になります |
| 自由 | max_tokens、stop_sequences、stream、metadata、service_tier | ターンごとに変えてかまいません |
固定フィールドは、コードの 1 か所に置いて、create と continue の両方がそこから読む形にしました。値を二箇所に書くと、いつか片方だけ直して 400 を踏むからです。
再送を入れたラッパー
ここからがコードです。何を解決するかを先に書くと、「continue が 404 などで拒まれたとき、手元の正本から create で送り直す」ことです。
import anthropic
BETA = ["message-threads-2026-08-12"]
REPLAY_CODES = {"thread_not_found", "thread_already_continued", "thread_continue_in_flight"}
def error_code(exc):
"""SDK 例外の body から error_code を取り出す。body の形は版で違うので両方を見る。"""
body = getattr(exc, "body", None)
if not isinstance(body, dict):
return None
err = body.get("error", body)
return (err.get("details") or {}).get("error_code")
class ThreadChat:
def __init__(self, client, *, model, system, tools=None, max_tokens=1024):
self.client = client
# 固定フィールドは 1 か所に置き、create と continue で同じ値を送る
self.fixed = dict(model=model, betas=BETA, cache_control={"type": "ephemeral"})
self.max_tokens = max_tokens
self.system = system
self.tools = tools
self.history = [] # 会話の正本。API 側の thread とは別に、自分で必ず持つ
self.prev_id = None # None のときは次のターンを create で送る
self.replays = 0 # 何回やり直したか。あとで数えるための印
def _create(self, messages):
kw = dict(self.fixed, max_tokens=self.max_tokens, system=self.system,
messages=messages, thread={"type": "create"})
if self.tools:
kw["tools"] = self.tools
return self.client.beta.messages.create(**kw)
def _continue(self, new_messages):
# system と tools は渡さない(渡すと 400)
return self.client.beta.messages.create(
**self.fixed, max_tokens=self.max_tokens, messages=new_messages,
thread={"type": "continue", "previous_message_id": self.prev_id})
def send(self, text):
new = {"role": "user", "content": text}
if self.prev_id is None:
resp = self._create(self.history + [new])
else:
try:
resp = self._continue([new])
except (anthropic.NotFoundError, anthropic.BadRequestError) as exc:
if error_code(exc) not in REPLAY_CODES:
raise # fingerprint 不一致などは、やり直しで隠さず直す
self.replays += 1
resp = self._create(self.history + [new])
if resp.stop_reason == "refusal":
self.prev_id = None # 拒否のあとは続けない。この 1 ターンは正本にも入れない
return resp
self.history += [new, {"role": "assistant", "content": resp.content}]
# フォールバックモデルが書いた応答は API が保存しないので、次は create に戻す
iters = getattr(getattr(resp, "usage", None), "iterations", None) or []
fell_back = any(getattr(i, "type", None) == "fallback_message" for i in iters)
self.prev_id = None if fell_back else resp.id
return respなぜこう書くのか、三点だけ添えます。
まず、thread_fingerprint_mismatch は REPLAY_CODES に入れていません。固定フィールドの食い違いは、やり直せば通ってしまうかもしれませんが、それは食い違いを隠すだけです。自分のコードのどこかで値が揺れている合図なので、例外のまま上へ投げます。
次に、replays を数えています。やり直しは安全ですが、毎回起きているなら、threads にした意味が薄れています。あとで一日分を数えるための印です。
最後に、usage.iterations の fallback_message を見る部分は、ドキュメントの記述(「usage.iterations に fallback_message があれば、フォールバックモデルが書いた応答」)から組みました。属性の形は手元の SDK の版で一度 print して、確かめてから使っていただければと思います。
偽のクライアントで確かめたこと
先ほどのコードは、404 や 400 を返す偽のクライアントに対して、次の四点が期待どおりになることを確かめました。
- 1 ターン目は create で、
systemを含めて送られる。 - 2 ターン目が
thread_not_foundの 404 を受けると、同じ入力を create で、手元の正本 3 件(過去の往復と新しい発言)を付けて送り直す。 - 送り直しのあとは、新しい
idから continue に戻る。 thread_fingerprint_mismatchはそのまま例外になる。拒否(refusal)のあとはprev_idが空になり、次のターンが create になる。
これは本物の API の振る舞いの検証ではありません。「ドキュメントを読んで私が理解した順番どおりに、コードが分岐するか」を確かめた、という範囲です。
エラーが返ったときの切り分け
エラーは、モデルが動く前に返り、トークンは使われず、スレッドの期限も変わりません。ドキュメントの表を、やることの順に並べ直しました。
| ステータス | error_code | 最初にやること |
|---|---|---|
| 404 | thread_not_found | 正本から create で送り直す。ただし、存在しないモデル名でも 404 になるので、先に error_code を見る |
| 400 | thread_fingerprint_mismatch | メッセージに出る項目名を見て、固定フィールドを揃える。変えたいなら新しい thread |
| 400 | thread_already_continued | 各 id から続けられるのは 1 回だけ。最新の id から続ける。受け取れていなければ送り直す |
| 400 | thread_continue_in_flight | 自分の前の試行が走っていることが多い。終わるのを待つか、送り直す |
| 400 | thread_unsupported_request | 非対応の機能を外すか、thread なしで全会話を送る |
404 の原因は一つに決まりません。期限切れのほか、max_tokens で切れたツール呼び出しの応答のように保存されなかったターン、別のキーや別のワークスペースから送ったリクエストも含まれます。原因を当てにいくより、送り直して、キーとワークスペースを揃えておくほうが早いのだと思います。
移さない判断
threads に移さないほうがよい場面も、ドキュメントにははっきり書かれています。
- Message Batches と token counting では使えません。
- compaction(on demand を含む)は使えません。長くなった会話は、要約を自分で書いて、新しい create で始め直します。
- context editing も、一部を除いて使えません。
- ZDR と HIPAA の対象外です。組織で HIPAA の設定が有効な場合は、
thread_unsupported_requestになります。 - スレッドの一覧・取得・削除の API はありません。
私なら、会話がせいぜい数ターンで終わる用途や、バッチで回す処理は、今のまま全履歴を送る形に残します。移すのは、ターンが十を超えて長引くエージェントのループからです。
次の一手
会話の正本は、いつも自分の手元に置き、thread は速く送るための写しとして借ります。 個人開発の小さなループでも、この線引きにしておけば、404 が出ても慌てずに済みます。
最初の一歩として、全部を書き換えず、長いループを一つだけ選んで ThreadChat のような薄いラッパーで包み、一日ぶんの replays を数えていただければと思います。送り直しがほとんど起きないなら、そこから範囲を広げれば足ります。