最近幫這個部落格加了一個 AI 聊天助手,用右下角的浮動按鈕呈現,點擊就能展開聊天視窗。 這篇文章記錄整個實作過程,包含選擇 Groq 的原因、Vercel AI SDK 在整個架構中扮演什麼角色,以及實作細節。
套件安裝
npm install ai @ai-sdk/groq @ai-sdk/react這三個套件各自的職責:
| 套件 | 版本 | 用途 |
|---|---|---|
ai | v6 | 核心:streamText、型別定義、server utilities |
@ai-sdk/groq | — | Groq provider(包裝 llama-3.3-70b-versatile 等模型) |
@ai-sdk/react | v3 | React hooks:useChat |
注意:
aiv6 搭配@ai-sdk/reactv3,這兩個版本號是獨立的,不要混淆。
Vercel AI SDK 是什麼
簡單說,它是一個整合各家 AI 模型平台的統一工具層。
Groq、OpenAI、Anthropic 各自都有 REST API,但格式和細節不太一樣。如果直接用 fetch 呼叫,除了要自己對接各家格式,還要處理 streaming 回應的解析邏輯:
// 直接呼叫 API 的話,stream 這段要自己處理
const res = await fetch("https://api.groq.com/openai/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.GROQ_API_KEY}`, ... },
body: JSON.stringify({ model: "llama-3.3-70b-versatile", messages, stream: true }),
});
// 之後要自己逐行解析 SSE、去掉 `data:` 前綴、偵測 `[DONE]` 結束...用了 SDK 之後,同樣的事情變成:
const result = streamText({ model: groq("llama-3.3-70b-versatile"), messages });
return result.toUIMessageStreamResponse();SDK 在背後處理掉的事情:
- Server 端:stream 解析、錯誤處理、正確的 response headers
- Client 端:
useChathook 負責管理整個對話狀態,包含訊息列表和 loading 狀態 - 統一介面:Groq、OpenAI、Anthropic 都用同一套寫法呼叫,想換平台只要換
model: openai("gpt-4o")這一行,其他程式碼完全不動
為什麼選 Groq(免費)
Groq 對個人開發者非常友好:
- 完全免費:每天有 rate limit(每分鐘幾百個請求),對 demo 和學習完全夠用
- 不需要信用卡:用 GitHub 或 Google 帳號登入就能拿到 API Key
- 速度極快:Groq 用自研的 LPU 硬體,inference 速度比 OpenAI 快很多
取得 Groq API Key
前往 console.groq.com,用 GitHub 或 Google 帳號登入(不需要信用卡):

登入後,點擊右上方的 API Keys:

點擊 Create API Key,幫這把 key 取一個識別用的名稱後送出:

Key 只會顯示這一次,記得馬上複製起來,它是 gsk_ 開頭的字串。
設定環境變數
拿到 key 之後,在專案根目錄建立 .env.local 並貼進去:
GROQ_API_KEY=gsk_你的key.env.local 預設被 git 忽略,key 不會被 push 出去。createGroq() 會自動讀取這個環境變數,不需要手動傳入。
API Route:Server-side Streaming
在 app/api/chat/route.ts 建立 POST endpoint:
import { createGroq } from "@ai-sdk/groq";
import { streamText, convertToModelMessages } from "ai";
const groq = createGroq();
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: groq("llama-3.3-70b-versatile"),
system: `你是這個技術部落格的 AI 助手。
請用繁體中文回答,語氣友善、回答簡潔。`,
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse();
}幾個要注意的地方:
await convertToModelMessages(messages):前端送來的訊息格式跟 AI model 接受的格式不同,這個函式負責轉換,而且是非同步的,要記得加 await。
toUIMessageStreamResponse():把結果包成 streaming Response 回傳給前端,讓回應可以邊生成邊傳、而不是等全部完成才送出。
React Component:useChat Hook
useChat 是 @ai-sdk/react 提供的 hook,專門用來處理 AI 聊天的 client 端狀態:它幫你維護訊息列表、追蹤目前是 loading 還是 streaming、接收 server 回傳的 stream chunks 並即時把內容更新到畫面上。
建立 components/AiChatBox.tsx:
"use client";
import { useChat } from "@ai-sdk/react";
import { isTextUIPart } from "ai";
import { useEffect, useRef, useState } from "react";
export default function AiChatBox() {
const { messages, sendMessage, status, stop, error } = useChat();
const [input, setInput] = useState("");
const isLoading = status === "submitted" || status === "streaming";
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage({ text: input });
setInput("");
};
return (
<div className="flex flex-col h-[480px] ...">
{/* 訊息列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((m) => {
const text = m.parts
.filter(isTextUIPart)
.map((p) => p.text)
.join("");
return (
<div key={m.id} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
<div className="...">{text}</div>
</div>
);
})}
{/* 等待動畫 */}
{status === "submitted" && <LoadingDots />}
<div ref={bottomRef} />
</div>
{/* 輸入區 */}
<form onSubmit={handleSubmit} className="...">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
/>
{isLoading ? (
<button type="button" onClick={stop}>停止</button>
) : (
<button type="submit" disabled={!input.trim()}>送出</button>
)}
</form>
</div>
);
}訊息格式:UIMessage
useChat 的 messages 是 UIMessage[],每則訊息有 parts 陣列:
{
id: "abc",
role: "assistant",
parts: [
{ type: "text", text: "你好,有什麼可以幫忙的?" }
]
}parts 是陣列設計,是因為同一則訊息可以包含不只文字,未來也能放圖片、工具呼叫、推理過程等。目前純聊天的情境只需要取出 type: "text" 的部分,isTextUIPart 就是專門用來過濾它的 type guard。
ChatStatus 的四個狀態
useChat 的 status 可以是這四種之一:
ready → submitted → streaming → ready
↓
error
ready:閒置,可以送出新訊息submitted:已送出,等待 server 開始回應(這時顯示 loading dots)streaming:server 正在回傳 stream chunks(訊息在即時增長)error:發生錯誤
我定義 isLoading = status === "submitted" || status === "streaming",這個條件下 input 停用、按鈕換成「停止」。
部署到 Vercel
.env.local 不會被 push,deploy 之前要在 Vercel 補上環境變數:進到專案的 Settings → Environment Variables,新增 GROQ_API_KEY 並填入 key,Production 和 Preview 都勾起來,儲存後 Redeploy 一次。

小結
整個功能的資料流:
使用者輸入
↓
sendMessage({ text }) ← @ai-sdk/react
↓
POST /api/chat ← 帶著 UIMessage[]
↓
convertToModelMessages() ← UIMessage → ModelMessage(await!)
↓
streamText({ model, messages }) ← ai
↓
toUIMessageStreamResponse() ← 開始 stream 回傳
↓
useChat 接收 chunks,更新 messages
↓
m.parts.filter(isTextUIPart) ← 取出文字顯示
整體來說 Vercel AI SDK 把最繁瑣的部分都藏起來了,讓你只需要關心三件事:選哪個模型、給什麼 system prompt、UI 怎麼呈現。 實際效果可以直接用右下角的 🤖 按鈕試試看。