【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。
参考资料
- Spring AI ChatClient:https://docs.spring.io/spring-ai/reference/api/chatclient.html
- Spring AI Observability:https://docs.spring.io/spring-ai/reference/observability/
- Spring AI Advisors:https://docs.spring.io/spring-ai/reference/api/advisors.html
- Spring Framework WebFlux:https://docs.spring.io/spring-framework/reference/web/webflux.html
- Reactor Reference:https://projectreactor.io/docs/core/release/reference/
- k6 Documentation:https://grafana.com/docs/k6/latest/
到这里,系列已经覆盖 Agent、RAG、MCP、记忆、工作流、评测、治理、安全和性能。后续可以继续扩展模型路由、成本分析、企业 SSO、私有化部署和多模态应用。