最近在做一个智能客服项目遇到了不少头疼的问题比如用户聊着聊着系统就“失忆”了高峰期响应慢得像蜗牛多轮对话的状态更是乱成一锅粥。经过一番折腾基于 SpringAI 搞出了一套还算不错的解决方案这里把从架构设计到性能优化的全过程梳理一下希望能给有类似需求的同学一些参考。1. 直面痛点智能客服系统的三大顽疾在项目初期我们主要被三个问题困扰长对话上下文丢失用户的问题往往不是孤立的。比如用户先问“我的订单状态”接着问“什么时候能到”传统做法可能只处理当前单句导致客服机器人无法理解“什么时候能到”指的是订单的物流时间回答得牛头不对马嘴。高并发下响应超时促销活动期间用户咨询量激增同步处理请求的服务器线程池迅速被打满大量请求排队等待平均响应时间RT从几百毫秒飙升到好几秒用户体验急剧下降。多轮对话状态管理混乱一个完整的客服流程可能包含身份验证、问题分类、信息收集、解决方案提供等多个步骤。用简单的if-else或散落的Session属性来管理这些状态代码很快就会变得难以维护和扩展状态跳转逻辑像一团乱麻。2. 技术选型为什么是 SpringAI 反应式架构针对这些问题我们评估了几套方案自然语言理解NLU传统规则引擎 vs SpringAI传统规则引擎需要手动编写大量正则表达式和关键词模板维护成本高且无法理解语义相近的不同问法如“怎么付款”和“支付方式”。扩展性差每增加一个业务场景就要写一堆新规则。SpringAI 的 NLU 能力它提供了对主流大语言模型LLM的统一抽象。我们可以利用 LLM 强大的语义理解能力将用户query映射到预定义的业务意图Intent和槽位Slot。这样系统能更准确地理解用户多样化的表达而我们需要管理的只是意图和槽位的定义而非具体的文本模式。Trade-off考量引入了模型API调用的延迟和成本但换来了更高的准确性和可维护性。请求处理同步阻塞 vs Reactor 异步流水线同步阻塞处理一个请求占用一个线程直到完全处理完毕。在IO密集型场景如调用LLM API、查询数据库下线程大量时间在等待资源利用率低并发能力受限于线程池大小。Reactor 异步流水线基于 Project Reactor 实现非阻塞、背压Backpressure支持的异步流。将对话处理拆解为多个异步步骤如意图识别、状态查询、回复生成形成处理流水线。当上游步骤生产数据过快时背压机制能通知上游放慢速度避免下游组件被压垮。Trade-off考量编程模型从命令式转为声明式有一定学习成本调试更复杂但能极大提升系统的吞吐量和资源利用率。状态管理单体会话存储 vs 分布式状态机单体会话存储如HttpSession简单但无法支持水平扩展。一旦用户请求被负载均衡到另一台服务器状态就丢失了。分布式状态机将整个对话流程抽象为一个状态机例如使用 Spring State Machine 的概念。每个对话实例的当前状态、历史上下文等数据持久化到 Redis 或 RedisGraph 这样的分布式存储中。这样任何一台服务实例都能读取并更新对话状态实现了无状态服务的状态管理。Trade-off考量增加了外部存储的依赖和网络开销但换来了系统的弹性和可扩展性。3. 核心实现拆解三大模块基于以上选型我们构建了三个核心模块。3.1 基于 SpringAI 的意图识别器IntentRecognizer我们不直接使用 SpringAI 的ChatClient进行开放式聊天而是将其用于结构化的意图识别。import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.model.ChatResponse; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.prompt.PromptTemplate; /** * 自定义意图识别器。 * 负责将用户输入的自然语言解析为系统可理解的意图和槽位。 */ Component public class CustomIntentRecognizer { private final ChatClient chatClient; // 预定义的意图列表用于Few-Shot Prompting或引导模型输出 private static final String INTENT_PROMPT_TEMPLATE 你是一个客服意图分类器。请将用户的输入分类到以下意图之一并提取相关槽位信息。 意图列表[{intent_list}] 槽位定义{slot_definitions} 用户输入{user_input} 请以JSON格式回复包含字段intent, confidence, slots。 ; public CustomIntentRecognizer(ChatClient chatClient) { this.chatClient chatClient; } /** * 识别用户输入的意图。 * param userInput 用户原始输入文本 * param context 可选的历史对话上下文用于辅助理解 * return RecognitionResult 包含意图、置信度和槽位的识别结果 */ public MonoRecognitionResult recognize(String userInput, String context) { PromptTemplate promptTemplate new PromptTemplate(INTENT_PROMPT_TEMPLATE); MapString, Object model new HashMap(); model.put(intent_list, 查询订单, 物流跟踪, 退货申请, 账户咨询); model.put(slot_definitions, 订单号: string, 物流单号: string); model.put(user_input, userInput); // 可以在此处注入上下文 if (context ! null) { model.put(conversation_context, context); } Prompt prompt promptTemplate.create(model); return Mono.fromCallable(() - chatClient.prompt(prompt) .call() .content()) .map(this::parseModelResponse) // 将LLM返回的JSON字符串解析为RecognitionResult对象 .subscribeOn(Schedulers.boundedElastic()); // 将可能阻塞的LLM调用转移到弹性线程池 } private RecognitionResult parseModelResponse(String jsonResponse) { // 使用Jackson或Gson解析JSON构建RecognitionResult对象 // 示例代码略 return new RecognitionResult(); } }3.2 使用 Project Reactor 实现背压控制的消息流我们将每个用户对话请求视为一个事件放入一个 Reactor Flux 流中进行处理。import reactor.core.publisher.Flux; import reactor.core.publisher.Sinks; import reactor.core.scheduler.Schedulers; /** * 异步消息处理中心。 * 使用 Reactor 的 Sinks 作为消息处理器实现发布-订阅和背压控制。 */ Component public class AsyncMessageProcessor { // 使用 Sinks.many() 创建一个支持背压的多播器用于分发消息事件 private final Sinks.ManyDialogEvent eventSink Sinks.many().multicast().onBackpressureBuffer(1000); /** * 处理用户消息入口。 * param sessionId 对话会话ID * param userMessage 用户消息 * return 一个Mono在异步处理完成后发出信号可能不直接包含回复回复通过其他通道如WebSocket下发 */ public MonoVoid processMessage(String sessionId, String userMessage) { DialogEvent event new DialogEvent(sessionId, userMessage, System.currentTimeMillis()); return Mono.fromRunnable(() - { // 尝试发出事件如果下游有压力buffer满EmitResult会返回FAIL_NON_SERIALIZED或FAIL_OVERFLOW Sinks.EmitResult result eventSink.tryEmitNext(event); if (result.isFailure()) { // 处理发射失败例如记录日志、返回错误响应给用户 log.warn(Event emit failed for session {}: {}, sessionId, result); } }).subscribeOn(Schedulers.parallel()); } /** * 获取事件流供下游业务处理器订阅。 * return 包含所有DialogEvent的Flux流 */ public FluxDialogEvent getEventStream() { return eventSink.asFlux() .publishOn(Schedulers.boundedElastic()) // 切换到弹性线程池进行IO密集型处理 .doOnNext(event - log.debug(Processing event for session: {}, event.getSessionId())) .flatMap(this::handleEvent, 10); // 设置并发度控制同时处理的事件数实现背压 } private MonoVoid handleEvent(DialogEvent event) { // 这里串联调用意图识别、状态机推进、回复生成等步骤 // 例如 return intentRecognizer.recognize(event.getMessage(), fetchContext(event.getSessionId())) .flatMap(result - stateMachineService.transit(event.getSessionId(), result)) .flatMap(state - replyGenerator.generate(state)) .flatMap(reply - pushToUser(event.getSessionId(), reply)) .doOnError(e - log.error(Failed to handle event for session: event.getSessionId(), e)) .onErrorResume(e - Mono.empty()); // 错误处理避免一个事件失败导致整个流终止 } }3.3 采用 RedisGraph 实现对话状态持久化对于复杂的关系型状态如对话涉及多个实体及其关系我们选用 RedisGraph。import org.springframework.data.redis.core.RedisTemplate; import com.redislabs.modules.rejson.JReJSON; /** * 基于 RedisGraph 的对话状态管理器。 * 将每个对话建模为一个图节点表示状态、意图、实体边表示转换关系或关联。 */ Component public class DialogStateManager { private final RedisTemplateString, String redisTemplate; private final JReJSON redisJson; private static final String GRAPH_KEY_PREFIX dialog_graph:; /** * 初始化或更新对话状态图。 * param sessionId 对话ID * param currentNode 当前状态节点 * param intent 识别出的意图 * param entities 提取的实体列表 */ public MonoBoolean updateStateGraph(String sessionId, StateNode currentNode, Intent intent, ListEntity entities) { return Mono.fromCallable(() - { String graphKey GRAPH_KEY_PREFIX sessionId; // 使用 Cypher 查询语言更新图 String cypher MERGE (s:Session {id: $sessionId}) MERGE (state:State {name: $stateName}) MERGE (intent:Intent {name: $intentName}) MERGE (s)-[:CURRENT_STATE]-(state) MERGE (s)-[:HAS_INTENT]-(intent) WITH s, state, intent UNWIND $entities AS entityMap MERGE (e:Entity {type: entityMap.type, value: entityMap.value}) MERGE (state)-[:CONTAINS]-(e) RETURN id(s) ; MapString, Object params new HashMap(); params.put(sessionId, sessionId); params.put(stateName, currentNode.getName()); params.put(intentName, intent.getName()); params.put(entities, entities.stream().map(e - Map.of(type, e.getType(), value, e.getValue())).collect(Collectors.toList())); // 执行 Cypher 查询此处需使用 RedisGraph 客户端如 Jedis 或 Lettuce 的扩展 // 示例代码略实际调用 redisGraphClient.query(cypher, params) return true; }).subscribeOn(Schedulers.boundedElastic()); } /** * 获取对话的完整上下文。 * param sessionId 对话ID * return 包含历史状态、意图和实体的上下文字符串 */ public MonoString fetchContext(String sessionId) { return Mono.fromCallable(() - { String graphKey GRAPH_KEY_PREFIX sessionId; // 查询图获取最近的N轮对话信息 String cypher MATCH (s:Session {id: $sessionId})-[:CURRENT_STATE]-(state) OPTIONAL MATCH (state)-[:CONTAINS]-(entity) OPTIONAL MATCH (s)-[:HAS_INTENT]-(intent) RETURN state.name as state, collect(entity) as entities, intent.name as intent ORDER BY state.timestamp DESC LIMIT 5 ; MapString, Object params Map.of(sessionId, sessionId); // 执行查询并拼接上下文... return buildContextFromResult(/* 查询结果 */); }).subscribeOn(Schedulers.boundedElastic()); } }4. 工程化封装与验证为了让这套方案易于复用我们将其封装为 Spring Boot Starter。4.1 可插拔的 Spring Boot Starter创建spring-boot-starter-ai-customer-service模块自动配置核心 BeanIntentRecognizer,AsyncMessageProcessor,DialogStateManager并暴露可配置属性如 LLM API 地址、Redis 连接信息、背压缓冲区大小等。4.2 性能压测与链路追踪使用 JMeter 模拟高并发用户对话场景重点观察在背压机制下的系统表现吞吐量、错误率、响应时间。同时集成 OpenTelemetry在每个关键步骤recognize,transit,generate,push添加 Span以便在 Jaeger 或 Zipkin 中清晰看到每个请求的完整链路和耗时瓶颈。5. 生产环境注意事项系统上线后以下几点需要特别关注对话超时补偿机制LLM 调用或网络抖动可能导致响应超时。我们设计了补偿策略为每个对话请求设置超时如 8 秒若超时则立即向用户返回一个“正在思考”的占位回复同时在后台继续处理处理完成后通过 WebSocket 推送最终结果。若后台处理也失败则记录日志并可能触发重试或转人工。敏感词过滤优化在将用户输入发送给 LLM 或存入数据库前必须进行敏感词过滤。我们采用基于确定性有限自动机DFA算法的词库进行匹配将时间复杂度从 O(n*m) 降低到 O(n)并支持热更新词库确保高效且实时地拦截违规内容。Kubernetes HPA 弹性扩缩容配置 Horizontal Pod Autoscaler (HPA)基于自定义指标如async_message_processor_queue_size消息队列积压量或通用指标如 CPU/内存利用率来自动扩缩容 Pod 实例。当消息积压增多时自动扩容以提升消费能力当负载降低时自动缩容以节省资源。6. 结尾思考精度与延迟的永恒博弈通过这套方案我们最终在保证 98% 以上意图识别准确率的前提下将系统 QPS 提升了 3 倍。然而一个更深层的问题始终存在如何平衡大语言模型的精度与响应延迟使用更强大的模型如 GPT-4通常意味着更高的准确度和更强的理解能力但随之而来的是更长的 API 调用延迟和更高的成本。反之使用更轻量的模型或蒸馏后的模型响应更快成本更低但可能在复杂场景下表现不佳。我们的实践思路是分层处理高频简单问题使用本地部署的轻量级模型或甚至基于 Embedding 的语义检索来快速匹配标准问答库追求极速响应毫秒级。中低频复杂问题路由到云端的高精度大模型如 GPT-3.5/4接受稍高的延迟秒级但提供更精准和灵活的回复。流式输出对于大模型生成的长回复采用流式传输Server-Sent Events 或 WebSocket让用户能尽快看到开头部分感知延迟降低。此外缓存是关键。对常见问题及其标准答案进行缓存对相似的用户 query 经过 Embedding 计算相似度后返回缓存结果能极大减少对 LLM 的调用。最终没有银弹。平衡点取决于你的业务场景、用户容忍度和预算。持续监控、A/B 测试不同策略的效果并建立一套动态路由和降级机制可能是应对这个博弈的最佳实践。