【AI 工程化第十五篇】Spring AI 流式输出高并发性能优化实战:SSE、背压、限流、缓存与灰度发布

【AI 工程化第十五篇】Spring AI 流式输出高并发性能优化实战:SSE、背压、限流、缓存与灰度发布

系列定位:

前面 13 篇和 14 篇分别解决了"检索质量"和"安全治理"。本篇把服务推到更接近生产的最后一公里:用户要实时看到答案,系统还要扛住高并发、模型慢响应、客户端断线和突发流量。

技术栈:Java 17 · Spring Boot 3.5.x / 4.1.x · Spring WebFlux · Spring AI 2.0.x · Redis · PostgreSQL · Nginx

适用场景:AI 对话、RAG 问答、Agent 工作流、代码助手、客服 Copilot

目标:同步接口可用、流式接口稳定、慢请求可控、链路可观测、发布可回滚


为什么"把 call 改成 stream"远远不够

很多项目的第一版流式代码只有:

java 复制代码
return chatClient.prompt().user(question).stream().content();

在开发机上可以显示文字,上线后常见问题是:

text 复制代码
代理缓冲导致前端一次性收到全部内容
客户端断线后模型仍然继续生成,浪费 Token
下游模型变慢,连接长时间占满线程和连接池
并发升高后所有用户一起超时
心跳缺失导致负载均衡器主动断开
同一用户疯狂刷新,重复创建多个模型请求

因此流式接口需要同时设计四个面:

text 复制代码
传输层:SSE 格式、心跳、断线、代理配置
反应式:Flux、背压、取消传播、超时
治理层:限流、并发槽位、Token 预算、熔断
产品层:首 token 时间、可恢复会话、降级提示

一、目标架构设计

1.1 功能模块图

text 复制代码
┌──────────────────────────────────────────────────────────────┐
│ Browser / React / Vue                                        │
│ EventSource / fetch stream · cancel · reconnect              │
└──────────────────────────────┬───────────────────────────────┘
                               ▼
┌──────────────────────────────────────────────────────────────┐
│ Nginx / Gateway                                               │
│ proxy_buffering off · idle timeout · auth · rate limit         │
└──────────────────────────────┬───────────────────────────────┘
                               ▼
┌──────────────────────────────────────────────────────────────┐
│ Spring WebFlux SSE Controller                                 │
│ request validation · traceId · disconnect detection            │
└──────────────────────────────┬───────────────────────────────┘
                               ▼
┌──────────────────────────────────────────────────────────────┐
│ Stream Orchestrator                                           │
│ semaphore · budget · timeout · cancellation · fallback         │
└──────────────┬────────────────────────────┬───────────────────┘
               ▼                            ▼
┌──────────────────────────┐      ┌─────────────────────────────┐
│ Cache Layer              │      │ Spring AI ChatClient         │
│ exact / semantic / Redis │      │ stream() + advisors           │
└──────────────┬───────────┘      └──────────────┬──────────────┘
               ▼                                  ▼
┌──────────────────────────┐      ┌─────────────────────────────┐
│ Metrics & Audit          │◀─────│ Model Gateway / Provider      │
│ TTFT · TPS · P95 · cost  │      │ retry · circuit breaker        │
└──────────────────────────┘      └─────────────────────────────┘

1.2 时序图

text 复制代码
浏览器        WebFlux       StreamOrchestrator       ChatClient       模型
 │ 请求 ───────▶│                   │                    │              │
 │              │ 鉴权/限流 ───────▶│                    │              │
 │              │                   │ 检查缓存            │              │
 │              │                   │────────────────────▶│              │
 │              │                   │◀───────────────────│              │
 │              │                   │ 获取并发槽位        │              │
 │◀─ headers ───│◀──────────────────│                    │              │
 │              │                   │ stream() ─────────▶│─────────────▶│
 │◀─ token ─────│◀──────────────────────────────────────│◀─────────────│
 │◀─ heartbeat ─│                   │                    │              │
 │ 断开 ────────▶│                   │ cancel() ─────────▶│              │
 │              │                   │ 记录取消和成本       │              │

1.3 性能目标

指标 含义 建议目标
TTFT 首个有效 Token 时间 P95 < 1.5 秒
Token TPS 稳定输出速度 > 10 token/s,按模型调整
SSE 建连成功率 客户端成功建立流 > 99.9%
正常完成率 非取消、非超时结束 > 99%
P95 总时延 完整回答耗时 业务分层设定
取消回收率 断线后模型请求停止 > 95%
单实例并发流 活跃 SSE 数 受连接池和模型配额约束

二、Spring WebFlux SSE 基础实现

2.1 响应事件模型

java 复制代码
public record StreamEvent(
        String type,
        String requestId,
        String data,
        long timestamp
) {
    public static StreamEvent token(String requestId, String data) {
        return new StreamEvent("token", requestId, data, System.currentTimeMillis());
    }

    public static StreamEvent done(String requestId) {
        return new StreamEvent("done", requestId, "", System.currentTimeMillis());
    }

    public static StreamEvent error(String requestId, String message) {
        return new StreamEvent("error", requestId, message, System.currentTimeMillis());
    }
}

2.2 Controller

java 复制代码
@RestController
@RequestMapping("/api/v1/ai")
public class StreamingChatController {

    private final StreamingChatService streamingChatService;

    public StreamingChatController(StreamingChatService streamingChatService) {
        this.streamingChatService = streamingChatService;
    }

    @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    @PreAuthorize("hasAuthority('ai:chat')")
    public Flux<ServerSentEvent<StreamEvent>> stream(
            @AuthenticationPrincipal SecurityUser user,
            @Valid @RequestBody StreamChatRequest request) {
        String requestId = UUID.randomUUID().toString().replace("-", "");

        return streamingChatService.stream(user, requestId, request)
                .map(event -> ServerSentEvent.<StreamEvent>builder()
                        .id(requestId)
                        .event(event.type())
                        .data(event)
                        .build())
                .onErrorResume(ex -> Flux.just(ServerSentEvent.<StreamEvent>builder()
                        .id(requestId)
                        .event("error")
                        .data(StreamEvent.error(requestId, "AI 服务暂时不可用"))
                        .build()));
    }
}

接口协议建议固定为四类事件:

text 复制代码
meta:requestId、model、conversationId
token:增量文本
usage:Prompt Token、Completion Token、耗时
done / error:结束或失败

不要只发送裸字符串,否则前端无法区分"正文中的换行"和"协议控制消息"。

2.3 请求模型

java 复制代码
public record StreamChatRequest(
        @NotBlank @Size(max = 8000) String message,
        @NotBlank @Size(max = 64) String conversationId,
        String knowledgeId,
        Boolean enableReasoning
) {
}

三、正确处理 Flux、背压和取消

3.1 不要在 WebFlux 链路里阻塞

错误示例:

java 复制代码
Flux<String> result = Flux.create(sink -> {
    String answer = blockingClient.call();
    sink.next(answer);
});

如果必须调用阻塞式 SDK,要放到隔离线程池:

java 复制代码
Mono.fromCallable(blockingClient::call)
        .subscribeOn(Schedulers.boundedElastic());

更推荐直接使用支持 Reactor 的流式客户端,避免把模型响应堆到内存再发送。

3.2 取消传播

java 复制代码
public Flux<StreamEvent> stream(SecurityUser user,
                                String requestId,
                                StreamChatRequest request) {
    AtomicBoolean cancelled = new AtomicBoolean(false);
    long startedAt = System.nanoTime();

    return acquirePermit(user)
            .flatMapMany(permit -> {
                Flux<StreamEvent> modelFlux = chatClient.prompt()
                        .user(request.message())
                        .advisors(advisor -> advisor.param("conversationId",
                                request.conversationId()))
                        .stream()
                        .content()
                        .map(content -> StreamEvent.token(requestId, content))
                        .doOnCancel(() -> {
                            cancelled.set(true);
                            log.info("ai_stream cancelled requestId={} userId={}",
                                    requestId, user.userId());
                        })
                        .doFinally(signal -> {
                            permit.close();
                            recordStreamFinished(requestId, signal, cancelled.get(), startedAt);
                        });

                return Flux.concat(
                        Mono.just(new StreamEvent("meta", requestId, "", System.currentTimeMillis())),
                        modelFlux,
                        Mono.just(StreamEvent.done(requestId))
                );
            })
            .timeout(Duration.ofSeconds(90));
}

客户端关闭连接后,必须让取消信号一直传到 ChatClient.stream() 和底层 HTTP 客户端;否则用户看不到内容,但模型仍然消耗 Token。

3.3 背压策略

模型产生 Token 的速度通常不快,但下游网络和浏览器可能变慢。建议:

java 复制代码
modelFlux.onBackpressureBuffer(
        256,
        dropped -> metrics.counter("ai.stream.dropped").increment(),
        BufferOverflowStrategy.ERROR)

不要设置无限缓冲。无限缓冲的本质是把网络问题转化成堆内存问题。

对于实时聊天,宁可在极端情况下结束本次请求并提示重试,也不要让整个实例 OOM。


四、心跳和代理配置

4.1 SSE 心跳

模型可能 10 秒没有输出,但连接仍然正常。使用注释事件或心跳事件保持链路活跃:

java 复制代码
Flux<StreamEvent> heartbeat = Flux.interval(Duration.ofSeconds(10))
        .map(tick -> new StreamEvent("heartbeat", requestId, "", System.currentTimeMillis()));

return Flux.merge(modelFlux, heartbeat)
        .takeUntilOther(modelFlux.ignoreElements().then());

更简单且更安全的写法是使用 switchOnFirst 或在事件层统一发送心跳;关键是模型结束后心跳必须结束,不能泄漏定时任务。

4.2 Nginx 配置

nginx 复制代码
location /api/v1/ai/chat/stream {
    proxy_pass http://ai_backend;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;
    proxy_read_timeout 120s;
    proxy_send_timeout 120s;
    add_header X-Accel-Buffering no;
}

如果经过网关、CDN、Ingress,还要逐层确认:

text 复制代码
是否开启响应缓冲
是否允许 text/event-stream
空闲超时是多少
是否改写 Connection / Transfer-Encoding

4.3 Spring 配置

yaml 复制代码
spring:
  webflux:
    timeout:
      connect: 2s
      response: 120s
server:
  compression:
    enabled: false

对 SSE 不建议开启可能改变事件边界的压缩策略;具体要结合网关和客户端验证。


五、并发槽位:限制的是模型请求,不是 HTTP 连接

一个用户可以打开多个浏览器标签,一个 HTTP 连接也可能长时间占用资源。真正需要保护的是下游模型调用并发。

5.1 单实例并发控制

java 复制代码
@Component
public class ModelConcurrencyLimiter {

    private final Semaphore semaphore;

    public ModelConcurrencyLimiter(
            @Value("${app.ai.max-concurrent-streams:100}") int maxConcurrent) {
        this.semaphore = new Semaphore(maxConcurrent);
    }

    public Mono<Permit> acquire() {
        return Mono.defer(() -> semaphore.tryAcquire()
                ? Mono.just(new Permit(semaphore))
                : Mono.error(new TooManyRequestsException("AI 并发已满")));
    }

    public static final class Permit implements AutoCloseable {
        private final Semaphore semaphore;
        private final AtomicBoolean released = new AtomicBoolean();

        private Permit(Semaphore semaphore) {
            this.semaphore = semaphore;
        }

        @Override
        public void close() {
            if (released.compareAndSet(false, true)) {
                semaphore.release();
            }
        }
    }
}

5.2 多实例并发控制

单机 Semaphore 不能限制集群总并发。多实例场景可以选择:

text 复制代码
Redis 分布式信号量
网关按租户限流
模型网关统一排队
消息队列承接非实时长任务

关键是区分两类请求:

text 复制代码
实时聊天:低队列、快速失败、有限重试
长任务:进入队列、异步执行、结果可查询

不要把所有请求都塞进一个无限队列。


六、限流:用户、租户、IP 三个维度

6.1 Redis Token Bucket

java 复制代码
@Component
public class AiRateLimiter {

    private final StringRedisTemplate redisTemplate;

    public Mono<Void> check(AiSecurityContext context) {
        String key = "ai:rate:" + context.tenantId() + ":" + context.userId();
        return Mono.fromCallable(() -> {
            Long count = redisTemplate.opsForValue().increment(key);
            if (count != null && count == 1L) {
                redisTemplate.expire(key, Duration.ofMinutes(1));
            }
            if (count != null && count > 30) {
                throw new TooManyRequestsException("请求过于频繁");
            }
            return (Void) null;
        }).subscribeOn(Schedulers.boundedElastic()).then();
    }
}

示例使用固定窗口便于理解,生产可以使用 Lua 实现真正的 Token Bucket,并统一处理 Redis 超时。限流 Redis 故障时要明确选择:

text 复制代码
安全敏感接口:fail-closed
普通低风险问答:有限 fail-open,但记录告警

6.2 限流响应

http 复制代码
HTTP/1.1 429 Too Many Requests
Retry-After: 5
X-Trace-Id: abc123
Content-Type: application/json

前端看到 429 时应显示可重试提示,不要立刻无限重试。


七、超时、重试和熔断

7.1 超时预算拆分

总超时不应该只配置一个 120 秒:

text 复制代码
排队等待:200ms
Query Rewrite:800ms
检索:1000ms
Reranker:800ms
首 Token:1500ms
流式输出:90000ms

如果首 Token 超时,应该快速切换到降级模型或返回"请稍后重试",而不是让用户空等 90 秒。

7.2 重试原则

text 复制代码
连接建立失败:最多 1 次快速重试
已经开始输出:不要重试同一个流
用户取消:绝不重试
429:根据 Retry-After 退避
4xx 参数错误:绝不重试
5xx:结合幂等和供应商能力重试
java 复制代码
Retry retry = Retry.backoff(1, Duration.ofMillis(200))
        .filter(this::isRetryable)
        .onRetryExhaustedThrow((spec, signal) -> signal.failure());

return upstream.retryWhen(retry);

7.3 熔断

模型供应商连续失败时打开熔断:

text 复制代码
CLOSED:正常调用
OPEN:快速失败或切换备用模型
HALF_OPEN:少量探测请求

熔断状态要按"供应商 + 模型 + 区域"隔离,不能因为一个模型故障把整个 AI 平台熔断。


八、缓存:缓存结果,不缓存错误权限

8.1 精确缓存

适合:公共产品文档、固定 FAQ、无个性化权限的数据。

缓存键必须包含影响答案的全部条件:

text 复制代码
sha256(tenantId + knowledgeVersion + model + promptVersion + normalizedQuestion)
java 复制代码
public Mono<Optional<String>> getExactCache(CacheKey key) {
    return reactiveRedisTemplate.opsForValue()
            .get(key.value())
            .map(Optional::of)
            .defaultIfEmpty(Optional.empty());
}

8.2 不能缓存的场景

text 复制代码
包含用户个人信息
包含实时订单、余额、库存
不同用户 ACL 不同
涉及高风险工具执行
模型答案依赖当前会话记忆

8.3 语义缓存

语义缓存可以降低重复问题成本,但必须把以下字段放进缓存命中条件:

text 复制代码
tenantId
knowledgeId
documentVersion
promptVersion
modelName
securityPolicyVersion

知识库更新或 Prompt 版本切换时,优先按版本失效,不要逐条扫描大 Key 空间。


九、流式服务编排实现

java 复制代码
@Service
public class StreamingChatService {

    private static final Logger log = LoggerFactory.getLogger(StreamingChatService.class);

    private final ChatClient chatClient;
    private final ModelConcurrencyLimiter concurrencyLimiter;
    private final AiRateLimiter rateLimiter;
    private final AiBudgetService budgetService;
    private final StreamMetrics metrics;

    public Flux<StreamEvent> stream(SecurityUser user,
                                    String requestId,
                                    StreamChatRequest request) {
        AiSecurityContext context = user.toAiSecurityContext(requestId);
        long startedAt = System.nanoTime();

        return rateLimiter.check(context)
                .then(budgetService.check(context, request.message()))
                .then(concurrencyLimiter.acquire())
                .flatMapMany(permit -> {
                    AtomicBoolean emittedToken = new AtomicBoolean(false);
                    AtomicBoolean cancelled = new AtomicBoolean(false);

                    Flux<StreamEvent> upstream = chatClient.prompt()
                            .system("你是企业助手,只回答当前用户有权访问的内容。")
                            .user(request.message())
                            .advisors(advisor -> advisor.param("conversationId",
                                    request.conversationId()))
                            .stream()
                            .content()
                            .filter(StringUtils::hasText)
                            .map(token -> {
                                emittedToken.set(true);
                                return StreamEvent.token(requestId, token);
                            })
                            .timeout(Duration.ofSeconds(90))
                            .onBackpressureBuffer(256,
                                    dropped -> metrics.incrementDropped(requestId),
                                    BufferOverflowStrategy.ERROR)
                            .doOnSubscribe(subscription -> metrics.started(requestId))
                            .doOnCancel(() -> {
                                cancelled.set(true);
                                metrics.cancelled(requestId);
                            })
                            .doOnError(error -> metrics.failed(requestId, error))
                            .doFinally(signal -> {
                                permit.close();
                                metrics.finished(requestId, signal, emittedToken.get(),
                                        cancelled.get(), elapsedMs(startedAt));
                                log.info("ai_stream_finished requestId={} tenantId={} signal={} "
                                                + "emittedToken={} cancelled={} durationMs={}",
                                        requestId, context.tenantId(), signal,
                                        emittedToken.get(), cancelled.get(), elapsedMs(startedAt));
                            });

                    Flux<StreamEvent> heartbeats = Flux.interval(Duration.ofSeconds(10))
                            .map(tick -> new StreamEvent("heartbeat", requestId, "",
                                    System.currentTimeMillis()))
                            .takeUntilOther(upstream.ignoreElements().then());

                    return Flux.concat(
                            Mono.just(new StreamEvent("meta", requestId, "",
                                    System.currentTimeMillis())),
                            Flux.merge(upstream, heartbeats),
                            Mono.just(StreamEvent.done(requestId))
                    );
                });
    }
}

实践中建议把心跳和正文统一为一个事件流协调器,避免 merge 的结束语义过于复杂;上面的代码重点演示资源释放和取消链路。


十、断点续传和会话一致性

SSE 本身不是可靠消息队列。客户端断线后,不能简单地从头生成一遍。

10.1 事件序号

java 复制代码
public record SequencedEvent(long sequence, StreamEvent event) {
}

服务端可以把已发送事件短暂写入 Redis Stream:

text 复制代码
stream:ai:{conversationId}:{requestId}
sequence → event
TTL 10 分钟

客户端重连时带上:

http 复制代码
Last-Event-ID: 42

服务端先补发 43 之后的事件,再决定是否继续连接模型。注意:

text 复制代码
补发的是服务端已生成的事件
不能让模型从中间位置重新生成并假装连续

10.2 简化版策略

如果暂时不做续传,可以明确产品语义:

text 复制代码
断线后显示"回答已中断"
提供"继续生成"按钮
继续请求携带已有文本摘要
服务端限制最多继续一次

十一、模型调用日志和指标

11.1 日志字段

text 复制代码
traceId
requestId
tenantId
userId
conversationId
modelName
promptVersion
ttftMs
durationMs
promptTokens
completionTokens
finishReason
cancelled
errorType

11.2 Micrometer 指标

java 复制代码
@Component
public class StreamMetrics {

    private final MeterRegistry meterRegistry;

    public void ttft(String model, long durationMs) {
        Timer.builder("ai.stream.ttft")
                .tag("model", model)
                .register(meterRegistry)
                .record(durationMs, TimeUnit.MILLISECONDS);
    }

    public void inFlight(int count) {
        Gauge.builder("ai.stream.in_flight", () -> count)
                .register(meterRegistry);
    }
}

推荐仪表盘:

text 复制代码
活跃 SSE 数
模型并发槽位使用率
TTFT P50/P95/P99
Token TPS
正常结束 / 客户端取消 / 超时 / 供应商错误
429 数量
熔断状态
每租户 Token 和费用

Spring AI 会对 ChatClient 的 call() 和 stream() 操作产生观测信息。生产环境应结合自己的 requestId、租户和业务指标,形成可回溯链路。


十二、统一异常与 SSE 错误协议

流已经开始后,不能再修改 HTTP 状态码。错误要作为最后一个 SSE 事件发送:

java 复制代码
public Flux<StreamEvent> toSafeError(String requestId, Throwable ex) {
    log.error("stream failed requestId={} type={}", requestId,
            ex.getClass().getSimpleName(), ex);
    return Flux.just(StreamEvent.error(requestId, "回答生成失败,请稍后重试"));
}

连接建立前的参数错误仍然使用正常 HTTP 400/401/429。连接建立后的模型错误使用:

text 复制代码
event: error
data: {"type":"error","requestId":"...","data":"回答生成失败,请稍后重试"}

不要把供应商响应、堆栈、SQL、Prompt 和密钥放进 SSE 错误事件。


十三、前端对接示例

13.1 fetch 读取 SSE

typescript 复制代码
export interface StreamEvent {
  type: 'meta' | 'token' | 'usage' | 'heartbeat' | 'done' | 'error';
  requestId: string;
  data: string;
  timestamp: number;
}

export async function streamChat(
  request: { message: string; conversationId: string; knowledgeId?: string },
  onEvent: (event: StreamEvent) => void,
  signal: AbortSignal
): Promise<void> {
  const response = await fetch('/api/v1/ai/chat/stream', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${token}`,
      'Content-Type': 'application/json',
      Accept: 'text/event-stream'
    },
    body: JSON.stringify(request),
    signal
  });

  if (!response.ok || !response.body) {
    throw new Error(`stream request failed: ${response.status}`);
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });

    const frames = buffer.split('\n\n');
    buffer = frames.pop() ?? '';
    for (const frame of frames) {
      const dataLine = frame.split('\n').find(line => line.startsWith('data:'));
      if (!dataLine) continue;
      onEvent(JSON.parse(dataLine.slice(5).trim()) as StreamEvent);
    }
  }
}

13.2 取消请求

typescript 复制代码
const controller = new AbortController();

await streamChat(
  { message: '解释这段代码', conversationId: 'conv-001' },
  event => {
    if (event.type === 'token') appendAnswer(event.data);
    if (event.type === 'error') showError(event.data);
  },
  controller.signal
);

// 用户点击停止
controller.abort();

前端"停止生成"必须真正取消 fetch,而不是仅仅隐藏加载动画。否则后端不会收到取消信号。


十四、长任务不要硬塞进 SSE

以下任务不适合一直保持 SSE:

text 复制代码
批量解析 10 万份文档
生成几十页报告
跨多个系统的 Agent 工作流
需要人工审批的长流程

正确拆分:

text 复制代码
POST /api/v1/ai/jobs          创建任务,返回 jobId
GET  /api/v1/ai/jobs/{jobId}  查询状态
GET  /api/v1/ai/jobs/{jobId}/events  订阅进度

任务执行使用消息队列或持久化工作流;SSE 只负责推送进度。这样连接断开不会丢失任务本身。

14.1 任务表

sql 复制代码
CREATE TABLE t_ai_job (
    id              BIGSERIAL PRIMARY KEY,
    job_id          VARCHAR(64)  NOT NULL UNIQUE,
    tenant_id       VARCHAR(64)  NOT NULL,
    user_id         VARCHAR(64)  NOT NULL,
    job_type        VARCHAR(64)  NOT NULL,
    status          VARCHAR(32)  NOT NULL,
    progress        INT          NOT NULL DEFAULT 0,
    input_json      JSONB        NOT NULL,
    result_ref      VARCHAR(500),
    error_code      VARCHAR(64),
    create_time     TIMESTAMP    NOT NULL DEFAULT now(),
    update_time     TIMESTAMP    NOT NULL DEFAULT now()
);

十五、压测方案

15.1 k6 示例

javascript 复制代码
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  scenarios: {
    stream_chat: {
      executor: 'constant-arrival-rate',
      rate: 20,
      timeUnit: '1s',
      duration: '2m',
      preAllocatedVUs: 40,
      maxVUs: 200
    }
  }
};

export default function () {
  const response = http.post(
    'http://localhost:8080/api/v1/ai/chat/stream',
    JSON.stringify({
      message: '什么是幂等?',
      conversationId: `load-${__VU}`
    }),
    {
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'text/event-stream',
        'Authorization': `Bearer ${__ENV.TOKEN}`
      },
      timeout: '120s'
    }
  );

  check(response, {
    'status is 200': r => r.status === 200,
    'has stream data': r => r.body.includes('event:')
  });
  sleep(1);
}

压测不要只看 HTTP 200,要观察:

text 复制代码
首 Token 时间
并发槽位是否被占满
模型供应商 429
客户端取消后的 Token 是否继续增长
实例堆内存和连接数
网关是否缓冲

15.2 故障注入

至少模拟:

text 复制代码
模型响应延迟 30 秒
模型中途断开
Redis 限流不可用
Reranker 超时
客户端读取到一半主动断开
单租户突然突发 10 倍流量

十六、灰度和回滚

流式输出升级时,建议按以下维度灰度:

text 复制代码
租户白名单
用户百分比
模型版本
区域 / 可用区
请求类型(普通问答 / RAG / Agent)

灰度期间同时记录对照指标:

text 复制代码
TTFT、P95、完成率、取消率、Token 成本、用户点赞率

回滚开关要放在配置中心或动态策略表中,避免为了关闭新流式链路而重新发版。

yaml 复制代码
app:
  ai:
    stream-enabled: true
    stream-canary-percent: 10
    fallback-model: fast-chat-model

如果主模型连续超时,降级模型只承担低风险、低上下文请求;复杂 Agent 不要盲目降级到能力不足的模型。


十七、生产配置基线

yaml 复制代码
app:
  ai:
    max-concurrent-streams: 100
    max-stream-seconds: 90
    first-token-timeout-ms: 1500
    max-message-chars: 8000
    per-user-rpm: 30
    per-tenant-rpm: 600
    heartbeat-seconds: 10
    max-tool-calls: 4
    max-model-calls: 8

spring:
  codec:
    max-in-memory-size: 1MB
  webflux:
    timeout:
      response: 120s

这些值不是通用答案。必须结合模型配额、单实例 CPU、连接池、网关超时和业务 SLO 做压测后调整。


十八、统一异常处理

java 复制代码
@RestControllerAdvice
public class StreamExceptionHandler {

    @ExceptionHandler(TooManyRequestsException.class)
    @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
    public ApiResponse<Void> handleRateLimit(TooManyRequestsException ex) {
        return ApiResponse.failure("RATE_LIMITED", "请求过于频繁,请稍后重试");
    }

    @ExceptionHandler(AiBudgetExceededException.class)
    @ResponseStatus(HttpStatus.PAYMENT_REQUIRED)
    public ApiResponse<Void> handleBudget(AiBudgetExceededException ex) {
        return ApiResponse.failure("AI_BUDGET_EXCEEDED", "本次 AI 使用额度已用尽");
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse<Void> handleArgument(MethodArgumentNotValidException ex) {
        return ApiResponse.failure("INVALID_ARGUMENT", "请求参数不合法");
    }
}

对于流已经建立后的异常,统一由 onErrorResume 转换成 SSE error 事件;对于建立前的异常,使用标准 HTTP 状态码。


十九、上线检查清单

  • SSE 使用 text/event-stream,网关关闭 buffering
  • 浏览器取消请求能传播到模型调用
  • doFinally 释放并发槽位、计时器和上下文
  • 不使用无限背压缓冲
  • 有心跳,并验证全链路 idle timeout
  • 并发限制按模型调用槽位设计
  • 限流按用户、租户和 IP 分层
  • 首 Token、总时长、排队和检索有独立预算
  • 流开始输出后不重复重试
  • 模型供应商按模型和区域熔断
  • 缓存键包含租户、权限、知识库和 Prompt 版本
  • 实时请求与长任务分离
  • 错误事件不泄漏 Prompt、SQL、供应商信息
  • 记录 Prompt Token、Completion Token、TTFT、耗时和取消原因
  • k6 压测覆盖慢模型、断线、429 和 Redis 故障
  • 有按租户或模型版本的灰度开关和快速回滚

二十、总结

一个真正可用的 AI 流式接口,核心不是把返回类型改成 Flux,而是把整条链路的生命周期管理清楚:

text 复制代码
请求进入:鉴权、限流、预算、并发槽位
模型调用:超时、熔断、取消传播、Token 统计
数据输出:SSE 事件、心跳、背压、代理不缓冲
连接结束:释放资源、记录指标、保存会话
异常恢复:降级、重连、长任务拆分、灰度回滚

当用户点下"停止生成"后,模型真的停止;当供应商变慢时,系统能快速降级;当并发上升时,服务可以拒绝一部分请求而不是整体雪崩,这才是生产级 AI API。


参考资料

到这里,系列已经覆盖 Agent、RAG、MCP、记忆、工作流、评测、治理、安全和性能。后续可以继续扩展模型路由、成本分析、企业 SSO、私有化部署和多模态应用。

相关推荐
AICoder码农王1 小时前
如何写 Skill 第4课,脚本不进门
人工智能
打呵欠的猫1 小时前
用 AI 做性能分析:一行命令找出拖慢页面的 5 个组件,优化后首屏快了 40%
前端·性能优化·ai编程
星云低代码开发平台1 小时前
AI 连接器运行健康度:指标、异常恢复和联调清单
大数据·运维·网络·数据库·人工智能·mcp
stereohomology1 小时前
用免费算力额度实现markdown TeX混合编码主题及转换
人工智能·语言模型·llm·薅羊毛
Cx330❀1 小时前
Qt 界面美化指南:从零掌握 QSS 核心机制与工程实践
开发语言·qt·ui·性能优化·图形渲染
一切皆是因缘际会1 小时前
科技的变化
人工智能
aiAIman1 小时前
Jev 入门:没有 API Key,先看懂三种原语和请求结构(Datawhale Jev Cookbook )
人工智能·aigc
JMchen1 小时前
实战项目一——实现一个高性能图片滤镜App
android·c++·性能优化
Data-Miner2 小时前
AI 做表数据分析知识百科:定义、原理、概念与应用
人工智能·数据挖掘·数据分析