做物流智能客服这个项目之前我在Spring Boot里已经写了三年的订单、运单、报表LLM那套东西在我看来也就是圈子里在炒新概念。直到产品经理把一个需求拍在我桌上客服机器人要能查物流轨迹、能回答面单规则和理赔条款、还能记住客户上次说过什么。我一开始的想法是搞三个独立服务去堆一个知识库问答、一个会话记录、一堆OpenAI Function Calling然后在Controller里手工编排。后来发现Spring AI里三个东西——RAG、记忆、工具调用——设计出来就是为了一体化干这件事的它们能串成一个Agent运行时的统一对话流。这篇文章把我实际搭建这套物流智能客服系统的过程完整整理出来重点不是某个API怎么调而是这三样东西为什么必须同时存在、合并到一起时各自负责什么、顺序怎么编排。适合正在用Java、想给Spring Boot工程塞进AI能力、又不想把各家供应商SDK散落到业务代码里的团队参考也适合对RAG只停留在概念阶段、想看看真实落地长什么样的同学。1. 为什么非要把这三样绑在一起物流客服的痛点拆解1.1 客服一天里到底在处理什么我蹲过一阵子客服工单系统把物流客服的真实工作抽出来其实就三类问题实时信息类“我的件到哪了”“运费多少钱”“今天能送到吗”。这些数据不在任何文档里在TMS、OMS、财务系统里需要动态查询。规则知识类“面单上要写什么”“锂电池能不能走航空”“拒收后运费谁承担”“理赔要几个工作日”。这些内容写在操作手册、合同条款、公告通知里。连续性类客户昨天投诉过丢件上午才说好“下午再送一次”下午又来问进度。客服如果记不住客户就得重复两遍体验直接崩。这三类问题恰好对应三种技术工具调用、RAG、记忆。一开始我也想过只做其中一样但数据说话我拿过去三个月2000条真实客服会话做了统计大约34%是实时信息查询48%是规则知识问答剩下的18%都要依赖历史上下文。你只做RAG最多解决一半问题只做工具调用规则类问题模型就开始一本正经地瞎编只做记忆什么东西都答不了只是显得“有礼貌”。1.2 只做单一能力的系统为什么会被骂我在预研阶段写过三个最小原型专门踩这些坑。只接RAG不接工具的demo客户问“我的件现在停在哪”系统从知识库里检索出一句“包裹已从XX转运中心发出”看着合理其实是另一单的文档片段。因为轨迹是实时数据静态库里根本没有模型就把相似的内容当答案吐了出来。只接工具不接RAG的demo客户问“运单上有错别字能不能快递员帮我改”系统没法回答因为改面单规则不在工具返回里模型自由发挥就会说出“请联系快递员修改”这种违规结论。实际上物流公司对改面单有严格流程乱答会扯出责任问题。最让我尴尬的是没有记忆的版本。一个客户早上问过理赔材料下午同一会话里问“我已上传材料下一步呢”系统完全不记得上午发生过什么重新开始问“请问您要咨询理赔吗”。这种机器人上了线客户能忍住不骂人已经算素质高。1.3 目标与验收口径所以这个项目从立项开始目标就不是“做一个能聊天的机器人”而是“用一套对话运行时把三类问题在一个入口里解决掉”。验收口径也很直白人工客服介入率能不能降下来一次性解决率能不能升上去以及错误回答尤其是涉及理赔、时效承诺的能不能控制在安全范围。后面所有设计和压测都是围绕这三个指标在转。2. 工程底座把Spring AI接进Spring Boot2.1 选型Spring AI还是LangChain4j团队里当时有两条路线争议用LangChain4j的人觉得生态靠近LangChain文档多我的判断是物流系统里已经有大量Spring Boot服务、注册中心、配置中心团队没有Python背景维护一套非Spring生态的Agent框架成本会很高。Spring AI是Spring官方在做的事和Spring Boot的自动装配、starter机制天生能嵌模型供应商抽象也够用。后来我也看了一下Spring AI Alibaba官方社区里基于通义的那套适配如果业务跑在阿里云上替换起来就是改依赖和配置的事。这种可替换性对我们很重要因为物流公司的模型选型往往不是定死的今天用OpenAI明天可能因为合规要求换掉。我不太认同那些把LangChain4j说得一无是处的观点它有自己的优势只是在这个Java为主、靠近Spring生态的团队里Spring AI是更省事的底座。2.2 依赖和配置别小看这一步Maven依赖长这样我用的是当时比较稳的1.1.x版本双版本管理dependencyManagement dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-bom/artifactId version1.1.0/version typepom/type scopeimport/scope /dependency /dependencyManagement dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-tika-document-reader/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-pgvector-store/artifactId /dependency我这里用PGVector做向量库因为它直接建在已有的PostgreSQL实例里运维不用额外引入Milvus。配置上我建议embedding模型和chat模型分开配别图省事都用同一个。我们的chat走OpenAI兼容接口embedding走本地Ollama的nomic-embed-text理由后面讲RAG部分会展开。关键配置大概是这样spring: ai: ollama: base-url: http://your-embedding-host:11434 embedding: model: nomic-embed-text pgvector: index-type: hnsw dimension: 768 initialize-schema: true openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-mini temperature: 0.2有几个必须注意的细节PGVector的dimension必须和embedding模型输出维度一致我用的nomic-embed-text是768维如果换bge-m3就是1024维建表后改维度非常痛苦最好上线前就定死。initialize-schema: true会自动建vector扩展但生产环境我建议DBA手动执行避免AI框架在启动时拿到过高权限。还有temperature不要给太高客服场景需要的是稳定不是创意。2.3 把ChatClient收敛成唯一入口Spring AI的ChatClient是整个系统的核心类似Spring Boot里的RestTemplate所有聊天请求都从它过。我封装成了一个Spring Bean而不是在每个Controller里各自newConfiguration public class AicAgentConfig { Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory, VectorStore vectorStore) { return builder .defaultAdvisors( new UserProfileAdvisor(userProfileMemory()), // 自定义长期记忆后面细讲 new MessageChatMemoryAdvisor(chatMemory), new QuestionAnswerAdvisor(vectorStore, searchRequestConfig()), new ToolCallAdvisor() ) .defaultTools( new QueryTrackTool(), new CalcFreightTool(), new QueryTimingTool() ) .build(); } }这种写法的好处是以后新加记忆策略、新加知识库filter、新加工具都在一个地方改而不是改散落在Controller里的prompt拼接逻辑。我团队后来维护这套代码的同事最感谢我的就是这一点不是你多牛逼而是别人接手时只用看这一个类就能理解全貌。3. RAG不是“扔PDF进去就能答”物流文档的知识工程3.1 拆文档固定长度切分在物流场景会翻车RAG第一节就翻车了。我把一份《国内标准快递理赔条款》PDF按500字符切块灌进向量库然后问“派送延误超过几天可以申请理赔”系统答非所问。查了检索命中的chunk才发现条款里“延误理赔时效为X天”这句话被切成了两半上半截在chunk 17下半截在chunk 18embedding之后语义就断了。这件事让我明白物流文档拆解不能图省事用固定长度要按文档结构拆。Spring AI提供了不少splitter我最后用的是组合策略ListDocument pdfDocs new PagePdfDocumentReader( new FileSystemResource(rules/compensation.pdf) ).get(); MarkdownHeaderTextSplitter headerSplitter new MarkdownHeaderTextSplitter(); TokenTextSplitter overlapSplitter TokenTextSplitter.builder() .chunkSize(400) .chunkOverlap(80) .build(); // 策略先把PDF文本转成Markdown按标题切出章节再对小章节做overlap切分 ListDocument chunks new ArrayList(); for (Document doc : pdfDocs) { for (Document mdDoc : headerSplitter.apply(doc.getText())) { chunks.addAll(overlapSplitter.apply(mdDoc)); } }对Excel那种表格型文档我另外写了一个转换器把每一行转成一句自然语言描述比如“华东区域爆款特惠产品首重1kg以内8元”再灌进向量库。直接按行列拆分喂进去embedding模型根本理解不了列结构这是本地知识库里表格文档最常见的坑。3.2 检索命中率上不去别先怪模型我在项目里建了一个只有80条问题的评估集每条问题人工标注“应该命中哪些知识段落”然后用一个指标看系统是不是真的找到对的东西hit rate也就是正确片段是否出现在TopK检索结果里。第一版结果很难看hit rate只有0.62意味着38%的问题根本没检索到正确答案后面无论模型多聪明都白搭。排查下来有三个原因同义词缺失客户问“泡货怎么收费”文档里写的是“轻泡件计算规则”向量的距离没那么近Top5没带上。表格知识没做语义化禁运品Excel里“电子烟”被归在“电池类危险品”embedding后语义离得远。TopK太小默认TopK4有些正确段落排在第五、第六位。我的处理是对知识库做了一层“同义词别名”扩充在原始段落开头加一行“本标准适用于轻泡件、泡货、抛货等表述”让这个chunk对多种问法都有响应。然后把TopK提到6在线检索时再配合后置过滤成本可控。调整完后hit rate到了0.84我又在检索结果后面接了一层rerank把候选段落用模型重新打分最终稳定在0.9左右。提示hit rate衡量的是“检索环节有没有捞到正确答案”不是“回答对不对”。如果你发现AI答错先查知识有没有被放进用户上下文再查模型的prompt别一上来就换大模型。3.3 QuestionAnswerAdvisor检索接入对话流在Spring AI里RAG通过QuestionAnswerAdvisor接入聊天会话它做的事情是在真正调用大模型之前用向量库检索出相关内容拼到prompt里。我这边的调用方式QuestionAnswerAdvisor questionAnswerAdvisor new QuestionAnswerAdvisor( vectorStore, SearchRequest.builder() .topK(6) .similarityThreshold(0.45) .build(), 回答物流规则问题时应优先引用提供的知识库内容。 如果知识库没有直接依据必须回复该问题需要人工客服协助确认不得自行推断。 );这个自定义system prompt非常关键。通用RAG教程会让你说“请根据上下文回答”但物流客服的合规要求更高没有依据时必须转人工。比如客户问“寄香水到新加坡需要什么文件”知识库里没有这条模型就不该硬编。我们还专门做过红鲱鱼测试故意问一个知识库里不存在的问题看系统是不是宁可承认不知道也不瞎说这个指标比回答多流畅都重要。3.4 知识更新给文档加版本号物流规则更新频率比想象中高特别是节假日时效调整、燃油附加费变动。我的做法比较简单给每个文档加上updatedAt元数据VectorStore写入时用filter只检索指定时间范围内的chunk。Spring AI的SearchRequest支持表达式过滤器FilterExpressionBuilder b new FilterExpressionBuilder(); SearchRequest request SearchRequest.builder() .filterExpression(b.and( b.gte(updatedAt, versionStamp) ).build()) .build();更省事的方案是直接换collection名做版本隔离新规则上线时灌新的collection发布时把系统切过去。缺点是得重灌所有embedding但物流公司的知识库规模一般也就几千个chunk重灌成本并不高换来的是规则版本的确定性。4. 记忆短期滚动上下文加长期用户画像4.1 Spring AI现有的短期记忆机制Spring AI的ChatMemory接口管理会话历史MessageChatMemoryAdvisor负责在每次请求时把历史消息注入给模型。核心键是conversationId客户端每次会话传同一个IDAI才能把多轮对话串起来。我在生产里用的不是默认的InMemoryChatMemory。客服系统会水平扩容请求会落在不同Pod上本地内存一清空就“失忆”。Spring AI的Redis适配可以解决这个问题Bean public ChatMemory chatMemory(RedisTemplateString, Object redisTemplate) { return new RedisChatMemory(redisTemplate); }注意Redis里存的每条消息都要带角色和时间戳否则排序会乱。我踩过一次坑早期版本没有正确处理角色结果System Prompt被当成用户消息写进历史下一次调用全部串味。短期记忆窗口要设上限。我一直用最近10轮对话不是越多越好。太长的历史会把模型注意力稀释它开始重复历史里的细节真正要处理的“查运费”“查轨迹”反而被淹没。短窗口还有个附带的好处是节省token客服场景量大省token就是省钱。4.2 跨对话不忘事长期记忆就该用“置信度时间半衰期”短期记忆解决了单个会话内的连续性但客户隔一天再来还是什么都不记得。群里讨论“跨对话记忆”时有个思路我沿用了下来把记忆抽象成一条条“事实”每条事实带一个置信度分数并且随着时间衰减。这个思路后来看有点像通义那边分享的双网络记忆模型的实践——短期工作记忆解决当前对话长期记忆库存储跨会话画像信息在两者之间流转。我实现得比较简单。客服会话结束后用一个异步任务让LLM从刚结束的对话里抽取事实格式类似{ facts: [ { slot: delivery_time_preference, value: 下午18:00后签收, confidence: 0.9 } ], sensitive_removed: [身份证号, 手机号] }写进长期记忆库我用Redis做存储key是userIdvalue是JSON数组同时记录写入时的last_used时间。每次用户发起新会话时扫描该用户所有记忆按半衰期公式计算当前有效强度public double decay(double confidence, Instant lastUsed, int halfLifeDays) { long days Duration.between(lastUsed, Instant.now()).toDays(); return confidence * Math.pow(0.5, (double) days / halfLifeDays); }我这边把halfLifeDays设为30天。客户两个星期前说过“晚上6点后派送”这条记忆强度还有0.65左右应该保留三个月前的记忆强度已经跌到0.1以下就别往prompt里塞了。然后写一个自定义的UserProfileAdvisor在请求真正发给模型之前把有效记忆拼成一段“过往偏好”放进系统提示词。这一步把“客户上次说不要放驿站”从一次性的对话记忆变成了三个月后依然生效的用户画像。上线后客服团队反馈最明显的就是客户不再需要重复交代“放在门口快递柜就行”AI自己会问“还是放到丰巢吗”。4.3 记忆污染情绪和敏感信息是两把刀记忆侧我栽过两次写出来给你们避坑。第一次是记忆抽取的时候把情绪状态也存进去了。“客户非常生气说要投诉到底”被我作为一条事实记了下来。结果下一次对话时AI一上来就道歉、态度卑微正事没干。原因就是情绪型记忆把整个session的基调带偏了。解决方案抽取事实时明确只提取客观偏好、身份、业务事实不提取情绪状态除非情绪是业务关键信息比如投诉中。第二次是隐私。物流客服对话里天然有身份证号、手机号、地址这类敏感信息。如果不做脱敏这些信息存进Redis就是定时炸弹。我在抽取环节挂了敏感字段过滤器正则识别手机号、身份证后直接替换成占位符记忆库里存的是“手机号已提供”不是真实的11位数字。这个处理当时被安全同事特批通过是项目能过审的关键。5. 工具调用让模型去“戳”那些接口5.1 用Tool定义一组物流工具Spring AI的工具调用相比自己拼Function Calling协议好处在于它用注解标注方法就能自动生成模型的tool schema。我定义了一套和物流客服强相关的工具Component public class QueryTrackTool { Tool(description 根据运单号查询最新物流轨迹) public String queryTrack( ToolParam(description 运单号一般为10位或12位数字) String trackingNo) { TrackDTO track trackApi.query(trackingNo); return track.formatForAgent(); } } Component public class CalcFreightTool { Tool(description 计算两个地址之间的物流运费) public String calcFreight( ToolParam(description 始发地格式省市区如上海市浦东新区) String from, ToolParam(description 目的地格式省市区如浙江省杭州市西湖区) String to, ToolParam(description 重量单位公斤数字) double weight, ToolParam(description 产品类型如标快、特快、经济件) String productType) { return freightApi.quote(from, to, weight, productType); } }工具不是越多越好。第一版我加了12个工具包括“改地址”“催派件”“生成电子面单”结果模型选工具经常选错本该查运费的去调了改地址。后来砍到5个核心工具查轨迹、算运费、查时效、查网点、查理赔进度准确率明显回升。模型每次可选的工具越少它的决策越稳这是工具设计的第一原则。5.2 参数校验和返回结构是工具侧的隐形陷阱模型生成参数不是100%可靠的跟模型说“start_date是2024-01-01”它可能给你回“2024年1月1日”。Spring AI的ToolParam只提供description不负责做实际校验。我的建议是工具方法内部必须做一遍硬校验运单号正则先过一遍长度、数字规则不对直接返回“运单号格式不正确请确认后重试”。地址先做一次匹配匹配不到就在返回信息里提示模型“未识别到地址请让用户补充城市信息”。重量负数直接抛错不要让模型自己去想“重量为负怎么办”。返回内容的结构更要命。工具返回的明文如果是一大段JSON模型很容易把字段名当成废话重复或者为了简化而遗漏信息。我把每个工具的结果封装成面向客服话术的紧凑文本比如轨迹: 2024-06-12 10:23 [上海转运中心] 已离开下一站杭州转运中心 时效: 预计06-13 18:00前到达 最新状态: 运输中这种格式模型几乎不会二次加工出错而且省token。我之前犯过把完整物流详情HashMap直接toString塞给模型结果模型开始胡编“派送员张三电话138…”的长篇大论。5.3 工具不可用时的兜底策略线上一定会遇到第三方接口超时或者熔断。我的处理是工具内部捕获异常后不抛异常而是返回“轨迹查询服务暂时不可用请告知客户稍后查询或转人工”。这句话看似简单实际作用很大——它让模型知道工具确实调了但没拿到数据从而不会自己编造轨迹。如果要更严格可以在系统提示词里写死“所有工具返回的信息都是唯一事实来源工具不可用时必须承认无法查询禁止猜测运单状态。”我测试过没有这句提示词的情况下6次里有1次模型会在工具失败后编一个看似合理的轨迹这在客服场景是不能接受的。6. 三件套的编排顺序Advisor链怎么串6.1 Advisor机制大模型调用前后的过滤器Spring AI把RAG、记忆、工具调用都抽象成了Advisor你可以想象成Java Web里的Filter链请求进来依次经过每个Advisor每个Advisor可以在大模型调用前改写请求加记忆、加上下文、补充工具也可以在大模型返回后处理响应记录日志、格式化输出。我实际跑通的链路是ChatClient chatClient ChatClient.builder(chatModel) .defaultAdvisors( new UserProfileAdvisor(), // 1. 注入长期记忆 new MessageChatMemoryAdvisor(), // 2. 注入短期历史 new ToolCallAdvisor(), // 3. 注册工具 new QuestionAnswerAdvisor() // 4. 注入知识库片段 ) .defaultTools(queryTrackTool, calcFreightTool, ...) .build();这个顺序不是随便排的。长期记忆和短期历史必须放最前面因为它们是“背景信息”模型理解整个对话需要它们。工具调用放中间是为了让模型先知道它有几个工具可用再去决定要不要用。RAG放最后因为知识库检索出来的参考内容是对最终答案影响最大的上下文越靠后的Advisor生成的内容越容易被模型优先采信。6.2 工具和知识库打架时必须说清楚谁说了算这是整个项目里最微妙的地方。有一次客户问“从广州到北京的经济件多久能到”知识库里恰好有一份“经济件时效3-5天”的旧时效表工具实时查询返回的却是“已延误预计6天”。模型不淡定了它同时看到两个信息最后把知识库的3-5天和工具的6天各抄了一半回答“预计5-6天”。问题的根源是没有明确知识库和工具的优先级。我在系统提示词里补了一条规则实时数据的唯一来源是工具返回结果知识库只用于回答规则条款类问题。如果工具返回的时效、运费、轨迹与知识库描述不一致一律以工具返回为准。补上这条后类似冲突基本消失。你也可以在RAG的检索语句里把动态数字类信息过滤掉但提示词层面先讲清楚优先级是成本最低的做法。6.3 让每次调用都可观测AI客服上了线最可怕的是黑盒。客户投诉说“机器人给我承诺今天一定到”你查日志却发现模型压根没有输出这句话AI还跟你犟嘴。所以我在每个Advisor前后都打了结构化日志每次RAG检索命中了哪几个chunk相似度分数多少每次工具调用传入了什么参数、返回了什么、耗时多少一次完整问答消耗了多少输入token和输出tokenSpring AI自带LoggerAdvisor会让你在日志里看到prompt和response的全量内容开发期很好用。生产环境我建议自己写一个轻量级Advisor只记录元数据因为全量prompt日志量非常大而且可能包含敏感信息日志平台都撑不住。6.4 并发和超时客服场景比想象中残酷压测的时候我发现了两个性能问题。第一个是模型API的并发限制。团队用的那个API服务有每分钟调用上限客服高峰期瞬间涌入几十个并发直接429。解决方案是给ChatClient调用加了Semaphore限流和队列宁可让用户多等几秒也不能让请求直接失败。第二个是超时。一次完整问答链路要经过记忆读取、向量检索、工具调用、大模型生成任何一个环节慢都拖后腿。我把大模型调用超时设成15秒工具调用超时设成3秒整体超时设成20秒。超过20秒直接返回“后台正在处理请稍后查看”绝不让用户体验无限转圈。7. 上线后的实际效果与几个值得记下来的坑7.1 效果数据系统上线后运行了一个多月我们拿到的数据大概是这样的在内部评估集上典型知识类问题的回答准确率从第一版的47%提到了81%转人工率下降了22个百分点人工客服日常会话里重复让客户描述问题的情况明显减少因为记忆真的把跨会话的上下文记住了。我特别看重的一个数据是“无依据断言率”也就是模型在知识库没有依据时还强行回答的比例。我们专门做了一个监测报表用规则扫描回复文本命中“预计明天到”“一定送达”这类强承诺话术的对话下降了很多。这在物流咨询里比准确率更重要因为乱承诺时效是要赔钱的。7.2 复盘三个返工最多的坑第一记忆抽取不能贪多求全。我最早以为抽取的事实越多越好结果系统开始记“客户喜欢用蓝色字体”“客户在第3句时语气不好”这种垃圾。后来给抽取的slot做了白名单只有地址偏好、派送偏好、业务标签如“老客户”“协议客户”等业务相关的才允许写入垃圾记忆骤减。第二RAG文档更新慢导致回复过时。有一版运费规则变更后生产环境还挂着旧知识库AI按旧的“首重8元”报价。后来我做了个简单的定时同步任务每次规则变更是自动灌新chunk到新collection并同步修改配置中心里当前collection的指向。麻雀虽小但保证“AI读到的一定是最新一版”这种自动化让我能睡个安稳觉。第三不要把模型能力浪费在低价值内容上。有些对话模型根本不需要大模型比如“查一下运单号SF123456”本质是意图识别加工具调用。我们后来在入口加了一个意图路由层命中“查轨迹”“查运费”这类强意图直接走工具链不经过完整RAG和记忆链路响应时间从5秒压到2秒以内。这也是向agentic rag方向走的第一步把一部分编排逻辑前置而不是每次问答都走全链路。7.3 保持简单是最难的回顾这套系统真正花时间的不是调大模型而是把知识库拆好、把记忆写成可衰减的结构、把工具边界划清楚。Spring AI迭代速度很快我写这套的时候接口还是1.x风格内部已经有人在试用2.0的快照了不少API变得更好用但RAG、记忆、工具调用这三件套的职责划分和协作方式整个思路是稳定成立的。如果你也在做类似的客服系统我最后的建议是先把三类问题分清楚再动手写代码。否则你会在“AI什么都能干”的幻觉里把一个本应该很清晰的项目做成一个别人完全不敢接手的大泥潭。