news 2026/10/17 6:49:45

Day89-综合项目(下):多模型路由 + 用量统计 + 上线部署

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Day89-综合项目(下):多模型路由 + 用量统计 + 上线部署

RAG 主链路在本地跑通,只是第一步。卡住 80% AI 工程师的是"怎么变成对外能用的服务"——不是技术难,是工程杂。三件事要做:

  1. 模型路由:复杂推理走 GPT-4o、客服短问答走 DeepSeek、月账单直接砍半

  2. 用量统计:每个用户每次花了多少钱,月底看清单不用猜,老板要查能秒出

  3. 一键部署:5 个服务 + 4 个中间件,docker compose up -d5 分钟拉起


一、先看我们要补哪两块

回顾 Day87 的架构图,红色部分是这一篇要补的:

┌─ rag-service ──────────────┐ │ Day88 已实现 │ gateway-ai ──→│ DocumentChunkService │ (本篇实现) │ RagSearchService │ │ RagChatController (SSE) │ └─────────────────────────────┘ │ └→ admin-service (本篇实现:用量落库 + 仪表盘) ​ 其它服务(document-service / conversation-svc / bff)维持原状

gateway-ai是 Day49 微服务化那篇讲的"模型网关"的具体落地,admin-service是 Day79 用量统计的具体实现。两块独立部署,互不影响主链路。


二、多模型动态路由:业务侧无感,账单会说话

2.1 为什么必须做路由

我见过一个最离谱的账单:某团队全员用 GPT-4o 写 SQL 解释,月花费 12 万。后来把 70% 的请求切到 DeepSeek,账单降到 1.8 万——模型选择是 AI 系统里 ROI 最高的优化手段。

路由要解决三件事:

维度怎么决定例子
场景简单客服/翻译/总结 vs 复杂推理客服走 mini,代码审查走 GPT-4o
成本单次调用预算0.001 美元以下用 DeepSeek
可用性主模型挂了切备用DeepSeek 超时切通义千问

2.2 模型路由实现

// ai-platform-gateway-ai 模块 @Component public class ModelRouter { ​ /** 模型客户端池,key 是模型标识 */ private final Map<String, ChatModel> models = new ConcurrentHashMap<>(); /** Micrometer 指标:每选一次模型都打点,Grafana 能看分布 */ private final MeterRegistry metrics; /** 各模型的实时熔断状态 */ private final CircuitBreakerRegistry breakers; ​ // 路由决策权重(可放进 Nacos 配置,动态调整) @Value("${router.cost-weight:0.4}") private double costWeight; @Value("${router.latency-weight:0.3}") private double latencyWeight; @Value("${router.quality-weight:0.3}") private double qualityWeight; ​ public ModelRouter(Map<String, ChatModel> modelBeans, MeterRegistry metrics, CircuitBreakerRegistry breakers) { modelBeans.forEach((k, v) -> models.put(k, v)); this.metrics = metrics; this.breakers = breakers; } ​ /** * 根据 hint 选一个模型。 * 这是 AI 系统里被调用最频繁的入口,必须做到 0 阻塞、0 远程调用。 */ public ChatModel route(RouteHint hint) { String chosen = decide(hint); metrics.counter("llm.route.chosen", "model", chosen, "scenario", hint.scenario()).increment(); return models.get(chosen); } ​ private String decide(RouteHint h) { // ① 熔断的模型直接排除 Set<String> available = models.keySet().stream() .filter(name -> breakers.circuitBreaker(name).getState() != CircuitBreaker.State.OPEN) .collect(Collectors.toSet()); ​ // ② 按场景分流(绝大多数请求走这里) String byScenario = switch (h.scenario()) { case "code-review", "deep-reason", "complex-rag" -> available.contains("gpt-4o") ? "gpt-4o" : "qwen-long"; case "translation" -> available.contains("claude-sonnet") ? "claude-sonnet" : "gpt-4o-mini"; case "summarize" -> h.inputLength() < 500 ? "gpt-4o-mini" : "claude-sonnet"; case "customer-service", "general" -> h.budgetPerCall() < 0.001 ? "deepseek-chat" : "gpt-4o-mini"; default -> "gpt-4o-mini"; }; ​ if (available.contains(byScenario)) return byScenario; ​ // ③ 兜底:场景命中但该模型熔断,取第一个可用 return available.stream().findFirst().orElseThrow( () -> new IllegalStateException("所有模型都不可用,请检查 LLM 服务")); } ​ /** 用于 A/B 测试:同一用户始终走同一模型,避免体验抖动 */ public ChatModel routeAB(RouteHint hint, String experimentName) { if (experimentName == null) return route(hint); int hash = Math.abs(Objects.hash(hint.userId(), experimentName)); boolean toTreatment = (hash & 0x7F) < 0x40; // 50% 流量 String model = toTreatment ? "deepseek-chat" : "gpt-4o-mini"; metrics.counter("llm.ab.test", "experiment", experimentName, "arm", toTreatment ? "treatment" : "control").increment(); return models.get(model); } }

几个关键细节:

  • 路由决策是本地计算,不调远程,纳秒级返回。不要去数据库查权重,全放进内存或本地配置。

  • 熔断器用 Resilience4j,Open 状态持续 30 秒后自动切到 Half-Open 试探。DeepSeek 抽风时它能把流量秒切到备用。

  • routeAB用userId哈希:同一用户在同一实验里始终走同一模型,否则他一会儿收到 DeepSeek 的答案一会儿收到 GPT 的,体感混乱。

2.3 模型客户端配置

// ai-platform-gateway-ai/config/ModelConfig.java @Configuration public class ModelConfig { ​ @Bean("deepseek-chat") public ChatModel deepSeekChat(@Value("${llm.deepseek.api-key}") String key) { return new DeepSeekChatModel( "https://api.deepseek.com", key, "deepseek-chat"); } ​ @Bean("gpt-4o-mini") public ChatModel gpt4oMini(@Value("${llm.openai.api-key}") String key) { return new OpenAiChatModel( "https://api.openai.com", key, "gpt-4o-mini"); } ​ @Bean("gpt-4o") public ChatModel gpt4o(@Value("${llm.openai.api-key}") String key) { return new OpenAiChatModel( "https://api.openai.com", key, "gpt-4o"); } ​ @Bean("qwen-long") public ChatModel qwenLong(@Value("${llm.qwen.api-key}") String key) { return new QwenChatModel( "https://dashscope.aliyuncs.com", key, "qwen-long"); } ​ /** 每个模型一个熔断器,失败率 > 50% 持续 10 秒就熔断 */ @Bean public CircuitBreakerRegistry circuitBreakerRegistry() { CircuitBreakerConfig cfg = CircuitBreakerConfig.custom() .failureRateThreshold(50) .waitDurationInOpenState(Duration.ofSeconds(30)) .slidingWindowSize(20) .build(); return CircuitBreakerRegistry.of(cfg); } } application.yml 关键段: llm: deepseek: api-key: ${DEEPSEEK_API_KEY} base-url: https://api.deepseek.com openai: api-key: ${OPENAI_API_KEY} base-url: https://api.openai.com qwen: api-key: ${QWEN_API_KEY} base-url: https://dashscope.aliyuncs.com ​ router: cost-weight: 0.4 latency-weight: 0.3 quality-weight: 0.3

2.4 在 RAG 服务里使用路由

Day88 的 RagChatController 改成注入 ModelRouter,而不是写死 deepseek-chat: @RestController @RequestMapping("/api/v1/rag") public class RagChatController { ​ @Autowired private RagSearchService searchService; @Autowired private ModelRouter modelRouter; @Autowired private ConversationRepository conversationRepository; ​ @GetMapping(value = "/ask", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> ask( @RequestParam String question, @RequestParam(required = false) Long conversationId, @RequestHeader("X-User-Id") Long userId) { ​ // 根据场景选择模型:复杂 RAG 用 GPT-4o,简单客服用 mini RouteHint hint = new RouteHint( userId, "complex-rag", question.length(), 0.005 // 单次预算 5 厘 ); ChatModel chosenModel = modelRouter.route(hint); ​ // ... 后续 Day88 的检索 + 流式输出代码不变 // chatClient 改成用 chosenModel.stream(...) } }

2.5 路由效果怎么验证

光看代码不知道有没有用。两条线验证:

  1. 离线评测:用 50 个真实业务问题,让 4 个模型各跑一遍,统计准确率/成本/延迟,画成雷达图。每月回归一次,新模型上线前必须有这张图。

  2. 在线 A/B:上线新模型走routeAB,灰度 10% → 30% → 50% → 100%,每个阶段至少跑 3 天,对比业务指标(追问率、人工接管率)。

别相信"新模型肯定更好"的嘴炮,用数据说话。


三、用量统计:每分钱都有出处

3.1 落库时机

关键认知:用量落库要在LLM 响应返回前同步落,否则月底补账永远算不准。

落库逻辑放在ModelRouter.route()返回的模型外面包一层:

@Component @RequiredArgsConstructor public class BillingChatModel implements ChatModel { ​ private final ChatModel delegate; private final UsageRecorder usageRecorder; private final MeterRegistry metrics; ​ @Override public ChatResponse call(Prompt prompt) { long start = System.currentTimeMillis(); try { ChatResponse resp = delegate.call(prompt); // ① 成功:记录用量 + 成本 recordUsage(prompt, resp, start, null); return resp; } catch (Throwable t) { // ② 失败:记录失败指标,prompt 侧 token 也得算(按最大预估) recordUsage(prompt, null, start, t.getClass().getSimpleName()); throw t; } } ​ private void recordUsage(Prompt prompt, ChatResponse resp, long start, String errType) { // 这里的 token 计数,OpenAI/DeepSeek 协议都会在 response.usage 里返回 Usage usage = resp != null ? resp.getMetadata().getUsage() : null; int promptTokens = usage != null ? usage.getPromptTokens() : estimateTokens(prompt); int completionTokens = usage != null ? usage.getCompletionTokens() : 0; String modelName = delegate.getClass().getSimpleName(); long costCents = computeCostCents(modelName, promptTokens, completionTokens); ​ // ① 异步落库(不阻塞主链路) usageRecorder.recordAsync(UsageRecord.builder() .userId(currentUserId()) // 从 ThreadLocal / Context 拿 .model(modelName) .promptTokens(promptTokens) .completionTokens(completionTokens) .costCents(costCents) .latencyMs(System.currentTimeMillis() - start) .errType(errType) .createdAt(Instant.now()) .build()); ​ // ② Prometheus 指标 metrics.counter("llm.tokens.total", "type", "prompt", "model", modelName).increment(promptTokens); metrics.counter("llm.tokens.total", "type", "completion", "model", modelName).increment(completionTokens); metrics.counter("llm.cost.cents.total", "model", modelName).increment(costCents); if (errType != null) { metrics.counter("llm.call.error", "model", modelName, "type", errType).increment(); } } ​ /** 成本计算,单位"分"避免浮点 */ private long computeCostCents(String model, int promptTok, int completionTok) { // 价格表(2026 年中参考价,按 ¥7/$1 折算成人民币分) return switch (model) { case "deepseek-chat" -> (long) ((promptTok * 0.14 + completionTok * 0.28) / 7); case "gpt-4o-mini" -> (long) ((promptTok * 1.5 + completionTok * 6.0) / 7); case "gpt-4o" -> (long) ((promptTok * 75 + completionTok * 225) / 7); case "qwen-long" -> (long) ((promptTok * 0.5 + completionTok * 2.0) / 7); default -> 0L; }; } }

3.2 异步落库与聚合

@Service @RequiredArgsConstructor public class UsageRecorder { ​ private final KafkaTemplate<String, UsageRecord> kafka; /** 实时累计,用于实时仪表盘(小时粒度) */ private final RedisTemplate<String, AtomicLong> redis; /** 日聚合,用于月报表 */ private final UsageDailyRepository dailyRepo; ​ /** * 异步落库,主链路不等待。 * 失败时降级到本地日志,运维定期捞回。 */ @Async("usageExecutor") public void recordAsync(UsageRecord r) { try { kafka.send("llm-usage", String.valueOf(r.userId()), r); } catch (Exception e) { // Kafka 不可用降级到 Redis Stream redis.opsForStream().add("usage-fallback", toMap(r)); log.warn("usage kafka send failed, fallback to redis: {}", e.getMessage()); } } } ​ /** 消费端:日聚合落库 */ @Component @RequiredArgsConstructor public class UsageDailyConsumer { ​ private final UsageDailyRepository repo; ​ @KafkaListener(topics = "llm-usage", groupId = "usage-aggregator") public void consume(UsageRecord r) { // 同一用户同一模型同一天的记录累加 UsageDailyKey key = new UsageDailyKey(r.userId(), r.model(), LocalDate.ofInstant(r.createdAt(), ZoneId.systemDefault())); repo.upsert(key, r.promptTokens(), r.completionTokens(), r.costCents(), 1); } }

3.3 仪表盘接口与前端

管理后台需要一个/api/v1/admin/usage/dashboard接口,按天/按模型聚合:

@GetMapping("/api/v1/admin/usage/dashboard") public UsageDashboard dashboard( @RequestParam @DateTimeFormat(iso = DATE) LocalDate from, @RequestParam @DateTimeFormat(iso = DATE) LocalDate to, @RequestParam(required = false) Long userId) { ​ return UsageDashboard.builder() .summary(dailyRepo.summary(from, to, userId)) // 总调用/总 token/总成本 .byModel(dailyRepo.aggregateByModel(from, to, userId)) .byDay(dailyRepo.dailySeries(from, to, userId)) .topUsers(dailyRepo.topUsersByCost(from, to, 10)) .build(); }

返回示例(前端 Echarts 直接画):

{ "summary": { "totalCalls": 12450, "totalPromptTokens": 28900000, "totalCompletionTokens": 5320000, "totalCostCents": 186420 }, "byModel": [ { "model": "deepseek-chat", "calls": 8720, "costCents": 42180 }, { "model": "gpt-4o-mini", "calls": 3100, "costCents": 86400 }, { "model": "gpt-4o", "calls": 480, "costCents": 51000 }, { "model": "qwen-long", "calls": 150, "costCents": 6840 } ], "byDay": [ { "date": "2026-08-26", "costCents": 24580, "calls": 1620 }, { "date": "2026-08-27", "costCents": 28140, "calls": 1850 }, ... ] }

前端 Vue3 + Element Plus 画饼图 + 折线图,参考 Day59-Day60 那套组件库。关键的运营指标:

  • 成本 Top 10 用户:内部知识库看到这列表就要找用户聊,是不是有人刷接口

  • 单次调用成本分布:发现异常贵请求(Prompt 写太长?循环调用?)

  • 按模型成本占比:判断路由策略是否生效

3.4 用户自查询接口

每个用户能查自己的用量,减少客诉:

@GetMapping("/api/v1/me/usage") public UsageReport myUsage(@AuthenticationPrincipal UserPrincipal user, @RequestParam(defaultValue = "30") int days) { LocalDate since = LocalDate.now().minusDays(days); return UsageReport.builder() .totalCalls(usageRepo.countCalls(user.id(), since)) .totalCostCents(usageRepo.sumCost(user.id(), since)) .byModel(usageRepo.aggregateByModel(user.id(), since)) .dailySeries(usageRepo.dailySeries(user.id(), since)) .build(); }

B 端用户:嵌入企业控制台;C 端用户:放进"账单明细"。让用户能自己查,比每天被问"这个月怎么这么贵"省心 10 倍。


四、Docker Compose 一键拉起

4.1 服务清单

回顾 Day87 的架构,我们要拉起 5 个 Spring Boot 服务 + 4 个中间件:

角色服务镜像/构建
数据库PostgreSQL 16 + pgvectorpgvector/pgvector:pg16
缓存Redis 7redis:7-alpine
消息队列RabbitMQ 3rabbitmq:3-management
对象存储MinIOminio/minio
检索兜底Elasticsearch 8elasticsearch:8.11.0
应用document-service本地多阶段构建
应用rag-service本地多阶段构建
应用conversation-service本地多阶段构建
应用gateway-ai本地多阶段构建
应用admin-service本地多阶段构建
反代Nginxnginx:alpine

4.2 生产级 Compose 文件

# docker/docker-compose.prod.yml version: '3.9' ​ services: # ==================== 基础设施 ==================== postgres: image: pgvector/pgvector:pg16 restart: always environment: POSTGRES_DB: kbplatform POSTGRES_USER: kb POSTGRES_PASSWORD_FILE: /run/secrets/pg_pwd secrets: [pg_pwd] volumes: - pgdata:/var/lib/postgresql/data - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U kb"] interval: 10s timeout: 5s retries: 5 deploy: resources: { limits: { cpus: '2', memory: 4G } } ​ redis: image: redis:7-alpine restart: always command: redis-server --appendonly yes --requirepass ${REDIS_PWD} volumes: - redisdata:/data healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PWD}", "ping"] interval: 10s deploy: resources: { limits: { cpus: '1', memory: 2G } } ​ rabbitmq: image: rabbitmq:3-management restart: always environment: RABBITMQ_DEFAULT_USER: kb RABBITMQ_DEFAULT_PASS_FILE: /run/secrets/rabbit_pwd secrets: [rabbit_pwd] ports: ["15672:15672"] # 控制台,生产可不开 deploy: resources: { limits: { cpus: '1', memory: 1G } } ​ minio: image: minio/minio restart: always command: server /data --console-address ":9001" environment: MINIO_ROOT_USER_FILE: /run/secrets/minio_user MINIO_ROOT_PASSWORD_FILE: /run/secrets/minio_pwd secrets: [minio_user, minio_pwd] volumes: - miniodata:/data ports: ["9001:9001"] ​ elasticsearch: image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0 restart: always environment: discovery.type: single-node xpack.security.enabled: "false" ES_JAVA_OPTS: "-Xms2g -Xmx2g" volumes: - esdata:/usr/share/elasticsearch/data deploy: resources: { limits: { cpus: '2', memory: 4G } } ​ # ==================== 应用服务 ==================== document-service: build: context: ../ai-platform-document dockerfile: Dockerfile restart: always depends_on: postgres: { condition: service_healthy } rabbitmq: { condition: service_started } minio: { condition: service_started } environment: SPRING_PROFILES_ACTIVE: prod DB_PASSWORD_FILE: /run/secrets/pg_pwd RABBIT_PASSWORD_FILE: /run/secrets/rabbit_pwd MINIO_PASSWORD_FILE: /run/secrets/minio_pwd secrets: [pg_pwd, rabbit_pwd, minio_pwd] deploy: resources: { limits: { cpus: '2', memory: 3G } } ​ rag-service: build: { context: ../ai-platform-rag, dockerfile: Dockerfile } restart: always depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } elasticsearch: { condition: service_started } gateway-ai: { condition: service_started } environment: SPRING_PROFILES_ACTIVE: prod DB_PASSWORD_FILE: /run/secrets/pg_pwd REDIS_PASSWORD: ${REDIS_PWD} DEEPSEEK_API_KEY_FILE: /run/secrets/deepseek_key OPENAI_API_KEY_FILE: /run/secrets/openai_key QWEN_API_KEY_FILE: /run/secrets/qwen_key secrets: [pg_pwd, deepseek_key, openai_key, qwen_key] deploy: resources: { limits: { cpus: '2', memory: 4G } } ​ conversation-service: build: { context: ../ai-platform-conversation, dockerfile: Dockerfile } restart: always depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } environment: SPRING_PROFILES_ACTIVE: prod DB_PASSWORD_FILE: /run/secrets/pg_pwd REDIS_PASSWORD: ${REDIS_PWD} secrets: [pg_pwd] deploy: resources: { limits: { cpus: '1', memory: 2G } } ​ gateway-ai: build: { context: ../ai-platform-gateway-ai, dockerfile: Dockerfile } restart: always depends_on: [postgres, redis] environment: SPRING_PROFILES_ACTIVE: prod DB_PASSWORD_FILE: /run/secrets/pg_pwd REDIS_PASSWORD: ${REDIS_PWD} DEEPSEEK_API_KEY_FILE: /run/secrets/deepseek_key OPENAI_API_KEY_FILE: /run/secrets/openai_key QWEN_API_KEY_FILE: /run/secrets/qwen_key secrets: [pg_pwd, deepseek_key, openai_key, qwen_key] deploy: resources: { limits: { cpus: '2', memory: 4G } } ​ admin-service: build: { context: ../ai-platform-admin, dockerfile: Dockerfile } restart: always depends_on: postgres: { condition: service_healthy } rabbitmq: { condition: service_started } environment: SPRING_PROFILES_ACTIVE: prod DB_PASSWORD_FILE: /run/secrets/pg_pwd RABBIT_PASSWORD_FILE: /run/secrets/rabbit_pwd secrets: [pg_pwd, rabbit_pwd] deploy: resources: { limits: { cpus: '1', memory: 2G } } ​ # ==================== 反向代理 ==================== nginx: image: nginx:alpine restart: always depends_on: - rag-service - admin-service volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./certs:/etc/nginx/certs:ro ports: ["80:80", "443:443"] deploy: resources: { limits: { cpus: '0.5', memory: 256M } } ​ volumes: pgdata: {} redisdata: {} miniodata: {} esdata: {} ​ secrets: pg_pwd: { file: ./secrets/pg_pwd.txt } redis_pwd: { file: ./secrets/redis_pwd.txt } rabbit_pwd: { file: ./secrets/rabbit_pwd.txt } minio_user: { file: ./secrets/minio_user.txt } minio_pwd: { file: ./secrets/minio_pwd.txt } deepseek_key: { file: ./secrets/deepseek_key.txt } openai_key: { file: ./secrets/openai_key.txt } qwen_key: { file: ./secrets/qwen_key.txt }

4.3 多阶段构建:镜像体积砍 70%

应用服务的Dockerfile(以rag-service为例):

# 阶段一:构建 FROM maven:3.9-eclipse-temurin-21 AS builder WORKDIR /build COPY pom.xml . RUN mvn -B -e dependency:go-offline COPY src ./src RUN mvn -B -e clean package -DskipTests ​ # 阶段二:运行时 FROM eclipse-temurin:21-jre-alpine WORKDIR /app COPY --from=builder /build/target/ai-platform-rag-*.jar app.jar # 非 root 运行 RUN addgroup -S app && adduser -S app -G app USER app EXPOSE 8080 # 镜像启动时优雅停机 ENTRYPOINT ["java", "-XX:+UseG1GC", \ "-XX:MaxRAMPercentage=75.0", \ "-jar", "/app/app.jar"]

为什么不用 Spring Boot 的 fat jar launcher:虚拟线程(JDK 21)下手动指定 GC 参数更可控。

4.4 Nginx 配置(关键段)

# docker/nginx.conf upstream rag_upstream { server rag-service:8080; keepalive 32; } upstream admin_upstream { server admin-service:8080; keepalive 16; } ​ server { listen 443 ssl; server_name kb.example.com; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; ​ # SSE 关键配置:禁用缓冲、禁用超时 location /api/v1/rag/ask { proxy_pass http://rag_upstream; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_buffering off; # 关闭缓冲,SSE 必需 proxy_cache off; proxy_read_timeout 300s; # 5 分钟硬上限 proxy_set_header X-Accel-Buffering no; } ​ location /api/v1/admin/ { proxy_pass http://admin_upstream; proxy_read_timeout 60s; } ​ location /api/v1/documents/ { proxy_pass http://rag_upstream; # 文档管理走 rag-service client_max_body_size 100m; # 大文件上传 } }

SSE 三个魔鬼参数(缺一不可):

  • proxy_buffering off—— 否则 Nginx 会把响应缓存完才转发,前端看不到流式

  • proxy_read_timeout 300s—— 默认 60 秒会断

  • X-Accel-Buffering: no—— 兼容某些 Nginx 版本

4.5 一键部署脚本

#!/usr/bin/env bash # docker/deploy.sh set -euo pipefail ​ SERVER=${1:?"用法: ./deploy.sh user@server-ip"} REMOTE_DIR=/opt/knowledge-platform IMAGE_TAG=${IMAGE_TAG:-$(git rev-parse --short HEAD)} ​ echo "=== [1/5] 构建应用镜像 ===" cd .. docker build -t ghcr.io/example/ai-platform-rag:${IMAGE_TAG} ./ai-platform-rag docker build -t ghcr.io/example/ai-platform-gateway-ai:${IMAGE_TAG} ./ai-platform-gateway-ai docker build -t ghcr.io/example/ai-platform-admin:${IMAGE_TAG} ./ai-platform-admin # ... 其它服务同理 ​ echo "=== [2/5] 推送镜像到镜像仓库 ===" docker push ghcr.io/example/ai-platform-rag:${IMAGE_TAG} # ... 其它服务 ​ echo "=== [3/5] 同步配置到服务器 ===" rsync -az --delete \ --exclude 'secrets/*.txt' \ ./docker/ $SERVER:$REMOTE_DIR/docker/ ​ echo "=== [4/5] 远端拉起服务 ===" ssh $SERVER << EOF cd $REMOTE_DIR/docker ​ # 注入密钥(运维一次性执行) [ -f secrets/deepseek_key.txt ] || echo "\$DEEPSEEK_API_KEY" > secrets/deepseek_key.txt chmod 600 secrets/*.txt ​ # 拉镜像 + 启动 docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d ​ # 等待健康检查 sleep 30 docker compose -f docker-compose.prod.yml ps EOF ​ echo "=== [5/5] 验证 ===" HEALTH=$(curl -sf https://kb.example.com/actuator/health || echo "FAIL") if [[ "$HEALTH" == *"UP"* ]]; then echo "✅ 上线成功: $HEALTH" else echo "❌ 健康检查失败,请查日志: ssh $SERVER 'docker compose logs --tail=200'" exit 1 fi

执行:

chmod +x docker/deploy.sh ./docker/deploy.sh deploy@10.0.0.5

5 分钟后https://kb.example.com/api/v1/rag/ask?question=hi就能用了。


五、部署后的运维清单

代码上线不是结束,运维 24 小时才能算稳定。我给你三件必做:

5.1 监控告警

Grafana 必备 6 个面板(PromQL 一并给你):

面板PromQL
LLM 调用 QPSsum(rate(llm_tokens_total[5m])) by (model)
各模型成本占比sum by (model) (rate(llm_cost_cents_total[1h]))
P99 响应延迟histogram_quantile(0.99, sum(rate(rag_latency_ms_bucket[5m])) by (le, model))
错误率rate(llm_call_error_total[5m]) / rate(llm_call_total[5m])
缓存命中率rate(semantic_cache_hit_total[5m]) / (rate(semantic_cache_hit_total[5m]) + rate(semantic_cache_miss_total[5m]))
单用户成本 Top10topk(10, sum by (user_id) (rate(llm_cost_cents_total[24h])))

告警规则(建议直接进 Alertmanager):

groups: - name: ai-platform rules: - alert: HighErrorRate expr: rate(llm_call_error_total[5m]) / rate(llm_call_total[5m]) > 0.05 for: 2m labels: { severity: P1 } annotations: { summary: "LLM 错误率超过 5%" } - alert: HighCost expr: increase(llm_cost_cents_total[1h]) > 50000 labels: { severity: P2 } annotations: { summary: "LLM 单小时花费超 ¥500" } - alert: SlowRag expr: histogram_quantile(0.99, rate(rag_latency_ms_bucket[5m])) > 5000 for: 5m labels: { severity: P2 }

5.2 数据备份

# crontab 每天凌晨 3 点执行 0 3 * * * /opt/knowledge-platform/scripts/backup.sh ​ # scripts/backup.sh #!/usr/bin/env bash set -e BACKUP_DIR=/data/backup/postgres/$(date +%Y%m%d) mkdir -p "$BACKUP_DIR" docker exec postgres pg_dump -U kb kbplatform | gzip > "$BACKUP_DIR/db.sql.gz" # 同步到 OSS ossutil cp "$BACKUP_DIR/db.sql.gz" oss://kb-backup/postgres/ -r # 保留 30 天 find /data/backup/postgres -mtime +30 -delete

5.3 灰度发布

新版本不要一把全量:

# 步骤 1:单实例验证 docker compose -f docker-compose.prod.yml up -d --scale rag-service=2 ​ # 步骤 2:Nginx 切 10% 流量到新实例 # 通过 weight 控制: # upstream rag_upstream { # server rag-service-v1:8080 weight=9; # server rag-service-v2:8080 weight=1; # } ​ # 步骤 3:观察 1 小时无异常,逐步提比例 # 10% → 30% → 50% → 100% ​ # 步骤 4:缩掉旧实例 docker compose -f docker-compose.prod.yml up -d --scale rag-service=1 \ --no-deps rag-service-v1

六、给团队的三条建议

  1. 路由策略先 if-else,三个月内别碰 ML。4 条 if-else 解决 90% 的路由决策。引入学习排序看似高级,实际是给自己挖坑——冷启动数据不够、调参浪费时间、ROI 极低。等日调用量过 10 万、规则扛不住时再考虑。

  2. 用量落库要"全链路",不能漏。任何一次 LLM 调用——包括 ReAct Agent 的 tool 调用、Embedding 计算、流式输出——都要落账。漏一个口子,月底账单就对不上,然后老板问你"是不是有人在薅羊毛",你只能沉默。BillingChatModel这种包装模式的好处是强制全量打点,业务侧拿不到裸ChatModel。

  3. Docker Compose 是 80% 项目的最优解,别急着 K8s。我见过太多团队Compose 没跑通就上 K8s,结果上线 3 个月,etcd 崩过 2 次、Operator 版本不兼容、运维工程师 24 小时待命。Compose 跑稳了,单机扛不住了,再上 K8s 不迟。Day58 + Day63 那套 K8s 配置留着,等量到了再翻出来。


90 天专栏走到 Day89,Day87 的架构图、Day88 的 RAG 代码、今天这篇的路由 + 统计 + 部署——一套完整可上线的 AI 知识库平台就齐了。

代码不是写完就完,能跑、能算账、能扛流量、能恢复——这才是工程。

从代码到生产,不是部署而是治理。Docker Compose 只是房子,监控告警 / 备份 / 灰度 / 应急才是物业——我见过最多"代码能跑但上不了线"的项目,都死在把部署当一次性任务。


下篇预告(Day90):Java 后端工程师的 AI 时代竞争力地图。90 天系列收官。不写代码、不画架构,聊三件事:你下一步应该学什么、面试该怎么准备 AI 题、3 年后能拿什么 offer。一份写给"听完了所有 89 篇"的你的一份成长指南。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 6:49:22

仓库管理4张表、5个流程、6个指标,一次给你讲明白!

仓库管理最怕什么&#xff1f;不是货多、人少&#xff0c;而是仓库忙得团团转&#xff0c;问题却一个接一个&#xff1a; 货物入库了&#xff0c;系统没更新&#xff1b; 生产急着领料&#xff0c;仓管员翻遍货架也找不到&#xff1b; 账面显示库存充足&#xff0c;现场却无货可…

作者头像 李华
网站建设 2026/10/17 6:49:01

某医保服务平台headers、signData、encData(SM2+SM4)逆向分析

⚠️ 免责声明&#xff1a;本文仅供技术交流与学习使用&#xff0c;请勿将相关技术用于非法用途或侵犯他人权益。仅供学习使用&#xff0c;如有侵权&#xff0c;联系删除文章目标接口&#xff1a;aHR0cHM6Ly9mdXd1Lm5oc2EuZ292LmNuL2VidXMvZnV3dS9hcGkvbnRobC9hcGkvQ29tbVF1ZXJ…

作者头像 李华
网站建设 2026/10/17 6:47:53

14|RAG 入门:构建带来源引用的知识问答 - eino教程

14&#xff5c;RAG 入门&#xff1a;构建带来源引用的知识问答 《Eino 实战&#xff1a;用 Go 构建 AI 应用与智能体》 第 14 篇 关键词&#xff1a;Retriever、RAG、上下文组装、来源引用、无结果处理 前置条件&#xff1a;已完成 13&#xff5c;知识入库。 上一篇我们把资料整…

作者头像 李华
网站建设 2026/10/17 6:46:19

案例 2.2 图片与声音开发实践博客

前言本次完成微信小程序《案例 2.2 图片与声音》开发&#xff0c;实现图片点击控制音频播放暂停基础功能&#xff0c;并按照作业要求扩展播放速率、控制台打印播放信息&#xff0c;记录完整开发过程、代码与踩坑要点。一、项目功能说明页面加载完成后&#xff0c;展示宝塔山图片…

作者头像 李华
网站建设 2026/10/17 6:45:18

电器元件-荧光灯(日光灯)

简介荧光灯就是咱们以前常说的日光灯&#xff0c;属于气体放电灯。 灯管里面抽走空气&#xff0c;充少量氩气水银&#xff0c;管壁涂一层荧光粉。原理简单说&#xff1a; 通电后&#xff0c;灯管里的水银会产生看不见的紫外线&#xff1b;紫外线打在管壁荧光粉上&#xff0c;紫…

作者头像 李华
网站建设 2026/10/17 6:44:57

机器学习之模型评估

模型评估理论基础&#xff1a; 欠拟合 (Underfitting)&#xff1a;模型过于简单&#xff0c;无法捕捉数据真实规律&#xff08;类比&#xff1a;学生没理解核心概念&#xff09;。 特征&#xff1a;训练误差和测试误差都高&#xff1b;模型复杂度不足&#xff1b;特征工程不充分…

作者头像 李华