テキストストリーミングは、LLMが生成し終えるのを待たずに、できた部分から順に送って表示する方式です。ただ、APIから届く断片は大きさも間隔もばらばらなので、そのまま描画すると文字が数語ずつ跳ねるように出ます。Smooth Text Streamingは、届いた断片を一度ためてから一定の間隔で少しずつ出し、表示の速度をそろえる手法の呼び名です。標準化された用語ではなく、VercelのAI SDKでは 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のトラブルシューティングでは、Azure OpenAIで応答が大きな塊で遅く届く事例を取り上げ、出力フィルターのストリーミングモードを非同期フィルターへ切り替える方法と、smoothStreamで語ごとに流す方法を挙げています。
ネットワークから届いた文字列はバッファに足すだけにして、画面への反映は別のタイミングで一定量ずつ行います。届く速さと表示する速さを切り離すので、到着がばらついても画面上の流れはそろいます。代わりに、バッファにたまった分だけ表示が受信より遅れます。この遅れをどこまで許すかが設計の中心です。
平滑化を入れる場所の選び方:サーバー・クライアント・描画の間引き
同じ「滑らかにする」でも、どこで処理するかで変わるものが違います。
| 方式 | 処理する場所 | 変わるもの | 向く場面 |
|---|---|---|---|
| smoothStream | サーバー(streamText) | 送る断片の大きさと間隔 | AI SDKでサーバーを書く |
| クライアント側フック | ブラウザ(React) | 画面に出す文字数 | サーバーを変えられない |
| useChatのthrottle | ブラウザ(React・Vue) | 再描画の回数 | 描画負荷を下げたい |
AI SDKでAPIルートを自分で書いているならsmoothStreamが最短です。OpenAIやAnthropicのSDKを直接使っている、あるいは別チームのAPIを受けるだけといった理由でサーバーに手を入れられない場合は、クライアント側で表示を制御します。useChat の throttle は再描画の回数を減らす設定で、文字の出方を滑らかにするものではありません。useChatの公式リファレンスも、ストリームの処理やコールバックは遅らせず、反映だけを間引くと説明しています。
AI SDKのsmoothStreamの使い方
streamTextへの組み込みとUIメッセージストリームへの変換
smoothStream() の戻り値は TransformStream そのものではなく、それを作る関数です。これを streamText の experimental_transform に渡します。7系でもオプション名に experimental_ が付いたままです。Next.jsのRoute Handlerなら、公式のチャットボットの例と同じ返し方で次の形になります。
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)で届いています。x-accel-buffering: no はnginxに応答をバッファさせないためのヘッダーで、SDKが自動で付けます。平滑化の対象はテキストと推論(reasoning)の差分だけです。ツール呼び出しやステップ終了などのイベントが来ると、それまでにたまった分を先に出してから、そのイベントをそのまま流します。
toUIMessageStreamResponseの非推奨と移行先
v6までのサンプルにある return result.toUIMessageStreamResponse(); は、7.0.0で @deprecated になりました。v6から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)。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の公式リファレンスも、中国語・日本語・韓国語・ベトナム語・タイ語では語単位の分割がうまく働かないと注記しています。
どの程度の差になるかを確かめるため、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系のリファレンスは日本語向けに /[\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のテキスト生成のドキュメントによると、変換は指定した順に適用され、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の解説は、ライブリージョンで複数の部分を読み込み終えてから伝えたい場合に、読み込みが終わるまで aria-busy="true" を付け、終わったら false に戻すよう説明しています。上の Chat コンポーネントでは status が submitted か streaming の間だけ aria-busy を立てています。
フックが受信の終わったレンダーで全文を返すのはこのためです。aria-busy が外れる更新と全文がそろう更新を同じにして、外れた後に文字が増え続けないようにしています。実際の読み上げ方はスクリーンリーダーごとに違うので、NVDAやVoiceOverでの確認は別途必要です。読み上げを細かく制御したい場合は、ARIA Notify(ariaNotify())で完了時に一度だけ通知する方法もあります。
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の違いで、Suspenseを使ったサーバー側の段階表示はStreaming UIの記事で扱っています。チャット画面を含む生成AIアプリを設計から依頼したい場合は、生成AI開発・AI受託開発のページで支援の範囲を紹介しています。
よくある質問
テキストストリーミング(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かクライアント側のフックを使います。