最近のAIチャットサービスではSmooth Text Streamingが導入されており、ユーザーはストレスなく自然に会話を読み進められる体験が提供されています。
一方、この仕組みがないと、テキストが細切れに表示されてカクカクした印象になってしまい、一見些細な違いに見えても、特に文章が長くなると目線が追いにくくなり、気づかないうちにユーザーのストレスにつながっていきます。
Smooth Text Streaming 未導入の場合
テキストが小刻みに表示され、ややカクついた印象を受ける
Smooth Text Streaming 導入時
文字が滑らかに流れ、自然で読みやすい表示になる
このようにAIとの対話体験では、返ってくる内容そのものだけでなく、「どう表示されるか」といった部分もUXを左右する要素になります。
本記事では、Vercel AI SDK v5を通じたSmooth Text Streamingを、最小限のコードでチャットUIに実装する方法を記載します。
Smooth Text Streamingとは、モデルから届いたテキストをそのまま即時に描画するのではなく、テキストを適切な粒度に分割(chunking)し、一定のテンポで整流(smoothing)して表示するという仕組みです。Vercel AI SDKではstreamText()のexperimental_transformに、smoothStream()を差し込む形で導入でき、テキストが自然なまとまりで滑らかで読みやすいリズムで表示されるようになります。
ざっくり、このような形です。
- サーバ側ではstreamText()を呼び出し、テキストに加えてツール実行イベントやstep情報などを含むストリームを得ます(単なるtextStreamと異なり、非テキストのイベントも含むのがポイント)。
- このストリームをtoUIMessageStreamResponse()で返すと、フロント側にはUI Message Stream(SSE)として届き、クライアントのuseChatがそれを解釈します(終端には[DONE]マーカー)。
- さらに、途中でexperimental_transformを挟んでsmoothStream()を指定すると、テキスト部分のみを自然なまとまりに整形できます。一方で、ツール入出力やメタ情報などの非テキストイベントは整形の対象外となり、遅延をかけずにそのまま送出されます。
以下からは、上記の内容を実際のコード(最小構成)で記載します。
サーバ側
サーバー側では、クライアントからのメッセージを受け取り、AIモデルに渡してストリーミングレスポンスを生成します。
import { NextRequest } from "next/server";
import {
streamText,
smoothStream,
UIMessage,
convertToModelMessages,
} from "ai";
import { openai } from "@ai-sdk/openai";
export async function POST(req: NextRequest) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: openai("gpt-4o"),
messages: convertToModelMessages(messages),
experimental_transform: smoothStream({
delayInMs: 30,
chunking: /[\u3040-\u309F\u30A0-\u30FF]|\S+\s+/,
}),
});
return result.toUIMessageStreamResponse();
}
フロント側
"use client";
import { useChat } from "@ai-sdk/react";
import { useState, useCallback } from "react";
import { DefaultChatTransport } from "ai";
export default function Chat() {
const [input, setInput] = useState("");
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
});
const isLoading = status === "submitted" || status === "streaming";
const getTextFromParts = useCallback((parts: Array{ type: string; text?: string }>) =>
parts
.filter((p) => p.type === "text" && typeof p.text === "string")
.map((p) => p.text as string)
.join(""), []);
return (
div className="mx-auto max-w-2xl space-y-5">
{}
header className="sticky top-0 z-10 bg-gradient-to-r from-slate-50/80 to-white/80 backdrop-blur border-b border-slate-200 dark:from-slate-900/60 dark:to-slate-900/60 dark:border-slate-800">
div className="mx-auto max-w-2xl px-4 py-3 flex items-center justify-between">
h1 className="text-base font-semibold tracking-tight">
span className="inline-block rounded bg-slate-900 text-white px-2 py-0.5 text-[11px] mr-2">
Demo
/span>
AI SDK Chat
/h1>
/div>
/header>
{}
section className="px-4">
div className="rounded-2xl border border-slate-200 dark:border-slate-800 bg-white/70 dark:bg-slate-900/60 shadow-sm">
div className="px-4 py-3 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between">
div className="flex items-center gap-2 text-xs text-slate-500">
span
className={`inline-block size-2 rounded-full ${
isLoading ? "bg-emerald-500 animate-pulse" : "bg-slate-300"
}`}
/>
{isLoading ? "生成中…" : "待機中"}
/div>
/div>
{}
div
className="px-5 py-4 max-h-[60vh] overflow-y-auto space-y-4"
aria-live="polite"
>
{messages.length === 0 ? (
div className="text-center text-slate-400 text-sm py-6">
ここに出力が表示されます
/div>
) : (
messages.map((m, idx) => {
const isUser = m.role === "user";
const content = getTextFromParts(
m.parts as Array{ type: string; text?: string }>
);
return (
div key={idx} className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
div className={`flex items-start gap-3 ${isUser ? "flex-row-reverse" : ""} max-w-[85%]`}>
div
className={`shrink-0 size-8 rounded-full flex items-center justify-center text-xs font-semibold ${
isUser
? "bg-slate-300 text-slate-700"
: "bg-slate-900 text-white"
}`}
aria-hidden
>
{isUser ? "You" : "AI"}
/div>
div
className={`${
isUser
? "bg-slate-900 text-white"
: "bg-slate-100 dark:bg-slate-800 text-slate-900 dark:text-slate-100"
} rounded-2xl px-4 py-2.5 shadow-sm whitespace-pre-wrap leading-7 text-[15px]`}
>
{content}
/div>
/div>
/div>
);
})
)}
/div>
/div>
/section>
{}
section className="px-4">
form onSubmit={(e) => {
e.preventDefault();
const value = input.trim();
if (!value || isLoading) return;
sendMessage({ text: value });
setInput("");
}} className="flex gap-2">
input
value={input}
onChange={(e) => setInput(e.target.value)}
className="flex-1 rounded-xl border border-slate-300 bg-white/60 px-4 py-2.5 shadow-sm outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-slate-300 dark:bg-slate-900/40 dark:border-slate-700"
placeholder="なんでも質問してみてください…"
aria-label="Message"
disabled={status !== "ready"}
/>
button
type="submit"
disabled={status !== "ready"}
className="rounded-xl bg-slate-900 text-white px-5 py-2.5 text-sm font-medium shadow-sm transition disabled:opacity-50 hover:bg-slate-800"
>
{isLoading ? "Sending…" : "Send"}
/button>
/form>
/section>
/div>
);
}
smoothStream()のポイント
delayInMs:既定10ms。20–30msとかで試すと、滑らかな流れになる印象。
chunking:既定は ‘word’。日本語は空白で単語が切れないため、デモコード記載のように正規表現での分割が推奨。
非テキストイベントは即時通過:smoothStream()はテキスト以外の部品は遅延させずに送出される。
補足:プロトコルの選び分け(UI Message Stream/Text Stream)
- UI Message Stream:テキスト以外にもToolの入出力やステップなどをすべて同じSSEストリームに乗せて配信できる方式。実装には toUIMessageStreamResponse()を用い、ストリームの終端は[DONE]で通知されます。複数の情報をまとめて扱えるため、ツール連携やイベント処理が必要な複合的なUIに適しています。
- Text Stream:テキストのみを流す軽量なプロトコルです。利用する際は toTextStreamResponse()とTextStreamChatTransportを組み合わせて実装します。テキスト以外の情報は不要で、単純に文章を表示できれば十分というシンプルなUIに向いています。
おわりに
最近ではAI関連のサービスに触れる機会が格段に増え、「AIを交えたUX設計」が求められる場面も確実に増えてきている中で、今回のAIチャットに関しても、個人的にも一見すると些細な違いに思える部分ですが、単にモデルの返答をそのまま表示するのではなく、“どう表示するか”を調整するだけで体験の印象は大きく変わるのだと実感しました。そう考えると、このような一見小さな工夫も、これからのAI体験を左右する大切なポイントになっていくのかなと思いました。
Views: 0