0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI SDK の reasoning-delta を答えに足すと、次の応答が壊れる

0
Posted at

text フィールドがあるチャンクを全部足すと、思考が答えになります。

Vercel AI SDK のストリームは、思考と本文を別の type で流します。reasoning-deltatext-delta の両方に text があります。"text" in chunk で足すと、ユーザーに見える答えの先頭が「制約は n≤10^5…」になります。その文字列を assistant メッセージとして次ターンに渡すと、Structured Output の JSON が突然パースできなくなります。モデルは「前回の答え」だと思って思考文を読みます。

自分の現場はリアルタイムの回答ストリームです。画面に出す本文と、折りたたみの思考は別イベントです。同じバッファに載せると、この区別が消えます。

result.stream を chunk.type で思考レーンと答えレーンに分岐する図

結論

レーンは二つです。分岐は chunk.type です。text の有無ではありません。

  • reasoning-delta → 思考レーン。UI の折りたたみ、TTFT の計測。履歴の assistant 本文には入れない
  • text-delta → 答えレーン。ユーザーに見せる本文、次ターンに渡す本文
  • error → その場で throw。無視すると流が正常終了に見え、あとで NoOutputGeneratedError だけが残ります

textStreamtext-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。ソートすると間に合わない。両端から詰める。二重ポインタで両端から詰めます。"

ユーザーが見る先頭は思考です。この文字列を次ターンの messagesrole: "assistant" で入れると、モデルは「前回は制約の話をしていた」と解釈します。JSON スキーマを要求している経路では、パースがここで折れます。

text を全部足す誤りと、type で分岐する正しさ

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-deltatext-delta だけ見ていると、この塊は消えます。あとで await result.responseNoOutputGeneratedError を投げます。503 の本文も requestId も、その時点ではありません。

const withError: StreamChunk[] = [
  { type: "text-delta", text: "途中まで" },
  { type: "error", error: new Error("upstream 503: no available account") },
  { type: "text-delta", text: "この文字は来ない" },
];

splitByTypeerror で 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 が死ぬ、という症状があれば聞きたいです。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?