Part 1:如何用 T3 Turbo 與 Gemini 打造聊天機器人

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

Part 1:如何用 T3 Turbo 與 Gemini 打造聊天機器人
Feng LiuFeng Liu
2025年12月12日

複雜性是早期新創公司的隱形殺手。你從一個簡單的想法開始——「我想要一個說話像鋼鐵人 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.jstRPCPostgreSQL。我暫時跳過了 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 PostgresSupabase。它們會自動處理連線池。Serverless + 資料庫連線是一個陷阱——不要自己管理它。

實用建議

如果你讀到這裡並且手癢想寫程式,這是我的建議:

  1. 不要從零開始。 樣板程式碼是動能的敵人。使用 T3 Turbo 或類似的鷹架。
  2. 型別安全就是速度。 第一個小時感覺比較慢,但接下來的十年會比較快。它能捕捉到通常在 Demo 時才會出現的 Bug。
  3. 上下文是關鍵。 沒有歷史記錄的聊天機器人只是一個花俏的搜尋欄。永遠要把最後幾則訊息傳給 LLM。
  4. 個性 > 功能。 一個聽起來像 Tony Stark 的機器人,會比一個多了 10 個額外功能的通用機器人獲得更多互動。

混亂的現實

建立這個過程並非一帆風順。我一開始搞砸了資料庫連線字串,花了 20 分鐘納悶為什麼 Drizzle 對我大吼大叫。我還觸發了 Gemini 的速率限制,因為我一開始傳送了太多歷史記錄 (教訓:永遠先從 .limit(5) 開始,然後再擴展)。

那個載入動畫?我試了三次才弄對,因為 CSS 動畫在 2024 年依然像是某種黑魔法。

但重點是:因為我使用的是一個穩健的技術堆疊,這些都是邏輯問題,而不是結構問題。地基非常穩固。我從不需要因為選錯了抽象層而重構整個 API。

發布它 (Ship It)

我們正生活在一個構建產品的黃金時代。工具很強大,AI 很聰明,而且進入門檻從未如此之低。

你現在有了程式碼。你有了技術堆疊。你了解了權衡取捨。

去打造一些本不該存在的東西,並在晚餐前發布它。

總構建時間:~2 小時 實際寫的程式碼行數:~200 生產環境遇到的 Bug:0 (目前為止)

T3 Stack + Gemini 不僅僅是快——它是以最好的方式讓人感到無聊。沒有驚喜。沒有「在我的機器上可以跑」。就只是專注於構建。

祝寫程式愉快。


資源:

完整程式碼github.com/giftedunicorn/my-chatbot

分享

Feng Liu

Feng Liu

shenjian8628@gmail.com