客服系统大概是AI应用落地最“顺理成章”的场景之一:对话天然密集、业务边界相对清晰、价值反馈直接。但真把一个LLM接进现有的客服平台,麻烦事比想象中多得多——租户之间怎么隔离?提示词怎么按客户定制?调用成本怎么控制?人工与机器人的协作边界在哪里?这些问题才是从Demo走向企业级生产的真正分水岭。
我最近完整做完了一个基于Spring AI的企业级多租户SaaS客服平台,从架构设计、核心模块落地到成本治理和可观测性,踩了不少坑,也沉淀了一套能直接复用的方案。这篇文章会把这套设计思路、关键代码片段、配置参数和排障经验完整拆开来讲,适合正在做智能客服、AI助手或者任何需要多租户LLM能力的后端团队参考,也适合准备用Spring AI作为技术底座、想少走弯路的开发者收藏。
1. 智能体客服平台的整体拆解:先想清楚边界,再谈技术选型
1.1 从传统客服到智能体客服,底层逻辑到底变了什么
传统客服平台的架构核心是IVR、工单、技能组和人工坐席,知识库是个独立的搜索系统,机器人的能力只停留在关键词匹配和固定流程编排上。引入LLM之后,变化的不只是“回答更聪明了”,而是整个对话链路的决策方式改变了:意图识别从规则匹配变成了模型推理,上下文管理从会话窗口变成了记忆窗口,工具调用从硬编码接口变成了模型按需选择。
这意味着系统设计的主线从“流程可配置”转向了“能力可编排”。过去我们关心的是“这个节点走哪个分支”,现在关心的是“这个租户的模型能访问哪些工具、知识库和业务数据”。Spring AI能成为底座,核心原因不是它让LLM调用更简单,而是它把这些周边能力——记忆、工具、提示词模板、上下文增强——统一抽象成了可组合的组件,正好匹配这种新的架构范式。
我接手的那套客服系统改造前有二十多万个问答对,分散在不同业务系统里。第一次尝试直接给模型塞全量文档,效果惨不忍睹:回答慢、Token消耗高、命中率低。后来才意识到,智能体客服的关键不是模型本身,而是围绕模型搭的那套“检索-指令-工具”框架,这也是后面所有设计的出发点。
1.2 为什么选Spring AI而不是直接调SDK或LangChain4j
技术选型的时候我对比过三条路线:直接封装各家模型的SDK、用LangChain4j、用Spring AI。直接封装SDK的问题很明显:每接一个模型供应商就要写一套适配层,而且多模型切换、提示词管理、函数调用这些公共能力全得自己造轮子。LangChain4j功能更丰富,但和Spring生态的整合度不如Spring AI自然,事务、缓存、配置体系、可观测性都要额外桥接。
Spring AI最打动我的地方在于它把“模型接入”和“应用集成”这两件事解耦了。模型接入层可以通过配置切换,应用通过统一的ChatClient接口消费能力。这意味着模型供应商变了、模型版本升了,业务代码几乎不用动。对于SaaS平台来说,这点非常重要,因为你不知道客户今天用的模型明天还能不能继续商用。
另一个被很多人忽略的点是Spring AI的Advisor机制。它相当于给ChatClient插上了AOP的翅膀,鉴权、租户上下文注入、敏感词过滤、质量评估都能做成横切逻辑,不用侵入业务代码。这正好契合多租户SaaS平台的需求:公共逻辑收敛到框架层,租户差异留在配置层。
1.3 整体拓扑:接入层、会话层、AI编排层、租户隔离层、数据层
这套客服平台的架构我拆成了五层,每层职责单一、接口明确:
接入层负责渠道适配,包括Web H5、小程序、App、企业微信等,统一转成内部对话协议。会话层负责会话生命周期管理、路由分配、人工坐席工作台。AI编排层是Spring AI的核心区,完成意图识别、上下文管理、工具调用、知识库检索、模型路由。租户隔离层横向贯穿所有层,从HTTP请求到LLM调用全链路传递租户身份。数据层负责存储会话记录、工单、知识库向量、用量数据、租户配置。
这里要特别强调:租户隔离不是一个独立服务,而是一种横切约束。我见过不少团队把“租户ID”当作普通参数传到Service层,结果漏了一个接口就出现数据串库事故。正确的做法是在网关或者Filter层就确定租户身份,放进上下文对象,所有数据访问和模型调用都强制从上下文读取,不允许业务代码自行传递。
2. 多租户隔离:先理清要隔离什么,再动手写代码
2.1 租户资源隔离清单:模型、提示词、知识库、配额一个都不能少
做多租户LLM平台,第一件事不是写代码,而是盘点隔离边界。我梳理过一份清单,包含五个必须隔离的资源维度:模型配置、提示词、知识库、会话记录、配额用量。
模型配置隔离指的是每个租户可以指定自己的模型供应商、模型名称、温度等参数。有的租户对成本敏感,倾向用轻量模型,有的租户处理复杂业务场景,需要旗舰模型,平台不能一刀切。提示词隔离更关键:每个租户的客服风格、品牌话术、业务限制都不同,系统提示词必须按租户动态组装,而且不能互相污染。知识库隔离是数据红线,租户A的知识文档绝对不能出现在租户B的检索结果里。会话记录隔离涉及合规,某个租户的客户对话只有该租户的管理员能查看。
配额用量隔离往往被忽略,但它决定了平台的成本安全。每个租户有月度Token上限、每分钟请求上限,超限要熔断或降级。我给每个资源维度都设计了独立的隔离级别和实现方式,下面这张表可以作为自查对照:
| 隔离维度 | 隔离级别 | 实现方式 | 失败影响 |
|---|---|---|---|
| 模型配置 | 配置级 | 租户配置表 + 动态ChatClient | 模型串用,成本计算混乱 |
| 提示词 | 模板级 | 租户提示词模板 + 缓存 | 客服风格错乱,严重时泄露品牌信息 |
| 知识库 | 数据级 | 向量集合按租户拆分 + 检索过滤 | 数据泄露,合规事故 |
| 会话记录 | 数据级 | 存储表带租户ID + 强制条件 | 数据串库 |
| 配额用量 | 配额级 | Redis计数 + 拦截校验 | 成本失控 |
2.2 租户上下文传递:从HTTP请求到LLM调用的全链路
租户上下文传递是整条链路最容易出问题的地方。我采用的方案是:Filter解析请求头里的租户标识,写入一个自定义的TenantContext对象,然后在Spring AI的Advisor里读取这个上下文,动态组装模型参数和提示词。
第一步,定义租户上下文。用ThreadLocal在单次请求内传递租户信息,配合TTL或者在异步场景下显式传递。核心代码是这样的:
public class TenantContext { private static final ThreadLocal<TenantInfo> HOLDER = new ThreadLocal<>(); public static void set(TenantInfo tenant) { HOLDER.set(tenant); } public static TenantInfo get() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }第二步,在过滤器里解析租户标识。租户标识通常放在请求头X-Tenant-Id里,也可以从JWT中解析。这里有几个关键细节:过滤器执行顺序要早于框架的拦截器,避免业务代码先执行导致上下文缺失;必须在finally里调用clear,否则线程池复用会串租户。
第三步,自定义Advisor在模型调用前注入租户逻辑。Spring AI的Advisor链在每次调用时执行,可以在这里读取TenantContext,做提示词合并、模型选择、配额预检。这一步把租户逻辑从业务Service里彻底抽离了,业务代码不再需要关心自己属于哪个租户。
2.3 提示词模板的租户化:数据库加载、缓存刷新、变量填充
租户化提示词这块,我踩过一个很深的坑:刚开始把所有租户的提示词写在一个枚举类里,后来客户数量上来,每次改提示词都要发版,而且不同租户的提示词只会越来越多。最后改成了数据库存储方案,配合本地缓存。
系统提示词的组装分三段:公共基础段定义平台边界,租户业务段描述该租户的业务规则和话术风格,会话变量段填充当前用户的上下文信息。Spring AI的PromptTemplate支持变量占位,我们可以在模板里写这样一段:
PromptTemplate promptTemplate = new PromptTemplate(""" 你是{companyName}的智能客服助手,你的名字叫{assistantName}。 请严格按照以下业务规则回答客户问题:{businessRules} 如果客户询问价格、优惠等敏感信息,请引导至人工服务。 """);模板内容从租户配置表里加载,缓存到Caffeine,设置合理的过期时间。租户改了配置之后通过事件刷新缓存,不用重启服务。这里要提醒:模板缓存的Key一定包含租户ID和模板版本号,否则缓存命中错乱的风险很高。
3. 核心会话模块实战:记忆管理、工具调用与人工接管
3.1 会话状态与短期记忆管理:按会话维度隔离存储
客服场景的记忆管理和普通聊天机器人不同:必须严格按会话维度隔离,同一个客户在不同会话里的上下文不能串。Spring AI的ChatMemory接口设计了消息窗口的抽象,我们可以基于Redis做分布式实现,这样多实例部署时会话状态一致,不会因为负载均衡把用户打到不同节点而丢失上下文。
我定义的记忆结构包含三部分:系统提示词、历史对话摘要、最近N轮原始消息。原始消息用于模型直接参考,摘要用于压缩超长上下文。窗口大小按模型上下文长度动态调整,比如上下文16K的模型,保留最近10轮原始消息,超过部分用摘要替换。
ChatMemory chatMemory = MessageWindowChatMemory.builder() .windowSize(10) .chatMemoryStore(redisChatMemoryStore) .build();这里有个实测经验:客服场景里,用户经常会问“刚才不是说可以退款吗”这类指代性问题,如果只保留最近几轮原始消息,容易丢失早期承诺。我的方案是每轮对话结束后异步生成摘要,写入会话摘要存储,下次调用时把摘要和最近消息一起作为上下文。摘要函数本身用轻量模型执行,成本可控,效果很好。
3.2 函数调用与工具编排:让客服机器人真正能“办事”
光会聊天不是客服平台的核心价值,能查订单、能改地址、能发起退款,这才是智能体区别于普通问答机器人的分水岭。Spring AI的工具调用机制很成熟:在Bean里定义方法,加上@Tool注解,框架自动生成工具描述和参数Schema,模型根据对话内容决定是否调用以及传什么参数。
@Component public class CustomerServiceTools { @Tool(description = "根据订单号查询订单状态、商品信息和物流进度") public OrderInfo queryOrder(@ToolParam(description = "订单号") String orderId) { // 调用订单服务,返回结构化结果 return orderService.query(orderId); } @Tool(description = "为客户申请退款,需要订单号和退款原因") public RefundResult applyRefund(@ToolParam(description = "订单号") String orderId, @ToolParam(description = "退款原因") String reason) { // 校验租户权限、订单归属后提交退款 return refundService.submit(orderId, reason); } }工具调用的落地细节有三个。第一,工具方法的输入参数规范要写清楚,模型不是程序员,描述不清晰它就会传错参数。第二,工具调用必须经过权限校验,客服机器人能调用的操作和人工坐席不同,比如退款超过一定金额必须转人工审批。第三,模型返回结果要给结构化输出,这里可以用Spring AI的Structured Output能力,把工具结果转成强类型对象,再融合进最终回复。
3.3 人工接管与人机协同:转人工的判断标准与状态移交
我最初做的版本是“机器人优先,解决不了就转人工”,但实际运营中发现问题没那么简单。转人工的判断标准要分层:客户主动要求转人工时无条件转;模型连续多轮无法解决时触发转人工;工具调用失败或业务规则要求人工介入时转人工。
实现上,我设计了一个会话状态机:BOT_SERVING、PENDING_HUMAN、HUMAN_SERVING、HUMAN_SOLVED、CLOSED。每次模型生成回复后,都会附带一个控制字段,指示本次回复是最终回答还是需要转人工。这个字段通过结构化输出从模型结果中解析,不额外增加一次调用。
转人工之后的关键问题是连续性和信息移交。我会把机器人的对话摘要、已收集的用户信息、候选工具结果打包成一份“交接单”,作为人工坐席工作台的首条消息。这样坐席不用从头读一遍聊天记录,客户也不用重复描述问题。据我的观察,这个设计能把平均处理时长降低20%左右,客户满意度提升明显。
4. 成本控制与模型路由:让每一分Token都花在刀刃上
4.1 模型路由:轻量模型优先,高难度任务自动升级
客服平台的模型成本是随业务量线性增长的,不做路由控制,月底账单会非常感人。我的路由策略是三级分发:先通过一个轻量模型做意图分类和难度评估,然后根据结果决定走轻量模型直接回答、走中档模型加知识库检索、还是升级旗舰模型做复杂推理。
意图分类这一步的产出是一个结构化的路由决策对象,包含目标模型档次、是否需要检索知识库、是否需要调用业务工具、预估复杂度等级。
具体实现上,我封了一个RouterModel,它实现Spring AI的ChatModel接口,内部持有多个不同供应商和规格的ChatModel实例。调用时根据当前请求的意图和上下文复杂度,动态选择背后的模型。
@Override public ChatResponse call(ChatRequest request) { String intent = intentClassifier.classify(request.getInstructions()); RoutingStrategy strategy = routingTable.getStrategy(intent); ChatModel targetModel = modelProvider.get(strategy.getModelTier()); return targetModel.call(request); }这套方案的收益很明显:我做过一次统计,大约65%的客服请求是重复性FAQ和简单查询,这些请求走轻量模型,成本只有旗舰模型的十分之一;只有涉及跨多个工具协同、复杂推理、情绪敏感对话的请求才升级到旗舰模型。整体成本比全部走旗舰模型节省了约55%,回答延迟也降了下来。
4.2 Token用量统计与配额限制:预检、后检、熔断三步走
配额控制如果只做“事后统计”等于没做。我的方案是三步走:调用前预检配额余量,调用中记录Token消耗,超出阈值触发熔断。
预检放在自定义Advisor里,从Redis读取当前租户在统计窗口内的Token累计值,加上预估消耗,超过配额直接拒绝请求并返回友好提示。预估消耗可以用消息长度和系统提示词长度做粗略估算,不用精确。
后检更关键。Spring AI的ChatResponse里带有Usage信息,包含输入Token数和输出Token数。每次调用完成后,从响应里拿到准确数字,异步累加到Redis计数器里。
ChatResponse response = chatClient.prompt() .user(userMessage) .call(); Usage usage = response.getMetadata().getUsage(); tokenUsageService.increment(tenantId, usage.getPromptTokens(), usage.getCompletionTokens());配额熔断不能只靠加法,要考虑并发。我用Redis的INCRBY加过期时间实现滑动窗口,窗口粒度可以设为小时级。对超限租户,除了拒绝新请求,还要给运维发送告警。这里有个细节:熔断状态要有TTL,不能用永久标记,否则调试和恢复都很麻烦。
4.3 回答缓存与提示词瘦身:最容易被忽略的两块成本洼地
很多人盯着模型单价,却忽略了两个成本漏洞:重复问题和臃肿提示词。客服场景里,高频问题集中在少数几个热点,比如“怎么退款”“发货时间”“修改地址”,这些完全可以用语义缓存兜住。
我的实现思路是:对用户消息做Embedding,计算与缓存库中历史问题的余弦相似度,超过0.92直接返回缓存答案,不调用模型。这里有几个细节:缓存Key要包含租户ID,不同租户的答案不同;缓存的答案要有版本号,知识库更新后能主动失效;缓存命中时记录“Cache Hit”指标,方便评估缓存策略的效果。
提示词瘦身这块,我发现很多团队的提示词越写越长,业务规则、案例、格式说明全堆在一起,每次调用都重复计费。优化思路是分级:把固定不变的平台级指令编译进模板,把变化频繁的租户级规则放数据库,把会话级动态信息控制在最短长度。实测一个精简后的提示词从1100个Token降到450个,在同等业务效果的前提下,单次调用成本降低了约30%。
5. 高可用、可观测性与安全加固:生产环境的最后一道防线
5.1 可观测性建设:按租户维度监控延迟、Token、错误率
LLM调用是分布式系统里最不可控的依赖,模型供应商的响应时间波动、限流、故障,都会直接影响客服体验。可观测性必须做到按租户维度监控三个核心指标:调用延迟、Token消耗、错误率。
Spring AI内置了Micrometer Observation的支持,把ChatModel调用、Advisor执行、工具调用都纳入了观测链路。我在这基础上扩展了自定义观测处理器,把租户ID、会话ID、模型名称作为Tag注入指标,这样监控面板上可以直接看到某个租户今天调用了多少次、平均延迟多少、Token消耗多少。
日志这块我用的是MDC机制:在租户上下文初始化的同时,把租户ID、会话ID写入MDC,这样所有业务日志都能自动带上这两个标识。排查问题时,直接按租户ID过滤日志,效率提升非常明显。
有一个排查案例让我印象很深:某租户反馈客服回复很慢,监控一看,发现这个租户的消息都被路由到了旗舰模型,而其他租户走的是轻量模型。根源是模型路由的意图分类器把该租户的某些固定话术判断成了“高复杂度”,简单调低了该租户的升级阈值就解决了。
5.2 安全加固:Prompt注入防护、敏感信息脱敏、工具权限收敛
客服系统是直面用户的系统,安全风险比内部系统高一个量级。Prompt注入是首先要防的:用户可能故意在消息里写“忽略之前的指令”“你现在是开发者模式”这类攻击。我在输入侧加了一道过滤,正则匹配常见的注入模式,命中直接拒绝进入模型。
脱敏处理放在两个位置:输入侧脱敏和输出侧脱敏。输入侧,对用户的手机号、身份证号、银行卡号做掩码处理后再发给模型;输出侧,对模型生成的回复做二次扫描,防止模型从历史对话或知识库中带出敏感信息。
工具权限收敛要结合租户和会话场景:同一家租户,普通咨询会话和高级会员会话能调用的工具范围不同。我采用的是“工具白名单”机制——把租户可用的工具集合注册进ChatClient,模型只能看到白名单内的工具,从源头杜绝越权调用。
5.3 常见问题排查速查表:多租户场景下的坑,我替你提前踩了
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 所有租户返回相同风格的提示词回复 | 租户提示词模板未生效,缓存Key未包含租户ID | 检查Advisor顺序,确认模板缓存Key拼接租户ID |
| 请求偶尔出现串租户数据 | ThreadLocal未清理,线程池复用导致上下文残留 | 在Filter的finally中调用TenantContext.clear() |
| Token配额没有生效 | 分布式环境Redis计数不准确,脚本边界条件错误 | 用Lua脚本保证INCR和过期原子性 |
| 模型工具没有被调用 | 工具方法权限修饰符不是public,或方法类未被Spring扫描 | 检查@Tool注解和组件扫描范围 |
| 回答质量时好时坏 | 模型路由不稳定,意图分类器误判复杂度 | 增加路由策略的规则优先级,调试时打印路由决策日志 |
| 缓存命中率偏低 | Embedding阈值设得太高,缓存答案版本过期 | 动态调整相似度阈值,增加缓存热度淘汰机制 |
| 人工接管后机器人还在回复 | 状态机未处理会话移交事件 | 在HUMAN_SERVING状态下禁止触发BOT逻辑 |
排查这类问题有一个通用思路:先看链路追踪里租户ID是否正确传递,再看缓存Key是否包含了所有隔离维度,最后看模型调用前后的上下文是否一致。大多数多租户事故都能通过这三步定位。
我个人在实际操作中最深的体会是:Spring AI现在还处于快速演进期,版本升级可能会有Breaking Change,所以一定要把模型接入层、业务编排层和渠道适配层彻底解耦,升级框架时可以分层验证。另外,别指望一套模板解决所有客户的需求,多租户平台的核心竞争力就是把“定制化”做成标准化产品能力。
最后再分享一个建议:如果你正准备启动类似的智能客服项目,可以先用一个租户、一个模型、一个场景跑通最小闭环,跑通之后再逐步叠加多租户隔离、模型路由、配额控制这些能力。不要一开始就把架构撑得太大,LLM应用的复杂度是逐渐暴露的,留出演进空间,比一步到位更重要。