【运维】Nginx 蓝绿部署与流式任务(SSE)不中断架构实战

技术栈:Next.js 15 App Router · React 19 · TypeScript · Tailwind CSS 4 · HeroUI · Vercel AI SDK 4 · Prisma 6 · PostgreSQL 16 · Redis · Nginx

核心命题:当 AI Agent 正在执行一个耗时 3 分钟的流式推理任务时,运维触发了版本发布------如何保证用户屏幕上的文字不会戛然而止?

1. 问题背景

1.1 AI Agent 与传统 Web 应用的本质区别

传统 CRUD 应用的请求生命周期通常在 50ms ~ 2s 之间。一次部署窗口内,即使粗暴地 kill 进程,用户感知到的不过是一个 502 错误,刷新即可恢复。

但 AI Agent 项目完全不同:

维度 传统 Web 应用 AI Agent 应用
单次请求时长 50ms ~ 2s 30s ~ 10min+
连接类型 短连接 / 快速响应 SSE 长连接 / WebSocket
中断代价 用户刷新重试 丢失数分钟推理结果,Token 费用白烧
状态 无状态 有状态(对话上下文、工具调用链)
并发连接数 峰值高但短暂 长尾连接持续占用

一个真实的场景:用户向 AI Agent 提问"帮我分析这份 200 页的财报并生成投资建议",Agent 开始调用工具链(PDF 解析 → 数据提取 → 多轮推理 → 报告生成),整个过程通过 SSE 流式输出,持续约 4 分钟。如果在第 3 分钟时运维执行了 docker restart,用户看到的是:

xml 复制代码
正在分析第 147 页的现金流数据...
正在交叉验证营收增长率与行业基准...
[连接中断]

4 分钟的等待、数美元的 Token 费用、用户的信任------全部归零。

1.2 蓝绿部署解决的核心问题

蓝绿部署(Blue-Green Deployment)的核心思想是:永远有两套完整的生产环境,流量切换是瞬间的,而旧环境的连接可以优雅地排空(drain)。

对于 AI Agent 项目,这意味着:

  • 新版本部署到 Green 环境,此时 Blue 仍在服务
  • Nginx 将新请求切到 Green,但 不切断 Blue 上已有的 SSE 长连接
  • Blue 进入 drain 模式:不再接受新连接,等待所有流式任务自然完成
  • 所有连接排空后,Blue 下线,完成部署

这就是"流式任务不可中断"的部署层保障。

2. 技术选型

2.1 为什么是 Next.js App Router(而非 Pages Router / Remix / Astro)

需求 Next.js App Router Remix Astro 纯 SPA(Vite + React)
Route Handlers (SSE 流式端点) ✅ 原生 ✅ Loader ❌ 需API ❌ 需API
Server Components ✅ 原生 ✅ 部分
流式 SSR
Vercel AI SDK ✅ 非常适配 ⚠️ 可用 ⚠️ 可用 ⚠️ 可用
部署灵活性 ✅ Node Server ✅ Node Server ✅ 静态 + API ✅ 静态 + API
TypeScript 体验

关键决策理由:

  1. Route Handlers 原生支持 ReadableStream:这是 SSE 流式输出的基础设施。App Router 的 app/api/chat/route.ts 可以直接返回 new Response(stream),无需额外的 Express/Fastify 层。
  2. Server Components 减少客户端 JS 体积:AI Agent 的对话历史、工具调用记录等重数据在 Server Component 中直接查库渲染,客户端只接收序列化后的 HTML,首屏性能显著提升。
  3. Vercel AI SDK 与 Next.js 的集成是最深的:useChatuseCompletionstreamText 等 API 在 Next.js 中是零配置开箱即用的。虽然 SDK 也支持其他框架,但文档、示例、社区解答都围绕 Next.js。
  4. output: 'standalone' 模式:构建产物是一个自包含的 Node.js 服务器,Docker 镜像可以控制在 ~150MB(对比完整 node_modules 的 ~1GB),这对蓝绿部署的镜像拉取速度至关重要。

缺点与权衡:

  • App Router 的心智模型比 Pages Router 复杂(Server/Client Component 边界、'use client' 指令)
  • 对 Node.js 运行时有硬依赖(不像 Astro 可以纯静态部署)
  • 自托管时需要自行处理缓存策略(revalidateunstable_cache 等在非 Vercel 环境下行为有差异)

2.2 为什么是 Vercel AI SDK(而非 LangChain.js / 自行封装)

typescript 复制代码
// Vercel AI SDK 的流式输出------3 行代码
import { streamText } from 'ai';
import { openai } from '@ai-sdk/openai';

const result = streamText({
  model: openai('gpt-4o'),
  prompt: '分析这份财报...',
});

return result.toDataStreamResponse(); // 自动处理 SSE 协议、错误、中止

对比 LangChain.js:

typescript 复制代码
// LangChain.js 的流式输出------需要手动管理
import { ChatOpenAI } from '@langchain/openai';
import { BytesOutputParser } from '@langchain/core/output_parsers';

const model = new ChatOpenAI({ modelName: 'gpt-4o', streaming: true });
const parser = new BytesOutputParser();
const stream = await model.pipe(parser).stream(messages);

// 手动构建 SSE Response
const encoder = new TextEncoder();
const readable = new ReadableStream({
  async start(controller) {
    for await (const chunk of stream) {
      controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
    }
    controller.close();
  },
});
return new Response(readable, {
  headers: { 'Content-Type': 'text/event-stream' },
});

选择 Vercel AI SDK 的理由:

  1. 协议层封装:自动处理 SSE 格式、[DONE] 信号、错误序列化、客户端重连。你不需要关心 data: {...}\n\n 的拼接。
  2. useChat Hook 开箱即用:前端一个 Hook 搞定流式渲染、消息状态管理、自动滚动、中止控制。
  3. Tool Calling 支持:AI Agent 的核心是工具调用,SDK 的 tools 参数 + maxSteps 实现了多步 Agent 循环。
  4. Provider 抽象:切换 OpenAI / Anthropic / 本地 Ollama 只需换一行 import。

缺点:

  1. 对复杂 Agent 编排(DAG、条件分支、人工审批节点)的支持不如 LangGraph
  2. 流式协议是 Vercel 自定义的 Data Stream Protocol,与标准 SSE 有差异(虽然也支持纯 SSE 模式)
  3. 版本迭代快,Breaking Change 较多(v3 → v4 迁移成本不低)

2.3 为什么是 Prisma + PostgreSQL(而非 Drizzle / TypeORM / MongoDB)

考量 Prisma Drizzle TypeORM MongoDB
类型安全 ✅ 生成 Client ✅ 原生 TS ⚠️ 装饰器 ⚠️ 弱
迁移管理 prisma migrate drizzle-kit ⚠️ 不稳定 ❌ 无 Schema
关系查询 ✅ include/select ✅ 手动 join ❌ 需 $ lookup
AI Agent 场景适配 ✅ JSON 字段 + 关系 ✅ 灵活 Schema
学习曲线
连接池 ✅ 内置 PgBouncer 兼容 ⚠️

关键理由 :AI Agent 的数据模型是 强关系 + 半结构化 的混合体

  • 强关系:User → Conversation → Message → ToolCall,这是典型的关系型数据
  • 半结构化:每条 Message 的 metadata(Token 用量、模型参数、工具调用参数)是 JSON

PostgreSQL 的 JSONB 类型 + Prisma 的 Json 字段完美匹配这个需求。MongoDB 虽然 Schema 灵活,但在事务一致性(一次 Agent 执行涉及多条 Message + 多条 ToolCall 的原子写入)上不如 PostgreSQL。

2.4 为什么是 HeroUI(而非 shadcn/ui / Ant Design / MUI)

HeroUI(原 NextUI)基于 Tailwind CSS + Framer Motion,与本项目技术栈天然契合:

  • Tailwind 原生:不需要额外的 CSS-in-JS 运行时(MUI 的 Emotion、Ant Design 的 Less 都是额外负担)
  • 组件质量高:ChatBubble、Spinner、ScrollShadow 等组件对 AI 对话场景非常友好
  • Tree-shaking 友好:按需引入,不会像 Ant Design 那样引入整个组件库
  • 暗色模式:AI 产品几乎标配暗色模式,HeroUI 的 next-themes 集成是开箱即用的

2.5 为什么是 Nginx 蓝绿(而非 Kubernetes / Docker Swarm / 云 ALB)

xml 复制代码
部署方案复杂度 vs 控制力:

简单 ◄────────────────────────────────────────────────────────────► 复杂
                                              
Nginx 手动       Docker Compose       K8s + Istio               云厂商 ALB
蓝绿脚本         + Nginx              Service Mesh              + Target Group
                                              
成本: $           成本: $$             成本: $$$$               成本: $$$
控制力: ★★★    控制力: ★★★★      控制力: ★★★★★        控制力: ★★

对于 单机 / 双机 的 AI Agent 项目(日活 < 10K),Nginx 蓝绿是性价比最高的方案:

  • 无需学习 K8s 的 200+ 概念
  • 无需支付云厂商 ALB 的按量费用
  • Nginx 的 upstream + max_fails + proxy_read_timeout 足以覆盖所有需求
  • 部署脚本 < 100 行 Bash,完全可控

当规模增长到需要 K8s 时,本文的架构设计(无状态应用层 + 有状态数据层分离)可以平滑迁移。

3. 系统架构图

3.1 整体架构

3.2 蓝绿部署状态机

3.3 AI Agent 流式任务生命周期

4. 蓝绿部署

4.1 Nginx 配置:核心中的核心

这是整个蓝绿部署的 灵魂文件。每一行配置都有其存在的理由。

bash 复制代码
# /etc/nginx/conf.d/ai-agent.conf

# ============================================
# 上游定义:Blue 和 Green 两个后端
# ============================================
upstream blue_backend {
    server 127.0.0.1:3001 max_fails=3 fail_timeout=30s;
    keepalive 64;  # 🔑 关键:保持与后端的长连接池
}

upstream green_backend {
    server 127.0.0.1:3002 max_fails=3 fail_timeout=30s;
    keepalive 64;
}

# 🔑 当前活跃环境的映射(通过 include 切换)
# 部署脚本会动态生成这个文件
include /etc/nginx/conf.d/active_upstream.conf;
# 内容示例:set $active_backend blue_backend;

# ============================================
# HTTP → HTTPS 重定向
# ============================================
server {
    listen 80;
    server_name agent.example.com;
    return 301 https://$host$request_uri;
}

# ============================================
# 主 HTTPS 服务
# ============================================
server {
    listen 443 ssl http2;
    server_name agent.example.com;

    ssl_certificate     /etc/letsencrypt/live/agent.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/agent.example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;

    # ------------------------------------------
    # 🔑 SSE 流式连接的关键配置
    # ------------------------------------------
    location /api/agent/ {
        proxy_pass http://$active_backend;

        # 🔑 禁用代理缓冲------SSE 必须实时推送
        # 如果开启 proxy_buffering,Nginx 会攒够一定字节才发给客户端
        # 用户会看到文字"一顿一顿"地出现,而不是逐字流出
        proxy_buffering off;

        # 🔑 禁用缓存
        proxy_cache off;

        # 🔑 超长读取超时------AI Agent 任务可能持续 10 分钟
        # 默认 60s 会导致长任务被 Nginx 主动断开
        proxy_read_timeout 600s;
        proxy_send_timeout 600s;

        # 🔑 连接超时------建立连接的超时,不是读取超时
        proxy_connect_timeout 10s;

        # 🔑 SSE 必需的 Headers
        proxy_set_header Connection '';
        proxy_http_version 1.1;

        # 禁用 chunked 编码的缓冲
        chunked_transfer_encoding on;

        # 传递真实客户端信息
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 🔑 关键:不设置 proxy_ignore_client_abort
        # 当用户关闭浏览器时,Nginx 应通知后端中止
        # 后端可以据此停止 AI 推理,节省 Token 费用
    }

    # ------------------------------------------
    # 健康检查端点------部署脚本用
    # ------------------------------------------
    location /api/health {
        proxy_pass http://$active_backend;
        proxy_read_timeout 5s;
        access_log off;  # 健康检查不记日志
    }

    # ------------------------------------------
    # 静态资源------长缓存
    # ------------------------------------------
    location /_next/static/ {
        proxy_pass http://$active_backend;
        proxy_cache_valid 200 365d;
        add_header Cache-Control "public, max-age=31536000, immutable";
    }

    # ------------------------------------------
    # 默认路由
    # ------------------------------------------
    location / {
        proxy_pass http://$active_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# ============================================
# 🔑 Drain 模式的独立 server(部署期间临时)
# 当 Blue 进入 drain 模式时,这个 server 块
# 让 Nginx 知道 Blue 上还有哪些活跃连接
# ============================================
server {
    listen 8080;
    server_name localhost;

    location /nginx_status {
        stub_status on;
        allow 127.0.0.1;
        deny all;
    }
}

proxy_buffering off 是关键的一行

xml 复制代码
开启 buffering(默认):
Client ←── [Nginx Buffer: 攒够 4KB/8KB] ←── Backend
用户感知:文字每 2-3 秒"蹦"出一段

关闭 buffering:
Client ←── [Nginx 透传] ←── Backend
用户感知:文字逐字流出,体验丝滑

4.2 活跃上游切换文件

bash 复制代码
# /etc/nginx/conf.d/active_upstream.conf
# 此文件由部署脚本自动生成,勿手动编辑

# 当前活跃:Blue
set $active_backend blue_backend;

# 切换时改为:
# set $active_backend green_backend;

4.3 Docker Compose:双环境编排

bash 复制代码
# docker-compose.yml
version: '3.9'

services:
  # ============================================
  # 🔵 Blue 环境
  # ============================================
  ai-agent-blue:
    image: ai-agent:${BLUE_TAG:-latest}
    container_name: ai-agent-blue
    restart: unless-stopped
    ports:
      - "127.0.0.1:3001:3000"  # 🔑 只绑定 localhost,外部通过 Nginx 访问
    environment:
      - NODE_ENV=production
      - PORT=3000
      - DATABASE_URL=postgresql://agent:${DB_PASSWORD}@postgres:5432/ai_agent
      - REDIS_URL=redis://redis:6379
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - DEPLOY_COLOR=blue  # 🔑 标识当前颜色,用于日志和监控
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s  # 🔑 Next.js 冷启动需要时间
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: '1.0'
    networks:
      - ai-agent-net

  # ============================================
  # 🟢 Green 环境
  # ============================================
  ai-agent-green:
    image: ai-agent:${GREEN_TAG:-latest}
    container_name: ai-agent-green
    restart: unless-stopped
    ports:
      - "127.0.0.1:3002:3000"
    environment:
      - NODE_ENV=production
      - PORT=3000
      - DATABASE_URL=postgresql://agent:${DB_PASSWORD}@postgres:5432/ai_agent
      - REDIS_URL=redis://redis:6379
      - OPENAI_API_KEY=${OPENAI_API_KEY}
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - DEPLOY_COLOR=green
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      resources:
        limits:
          memory: 1G
          cpus: '1.0'
    networks:
      - ai-agent-net
    profiles:
      - green  # 🔑 默认不启动,部署时 --profile green 激活

  # ============================================
  # 🐘 PostgreSQL(共享,不随蓝绿切换)
  # ============================================
  postgres:
    image: postgres:16-alpine
    container_name: ai-agent-postgres
    restart: unless-stopped
    environment:
      - POSTGRES_DB=ai_agent
      - POSTGRES_USER=agent
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U agent -d ai_agent"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - ai-agent-net

  # ============================================
  # 🔴 Redis(共享)
  # ============================================
  redis:
    image: redis:7-alpine
    container_name: ai-agent-redis
    restart: unless-stopped
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redisdata:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    networks:
      - ai-agent-net

volumes:
  pgdata:
  redisdata:

networks:
  ai-agent-net:
    driver: bridge

为什么数据库和 Redis 是共享的?

蓝绿部署的核心原则是:应用层有颜色,数据层无颜色。 如果 Blue 和 Green 各有独立数据库,就会面临数据同步的噩梦。共享数据库意味着:

  • 用户在 Blue 上创建的对话,切到 Green 后立即可见
  • 无需数据迁移脚本
  • 代价是:数据库 Schema 变更必须 向后兼容(详见踩坑分析)

5. 流式任务不可中断

5.1 设计哲学:三层防御

"不可中断"不是一个单点技术,而是 三层防御体系

  • 第 1 层:部署层(Nginx 蓝绿) → 不切断已有连接,新流量走新环境
  • 第 2 层:应用层(Graceful Shutdown) → Node.js 进程收到 SIGTERM 后等待任务完成
  • 第 3 层:数据层(任务状态持久化)→ 即使进程被 kill -9,任务状态可从 DB 恢复

5.2 第 1 层:Nginx 连接排空

参考 4.1 节详述。核心是 proxy_read_timeout 600s + 部署脚本等待活跃连接归零。

5.3 第 2 层:Next.js Graceful Shutdown

typescript 复制代码
// instrumentation.ts (Next.js 根目录)
// 🔑 Next.js 15 的 Instrumentation Hook------服务器生命周期管理

export async function register() {
  // 仅在 Node.js 运行时执行(非 Edge)
  if (process.env.NEXT_RUNTIME === 'nodejs') {
    const { initGracefulShutdown } = await import('@/lib/graceful-shutdown');
    initGracefulShutdown();
  }
}
typescript 复制代码
// lib/graceful-shutdown.ts
import { getActiveStreams, waitForStreamsDrain } from '@/lib/stream-registry';

export function initGracefulShutdown() {
  const SHUTDOWN_TIMEOUT_MS = 10 * 60 * 1000; // 10 分钟硬超时

  let isShuttingDown = false;

  const shutdown = async (signal: string) => {
    if (isShuttingDown) return;
    isShuttingDown = true;

    console.log(`[GracefulShutdown] 收到 ${signal},开始优雅关闭...`);
    console.log(`[GracefulShutdown] 当前活跃流式任务: ${getActiveStreams().size}`);

    // 🔑 第 1 步:标记为 drain 模式
    // 健康检查端点会返回 503,Nginx 不再转发新请求
    process.env.DRAIN_MODE = 'true';

    // 🔑 第 2 步:等待所有流式任务完成
    try {
      await Promise.race([
        waitForStreamsDrain(),
        new Promise((_, reject) =>
          setTimeout(
            () => reject(new Error('Drain 超时')),
            SHUTDOWN_TIMEOUT_MS
          )
        ),
      ]);
      console.log('[GracefulShutdown] 所有流式任务已完成 ✅');
    } catch (err) {
      console.error('[GracefulShutdown] 等待超时,强制关闭 ⚠️', err);
    }

    // 🔑 第 3 步:关闭数据库连接池
    const { prisma } = await import('@/lib/prisma');
    await prisma.$disconnect();

    console.log('[GracefulShutdown] 进程退出');
    process.exit(0);
  };

  process.on('SIGTERM', () => shutdown('SIGTERM'));
  process.on('SIGINT', () => shutdown('SIGINT'));
}
typescript 复制代码
// lib/stream-registry.ts
// 🔑 全局流式任务注册表------追踪所有活跃的 SSE 连接

const activeStreams = new Map<string, {
  conversationId: string;
  startedAt: Date;
  abortController: AbortController;
}>();

export function registerStream(
  streamId: string,
  conversationId: string,
  abortController: AbortController
) {
  activeStreams.set(streamId, {
    conversationId,
    startedAt: new Date(),
    abortController,
  });
  console.log(`[StreamRegistry] 注册流 ${streamId},当前活跃: ${activeStreams.size}`);
}

export function unregisterStream(streamId: string) {
  activeStreams.delete(streamId);
  console.log(`[StreamRegistry] 注销流 ${streamId},当前活跃: ${activeStreams.size}`);
}

export function getActiveStreams() {
  return activeStreams;
}

export function waitForStreamsDrain(): Promise<void> {
  return new Promise((resolve) => {
    const check = () => {
      if (activeStreams.size === 0) {
        resolve();
      } else {
        console.log(`[StreamRegistry] 等待 ${activeStreams.size} 个流完成...`);
        setTimeout(check, 5000); // 每 5 秒检查一次
      }
    };
    check();
  });
}

5.4 第 3 层:任务状态持久化

typescript 复制代码
// 即使进程被 kill -9,任务状态也不会丢失
// 因为每一步都已写入 PostgreSQL

// 在 Agent 执行的每个关键节点:
await prisma.message.update({
  where: { id: messageId },
  data: {
    status: 'STREAMING',        // → 'TOOL_CALLING' → 'COMPLETED'
    content: accumulatedText,    // 已生成的部分文本
    metadata: {
      tokensUsed: currentTokenCount,
      lastToolCall: toolName,
      checkpointAt: new Date(),  // 🔑 检查点时间戳
    },
  },
});

5.5 健康检查端点(Drain 感知)

typescript 复制代码
// app/api/health/route.ts
import { NextResponse } from 'next/server';
import { getActiveStreams } from '@/lib/stream-registry';
import { prisma } from '@/lib/prisma';

export async function GET() {
  const isDraining = process.env.DRAIN_MODE === 'true';
  const activeCount = getActiveStreams().size;

  // 🔑 Drain 模式下返回 503
  // Nginx 的 max_fails 机制会将此节点标记为不可用
  // 新请求不再转发到此节点
  if (isDraining) {
    return NextResponse.json(
      {
        status: 'draining',
        activeStreams: activeCount,
        color: process.env.DEPLOY_COLOR,
      },
      { status: 503 }
    );
  }

  // 正常健康检查
  try {
    await prisma.$queryRaw`SELECT 1`;
    return NextResponse.json({
      status: 'healthy',
      activeStreams: activeCount,
      color: process.env.DEPLOY_COLOR,
      uptime: process.uptime(),
      timestamp: new Date().toISOString(),
    });
  } catch {
    return NextResponse.json(
      { status: 'unhealthy', error: 'Database connection failed' },
      { status: 500 }
    );
  }
}

6. 关键代码

6.1 项目结构

xml 复制代码
ai-agent/
├── app/
│   ├── layout.tsx                    # 根布局(HeroUI Provider)
│   ├── page.tsx                      # 首页(对话列表)
│   ├── chat/
│   │   └── [conversationId]/
│   │       └── page.tsx              # 对话页面(Server Component)
│   ├── api/
│   │   ├── health/
│   │   │   └── route.ts             # 健康检查
│   │   ├── agent/
│   │   │   └── chat/
│   │   │       └── route.ts         # 🔑 AI Agent 流式端点
│   │   └── conversations/
│   │       ├── route.ts             # CRUD
│   │       └── [id]/
│   │           └── route.ts
│   └── globals.css
├── components/
│   ├── chat/
│   │   ├── ChatWindow.tsx           # 对话窗口(Client Component)
│   │   ├── MessageBubble.tsx        # 消息气泡
│   │   ├── ToolCallCard.tsx         # 工具调用展示
│   │   ├── StreamingIndicator.tsx   # 流式输出指示器
│   │   └── ChatInput.tsx            # 输入框
│   └── layout/
│       ├── Sidebar.tsx
│       └── Header.tsx
├── lib/
│   ├── prisma.ts                    # Prisma Client 单例
│   ├── redis.ts                     # Redis Client
│   ├── ai/
│   │   ├── agent.ts                 # 🔑 Agent 核心逻辑
│   │   ├── tools.ts                 # 工具定义
│   │   └── prompts.ts              # 系统提示词
│   ├── stream-registry.ts           # 流式任务注册表
│   └── graceful-shutdown.ts         # 优雅关闭
├── prisma/
│   ├── schema.prisma
│   └── migrations/
├── instrumentation.ts               # 🔑 服务器生命周期
├── next.config.ts
├── tailwind.config.ts
├── docker/
│   └── Dockerfile
├── scripts/
│   └── deploy.sh                    # 🔑 蓝绿部署脚本
├── nginx/
│   └── ai-agent.conf
├── docker-compose.yml
└── .env.example

6.2 AI Agent 流式端点(核心)

typescript 复制代码
// app/api/agent/chat/route.ts
import { streamText, tool, type CoreMessage } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
import { redis } from '@/lib/redis';
import { registerStream, unregisterStream } from '@/lib/stream-registry';
import { randomUUID } from 'crypto';

// 🔑 允许流式响应------Next.js App Router 默认可能缓冲
export const dynamic = 'force-dynamic';
export const maxDuration = 600; // Vercel 配置,自托管时由 Nginx 控制

export async function POST(req: Request) {
  const startTime = Date.now();
  const streamId = randomUUID();

  try {
    // ------------------------------------------
    // 1. 解析请求
    // ------------------------------------------
    const { conversationId, messages } = (await req.json()) as {
      conversationId: string;
      messages: CoreMessage[];
    };

    // ------------------------------------------
    // 2. 鉴权(简化示例)
    // ------------------------------------------
    const userId = req.headers.get('x-user-id');
    if (!userId) {
      return new Response(JSON.stringify({ error: 'Unauthorized' }), {
        status: 401,
        headers: { 'Content-Type': 'application/json' },
      });
    }

    // ------------------------------------------
    // 3. 🔑 分布式锁:防止同一对话并发执行
    // ------------------------------------------
    const lockKey = `agent:lock:${conversationId}`;
    const acquired = await redis.set(lockKey, streamId, {
      nx: true,     // 不存在时才设置
      ex: 600,      // 10 分钟自动过期(防死锁)
    });

    if (!acquired) {
      return new Response(
        JSON.stringify({ error: '该对话正在执行中,请等待完成' }),
        { status: 409, headers: { 'Content-Type': 'application/json' } }
      );
    }

    // ------------------------------------------
    // 4. 创建用户消息记录
    // ------------------------------------------
    const userMessage = messages[messages.length - 1];
    const dbMessage = await prisma.message.create({
      data: {
        conversationId,
        role: 'user',
        content: typeof userMessage.content === 'string'
          ? userMessage.content
          : JSON.stringify(userMessage.content),
        status: 'COMPLETED',
      },
    });

    // ------------------------------------------
    // 5. 创建 AI 消息占位记录
    // ------------------------------------------
    const aiMessage = await prisma.message.create({
      data: {
        conversationId,
        role: 'assistant',
        content: '',
        status: 'STREAMING',  // 🔑 标记为流式中
        metadata: { streamId, startedAt: new Date().toISOString() },
      },
    });

    // ------------------------------------------
    // 6. 🔑 注册流式任务 + AbortController
    // ------------------------------------------
    const abortController = new AbortController();
    registerStream(streamId, conversationId, abortController);

    // 监听客户端断开
    req.signal.addEventListener('abort', () => {
      console.log(`[Stream ${streamId}] 客户端主动断开`);
      abortController.abort();
    });

    // ------------------------------------------
    // 7. 🔑 核心:Vercel AI SDK 流式 Agent
    // ------------------------------------------
    const result = streamText({
      model: openai('gpt-4o', {
        // 并行工具调用
        parallelToolCalls: true,
      }),
      messages,
      system: `你是一个专业的 AI 分析助手。
当前时间:${new Date().toISOString()}
你可以使用工具来搜索信息、执行计算、解析文档。
请一步一步思考,给出详细、准确的回答。`,

      // 🔑 Agent 工具定义
      tools: {
        web_search: tool({
          description: '搜索互联网获取最新信息',
          parameters: z.object({
            query: z.string().describe('搜索关键词'),
            maxResults: z.number().optional().default(5),
          }),
          execute: async ({ query, maxResults }) => {
            // 实际项目中调用搜索 API
            const results = await fetch(
              `https://api.search.example.com/v1?q=${encodeURIComponent(query)}&n=${maxResults}`
            ).then((r) => r.json());
            return results;
          },
        }),

        calculate: tool({
          description: '执行数学计算',
          parameters: z.object({
            expression: z.string().describe('数学表达式,如 "2^10 + 3*4"'),
          }),
          execute: async ({ expression }) => {
            // 安全计算(生产环境用 mathjs 或沙箱)
            try {
              const { evaluate } = await import('mathjs');
              return { result: evaluate(expression) };
            } catch (e) {
              return { error: `计算错误: ${e}` };
            }
          },
        }),

        query_database: tool({
          description: '查询内部数据库获取业务数据',
          parameters: z.object({
            sql: z.string().describe('只读 SQL 查询(仅 SELECT)'),
          }),
          execute: async ({ sql }) => {
            // 🔑 安全检查:只允许 SELECT
            if (!/^\s*SELECT/i.test(sql)) {
              return { error: '仅允许 SELECT 查询' };
            }
            try {
              const rows = await prisma.$queryRawUnsafe(sql);
              return { rows, count: (rows as unknown[]).length };
            } catch (e) {
              return { error: `查询错误: ${e}` };
            }
          },
        }),
      },

      // 🔑 最大 Agent 循环步数
      // 每一步 = 一次 LLM 调用 + 可能的工具执行
      // 防止无限循环
      maxSteps: 10,

      // 🔑 中止信号
      abortSignal: abortController.signal,

      // 🔑 流式回调:每个 token 都触发
      onChunk({ chunk }) {
        // 可选:实时统计 Token
      },

      // 🔑 工具调用回调
      onStepFinish({ stepType, toolCalls, toolResults }) {
        if (toolCalls) {
          console.log(
            `[Stream ${streamId}] Step: ${stepType}, Tools: ${toolCalls.map((t) => t.toolName).join(', ')}`
          );
        }
      },
    });

    // ------------------------------------------
    // 8. 🔑 构建 SSE 响应 + 完成回调
    // ------------------------------------------
    const dataStream = result.toDataStreamResponse({
      // 自定义错误处理
      getErrorMessage: (error) => {
        if (error instanceof Error) {
          if (error.name === 'AbortError') {
            return '任务已中止';
          }
          return error.message;
        }
        return '未知错误';
      },
    });

    // 🔑 包装原始流,注入完成逻辑
    const originalBody = dataStream.body;
    if (!originalBody) {
      throw new Error('Stream body is null');
    }

    const { readable, writable } = new TransformStream();
    const writer = writable.getWriter();
    const reader = originalBody.getReader();

    // 异步管道:转发所有 chunk,完成后执行清理
    (async () => {
      let fullText = '';
      try {
        while (true) {
          const { done, value } = await reader.read();
          if (done) break;
          fullText += new TextDecoder().decode(value, { stream: true });
          await writer.write(value);
        }
      } catch (err) {
        console.error(`[Stream ${streamId}] 流式传输错误:`, err);
      } finally {
        await writer.close();

        // 🔑 清理:无论成功还是失败
        unregisterStream(streamId);
        await redis.del(lockKey);

        // 🔑 更新数据库:最终状态
        const duration = Date.now() - startTime;
        const usage = await result.usage;

        await prisma.message.update({
          where: { id: aiMessage.id },
          data: {
            content: fullText,
            status: abortController.signal.aborted ? 'ABORTED' : 'COMPLETED',
            metadata: {
              streamId,
              durationMs: duration,
              promptTokens: usage?.promptTokens,
              completionTokens: usage?.completionTokens,
              totalTokens: usage?.totalTokens,
              finishedAt: new Date().toISOString(),
            },
          },
        });

        console.log(
          `[Stream ${streamId}] 完成 | 耗时: ${duration}ms | Tokens: ${usage?.totalTokens ?? 'N/A'}`
        );
      }
    })();

    return new Response(readable, {
      headers: {
        ...Object.fromEntries(dataStream.headers.entries()),
        // 🔑 额外的 SSE 相关 Headers
        'X-Stream-Id': streamId,
        'X-Accel-Buffering': 'no',  // 🔑 告诉 Nginx 不要缓冲(双保险)
      },
    });
  } catch (error) {
    // 全局错误处理
    unregisterStream(streamId);
    console.error('[Agent Chat] 未捕获错误:', error);
    return new Response(
      JSON.stringify({ error: 'Internal Server Error' }),
      { status: 500, headers: { 'Content-Type': 'application/json' } }
    );
  }
}

6.3 前端:流式对话组件

typescript 复制代码
// components/chat/ChatWindow.tsx
'use client';

import { useChat } from '@ai-sdk/react';
import { useEffect, useRef, useState } from 'react';
import { Button, Spinner, ScrollShadow } from '@heroui/react';
import { MessageBubble } from './MessageBubble';
import { ChatInput } from './ChatInput';
import { ToolCallCard } from './ToolCallCard';

interface ChatWindowProps {
  conversationId: string;
  initialMessages?: Array<{
    id: string;
    role: 'user' | 'assistant';
    content: string;
  }>;
}

export function ChatWindow({ conversationId, initialMessages }: ChatWindowProps) {
  const bottomRef = useRef<HTMLDivElement>(null);
  const [isStreaming, setIsStreaming] = useState(false);

  // 🔑 Vercel AI SDK 的 useChat Hook
  const {
    messages,
    input,
    handleInputChange,
    handleSubmit,
    isLoading,
    error,
    stop,       // 🔑 用户主动停止
    reload,     // 重试
  } = useChat({
    api: '/api/agent/chat',
    id: conversationId,
    initialMessages,
    body: { conversationId },  // 额外参数
    maxSteps: 10,              // 与后端一致

    // 🔑 流式状态回调
    onStreamStart() {
      setIsStreaming(true);
    },
    onStreamEnd() {
      setIsStreaming(false);
    },

    // 🔑 错误处理
    onError(err) {
      console.error('[ChatWindow] 流式错误:', err);
      setIsStreaming(false);
    },

    // 🔑 工具调用回调(用于 UI 展示)
    onToolCall({ toolCall }) {
      console.log(`[ChatWindow] 工具调用: ${toolCall.toolName}`);
    },
  });

  // 自动滚动到底部
  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [messages]);

  return (
    <div className="flex h-full flex-col">
      {/* 消息列表 */}
      <ScrollShadow className="flex-1 px-4 py-6">
        <div className="mx-auto max-w-3xl space-y-6">
          {messages.map((message) => (
            <div key={message.id}>
              <MessageBubble
                role={message.role}
                content={message.content}
                isStreaming={isStreaming && message.id === messages[messages.length - 1]?.id}
              />

              {/* 🔑 工具调用可视化 */}
              {message.toolInvocations?.map((invocation) => (
                <ToolCallCard
                  key={invocation.toolCallId}
                  toolName={invocation.toolName}
                  args={invocation.args}
                  result={invocation.state === 'result' ? invocation.result : undefined}
                  state={invocation.state}
                />
              ))}
            </div>
          ))}

          {/* 流式加载指示器 */}
          {isLoading && (
            <div className="flex items-center gap-3 text-default-400">
              <Spinner size="sm" />
              <span className="text-sm">Agent 正在思考...</span>
            </div>
          )}

          {/* 错误提示 */}
          {error && (
            <div className="rounded-lg bg-danger-50 p-4 text-sm text-danger-600">
              <p>出错了:{error.message}</p>
              <Button
                size="sm"
                variant="flat"
                color="danger"
                className="mt-2"
                onPress={() => reload()}
              >
                重试
              </Button>
            </div>
          )}

          <div ref={bottomRef} />
        </div>
      </ScrollShadow>

      {/* 输入区域 */}
      <div className="border-t border-default-200 p-4">
        <div className="mx-auto max-w-3xl">
          <ChatInput
            input={input}
            onChange={handleInputChange}
            onSubmit={handleSubmit}
            isLoading={isLoading}
            onStop={stop}  // 🔑 用户可主动停止(但部署不会中断)
          />
          <p className="mt-2 text-center text-xs text-default-400">
            AI Agent 可能出错,请验证重要信息
          </p>
        </div>
      </div>
    </div>
  );
}
typescript 复制代码
// components/chat/MessageBubble.tsx
'use client';

import { cn } from '@heroui/react';
import { Avatar } from '@heroui/react';
import { StreamingIndicator } from './StreamingIndicator';

interface MessageBubbleProps {
  role: 'user' | 'assistant';
  content: string;
  isStreaming?: boolean;
}

export function MessageBubble({ role, content, isStreaming }: MessageBubbleProps) {
  const isUser = role === 'user';

  return (
    <div className={cn('flex gap-3', isUser && 'flex-row-reverse')}>
      <Avatar
        size="sm"
        name={isUser ? 'U' : 'AI'}
        className={cn(
          'shrink-0',
          isUser ? 'bg-primary' : 'bg-secondary'
        )}
      />
      <div
        className={cn(
          'max-w-[80%] rounded-2xl px-4 py-3 text-sm leading-relaxed',
          isUser
            ? 'bg-primary text-primary-foreground'
            : 'bg-default-100 text-default-foreground'
        )}
      >
        {/* 🔑 流式渲染:内容逐字出现 */}
        <div className="whitespace-pre-wrap break-words">
          {content}
          {isStreaming && <StreamingIndicator />}
        </div>
      </div>
    </div>
  );
}
typescript 复制代码
// components/chat/StreamingIndicator.tsx
'use client';

export function StreamingIndicator() {
  return (
    <span className="ml-1 inline-flex items-center gap-0.5">
      <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:0ms]" />
      <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:150ms]" />
      <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:300ms]" />
    </span>
  );
}
typescript 复制代码
// components/chat/ToolCallCard.tsx
'use client';

import { Card, CardBody, Chip, Spinner } from '@heroui/react';
import { ChevronDownIcon } from 'lucide-react';
import { useState } from 'react';

interface ToolCallCardProps {
  toolName: string;
  args: Record<string, unknown>;
  result?: unknown;
  state: 'call' | 'partial-call' | 'result';
}

const TOOL_LABELS: Record<string, string> = {
  web_search: '🔍 网络搜索',
  calculate: '🧮 数学计算',
  query_database: '🗄️ 数据库查询',
};

export function ToolCallCard({ toolName, args, result, state }: ToolCallCardProps) {
  const [expanded, setExpanded] = useState(false);

  return (
    <Card className="mx-12 my-2 border-small border-default-200 bg-default-50">
      <CardBody className="p-3">
        <button
          className="flex w-full items-center justify-between"
          onClick={() => setExpanded(!expanded)}
        >
          <div className="flex items-center gap-2">
            <Chip size="sm" variant="flat" color="secondary">
              {TOOL_LABELS[toolName] ?? toolName}
            </Chip>
            {state !== 'result' && <Spinner size="sm" />}
            {state === 'result' && <span className="text-xs text-success">✓ 完成</span>}
          </div>
          <ChevronDownIcon
            className={`h-4 w-4 transition-transform ${expanded ? 'rotate-180' : ''}`}
          />
        </button>

        {expanded && (
          <div className="mt-2 space-y-2 text-xs">
            <div>
              <span className="font-medium text-default-500">参数:</span>
              <pre className="mt-1 rounded bg-default-100 p-2 overflow-x-auto">
                {JSON.stringify(args, null, 2)}
              </pre>
            </div>
            {result && (
              <div>
                <span className="font-medium text-default-500">结果:</span>
                <pre className="mt-1 rounded bg-default-100 p-2 overflow-x-auto max-h-40">
                  {JSON.stringify(result, null, 2)}
                </pre>
              </div>
            )}
          </div>
        )}
      </CardBody>
    </Card>
  );
}

6.4 根布局(HeroUI + Tailwind)

typescript 复制代码
// app/layout.tsx
import type { Metadata } from 'next';
import { HeroUIProvider } from '@heroui/react';
import { ThemeProvider as NextThemesProvider } from 'next-themes';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'AI Agent Studio',
  description: '流式 AI Agent 对话平台',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-CN" suppressHydrationWarning>
      <body className={inter.className}>
        <HeroUIProvider>
          <NextThemesProvider
            attribute="class"
            defaultTheme="dark"
            enableSystem={false}
          >
            {children}
          </NextThemesProvider>
        </HeroUIProvider>
      </body>
    </html>
  );
}

6.5 Tailwind 配置

typescript 复制代码
// tailwind.config.ts
import type { Config } from 'tailwindcss';
import { heroui } from '@heroui/react';

const config: Config = {
  content: [
    './app/**/*.{ts,tsx}',
    './components/**/*.{ts,tsx}',
    './node_modules/@heroui/theme/dist/**/*.{js,ts,mjs,mts}',
  ],
  darkMode: 'class',
  theme: {
    extend: {
      // 自定义动画:流式打字效果
      keyframes: {
        'fade-in-up': {
          '0%': { opacity: '0', transform: 'translateY(8px)' },
          '100%': { opacity: '1', transform: 'translateY(0)' },
        },
      },
      animation: {
        'fade-in-up': 'fade-in-up 0.3s ease-out',
      },
    },
  },
  plugins: [heroui()],
};

export default config;

6.6 Prisma Client 单例

typescript 复制代码
// lib/prisma.ts
import { PrismaClient } from '@prisma/client';

// 🔑 开发环境热重载时避免创建多个连接池
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development'
      ? ['query', 'error', 'warn']
      : ['error'],
    // 🔑 连接池配置(生产环境建议搭配 PgBouncer)
    datasources: {
      db: {
        url: process.env.DATABASE_URL,
      },
    },
  });

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

6.7 Redis Client

typescript 复制代码
// lib/redis.ts
import Redis from 'ioredis';

const globalForRedis = globalThis as unknown as {
  redis: Redis | undefined;
};

export const redis =
  globalForRedis.redis ??
  new Redis(process.env.REDIS_URL!, {
    maxRetriesPerRequest: 3,
    retryStrategy(times) {
      return Math.min(times * 200, 2000);
    },
  });

if (process.env.NODE_ENV !== 'production') {
  globalForRedis.redis = redis;
}

7. 数据库设计

7.1 Prisma Schema

php 复制代码
// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

// ============================================
// 用户
// ============================================
model User {
  id            String         @id @default(cuid())
  email         String         @unique
  name          String?
  conversations Conversation[]
  createdAt     DateTime       @default(now())
  updatedAt     DateTime       @updatedAt

  @@map("users")
}

// ============================================
// 对话
// ============================================
model Conversation {
  id        String    @id @default(cuid())
  title     String    @default("新对话")
  userId    String
  user      User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  messages  Message[]
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  @@index([userId, updatedAt(sort: Desc)])
  @@map("conversations")
}

// ============================================
// 消息
// ============================================
model Message {
  id             String     @id @default(cuid())
  conversationId String
  conversation   Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
  role           Role
  content        String     @db.Text
  status         MessageStatus @default(PENDING)

  // 🔑 半结构化元数据:Token 用量、模型参数、流 ID 等
  metadata       Json?

  // 工具调用关联
  toolCalls      ToolCall[]

  createdAt      DateTime   @default(now())
  updatedAt      DateTime   @updatedAt

  @@index([conversationId, createdAt])
  @@index([status])  // 🔑 用于查询"正在流式中"的消息
  @@map("messages")
}

// ============================================
// 工具调用记录
// ============================================
model ToolCall {
  id          String   @id @default(cuid())
  messageId   String
  message     Message  @relation(fields: [messageId], references: [id], onDelete: Cascade)
  toolName    String
  args        Json     // 工具参数
  result      Json?    // 工具结果
  status      ToolCallStatus @default(PENDING)
  durationMs  Int?
  createdAt   DateTime @default(now())

  @@index([messageId])
  @@map("tool_calls")
}

// ============================================
// 枚举
// ============================================
enum Role {
  user
  assistant
  system
  tool
}

enum MessageStatus {
  PENDING       // 已创建,未开始
  STREAMING     // 🔑 正在流式输出
  TOOL_CALLING  // 正在执行工具
  COMPLETED     // 完成
  ABORTED       // 用户中止
  ERROR         // 出错
}

enum ToolCallStatus {
  PENDING
  RUNNING
  COMPLETED
  ERROR
}

7.2 关键迁移注意事项

bash 复制代码
# 🔑 蓝绿部署下的数据库迁移原则:
# 迁移必须向后兼容!

# ✅ 正确:添加新列(有默认值)
ALTER TABLE messages ADD COLUMN metadata JSONB DEFAULT '{}';

# ✅ 正确:添加新表
CREATE TABLE tool_calls (...);

# ❌ 错误:删除列(旧版本代码还在用)
ALTER TABLE messages DROP COLUMN old_field;

# ❌ 错误:重命名列
ALTER TABLE messages RENAME COLUMN content TO text;

# 🔑 安全的删除流程(两次部署):
# 第 1 次部署:代码不再读写 old_field,但列保留
# 第 2 次部署:确认无代码引用后,迁移删除列

8. 部署脚本

8.1 Dockerfile

yaml 复制代码
# docker/Dockerfile
# 🔑 多阶段构建:最终镜像 ~150MB

# ---- Stage 1: 依赖安装 ----
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile

# ---- Stage 2: 构建 ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# 🔑 Prisma 生成
RUN npx prisma generate

# 🔑 Next.js 构建(standalone 模式)
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

# ---- Stage 3: 生产运行 ----
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

# 🔑 安全:非 root 用户运行
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# 🔑 只复制 standalone 产物
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

# 🔑 Prisma 引擎(standalone 模式需要手动复制)
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma

USER nextjs

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

# 🔑 健康检查
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \
  CMD wget --spider -q http://localhost:3000/api/health || exit 1

CMD ["node", "server.js"]
typescript 复制代码
// next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  // 🔑 蓝绿部署的关键:standalone 输出
  output: 'standalone',

  // 实验性功能
  experimental: {
    // 🔑 instrumentation.ts 支持
    instrumentationHook: true,
  },

  // 服务端外部包(不打包进 bundle)
  serverExternalPackages: [
    '@prisma/client',
    'ioredis',
    'mathjs',
  ],
};

export default nextConfig;

8.2 蓝绿部署脚本

bash 复制代码
#!/usr/bin/env bash
# scripts/deploy.sh
# 🔑 AI Agent 蓝绿部署脚本
# 用法: ./deploy.sh <image_tag>

set -euo pipefail

# ============================================
# 配置
# ============================================
IMAGE_TAG="${1:?用法: ./deploy.sh <image_tag>}"
IMAGE_NAME="ai-agent"
NGINX_CONF="/etc/nginx/conf.d/active_upstream.conf"
DRAIN_TIMEOUT=600  # 10 分钟
HEALTH_CHECK_RETRIES=10
HEALTH_CHECK_INTERVAL=5

# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m'

log()  { echo -e "${BLUE}[DEPLOY]${NC} $(date '+%H:%M:%S') $*"; }
ok()   { echo -e "${GREEN}[  OK  ]${NC} $*"; }
warn() { echo -e "${YELLOW}[ WARN ]${NC} $*"; }
fail() { echo -e "${RED}[ FAIL ]${NC} $*"; exit 1; }

# ============================================
# Step 1: 检测当前活跃环境
# ============================================
log "检测当前活跃环境..."

CURRENT_ACTIVE=$(grep -oP 'set \$active_backend \K\w+' "$NGINX_CONF" 2>/dev/null || echo "blue_backend")

if [[ "$CURRENT_ACTIVE" == "blue_backend" ]]; then
    ACTIVE_COLOR="blue"
    STANDBY_COLOR="green"
    STANDBY_PORT=3002
    STANDBY_CONTAINER="ai-agent-green"
    ACTIVE_CONTAINER="ai-agent-blue"
else
    ACTIVE_COLOR="green"
    STANDBY_COLOR="blue"
    STANDBY_PORT=3001
    STANDBY_CONTAINER="ai-agent-blue"
    ACTIVE_CONTAINER="ai-agent-green"
fi

log "当前活跃: ${ACTIVE_COLOR} | 部署目标: ${STANDBY_COLOR}"

# ============================================
# Step 2: 拉取新镜像 & 启动 Standby 环境
# ============================================
log "拉取镜像 ${IMAGE_NAME}:${IMAGE_TAG}..."
docker pull "${IMAGE_NAME}:${IMAGE_TAG}"

log "启动 ${STANDBY_COLOR} 环境..."
docker compose \
    --profile "$STANDBY_COLOR" \
    up -d \
    --no-deps \
    --build \
    "ai-agent-${STANDBY_COLOR}"

# 更新镜像 tag
docker tag "${IMAGE_NAME}:${IMAGE_TAG}" "${IMAGE_NAME}:${STANDBY_COLOR}"

# ============================================
# Step 3: 健康检查
# ============================================
log "等待 ${STANDBY_COLOR} 环境就绪..."

for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
    HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
        "http://127.0.0.1:${STANDBY_PORT}/api/health" 2>/dev/null || echo "000")

    if [[ "$HTTP_CODE" == "200" ]]; then
        ok "${STANDBY_COLOR} 环境健康检查通过 (尝试 ${i}/${HEALTH_CHECK_RETRIES})"
        break
    fi

    if [[ "$i" -eq "$HEALTH_CHECK_RETRIES" ]]; then
        fail "${STANDBY_COLOR} 环境健康检查失败!HTTP ${HTTP_CODE}。回滚:停止 ${STANDBY_COLOR}"
        docker compose --profile "$STANDBY_COLOR" stop "ai-agent-${STANDBY_COLOR}"
        exit 1
    fi

    log "健康检查 ${i}/${HEALTH_CHECK_RETRIES}: HTTP ${HTTP_CODE},${HEALTH_CHECK_INTERVAL}s 后重试..."
    sleep "$HEALTH_CHECK_INTERVAL"
done

# ============================================
# Step 4: 🔑 切换 Nginx 流量
# ============================================
log "切换 Nginx 流量: ${ACTIVE_COLOR} → ${STANDBY_COLOR}..."

cat > "$NGINX_CONF" << EOF
# 由 deploy.sh 自动生成于 $(date -Iseconds)
# 上次活跃: ${ACTIVE_COLOR}_backend
set \$active_backend ${STANDBY_COLOR}_backend;
EOF

# 🔑 Nginx reload 是平滑的------不会断开已有连接
nginx -t && nginx -s reload
ok "Nginx 流量已切换到 ${STANDBY_COLOR}"

# ============================================
# Step 5: 🔑 等待旧环境连接排空(核心!)
# ============================================
log "等待 ${ACTIVE_COLOR} 环境连接排空 (超时: ${DRAIN_TIMEOUT}s)..."

ELAPSED=0
CHECK_INTERVAL=10

while [[ $ELAPSED -lt $DRAIN_TIMEOUT ]]; do
    # 查询旧环境的活跃流式任务数
    ACTIVE_STREAMS=$(curl -s \
        "http://127.0.0.1:$((STANDBY_PORT == 3001 ? 3002 : 3001))/api/health" \
        2>/dev/null | jq -r '.activeStreams // 0' 2>/dev/null || echo "unknown")

    # 也检查 Nginx 层面的活跃连接
    NGINX_ACTIVE=$(curl -s http://127.0.0.1:8080/nginx_status 2>/dev/null \

        | grep -oP 'Active connections: \K\d+' || echo "unknown")

    log "  活跃流式任务: ${ACTIVE_STREAMS} | Nginx 活跃连接: ${NGINX_ACTIVE} | 已等待: ${ELAPSED}s"

    if [[ "$ACTIVE_STREAMS" == "0" ]]; then
        ok "${ACTIVE_COLOR} 环境所有流式任务已完成!"
        break
    fi

    sleep "$CHECK_INTERVAL"
    ELAPSED=$((ELAPSED + CHECK_INTERVAL))
done

if [[ $ELAPSED -ge $DRAIN_TIMEOUT ]]; then
    warn "排空超时 (${DRAIN_TIMEOUT}s)!仍有活跃连接。"
    read -p "是否强制停止 ${ACTIVE_COLOR}?(y/N) " -n 1 -r
    echo
    if [[ ! $REPLY =~ ^[Yy]$ ]]; then
        warn "取消强制停止。${ACTIVE_COLOR} 容器保持运行,请手动处理。"
        exit 0
    fi
fi

# ============================================
# Step 6: 停止旧环境
# ============================================
log "停止 ${ACTIVE_COLOR} 环境..."

# 🔑 发送 SIGTERM(触发 Graceful Shutdown)
docker compose stop --timeout 30 "ai-agent-${ACTIVE_COLOR}"

ok "部署完成!"
echo ""
echo "=========================================="
echo "  活跃环境: ${STANDBY_COLOR}"
echo "  镜像版本: ${IMAGE_TAG}"
echo "  时间: $(date -Iseconds)"
echo "=========================================="

8.3 CI/CD 流水线(GitHub Actions)

yaml 复制代码
# .github/workflows/deploy.yml
name: Build & Blue-Green Deploy

on:
  push:
    tags: ['v*']

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Login to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          file: docker/Dockerfile
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: deploy
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /opt/ai-agent
            echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
            docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
            docker tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} ai-agent:${{ github.ref_name }}
            ./scripts/deploy.sh ${{ github.ref_name }}

9. 踩坑分析

9.1 Nginx proxy_buffering 导致流式输出"卡顿"

现象 :前端 useChat 收到的不是逐字 token,而是每隔 2-3 秒一大段文字。

原因 :Nginx 默认 proxy_buffering on,会攒够 proxy_buffer_size(默认 4KB/8KB)才发给客户端。

解决

bash 复制代码
proxy_buffering off;
# 或者在 Response Header 中设置(代码层面)
# 'X-Accel-Buffering': 'no'

两个都要设? 防御性编程。如果 Nginx 配置被其他运维覆盖,代码层面的 Header 是最后一道防线。

9.2 proxy_read_timeout 默认 60s 杀死长任务

现象 :AI Agent 执行到第 65 秒时连接突然断开,前端报错 network error

原因 :Nginx 的 proxy_read_timeout 默认 60s。如果在 60s 内后端没有发送任何数据(比如 Agent 正在执行一个耗时的工具调用),Nginx 认为后端无响应,主动断开。

解决

typescript 复制代码
proxy_read_timeout 600s;

进阶:在工具调用期间发送 SSE 心跳包,防止任何中间代理超时:

typescript 复制代码
// 在工具执行期间,每 15 秒发送一个心跳
const heartbeat = setInterval(() => {
  controller.enqueue(encoder.encode(': heartbeat\n\n'));
}, 15000);

try {
  const toolResult = await executeTool(toolCall);
  // ...
} finally {
  clearInterval(heartbeat);
}

9.3 Next.js Standalone 模式缺少 Prisma 引擎

现象 :Docker 容器启动后报错 Cannot find module '.prisma/client'

原因output: 'standalone' 只复制 Next.js 运行时必需的文件,Prisma 的查询引擎(.prisma/client)不在其中。

解决:在 Dockerfile 中手动复制:

typescript 复制代码
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma

9.4 数据库迁移在蓝绿切换时失败

现象 :新版本代码需要 tool_calls 表,但迁移还没跑,Green 环境启动后 500。

原因 :蓝绿部署中,数据库是共享的。如果迁移在 Green 启动之后才跑,Green 的代码会访问不存在的表。

解决:迁移必须在 启动新环境之前 执行,且必须 向后兼容:

bash 复制代码
# deploy.sh 中,在 Step 2 之前加入:
log "执行数据库迁移..."
docker run --rm \
    --network ai-agent_ai-agent-net \
    -e DATABASE_URL="postgresql://agent:${DB_PASSWORD}@postgres:5432/ai_agent" \
    "${IMAGE_NAME}:${IMAGE_TAG}" \
    npx prisma migrate deploy
ok "数据库迁移完成"

9.5 useChat 的 onFinish 在部署切换时不触发

现象 :蓝绿切换后,前端的 isLoading 状态卡在 true,输入框永远禁用。

原因 :SSE 连接在 Nginx reload 时虽然不会断,但 HTTP/2 的 GOAWAY 帧可能导致 EventSource 触发 error 而非 closeuseChatonFinish 只在正常 DONE 时触发。

解决

typescript 复制代码
// 前端增加超时保护
useEffect(() => {
  if (!isLoading) return;

  const timeout = setTimeout(() => {
    console.warn('[ChatWindow] 流式超时,强制重置状态');
    stop();  // 调用 useChat 的 stop
  }, 10 * 60 * 1000);  // 10 分钟

  return () => clearTimeout(timeout);
}, [isLoading]);

9.6 Redis 分布式锁在进程崩溃后不释放

现象 :用户报告"对话一直显示执行中",无法发送新消息。

原因 :进程被 kill -9(OOM Killer 等),finally 块未执行,Redis 锁未释放。

解决:锁必须设置 TTL(已在代码中 ex: 600),但还需要一个兜底机制:

typescript 复制代码
// 前端在收到 409 时,提供"强制解锁"按钮
// 后端解锁端点(需要管理员权限)
// app/api/agent/unlock/route.ts
export async function POST(req: Request) {
  const { conversationId } = await req.json();
  // 验证管理员权限...
  await redis.del(`agent:lock:${conversationId}`);
  await prisma.message.updateMany({
    where: { conversationId, status: 'STREAMING' },
    data: { status: 'ERROR', metadata: { error: 'Force unlocked' } },
  });
  return NextResponse.json({ ok: true });
}

9.7 Docker 容器内 DNS 解析慢导致首次请求超时

现象 :Green 环境健康检查前几次总是超时,第 4-5 次才通过。

原因 :Docker 内置 DNS 在容器刚启动时有 1-2s 延迟,加上 Next.js 冷启动(Prisma 引擎初始化、路由编译),start_period 不够。

解决

yaml 复制代码
healthcheck:
  start_period: 30s  # 给足冷启动时间

9.8 Tailwind CSS 4 + HeroUI 的暗色模式闪烁

现象 :页面加载瞬间先显示白色(亮色),然后闪一下变成暗色。

原因 :next-themes 在客户端 hydration 之前不知道用户偏好。

解决

html 复制代码
// app/layout.tsx
<html lang="zh-CN" suppressHydrationWarning>  // 🔑 必须加
typescript 复制代码
// 在 <head> 中注入内联脚本(Next.js 15 支持)
<script
  dangerouslySetInnerHTML={{
    __html: `
      try {
        const theme = localStorage.getItem('theme') || 'dark';
        document.documentElement.classList.add(theme);
      } catch {}
    `,
  }}
/>

10. 替代方案

10.1 部署方案对比

方案 适用规模 流式任务保护 复杂度 成本
Nginx 蓝绿(本文) 1-2 台服务器 ✅ 连接排空 ★★☆ $
Vercel / Railway 托管 小团队 ❌ 无法控制 ★☆☆ $$
K8s Rolling Update + PDB 3+ 节点 ✅ Pod 终止宽限期 ★★★★ $
K8s + Istio + Envoy 大规模微服务 ✅ 最精细控制 ★★★★★ $

生产建议

  • 日活 < 10K:本文方案(Nginx + Docker Compose)
  • 日活 10K ~ 100K:K8s + terminationGracePeriodSeconds: 600 + preStop Hook
  • 日活 > 100K:K8s + Istio,按路径级别的流量管理

10.2 AI SDK 替代方案

方案 流式支持 Agent 编排 学习曲线 适用场景
Vercel AI SDK(本文) ✅ 一流 ⚠️ 线性 对话式 Agent
LangChain.js + LangGraph ✅ DAG/分支 复杂工作流
自行封装 OpenAI SDK ✅ 手动 ✅ 完全控制 极简需求
Mastra TypeScript 优先

10.3 流式协议替代方案

协议 优点 缺点 适用场景
SSE(本文) 单向、简单、HTTP 兼容 仅服务端→客户端 AI 流式输出
WebSocket 双向、低延迟 复杂、需心跳、代理配置麻烦 实时协作
HTTP/2 Server Push 无需额外连接 浏览器支持差 资源预加载
gRPC-Web Streaming 强类型、高效 需 Envoy 代理 内部微服务

为什么选 SSE? AI Agent 的流式输出是 单向的(服务端 → 客户端),用户的输入通过独立的 POST 请求发送。SSE 天然匹配这个模式,且:

  • 基于 HTTP,Nginx/CDN/防火墙全部兼容
  • 浏览器原生 EventSource 支持自动重连
  • 不需要像 WebSocket 那样处理心跳、重连、状态同步

11. 生产 Checklist

部署前

  • prisma migrate deploy 向后兼容性验证
  • 新镜像在 staging 环境通过 E2E 测试
  • 确认 proxy_read_timeout ≥ 最长 Agent 任务时间
  • 确认 proxy_buffering off 已生效
  • Redis 内存 < 80%(锁 + 缓存)
  • PostgreSQL 连接池余量充足(Blue + Green 同时运行时连接数翻倍)

部署中

  • 健康检查通过后再切流量
  • nginx -t 配置语法检查
  • 监控面板确认 SSE 活跃连接数
  • 排空等待不超过 10 分钟

部署后

  • 验证新环境 /api/health 返回 200
  • 发送一条测试消息,确认流式输出正常
  • 检查日志无 ECONNREFUSEDPrismaClientInitializationError
  • 确认旧环境容器已停止(释放资源)
  • 监控 30 分钟:错误率、P99 延迟、Token 消耗

监控告警(推荐)

yaml 复制代码
# 关键指标
- 活跃 SSE 连接数 > 100 → 告警
- 流式任务平均时长 > 5min → 告警
- 数据库连接池使用率 > 80% → 告警
- Redis 内存 > 200MB → 告警
- 健康检查连续 3 次失败 → 紧急告警
- 部署排空时间 > 8min → 告警(可能有泄漏)

12. 总结

核心设计原则回顾

  1. 流式任务不是"可以中断的请求" → 部署策略围绕"不中断"设计,而非"快速重启"
  2. 应用层有颜色,数据层无颜色 → Blue/Green 共享 PostgreSQL + Redis → 迁移必须向后兼容
  3. 三层防御:部署层 + 应用层 + 数据层 → Nginx 不切连接 → 进程等待任务 → DB 持久化状态
  4. 配置关键
    • proxy_buffering off → 流式体验
    • proxy_read_timeout 600s → 长任务存活
    • X-Accel-Buffering: no → 防御性双保险
  5. 为失败设计,而非为成功设计
    • 锁有 TTL → 进程崩溃不会永久死锁
    • 消息有状态机 → kill -9 后可恢复
    • 前端有超时保护 → 不会永远 loading

蓝绿部署不是新技术,SSE 流式输出也不是新技术。但当 AI Agent 把请求时长从毫秒拉到分钟级,当一次中断意味着数美元的 Token 费用和用户信任的流失------这些"老技术"的组合方式、配置细节、边界处理,就成了产品体验的分水岭。

部署的最高境界,是用户根本不知道发生了部署。

本文所有代码基于 Next.js 15.3+ / React 19 / Vercel AI SDK 4.x / Prisma 6.x / HeroUI 2.x 编写,最后更新于 2026 年 7 月。

相关推荐
小渔村的拉线工2 小时前
1.HPM6E80 解析芯片整体工作原理和存储架构
单片机·嵌入式硬件·mcu·架构·hpm6e80
杨某不才3 小时前
如何能让Linux服务器对shell 终端 + sftp 文件传输长期保活
linux·运维·服务器
天天鸭3 小时前
5 万处中文的老项目实现国际化,如何用架构思维完成改造?
前端·javascript·架构
上海安当技术3 小时前
统一身份认证平台怎么落地?11 个异构业务系统接入 ASP 的完整实施路径
数据库·servlet·架构·kubernetes·jenkins
明月_清风3 小时前
🚀 OpenAI 数据代理架构全解析:从 600 PB 到自然语言的六层上下文工程
前端·后端·架构
三言老师4 小时前
文本工具组合统计服务器日志数据
linux·运维·服务器
m0_743697594 小时前
DNS服务器
运维·服务器
2601_962502904 小时前
点胶点钻机运动控制与视觉定位系统解析:精度、算法与工程实现
大数据·架构
鬼鬼鬼5 小时前
从 Prompt 到 Harness:企业级 Agent 工程的完整演进之路
设计模式·架构·ai编程