
1. 为什么我要用大模型搭一个答疑机器人组里维护着十几个内部系统每天被重复问题轰炸——这个报错怎么解配置项在哪改权限怎么申请。文档写了没人看群里问了没人答答了下次还问。我统计过一周的群消息超过六成是重复问题真正需要人工介入的不到两成。这个比例意味着只要把常见问题接住人力就能释放出来干真正有价值的事。于是动手做一个答疑机器人。核心诉求很明确基于大模型的理解能力把散落在文档、FAQ、历史问答里的知识整合起来用户用自然语言提问机器人给出准确回答并且回答要像打字一样实时流式输出而不是等十几秒突然蹦出一整段。这个流式输出的体验差异用过的人都知道等待感能差出好几倍。技术选型上我选了Spring AI作为工程底座配合SSEServer-Sent Events做流式推送前端用AbortController支持随时中断。为什么是这套组合因为团队是 Java 技术栈Spring AI 把大模型交互逻辑封装得足够干净不用自己造轮子去处理 HTTP 调用、重试、流式解析这些脏活。而 SSE 相比 WebSocket对于服务端单向推送、客户端只接收这种答疑场景实现成本低得多浏览器原生支持不需要额外协议握手。这篇文章我会把整个搭建过程拆开讲透从整体架构怎么设计、Spring AI 工程怎么搭、流式输出怎么实现、Abort 中断怎么处理到知识库怎么组织、提示词怎么写、踩过哪些坑。适合有 Java 基础、想快速落地一个可用答疑机器人的同学也适合正在评估 Agent 方案、想知道工程细节的同行。大模型、答疑机器人、Agent、LLM、流式输出这几个关键词会贯穿全文但我不堆概念只讲能跑起来的东西。2. 整体架构设计与技术选型思路2.1 答疑机器人的核心链路拆解一个答疑机器人剥开外壳本质是四段链路接收问题 → 检索知识 → 组织提示词 → 调用大模型 → 流式返回。听起来简单但每一段都有取舍。接收问题这层要考虑的是并发和会话管理。用户可能同时问多个问题也可能追问上下文。我用一个conversationId来标识会话服务端维护最近 N 轮的对话历史避免每次请求都把全部历史塞进提示词导致 token 爆炸。检索知识这层是最容易被低估的。很多人一上来就想着我把所有文档丢给大模型不就行了实测下来根本不行——文档一多上下文窗口塞不下就算塞得下大模型也会迷失在中间前面和后面的内容记得住中间的关键信息反而忽略。所以必须做检索先缩小范围再喂给模型。我采用的是关键词检索 向量检索混合的方式后面会细讲。组织提示词这层决定了回答质量的上限。同样一段知识提示词写得好模型答得准写得烂模型就开始胡编。这里涉及提示词工程和上下文工程我会给出实际用的模板。调用大模型这层要考虑的是模型选择、超时、重试、流式解析。Spring AI 在这里帮了大忙它把不同厂商的 API 差异抹平了切换模型基本只改配置。2.2 为什么选 Spring AI 而不是自己封装我一开始也想过自己封装 HTTP 调用毕竟就是发个请求收个响应。但真动手才发现坑太多不同厂商的请求体格式不一样、流式返回的 SSE 格式不一样、错误码不一样、重试策略不一样。自己封装等于把这些差异全扛在自己身上维护成本极高。Spring AI 的价值在于它提供了一层统一的抽象。ChatClient接口屏蔽了底层差异StreamingChatModel统一了流式调用Advisor机制让检索增强RAG可以插拔式接入。你写业务逻辑的时候面对的是统一的 API而不是各家厂商的 SDK。提示Spring AI 版本迭代较快建议锁定一个稳定版本不要盲目追新。我用的版本在流式接口上有过 breaking change升级时踩过坑。当然Spring AI 也不是银弹。它的抽象层在某些高级场景下会限制你的控制力比如你想精细控制流式 chunk 的合并策略可能得绕过它的封装。但对于答疑机器人这种场景它的抽象程度刚刚好。2.3 SSE 流式输出 vs WebSocket为什么选前者流式输出有两种主流方案SSE 和 WebSocket。我选 SSE理由有三。第一场景匹配。答疑机器人是典型的客户端问一句、服务端答一段的单向推送模式不需要双向实时通信。WebSocket 的全双工能力在这里是浪费。第二实现成本。SSE 基于普通 HTTP浏览器原生EventSource支持服务端 Spring 的SseEmitter开箱即用。WebSocket 需要额外的协议升级、心跳保活、断线重连逻辑代码量翻倍。第三调试友好。SSE 的返回就是一段文本流用 curl 就能看到效果排查问题直观。WebSocket 的二进制帧调试起来麻烦得多。代价是 SSE 不支持客户端向服务端推送但这个场景里我们本来就不需要。另外 SSE 在 HTTP/1.1 下有连接数限制同域名 6 个不过答疑场景并发不高影响可忽略。2.4 Abort 中断一个容易被忽略但必须做的功能用户提问后如果发现问错了或者等得不耐烦应该能随时中断。这个功能看似小但体验上很重要——没有中断用户只能干等或者刷新页面前者体验差后者浪费服务端资源。实现上前端用AbortController取消 fetch 请求服务端检测到连接断开后要主动停止对大模型的调用避免继续消耗 token。Spring AI 的流式接口返回的是Flux可以通过doOnCancel钩子感知取消事件进而释放资源。这块细节我在第 4 节会展开。3. Spring AI 工程搭建与核心配置3.1 项目初始化与依赖选择工程用 Maven 管理Spring Boot 3.x 打底。核心依赖就三个spring-boot-starter-web提供 Web 能力、spring-ai-starter大模型交互、spring-boot-starter-webflux流式返回需要 Reactive 支持。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-webflux/artifactId /dependency这里有个容易踩的坑Web 和 WebFlux 同时引入时Spring Boot 默认用 WebServlet 栈流式返回需要额外配置。我的做法是保留 Web 作为主栈流式接口用SseEmitter而不是Flux直接返回这样避免两套栈打架。如果你追求纯粹的 Reactive可以只用 WebFlux但那样整个工程都要按响应式写学习成本高。注意spring-ai-openai-spring-boot-starter是通用 OpenAI 兼容协议的 starter很多国产大模型都兼容这个协议改个 base-url 就能切换。这也是我选它的原因——不被单一厂商绑定。3.2 大模型接入配置参数怎么填配置文件里核心是这几项spring: ai: openai: base-url: https://your-model-endpoint/v1 api-key: ${MODEL_API_KEY} chat: options: model: your-model-name temperature: 0.3 max-tokens: 2048temperature我设成 0.3这是答疑场景的关键。答疑要的是准确和稳定不是创意。温度太高同一个问题两次回答不一样用户会困惑。0.3 是个平衡点既保留一点语言灵活性又不会胡编。max-tokens设 2048是因为答疑回答通常不会太长设太大反而让模型倾向于啰嗦。如果发现回答被截断再往上调。base-url和api-key用环境变量注入不要硬编码在配置文件里。这是基本的安全习惯代码提交到仓库时不会泄露密钥。3.3 对话客户端 ChatClient 的封装Spring AI 的ChatClient是核心入口。我封装了一个QaService对外暴露两个方法同步问答和流式问答。Service public class QaService { private final ChatClient chatClient; private final KnowledgeRetriever retriever; public QaService(ChatClient.Builder builder, KnowledgeRetriever retriever) { this.chatClient builder .defaultSystem(SYSTEM_PROMPT) .build(); this.retriever retriever; } public String ask(String question, String conversationId) { String context retriever.retrieve(question); return chatClient.prompt() .user(u - u.text(USER_TEMPLATE) .param(context, context) .param(question, question)) .call() .content(); } }defaultSystem设置的是系统提示词定义机器人的角色和行为边界。这个提示词我改了很多版后面单独讲。KnowledgeRetriever是检索组件负责根据问题找出相关知识片段。它的实现质量直接决定回答准确率是整条链路里最需要打磨的部分。3.4 系统提示词的设计要点系统提示词是机器人的人设和行为准则。我最终用的版本大致是这样你是一个内部系统答疑助手。你的职责是依据提供的知识片段回答用户问题。 规则 1. 只依据【知识片段】回答不要编造知识片段中没有的信息。 2. 如果知识片段无法回答该问题明确告知用户这个问题我暂时没有找到答案并建议联系人工。 3. 回答要简洁直接给出解决方案不要重复问题。 4. 涉及操作步骤时用有序列表列出。 5. 不要输出与问题无关的寒暄。这几条规则里第 1 条和第 2 条最关键。大模型天生倾向于给出一个答案哪怕它不知道也会编一个看起来合理的。明确告诉它不知道就说不知道能大幅降低幻觉率。实测下来加了这条规则后胡编的情况从经常出现降到偶尔出现。第 5 条是体验优化。不加的话模型经常开头来一句您好关于您的问题结尾来一句希望对您有帮助啰嗦且占 token。4. 流式输出与 Abort 中断的完整实现4.1 流式接口的服务端实现流式接口用SseEmitter实现。核心逻辑是拿到大模型的流式响应每收到一个 chunk 就通过 emitter 推给前端。GetMapping(value /qa/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamAsk(RequestParam String question, RequestParam String conversationId) { SseEmitter emitter new SseEmitter(120_000L); String context retriever.retrieve(question); FluxString stream chatClient.prompt() .user(u - u.text(USER_TEMPLATE) .param(context, context) .param(question, question)) .stream() .content(); stream.subscribe( chunk - { try { emitter.send(SseEmitter.event().data(chunk)); } catch (IOException e) { emitter.completeWithError(e); } }, emitter::completeWithError, emitter::complete ); emitter.onTimeout(emitter::complete); emitter.onCompletion(() - { /* 释放资源 */ }); return emitter; }SseEmitter的超时设 120 秒因为大模型生成完整回答可能需要几十秒设太短会中途断开。onTimeout和onCompletion回调里要做资源清理避免连接泄漏。produces MediaType.TEXT_EVENT_STREAM_VALUE这个注解不能少它告诉浏览器这是 SSE 流浏览器才会按流式处理而不是等全部返回。4.2 前端如何实时渲染流式内容前端用fetch而不是EventSource因为EventSource只支持 GET 且不能自定义请求头而fetch配合ReadableStream更灵活。async function askStream(question, conversationId, signal) { const response await fetch( /qa/stream?question${encodeURIComponent(question)}conversationId${conversationId}, { signal } ); const reader response.body.getReader(); const decoder new TextDecoder(); let answer ; while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); answer parseSseChunk(chunk); renderAnswer(answer); } }TextDecoder的{ stream: true }参数很重要。SSE 的 chunk 可能把一个多字节字符比如中文切成两半不加这个参数会解码出乱码。这个坑我踩过表现为回答里偶尔出现排查了半天才发现是解码问题。parseSseChunk负责剥离 SSE 的data:前缀提取真正的文本内容。SSE 格式是data: xxx\n\n需要按行解析。4.3 AbortController 中断的完整链路中断功能分两端前端取消请求服务端感知取消。前端const controller new AbortController(); askStream(question, conversationId, controller.signal); // 用户点击停止按钮时 controller.abort();调用abort()后fetch 请求被取消连接断开。服务端要感知这个断开。Spring AI 的stream()返回Flux当客户端断开时Flux会收到 cancel 信号。我通过doOnCancel钩子来处理FluxString stream chatClient.prompt() .user(...) .stream() .content() .doOnCancel(() - { log.info(客户端取消停止生成); // 释放资源记录日志 });这里有个细节取消后大模型那边可能还在生成。如果用的是按 token 计费的 API取消能省下后续 token 的费用。但有些厂商的流式接口取消后仍会计费这个要看你用的具体服务建议实测确认。提示doOnCancel只在客户端主动断开时触发。如果是服务端超时导致的断开走的是onTimeout。两个都要处理别漏。4.4 流式输出的性能与体验优化流式输出有个体验问题大模型返回的 chunk 粒度可能很小一个词一个词地蹦前端如果每个 chunk 都触发一次 DOM 更新会造成频繁重排页面卡顿。我的优化是前端做节流合并用一个缓冲区收集 chunk每 50 毫秒批量渲染一次。这样既保留了流式的实时感又避免了 DOM 抖动。let buffer ; let timer null; function onChunk(chunk) { buffer chunk; if (!timer) { timer setTimeout(() { renderAnswer(buffer); timer null; }, 50); } }50 毫秒是实测下来比较舒服的值。低于 30 毫秒渲染频率太高没意义高于 100 毫秒用户能感觉到卡顿。另外流式输出时最好加一个光标闪烁效果让用户知道还在生成中。这个纯 CSS 就能做体验提升明显。5. 知识库组织与检索增强实战5.1 知识来源的整理与清洗答疑机器人的回答质量七分靠知识三分靠模型。知识库没整理好模型再强也答不准。我的知识来源有三类产品文档、历史问答记录、常见问题 FAQ。这三类内容格式不一需要先清洗。产品文档通常是 Markdown结构清晰直接按标题切分即可。历史问答记录是聊天记录需要提取问题-答案对去掉寒暄和无关内容。FAQ 是表格按行拆成独立条目。清洗的核心原则是每个知识片段要自包含。也就是说单独拿出一个片段它自己能说清楚一件事不依赖上下文。比如点击右上角按钮这种片段就不合格因为不知道是哪个页面的右上角。要改成在订单详情页点击右上角按钮。5.2 分块策略多大一块才合适知识片段的大小直接影响检索效果。太大检索出来的内容冗余浪费 token太小信息不完整模型答不全。我试过几种粒度最终定在300 到 500 字一个片段。这个粒度下一个片段通常能完整描述一个操作步骤或一个概念检索时也不会带太多无关内容。分块时尽量按语义边界切不要机械地按字数切。比如按 Markdown 的二级标题切按段落切实在没有结构再按字数。机械切分容易把一个完整步骤切成两半检索到一半反而误导模型。5.3 混合检索关键词 向量纯向量检索有个问题对于专有名词、错误码、配置项名称这类精确匹配需求向量检索反而不如关键词检索准。比如用户问ERR_5003 怎么解决向量检索可能召回一堆语义相近但错误码不同的内容。我的方案是混合检索先用关键词检索比如 BM25 或简单的倒排索引召回一批再用向量检索召回一批两批结果合并去重后按相关性排序。关键词检索负责精确匹配向量检索负责语义匹配两者互补。实测下来混合检索的召回率比单一方式高不少尤其是对错误码、专有名词这类查询。5.4 检索结果如何注入提示词检索出知识片段后要注入到提示词里。我的模板是这样【知识片段】 {context} 【用户问题】 {question} 请依据上述知识片段回答用户问题。context是检索出的片段拼接多个片段之间用分隔线隔开。如果检索结果为空context填无相关知识模型看到这个就会按系统提示词里的规则回复暂时没有找到答案。这里有个技巧给每个片段编号并在提示词里要求模型引用编号。这样回答里能带上来源用户想深究可以去看原文。不过这个功能会增加 token 消耗看需求取舍。6. 常见问题排查与避坑经验6.1 流式输出常见故障速查现象可能原因排查方向回答一次性蹦出没有流式效果响应头没设text/event-stream检查produces注解中文出现乱码解码没开 stream 模式TextDecoder加{stream:true}流式中途断开超时设置太短调大SseEmitter超时取消后服务端还在跑没处理 cancel 信号加doOnCancel钩子回答重复或错乱会话历史管理有问题检查 conversationId 隔离这张表是我实际遇到过的坑的汇总。其中中文乱码和取消后还在跑这两个最隐蔽前者表现为偶发后者只有看日志才发现。6.2 大模型幻觉的抑制手段幻觉是答疑机器人的头号敌人。用户问一个知识库里没有的问题模型编一个看似合理的答案用户信了后果可能很严重。抑制幻觉我用了三招。第一招是系统提示词明确边界前面讲过告诉模型不知道就说不知道。第二招是检索为空时强制兜底如果检索结果为空直接返回固定话术不调用模型。第三招是降低 temperature减少模型的自由发挥。三招叠加后幻觉率大幅下降。但要说完全消除做不到。所以我在回答末尾加了一句以上回答基于知识库如有疑问请联系人工确认给用户一个心理预期。6.3 Token 消耗的控制技巧Token 就是钱答疑机器人如果 token 控制不好成本会失控。我做了几件事。限制会话历史长度。只保留最近 5 轮对话更早的丢弃。答疑场景通常不需要太长的上下文。检索片段数量限制。最多注入 3 个片段多了浪费。实测 3 个片段能覆盖绝大多数问题的知识需求。回答长度限制。max-tokens设 2048防止模型长篇大论。缓存高频问题。对于反复出现的相同问题直接返回缓存答案不调用模型。我统计过Top 20 的高频问题占了总提问量的四成缓存这部分能省下可观的成本。6.4 我踩过的三个真实坑第一个坑是会话串号。早期版本我用用户 IP 做会话标识结果同一个办公室的人共用出口 IP对话历史串在一起A 问的问题 B 能看到。后来改成前端生成 UUID 作为 conversationId问题解决。第二个坑是流式 chunk 边界。大模型返回的 chunk 不保证按语义切分可能把一个词切成两半。前端如果按 chunk 直接渲染会出现半个词。解决办法是前端做缓冲等收到完整词再渲染或者干脆按固定时间节流。第三个坑是并发下的资源泄漏。压测时发现连接数只增不减排查发现是SseEmitter的onCompletion回调里没正确释放资源。加上清理逻辑后恢复正常。这个坑提醒我流式接口的资源管理比普通接口复杂得多必须仔细处理每个回调。7. 后续可以怎么扩展这套答疑机器人跑起来后我又想了几个扩展方向。多轮追问是其一现在虽然保留了会话历史但对追问的处理还不够智能用户问那第二步呢模型有时接不上。多模态是其二如果用户能截图提问机器人能识别图片里的报错信息实用性会更强。反馈闭环是其三是让用户对回答点赞点踩把差评的问题收集起来定期补充到知识库形成正向循环。不过这些都是后话。当前这套方案从零到能用我一个人大概花了一周多其中大半时间花在知识库整理和提示词调优上写代码的时间反而不多。这也印证了那句话答疑机器人的难点不在工程在知识和提示词。工程部分 Spring AI 已经帮你扛了大半剩下的就是耐心打磨内容。如果你也在做类似的东西我的建议是先把最小闭环跑通——一个接口、一个知识片段、一个提示词能问答就行。跑通之后再逐步加检索、加流式、加中断。别一上来就追求大而全那样容易卡在半路。