---
title: "AI SDK smoothStreamの使い方｜日本語のchunking設定とSmooth Text Streamingの仕組み"
url: "https://www.issoh.co.jp/tech/details/8440/"
published: 2025-08-27
updated: 2026-11-07
categories: ["React"]
publisher: "株式会社一創"
---

# AI SDK smoothStreamの使い方｜日本語のchunking設定とSmooth Text Streamingの仕組み

テキストストリーミングは、LLMが生成し終えるのを待たずに、できた部分から順に送って表示する方式です。ただ、APIから届く断片は大きさも間隔もばらばらなので、そのまま描画すると文字が数語ずつ跳ねるように出ます。Smooth Text Streamingは、届いた断片を一度ためてから一定の間隔で少しずつ出し、表示の速度をそろえる手法の呼び名です。標準化された用語ではなく、Vercelの[AI SDK](/tech/details/9551/)では `smoothStream()` という関数で提供されています。

この記事では、2026年11月7日（日本時間）に公開された `ai` 7.0.164 と `@ai-sdk/react` 4.0.168 を使い、smoothStreamの組み込み方、日本語で既定設定がうまく働かない理由と直し方、ほかの変換や応答形式との組み合わせ、AI SDKを使わないReactでの実装を、手元で動かした結果とあわせて説明します。

## まとめ：滑らかな表示を作るときの要点

- **仕組み**：受信と表示を分け、受け取った文字をバッファにためてから一定のペースで出します。
- **AI SDKの場合**：`streamText` の `experimental_transform` に `smoothStream()` を渡します。応答は `createUIMessageStreamResponse` と `toUIMessageStream` で返します。`result.toUIMessageStreamResponse()` は7.0.0から非推奨です。
- **日本語の注意**：既定の `chunking: 'word'` は空白で区切るため、日本語では改行まで溜まります。87字の文章で、22個あった断片が2個（最大53字）にまとまり、何も設定しないときより粗くなりました。`Intl.Segmenter` を渡します。
- **ほかの変換と並べる場合**：`experimental_transform` は配列を受け取り、指定した順に適用します。smoothStreamは表示のための変換なので最後に置きます。
- **サーバーを変えられない場合**：クライアント側で表示文字数を進めるフックを書きます。速度は固定にせず、未表示の量に応じて上げます。
- **アクセシビリティ**：読み上げ対象の領域には生成中だけ `aria-busy="true"` を付け、`prefers-reduced-motion` が有効ならアニメーションを省きます。

## Smooth Text Streamingの仕組み：受信と表示の分離

LLMのAPIはトークンを数個ずつまとめて返し、間隔も一定しません。途中のプロキシやコンテンツフィルターがさらに束ねることもあります。[AI SDKのトラブルシューティング](https://ai-sdk.dev/docs/troubleshooting/azure-stream-slow)では、Azure OpenAIで応答が大きな塊で遅く届く事例を取り上げ、出力フィルターのストリーミングモードを非同期フィルターへ切り替える方法と、smoothStreamで語ごとに流す方法を挙げています。

ネットワークから届いた文字列はバッファに足すだけにして、画面への反映は別のタイミングで一定量ずつ行います。届く速さと表示する速さを切り離すので、到着がばらついても画面上の流れはそろいます。代わりに、バッファにたまった分だけ表示が受信より遅れます。この遅れをどこまで許すかが設計の中心です。

## 平滑化を入れる場所の選び方：サーバー・クライアント・描画の間引き

同じ「滑らかにする」でも、どこで処理するかで変わるものが違います。

| 方式               | 処理する場所           | 変わるもの       | 向く場面           |
| ---------------- | ---------------- | ----------- | -------------- |
| smoothStream     | サーバー（streamText） | 送る断片の大きさと間隔 | AI SDKでサーバーを書く |
| クライアント側フック       | ブラウザ（React）      | 画面に出す文字数    | サーバーを変えられない    |
| useChatのthrottle | ブラウザ（React・Vue）  | 再描画の回数      | 描画負荷を下げたい      |

AI SDKでAPIルートを自分で書いているならsmoothStreamが最短です。OpenAIやAnthropicのSDKを直接使っている、あるいは別チームのAPIを受けるだけといった理由でサーバーに手を入れられない場合は、クライアント側で表示を制御します。`useChat` の `throttle` は再描画の回数を減らす設定で、文字の出方を滑らかにするものではありません。[useChatの公式リファレンス](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat)も、ストリームの処理やコールバックは遅らせず、反映だけを間引くと説明しています。

## AI SDKのsmoothStreamの使い方

### streamTextへの組み込みとUIメッセージストリームへの変換

`smoothStream()` の戻り値は `TransformStream` そのものではなく、それを作る関数です。これを `streamText` の `experimental_transform` に渡します。7系でもオプション名に `experimental_` が付いたままです。Next.jsのRoute Handlerなら、[公式のチャットボットの例](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot)と同じ返し方で次の形になります。

```
import { openai } from '@ai-sdk/openai';
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  smoothStream,
  streamText,
  toUIMessageStream,
  type UIMessage,
} from 'ai';

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: openai('gpt-5.6'),
    messages: await convertToModelMessages(messages),
    experimental_transform: smoothStream(), // 既定は delayInMs: 10, chunking: 'word'
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}
```

変換はモデルの出力と応答の間に入るので、クライアントの `useChat` 側は何も変えなくて構いません。モックのモデルに「日本語の応答を」「送ります。」の2断片を返させ、後述の `Intl.Segmenter` を指定して応答を取り出すと、ヘッダーと本文は次のようになりました（`start` などの前後のイベントは省略）。

```
content-type: text/event-stream
x-vercel-ai-ui-message-stream: v1
x-accel-buffering: no

data: {"type":"text-start","id":"t"}
data: {"type":"text-delta","id":"t","delta":"日本語"}
data: {"type":"text-delta","id":"t","delta":"の"}
data: {"type":"text-delta","id":"t","delta":"応答"}
data: {"type":"text-delta","id":"t","delta":"を"}
data: {"type":"text-delta","id":"t","delta":"送り"}
data: {"type":"text-delta","id":"t","delta":"ます"}
data: {"type":"text-delta","id":"t","delta":"。"}
data: {"type":"text-end","id":"t"}
data: [DONE]
```

2つだった断片が語ごとの7つに分かれて、[SSE（Server-Sent Events）](/tech/details/7736/)で届いています。`x-accel-buffering: no` はnginxに応答をバッファさせないためのヘッダーで、SDKが自動で付けます。平滑化の対象はテキストと推論（reasoning）の差分だけです。ツール呼び出しやステップ終了などのイベントが来ると、それまでにたまった分を先に出してから、そのイベントをそのまま流します。

### toUIMessageStreamResponseの非推奨と移行先

v6までのサンプルにある `return result.toUIMessageStreamResponse();` は、7.0.0で `@deprecated` になりました。[v6から7.0への移行ガイド](https://ai-sdk.dev/docs/migration-guides/migration-guide-7-0)によると、v7では非推奨の警告付きで動き、次のメジャーリリースで削除されます。同時に、`streamText` の結果オブジェクトにある `toUIMessageStream`・`pipeUIMessageStreamToResponse`・`toTextStreamResponse`・`pipeTextStreamToResponse` の各メソッドも非推奨になりました。

置き換えでは、UIメッセージ変換用のオプションを `toUIMessageStream({ stream: result.stream, ...options })` に移し、`headers`・`status`・`statusText` などHTTP応答用の指定は `createUIMessageStreamResponse` に渡します。smoothStreamは `result.stream` の段階で効くので、新旧どちらの書き方でも上と同じ7つの `text-delta` とヘッダーが返りました（7.0.164）。

### delayInMsとchunkingの既定値と指定方法

オプションは2つです。`delayInMs` は断片を1つ出すごとに待つミリ秒で、既定は10です。`null` にすると待ちません。ブラウザで動かしていてタブが裏に回っている間は、タイマーの間引きでストリームが止まらないよう待ちを省きます。`chunking` はバッファをどこで区切るかの指定で、次の5通りを受け付けます。

| 指定                | 実際の区切り      | 日本語での挙動     |
| ----------------- | ----------- | ----------- |
| `'word'`（既定）      | `/\S+\s+/m` | 空白か改行まで溜まる  |
| `'line'`          | `/\n+/m`    | 改行まで溜まる     |
| RegExp            | 最初の一致の終わりまで | 空文字に一致すると例外 |
| Intl.Segmenter    | 先頭の1セグメント   | 語単位（公式推奨）   |
| 関数（ChunkDetector） | 戻り値の文字列     | 先頭一致でないと例外  |

関数を渡す場合は、バッファの先頭から切り出す文字列を返し、まだ切れないときは `null` を返します。返した文字列がバッファの先頭と一致しないと例外になります。

7.0.155で `chunking` の実行時検査が外れました（[aiパッケージのCHANGELOG](https://github.com/vercel/ai/blob/main/packages/ai/CHANGELOG.md)）。JavaScriptから `'sentence'` のような未対応の文字列を渡すと、7.0.129では `smoothStream()` の呼び出しで `InvalidArgumentError` になりましたが、7.0.164では最初のテキストが届いた時点の `TypeError` に変わりました。7.0.163で加わった `ping`（Anthropicの生存確認）は平滑化を通さずに流れ、「Hello wor」の直後に来ても「wor」を先に吐き出しません。

## 日本語でsmoothStreamの既定設定が逆効果になる理由

既定の `'word'` は `/\S+\s+/`、つまり「空白以外の文字の後に空白」で区切ります。日本語の文には単語間の空白が無いので、改行が来るまで1つの塊として溜まり続けます。[smoothStreamの公式リファレンス](https://ai-sdk.dev/docs/reference/ai-sdk-core/smooth-stream)も、中国語・日本語・韓国語・ベトナム語・タイ語では語単位の分割がうまく働かないと注記しています。

どの程度の差になるかを確かめるため、AI SDK付属のモックモデルで87字の日本語（2文・改行1つ）を4字ずつ22個の断片として5ms間隔で流し、`chunking` だけを変えて出力を比べました（`ai` 7.0.164、Node.js v26.5.0、`delayInMs` は既定の10ms）。入力は次のコードの `ja` の文字列です。「語の途中で切れた数」は、全文を `Intl.Segmenter` で区切ったときの語の境界と出力の切れ目を照らし合わせて数えたものです。時間は実行ごとにぶれるため、4回の最小と最大を載せています。

```
import { streamText, smoothStream } from 'ai';
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';

const ja = 'スムーズストリーミングは、受信した断片をそのまま描画せずに一度ためて、一定の間隔で少しずつ流す手法です。\n日本語は単語の間に空白が無いため、分割方法の選択が結果を左右します。';
const deltas = ja.match(/[\s\S]{1,4}/g) ?? []; // 4字ずつ22断片

const model = new MockLanguageModelV4({
  doStream: async () => ({
    stream: simulateReadableStream({
      initialDelayInMs: 0,
      chunkDelayInMs: 5,
      chunks: [
        { type: 'stream-start', warnings: [] },
        { type: 'text-start', id: 't' },
        ...deltas.map((delta) => ({ type: 'text-delta' as const, id: 't', delta })),
        { type: 'text-end', id: 't' },
        {
          type: 'finish',
          finishReason: { unified: 'stop', raw: 'stop' },
          usage: {
            inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
            outputTokens: { total: 1, text: 1, reasoning: 0 },
          },
        },
      ],
    }),
  }),
});

const result = streamText({
  model,
  prompt: 'x',
  experimental_transform: smoothStream({ chunking: 'word' }), // ここを差し替えて比べる
});

for await (const part of result.fullStream) {
  if (part.type === 'text-delta') console.log(JSON.stringify(part.text));
}
```

| chunking                | 出力の断片数 | 最大の断片 | 語の途中で切れた数 | 完了まで（4回）  |
| ----------------------- | ------ | ----- | --------- | --------- |
| なし（smoothStreamを外す）     | 22     | 4字    | 7         | 144〜165ms |
| ‘word’（既定）              | 2      | 53字   | 0         | 140〜147ms |
| ‘line’                  | 2      | 53字   | 0         | 138〜145ms |
| v4時代の推奨正規表現             | 48     | 6字    | 15        | 509〜520ms |
| Intl.Segmenter（ja・word） | 59     | 4字    | 8         | 641〜652ms |
| Intl.Segmenter＋末尾の語を保留  | 52     | 5字    | 1         | 562〜573ms |
| 句読点・改行で区切る正規表現          | 6      | 22字   | 0         | 136〜142ms |

既定の `'word'` では、22個の断片が53字と34字の2個にまとまりました。smoothStreamを外したときより粗く、改行まで何も表示されない時間ができます。7.0.129でも断片数・最大の断片・語の途中で切れた数は同じでした。

[v4系のリファレンス](https://v4.ai-sdk.dev/docs/reference/ai-sdk-core/smooth-stream)は日本語向けに `/[\u3040-\u309F\u30A0-\u30FF]|\S+\s+/` を勧めていましたが、これはひらがなとカタカナを1文字ずつ出す式です。「ス」「ム」「ー」「ズ」と1字ずつ切れ、語の途中の切れ目は15か所になりました。現在のリファレンスはこの式をやめて `Intl.Segmenter` を推奨しており、旧URLの日本語ページも現在は参照ページへ転送されます。

### Intl.Segmenterで末尾の語を保留するChunkDetector

`Intl.Segmenter` をそのまま渡すと語単位にはなりますが、それでも8か所で語が割れました。smoothStreamはバッファの先頭のセグメントをすぐ切り出すため、「ストリー」のように続きがまだ届いていない語が、その時点の区切りで出てしまうからです。バッファの最後のセグメントは次の断片を待ってから出すようにすると、割れは1か所に減りました。残った1か所はカタカナ語で、末尾を保留しても全文をまとめて区切った場合と同じ境界になるとは限りません。

```
import { openai } from '@ai-sdk/openai';
import {
  convertToModelMessages,
  createUIMessageStreamResponse,
  smoothStream,
  streamText,
  toUIMessageStream,
  type UIMessage,
} from 'ai';

const segmenter = new Intl.Segmenter('ja', { granularity: 'word' });

// 末尾の語は次の断片で続きが届くかもしれないので、後ろに語が続くときだけ切り出す
function japaneseWords(buffer: string): string | null {
  const it = segmenter.segment(buffer)[Symbol.iterator]();
  const first = it.next();
  if (first.done) return null;
  return it.next().done ? null : first.value.segment;
}

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: openai('gpt-5.6'),
    messages: await convertToModelMessages(messages),
    experimental_transform: smoothStream({
      delayInMs: 15,
      chunking: japaneseWords,
    }),
  });

  return createUIMessageStreamResponse({
    stream: toUIMessageStream({ stream: result.stream }),
  });
}
```

保留した最後の語は、テキストの終わり（`text-end`）が届いた時点でまとめて出るので、末尾が欠けることはありません。`Intl.Segmenter` はNode.js 16以降と、Chrome 87・Firefox 125・Safari 14.1以降で使えます。

読みやすさを優先するなら、表の最終行のように句読点で区切る正規表現 `/[^、。！？\n]*[、。！？\n]/` も選択肢です。語の途中では切れず、断片は最大22字と大きくなります。読点ごとに出るほうが目で追いやすい場面で使います。

### 断片数に比例して延びる応答の完了時間

smoothStreamは断片を1つ出すたびに `delayInMs` だけ待つため、断片が細かいほど全体の完了は遅れます。上の実験では、平滑化なしの144〜165msに対し、`Intl.Segmenter` では59断片で641〜652msでした。差の約0.5秒は、59断片×10msの待ちとほぼ一致します。長い回答では差が積み上がり、モデルが生成を終えてからも表示が続きます。日本語で語単位に切るなら、`delayInMs` を既定の10msより短くするか、句読点区切りにして断片を減らすかのどちらかで調整します。

## smoothStreamと他の変換の適用順序・応答形式の選択

### experimental\_transformに複数の変換を並べる順序

`experimental_transform` には変換の配列も渡せます。[AI SDKのテキスト生成のドキュメント](https://ai-sdk.dev/docs/ai-sdk-core/generating-text)によると、変換は指定した順に適用され、`onEnd` などのコールバックは変換後のテキストを受け取ります。モデルに「個人情」「報を含む」「文です。」の3断片を返させ、断片を記録するだけの変換をsmoothStream（`Intl.Segmenter` 指定）の前と後ろに置いて比べました。

| 配列の並び                 | 記録用の変換が受け取った断片         |
| --------------------- | ---------------------- |
| \[smoothStream, 記録用\] | 個人／情／報／を／含む／文／です／。（8個） |
| \[記録用, smoothStream\] | 個人情／報を含む／文です。（3個）      |

画面に届く結果はどちらも同じ8個で、違うのは後ろの変換が受け取る断片の細かさと呼ばれる回数です。どちらの並びでも「個人情報」は1つの断片に収まらないので、断片ごとに語を検出して伏せ字にする変換は、並びを変えても取りこぼします。その種の変換は自前のバッファを持ち、語がそろってから判定します。smoothStreamは配列の最後に置き、ほかの変換にはモデルの出力のままの断片を渡します。

```
experimental_transform: [
  maskTransform, // 内容を加工する変換を先に
  smoothStream({ chunking: new Intl.Segmenter('ja', { granularity: 'word' }) }), // 表示の平滑化は最後
],
```

### toTextStreamでテキストだけを返す場合のヘッダー

ツール呼び出しやメタデータが要らず、テキストだけを返すなら `toTextStream` と `createTextStreamResponse` を使います。smoothStreamはこの形式でも効き、上と同じ2断片の入力から「日本語」「の」「応答」「を」「送り」「ます」「。」の7個のチャンクが届きました。ただし応答ヘッダーは `content-type: text/plain; charset=utf-8` だけで、UIメッセージ形式では自動で付いた `x-accel-buffering: no` がありません。nginxの後ろに置くなら自分で付けます。

```
import { openai } from '@ai-sdk/openai';
import {
  convertToModelMessages,
  createTextStreamResponse,
  smoothStream,
  streamText,
  toTextStream,
  type UIMessage,
} from 'ai';

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json();

  const result = streamText({
    model: openai('gpt-5.6'),
    messages: await convertToModelMessages(messages),
    experimental_transform: smoothStream({
      chunking: new Intl.Segmenter('ja', { granularity: 'word' }),
    }),
  });

  return createTextStreamResponse({
    stream: toTextStream({ stream: result.stream }),
    headers: { 'X-Accel-Buffering': 'no' }, // テキスト形式の応答にはSDKが付けない
  });
}
```

クライアントの `useChat` には `transport: new TextStreamChatTransport({ api: '/api/chat' })` を渡します。公式のチャットボットのドキュメントにあるとおり、この形式ではツール呼び出しや使用量、終了理由は届きません。

## クライアント側で滑らかに表示するReactフックの実装

### 固定速度のタイプライターで表示待ちが溜まる問題

サーバーに手を入れられない場合は、受け取った全文を保持したまま、画面に出す文字数だけを `requestAnimationFrame` で少しずつ増やします。よく見かける実装は「1文字5ms」のような固定速度ですが、これはモデルの出力が表示速度より速いと、未表示の文字が溜まり続けます。60fpsで動かし、各フレームの描画更新後に数えた表示待ちの最大文字数を計算で比べました（900字の回答を100msごとにまとめて受け取る条件）。

| モデルの出力速度 | 表示方式                    | 表示待ちの最大文字数 |
| -------- | ----------------------- | ---------- |
| 300字/秒   | 固定 5ms/字（200字/秒）        | 317字       |
| 300字/秒   | 追従型（未表示÷0.25秒・最低120字/秒） | 87字        |
| 60字/秒    | 固定 5ms/字（200字/秒）        | 3字         |
| 60字/秒    | 追従型（未表示÷0.25秒・最低120字/秒） | 4字         |

出力の遅いモデルでは差がありませんが、速いモデルでは固定速度だと300字以上が未表示のまま溜まり、回答の終了時にまとめて現れます。次の追従型でも終了時に残りを一度に出します。この表の条件では表示待ちは最大87字ですが、受信速度や一度に届く量が増えると、この値を超えます。

### 未表示の量に応じて速度を上げるuseSmoothTextの実装

次のフックは、未表示の文字数に比例して速度を上げます（未表示の文字数÷0.25秒、最低でも毎秒120字）。残りが減るほど遅くなるので、一定時間で溜まった分を消化しきる動きではなく、受信が続く間は表示待ちを一定の範囲に抑える動きになります。受信した全文は `ref` に置き、アニメーションのループはそこから読むので、受信の更新が頻繁でもループは止まりません。受信が終わったときと、OSの「動きを減らす」設定が有効なときは、そのレンダーで全文を返します。

```
import { useEffect, useRef, useState } from 'react';

type Options = { minCharsPerSec?: number; catchUpMs?: number };

// 1フレームで何文字進めるか。速度は「未表示の文字数 ÷ catchUpMs」で、未表示が多いほど速い
export function nextLength(shown: number, total: number, dtMs: number,
  { minCharsPerSec = 120, catchUpMs = 250 }: Options = {}): number {
  const backlog = total - shown;
  if (backlog <= 0) return total;
  const perSec = Math.max(minCharsPerSec, (backlog * 1000) / catchUpMs);
  return Math.min(total, shown + Math.max(1, Math.round((perSec * dtMs) / 1000)));
}

export function useSmoothText(text: string, streaming: boolean, options: Options = {}): string {
  const { minCharsPerSec = 120, catchUpMs = 250 } = options;
  const [length, setLength] = useState(0);
  const [instant, setInstant] = useState(false);
  const lengthRef = useRef(0);
  const textRef = useRef(text);

  useEffect(() => {
    textRef.current = text; // 受信した最新の全文。ループはここから読む
  }, [text]);

  useEffect(() => {
    const media = window.matchMedia('(prefers-reduced-motion: reduce)');
    const update = () => setInstant(media.matches);
    update();
    media.addEventListener('change', update);
    return () => media.removeEventListener('change', update);
  }, []);

  useEffect(() => {
    if (!streaming || instant) return;
    let frame = 0;
    let last = performance.now();
    const tick = (now: number) => {
      const target = textRef.current;
      if (lengthRef.current > target.length) lengthRef.current = 0; // 別の文字列に差し替わった
      let next = nextLength(lengthRef.current, target.length, Math.max(0, now - last),
        { minCharsPerSec, catchUpMs });
      // サロゲートペア（絵文字など）の途中で切らない
      const code = target.charCodeAt(next - 1);
      if (code >= 0xd800 && code <= 0xdbff) next += 1;
      last = now;
      if (next !== lengthRef.current) {
        lengthRef.current = next;
        setLength(next);
      }
      frame = requestAnimationFrame(tick); // 受信中は毎フレーム予約し続ける
    };
    frame = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(frame);
  }, [streaming, instant, minCharsPerSec, catchUpMs]);

  // 受信が終わった・動きを減らす設定のときは、そのレンダーで全文を返す（aria-busyの解除と同じ更新）
  if (!streaming || instant) return text;
  return text.slice(0, length);
}
```

`useChat` と組み合わせると次のようになります。平滑化するのは生成中の最後のメッセージだけで、履歴は最初から全文を出します。

```
'use client';
import { useState } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useSmoothText } from './useSmoothText';

function SmoothText({ text, streaming }: { text: string; streaming: boolean }) {
  return <p>{useSmoothText(text, streaming)}</p>;
}

export default function Chat() {
  const [input, setInput] = useState('');
  const { messages, sendMessage, status } = useChat({
    transport: new DefaultChatTransport({ api: '/api/chat' }),
  });
  const busy = status === 'submitted' || status === 'streaming';

  return (
    <>
      <div aria-live="polite" aria-busy={busy}>
        {messages.map((m, mi) =>
          m.parts.map((part, i) =>
            part.type !== 'text' ? null : m.role === 'assistant' ? (
              <SmoothText
                key={`${m.id}-${i}`}
                text={part.text}
                streaming={status === 'streaming' && mi === messages.length - 1}
              />
            ) : (
              <p key={`${m.id}-${i}`}>{part.text}</p>
            ),
          ),
        )}
      </div>
      <form
        onSubmit={(e) => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput('');
        }}
      >
        <input aria-label="メッセージ" value={input} onChange={(e) => setInput(e.target.value)} disabled={busy} />
      </form>
    </>
  );
}
```

この記事のコード例は、`ai` 7.0.164・`@ai-sdk/react` 4.0.168・`@ai-sdk/openai` 4.0.110 の環境でTypeScript 7.0.2の `strict` の型検査を通しています。フックはjsdomとReact 19.3.1で、毎秒約515字の受信を3回流しました。表示待ちは132〜133字で頭打ちになり、受信速度×0.25秒（約129字）とほぼ一致しました。`streaming` を `false` にしたレンダーで、776〜779字から全900字に切り替わりました。`useChat` の `throttle` を付けると全文の更新がその間隔でまとめて届くため、このフックを使うなら `throttle` は付けないでおきます。

## スクリーンリーダーと動きを減らす設定への配慮

チャットの回答欄を `aria-live` の領域にしている場合、文字が増えるたびにスクリーンリーダーが読み上げようとします。平滑化で更新の回数が増えると、断片的な読み上げも増えます。[MDNのaria-busyの解説](https://developer.mozilla.org/ja/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-busy)は、ライブリージョンで複数の部分を読み込み終えてから伝えたい場合に、読み込みが終わるまで `aria-busy="true"` を付け、終わったら `false` に戻すよう説明しています。上の `Chat` コンポーネントでは `status` が `submitted` か `streaming` の間だけ `aria-busy` を立てています。

フックが受信の終わったレンダーで全文を返すのはこのためです。`aria-busy` が外れる更新と全文がそろう更新を同じにして、外れた後に文字が増え続けないようにしています。実際の読み上げ方はスクリーンリーダーごとに違うので、NVDAやVoiceOverでの確認は別途必要です。読み上げを細かく制御したい場合は、[ARIA Notify（ariaNotify()）](/tech/details/7987/)で完了時に一度だけ通知する方法もあります。

[prefers-reduced-motion](https://developer.mozilla.org/ja/docs/Web/CSS/Reference/At-rules/@media/prefers-reduced-motion) は、動きを最小限にしたいという端末側の設定をブラウザに伝えるメディア特性です。文字が流れる演出は必須の動きではないので、この設定が有効なら平滑化を止めて全文をそのまま出します。

## 滑らかに表示されないときの確認項目

- **日本語が改行までまとめて出る**：`chunking` が既定の `'word'` のままです。`Intl.Segmenter` か句読点区切りに変えます。
- **本番環境だけ塊で届く**：リバースプロキシのバッファリングや圧縮ミドルウェアが応答を溜めています。nginxは通常、UIメッセージ形式の応答に付く `x-accel-buffering: no` でバッファリングを止めますが、`proxy_ignore_headers` に `X-Accel-Buffering` を指定しているとこのヘッダーは無視されます。その場合は該当箇所で `proxy_buffering off` を設定します。テキスト形式の応答はこのヘッダーを自分で付けます。
- **Azure OpenAIで遅く大きな塊になる**：出力フィルターのストリーミングモードを確認します。
- **最初の文字が届いた時点で TypeError になる**：`chunking` に `'word'`・`'line'` 以外の文字列を渡しています。7.0.155以降は作成時に検査されず、ストリームの途中で落ちます。
- **生成が終わってからも表示が続く**：断片が細かすぎるか、`delayInMs` が長すぎます。実験では59断片で約0.5秒延びました。
- **更新がカクつく**：`useChat` の `throttle` に大きな値を入れていないか確認します。旧名の `experimental_throttle` は4.0系で非推奨になり、`throttle` に置き換わっています。

AI SDKそのものの導入手順やv6からv7への移行は[Vercel AI SDK v6とv7の違い](/tech/details/10402/)で、Suspenseを使ったサーバー側の段階表示は[Streaming UI](/tech/details/7059/)の記事で扱っています。チャット画面を含む生成AIアプリを設計から依頼したい場合は、[生成AI開発・AI受託開発](/service/ai/development/)のページで支援の範囲を紹介しています。

## よくある質問

### テキストストリーミング（text streaming）とはどういう意味ですか？

生成や処理が終わるのを待たずに、できた部分から順にテキストを送って表示する方式です。LLMのチャットでは、SSEなどで断片を受け取り、画面に追記していきます。Smooth Text Streamingは、その表示の速度をそろえる工夫を指します。

### smoothStreamはクライアントとサーバーのどちらに書きますか？

サーバー側です。`streamText` の `experimental_transform` に渡します。`useChat` の設定は変える必要がありません。

### toUIMessageStreamResponseを使うとsmoothStreamは効きますか？

効きます。ただし `result.toUIMessageStreamResponse()` は7.0.0から非推奨で、次のメジャーリリースで削除される予定です。移行先の `createUIMessageStreamResponse({ stream: toUIMessageStream({ stream: result.stream }) })` でも、同じように平滑化された `text-delta` がSSEで届きます。

### 日本語ではchunkingに何を指定すればよいですか？

公式は `new Intl.Segmenter('ja', { granularity: 'word' })` を推奨しています。語の途中の切れ目を減らすには、本文で紹介した末尾の語を保留する関数を使います。ただし実測では1か所残りました。語単位の表示より文節のまとまりを優先する場合は、句読点で区切る正規表現も選べます。

### useChatのexperimental\_throttleで文字を滑らかにできますか？

できません。再描画の回数を減らす設定で、文字の出方はむしろ粗くなります。名前も `throttle` に変わっています。滑らかにするにはsmoothStreamかクライアント側のフックを使います。

## 関連記事

- [Vercel AI SDKの使い方｜AI SDK 7でNext.jsチャットとLangChain連携を実装](/tech/details/9551/)
- [Vercel AI SDK v6の新機能｜ToolLoopAgent・needsApprovalの使い方とv7への移行【6.0.320対応】](/tech/details/10402/)
- [SSE（Server-Sent Events）とは？仕組み・WebSocketとの違い・実装を3ブラウザの実測で解説](/tech/details/7736/)
- [Streaming UIとは？Suspenseで段階表示する仕組みとAIチャットの表示設計](/tech/details/7059/)
- [ARIA Notify（ariaNotify()）の使い方：aria-liveとの違い・priorityの指定と対応ブラウザ](/tech/details/7987/)

---

出典: [AI SDK smoothStreamの使い方｜日本語のchunking設定とSmooth Text Streamingの仕組み](<https://www.issoh.co.jp/tech/details/8440/>)（株式会社一創）
