LLM 流式 UI 实战模式:React / Vue / SvelteKit
ChatGPT 风格的流式输出 UI 看起来简单,写好其实坑很多:abort、错误恢复、Markdown 渐进渲染、思考状态、tool call 可视化。本文给三大框架的实战模式。
一、整体架构
浏览器 → 你的 Next.js / Express API → LLM API
↑ ↑
SSE upstream SSE
服务端把上游 SSE 转给浏览器。不要让浏览器直连 LLM(暴露 API Key)。
二、Next.js Route Handler(服务端)
// app/api/chat/route.ts
import { OpenAI } from "openai";
const client = new OpenAI({
apiKey: process.env.YOTRADE_KEY,
baseURL: "https://yotradeapi.com/v1",
});
export async function POST(req: Request) {
const { messages } = await req.json();
const stream = await client.chat.completions.create({
model: "claude-sonnet-4-6",
messages,
stream: true,
});
const encoder = new TextEncoder();
const readable = new ReadableStream({
async start(controller) {
try {
for await (const part of stream) {
const delta = part.choices[0]?.delta?.content || "";
if (delta) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ delta })}\n\n`));
}
}
controller.enqueue(encoder.encode(`data: [DONE]\n\n`));
} finally {
controller.close();
}
},
});
return new Response(readable, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"Connection": "keep-alive",
},
});
}
关键:no-cache, no-transform、Connection: keep-alive。CDN / 反向代理需要透传 SSE(看 SSE 故障排查)。
三、React 客户端
"use client";
import { useState } from "react";
export function Chat() {
const [messages, setMessages] = useState<{ role: string; content: string }[]>([]);
const [pending, setPending] = useState("");
const [loading, setLoading] = useState(false);
const [abort, setAbort] = useState<AbortController | null>(null);
async function send(input: string) {
const next = [...messages, { role: "user", content: input }];
setMessages(next);
setLoading(true);
setPending("");
const ctrl = new AbortController();
setAbort(ctrl);
try {
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ messages: next }),
signal: ctrl.signal,
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let acc = "";
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop()!;
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6);
if (payload === "[DONE]") break;
try {
const obj = JSON.parse(payload);
if (obj.delta) {
acc += obj.delta;
setPending(acc);
}
} catch {}
}
}
setMessages([...next, { role: "assistant", content: acc }]);
setPending("");
} catch (err: any) {
if (err.name !== "AbortError") console.error(err);
if (pending) {
setMessages([...next, { role: "assistant", content: pending + " [中断]" }]);
setPending("");
}
} finally {
setLoading(false);
setAbort(null);
}
}
return (
<div>
<div className="messages">
{messages.map((m, i) => (
<div key={i} className={m.role}>{m.content}</div>
))}
{pending && <div className="assistant pending">{pending}</div>}
</div>
<form onSubmit={(e) => { e.preventDefault(); send((e.target as any).input.value); }}>
<input name="input" disabled={loading} />
<button type="submit" disabled={loading}>Send</button>
{abort && <button type="button" onClick={() => abort.abort()}>Stop</button>}
</form>
</div>
);
}
四、用 Vercel AI SDK(推荐)
npm install ai @ai-sdk/openai
省 80% 模板代码:
// app/api/chat/route.ts
import { streamText } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
const openai = createOpenAI({
apiKey: process.env.YOTRADE_KEY,
baseURL: "https://yotradeapi.com/v1",
});
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("claude-sonnet-4-6"),
messages,
});
return result.toDataStreamResponse();
}
// React
"use client";
import { useChat } from "ai/react";
export function Chat() {
const { messages, input, handleInputChange, handleSubmit, isLoading, stop } = useChat();
return (
<>
{messages.map((m) => <div key={m.id}>{m.content}</div>)}
<form onSubmit={handleSubmit}>
<input value={input} onChange={handleInputChange} />
{isLoading ? <button onClick={stop}>Stop</button> : <button>Send</button>}
</form>
</>
);
}
abort、错误处理、tool call 都内置。
五、Vue / Nuxt 实现
<script setup>
const messages = ref([])
const pending = ref('')
const loading = ref(false)
let controller = null
async function send(input) {
messages.value.push({ role: 'user', content: input })
loading.value = true
pending.value = ''
controller = new AbortController()
const res = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ messages: messages.value }),
signal: controller.signal,
})
const reader = res.body.getReader()
const decoder = new TextDecoder()
let acc = '', buf = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop()
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const p = line.slice(6)
if (p === '[DONE]') break
try {
const obj = JSON.parse(p)
if (obj.delta) {
acc += obj.delta
pending.value = acc
}
} catch {}
}
}
messages.value.push({ role: 'assistant', content: acc })
pending.value = ''
loading.value = false
}
</script>
六、SvelteKit
// +server.ts
export async function POST({ request }) {
const { messages } = await request.json();
const upstream = await fetch("https://yotradeapi.com/v1/chat/completions", { ... });
return new Response(upstream.body, {
headers: { "content-type": "text/event-stream" },
});
}
Svelte 直接代理 SSE,前端用 fetch + reader 模式同上。
七、Markdown 渐进渲染
流式 Markdown 渲染容易:
- 表格只渲染一半时格式错乱
- 代码块未闭合
- 链接
]()半截
解决方案:用 streaming-markdown 之类库,或者:
// 渲染时简单兜底
function safeMarkdown(s: string) {
// 把未闭合的 ``` 补上
const backticks = (s.match(/```/g) || []).length;
if (backticks % 2 === 1) s += "\n```";
return s;
}
八、思考状态
如果用了 extended thinking(Claude):
{
type: "thinking_delta",
thinking: "让我想想..."
}
{
type: "text_delta",
text: "答案是..."
}
UI 分两段:折叠的”思考过程”+ 显示的”答案”。用户体验更透明。
九、Tool Call 可视化
Agent 模式下 UI 可以显示当前在干什么:
{toolCalls.map(tc => (
<div key={tc.id} className="tool-call">
🔧 调用 {tc.name}({JSON.stringify(tc.args)})
{tc.result ? <pre>{tc.result}</pre> : <span>...</span>}
</div>
))}
让用户看到 agent 在干嘛,减少”卡了几十秒不知道在干什么”的焦虑。
十、错误恢复
try {
// 流式调用
} catch (err) {
if (acc) {
// 已有部分输出,保留 + 标记中断
addMessage({ role: "assistant", content: acc + " [中断]" });
} else {
// 完全没输出
showError("LLM 调用失败,请重试");
}
}
最差的体验是”用户等了 30 秒然后什么都没有”。部分输出也要保留。
十一、Abort 优雅处理
用户点 Stop 时:
controller.abort()
但服务端可能还在跑(如果 fetch 已开始)。服务端 Route Handler 要监听 client disconnect:
export async function POST(req: Request) {
req.signal.addEventListener("abort", () => {
// 关闭上游 LLM stream
upstreamController.abort();
});
// ...
}
不然客户端断了,服务器还在烧 token。
十二、相关阅读
UI 接 YoTradeApi 中转,按上面的模板服务端代理即可。