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。超出窗口会报错。

对话越长,消息历史越大,问题有两个:

  1. 超出窗口限制 → 直接报错
  2. 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 const

5. 自动生成标题

新对话的第一条消息发送后,自动用 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 检索增强生成。