ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于SpringAI的智能助手实战:对话、流式输出与工具调用全解析

基于SpringAI的智能助手实战:对话、流式输出与工具调用全解析 接手这个项目的时候我原以为只是给公司内部做一个“能聊天”的助手员工在页面上问一句机器人答一句。真正做下来才发现一个能上线的智能助手背后牵扯到会话上下文怎么存、长答案怎么流式输出、模型怎么调用企业内部工具、前端怎么对接、部署后怎么排查问题。这套基于SpringAI的智能助手方案是我前后折腾了两周才稳定下来的这篇文章把实现过程、核心代码和踩坑记录全部写出来给准备做SpringAI项目或者在准备毕业设计时选这个方向的同学一条可以直接参考的路径。1. 为什么我选SpringAI来做智能助手而不是自己去拼HTTP调用1.1 这个助手项目到底要被拿来做什么需求说起来不复杂做一个内部智能助手员工可以提问制度相关的问题比如“年假有多少天”“出差报销流程是什么”助手要能基于公司制度文档回答而且回答过程不能像传统搜索那样丢出一堆链接要直接给出结论。但往细了拆这个项目的技术点其实不少。最基础的能力是“对话”一个请求进来我调用大模型API把模型的回复返回给前端。第二个能力是“流式输出”大模型回答制度问题是典型的生成式长文本如果不做流式用户平均要等三四秒才能看到第一个字这在内部系统里是会被吐槽的。第三个能力是“工具调用”模型不知道公司制度库里有什么我必须给它提供检索工具让它先查到相关条款再回答否则它就会一本正经地编造制度。1.2 技术选型时的三个候选方案当时摆在面前的有三条路。第一条路是直接用HTTP调用大模型API。这个方案最简单我用RestTemplate或者WebClient发一个POST请求把消息列表传过去拿到JSON再解析。但做下去就会发现多轮对话的上下文拼接、Function Calling的参数序列化和结果回传全都要自己写。尤其是工具调用这一块模型返回的不一定是最终文本而是“调用某个函数、参数是什么”的结构化指令我需要自己去分发、执行、再把结果塞回请求里。这些逻辑听起来不复杂但细节极多而且每换一个模型服务商协议细节可能都不一样。第二条路是用LangChain4j。它功能全但引入之后项目的依赖和概念都变得很重而且和Spring生态的配合总有点隔阂感。第三条路就是SpringAI。它是Spring官方出的AI应用框架当时我认真看了一遍文档发现自己要的对话、流式、工具调用它都提供了很顺手的抽象。最关键的是项目本身已经是Spring Boot体系用SpringAI意味着我可以复用原来的工程结构、配置方式、异常处理逻辑学习成本最低。1.3 SpringAI真正解决的三个痛点我实际用下来SpringAI解决的核心痛点有三个。第一它把“模型对话”抽象成了ChatClient。我不用关心底层是HTTP还是WebSocket也不关心模型服务商的具体协议差异。只要配置不同的模型地址和模型名调用方式不变。这个抽象让上层的业务代码非常干净。第二它把流式输出做成了响应式流。ChatClient的stream方法返回的是Flux前端用SSE协议接入整条链路是通的。我不用自己去处理分块传输、消息切割这些琐碎事。第三它把Function Calling包装成了“工具注解”。我在Java方法上标注Tool这个方法的名称和描述就会被模型感知到模型判断需要查资料时会自动生成调用这个方法的指令由框架解析、调用、回传结果。这极大降低了智能体Agent的开发门槛。2. 工程骨架搭建Spring Boot项目里AI基础配置的完整思路2.1 依赖怎么加SpringAI和响应式依赖的搭配我新建的是一个独立的Spring Boot工程为了让流式接口顺畅我采用了WebFlux作为Web层。先看pom里最核心的依赖dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId version1.0.0/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency这里有一个很关键的细节如果你的工程里同时存在spring-boot-starter-web和spring-boot-starter-webflux启动时Spring Boot会因为web application type冲突直接报错。Spring AI的流式推荐用WebFlux所以我的做法是去掉MVC依赖只保留WebFlux让所有接口统一走响应式风格。这样后面的流式输出不需要额外的适配层Controller可以直接返回Flux。2.2 配置文件把模型API地址和密钥从代码里拆出去配置是项目里最容易被忽略但实际最影响扩展性的部分。我没有把模型服务的信息硬编码在代码里而是放在application.yml中并且允许通过环境变量覆盖。spring: application: name: spring-ai-assistant ai: openai: api-key: ${AI_API_KEY:sk-demo-key} base-url: ${AI_BASE_URL:https://api.openai.com/v1} chat: options: model: ${AI_MODEL:gpt-4o-mini} temperature: 0.3 max-tokens: 2048为什么要单独把base-url和api-key拆出来因为实际项目里模型服务不一定是固定的。今天用这个服务商明天可能因为成本或合规要求切到另一个服务商。很多国产大模型服务都提供了OpenAI兼容协议只需要把base-url替换成服务商提供的地址把api-key换成对应密钥代码一行都不用改。我在这个项目里就是通过切换配置完成了模型服务的替换省了大量改动。2.3 一个带默认人设的ChatClient实例SpringAI的ChatClient是整个对话的核心它有点像我以前用的RestTemplate一旦通过Builder构建好后面的业务代码都拿它来做请求。我单独建了一个配置类来初始化ChatClientConfiguration public class AssistantConfig { Bean ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem(你是一个企业智能助手。回答要简洁、准确、友好。 涉及员工制度问题时必须依赖提供的工具结果不能凭空编造。) .defaultOptions(ChatOptions.builder() .temperature(0.3) .build()) .build(); } }defaultSystem这个方法很有意思它相当于给模型设定一个“人设”。每个走这个ChatClient的请求都会默认带上这段系统提示词不用在业务代码里重复拼。temperature设置成0.3是想让回答更稳定因为制度问题需要准确性不需要太多创造性。2.4 为什么不建议在服务层到处new ChatClient有一个很容易踩的坑就是到处手动new一个ChatClient。ChatClient构建时会加载模型配置、工具注册信息如果每个业务方法各建各的一方面重复浪费资源另一方面容易出现配置不一致。我的建议是只通过Spring容器管理这一个Bean所有Service都注入同一个ChatClient。后续如果某个业务需要不同的模型或不同的人设再单独构建第二个定制化的ChatClient但每个Bean都要有清晰的职责边界。这样日志排查也会简单很多因为整条链路用的是同一个实例。3. 基本对话链路把“聊天”拆成数据和状态3.1 无状态对话只适合Demo真实场景必须带上会话记忆很多人第一次写SpringAI对话接口代码往往是这样的public String chat(String userMessage) { return chatClient.prompt() .user(userMessage) .call() .content(); }这段代码能跑但有一个致命问题它没有状态。用户说第一句话“帮我查年假制度”助手回答了用户再问“那事假呢”模型根本不记得之前聊过什么会把“那”当成一个孤立的问题。真实业务里员工问制度问题往往是连续追问的所以会话记忆不是可选项而是必需项。SpringAI其实提供了ChatMemory的抽象做了基于窗口的对话记忆。但我在项目里先选择了一种更可控的实现自己管理历史消息做成一个简单的会话存储。原因是这个方案逻辑透明方便我观察模型到底收到了什么上下文在调试阶段很有帮助。3.2 一个简单可用的会话历史实现我写了一个会话存储类用ConcurrentHashMap保存每个sessionId对应的历史消息队列保留最近20条。每次对话时把历史消息拼接成文本放进system提示词并保留最新的用户问题。Component public class SessionChatMemory { private final ConcurrentHashMapString, DequeMapString, String sessions new ConcurrentHashMap(); public DequeMapString, String getHistory(String sessionId) { return sessions.computeIfAbsent(sessionId, k - new ArrayDeque()); } public void trim(DequeMapString, String history) { while (history.size() 20) { history.pollFirst(); } } }对应的对话服务Service public class AssistantService { private final ChatClient chatClient; private final SessionChatMemory sessionMemory; public AssistantService(ChatClient chatClient, SessionChatMemory sessionMemory) { this.chatClient chatClient; this.sessionMemory sessionMemory; } public String chat(String sessionId, String userMessage) { DequeMapString, String history sessionMemory.getHistory(sessionId); history.addLast(Map.of(role, user, content, userMessage)); sessionMemory.trim(history); String historyText history.stream() .map(m - m.get(role) : m.get(content)) .reduce((a, b) - a \n b) .orElse(); String answer chatClient.prompt() .system(以下是本次会话的历史记录请结合历史回答当前问题\n\n historyText) .user(userMessage) .call() .content(); history.addLast(Map.of(role, assistant, content, answer)); sessionMemory.trim(history); return answer; } }这段代码很简单但它把“状态”这件事落了地。上线之后我通过这个存储可以直接看到每个会话聊了什么定位问题非常方便。3.3 内存方案的边界多实例和会话超时这个基于内存的方案有几个边界条件需要提前知道。第一它只在单实例下有效。如果服务部署了多个副本用户的请求被负载均衡到不同实例会话历史就会丢失。所以项目一旦要横向扩容就需要把会话存储迁移到Redis这类外部存储。第二内存里的会话不会自动清理如果用户量一大会越积越多。所以我加了一个定时清理逻辑会话超过24小时没有活跃就直接清掉避免内存占用无限增长。如果直接用SpringAI的ChatMemory和MessageChatMemoryAdvisor这些边界也一样存在只不过把“管理历史”这件事从自己手写变成了框架行为。核心思路是一样的会话要按sessionId隔离历史要控制长度数据要在多实例下共享。4. 流式输出长回答的体验关键点4.1 用户能忍受的等待时间很短流式是为了首字更快对话接口上线之后我让几个同事试用反馈最集中的问题不是回答准不准而是“太慢了”。一个制度问题模型生成几百字的答案非流式接口要等全部生成完才一次性返回耗时往往在4秒以上。人会有一种“像是死机了”的错觉。流式输出的原理是让服务端边生成边推送用户看到的第一个字往往在1秒内就能出现后续内容逐段冒出来。体验差异非常大。所以正式项目里我直接把流式作为默认的对话方式非流式只保留给后端服务间调用或测试接口使用。4.2 服务端怎么把Flux推给前端SpringAI的ChatClient提供了stream方法调用后返回Flux 每个元素就是模型生成的文本片段。在WebFlux环境下我直接返回这个Flux并指定content-type为text/event-stream。RestController RequestMapping(/api/assistant) public class AssistantController { private final AssistantService assistantService; public AssistantController(AssistantService assistantService) { this.assistantService assistantService; } PostMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public FluxString chatStream(RequestBody ChatRequest request) { return assistantService.chatStream(request.sessionId(), request.message()); } }AssistantService中对应的方法public FluxString chatStream(String sessionId, String userMessage) { DequeMapString, String history sessionMemory.getHistory(sessionId); history.addLast(Map.of(role, user, content, userMessage)); sessionMemory.trim(history); String historyText history.stream() .map(m - m.get(role) : m.get(content)) .reduce((a, b) - a \n b) .orElse(); FluxString answerStream chatClient.prompt() .system(以下是本次会话的历史记录请结合历史回答当前问题\n\n historyText) .user(userMessage) .stream() .content(); return answerStream .doOnComplete(() - { history.addLast(Map.of(role, assistant, content, lastAnswer)); sessionMemory.trim(history); }); }这里有一个细节需要特别提醒流式场景下把模型回复保存到会话历史时不能在doOnComplete里直接用静态字符串因为流式输出的内容是一个一个片段。我的做法是先把碎片拼接到一个StringBuilder里在doOnNext时往里面追加然后在doOnComplete里统一把完整回答写入历史。上面代码里的lastAnswer实际上是一个通过doOnNext累计拼接的结果变量真实实现里要把它设计成一个可变的累计器同时要注意并发安全。4.3 前端接收SSE流的要点流式接口在浏览器端用EventSource最简单但EventSource有个限制它默认只支持GET请求而我们的对话接口用的是POST因为要传sessionId和消息体。所以我在前端用的是fetch加上ReadableStream逐段读取。async function sendStreamMessage(sessionId, userMessage) { const response await fetch(/api/assistant/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ sessionId, message: userMessage }) }); const reader response.body.getReader(); const decoder new TextDecoder(); let answerText ; while (true) { const { done, value } await reader.read(); if (done) break; answerText decoder.decode(value, { stream: true }); // 每次拿到新片段更新页面上的对话内容 updateAnswerPanel(answerText); } }一个容易踩的坑是TextDecoder解码时的stream参数。如果不用{ stream: true }多字节字符被拆分到两次chunk里时可能出现乱码。加上这个参数后解码器会缓存未完成的字节等下一个片段到了再拼接基本上能避免中文乱码问题。4.4 流式模式下容易踩的坑流式接口上线前我踩了一个跟“代理缓冲”有关的问题。服务部署到内网环境后前端收到的内容不是逐字出现的而是等了好一会才一次性拿到全部文本。排查了很久才发现不是SpringAI的问题而是Nginx代理默认会缓冲上游响应导致SSE流被卡住。解决办法是在Nginx配置里加两行proxy_buffering off; proxy_cache off;另外如果用的是Spring MVC而不是WebFlux直接返回Flux是走不通的需要在Controller里返回SseEmitter或者把整个服务切到WebFlux。我两种都试过相比之下WebFlux的方案代码最少后续扩展也最方便。5. 用Tool把助手从“聊天”升级成“干活”5.1 为什么需要工具而不是把全库都塞进提示词对话和流式都做完之后助手勉强能用了但离“智能”还差得远。员工问“病假需要什么材料”模型没有看过公司制度库只能凭通用知识回答大概率是错的。有人可能会想把制度全文塞进系统提示词不就行了问题是制度文档动辄几十页远超模型上下文窗口而且每次请求都塞一遍成本和延迟都受不了。正确做法是给模型一个“手电筒”让它自己决定何时去制度库里查。这在大模型术语里叫Function Calling模型在生成回答前会判断是否需要某个外部能力如果需要就输出一个结构化的工具调用指令我们的代码执行这个指令把结果返回给模型模型再基于结果生成最终答案。SpringAI把这个过程包装成了Tool注解。5.2 Tool的用法和name属性到底要不要写我在项目里定义了一个制度检索工具Component public class PolicyTools { Tool(name searchPolicy, description 根据员工提问检索制度库返回相关条款内容) public String searchPolicy(String keyword) { if (keyword null || keyword.isBlank()) { return 请提供要查询的关键词; } return policySearchService.search(keyword, 5); } }这里我特意写了name属性这个是值得展开说的。网上很多教程直接写Tool注解不写name默认用法名作为工具名。看起来没什么问题实际项目里却有两个隐患。第一个隐患是Java方法名有编码规范约束通常用camelCase命名但大模型看到的工具名最好是一个语义化的标识两套命名如果不一致模型理解会出现偏差。第二个隐患是重构代价如果某天你把方法名从searchPolicy改成queryPolicyByKeywordSearch工具名也跟着变了而模型指令或者日志里记录的旧名称就会全部失效。显式写name把这个变化隔离掉了方法名怎么改模型看到的工具名始终是稳定的这是我很推荐的原因。5.3 注册工具的正确方式写好了Tool方法还必须把它注册到ChatClient里模型才能感知到。这一步是新手最容易漏掉的只在类上加了Component以为SpringAI会自动扫描所有带Tool的方法并不是这样你需要显式传入工具对象。Bean ChatClient chatClient(ChatClient.Builder builder, PolicyTools policyTools) { return builder .defaultSystem(你是企业智能助手涉及制度问题时必须使用查询工具。) .defaultTools(policyTools) .build(); }defaultTools这一步非常关键。注册之后SpringAI会扫描传入对象里所有带Tool注解的方法并把它们的名称、描述、参数结构告诉模型。我调试时曾经出现模型完全不用工具的情况第一反应是模型不行后来一查根因就是漏了defaultTools模型压根不知道有这个工具存在。5.4 工具返回内容过长怎么处理工具调用上线后又出现了一个新问题制度检索接口把命中的5条制度全文都返回给模型每条可能几百上千字模型一次生成时上下文瞬间膨胀经常直接报超限。这和大模型上下文窗口有关。我的处理方案是限制工具返回值长度。检索工具不返回全文而是先返回每条制度的标题和关键摘要并且明确告诉模型“如果需要查看完整条款可以继续调用detailPolicy工具传入条款编号”。这样把“搜索”和“看详情”拆成两步大部分问题在摘要层就能回答只有真正需要细节时才调用详情工具token消耗明显下降。这个“工具返回内容要克制”的原则我认为是设计智能体系统时最容易被低估的一点。6. 一个可直接参考的落地案例制度条例学习助手6.1 需求拆解和整体链路这个项目落地时我把它定位成一个“制度条例学习助手”核心场景是员工在Web页面或企业微信里提问助手基于制度库回答。整体链路是前端发起流式对话请求后端根据sessionId找到会话历史携带历史调用ChatClientChatClient按照系统提示词决定是否调用工具工具查制度库把检索结果回传模型模型生成最终答案再通过SSE流式推回前端。整个链路里SpringAI承担了“对话编排”的角色我只需要写好业务工具和系统提示词剩下的交给了框架。6.2 制度知识怎么让模型“看得到”制度库我放在关系型数据库里同时用了向量检索来做召回。每条制度文本会先被切分成段落调用Embedding模型生成向量写入VectorStore。查询时把员工的问题向量化做相似度检索找到最相关的几个段落。这里要特别注意一个点如果制度库还没有做向量化一个简单的关键词检索也能撑起第一版。SpringAI的工具方法里只需要返回一个符合业务逻辑的查询结果底层是ES、数据库LIKE还是向量库模型并不关心。先跑通再优化召回效果这种做法能让你快速验证整个助手框架是通的。6.3 检索工具和对话主流程的衔接制度查询工具我这样设计Component public class PolicySearchTools { private final EmbeddingModel embeddingModel; private final VectorStore vectorStore; public PolicySearchTools(EmbeddingModel embeddingModel, VectorStore vectorStore) { this.embeddingModel embeddingModel; this.vectorStore vectorStore; } Tool(name queryPolicy, description 在制度条例库中检索与员工问题相关的条款输入为员工问题的核心关键词或完整问题) public String queryPolicy(String query) { ListDocument docs vectorStore.similaritySearch( SearchRequest.builder() .query(query) .topK(3) .build()); if (docs.isEmpty()) { return 制度库中没有检索到相关内容; } return docs.stream() .map(doc - 【标题】 doc.getMetadata(title) \n【内容】 doc.getText()) .collect(Collectors.joining(\n\n)); } }会话主流程里我额外加了一段日志输出把模型是否调用了工具、传递了什么参数、工具返回了什么内容打印出来。调试阶段这几乎是救命稻草能让你区分到底问题是出在工具没被调用还是工具调用返回了还是模型没有正确使用返回结果。6.4 实测效果和可优化点一个典型提问流程是这样的员工问“今年我有几天带薪年假”模型判断需要查询制度调用queryPolicy工具参数是从问句中提取的“带薪年假”工具返回相关条例模型结合条例和员工入职时间给出结论。整个过程首字出现在1秒左右最终回答是完整的一段条文说明同时给出依据可信度明显提升。可优化点也很明确。一是会话历史目前还是内存版本多实例部署时要换成Redis。二是制度库做更新后向量数据需要同步重建。三是工具调用给模型增加了额外一轮交互平均延迟会比纯对话高一截如果对延迟特别敏感可以把“是否调用工具”的判断逻辑前移到业务层先做一轮检索再决定要不要让模型生成。这些都是后续迭代的方向。7. 实际部署中的踩坑记录与排查思路7.1 企业微信侧边栏打不开助手先别怀疑后端项目要嵌进企业微信侧边栏结果上传配置后侧边栏就是一片空白或者根本看不到助手入口。这是项目里最让人头大的前端问题一度怀疑接口有问题。后来排查发现问题基本不在后端。企业微信的侧边栏本质上是一个内置浏览器打开的网页后端接口能通过普通浏览器正常访问不代表侧边栏里能正常显示。常见的原因有三个一是前端页面用了history路由在侧边栏内刷新后找不到对应路径变成了空白页二是页面部署的域名没有加入企业微信的JS-SDK安全域名侧边栏的鉴权JS没有初始化整个页面初始化逻辑被卡住三是页面本身在普通PC浏览器上没问题但侧边栏内置浏览器版本较旧有些CSS或JavaScript语法不兼容。排查这类问题的时候我的建议是先改成一个最简单的静态页面放到侧边栏配置的URL上如果这个页面能正常显示再逐步引入项目代码就能比较快地定位是路由、鉴权还是兼容性的问题。7.2 Tool不生效的排查链路模型回答完全不调用工具这是我踩过的一次硬坑。排查思路我后来总结成了一条链路。第一步看请求日志中是否出现了function_call或tool_call相关记录。如果没有说明模型根本没有感知到工具如果有说明模型想调用工具但参数或执行环节出了问题。第二步确认ChatClient是否defaultTools了工具对象漏掉这一步的情况最常见。第三步确认工具方法的描述是否写得足够明确。描述太泛比如写成“工具”模型不知道什么时候用描述太窄模型又会在不相关的问题上去硬套。第四步检查工具方法返回值是否能被正常序列化。如果方法内部抛异常框架会把异常信息回传给模型模型可能会放弃调用直接说“暂时无法回答”。我遇到过工具方法里数据库连接超时表现不是报错而是模型回答变得很奇怪不加日志根本看不出来。7.3 流式部署后首字卡顿的排查流式在本地测试一切正常部署到测试环境后却出现“等很久才一次性吐字”的现象。这种问题基本是链路中的某一层在缓冲数据。排查的顺序是先在服务器上直接curl接口如果服务器本地也是正常的问题大概率出在前置的Nginx或网关如果服务器本地也是卡顿的再去看模型服务和网络延迟。Nginx缓冲和SSE的兼容性问题是重灾区。我当时的修复就是在server配置里关了proxy_buffering。另外还有一个细节如果你在网关层做了超时设置比如默认30秒无响应就断连流式接口长回答生成时间可能超过这个阈值需要把网关的读写超时时间调长。7.4 上下文窗口溢出和长文本截断上下文窗口溢出表现为请求直接返回400错误信息通常提示上下文长度超出限制。这个问题的根因是历史消息和工具返回内容叠加起来超出了模型限制。除了限制历史长度之外我还做了一层兜底对进入提示词的工具返回内容做截断。比如制度库某条条款特别长超过了1000个字符就只保留最核心的前半段并明确告诉模型“内容已截断”。这样模型在生成时至少清楚哪些信息是完整的哪些可能有缺失引导它追问而不是拿着半句话硬编。7.5 SpringAI版本变化带来的API迁移问题SpringAI的版本迭代速度相当快0.8.x到1.0.x之间的API就有不小的变化。网上能找到的很多教程是基于旧版本写的直接复制到新版本跑不通非常正常。我的建议是锁死版本并且以官方文档为第一标准。在pom里固定Version不盲目跟随SNAPSHOT版本。升级时先跑一遍官方提供的迁移指南重点关注ChatClient的构造方式、Tool的注册方式、ChatMemory的API变化这三类最容易变动的地方。我实际迁移时还被一个细节卡过旧版本里用OpenAiChatModel直接注入新版本普遍推荐ChatClient.Builder两种方式在自动配置上已经不一样了照着旧教程写经常注入失败。项目做到这里我对智能助手的理解已经不再是“对话接口”那么单薄了。它更像是一个“带轮子的大模型应用”对话是基础流式是体验工具是能力边界。每次遇到问题我都会先把日志拉出来看看模型到底收没收到消息、收没收到工具结果再决定下一步怎么调。这套排查习惯比任何框架技巧都更值钱。如果你也在用SpringAI搭类似项目建议按这个思路走先跑通静态对话再加流式然后接工具最后再考虑多实例和存储优化。每一步都确认可以上线了再进入下一步返工的概率会小很多。
返回列表