AI Chat 对话系统实战
上一章讲了 AI SDK 的 API。本章把它们组装成一个完整的对话系统——从数据库设计、消息持久化、上下文窗口管理、System Prompt 工程,到多轮对话的完整闭环。这是 AI SaaS 的核心功能模块。
1. 数据模型设计
对话系统的数据模型需要反映 LLM 的工作方式。LLM 的输入是一个 消息列表,每条消息有 role(角色)和 content(内容)。角色分三种:system(系统指令,设定 AI 的行为边界)、user(用户输入)、assistant(AI 回复)。多轮对话的本质就是把历史消息全部发给 AI,让它“记住”之前说了什么——AI 本身是无状态的,每次调用都是独立的,“记忆”完全靠传入的消息列表。
一个对话系统的数据结构:
User → Workspace → Chat → Message
↓
AI Model / System Prompt / Temperature
// src/db/schema.ts — 对话相关表
export const chats = pgTable('chats', {
id: uuid('id').defaultRandom().primaryKey(),
title: text('title').notNull().default('新对话'),
model: text('model').notNull().default('gpt-4o'),
systemPrompt: text('system_prompt'),
temperature: real('temperature').default(0.7),
workspaceId: uuid('workspace_id').references(() => workspaces.id).notNull(),
userId: uuid('user_id').references(() => users.id).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
export const messages = pgTable('messages', {
id: uuid('id').defaultRandom().primaryKey(),
chatId: uuid('chat_id').references(() => chats.id, { onDelete: 'cascade' }).notNull(),
role: text('role', { enum: ['user', 'assistant', 'system', 'tool'] }).notNull(),
content: text('content').notNull(),
toolCallId: text('tool_call_id'),
toolName: text('tool_name'),
inputTokens: integer('input_tokens'),
outputTokens: integer('output_tokens'),
createdAt: timestamp('created_at').defaultNow().notNull(),
})关键设计决策:
systemPrompt存在 Chat 级别:每个对话可以有不同的 System Prompt,而不是全局固定。这支持不同场景(代码助手、写作助手、翻译助手)使用不同的指令temperature存在 Chat 级别:用户可以调整创造性。temperature控制 AI 输出的随机性:0 表示确定性最强(每次都选概率最高的 Token),2 表示最随机。代码助手适合低温度(0.3),创意写作适合高温度(0.9)role包含tool:支持工具调用消息(第 35 章详讲)inputTokens/outputTokens分开存:AI 提供商按输入和输出分别计费,且输出 Token 通常比输入贵 3-5 倍,分开记录才能精确计算成本
2. 对话 API
2.1 创建对话
// actions/chat.ts
'use server'
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createChat(formData?: FormData) {
const { workspace } = await requireWorkspaceAccess()
const session = await requireAuth()
const title = formData?.get('title') as string | undefined
const [chat] = await db.insert(chats).values({
title: title || '新对话',
workspaceId: workspace.id,
userId: session.user.id,
}).returning()
revalidateTag('chats')
redirect(`/chat/${chat.id}`)
}2.2 加载对话历史
对话页面是 Server Component——直接查数据库加载历史消息,传给客户端 ChatUI:
// app/(dashboard)/chat/[id]/page.tsx
import { db } from '@/src/db'
import { chats, messages } from '@/src/db/schema'
import { ChatUI } from './chat-ui'
export default async function ChatPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const { workspace } = await requireWorkspaceAccess()
const chat = await db.query.chats.findFirst({
where: and(eq(chats.id, id), eq(chats.workspaceId, workspace.id)),
with: {
messages: { orderBy: asc(messages.createdAt) },
},
})
if (!chat) notFound()
// 转换为 AI SDK 的 Message 格式
const initialMessages = chat.messages.map((msg) => ({
id: msg.id,
role: msg.role as 'user' | 'assistant',
content: msg.content,
createdAt: msg.createdAt,
}))
return (
<ChatUI
chatId={chat.id}
initialMessages={initialMessages}
model={chat.model}
systemPrompt={chat.systemPrompt}
/>
)
}2.3 流式对话 API
Route Handler 负责调用 AI、保存消息:
// app/api/chat/route.ts
import { streamText } from 'ai'
import { getModel } from '@/lib/ai-models'
export async function POST(request: Request) {
const session = await auth()
if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 })
const { messages, chatId, model: modelId, systemPrompt } = await request.json()
// 1. 验证对话归属
const { workspace } = await requireWorkspaceAccess()
const chat = await db.query.chats.findFirst({
where: and(eq(chats.id, chatId), eq(chats.workspaceId, workspace.id)),
})
if (!chat) return Response.json({ error: 'Chat not found' }, { status: 404 })
// 2. 保存用户消息
const userMessage = messages[messages.length - 1]
await db.insert(dbMessages).values({
chatId,
role: 'user',
content: userMessage.content,
})
// 3. 调用 AI 模型
const result = streamText({
model: getModel(modelId || chat.model),
system: systemPrompt || chat.systemPrompt || '你是一个专业的 AI 助手。',
messages,
temperature: chat.temperature ?? 0.7,
maxTokens: 4096,
onFinish: async ({ text, usage }) => {
// 4. 保存 AI 回复
await db.insert(dbMessages).values({
chatId,
role: 'assistant',
content: text,
inputTokens: usage.promptTokens,
outputTokens: usage.completionTokens,
})
// 5. 更新对话时间
await db.update(chats)
.set({ updatedAt: new Date() })
.where(eq(chats.id, chatId))
// 6. 记录 Token 用量
await db.update(tokenQuotas)
.set({ used: sql`used + ${usage.totalTokens}` })
.where(eq(tokenQuotas.workspaceId, workspace.id))
},
})
return result.toDataStreamResponse()
}2.4 客户端 ChatUI
// app/(dashboard)/chat/[id]/chat-ui.tsx
'use client'
import { useChat, Message } from 'ai/react'
import { useRef, useEffect } from 'react'
import { Markdown } from '@/components/markdown'
interface ChatUIProps {
chatId: string
initialMessages: Message[]
model: string
systemPrompt: string | null
}
export function ChatUI({ chatId, initialMessages, model, systemPrompt }: ChatUIProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const {
messages, input, handleInputChange, handleSubmit,
isLoading, stop, reload, error,
} = useChat({
api: '/api/chat',
initialMessages,
body: { chatId, model, systemPrompt },
onError: (error) => {
toast.error('回复失败:' + error.message)
},
})
// 自动滚动到底部
useEffect(() => {
scrollRef.current?.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: 'smooth',
})
}, [messages])
return (
<div className="flex flex-col h-full">
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-6">
{messages.length === 0 && (
<div className="text-center text-gray-400 mt-20">
<p className="text-lg">开始一段新对话</p>
<p className="text-sm mt-2">输入你的问题,AI 会为你解答</p>
</div>
)}
{messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[80%] rounded-2xl px-4 py-3 ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-800'
}`}>
{msg.role === 'assistant' ? (
<Markdown content={msg.content} />
) : (
<p className="whitespace-pre-wrap">{msg.content}</p>
)}
</div>
</div>
))}
{isLoading && messages[messages.length - 1]?.role === 'user' && (
<div className="flex justify-start">
<div className="bg-gray-100 rounded-2xl px-4 py-3">
<span className="animate-pulse">思考中...</span>
</div>
</div>
)}
{error && (
<div className="text-center">
<p className="text-red-500 text-sm">{error.message}</p>
<button onClick={() => reload()} className="text-blue-500 text-sm mt-1">
重新生成
</button>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="p-4 border-t flex gap-2">
<textarea
value={input}
onChange={handleInputChange}
placeholder="输入消息... (Enter 发送,Shift+Enter 换行)"
className="flex-1 resize-none border rounded-xl px-4 py-3 max-h-32"
rows={1}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e as any)
}
}}
/>
{isLoading ? (
<button type="button" onClick={stop} className="self-end px-4 py-3 bg-red-500 text-white rounded-xl">
停止
</button>
) : (
<button type="submit" disabled={!input.trim()} className="self-end px-4 py-3 bg-blue-600 text-white rounded-xl disabled:opacity-50">
发送
</button>
)}
</form>
</div>
)
}3. 上下文窗口管理
3.1 问题:什么是 Token 和 Context Window
先理解两个核心概念:
Token 是 LLM 处理文本的最小单位。它不是字,也不是词,而是模型词表中的一个片段。英文中,一个常见单词通常是 1 个 Token,较长的词会被拆成多个("programming" → "program" + "ming")。中文中,一个汉字通常是 1-2 个 Token。粗略估算:1 个英文单词 ≈ 1 Token,1 个中文字 ≈ 1.5 Token。
Context Window(上下文窗口)是模型一次能处理的最大 Token 数,包含输入(System Prompt + 历史消息 + 用户新输入)和输出。GPT-4o 是 128K Token,Claude 是 200K。超出窗口会报错。
对话越长,消息历史越大,问题有两个:
- 超出窗口限制 → 直接报错
- Token 越多费用越高 → GPT-4o 输入 $2.5/1M Token,100K 消息历史就是 $0.25/次调用
3.2 策略:滑动窗口
最简单的策略——只发送最近 N 条消息。简单粗暴但有效,缺点是丢失早期对话中的重要信息:
function trimMessages(messages: Message[], maxMessages = 50): Message[] {
if (messages.length <= maxMessages) return messages
// 保留 System Message + 最近的消息
const systemMessages = messages.filter((m) => m.role === 'system')
const recentMessages = messages.slice(-maxMessages)
return [...systemMessages, ...recentMessages]
}3.3 策略:Token 预算
更精确的方法——按 Token 数量而非消息条数截断。因为不同消息长度差异巨大,一条消息可能 10 Token 也可能 5000 Token。用 js-tiktoken 库可以精确计算每条消息的 Token 数(tiktoken 是 OpenAI 开源的分词器,和模型内部用的一样):
import { encodingForModel } from 'js-tiktoken'
function trimByTokenBudget(
messages: Message[],
maxTokens = 100000,
model = 'gpt-4o',
): Message[] {
const encoder = encodingForModel(model)
let totalTokens = 0
const result: Message[] = []
// 从最新消息向前遍历
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
const tokens = encoder.encode(msg.content).length + 4 // 每条消息额外 4 Token 开销
if (totalTokens + tokens > maxTokens) break
totalTokens += tokens
result.unshift(msg)
}
return result
}3.4 策略:摘要压缩
滑动窗口和 Token 预算都是“丢弃”旧消息。摘要压缩是更智能的方法——用一个便宜的小模型(如 gpt-4o-mini)把早期对话压缩成一段摘要,然后只保留摘要 + 最近消息。这样既保留了早期对话的核心信息,又大幅减少了 Token 数量。代价是额外的一次 AI 调用成本(但用 mini 模型很便宜):
import { generateText } from 'ai'
async function compressHistory(messages: Message[]): Promise<Message[]> {
// 前 80% 的消息生成摘要
const cutPoint = Math.floor(messages.length * 0.8)
const oldMessages = messages.slice(0, cutPoint)
const recentMessages = messages.slice(cutPoint)
const { text: summary } = await generateText({
model: openai('gpt-4o-mini'),
messages: [
{
role: 'system',
content: '请简要总结以下对话的关键信息和结论,保留重要的上下文。',
},
...oldMessages,
],
})
return [
{ role: 'system', content: `之前的对话摘要:${summary}` },
...recentMessages,
]
}3.5 在 API 中应用
// app/api/chat/route.ts
export async function POST(request: Request) {
const { messages, chatId, model: modelId } = await request.json()
// 上下文窗口管理
const trimmedMessages = trimByTokenBudget(messages, 100000)
const result = streamText({
model: getModel(modelId),
messages: trimmedMessages,
// ...
})
return result.toDataStreamResponse()
}4. System Prompt 工程
System Prompt 是整个 AI 应用中最重要的一段文本。它在每次调用时作为第一条消息发给 AI,设定 AI 的角色、能力边界和输出风格。一个好的 System Prompt 能让普通模型效果接近微调模型,一个差的 System Prompt 会让最强模型表现平庸。
编写 System Prompt 的核心原则:
- 角色设定:明确告诉 AI 它是谁(“你是一个高级全栈工程师” vs “你是一个 AI 助手”)
- 行为约束:明确说明能做什么、不能做什么
- 输出格式:指定语言、长度、风格偏好
- 少即是多:简洁有效的指令比冗长的指令效果更好
4.1 基础 System Prompt
System Prompt 决定 AI 的"人格"和行为边界:
const DEFAULT_SYSTEM_PROMPT = `你是一个专业的 AI 助手。请遵循以下规则:
1. 使用中文回复
2. 回答要简洁、准确、有条理
3. 代码示例使用 TypeScript
4. 不确定的内容要明确说明
5. 拒绝回答与工作无关的问题`4.2 动态 System Prompt
静态 System Prompt 的局限是缺少上下文。动态注入当前时间、用户信息、工作区信息等,可以让 AI 的回复更贴合具体场景。这本质上是把“软编码”的信息(代码里写死的指令)和“硬编码”的信息(运行时动态获取的上下文)结合起来:
function buildSystemPrompt(chat: Chat, workspace: Workspace): string {
const parts = [chat.systemPrompt || DEFAULT_SYSTEM_PROMPT]
// 注入工作空间信息
parts.push(`\n当前工作空间:${workspace.name}`)
parts.push(`当前日期:${new Date().toLocaleDateString('zh-CN')}`)
// 如果是代码助手模式,注入技术栈
if (chat.mode === 'code') {
parts.push(`\n技术栈:Next.js 15, TypeScript, Tailwind CSS, Drizzle ORM`)
}
return parts.join('\n')
}4.3 预设角色
让用户选择 AI 角色——不同角色有不同的 System Prompt:
// lib/ai-presets.ts
export const AI_PRESETS = {
general: {
name: '通用助手',
systemPrompt: '你是一个通用 AI 助手,简洁准确地回答问题。',
temperature: 0.7,
},
coder: {
name: '代码助手',
systemPrompt: '你是一个高级全栈工程师。只输出代码和简要说明,不要多余解释。使用 TypeScript。',
temperature: 0.3,
},
writer: {
name: '写作助手',
systemPrompt: '你是一个专业的中文写作助手。文风简洁、有力、避免套话。',
temperature: 0.9,
},
translator: {
name: '翻译助手',
systemPrompt: '你是一个专业翻译。中文输入翻译为英文,英文输入翻译为中文。只输出翻译结果。',
temperature: 0.3,
},
} as const5. 自动生成标题
新对话的第一条消息发送后,自动用 AI 生成一个简短标题:
// lib/auto-title.ts
import { generateText } from 'ai'
import { openai } from '@ai-sdk/openai'
export async function generateChatTitle(firstMessage: string): Promise<string> {
const { text } = await generateText({
model: openai('gpt-4o-mini'),
prompt: `根据以下用户消息,生成一个简短的对话标题(不超过 20 个字,不要引号):\n\n${firstMessage}`,
maxTokens: 50,
temperature: 0.5,
})
return text.trim().slice(0, 30)
}在 API 的 onFinish 中触发:
onFinish: async ({ text, usage }) => {
// ... 保存消息
// 如果是第一条消息,自动生成标题
const messageCount = await db.select({ count: count() })
.from(dbMessages)
.where(eq(dbMessages.chatId, chatId))
if (messageCount[0].count <= 2) { // user + assistant = 2 条
const title = await generateChatTitle(userMessage.content)
await db.update(chats).set({ title }).where(eq(chats.id, chatId))
}
}6. Markdown 渲染
AI 回复通常包含 Markdown——代码块、列表、表格。需要一个好的 Markdown 渲染组件:
// components/markdown.tsx
'use client'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism'
export function Markdown({ content }: { content: string }) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '')
const isInline = !match
if (isInline) {
return <code className="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded text-sm" {...props}>{children}</code>
}
return (
<div className="relative group">
<SyntaxHighlighter
style={oneDark}
language={match[1]}
PreTag="div"
className="rounded-lg !mt-0"
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
<CopyButton text={String(children)} />
</div>
)
},
}}
>
{content}
</ReactMarkdown>
)
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
return (
<button
onClick={() => { navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 2000) }}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-gray-700 text-white text-xs px-2 py-1 rounded"
>
{copied ? '已复制' : '复制'}
</button>
)
}7. 对话管理
7.1 侧边栏对话列表
// app/(dashboard)/chat/sidebar.tsx
import { db } from '@/src/db'
export async function ChatSidebar() {
const { workspace } = await requireWorkspaceAccess()
const chatList = await db.query.chats.findMany({
where: eq(chats.workspaceId, workspace.id),
orderBy: desc(chats.updatedAt),
columns: { id: true, title: true, updatedAt: true, model: true },
})
return (
<div className="w-64 border-r h-full overflow-y-auto">
<div className="p-3">
<form action={createChat}>
<button className="w-full py-2 bg-blue-600 text-white rounded-lg text-sm">
新对话
</button>
</form>
</div>
<ul className="space-y-1 px-2">
{chatList.map((chat) => (
<li key={chat.id}>
<Link
href={`/chat/${chat.id}`}
className="block px-3 py-2 rounded-lg hover:bg-gray-100 text-sm truncate"
>
{chat.title}
</Link>
</li>
))}
</ul>
</div>
)
}7.2 删除与清空
// actions/chat.ts
'use server'
export async function deleteChat(chatId: string) {
const { workspace } = await requireWorkspaceAccess()
await db.delete(chats).where(
and(eq(chats.id, chatId), eq(chats.workspaceId, workspace.id)),
)
revalidateTag('chats')
redirect('/chat')
}
export async function clearChatHistory(chatId: string) {
const { workspace } = await requireWorkspaceAccess()
// 验证归属
const chat = await db.query.chats.findFirst({
where: and(eq(chats.id, chatId), eq(chats.workspaceId, workspace.id)),
})
if (!chat) throw new Error('对话不存在')
await db.delete(dbMessages).where(eq(dbMessages.chatId, chatId))
revalidateTag('chats')
}本章小结
- 数据模型:Chat(对话元信息 + 模型配置)→ Message(消息历史 + Token 统计)
- 完整闭环:创建对话 → 加载历史 → 流式对话 → 保存消息 → 记录用量
- 上下文管理:滑动窗口(简单)→ Token 预算(精确)→ 摘要压缩(极端情况)
- System Prompt:基础模板 + 动态注入上下文 + 预设角色
- 自动标题:首条消息后用
gpt-4o-mini生成简短标题 - Markdown 渲染:
react-markdown+ 代码高亮 + 复制按钮
下一章讲 RAG 检索增强生成。