トークンを無駄にするな(15 分読了)
TLDR AI は、AI モデルの推論コスト削減とトークン使用効率最大化のための具体的な戦略とベストプラクティスを詳述している。
キーポイント
トークン使用の最適化戦略
不要なトークンの生成を避け、モデルが最も価値ある情報を出力するようプロンプト設計を見直す必要性について解説しています。
推論コスト削減の実践手法
キャッシュの活用やモデル選択の最適化など、運用コストを抑えながらパフォーマンスを維持するための具体的な技術的アプローチを提示しています。
効率性と品質のバランス
コスト削減のために精度が犠牲にならないよう、ユースケースに応じた適切なモデル選定と評価基準の重要性を強調しています。
影響分析・編集コメントを表示
影響分析
この記事は、AI 開発におけるコスト意識を高める重要な指針となり、特に大規模な LLM アプリケーションを運用する企業にとって即座に実践可能なフレームワークを提供します。トークン効率への注目は、単なる技術的な最適化を超え、ビジネスモデルの持続可能性を決定づける戦略的課題として認識を広める契機となります。
編集コメント
コスト最適化の重要性が叫ばれる中、具体的な実装戦略を示す本記事は、開発者にとって極めてタイムリーで価値のある内容です。
(この投稿自体は LLM のゴミですが、味は悪くありません)
要約 - エージェントと LLM プロバイダーの間に永続的なバッファを設けてください。プロバイダーとの接続は現在、プロセスよりも長く存続するため、ストリーミング中にデプロイを行っても、すでに支払ったトークンを失うことはありません。また、切断されたブラウザが追いつくことを可能にする同じバッファが、クラッシュしたターンを回復させる役割も果たします。一つのログ、二つの読者。
私はここ数週間、ある一つの疑問に悩まされ続けてきました:ターン中にプロセスが終了した場合、エージェントには何が起こるのでしょうか?
これはすぐに深遠な問題になります。ツール呼び出しが実行されたかどうか不明な場合や、サブエージェント、人間を待っている途中の半分のストリームなどです。これらについては別の記事で詳しく書きます(永続的なエージェントループ、近日公開予定)。しかし、その一部は小さく独立しているため、単独で取り出すことができます:
プロセスが推論中に終了した場合、単に場所を失うだけでなく、お金を失います。
見落としやすい問題
あなたのエージェントがモデルに対してストリーミングリクエストを開き、モデルの生成が始まります。出力トークンは生成された瞬間に請求されます。その後、プロセスが置き換えられます。デプロイによるものかもしれませんし、退去(eviction)によるものかもしれませんし、メモリ不足(OOM)によるものかもしれません。
よくある安心材料は「心配しないでください、状態は永続的です」です。確かに会話履歴は無事でした。しかし、*プロバイダーへの進行中の HTTP リクエスト* はそうではありませんでした。それは直ちに終了したプロセスのメモリ内に存在していたのです。したがって、回復する際に唯一の選択肢は、再度呼び出しを行うことです。その結果、出力トークンに対して二重に請求されます。
さて、これをエージェントにしましょう。実際のエージェントは1回のターンで複数のツール呼び出しを行います:
ユーザーメッセージ
→ テキストをストリーミング
→ ツール呼び出し → ツール結果
→ さらにテキストをストリーミング
→ ツール呼び出し → ツール結果
→ 回答をストリーミング
すべての中断は、そのターンで生成された出力トークンをすべて破棄します。そして、実際に使用したいモデルほどこの問題は深刻化します:GPT-5.5 では100万トークンあたり30ドルですが、GPT-5.5-mini では2ドルです。つまり、フラッグシップモデルの再試行はミニ版の約15倍のコストがかかります。モデルが高性能になるほど、その痛手は大きくなります。デプロイは常に行われ、退去(eviction)も常に行われます。ライブストリーミング中にこれらが発生するたびに、それは無駄に捨てられたお金です。
この問題は「成功したケース」では隠れてしまいます。インシデント後にトークンを数え始め、数字が合わないことに気づいたときに初めて表面化します。
対策:リクエストとプロセスを紐付けないこと
クラッシュ時にトークンが無駄になる理由は、プロバイダーとの接続が「クラッシュするものの中」に存在しているからです。それを外に出しましょう。
エージェントとプロバイダーの間にバッファを置き、別のデプロイメントとして独立させます:独自のWorker、あるいは独自のDurable Objectです。
image
image
リクエストが来ると、バッファは順序立てて3つの処理を行います。まず、新しいストリームのために状態をリセットします。次に、プロバイダー接続からデータをSQLiteへ排水するバックグラウンドタスクを開始します。そして、即座に呼び出し元に、これらの行が着信するたびに追跡(テール)するストリーミングを返します:
async proxyAndBuffer(req: ProviderRequest): Promise<Response> {
this.resetBuffer(); // status = "streaming", chunkCount = 0
const reader = (await fetch(req.url, req)).body!.getReader();
// drain the provider in the background. deliberately NOT awaited - the
// response below returns right away while this keeps running.
this.keepAliveWhile(() => this.consumeProvider(reader));
// give the caller a stream that tails the rows as they're written.
return new Response(this.tailFrom(0), {
headers: { "X-Buffer-Status": "streaming" }
});
}
private async consumeProvider(reader: Reader) {
for (let i = 0; ; i++) {
const { done, value } = await reader.read();
if (done) break;
this.sqlINSERT INTO buffer_chunks VALUES (${i}, ${decode(value)});
this.notify(); // wake any tailers (more below)
}
this.setStatus("completed");
}
負荷を支えている部分は、consumeProvider が *接続されていない* 部分です。これはエージェント内で実行されるのではなく、エージェントのデプロイによって影響を受けていない別のデプロイメントで実行されます。したがって、ストリーミング中にエージェントが退去し、そのテール接続がキャンセルされても、排水ループは読み取りを続けます。誰かが監視しているかどうかに関わらず、あなたが支払ったトークンは SQLite 内に確実に格納され続けます。
keepAliveWhile は、バッファが排水されている間、そのバッファを開いたまま保つ役割を果たします。長い生成期間には静かな時間帯があり、Durable Object がアイドル状態に見えるために退去させられる可能性があります。keepAliveWhile は、排水の持続時間中にアラームを心拍(ハートビート)として送り続け、タスクが完了するか例外が発生した瞬間にそれを停止するため、バッファはそれらのギャップを生き延びますが、その後に不要な心拍を送り続けることはありません。
エージェントが再起動すると、/resume?from=N を呼び出して見逃したチャンクを受け取ります。トークンの無駄も、プロバイダーの重複呼び出しもありません。
一つのログ、二つのリーダー
これを構築している間、以前にこの一部をすでに解決したような感覚を抱き続けました。そして実際、その通りでした。これは *resumable streaming*(再開可能なストリーミング)と同じ問題です。ご存知のあのケース:ユーザーが応答の最中にいて、ラップトップを閉じ、Wi-Fi からセルラーネットワークに切り替え、再び戻ってくると、ストリームは…そのまま続きます。その実現方法は、ストリーミングされるたびにすべてのチャンクを永続的なログに保存し、再接続した際にクライアントが保存されたチャンクを読み取って、ライブカーソルまで追いつくことです。
回復プロセスも *全く同じログ* を使用します。バッファは各チャンクをインデックスをキーとして SQLite に格納します:
CREATE TABLE buffer_chunks (
chunk_index INTEGER PRIMARY KEY,
data TEXT NOT NULL
)
これを読み戻すのは一つの関数です。ライブプロキシは tailFrom(0) を呼び出しますが、再開するエージェントは、自分が最後に確認した最後のチャンクインデックス N を指定して tailFrom(N) を呼び出します。カーソルこそが唯一の違いです:
tailFrom(cursor: number): ReadableStream {
return new ReadableStream({
pull: async (c) => {
const rows = this.rowsFrom(cursor); // cursor 以降に保存されたすべてのデータ
if (rows.length) { c.enqueue(rows); cursor += rows.length; return; }
if (this.isDone()) return c.close(); // 完了 / 中断 / エラー
await this.signal.promise; // それ以外の場合は、次の notify() を待つ
}
});
}
so /resume?from=N is just tailFrom(N). and there are only two situations it hits:
- the producer is still alive. more chunks are coming. it tails: serve what's stored, wait for the next one, repeat, until the stream completes. this is a browser reconnecting.
- the producer is gone. it died with the old process, so the stream is orphaned and no more chunks are coming. instead of tailing toward a live cursor, you reconstruct whatever's stored, finalize it, and continue the turn from there. this is crash recovery.
same durable log. the *only* difference is whether a live producer is still attached. resumable streaming answers "a client reconnected: catch it up." recovery answers "the producer died: finish what it was writing." persisting chunks for reconnects buys you most of crash recovery for free.
the buffer makes the distinction explicit in its state machine. on restart, if it finds its own status still marked streaming, it *knows* the previous incarnation died mid-flight, flips itself to interrupted, and callers know they're getting partial data:
アイドル → ストリーミング → 完了 → (確認 / TTL) → アイドル
│
│ [DO evicted]
▼
中断 ← "プロデューサーがいなくなったので、これまでに取得したデータです"
ポーリングなしでの追跡
守るべき重要なポイントが一つあります。トレイルリーダーは SQLite をポーリングしません。ホットループ内でデータベースをポーリングしてトークンが到着したかを確認するのは、デモでは問題なさそうに思えますが、本番環境では悲惨な体験になります。代わりに、ドレインループの notify() は各挿入後に共有されたプロミスを解決し、トレイルラーはそれを待機するだけです。
これは Durable Object がシングルスレッドで動作するため機能します。挿入と通知は 1 つの同期ブロック内で発生するため、目覚めたトレイルラーは常に新しい行が既にコミットされていることを確認できます。競合条件はなく、調整すべきポーリング間隔もありません。実行環境が難しい部分を代わりにやってくれるため、これが DO でエージェントを構築する際の最大のメリットの一つです。
SSE パーサーを 1 つも書かずに再生
さて、ループの復旧はできました。今度はこれを、エージェントループが継続できる形式に変換する必要があります。明らかなアプローチは、バッファされた SSE を自分でパースすることですが、それは罠です。OpenAI のフォーマット、Anthropic のフォーマット、Google のフォーマットのために独自のパーサーを所有し続け、それらが提供するワイヤーフォーマットの変更を追いかける羽目になります。
だから、そうしないでください。生バイトを保存し、復路ではプロバイダーが持つ*独自の*パーサーを再利用してください。これが workers-ai-provider に入るモデルです。1 つのプロバイダーがすべてのモデルを AI Gateway を経由してルーティングし、各プラグインはネイティブのワイヤーフォーマットを持ち、デフォルトで再開機能が有効になっています。
import { createWorkersAI } from "workers-ai-provider";
import { openai } from "workers-ai-provider/openai";
import { anthropic } from "workers-ai-provider/anthropic";
const workersai = createWorkersAI({
binding: env.AI,
providers: [openai, anthropic] // each plugin brings its own SSE parser
});
const result = streamText({
model: workersai("openai/gpt-5.5", { resume: true })
});
// result.response.headers["cf-aig-run-id"] identifies the run to re-attach to.
streamText() は、解析を実行し、ツールループを処理し、推論に対応し、レスポンスをネイティブにレンダリングします。これは新規呼び出しと同じです。どこでもカスタムの SSE(Server-Sent Events)パースは不要であり、プロバイダーがフォーマットを変更しても、あなたが *その* プロバイダーのパースャーを使用しているためコストはかかりません。
この evict(退去/破棄)ケースこそが本質的なポイントです。ストリーム実行中は、{ runId, eventOffset } を onDispatch および onProgress を通じて永続化します。エージェントが戻ってきたら、モデルを再呼び出すのではなく、同じランに再接続します:
const stream = createResumableStream({
binding: env.AI,
gateway: "my-gateway",
runId, // saved from cf-aig-run-id
fromEvent: savedOffset, // saved from onProgress
onResumeExpired: "accept-partial" // once the ~5.5 min buffer TTL elapses
});
再課金も、重複呼び出しも、維持すべきパースャーもありません。
待てよ、他の誰かもこれを実行しているのか?
「トークンを無駄にするな」という言葉は、すでに誰かが実装しているはずだと確認しに行ってきました。結果として、あるプロバイダーがほぼ完全にこの仕組み(自社の API 向けに)を実装しており、他のすべてのプロバイダーはこれをユーザー側に任せています。
本質的な質問は以下の 2 つです。
- 接続が切断された後もプロバイダーは生成を継続しているのか?(それともトークンは失われるのか?)
- 既に生成された分の課金を再請求されることなく、カーソルを使って同じストリームを再開できるか?
切断後に生成を続けるか?カーソルで再開して再課金なしか?どうするか
OpenAI (Responses, バックグラウンドモード) はいはいバックグラウンド:true + ストリーム:true、?starting_after={sequence_number} を経由して再開
Anthropic いいえいいえモデルに「continue」と指示するプロンプト - トークンの再課金が発生し、内容がずれる可能性がある
Google Gemini いいえいいえ同じくここから再開するための再プロンプトハック
OpenRouter 部分的(キャンセルで課金が停止)いいえ(レスポンスキャッシュのみ)レスポンスキャッシングとキャンセル機能;独自のバッファを構築する必要がある
Vercel AI SDK (resumable-stream) はい(waitUntil を経由してプロデューサーが生存状態を維持)はい、ただしページ再読み込み時のみapp レイヤーの Redis バッファ - 同じトリックだがスコープは狭い
this / AI Gateway はいはいインフラ層の永続的バッファ、プロバイダー非依存、*あなたのデプロイ* を超えて存続する
いくつかの点が際立っています。
OpenAI はすでにそのアイデアを実証しました。 Responses API の Background mode を使用すると、ジョブはサーバーサイドで実行され続け、接続が切れてもシークエンス番号カーソルを追跡することで再開できます:GET /v1/responses/{id}?stream=true&starting_after={n}。これは耐久性のある推論であり、プロバイダーネイティブな機能として本日すでに提供されています。ただし、これは OpenAI 独自の API にロックされており(store: true が必要)、TTFT(Time To First Token)の値が高くなるという課題があります。
Anthropic と Gemini は再課金を要求します。 両者ともサーバーサイドでの再開をサポートしていません。文書に記載されている回復方法は、部分的な応答をキャプチャし、モデルが中断した場所から続きを生成するよう *新しい* リクエストを送信することです。これにより出力トークンが再度消費され、その続行部分が元の応答と一致する保証はありません。Anthropic のドキュメント では、ツール使用や思考ブロックは部分的に回復できないことさえ明記されています。これは本記事の冒頭で述べた「税」そのものです。
Vercel の resumable-stream は最も近い親戚です。 彼らも独自に同じ核心となるトリックに到達しました:プロデューサーは元のリーダーが去ってもストリームを完了し、第 2 のコンシューマーが続いて追従できるようにします。しかしこれはアプリケーション層での実装であるため、Redis を自分で実行する必要があり、プロデューサーは *あなたの* プロセス内に存在するため、コードの再デプロイ時に生き残ることはできません。この最後のケースこそが、私が別々のデプロイを必要とした理由そのものです。
つまり、誰もやっていないわけではありません。OpenAI は一つの API に対してこれを行う価値があることを示しました。しかし、他のすべてのプロバイダーは、再プロンプトして再支払いを迫ってきます。そして、*あなたのプロセス自体*が停止した場合のケースをカバーする者はいません。これが市場の隙間です。
結論:これは AI Gateway に実装されます
では、これをどこに配置すべきでしょうか?比較対象はまさにそこにあります。OpenAI のバージョンが機能するのは、あなたがデプロイしないインフラ上で動作しているからです。Vercel のバージョンが不十分なのは、それがそうではないからです。あなたは、リクエストパス上にすでに存在し、すべてのプロバイダーを前面に出し、コードをリリースした際に再デプロイされないバッファ somewhere を望んでいます。それが AI Gateway です。
私はこれを Durable Object(RFC #1257)としてプロトタイプ化しましたが、本来はエージェント内ではなく、管理されたインフラ上で動作するものとして設計されていました。それをゲートウェイに配置すれば、OpenAI のバックグラウンドモードのアイデアを*すべての*プロバイダーに提供できます。今日でも再支払いを要求しているプロバイダーも含まれます。
そして朗報です:Durable Resume(永続的な再開機能)はまもなく Cloudflare AI Gateway に登場します。まだ広くリリースされていませんが、実際に本番トラフィックで動作させています。すべての実行には cf-aig-run-id が付与され、ゲートウェイに対してイベントインデックスから再プレイを依頼できます。私は 6 つのモデルを実行し、ストリームの中間点でそれぞれ切断し、後半部分の再生を要求しました:
モデルイベント数バイト数途中からの再開→末尾と一致?
gpt-4o-mini6320,604✅ バイト単位で完全一致
gpt-5.4257,350✅ バイト単位で完全一致
claude-haiku-4.5111,924✅ バイト単位で完全一致
claude-sonnet-4.5101,868✅ バイト単位で完全一致
gemini-3-flash42,253✅ バイト単位で完全一致
gpt-4o-mini の実行におけるイベントインデックス 31 から再開した結果、ストリームの後半部分がバイト単位で正確に返されました。(一つの注意点:from は*イベントインデックス*であり、バイトオフセットではありません。バイトオフセットは解決しませんでした。これが適用される際に知っておくべき点です。)
私が人々に理解してほしいのはこれです。この DO ハックを手動で構築し続ける必要は永遠にありません。目標は、これをファーストクラスのオプトイン機能にすることです。目指している形状は、まもなくチャットエージェント基本クラス(AIChatAgent および Think)に追加される予定で、以下のような形になります:
export class MyAgent extends Think<Env> {
override durableBuffer = true; // 推論を永続バッファ経由でルーティングする(まもなく実装)
}
スイッチを一つ切り替えるだけで、同じトークンに対して二度と課金されなくなります。
まとめ
- 出力トークンは生成された瞬間に請求されます。クラッシュによってリトライが必要になった場合、再度課金され、アジェンティックループ内ではそのターン内のすべてのツール呼び出しにおいてこのコストが複合的に積み重なります。
- プロバイダー接続を、それを開いたプロセスに紐付けないでください。別個の、再デプロイされないバッファがストリームをデプロイ間でも維持し、keepAliveWhile が実行中にバックグラウンドでの draining を開いたまま保ちます。
- 再開可能なストリーミングとクラッシュ回復は一つのメカニズムです。同じ永続チャンクログを使用します。唯一の疑問点は、ライブプロデューサーがまだ接続されているかどうかだけです。
- 生バイトを保存し、実際のプロバイダーのパースャーを通じて再生してください。SSE パースャーを手で実装しないでください。各プロバイダー独自のプラグインにフォーマット変換と ID を通じた実行への再アタッチを任せてください。
- これはゲートウェイに属するものです。再デプロイされない管理されたインフラストラクチャが適切な場所であり、Cloudflare AI Gateway への永続的なリジューム機能はまもなく登場します。
より大きな物語は別の記事として扱うべきです:回復したストリームをどう活用するか、そしてエージェントループの回復決定ツリーの残りの部分について。これは脇道の話でしたが、すべてのデプロイでコスト削減につながる重要な話なので、独立して取り上げる価値があると考えました。
トークンを無駄にしてはいけません。
原文を表示
*(this post itself is LLM slop, but it tastes alright)*
tl;dr - put a durable buffer between your agent and the LLM provider. the provider connection now outlives your process, so a deploy in the middle of a stream doesn’t cost you the tokens you already paid for. and the same buffer that lets a disconnected browser catch back up is the thing that recovers a crashed turn. one log, two readers.
I’ve spent the last few weeks stuck on one question: what happens to an agent when the process running it dies in the middle of a turn?
it goes deep fast. tool calls that may or may not have fired. sub-agents. half-written streams waiting on a human. I’m writing all of that up separately (durable agent loops, coming soon). but one piece of it is small and self-contained enough to pull out on its own:
when your process dies mid-inference, you don’t just lose your place. you lose money.
the problem that’s easy to miss
your agent opens a streaming request to a model, and the model starts generating. you’re billed for those output tokens the moment they’re generated. then your process gets replaced. maybe a deploy, maybe an eviction, maybe an OOM.
the usual reassurance is “don’t worry, the state is durable.” and sure, your conversation history survived. but the *in-flight HTTP request to the provider* did not. it lived in the memory of the process that just died. so when you recover, your only option is to make the call again. you pay for those output tokens a second time.
now make it an agent. a real one does multiple tool calls in a single turn:
user message
→ stream some text
→ tool call → tool result
→ stream more text
→ tool call → tool result
→ stream the answerevery interruption throws away *all* the output tokens generated so far in that turn. and it scales with the model you actually want to use: output runs $30 per million tokens on gpt-5.5 versus $2 on gpt-5.5-mini, so a flagship retry burns ~15x what a mini one does. the better the model, the more it hurts. deploys happen constantly, evictions happen constantly, and each one that lands on a live stream is money straight out the window.
the happy path hides it. you only see it when you start counting tokens after an incident and the numbers don’t add up.
the move: stop tying the request to the process
the reason a crash wastes tokens is that the provider connection lives *inside the thing that crashed*. so move it out.
put a buffer between the agent and the provider, and make it a separate deployment: its own Worker, its own Durable Object.
when a request comes in, the buffer does three things in order. it resets its state for a fresh stream. it kicks off a background task that drains the provider connection into SQLite. and it immediately hands the caller back a stream that tails those same rows as they land:
async proxyAndBuffer(req: ProviderRequest): Promise<Response> {
this.resetBuffer(); // status = "streaming", chunkCount = 0
const reader = (await fetch(req.url, req)).body!.getReader();
// drain the provider in the background. deliberately NOT awaited - the
// response below returns right away while this keeps running.
this.keepAliveWhile(() => this.consumeProvider(reader));
// give the caller a stream that tails the rows as they're written.
return new Response(this.tailFrom(0), {
headers: { "X-Buffer-Status": "streaming" }
});
}
private async consumeProvider(reader: Reader) {
for (let i = 0; ; i++) {
const { done, value } = await reader.read();
if (done) break;
this.sql`INSERT INTO buffer_chunks VALUES (${i}, ${decode(value)})`;
this.notify(); // wake any tailers (more below)
}
this.setStatus("completed");
}the load-bearing part is what consumeProvider is *not* attached to. it doesn’t run inside the agent. it runs here, in a separate deployment that wasn’t touched by the agent’s deploy. so when the agent gets evicted mid-stream and its tail connection is cancelled, the drain loop keeps reading. the tokens you paid for keep landing in SQLite, whether or not anyone’s listening.
keepAliveWhile is what holds the buffer open while it drains. a long generation has quiet stretches, and a Durable Object can be evicted for looking idle. keepAliveWhile heartbeats an alarm for the duration of the drain and drops it the moment the task finishes or throws, so the buffer survives those gaps without leaking a heartbeat afterwards.
when the agent restarts, it calls /resume?from=N and gets the chunks it missed. no wasted tokens, no duplicate provider call.
one log, two readers
while building this I kept feeling like I’d already solved a piece of it before. and I had. it’s the same problem as *resumable streaming*. you know the one: a user is mid-response, closes their laptop, switches from wifi to cellular, comes back, and the stream just… continues. the way you do that is you persist every chunk to a durable log as it streams, and on reconnect the client reads stored chunks until it catches up to the live cursor.
recovery is the *exact same log*. the buffer stores each chunk in SQLite keyed by index:
CREATE TABLE buffer_chunks (
chunk_index INTEGER PRIMARY KEY,
data TEXT NOT NULL
)reading it back is one function. the live proxy called tailFrom(0); a resuming agent calls tailFrom(N) with the last chunk index it saw. the cursor is the only difference:
tailFrom(cursor: number): ReadableStream {
return new ReadableStream({
pull: async (c) => {
const rows = this.rowsFrom(cursor); // everything stored since `cursor`
if (rows.length) { c.enqueue(rows); cursor += rows.length; return; }
if (this.isDone()) return c.close(); // completed / interrupted / error
await this.signal.promise; // else wait for the next notify()
}
});
}so /resume?from=N is just tailFrom(N). and there are only two situations it hits:
- the producer is still alive. more chunks are coming. it tails: serve what’s stored, wait for the next one, repeat, until the stream completes. this is a browser reconnecting.
- the producer is gone. it died with the old process, so the stream is orphaned and no more chunks are coming. instead of tailing toward a live cursor, you reconstruct whatever’s stored, finalize it, and continue the turn from there. this is crash recovery.
same durable log. the *only* difference is whether a live producer is still attached. resumable streaming answers “a client reconnected: catch it up.” recovery answers “the producer died: finish what it was writing.” persisting chunks for reconnects buys you most of crash recovery for free.
the buffer makes the distinction explicit in its state machine. on restart, if it finds its own status still marked streaming, it *knows* the previous incarnation died mid-flight, flips itself to interrupted, and callers know they’re getting partial data:
idle → streaming → completed → (ack / TTL) → idle
│
│ [DO evicted]
▼
interrupted ← "the producer is gone, here's what I have"tailing without polling
one detail worth keeping. the tail reader never polls SQLite. polling a database in a hot loop to see if a token landed feels fine in a demo and is miserable in production. instead, the drain loop’s notify() resolves a shared promise after each insert, and the tailer just awaits it.
it works because a Durable Object runs single-threaded: the insert and the notify happen in one synchronous block, so a tailer that wakes up always sees the new row already committed. no race, no poll interval to tune. the runtime does the hard part for you, which is most of the pitch for building agents on DOs.
replay without writing a single SSE parser
ok, you’ve got the run back. now you have to turn it into something your agent loop can continue from. the obvious approach is to parse the buffered SSE yourself. that’s a trap. you’d own a bespoke parser for OpenAI’s format, and Anthropic’s, and Google’s, forever, and chase every wire-format change they ship.
so don’t. store raw bytes and reuse the provider’s *own* parser on the way back. that’s the model going into workers-ai-provider: one provider routes every model through AI Gateway, each plugin carries its native wire format, and resume is on by default.
import { createWorkersAI } from "workers-ai-provider";
import { openai } from "workers-ai-provider/openai";
import { anthropic } from "workers-ai-provider/anthropic";
const workersai = createWorkersAI({
binding: env.AI,
providers: [openai, anthropic] // each plugin brings its own SSE parser
});
const result = streamText({
model: workersai("openai/gpt-5.5", { resume: true })
});
// result.response.headers["cf-aig-run-id"] identifies the run to re-attach to.streamText() parses, runs the tool loop, handles reasoning, and renders the response natively, the same as a fresh call. zero custom SSE parsing anywhere, and a provider changing their format costs you nothing because you’re on *their* parser.
the eviction case is the whole point. as the stream runs you persist { runId, eventOffset } (via onDispatch and onProgress); when the agent comes back, you re-attach to the same run instead of re-calling the model:
const stream = createResumableStream({
binding: env.AI,
gateway: "my-gateway",
runId, // saved from cf-aig-run-id
fromEvent: savedOffset, // saved from onProgress
onResumeExpired: "accept-partial" // once the ~5.5 min buffer TTL elapses
});no re-billing, no duplicate call, no parser to maintain.
wait, does anyone else do this?
I went and checked, because “never waste a token” seemed like something someone would have built already. turns out one provider has built almost exactly this (for their own API), and everyone else leaves it to you.
the two questions that matter:
- does the provider keep generating after your connection drops? (or are the tokens gone?)
- can you resume the same stream by cursor, without re-billing what was already generated?
keeps generating after you drop?resume by cursor, no re-bill?how
OpenAI (Responses, background mode)yesyesbackground: true + stream: true, resume via ?starting_after={sequence_number}
Anthropicnonore-prompt the model to “continue” - re-bills tokens, may drift
Google Gemininonosame continue-from-here re-prompt hack
OpenRouterpartial (cancel stops billing)no (whole-response cache only)response caching + cancellation; build your own buffer
Vercel AI SDK (resumable-stream)yes (producer kept alive via waitUntil)yes, but page-reload onlyapp-layer Redis buffer - same trick, narrower scope
this / AI Gatewayyesyesinfra-layer durable buffer, provider-agnostic, survives *your deploy*
a few things jump out.
OpenAI already proved the idea. Background mode on the Responses API keeps the job running server-side even if you drop, and you resume by tracking a sequence_number cursor: GET /v1/responses/{id}?stream=true&starting_after={n}. this is durable inference, provider-native, shipping today. it’s just locked to OpenAI’s own API (needs store: true, and TTFT runs higher).
Anthropic and Gemini make you re-pay. neither supports server-side resume. the documented recovery is to capture the partial response and send a *new* request asking the model to continue from where it left off. that spends the output tokens again, and the continuation isn’t guaranteed to match. Anthropic’s docs even note that tool-use and thinking blocks can’t be partially recovered. this is exactly the tax from the top of this post.
Vercel’s resumable-stream is the closest cousin. it independently arrived at the same core trick: the producer completes the stream even if the original reader goes away, and a second consumer can follow along. but it’s app-layer, so you run the Redis, and the producer lives in *your* process - so it doesn’t survive you redeploying your code. that last case is the whole reason I needed a separate deployment.
so it’s not that nobody does this. OpenAI showed it’s worth doing for one API. everyone else makes you re-prompt and re-pay. and nobody covers the case where *your own process* is the thing that died. that’s the gap.
the punchline: this is coming to AI Gateway
so where should this live? the comparison points right at it. OpenAI’s version works because it runs on infra you don’t deploy; Vercel’s falls short because it doesn’t. you want the buffer somewhere that’s already in the request path, already fronts every provider, and never gets redeployed when you ship your code. that’s AI Gateway.
I prototyped it as a Durable Object (RFC #1257), but it was always meant to live in managed infrastructure, not in your agent. put it in the gateway and you take OpenAI’s background-mode idea and hand it to *every* provider, including the ones that make you re-pay today.
and the good news: durable resume is coming soon to Cloudflare AI Gateway. it’s not widely released yet, but I’ve been running real traffic through it. every run comes back with a cf-aig-run-id, and you can ask the gateway to replay from an event index. I ran six models through it, cut each stream at the midpoint, and asked for the tail:
modeleventsbytesresume from mid → tail matches?
gpt-4o-mini6320,604✅ byte-exact
gpt-5.4257,350✅ byte-exact
claude-haiku-4.5111,924✅ byte-exact
claude-sonnet-4.5101,868✅ byte-exact
gemini-3-flash42,253✅ byte-exact
resume from event index 31 of the gpt-4o-mini run returned exactly the back half of the stream, byte-for-byte. (one wrinkle: from is an *event index*, not a byte offset. byte offsets didn’t resolve. worth knowing when it lands.)
that’s the thing I want people to take away. you won’t have to hand-build this DO hack forever. the goal is to make it a first-class, opt-in feature - the shape we’re aiming for, coming soon to the chat agent base classes (AIChatAgent and Think), will be something like:
export class MyAgent extends Think<Env> {
override durableBuffer = true; // route inference through a durable buffer (coming soon)
}flip one switch, and you never pay for the same token twice.
takeaways
- you’re billed for output tokens the moment they’re generated. if a crash forces a retry, you pay again, and in an agentic loop that compounds across every tool call in the turn.
- don’t tie the provider connection to the process that opened it. a separate, never-redeployed buffer keeps the stream alive across your deploys, and keepAliveWhile holds the background drain open while it runs.
- resumable streaming and crash recovery are one mechanism. the same durable chunk log; the only question is whether a live producer is still attached.
- store raw bytes, replay through the real provider’s parser. don’t hand-roll SSE parsers; let each provider’s own plugin do the format conversion and re-attach to a run by id.
- this belongs in the gateway. managed infra that never redeploys is the right place for it, and durable resume is coming soon to Cloudflare AI Gateway.
the bigger story is its own post: what to do with that recovered stream once you’ve got it, and the rest of the agent-loop recovery decision tree. this was the tangent. but it’s the one that saves you money on every deploy, so I figured it was worth pulling out on its own.
never waste a token.
関連記事
[AINews] 今日特に大きな出来事はありませんでした
Latent Space は、GLM 5.2 が依然として注目されていると指摘しつつ、AIE WF 2026 の通常チケットが月曜日に完売すると発表しました。同サイト購読者向けに限定割引を提供し、参加者には Warp や Datadog などからのスポンサークレジットも付与されます。
米国がアンソロピックの「Fable 5」発売を禁止、しかし市場は動じず
米国政府は国家安全保障上の懸念から、アマゾンの研究者らがガードレール回避手法を発見したとして、アンソロピックに対し最新モデル「Fable 5」と「Mythos 5」の販売差し止めを命じた。サイバーセキュリティ研究者らはこの措置が危険だとする公開書簡に署名し、同社も他モデルでも同様の抜け道が存在すると指摘している。
社内データ分析エージェントの構築方法について
GitHub は、大規模なデータ組織が直面する自己完結型のデータアクセスと洞察提供の課題に対し、AI を活用した信頼性の高い解決策として、社内でデータ分析エージェントを構築したことを発表した。
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み