关于 ai-streaming
面向使用 Vercel AI SDK 的开发者,用于在 Web 应用中接入 AI 响应流的技能。它先检查现有项目配置,再介绍服务端路由、客户端 Hook、身份验证、取消请求与错误处理。
主要功能
- 01检查现有 AI 项目配置
- 02创建流式 API 路由
- 03连接聊天与补全 Hook
- 04在开始流式响应前验证身份
- 05处理取消请求与流错误
- 06呈现部分输出和加载状态
适用场景
- 01为 Web 应用添加流式聊天
- 02构建单轮文本生成
- 03避免已离开的请求继续运行
- 04排查响应缓冲或流错误
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ tushaarmehtaa/ tushar-skills --skill ai-streaming安装后在 Claude Code 中输入/ai-streaming确认已加载。
详细介绍
将模型生成的 AI 响应以流式方式传输到浏览器。使用 Vercel AI SDK——这是适用于 Next.js AI 应用的理想抽象层。先读取项目,再接入现有的身份验证和模型配置。
会悄悄导致 AI 流式传输出错的四件事
- 路由处理程序使用了错误的运行时。 流式传输需要 Edge 运行时,或正确处理响应的 Node.js 运行时。如果没有
export const runtime = 'edge'或适当的流式传输设置,响应就会被完全缓冲,最后一次性全部返回,而不是“流式”返回。 - 没有处理中止。 如果用户在生成过程中离开页面,模型会继续运行并消耗 Token。务必传递
AbortController的abortSignal,并在组件卸载时进行清理。 - 流开始后才检查身份验证。 调用
streamText后,响应头就会发送。流开始后无法再返回 401。必须在调用任何 SDK 方法之前完成身份验证检查和验证。 - 客户端缺少错误边界。 如果流在响应过程中出错,
useChat会在error状态中报告错误;但如果不渲染该状态,UI 就会带着部分输出悄然卡住。
阶段 1:检测项目
cat package.json | grep -E "ai|anthropic|openai|@ai-sdk"
- 存在
ai包? → 已安装 Vercel AI SDK,进入阶段 3 - 仅有
@anthropic-ai/sdk? → 在此基础上添加 Vercel AI SDK - 仅有
openai? → 在此基础上添加 Vercel AI SDK - 都没有? → 从头安装
检查现有的 AI 路由:
find . -name "*.ts" -path "*/api/*" | xargs grep -l "streamText\|createOpenAI\|anthropic" 2>/dev/null
阶段 2:安装
npm install ai @ai-sdk/anthropic
# or for OpenAI:
npm install ai @ai-sdk/openai
添加到 .env.example:
ANTHROPIC_API_KEY=
# or
OPENAI_API_KEY=
阶段 3:路由处理程序
创建流式 API 路由。必须先检查身份验证,再调用任何 SDK:
// app/api/chat/route.ts
import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export const runtime = 'edge'; // required for true streaming
export async function POST(req: Request) {
// 1. Auth — must happen before streamText
const user = await getAuthUser(req);
if (!user) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2. Credits / rate limit check (if applicable)
if (user.credits <= 0) {
return Response.json({ error: 'No credits remaining', upgradeUrl: '/pricing' }, { status: 402 });
}
// 3. Parse the request
const { messages } = await req.json();
// 4. Stream
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
messages,
system: 'You are a helpful assistant.',
maxTokens: 1024,
abortSignal: req.signal, // propagate client abort
onFinish: async ({ usage }) => {
// Deduct credits after completion
await deductCredits(user.id, usage.totalTokens);
},
});
return result.toDataStreamResponse();
}
单轮生成(非聊天):
// app/api/generate/route.ts
import { streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export const runtime = 'edge';
export async function POST(req: Request) {
const user = await getAuthUser(req);
if (!user) return Response.json({ error: 'Unauthorized' }, { status: 401 });
const { prompt } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-4-6'),
prompt,
abortSignal: req.signal,
});
return result.toDataStreamResponse();
}
阶段 4:客户端 Hook
聊天界面(多轮):
'use client';
import { useChat } from 'ai/react';
export function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading, error, stop } = useChat({
api: '/api/chat',
onError: (err) => console.error('Chat error:', err),
});
return (
<div>
<div>
{messages.map((m) => (
<div key={m.id} className={m.role === 'user' ? 'text-right' : 'text-left'}>
<p>{m.content}</p>
</div>
))}
{isLoading && <p className="text-muted animate-pulse">Thinking...</p>}
{error && <p className="text-red-500">Something went wrong. Try again.</p>}
</div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="Ask anything..."
disabled={isLoading}
/>
{isLoading ? (
<button type="button" onClick={stop}>Stop</button>
) : (
<button type="submit">Send</button>
)}
</form>
</div>
);
}
单轮生成:
'use client';
import { useCompletion } from 'ai/react';
export function Generator() {
const { completion, input, handleInputChange, handleSubmit, isLoading, error, stop } = useCompletion({
api: '/api/generate',
});
return (
<div>
<form onSubmit={handleSubmit}>
<textarea value={input} onChange={handleInputChange} />
<button type="submit" disabled={isLoading}>Generate</button>
{isLoading && <button type="button" onClick={stop}>Stop</button>}
</form>
{error && <p className="text-red-500">{error.message}</p>}
{completion && <div className="whitespace-pre-wrap">{completion}</div>}
</div>
);
}
阶段 5:流式加载状态
需要处理三种不同的状态:
// 1. Not started — show the input
// 2. Loading (model thinking, no tokens yet) — show spinner
// 3. Streaming (tokens arriving) — show partial text
function StreamingOutput({ isLoading, completion }: { isLoading: boolean; completion: string }) {
if (!isLoading && !completion) return null;
return (
<div>
{isLoading && !completion && (
// Model is thinking — no tokens yet
<div className="flex gap-1">
<span className="animate-bounce">·</span>
<span className="animate-bounce delay-100">·</span>
<span className="animate-bounce delay-200">·</span>
</div>
)}
{completion && (
// Tokens arriving
<p className="whitespace-pre-wrap">
{completion}
{isLoading && <span className="animate-pulse">▋</span>}
</p>
)}
</div>
);
}
阶段 6:将额度接入流
如果项目使用通过 ship-credits 技能创建的额度系统,请在流结束后扣除额度,而不是提前扣除:
// In the route handler onFinish callback:
onFinish: async ({ usage, finishReason }) => {
if (finishReason === 'stop' || finishReason === 'length') {
const cost = Math.ceil(usage.totalTokens / 100); // define your credit cost
await db.update(users)
.set({ credits: sql`credits - ${cost}` })
.where(eq(users.id, user.id));
}
},
切勿在流开始前扣除额度——如果模型在生成第一个 Token 时就出错,用户就会白白损失额度。
验证
[ ] Route handler exports runtime = 'edge'
[ ] Auth check happens before any streamText call
[ ] req.signal passed as abortSignal to streamText
[ ] useChat/useCompletion renders error state (not just isLoading)
[ ] Stop button shown when isLoading — lets user cancel
[ ] Credits deducted in onFinish, not before the stream
[ ] Partial text renders during stream (not only after complete)
[ ] Test: start generation, navigate away — confirm model call aborts in server logs
[ ] Test: trigger an error — confirm error state renders, not a frozen UI
评论
支持 Markdown