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

复杂度是早期初创公司的隐形杀手。你最初的想法很简单——“我想做一个说话像钢铁侠的聊天机器人”——结果三周后,你还在配置 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.js、tRPC 和 PostgreSQL。我暂时跳过了 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 Postgres 或 Supabase。它们会自动处理连接池。Serverless + 数据库连接是一个陷阱——别自己去管理它。
实战心得
如果你读到这里,手痒想写代码了,这是我的建议:
- 不要从零开始。 样板代码是动力的敌人。使用 T3 Turbo 或类似的脚手架。
- 类型安全就是速度。 第一个小时感觉比较慢,但在接下来的十年里会更快。它能捕捉到那些通常在演示期间才会出现的 Bug。
- 上下文为王。 没有历史记录的聊天机器人只是一个花哨的搜索栏。永远记得把最近几条消息传给 LLM。
- 个性 > 功能。 一个说话像钢铁侠的机器人,比一个多出 10 个功能但平庸的机器人更能获得用户互动。
混乱的现实
构建这个过程并非一帆风顺。我最初搞砸了数据库连接字符串,花了 20 分钟纳闷为什么 Drizzle 冲我大喊大叫。我还触发了 Gemini 的速率限制,因为我一开始发送了太多的历史记录(教训:总是先从 .limit(5) 开始,然后再扩展)。
那个加载动画?我试了三次才搞对,因为 CSS 动画在 2024 年不知为何仍然像是黑魔法。
但关键在于:因为我使用的是一个健壮的技术栈,这些都是逻辑问题,而不是结构问题。地基很稳固。我从未因为选错了抽象层而不得不重构整个 API。
上线吧 (Ship It)
我们正生活在构建者的黄金时代。工具强大,AI 聪明,而且准入门槛从未如此之低。
你现在有了代码。有了技术栈。也理解了权衡。
去构建一些本不该存在的东西,然后在晚饭前上线。
总构建时间:~2 小时 实际代码行数:~200 生产环境中遇到的 Bug:0 (目前为止)
T3 栈 + Gemini 不仅仅是快——它是那种最好的无聊。没有惊喜。没有“在我机器上能跑”。只有构建。
编码愉快。
资源:
分享

Feng Liu
shenjian8628@gmail.com