1. 为什么我要用大模型搭一个答疑机器人
组里维护着十几个内部系统,每天被重复问题轰炸——"这个报错怎么解""配置项在哪改""权限怎么申请"。文档写了没人看,群里问了没人答,答了下次还问。我统计过一周的群消息,超过六成是重复问题,真正需要人工介入的不到两成。这个比例意味着,只要把常见问题接住,人力就能释放出来干真正有价值的事。
于是动手做一个答疑机器人。核心诉求很明确:基于大模型的理解能力,把散落在文档、FAQ、历史问答里的知识整合起来,用户用自然语言提问,机器人给出准确回答,并且回答要像打字一样实时流式输出,而不是等十几秒突然蹦出一整段。这个"流式输出"的体验差异,用过的人都知道,等待感能差出好几倍。
技术选型上,我选了Spring AI作为工程底座,配合SSE(Server-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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>这里有个容易踩的坑:Web 和 WebFlux 同时引入时,Spring Boot 默认用 Web(Servlet 栈),流式返回需要额外配置。我的做法是保留 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); Flux<String> 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钩子来处理:
Flux<String> 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 已经帮你扛了大半,剩下的就是耐心打磨内容。
如果你也在做类似的东西,我的建议是先把最小闭环跑通——一个接口、一个知识片段、一个提示词,能问答就行。跑通之后再逐步加检索、加流式、加中断。别一上来就追求大而全,那样容易卡在半路。