第1739篇:移动端AI应用的特殊挑战——网络抖动与离线缓存策略
第1739篇:移动端AI应用的特殊挑战——网络抖动与离线缓存策略
在桌面端做 AI 产品和在移动端做,体感差距大得超出我的预期。
有一次我们做用户测试,一个用户在地铁上用我们的 AI 助手,刚开始流式输出到一半,进入隧道信号断了,整个对话就卡死了。等到出了隧道,界面还是那个卡死的状态,没有任何恢复机制。用户直接关掉了应用。
这个场景在桌面端不会发生——有线网络很稳定。但在移动端,这是最正常不过的使用场景。4G 信号随时可能抖动,Wi-Fi 和移动数据会自动切换,进电梯进隧道信号直接断——这些情况要你设计每一个环节都能优雅处理。
今天就专门聊移动端 AI 应用的网络和缓存挑战,以及 Java 后端需要配合做哪些事。
移动端网络的特殊性
移动端网络有几个桌面端没有的特点:
间歇性断连:信号可能突然消失又恢复,这个过程可能是几秒到几十秒。期间发出的请求全部失败,恢复后需要无缝重连。
高延迟抖动:即使信号存在,延迟也可能在 50ms 到 2000ms 之间跳动,和服务器的长连接(SSE/WebSocket)容易误判断线。
网络类型切换:从 Wi-Fi 切换到 4G,TCP 连接断开,所有进行中的请求全部终止。
带宽限制:AI 回复的内容可能很长,在弱网环境下单个大响应会严重阻塞其他请求。
省电和后台限制:手机放入后台后,系统可能挂起网络请求,保活的 WebSocket 连接也可能被断开。
后端:为移动端定制的 API 设计
很多团队用同一套 API 服务桌面端和移动端,然后发现移动端体验很差。这是因为两端的网络特性差异太大,需要做一些针对性的设计。
1. 支持断点续传的流式接口
普通的 SSE 接口不支持断点续传——连接断了,就要重头开始。这对移动端来说是灾难性的:生成到一半断连,只能全部重来,浪费算力,也浪费用户的流量和耐心。
后端改造:把已生成的内容缓存起来,客户端重连时提供 Resume 机制:
@Service
public class ResumableStreamService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String BUFFER_KEY_PREFIX = "stream:buffer:";
private static final int BUFFER_EXPIRE_MINUTES = 10; // 10分钟内可以续传
/**
* 流式生成时,将已生成的内容分段缓存
*/
public void cacheStreamChunk(String streamId, int chunkIndex, String content) {
String key = BUFFER_KEY_PREFIX + streamId + ":" + chunkIndex;
redisTemplate.opsForValue().set(key, content, Duration.ofMinutes(BUFFER_EXPIRE_MINUTES));
// 同时更新最大 chunkIndex
redisTemplate.opsForValue().set(
BUFFER_KEY_PREFIX + streamId + ":maxChunk",
String.valueOf(chunkIndex),
Duration.ofMinutes(BUFFER_EXPIRE_MINUTES)
);
}
/**
* 获取某个 streamId 的所有缓存内容
*/
public StreamBuffer getBuffer(String streamId) {
String maxChunkStr = redisTemplate.opsForValue().get(
BUFFER_KEY_PREFIX + streamId + ":maxChunk"
);
if (maxChunkStr == null) {
return StreamBuffer.empty();
}
int maxChunk = Integer.parseInt(maxChunkStr);
List<String> chunks = new ArrayList<>();
for (int i = 0; i <= maxChunk; i++) {
String chunk = redisTemplate.opsForValue().get(
BUFFER_KEY_PREFIX + streamId + ":" + i
);
if (chunk != null) {
chunks.add(chunk);
}
}
String fullContent = String.join("", chunks);
return StreamBuffer.builder()
.streamId(streamId)
.content(fullContent)
.lastChunkIndex(maxChunk)
.contentLength(fullContent.length())
.build();
}
}续传接口:
@GetMapping(value = "/stream/resume/{streamId}",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter resumeStream(
@PathVariable String streamId,
@RequestHeader("X-User-Id") String userId,
@RequestParam(defaultValue = "0") int fromOffset) {
// 验证这个 streamId 属于这个用户
StreamSession session = streamSessionService.getSession(streamId, userId);
SseEmitter emitter = new SseEmitter(180_000L);
CompletableFuture.runAsync(() -> {
try {
// 1. 先发送已缓存的内容(从 fromOffset 开始)
StreamBuffer buffer = resumableStreamService.getBuffer(streamId);
if (buffer.getContentLength() > fromOffset) {
String missedContent = buffer.getContent().substring(fromOffset);
emitter.send(SseEmitter.event()
.name("catchup")
.data(Map.of(
"content", missedContent,
"fromOffset", fromOffset,
"toOffset", buffer.getContentLength()
)));
}
// 2. 如果流式生成还在继续,接上后续内容
if (!session.isCompleted()) {
attachToOngoingStream(session, buffer.getContentLength(), emitter);
} else {
// 已完成,发送完成事件
emitter.send(SseEmitter.event()
.name("done")
.data(Map.of("totalLength", buffer.getContentLength())));
emitter.complete();
}
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}请求队列:弱网环境下的消息可靠性
移动端弱网时,用户发出的消息可能没有到达服务器。如果前端没有重试机制,消息就丢了。
后端要设计幂等接口,配合前端的重试队列:
@PostMapping("/messages")
public ResponseEntity<SendMessageResponse> sendMessage(
@RequestHeader("X-User-Id") String userId,
@RequestHeader("X-Request-Id") String requestId, // 客户端生成的唯一请求ID
@RequestBody SendMessageRequest request) {
// 幂等检查:同一个 requestId 只处理一次
Optional<SendMessageResponse> cachedResponse = idempotencyService.get(requestId);
if (cachedResponse.isPresent()) {
// 重复请求,直接返回缓存的响应
return ResponseEntity.ok(cachedResponse.get());
}
try {
SendMessageResponse response = messageService.send(userId, request);
// 缓存响应,5分钟内的重试都返回相同结果
idempotencyService.cache(requestId, response, Duration.ofMinutes(5));
return ResponseEntity.ok(response);
} catch (Exception e) {
// 处理失败,不缓存,允许客户端重试
throw e;
}
}@Service
public class IdempotencyService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String IDEMPOTENCY_KEY_PREFIX = "idempotency:";
public <T> Optional<T> get(String requestId) {
String key = IDEMPOTENCY_KEY_PREFIX + requestId;
String cachedJson = redisTemplate.opsForValue().get(key);
if (cachedJson == null) return Optional.empty();
try {
return Optional.of(objectMapper.readValue(cachedJson, new TypeReference<T>() {}));
} catch (Exception e) {
return Optional.empty();
}
}
public <T> void cache(String requestId, T response, Duration ttl) {
try {
String key = IDEMPOTENCY_KEY_PREFIX + requestId;
String json = objectMapper.writeValueAsString(response);
redisTemplate.opsForValue().set(key, json, ttl);
} catch (JsonProcessingException e) {
log.warn("幂等缓存序列化失败", e);
}
}
}离线缓存策略:没有网络时能做什么
纯在线的 AI 应用,没网就什么都不能做,这对移动端用户来说体验很差。
合理的离线缓存设计,能让用户在弱网/无网时仍然有部分价值:
离线可用的功能:
- 浏览历史对话(缓存到本地)
- 搜索本地缓存的历史回复
- 使用离线轻量模型(如果集成了端侧模型)
- 查看已下载的知识库内容
后端要做的是:设计好离线数据的同步协议,确保客户端本地缓存和服务器数据能正确同步。
@RestController
@RequestMapping("/api/sync")
public class OfflineSyncController {
@Autowired
private SyncService syncService;
/**
* 移动端启动或网络恢复时调用
* 获取离线期间的所有变更
*/
@GetMapping("/delta")
public DeltaSyncResponse getDelta(
@RequestHeader("X-User-Id") String userId,
@RequestParam long lastSyncTimestamp,
@RequestParam(defaultValue = "100") int maxItems) {
long currentTimestamp = System.currentTimeMillis();
// 获取自上次同步后的变更
List<SyncItem> changes = syncService.getChanges(
userId, lastSyncTimestamp, maxItems
);
return DeltaSyncResponse.builder()
.serverTimestamp(currentTimestamp)
.changes(changes)
.hasMore(changes.size() >= maxItems) // 是否还有更多,分页拉取
.build();
}
/**
* 上传离线期间本地产生的操作
* 比如用户离线时写了草稿、删除了对话等
*/
@PostMapping("/upload-local-ops")
public UploadOpsResponse uploadLocalOperations(
@RequestHeader("X-User-Id") String userId,
@RequestBody List<LocalOperation> localOps) {
List<OperationResult> results = localOps.stream()
.map(op -> syncService.applyLocalOperation(userId, op))
.collect(Collectors.toList());
return UploadOpsResponse.builder()
.results(results)
.serverTimestamp(System.currentTimeMillis())
.build();
}
}
@Data
@Builder
public class SyncItem {
private String entityType; // SESSION / MESSAGE / PREFERENCE
private String entityId;
private ChangeType changeType; // CREATE / UPDATE / DELETE
private Object data;
private long changedAt;
// 数据压缩标志(大内容会压缩传输)
private boolean compressed;
private Long uncompressedSize;
}离线数据的增量压缩传输:
移动端流量宝贵,历史对话内容多时,同步可能传输很多数据。对大文本要压缩:
@Service
public class CompressedSyncService {
/**
* 对大内容使用 GZIP 压缩
*/
public byte[] compressIfNeeded(String content) {
if (content.length() < 1024) {
// 小内容不压缩,压缩开销不值得
return content.getBytes(StandardCharsets.UTF_8);
}
try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
GZIPOutputStream gzip = new GZIPOutputStream(bos)) {
gzip.write(content.getBytes(StandardCharsets.UTF_8));
gzip.finish();
byte[] compressed = bos.toByteArray();
// 只有压缩效果好才用压缩版本
if (compressed.length < content.length() * 0.8) {
return compressed;
}
return content.getBytes(StandardCharsets.UTF_8);
} catch (IOException e) {
return content.getBytes(StandardCharsets.UTF_8);
}
}
}心跳机制:检测和处理连接状态
移动端 WebSocket/SSE 连接的假死问题(连接看起来还在,但实际上数据不通)比桌面端严重得多。需要专门的心跳机制:
@Service
public class MobileHeartbeatService {
// 移动端心跳间隔比桌面端更短
private static final int MOBILE_HEARTBEAT_INTERVAL_SECONDS = 15;
private static final int MOBILE_HEARTBEAT_TIMEOUT_SECONDS = 45;
@Autowired
private SessionStateWebSocketHandler wsHandler;
// 记录每个连接最后一次收到心跳的时间
private final ConcurrentHashMap<String, Long> lastHeartbeatTime =
new ConcurrentHashMap<>();
@Scheduled(fixedRate = 15_000) // 每15秒检查一次
public void sendHeartbeatsAndCheckTimeout() {
long now = System.currentTimeMillis();
wsHandler.getAllConnections().forEach((sessionId, wsSession) -> {
try {
// 发送心跳 ping
wsSession.sendMessage(new PingMessage());
// 检查是否超时(对方45秒没有响应)
Long lastHeartbeat = lastHeartbeatTime.get(sessionId);
if (lastHeartbeat != null &&
now - lastHeartbeat > MOBILE_HEARTBEAT_TIMEOUT_SECONDS * 1000) {
log.warn("连接 {} 心跳超时,主动关闭", sessionId);
wsSession.close(CloseStatus.GOING_AWAY);
}
} catch (IOException e) {
log.warn("心跳发送失败: {}", sessionId);
}
});
}
public void recordPong(String sessionId) {
lastHeartbeatTime.put(sessionId, System.currentTimeMillis());
}
}流量优化:移动端专属的响应裁剪
移动端用户流量有限,尤其是在 4G 环境下,要尽量减少数据传输量:
@Service
public class MobileResponseOptimizer {
/**
* 根据客户端类型裁剪响应体
*/
public Object optimize(Object fullResponse, ClientType clientType,
NetworkQuality networkQuality) {
if (clientType != ClientType.MOBILE) {
return fullResponse; // 非移动端不裁剪
}
return switch (networkQuality) {
case WIFI, LTE_GOOD -> fullResponse; // 好的网络完整返回
case LTE_FAIR -> stripNonEssentialFields(fullResponse); // 中等网络去掉非必要字段
case LTE_POOR, EDGE -> aggressiveStrip(fullResponse); // 弱网只保留核心内容
};
}
private Object stripNonEssentialFields(Object response) {
// 去掉 debug 信息、扩展元数据等
if (response instanceof AIResponse r) {
return AIResponse.builder()
.content(r.getContent())
.messageId(r.getMessageId())
// 省略 tokenUsage, modelVersion 等非关键字段
.build();
}
return response;
}
private Object aggressiveStrip(Object response) {
// 极弱网:如果内容很长,只返回前500字,其余通过分页接口获取
if (response instanceof AIResponse r) {
String content = r.getContent();
boolean truncated = content.length() > 500;
return AIResponse.builder()
.content(truncated ? content.substring(0, 500) : content)
.messageId(r.getMessageId())
.truncated(truncated)
.fullContentUrl(truncated ? "/api/messages/" + r.getMessageId() + "/full" : null)
.build();
}
return response;
}
}客户端网络质量检测通过请求头传入,避免服务器自己猜:
@Component
public class NetworkQualityExtractor {
public NetworkQuality extract(HttpServletRequest request) {
// 客户端在请求头里携带网络质量信息
// Android/iOS 的网络层可以在拦截器里自动加这个头
String hint = request.getHeader("X-Network-Quality");
if (hint == null) {
// 没有提供,根据 User-Agent 猜测
String ua = request.getHeader("User-Agent");
return ua != null && (ua.contains("Mobile") || ua.contains("Android"))
? NetworkQuality.LTE_FAIR // 移动端默认中等网络
: NetworkQuality.WIFI;
}
return NetworkQuality.fromString(hint);
}
}请求优先级队列:关键操作先行
移动端弱网时,用户的每个请求都很宝贵。如果后台同步任务占用了有限的带宽,影响了用户主动触发的请求,体验会很差。
后端要支持请求优先级:
@Service
public class PrioritizedRequestProcessor {
// 高优先级队列(用户主动操作)
private final PriorityBlockingQueue<PrioritizedRequest> highPriorityQueue =
new PriorityBlockingQueue<>(100, Comparator.comparingInt(r -> -r.getPriority()));
// 低优先级队列(后台同步)
private final BlockingQueue<PrioritizedRequest> lowPriorityQueue =
new LinkedBlockingQueue<>(500);
/**
* 接受请求时根据来源分配优先级
*/
public void enqueue(PrioritizedRequest request) {
if (request.getPriority() >= RequestPriority.HIGH) {
highPriorityQueue.offer(request);
} else {
lowPriorityQueue.offer(request);
}
}
/**
* 处理线程优先消费高优先级队列
*/
@Scheduled(fixedRate = 100)
public void processRequests() {
// 先处理所有高优先级请求
while (!highPriorityQueue.isEmpty()) {
PrioritizedRequest req = highPriorityQueue.poll();
if (req != null) processRequest(req);
}
// 再处理低优先级(有限制,避免占用太多资源)
int lowPriorityBudget = 3;
while (lowPriorityBudget-- > 0 && !lowPriorityQueue.isEmpty()) {
PrioritizedRequest req = lowPriorityQueue.poll();
if (req != null) processRequest(req);
}
}
}监控:移动端专属指标
监控移动端的网络状况,需要比桌面端更细的指标:
@Component
public class MobileNetworkMetricsCollector {
@Autowired
private MeterRegistry meterRegistry;
public void recordRequest(HttpServletRequest request, long latencyMs, boolean success) {
String networkQuality = request.getHeader("X-Network-Quality");
String clientType = extractClientType(request);
if ("MOBILE".equals(clientType)) {
// 按网络质量分层统计
Tags tags = Tags.of(
"network", networkQuality != null ? networkQuality : "UNKNOWN",
"success", String.valueOf(success)
);
meterRegistry.timer("mobile.request.latency", tags)
.record(latencyMs, TimeUnit.MILLISECONDS);
meterRegistry.counter("mobile.request.count", tags).increment();
// 统计重试率(X-Retry-Count 头由客户端设置)
String retryCount = request.getHeader("X-Retry-Count");
if (retryCount != null && Integer.parseInt(retryCount) > 0) {
meterRegistry.counter("mobile.request.retry",
Tags.of("attempt", retryCount)).increment();
}
}
}
}一些真实踩坑
坑1:SSE 超时时间设太短。我们最初把 SSE 的超时设成 30 秒,结果用户在生成长内容时(60秒+),连接会超时断开。移动端要根据最长可能的生成时间来设置,我们后来改成了 3 分钟。
坑2:忽略了 HTTP 中间代理的超时。有些运营商的 HTTP 代理会对长连接有额外的超时限制(比如 60 秒),我们发现部分用户无论怎么设都在 60 秒断开,后来发现是运营商代理问题,通过在 SSE 流里定期发送注释行(: keepalive\n\n)来保活解决了。
坑3:网络切换时没有重连逻辑。用户从 Wi-Fi 走出门切到 4G,TCP 连接断了,前端没有感知到,一直等不到后续数据。后端改成对每个活跃流每隔 30 秒发一个 keepalive 事件,前端检测到超时没有收到事件就主动重连。
总结
移动端 AI 应用的核心挑战就是"网络不可靠"这四个字,但这不是借口,而是设计约束。
工程上的应对:
- 断点续传:流式输出中途断连,能从断点继续
- 幂等请求:客户端可以安全重试,服务端不重复处理
- 离线同步:delta 协议拉取增量,本地缓存历史数据
- 心跳保活:主动检测连接状态,定期发送 keepalive
- 流量裁剪:根据网络质量动态调整响应体大小
- 优先级队列:关键操作优先,后台同步让路
这些都是后端工程师完全能掌控的事,做好了,移动端用户的体验会有质的提升。
