技术栈: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 体验 | ✅ | ✅ | ✅ | ✅ |
关键决策理由:
- Route Handlers 原生支持
ReadableStream:这是 SSE 流式输出的基础设施。App Router 的app/api/chat/route.ts可以直接返回new Response(stream),无需额外的 Express/Fastify 层。 - Server Components 减少客户端 JS 体积:AI Agent 的对话历史、工具调用记录等重数据在 Server Component 中直接查库渲染,客户端只接收序列化后的 HTML,首屏性能显著提升。
- Vercel AI SDK 与 Next.js 的集成是最深的:
useChat、useCompletion、streamText等 API 在 Next.js 中是零配置开箱即用的。虽然 SDK 也支持其他框架,但文档、示例、社区解答都围绕 Next.js。 output: 'standalone'模式:构建产物是一个自包含的 Node.js 服务器,Docker 镜像可以控制在 ~150MB(对比完整 node_modules 的 ~1GB),这对蓝绿部署的镜像拉取速度至关重要。
缺点与权衡:
- App Router 的心智模型比 Pages Router 复杂(Server/Client Component 边界、
'use client'指令) - 对 Node.js 运行时有硬依赖(不像 Astro 可以纯静态部署)
- 自托管时需要自行处理缓存策略(
revalidate、unstable_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 的理由:
- 协议层封装:自动处理 SSE 格式、
[DONE]信号、错误序列化、客户端重连。你不需要关心data: {...}\n\n的拼接。 - useChat Hook 开箱即用:前端一个 Hook 搞定流式渲染、消息状态管理、自动滚动、中止控制。
- Tool Calling 支持:AI Agent 的核心是工具调用,SDK 的
tools参数 +maxSteps实现了多步 Agent 循环。 - Provider 抽象:切换 OpenAI / Anthropic / 本地 Ollama 只需换一行 import。
缺点:
- 对复杂 Agent 编排(DAG、条件分支、人工审批节点)的支持不如 LangGraph
- 流式协议是 Vercel 自定义的 Data Stream Protocol,与标准 SSE 有差异(虽然也支持纯 SSE 模式)
- 版本迭代快,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 而非 close。useChat 的 onFinish 只在正常 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
- 发送一条测试消息,确认流式输出正常
- 检查日志无
ECONNREFUSED、PrismaClientInitializationError - 确认旧环境容器已停止(释放资源)
- 监控 30 分钟:错误率、P99 延迟、Token 消耗
监控告警(推荐)
yaml
# 关键指标
- 活跃 SSE 连接数 > 100 → 告警
- 流式任务平均时长 > 5min → 告警
- 数据库连接池使用率 > 80% → 告警
- Redis 内存 > 200MB → 告警
- 健康检查连续 3 次失败 → 紧急告警
- 部署排空时间 > 8min → 告警(可能有泄漏)
12. 总结
核心设计原则回顾
- 流式任务不是"可以中断的请求" → 部署策略围绕"不中断"设计,而非"快速重启"
- 应用层有颜色,数据层无颜色 → Blue/Green 共享 PostgreSQL + Redis → 迁移必须向后兼容
- 三层防御:部署层 + 应用层 + 数据层 → Nginx 不切连接 → 进程等待任务 → DB 持久化状态
- 配置关键
proxy_buffering off→ 流式体验proxy_read_timeout 600s→ 长任务存活X-Accel-Buffering: no→ 防御性双保险
- 为失败设计,而非为成功设计
- 锁有 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 月。