Part 1:如何用 T3 Turbo 與 Gemini 打造聊天機器人
許多創辦人都深陷於「環境設定地獄」。我僅用一個下午,就構建了一個具備完整型別安全(Type-safe)的 AI 聊天機器人。這裡公開我的完整技術堆疊——Next.js、tRPC 和 Gemini——並附上程式碼,讓你也能親手打造。

複雜性是早期新創公司的隱形殺手。你從一個簡單的想法開始——「我想要一個說話像鋼鐵人 Tony Stark 的聊天機器人」——結果三週後,你還在設定 Webpack,跟 Docker 容器搏鬥,或者在除錯一個根本還沒人用的身份驗證流程。
這是一個陷阱,我看過無數才華洋溢的工程師一次又一次地掉進去。我們熱愛工具。我們熱愛優化。但在新創這場遊戲中,「發布產品 (Shipping)」是唯一重要的指標。
如果你最近沒有關注現代 TypeScript 生態系,你可能會感到驚訝。那個需要拼湊各種不相干 API 並祈禱它們能協同工作的日子已經大致過去了。我們已經進入了「Vibe Coder (感覺流開發者)」的時代——在這裡,從想法到產品上線的距離是用小時計算,而不是用衝刺 (Sprints) 計算。
今天,我要帶你走一遍這套感覺像是「開外掛」的技術堆疊:Create T3 Turbo 結合 Google 的 Gemini AI。它從資料庫到前端都是型別安全 (Type-safe) 的,速度快得離譜,說實話,它讓寫程式重新變得有趣。
為什麼選擇這個技術堆疊?
你可能會想:「劉峰,為什麼又要換一個堆疊?我不能直接用 Python 和 Streamlit 嗎?」
當然可以,如果你只是做個原型 (Prototype)。但如果你是在打造一個產品——一個需要擴展、處理用戶並維護狀態的東西——你需要一個真正的架構。問題在於,「真正的架構」通常意味著「好幾週的樣板程式碼 (Boilerplate)」。
T3 Stack (Next.js, tRPC, Tailwind) 翻轉了這個劇本。它給了你全端應用程式的穩健性,同時擁有腳本語言的開發速度。當你加上 Drizzle ORM (輕量級、類 SQL) 和 Google Gemini (快速、免費額度大方),你就擁有了一套能讓單人創辦人勝過十人團隊的工具箱。
讓我們來打造一些真實的東西吧。
第一步:一行指令搞定設定
忘掉手動設定 ESLint 和 Prettier 吧。我們要使用 create-t3-turbo。這會建立一個 Monorepo (單一儲存庫) 結構,這非常完美,因為它將你的 API 邏輯與 Next.js 前端分開,為你未來不可避免地要推出 React Native 手機 App 預先做好了準備。
pnpm create t3-turbo@latest my-chatbot
cd my-chatbot
pnpm install
當被問到選項時,我選擇了 Next.js、tRPC 和 PostgreSQL。我暫時跳過了 Auth (身份驗證),因為再次強調,我們的目標是優化發布速度,而不是追求完美。你可以在十分鐘後再加入 NextAuth。
你得到的 Monorepo 結構如下:
my-chatbot/
├── apps/nextjs/ # 你的 Web 應用程式
├── packages/
│ ├── api/ # tRPC routers (共用邏輯)
│ ├── db/ # 資料庫 Schema + Drizzle
│ └── ui/ # 共用元件
這種分離意味著你的 API 邏輯可以在網頁、手機甚至 CLI 應用程式之間重複使用。我看過有些團隊浪費了幾個月的時間重構,只因為他們一開始把所有東西都塞在一個資料夾裡。
第二步:大腦 (Gemini)
OpenAI 很棒,但你試過 Gemini Flash 嗎?它快得驚人,而且定價極具侵略性。對於一個延遲會破壞體驗 (Vibe) 的聊天介面來說,速度就是功能。
為什麼選 Gemini Flash 而不是 GPT-3.5/4?
- 速度:約 800ms vs 2-3秒 的回應時間
- 成本:比 GPT-4 便宜 60 倍
- 上下文:100 萬 token 的上下文視窗 (沒錯,一百萬)
我們需要 AI SDK 來標準化與 LLM 的溝通。
cd packages/api
pnpm add ai @ai-sdk/google
在專案根目錄設定你的 .env。本地開發時別在資料庫上想太多;一個本地的 Postgres 實例就夠了。
POSTGRES_URL="postgresql://user:pass@localhost:5432/chatbot"
GOOGLE_GENERATIVE_AI_API_KEY="your_key_here"
專家提示:從 https://aistudio.google.com/app/apikey 獲取你的 Gemini API 金鑰。免費層級大方得荒謬——每分鐘 60 次請求。在你達到速率限制之前,你早就已經找到產品市場契合度 (PMF) 了。
第三步:定義現實 (Schema)
這就是 Drizzle 發光發熱的地方。在舊時代,你得手寫遷移 (migrations) 檔案。現在,你用 TypeScript 定義你的 Schema,資料庫就會乖乖聽話。
在 packages/db/src/schema.ts 中,我們定義什麼是「訊息 (Message)」。注意到我們使用了 drizzle-zod 嗎?這會自動為我們的 API 建立驗證 Schema。這就是「不要重複自己 (DRY)」原則的實際應用。
import { pgTable } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
// Message table for chatbot
export const Message = pgTable("message", (t) => ({
id: t.integer().primaryKey().generatedAlwaysAsIdentity(),
role: t.varchar({ length: 20 }).notNull(), // 'user' or 'assistant'
content: t.text().notNull(),
createdAt: t.timestamp().defaultNow().notNull(),
}));
// Zod schema auto-generated from table definition
export const CreateMessageSchema = createInsertSchema(Message, {
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(10000),
}).omit({ id: true, createdAt: true });
推送它:pnpm db:push。搞定。你的資料庫現在存在了。
剛剛發生了什麼? Drizzle 查看了你的 TypeScript 定義並建立了資料表。沒寫 SQL。沒有遷移檔案。這就是模式驅動開發 (Schema-driven development) 的魔法。
如果你想驗證,執行:pnpm db:studio,你會在 https://local.drizzle.studio 看到一個網頁介面,你的 message 資料表正坐在那裡,準備接收資料。
第四步:神經系統 (tRPC)
這通常是讓人腦洞大開的部分。使用 REST 或 GraphQL 時,你必須分別定義端點、型別和 Fetcher。使用 tRPC,你的後端函式就是你的前端函式。
我們正在建立一個程序,它會儲存使用者的訊息,抓取歷史記錄 (在 AI 中上下文為王),將其發送給 Gemini,並儲存回覆。
建立 packages/api/src/router/chat.ts:
import type { TRPCRouterRecord } from "@trpc/server";
import { google } from "@ai-sdk/google";
import { generateText } from "ai";
import { z } from "zod/v4";
import { desc } from "@acme/db";
import { Message } from "@acme/db/schema";
import { publicProcedure } from "../trpc";
const SYSTEM_PROMPT = "You are a helpful AI assistant.";
export const chatRouter = {
sendChat: publicProcedure
.input(z.object({ content: z.string().min(1).max(10000) }))
.mutation(async ({ ctx, input }) => {
// 1. Save User Message
await ctx.db
.insert(Message)
.values({ role: "user", content: input.content });
// 2. Get Context (Last 10 messages)
const history = await ctx.db
.select()
.from(Message)
.orderBy(desc(Message.createdAt))
.limit(10);
// 3. Ask Gemini
const { text } = await generateText({
model: google("gemini-1.5-flash"),
system: SYSTEM_PROMPT,
messages: history.reverse().map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
});
// 4. Save AI Reply
return await ctx.db
.insert(Message)
.values({ role: "assistant", content: text })
.returning();
}),
getMessages: publicProcedure.query(({ ctx }) =>
ctx.db.select().from(Message).orderBy(Message.createdAt),
),
clearMessages: publicProcedure.mutation(({ ctx }) => ctx.db.delete(Message)),
} satisfies TRPCRouterRecord;
在 packages/api/src/root.ts 中註冊 router:
import { chatRouter } from "./router/chat";
import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
chat: chatRouter,
});
export type AppRouter = typeof appRouter;
看看那個流程。它是線性的、可讀的,而且完全型別化。如果你更改資料庫 Schema,這段程式碼會立即變紅。沒有執行時期的驚喜。
為什麼要用 .reverse()? 我們按降序查詢訊息 (最新的在前),但 LLM 預期的是時間順序 (最舊的在前)。這是一個微小的細節,但能避免對話變得混亂。
第五步:介面
在 apps/nextjs/src/app/chat/page.tsx 中,我們把它接起來。因為我們使用 tRPC,我們免費獲得了 React Query。useQuery 處理獲取、快取和載入狀態,我們不需要為了獲取資料寫任何一個 useEffect。
(我只包含了一個 useEffect 用於滾動到底部——因為使用者體驗很重要)。
"use client";
import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { RouterOutputs } from "@acme/api";
import { useTRPC } from "~/trpc/react";
export default function ChatPage() {
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const endRef = useRef<HTMLDivElement>(null);
const trpc = useTRPC();
const queryClient = useQueryClient();
// Automatic data fetching with caching
const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());
// Mutation with optimistic updates
const sendMsg = useMutation(
trpc.chat.sendChat.mutationOptions({
onSuccess: async () => {
await queryClient.invalidateQueries(trpc.chat.pathFilter());
setInput("");
setLoading(false);
},
onError: (err) => {
console.error("Failed:", err);
setLoading(false);
},
}),
);
// Auto-scroll to latest message
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || loading) return;
setLoading(true);
sendMsg.mutate({ content: input.trim() });
};
return (
<div className="flex h-screen flex-col bg-gray-50">
{/* Header */}
<div className="border-b bg-white p-4">
<h1 className="text-xl font-bold">AI Chat</h1>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
<div className="mx-auto max-w-4xl space-y-4">
{messages?.map((m: RouterOutputs["chat"]["getMessages"][number]) => (
<div key={m.id} className={m.role === "user" ? "text-right" : ""}>
<div
className={`inline-block rounded-2xl px-4 py-3 ${
m.role === "user"
? "bg-blue-500 text-white"
: "bg-white border shadow-sm"
}`}
>
<p className="whitespace-pre-wrap">{m.content}</p>
</div>
</div>
))}
{loading && (
<div className="flex gap-2">
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.2s]" />
<div className="h-2 w-2 animate-bounce rounded-full bg-gray-400 [animation-delay:0.4s]" />
</div>
)}
<div ref={endRef} />
</div>
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="border-t bg-white p-4">
<div className="mx-auto flex max-w-4xl gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
className="flex-1 rounded-lg border px-4 py-3 focus:ring-2 focus:ring-blue-500 focus:outline-none"
disabled={loading}
/>
<button
type="submit"
disabled={!input.trim() || loading}
className="rounded-lg bg-blue-500 px-6 py-3 font-medium text-white hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed"
>
Send
</button>
</div>
</form>
</div>
);
}
別忘了首頁。更新 apps/nextjs/src/app/page.tsx:
import Link from "next/link";
export default function HomePage() {
return (
<main className="flex min-h-screen items-center justify-center bg-gradient-to-b from-blue-500 to-blue-700">
<div className="text-center text-white">
<h1 className="text-5xl font-bold">AI Chatbot</h1>
<p className="mt-4 text-xl">Built with T3 Turbo + Gemini</p>
<Link
href="/chat"
className="mt-8 inline-block rounded-full bg-white px-10 py-3 font-semibold text-blue-600 hover:bg-gray-100 transition"
>
Start Chatting
</Link>
</div>
</main>
);
}
執行 pnpm dev 並訪問 http://localhost:3000。點擊 "Start Chatting",你就擁有一個運作中的 AI 聊天機器人了。
tRPC 的魔法:注意到我們從未寫過 API fetch 嗎?沒有 fetch() 呼叫,沒有 URL 字串,沒有手動錯誤處理。TypeScript 知道 sendMsg.mutate() 預期什麼。如果你更改後端輸入 Schema,你的前端會拋出編譯錯誤。這就是未來。
第六步:注入靈魂 (Vibe Check)
一個通用的助理很無聊。一個通用的助理會被刪除。LLM 的美妙之處在於它們是非常優秀的角色扮演者。
我發現賦予你的機器人強烈的觀點會讓它的互動性提高 10 倍。不要只是提示「你是個有用的助手」。要提示一個個性。
讓我們修改後端以接受角色設定。更新 packages/api/src/router/chat.ts:
const PROMPTS = {
default: "You are a helpful AI assistant. Be concise and clear.",
luffy:
"You are Monkey D. Luffy from One Piece. You're energetic, optimistic, love meat and adventure. You often say 'I'm gonna be King of the Pirates!' Speak simply and enthusiastically.",
stark:
"You are Tony Stark (Iron Man). You're a genius inventor, witty, and sarcastic. You love technology and often mention Stark Industries. Call people 'kid' or 'buddy'. Be charming but arrogant.",
};
export const chatRouter = {
sendChat: publicProcedure
.input(
z.object({
content: z.string().min(1).max(10000),
character: z.enum(["default", "luffy", "stark"]).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
// Pick the personality
const systemPrompt = PROMPTS[input.character || "default"];
await ctx.db
.insert(Message)
.values({ role: "user", content: input.content });
const history = await ctx.db
.select()
.from(Message)
.orderBy(desc(Message.createdAt))
.limit(10);
const { text } = await generateText({
model: google("gemini-1.5-flash"),
system: systemPrompt, // ← Dynamic prompt
messages: history.reverse().map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
});
return await ctx.db
.insert(Message)
.values({ role: "assistant", content: text })
.returning();
}),
// ... rest stays the same
};
更新前端以傳遞角色選擇:
// In ChatPage component, add state for character
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");
// Update the mutation call
sendMsg.mutate({ content: input.trim(), character });
// Add a dropdown before the input:
<select
value={character}
onChange={(e) => setCharacter(e.target.value as any)}
className="rounded-lg border px-3 py-2"
>
<option value="default">🤖 Default</option>
<option value="luffy">👒 Luffy</option>
<option value="stark">🦾 Tony Stark</option>
</select>
現在你不只是建立了一個聊天機器人;你建立了一個角色互動平台。這才是一個產品。
你真正關心的技術細節
為什麼不直接用 Prisma?
Prisma 很棒,但 Drizzle 更快。我們說的是 2-3 倍的查詢效能。當你是單人創辦人時,每一毫秒都在複利。加上 Drizzle 類似 SQL 的語法,意味著較少的心智負擔。
關於串流回應 (Streaming) 呢?
Vercel AI SDK 開箱即支援串流。將 generateText 替換為 streamText 並在前端使用 useChat hook。我在這裡跳過它是因為作為教學,請求/回應模式比較簡單。但在生產環境中?全部都要串流。即使總時間相同,使用者也會覺得串流「比較快」。
上下文視窗管理
目前我們抓取最後 10 則訊息。這在出問題之前都沒問題。如果你在打造一個嚴肅的產品,實作一個 Token 計數器並動態調整歷史記錄。AI SDK 有這方面的工具。
import { anthropic } from "@ai-sdk/anthropic";
const { text } = await generateText({
model: anthropic("claude-3-5-sonnet-20241022"),
maxTokens: 1000, // Control costs
// ...
});
資料庫連線池 (Connection Pooling)
本地 Postgres 對於開發來說沒問題。對於生產環境,使用 Vercel Postgres 或 Supabase。它們會自動處理連線池。Serverless + 資料庫連線是一個陷阱——不要自己管理它。
實用建議
如果你讀到這裡並且手癢想寫程式,這是我的建議:
- 不要從零開始。 樣板程式碼是動能的敵人。使用 T3 Turbo 或類似的鷹架。
- 型別安全就是速度。 第一個小時感覺比較慢,但接下來的十年會比較快。它能捕捉到通常在 Demo 時才會出現的 Bug。
- 上下文是關鍵。 沒有歷史記錄的聊天機器人只是一個花俏的搜尋欄。永遠要把最後幾則訊息傳給 LLM。
- 個性 > 功能。 一個聽起來像 Tony Stark 的機器人,會比一個多了 10 個額外功能的通用機器人獲得更多互動。
混亂的現實
建立這個過程並非一帆風順。我一開始搞砸了資料庫連線字串,花了 20 分鐘納悶為什麼 Drizzle 對我大吼大叫。我還觸發了 Gemini 的速率限制,因為我一開始傳送了太多歷史記錄 (教訓:永遠先從 .limit(5) 開始,然後再擴展)。
那個載入動畫?我試了三次才弄對,因為 CSS 動畫在 2024 年依然像是某種黑魔法。
但重點是:因為我使用的是一個穩健的技術堆疊,這些都是邏輯問題,而不是結構問題。地基非常穩固。我從不需要因為選錯了抽象層而重構整個 API。
發布它 (Ship It)
我們正生活在一個構建產品的黃金時代。工具很強大,AI 很聰明,而且進入門檻從未如此之低。
你現在有了程式碼。你有了技術堆疊。你了解了權衡取捨。
去打造一些本不該存在的東西,並在晚餐前發布它。
總構建時間:~2 小時 實際寫的程式碼行數:~200 生產環境遇到的 Bug:0 (目前為止)
T3 Stack + Gemini 不僅僅是快——它是以最好的方式讓人感到無聊。沒有驚喜。沒有「在我的機器上可以跑」。就只是專注於構建。
祝寫程式愉快。
資源:
分享

Feng Liu
shenjian8628@gmail.com