第一部分:如何使用 T3 Turbo & Gemini 构建 Chatbot

很多创始人都被困在“配置地狱”里。我只花了一个下午就构建了一个完全类型安全的 AI 聊天机器人。这是我的具体技术栈——Next.js、tRPC 和 Gemini——代码都在这儿,你也可以自己动手试试。

第一部分:如何使用 T3 Turbo & Gemini 构建 Chatbot
Feng LiuFeng Liu
2025年12月12日

复杂度是早期初创公司的隐形杀手。你最初的想法很简单——“我想做一个说话像钢铁侠的聊天机器人”——结果三周后,你还在配置 Webpack,跟 Docker 容器搏斗,或者调试一个根本还没人用的身份验证流程。

这是一个我见过无数才华横溢的工程师反复掉进的陷阱。我们热爱工具。我们热爱优化。但在创业这场游戏中,发布(Shipping)是唯一重要的指标

如果你最近没关注现代 TypeScript 生态系统,你可能会大吃一惊。那个东拼西凑各种 API 还要祈祷它们能跑通的日子,很大程度上已经过去了。我们已经进入了 "Vibe Coder"(氛围编码者)的时代——从想法到产品上线的距离,现在是用小时来计算,而不是用冲刺(Sprints)来计算。

今天,我要带你体验一套感觉像是开了“作弊码”的技术栈:Create T3 Turbo 结合 Google Gemini AI。它实现了从数据库到前端的全链路类型安全,速度快得离谱,老实说,它让写代码重新变得有趣起来。

为什么选择这套技术栈?

你可能会想:“刘枫,为什么要搞一套新栈?我不能直接用 Python 和 Streamlit 吗?”

当然可以,如果你只是做个原型的话。但如果你是在构建一个产品——一个需要扩展、处理用户请求并维护状态的东西——你需要一个真正的架构。问题在于,“真正的架构”通常意味着“几周的样板代码”。

T3 技术栈 (Next.js, tRPC, Tailwind) 彻底改变了剧本。它给了你全栈应用的健壮性,同时拥有脚本语言般的开发速度。当你加上 Drizzle ORM(轻量级,类 SQL)和 Google Gemini(快速,免费额度大方),你就拥有了一套能让独立创始人单挑十人团队的工具包。

来吧,我们做点真东西。

第一步:一键配置

忘了手动配置 ESLint 和 Prettier 吧。我们要使用 create-t3-turbo。它会设置一个 Monorepo(单体仓库)结构,这非常完美,因为它将你的 API 逻辑与 Next.js 前端分离开来,为你未来不可避免地要开发 React Native 移动端应用做好了铺垫。

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 路由 (共享逻辑)
│   ├── db/               # 数据库 Schema + Drizzle
│   └── ui/               # 共享组件

这种分离意味着你的 API 逻辑可以在 Web、移动端甚至 CLI 应用之间复用。我见过有些团队因为一开始把所有东西都塞在一个文件夹里,后来不得不浪费几个月的时间来重构。

第二步:大脑 (Gemini)

OpenAI 固然好,但你试过 Gemini Flash 吗?它快得惊人,而且定价极具攻击性。对于聊天界面来说,延迟会毁掉体验,所以速度就是功能。

为什么选 Gemini Flash 而不是 GPT-3.5/4?

  • 速度:响应时间约 800ms vs 2-3s
  • 成本:比 GPT-4 便宜 60 倍
  • 上下文:100 万 token 的上下文窗口(是的,一百万)

我们需要 AI SDK 来标准化与大语言模型的交互。

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 Key。它的免费层级大方得离谱——每分钟 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";

// 聊天机器人的消息表
export const Message = pgTable("message", (t) => ({
  id: t.integer().primaryKey().generatedAlwaysAsIdentity(),
  role: t.varchar({ length: 20 }).notNull(), // 'user' 或 'assistant'
  content: t.text().notNull(),
  createdAt: t.timestamp().defaultNow().notNull(),
}));

// 根据表定义自动生成的 Zod schema
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,你会看到一个 Web 界面出现在 https://local.drizzle.studio,你的 message 表就静静地躺在那里,准备接收数据。

第四步:神经系统 (tRPC)

这通常是让大家大吃一惊的部分。使用 REST 或 GraphQL,你必须分别定义端点、类型和提取器。使用 tRPC,你的后端函数就是你的前端函数。

我们要创建一个过程(procedure),用于保存用户的消息,抓取历史记录(在 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. 保存用户消息
      await ctx.db
        .insert(Message)
        .values({ role: "user", content: input.content });

      // 2. 获取上下文 (最近10条消息)
      const history = await ctx.db
        .select()
        .from(Message)
        .orderBy(desc(Message.createdAt))
        .limit(10);

      // 3. 询问 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. 保存 AI 回复
      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注册路由

import { chatRouter } from "./router/chat";
import { createTRPCRouter } from "./trpc";

export const appRouter = createTRPCRouter({
  chat: chatRouter,
});

export type AppRouter = typeof appRouter;

看看这个流程。它是线性的、可读的,并且完全类型化的。如果你更改了数据库 Schema,这段代码会立即变红。没有运行时的意外惊喜。

为什么要用 .reverse() 我们按降序查询消息(最新的在前),但大语言模型期望按时间顺序(最旧的在前)。这是一个微小的细节,但能避免对话逻辑混乱。

第五步:界面

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();

  // 自动数据获取与缓存
  const { data: messages } = useQuery(trpc.chat.getMessages.queryOptions());

  // 带有乐观更新的 Mutation
  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);
      },
    }),
  );

  // 自动滚动到最新消息
  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">
      {/* 头部 */}
      <div className="border-b bg-white p-4">
        <h1 className="text-xl font-bold">AI Chat</h1>
      </div>

      {/* 消息列表 */}
      <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>

      {/* 输入框 */}
      <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)

一个平庸的助手是无聊的。一个平庸的助手注定被卸载。大语言模型的美妙之处在于它们是出色的角色扮演者。

我发现,给你的机器人一个鲜明的观点会让它的互动性提高 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 }) => {
      // 选择个性
      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, // ← 动态 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();
    }),

  // ... 其余部分保持不变
};

更新前端以传递角色选择:

// 在 ChatPage 组件中,添加角色状态
const [character, setCharacter] = useState<"default" | "luffy" | "stark">("default");

// 更新 mutation 调用
sendMsg.mutate({ content: input.trim(), character });

// 在输入框前添加一个下拉菜单:
<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, // 控制成本
  // ...
});

数据库连接池

本地 Postgres 用于开发没问题。对于生产环境,使用 Vercel PostgresSupabase。它们会自动处理连接池。Serverless + 数据库连接是一个陷阱——别自己去管理它。

实战心得

如果你读到这里,手痒想写代码了,这是我的建议:

  1. 不要从零开始。 样板代码是动力的敌人。使用 T3 Turbo 或类似的脚手架。
  2. 类型安全就是速度。 第一个小时感觉比较慢,但在接下来的十年里会更快。它能捕捉到那些通常在演示期间才会出现的 Bug。
  3. 上下文为王。 没有历史记录的聊天机器人只是一个花哨的搜索栏。永远记得把最近几条消息传给 LLM。
  4. 个性 > 功能。 一个说话像钢铁侠的机器人,比一个多出 10 个功能但平庸的机器人更能获得用户互动。

混乱的现实

构建这个过程并非一帆风顺。我最初搞砸了数据库连接字符串,花了 20 分钟纳闷为什么 Drizzle 冲我大喊大叫。我还触发了 Gemini 的速率限制,因为我一开始发送了太多的历史记录(教训:总是先从 .limit(5) 开始,然后再扩展)。

那个加载动画?我试了三次才搞对,因为 CSS 动画在 2024 年不知为何仍然像是黑魔法。

但关键在于:因为我使用的是一个健壮的技术栈,这些都是逻辑问题,而不是结构问题。地基很稳固。我从未因为选错了抽象层而不得不重构整个 API。

上线吧 (Ship It)

我们正生活在构建者的黄金时代。工具强大,AI 聪明,而且准入门槛从未如此之低。

你现在有了代码。有了技术栈。也理解了权衡。

去构建一些本不该存在的东西,然后在晚饭前上线。

总构建时间:~2 小时 实际代码行数:~200 生产环境中遇到的 Bug:0 (目前为止)

T3 栈 + Gemini 不仅仅是快——它是那种最好的无聊。没有惊喜。没有“在我机器上能跑”。只有构建。

编码愉快。


资源:

完整代码github.com/giftedunicorn/my-chatbot

分享

Feng Liu

Feng Liu

shenjian8628@gmail.com

第一部分:如何使用 T3 Turbo & Gemini 构建 Chatbot | 刘枫