text フィールドがあるチャンクを全部足すと、思考が答えになります。
Vercel AI SDK のストリームは、思考と本文を別の type で流します。reasoning-delta と text-delta の両方に text があります。"text" in chunk で足すと、ユーザーに見える答えの先頭が「制約は n≤10^5…」になります。その文字列を assistant メッセージとして次ターンに渡すと、Structured Output の JSON が突然パースできなくなります。モデルは「前回の答え」だと思って思考文を読みます。
自分の現場はリアルタイムの回答ストリームです。画面に出す本文と、折りたたみの思考は別イベントです。同じバッファに載せると、この区別が消えます。
結論
レーンは二つです。分岐は chunk.type です。text の有無ではありません。
-
reasoning-delta→ 思考レーン。UI の折りたたみ、TTFT の計測。履歴の assistant 本文には入れない -
text-delta→ 答えレーン。ユーザーに見せる本文、次ターンに渡す本文 -
error→ その場で throw。無視すると流が正常終了に見え、あとでNoOutputGeneratedErrorだけが残ります
textStream は text-delta だけを出します。思考を捨てるので、この混入は起きません。その代わり、思考中は文字がゼロのままです。沈黙の時計が先に切れます。沈黙の切り方は前回書きました。今回は、届いたチャンクをどこに足すかです。
いまの SDK では result.stream です。少し前の版では fullStream という名前でした。どちらも type 付きのチャンク列です。
誤りの再現
思考が先に来て、本文が後から来るストリームです。SDK は呼びません。チャンクの形だけ再現します。
type StreamChunk =
| { type: "start" }
| { type: "reasoning-delta"; text: string }
| { type: "text-delta"; text: string }
| { type: "error"; error: Error }
| { type: "finish" };
const nativeReasoning: StreamChunk[] = [
{ type: "start" },
{ type: "reasoning-delta", text: "制約は n≤10^5。ソートすると間に合わない。" },
{ type: "reasoning-delta", text: "両端から詰める。" },
{ type: "text-delta", text: "二重ポインタで両端から詰めます。" },
{ type: "finish" },
];
function naiveConcat(chunks: StreamChunk[]): string {
let out = "";
for (const chunk of chunks) {
if ("text" in chunk) out += chunk.text;
}
return out;
}
Bun で回すと、答えはこうなります。
naiveConcat = "制約は n≤10^5。ソートすると間に合わない。両端から詰める。二重ポインタで両端から詰めます。"
ユーザーが見る先頭は思考です。この文字列を次ターンの messages に role: "assistant" で入れると、モデルは「前回は制約の話をしていた」と解釈します。JSON スキーマを要求している経路では、パースがここで折れます。
OpenAI の reasoning_content も、Claude の thinking_delta も、中身は同じです。SDK が reasoning-delta に正規化したあとも、足し算の対象を text にしている限り混ざります。
正しさ:type で分岐する
type Lanes = { reasoning: string; answer: string };
function splitByType(chunks: StreamChunk[]): Lanes {
const lanes: Lanes = { reasoning: "", answer: "" };
for (const chunk of chunks) {
if (chunk.type === "error") throw chunk.error;
if (chunk.type === "reasoning-delta") lanes.reasoning += chunk.text;
else if (chunk.type === "text-delta") lanes.answer += chunk.text;
}
return lanes;
}
lanes.reasoning = "制約は n≤10^5。ソートすると間に合わない。両端から詰める。"
lanes.answer = "二重ポインタで両端から詰めます。"
次ターンに渡すのは lanes.answer だけです。思考を履歴に残したい場合は、別フィールドに置きます。Claude の Extended Thinking は、思考ブロックに signature が付きます。本文へ連結した時点で署名は壊れます。次のリクエストが 400 で返ることがあります。
第二の罠:text-delta の中の <think>
一部のモデルは reasoning-delta を出しません。思考を <think>...</think> として text-delta に載せます。Qwen 系でよく見ます。type で分岐しても、答えレーンに思考が残ります。
タグはチャンク境界で割れます。正規表現をチャンクごとに掛けると、開始も終了も見つかりません。
再現に使った列です。
const taggedText = ["<thi", "nk>内側で場合分けする。", "</th", "ink>答えは 42 です。"];
結合すると <think>内側で場合分けする。</think>答えは 42 です。 です。ストリームの途中では、どのチャンクも完全なタグを含みません。
開始タグと終了タグの半端をバッファに残す必要があります。見つからないからといって、バッファ全体を思考へ捨てると、</th が消えて終了タグが二度と揃いません。直す前の実装は、思考レーンに 内側で場合分けする。</think>答えは 42 です。 を全部入れて、答えは空でした。
class ThinkTagSplitter {
private buf = "";
private inThink = false;
reasoning = "";
answer = "";
push(text: string): void {
this.buf += text;
while (this.buf.length > 0) {
if (this.inThink) {
const end = this.buf.indexOf("</think>");
if (end < 0) {
const partial = partialPrefix(this.buf, "</think>");
this.reasoning += this.buf.slice(0, this.buf.length - partial);
this.buf = this.buf.slice(this.buf.length - partial);
return;
}
this.reasoning += this.buf.slice(0, end);
this.buf = this.buf.slice(end + "</think>".length);
this.inThink = false;
continue;
}
const start = this.buf.indexOf("<think>");
if (start < 0) {
const partial = partialPrefix(this.buf, "<think>");
this.answer += this.buf.slice(0, this.buf.length - partial);
this.buf = this.buf.slice(this.buf.length - partial);
return;
}
this.answer += this.buf.slice(0, start);
this.buf = this.buf.slice(start + "<think>".length);
this.inThink = true;
}
}
finish(): void {
if (this.buf.length === 0) return;
if (this.inThink) this.reasoning += this.buf;
else this.answer += this.buf;
this.buf = "";
}
}
function partialPrefix(haystack: string, needle: string): number {
const max = Math.min(haystack.length, needle.length - 1);
for (let n = max; n > 0; n--) {
if (needle.startsWith(haystack.slice(-n))) return n;
}
return 0;
}
tagged.reasoning= "内側で場合分けする。"
tagged.answer = "答えは 42 です。"
SDK には extractReasoningMiddleware があります。タグ名を渡すと同じ剥がしをやります。自前で持つ理由は、チャンク境界の半端を自分で見られることです。ミドルウェアの内側でタグが消えると、ログに「思考が答えに混ざった」痕跡が残りません。
type 分岐とタグ剥がしは両方要ります。片方だけでは、もう片方のモデルで混ざります。
error 塊を落とすな
AI SDK は上流エラーを throw しません。{ type: "error", error } を流に載せて、そのまま終わります。reasoning-delta と text-delta だけ見ていると、この塊は消えます。あとで await result.response が NoOutputGeneratedError を投げます。503 の本文も requestId も、その時点ではありません。
const withError: StreamChunk[] = [
{ type: "text-delta", text: "途中まで" },
{ type: "error", error: new Error("upstream 503: no available account") },
{ type: "text-delta", text: "この文字は来ない" },
];
splitByType は error で throw します。Bun の出力です。
error.message = upstream 503: no available account
「この文字は来ない」は足されていません。エラーのあとに本文が続いても、答えレーンへは入れません。
本番でやっていること
ソケットへ出すイベントを分けています。思考は reasoning、本文は delta です。クライアントは別コンポーネントに描きます。永続化する assistant 本文は delta 側だけです。
ヘッジ(先に動き出した経路が勝ち)では、空でない reasoning-delta も「上流が動いた」と数えます。思考のあとで本文を待っていると、負けた経路がツールを呼び始めます。思考トークンは completion 課金に入ります。分岐を間違えると、見えない思考をユーザーに見せるだけでなく、負けた経路の思考分まで払います。
確認したケース
上のコードを Bun 1.4 で実行しています。
| ケース | 結果 |
|---|---|
text がある塊を全部連結 |
答えの先頭が思考。制約は n≤10^5 から始まる |
type で二レーン |
思考と本文が分かれる。答えは 二重ポインタで両端から詰めます。
|
<thi / nk> / </th / ink> に割ったタグ |
思考 内側で場合分けする。、答え 答えは 42 です。
|
| 終了タグの半端をバッファに残さない | 思考レーンに </think>答えは 42 です。 が入る。答えは空 |
流の途中の error
|
upstream 503: no available account が残る。後続の本文は足さない |
リポジトリに SDK は不要です。チャンク配列を自分で渡せば、同じ分岐を試せます。
皆さんの現場では、text があるチャンクを全部 answer に足していませんか。次ターンだけ Structured Output が死ぬ、という症状があれば聞きたいです。