企业AI助手从0到1(四):上线部署与运营优化
企业AI助手从0到1(四):上线部署与运营优化
开篇故事:上线第一天1000用户,第二天发现了7个大问题
2026年1月15日,陈建国的AI助手正式全量上线。
上线第一天的数据:
- 1847名员工收到通知邮件
- 当天登录使用:1023人(55.4%)
- 发送消息:6891条
- 平均响应时间:3.1秒
- 系统可用性:99.7%(3次短暂超时,各<30秒)
陈建国当天晚上11点给我发消息:"建国,系统撑住了!"
我说:"先别庆祝,看看明天的反馈。"
上线第二天,用户反馈涌入,7个大问题暴露:
问题1(紧急):有用户发现系统没有限制对话频率,他们的测试账号一分钟发了500条消息,把Token额度刷掉了2%(当月预算的2%)。
问题2(严重):知识库里有3份2024年的文档没有更新,AI据此给出了错误的请假天数(新规2025年1月1日起生效,AI还在按旧规回答)。
问题3(严重):某个部门的企微机器人集成出了问题,所有来自企微的问题都返回了500错误,影响200+人。
问题4(一般):移动端H5页面在微信浏览器里显示错位,iOS用户占比33%,这部分用户体验极差。
问题5(一般):AI在回答英文技术文档问题时,会把答案也用英文回答,但用户希望中文回答。
问题6(优化):用户反馈"等待时间太长",实测非流式接口平均4.8秒,但流式接口首字是1.8秒——前端没有默认开流式。
问题7(优化):日均Token消耗比预期高40%,原因是Prompt太长,context text截取不合理。
陈建国用了3天处理了前3个紧急/严重问题,用了1周处理了后4个。
这次上线经历,让他总结出了"20项上线前检查清单"——今天全部分享给你。
一、上线前20项检查清单
1.1 基础设施检查(5项)
□ 1. 所有环境变量已配置(数据库密码、API Key、JWT密钥)
检查方式:启动时打印配置摘要(不打印完整密钥)
□ 2. 数据库连接池大小与预期并发匹配
公式:pool_size ≥ (预期并发 × 平均查询时间/响应时间)
陈建国案例:100并发 × (20ms/3000ms) ≈ 1,设置20足够
□ 3. Redis连接数限制已配置
生产建议:max-active=32, min-idle=8
□ 4. 文件存储(MinIO/OSS)的bucket权限设置为私有
确认:只有后端服务能直接访问bucket
□ 5. 网关层限流已开启
必须:每用户每分钟请求上限,每接口QPS上限1.2 安全检查(5项)
□ 6. 生产环境JWT密钥已替换(不是默认值)
检查:grep -r "your-256-bit-secret" src/
□ 7. 所有管理员接口已添加权限注解
检查:@PreAuthorize("hasRole('ADMIN')")
□ 8. 敏感话题检测规则已测试(10个场景)
必测:薪资查询、个人信息查询、财务数据查询
□ 9. HTTPS已启用(HTTP自动跳转HTTPS)
检查:curl -I http://your-domain.com 应返回301
□ 10. 知识库文档权限已全部review
必须:每份文档的access_level由文档负责人确认1.3 功能检查(5项)
□ 11. 端到端测试通过(10个标准对话场景)
包含:普通问答、敏感话题拦截、知识库未覆盖的问题
□ 12. 流式/非流式接口都已测试
在不同网络环境下测试(WiFi、4G、内网)
□ 13. 文档上传、删除、重处理全流程测试
包含:PDF、Word、超大文件(>10MB)的处理
□ 14. 用户反馈(点赞/点踩)功能测试
确认:反馈数据已存入数据库,可在管理后台查看
□ 15. 异常情况下的用户友好提示
测试:LLM超时、知识库空、网络断开时的提示信息1.4 运维检查(5项)
□ 16. 监控看板已搭建(至少4个核心指标)
必须:请求量、响应时间、错误率、Token消耗
□ 17. 告警规则已配置(至少3条)
必须:错误率>5%告警、响应时间>10秒告警、Token日消耗>预算80%告警
□ 18. 日志收集已配置(能查询近30天日志)
工具:ELK / Loki / 阿里云日志服务
□ 19. 备份方案已验证
数据库:每日自动备份,已测试恢复流程
□ 20. 回滚方案已准备
Docker镜像已打tag,一键回滚命令已文档化二、灰度发布方案
2.1 为什么必须灰度
1000人直接全量上线,是陈建国第一次上线时犯的错。
正确做法:先给100人,收集2-3天反馈,确认没有重大问题后再全量。
灰度的本质:用10%的用户发现90%的问题,同时只影响10%的用户。
2.2 灰度实现方案
// config/GrayReleaseConfig.java
package com.enterprise.aiassistant.config;
import com.enterprise.aiassistant.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.Set;
@Slf4j
@Component
public class GrayReleaseConfig {
// 灰度比例(0-100)
@Value("${app.gray-release.percentage:10}")
private int grayPercentage;
// 指定用户白名单(内测用户,总是走新版本)
@Value("${app.gray-release.whitelist:}")
private Set<String> whitelist;
// 是否启用灰度
@Value("${app.gray-release.enabled:false}")
private boolean enabled;
/**
* 判断用户是否在灰度组
*/
public boolean isInGrayGroup(User user) {
if (!enabled) return true; // 灰度未启用,所有人走新版本
// 白名单用户直接进灰度
if (whitelist.contains(user.getUsername())) {
return true;
}
// 基于用户ID的确定性哈希分组(同一用户每次结果相同)
int hash = Math.abs(user.getId().hashCode()) % 100;
return hash < grayPercentage;
}
}2.3 灰度配置(application.yml新增)
app:
gray-release:
enabled: true
percentage: 10 # 10%用户进入灰度
whitelist:
- admin
- test_user_001
- chenjianguo # 陈建国自己2.4 灰度日程安排
第1天:10%用户(约180人)
观察:错误率 < 1%,满意度 > 4.0/5
第3天:30%用户(约540人)
观察:系统性能无劣化,无新类型错误
第5天:60%用户(约1080人)
观察:高峰期(早9点、午12点)系统稳定
第7天:100%用户(1847人)
全量上线,持续监控3天三、监控体系搭建
3.1 监控架构
3.2 自定义指标代码
// metrics/AiAssistantMetrics.java
package com.enterprise.aiassistant.metrics;
import io.micrometer.core.instrument.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.concurrent.atomic.AtomicLong;
@Slf4j
@Component
public class AiAssistantMetrics {
// 对话请求计数器
private final Counter chatRequestCounter;
private final Counter chatErrorCounter;
// 响应时间直方图
private final Timer chatResponseTimer;
// Token消耗计数器
private final Counter tokenConsumedCounter;
// 知识库检索指标
private final Counter knowledgeHitCounter;
private final Counter knowledgeMissCounter;
// 当前活跃会话数(Gauge)
private final AtomicLong activeSessionCount = new AtomicLong(0);
// 用户反馈计数器
private final Counter positiveFeedbackCounter;
private final Counter negativeFeedbackCounter;
public AiAssistantMetrics(MeterRegistry registry) {
// 对话请求计数
this.chatRequestCounter = Counter.builder("ai_chat_requests_total")
.description("对话请求总量")
.tag("type", "chat")
.register(registry);
this.chatErrorCounter = Counter.builder("ai_chat_errors_total")
.description("对话错误总量")
.register(registry);
// 响应时间(百分位数:P50/P90/P99)
this.chatResponseTimer = Timer.builder("ai_chat_response_seconds")
.description("对话响应时间(秒)")
.publishPercentiles(0.5, 0.9, 0.99)
.publishPercentileHistogram()
.register(registry);
// Token消耗
this.tokenConsumedCounter = Counter.builder("ai_tokens_consumed_total")
.description("消耗Token总量")
.register(registry);
// 知识库命中情况
this.knowledgeHitCounter = Counter.builder("ai_knowledge_searches_total")
.description("知识库检索量")
.tag("result", "hit")
.register(registry);
this.knowledgeMissCounter = Counter.builder("ai_knowledge_searches_total")
.description("知识库检索量")
.tag("result", "miss")
.register(registry);
// 活跃会话数(实时)
Gauge.builder("ai_active_sessions", activeSessionCount, AtomicLong::get)
.description("当前活跃会话数")
.register(registry);
// 用户反馈
this.positiveFeedbackCounter = Counter.builder("ai_feedback_total")
.description("用户反馈总量")
.tag("type", "positive")
.register(registry);
this.negativeFeedbackCounter = Counter.builder("ai_feedback_total")
.description("用户反馈总量")
.tag("type", "negative")
.register(registry);
}
public void recordChatRequest() {
chatRequestCounter.increment();
}
public void recordChatError() {
chatErrorCounter.increment();
}
public Timer.Sample startTimer() {
return Timer.start();
}
public void recordResponseTime(Timer.Sample sample) {
sample.stop(chatResponseTimer);
}
public void recordTokens(int count) {
tokenConsumedCounter.increment(count);
}
public void recordKnowledgeHit(boolean hit) {
if (hit) {
knowledgeHitCounter.increment();
} else {
knowledgeMissCounter.increment();
}
}
public void incrementActiveSessions() {
activeSessionCount.incrementAndGet();
}
public void decrementActiveSessions() {
activeSessionCount.decrementAndGet();
}
public void recordFeedback(boolean positive) {
if (positive) {
positiveFeedbackCounter.increment();
} else {
negativeFeedbackCounter.increment();
}
}
}3.3 在ChatService中集成指标
// 在ChatService.chat()中集成
// (在方法开头和结尾添加)
public com.enterprise.aiassistant.dto.response.ChatResponse chat(
String sessionId, ChatRequest request, User user) {
metrics.recordChatRequest();
var sample = metrics.startTimer();
try {
// ... 原有业务逻辑 ...
// 记录知识库命中情况
metrics.recordKnowledgeHit(!relevantDocs.isEmpty());
// 记录Token消耗
if (tokensUsed > 0) {
metrics.recordTokens(tokensUsed);
}
metrics.recordResponseTime(sample);
return response;
} catch (Exception e) {
metrics.recordChatError();
metrics.recordResponseTime(sample);
throw e;
}
}3.4 Prometheus配置
# prometheus.yml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'ai-assistant'
static_configs:
- targets: ['ai-assistant:8080']
metrics_path: '/actuator/prometheus'
- job_name: 'postgres'
static_configs:
- targets: ['postgres-exporter:9187']
- job_name: 'redis'
static_configs:
- targets: ['redis-exporter:9121']四、Grafana看板配置
4.1 核心看板面板(Grafana JSON片段)
{
"title": "企业AI助手 - 运营看板",
"panels": [
{
"title": "今日对话量",
"type": "stat",
"targets": [{
"expr": "increase(ai_chat_requests_total[1d])",
"legendFormat": "今日对话量"
}]
},
{
"title": "响应时间P99",
"type": "gauge",
"targets": [{
"expr": "histogram_quantile(0.99, rate(ai_chat_response_seconds_bucket[5m]))",
"legendFormat": "P99响应时间(秒)"
}],
"fieldConfig": {
"defaults": {
"thresholds": {
"steps": [
{"color": "green", "value": 0},
{"color": "yellow", "value": 5},
{"color": "red", "value": 10}
]
}
}
}
},
{
"title": "知识库命中率",
"type": "gauge",
"targets": [{
"expr": "rate(ai_knowledge_searches_total{result='hit'}[1h]) / rate(ai_knowledge_searches_total[1h]) * 100",
"legendFormat": "命中率%"
}]
},
{
"title": "Token消耗趋势",
"type": "timeseries",
"targets": [{
"expr": "increase(ai_tokens_consumed_total[1h])",
"legendFormat": "每小时Token消耗"
}]
},
{
"title": "用户满意度",
"type": "stat",
"targets": [{
"expr": "rate(ai_feedback_total{type='positive'}[1d]) / rate(ai_feedback_total[1d]) * 100",
"legendFormat": "满意度%"
}]
},
{
"title": "错误率",
"type": "timeseries",
"targets": [{
"expr": "rate(ai_chat_errors_total[5m]) / rate(ai_chat_requests_total[5m]) * 100",
"legendFormat": "错误率%"
}]
}
]
}五、告警配置
5.1 告警规则
# alerting-rules.yml
groups:
- name: ai-assistant-alerts
rules:
# 规则1:错误率告警(5分钟内错误率超过5%)
- alert: HighErrorRate
expr: rate(ai_chat_errors_total[5m]) / rate(ai_chat_requests_total[5m]) > 0.05
for: 2m
labels:
severity: critical
annotations:
summary: "AI助手错误率过高"
description: "过去5分钟错误率 {{ $value | humanizePercentage }},超过阈值5%"
# 规则2:响应时间告警(P99超过10秒)
- alert: SlowResponse
expr: histogram_quantile(0.99, rate(ai_chat_response_seconds_bucket[5m])) > 10
for: 3m
labels:
severity: warning
annotations:
summary: "AI助手响应时间过长"
description: "P99响应时间 {{ $value }}秒,超过阈值10秒"
# 规则3:Token消耗告警(每小时消耗超过1万)
- alert: HighTokenConsumption
expr: increase(ai_tokens_consumed_total[1h]) > 10000
for: 0m
labels:
severity: warning
annotations:
summary: "Token消耗异常"
description: "过去1小时消耗 {{ $value }} tokens,请检查是否有异常请求"
# 规则4:服务宕机告警
- alert: ServiceDown
expr: up{job="ai-assistant"} == 0
for: 1m
labels:
severity: critical
annotations:
summary: "AI助手服务宕机"
description: "AI助手服务已宕机超过1分钟"
# 规则5:知识库命中率低告警
- alert: LowKnowledgeHitRate
expr: rate(ai_knowledge_searches_total{result="hit"}[30m]) /
rate(ai_knowledge_searches_total[30m]) < 0.5
for: 10m
labels:
severity: warning
annotations:
summary: "知识库命中率过低"
description: "过去30分钟知识库命中率 {{ $value | humanizePercentage }},低于50%,知识库可能需要补充"5.2 企业微信告警通知配置
# alertmanager.yml
global:
resolve_timeout: 5m
route:
group_by: ['alertname']
group_wait: 30s
group_interval: 5m
repeat_interval: 4h
receiver: 'wechat-work'
routes:
- match:
severity: critical
receiver: 'wechat-work'
repeat_interval: 30m # 严重告警每30分钟重复一次
receivers:
- name: 'wechat-work'
webhook_configs:
- url: 'http://alertmanager-webhook:8080/wechat'
send_resolved: true六、成本控制
6.1 Token消耗分析
陈建国上线第2天发现Token消耗比预期高40%,排查后发现:
问题1:Prompt太长
- 原来:contextText截取top_k=10个文档片段,平均2000 token
- 优化后:top_k=5,每个片段最多200字符,contextText约800 token
- 节省:每次对话约1200 token,按0.01元/1000 token计算,每次节省0.012元
问题2:系统Prompt重复
- 原来:每次都发送完整的系统Prompt(约400 token)
- 优化:系统Prompt固定,利用API的system角色缓存(支持前缀缓存的模型,如GPT-4o)
- 节省:约30%的系统Prompt token
6.2 Token消耗监控服务
// service/CostMonitorService.java
package com.enterprise.aiassistant.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class CostMonitorService {
private final AiAssistantMetrics metrics;
// 每1000 token的价格(元)
@Value("${app.cost.price-per-1k-tokens:0.01}")
private double pricePerThousandTokens;
// 每月Token预算
@Value("${app.cost.monthly-budget-tokens:5000000}")
private long monthlyBudgetTokens;
/**
* 每小时检查Token消耗趋势
* 如果预估月度消耗将超预算80%,发出告警
*/
@Scheduled(fixedRate = 3600_000)
public void checkCostTrend() {
// TODO: 实现Token消耗趋势分析
// 获取今日累计消耗,估算月度消耗
// 与预算对比,超过80%时告警
log.info("成本监控检查完成");
}
/**
* 每月1日生成成本报告
*/
@Scheduled(cron = "0 0 8 1 * ?")
public void generateMonthlyReport() {
log.info("=== 月度成本报告 ===");
// TODO: 查询上月Token总消耗
// 计算实际成本
// 与预算对比
// 输出报告并发邮件给技术负责人
}
}6.3 Token优化策略实现
// util/PromptOptimizer.java
package com.enterprise.aiassistant.util;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class PromptOptimizer {
@Value("${app.knowledge.max-context-chars:1600}")
private int maxContextChars;
/**
* 优化RAG上下文文本长度
* 控制总字符数,避免Token超限
*/
public String buildOptimizedContext(
List<org.springframework.ai.document.Document> docs) {
if (docs.isEmpty()) {
return "(知识库中未找到相关文档)";
}
StringBuilder sb = new StringBuilder();
int usedChars = 0;
for (int i = 0; i < docs.size(); i++) {
var doc = docs.get(i);
String title = (String) doc.getMetadata()
.getOrDefault("doc_title", "未知文档");
String content = doc.getText();
// 每个文档片段最多200字符
int maxCharsForThisDoc = Math.min(200,
maxContextChars - usedChars - 20); // 20留给标题
if (maxCharsForThisDoc <= 0) break;
if (content.length() > maxCharsForThisDoc) {
content = content.substring(0, maxCharsForThisDoc) + "...";
}
String entry = String.format("【文档%d: %s】\n%s\n\n",
i + 1, title, content);
sb.append(entry);
usedChars += entry.length();
if (usedChars >= maxContextChars) break;
}
return sb.toString();
}
}七、用户反馈分析与知识库持续优化
7.1 反馈处理流程
7.2 反馈分析服务
// service/FeedbackAnalysisService.java
package com.enterprise.aiassistant.service;
import com.enterprise.aiassistant.repository.MessageRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class FeedbackAnalysisService {
private final MessageRepository messageRepository;
/**
* 每天早8点分析前一天的差评
*/
@Scheduled(cron = "0 0 8 * * ?")
public void analyzeDailyFeedback() {
LocalDate yesterday = LocalDate.now().minusDays(1);
// 获取昨日所有差评消息
List<Map<String, Object>> negativeMessages =
messageRepository.findNegativeFeedbackWithContext(yesterday);
if (negativeMessages.isEmpty()) {
log.info("昨日无差评,系统运行良好");
return;
}
log.info("=== 昨日差评分析报告 ===");
log.info("差评数量: {}", negativeMessages.size());
// 分类分析
long knowledgeGap = negativeMessages.stream()
.filter(m -> isKnowledgeGapIssue(m))
.count();
long aiHallucination = negativeMessages.stream()
.filter(m -> isHallucinationIssue(m))
.count();
long otherIssues = negativeMessages.size() - knowledgeGap - aiHallucination;
log.info("知识库缺失问题: {} 条", knowledgeGap);
log.info("AI幻觉问题: {} 条", aiHallucination);
log.info("其他问题: {} 条", otherIssues);
// 高频差评问题提取(Top5)
log.info("Top5差评问题:");
negativeMessages.stream()
.limit(5)
.forEach(m -> log.info(" 问题: {}", m.get("user_question")));
}
private boolean isKnowledgeGapIssue(Map<String, Object> message) {
String answer = (String) message.getOrDefault("ai_answer", "");
// 如果AI回答中有"找不到"、"不知道"等,说明是知识库缺失
return answer.contains("找不到") || answer.contains("没有相关信息")
|| answer.contains("建议联系");
}
private boolean isHallucinationIssue(Map<String, Object> message) {
String feedbackText = (String) message.getOrDefault("feedback_text", "");
// 用户反馈文字中提到"错误"、"不对"、"胡说"等
return feedbackText.contains("错误") || feedbackText.contains("不对")
|| feedbackText.contains("胡说") || feedbackText.contains("不准确");
}
}八、版本迭代:基于数据的功能优先级决策
8.1 功能优先级决策框架
上线后如何决定下一版本做什么?不能靠拍脑袋,要靠数据。
决策维度(加权):
1. 用户需求强度(权重40%)
数据来源:差评内容分析 + 用户调研NPS问卷
2. 使用频率差异(权重30%)
数据来源:各功能使用量统计,找出高使用低满意的功能
3. 业务价值(权重20%)
数据来源:功能使用对应的业务价值估算
4. 开发成本(权重10%)
数据来源:技术评估工作量8.2 陈建国项目第二期决策
上线6周后,数据给出的优先级排序:
| 功能 | 用户需求 | 使用频率 | 业务价值 | 工作量 | 综合得分 |
|---|---|---|---|---|---|
| 知识库时效性检查(文档过期提醒) | 高 | 高 | 高 | 小 | 92分 |
| 移动端体验优化 | 高 | 高 | 中 | 中 | 84分 |
| 多知识库切换 | 中 | 中 | 高 | 中 | 72分 |
| 语音输入 | 低 | 低 | 中 | 大 | 38分 |
第二期只做前两个,放弃语音输入。
九、完整部署方案
9.1 生产环境docker-compose
# docker-compose.prod.yml
version: '3.9'
services:
# AI助手应用
ai-assistant:
image: enterprise/ai-assistant:${VERSION:-latest}
container_name: ai-assistant
restart: unless-stopped
environment:
SPRING_PROFILES_ACTIVE: prod
DB_USERNAME: ${DB_USERNAME}
DB_PASSWORD: ${DB_PASSWORD}
REDIS_PASSWORD: ${REDIS_PASSWORD}
OPENAI_API_KEY: ${OPENAI_API_KEY}
JWT_SECRET: ${JWT_SECRET}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
deploy:
resources:
limits:
memory: 2G
cpus: '2.0'
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
# PostgreSQL(pgvector)
postgres:
image: pgvector/pgvector:pg16
container_name: ai-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ai_assistant
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./backup:/backup # 备份目录挂载
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ai_assistant"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
memory: 4G
# Redis
redis:
image: redis:7-alpine
container_name: ai-redis
restart: unless-stopped
command: redis-server --requirepass ${REDIS_PASSWORD} --appendonly yes
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 3s
retries: 3
# MinIO
minio:
image: minio/minio:latest
container_name: ai-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
volumes:
- minio_data:/data
ports:
- "9001:9001" # 仅暴露管理UI,API端口不对外
# Nginx反向代理
nginx:
image: nginx:alpine
container_name: ai-nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d
- ./nginx/ssl:/etc/nginx/ssl
- ./nginx/html:/usr/share/nginx/html
depends_on:
- ai-assistant
# Prometheus
prometheus:
image: prom/prometheus:latest
container_name: ai-prometheus
restart: unless-stopped
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
- ./monitoring/alerting-rules.yml:/etc/prometheus/rules.yml
- prometheus_data:/prometheus
ports:
- "9090:9090"
# Grafana
grafana:
image: grafana/grafana:latest
container_name: ai-grafana
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
volumes:
- grafana_data:/var/lib/grafana
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards
ports:
- "3000:3000"
volumes:
postgres_data:
redis_data:
minio_data:
prometheus_data:
grafana_data:9.2 Nginx配置
# nginx/conf.d/ai-assistant.conf
upstream ai_backend {
server ai-assistant:8080;
keepalive 32;
}
server {
listen 80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# 一般接口
location /api/ {
proxy_pass http://ai_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_connect_timeout 10s;
proxy_read_timeout 60s;
}
# SSE流式接口(需要特殊配置)
location /api/chat/sessions/*/messages/stream {
proxy_pass http://ai_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# SSE关键配置
proxy_read_timeout 300s; # 等待流式响应的超时时间
proxy_buffering off; # 禁用缓冲,让数据实时推送
proxy_cache off;
add_header X-Accel-Buffering no; # 禁用Nginx加速缓冲
# SSE Connection headers
proxy_set_header Connection '';
proxy_http_version 1.1;
}
# 静态资源(前端)
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
expires 1h;
}
}十、项目复盘:6个月运营数据
陈建国的项目上线满6个月,他给我发来了一份数据报告。
10.1 核心业务指标
| 指标 | 上线1月 | 上线3月 | 上线6月 |
|---|---|---|---|
| 日均活跃用户 | 823人 | 1102人 | 1256人 |
| 日均对话量 | 4563条 | 6234条 | 7891条 |
| 知识库命中率 | 81% | 87% | 91% |
| 用户满意度 | 3.8/5 | 4.2/5 | 4.5/5 |
| HR日均人工咨询 | 减少62% | 减少71% | 减少78% |
| 系统可用性 | 99.1% | 99.7% | 99.9% |
10.2 成本数据
| 月份 | Token消耗 | API费用 | 基础设施费用 | 总费用 |
|---|---|---|---|---|
| 第1月 | 420万 | 4200元 | 1800元 | 6000元 |
| 第3月 | 580万 | 3770元 | 1800元 | 5570元 |
| 第6月 | 720万 | 3600元 | 1800元 | 5400元 |
注:第3月后Token费用下降,是因为Prompt优化(上下文截取优化)。
10.3 ROI估算
投入:
开发成本:陈建国+1名助手,4个月,约30万元
每月运维成本:5400元
收益:
HR团队节省时间:3人 × 每天3小时 × 6个月 ≈ 解放1080人时
折算薪资价值:1080小时 × 100元/时 = 10.8万元
员工自助查询效率提升(难量化,业务价值约20万元估算)
简单ROI(6个月)= (10.8 + 20) / (30 + 0.54×6) = 约88%
预计12个月回本陈建国说:"老板看完这份报告,立刻批了预算,要做第二期:设备维修助手。"
FAQ
Q1:系统上线后维护工作量大吗?
A:稳定运行后,每周大概需要2-4小时的运维工作:查看监控看板、处理用户反馈、更新知识库文档。偶尔会有LLM服务的API变更需要适配(Spring AI帮你屏蔽了大部分变化)。
Q2:Docker Compose适合多大规模?要不要上K8s?
A:2000人以内的企业,Docker Compose完全够用。主要瓶颈是单机资源,而不是编排。1000 QPS以内,单台16核32G的服务器可以支撑。上K8s的门槛是:需要水平扩展、需要零停机发布、团队有K8s运维经验——满足3个条件再上,否则是增加复杂度。
Q3:知识库文档有几千份,向量化需要很长时间吗?
A:3000份文档、平均20页/份,预计向量化时间约6-8小时(受OpenAI API限流影响)。建议在上线前一天夜间批量跑完,之后增量更新每份文档<5分钟。
Q4:Grafana看板能共享给业务负责人吗?
A:可以创建只读账号。建议给业务负责人看"业务视角"看板(满意度、日活、命中率),给技术负责人看"技术视角"看板(响应时间、错误率、Token消耗)。
Q5:上线后LLM API价格涨了怎么办?
A:这是真实发生过的风险。建议:1)合同中约定API使用量,避免超用;2)代码层面抽象LLM调用(Spring AI已做到),切换模型只改配置;3)准备国产模型备用方案(通义千问/文心一言),价格通常更有优势。
补充:完整的回滚与CI/CD流程
回滚方案
#!/bin/bash
# rollback.sh - 一键回滚脚本
ROLLBACK_VERSION=${1:-"previous"} # 指定版本,默认回滚到上一个版本
echo "=== 开始回滚 ==="
echo "目标版本: $ROLLBACK_VERSION"
# 1. 备份当前运行的版本号
CURRENT_VERSION=$(docker inspect ai-assistant --format='{{.Config.Image}}' 2>/dev/null)
echo "当前版本: $CURRENT_VERSION"
# 2. 停止当前服务
echo "停止当前服务..."
docker-compose -f docker-compose.prod.yml stop ai-assistant
# 3. 切换镜像
if [ "$ROLLBACK_VERSION" == "previous" ]; then
# 自动获取上一个版本
ROLLBACK_IMAGE=$(docker images enterprise/ai-assistant --format "{{.Tag}}" | sed -n '2p')
echo "自动选择上一版本: $ROLLBACK_IMAGE"
else
ROLLBACK_IMAGE="$ROLLBACK_VERSION"
fi
# 更新docker-compose.yml中的版本
export VERSION="$ROLLBACK_IMAGE"
# 4. 启动回滚版本
echo "启动回滚版本..."
docker-compose -f docker-compose.prod.yml up -d ai-assistant
# 5. 等待服务健康
echo "等待服务健康检查..."
for i in {1..12}; do
sleep 5
STATUS=$(docker inspect ai-assistant --format='{{.State.Health.Status}}' 2>/dev/null)
echo "健康状态: $STATUS (${i}/12)"
if [ "$STATUS" == "healthy" ]; then
echo "=== 回滚成功! 当前运行版本: $ROLLBACK_IMAGE ==="
exit 0
fi
done
echo "=== 警告: 回滚后服务健康检查超时,请手动检查 ==="
exit 1CI/CD流水线配置(GitHub Actions)
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
tags: ['v*']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
- name: Build with Maven
run: mvn clean package -DskipTests
- name: Run Tests
run: mvn test
- name: Build Docker Image
run: |
VERSION=${GITHUB_REF_NAME:-latest}
docker build -t enterprise/ai-assistant:$VERSION .
docker tag enterprise/ai-assistant:$VERSION enterprise/ai-assistant:latest
- name: Push to Registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login ${{ secrets.REGISTRY_URL }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
VERSION=${GITHUB_REF_NAME:-latest}
docker push enterprise/ai-assistant:$VERSION
docker push enterprise/ai-assistant:latest
deploy:
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: Deploy to Production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_SERVER }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /opt/ai-assistant
VERSION=${GITHUB_REF_NAME}
export VERSION=$VERSION
docker-compose -f docker-compose.prod.yml pull ai-assistant
docker-compose -f docker-compose.prod.yml up -d ai-assistant
echo "部署完成: $VERSION"系列总结
这是"企业AI助手从0到1"系列的第四篇(终篇)。
四篇系列回顾:
- 第一篇:需求分析 → 不做全能AI,从最痛的场景开始
- 第二篇:知识库建设 → 文档质量决定回答质量,分块策略是关键
- 第三篇:对话+权限 → 权限不是事后补丁,要从设计阶段就考虑
- 第四篇:上线+运营 → 上线不是终点,是另一个开始
陈建国的项目,从需求混乱到稳定运行,走了整整8个月。这8个月里踩过的坑,都在这四篇文章里了。
下一个系列(article-179),我们换一个视角:金融行业AI应用实战,合规才是最大的技术挑战。
