Spring Boot 接入大模型对话接口:HR问答机器人实战全解析
最近把 AI 全栈系列的第十二个实战小节做完了这一节的内容是用 Spring Boot 跑通第一个 AI 对话接口落地场景是一个 HR 政策问答机器人。说实话这个标题看起来简单但真正把接口调通、让回答靠谱、再扛住生产环境的基本压力中间隔着一整条细节链。先说结论Spring Boot 接大模型接口这件事本质就是一次 HTTP POST 请求很多初次接触的人被“AI”两个字唬住了以为有什么神奇的东西。实际上难点不在“怎么调通”而在“怎么把这个接口封装成能用的业务能力”。尤其是 HR 政策问答这个场景员工问的是产假多少天、年假怎么折算、公积金基数怎么定每一条都有明确的政策依据模型一旦开始自由发挥比不回答还危险。这篇文章我会从工程搭建、接口设计、会话管理、提示词工程、生产化改造到实测排障把整个链路完整复现一遍。适合已经开始写 Spring Boot、但第一次接大模型对话接口的开发者参考也适合想把内部知识问答类场景做成一个正经服务的团队借鉴。内容基于我实际项目里的简化版本密钥和域名相关部分都做了脱敏处理你拿到手替换成自己的配置就能跑。1. 为什么先用 Spring Boot 接大模型而不是 Python 脚本1.1 企业内部系统的技术栈现状决定了你的集成姿势绝大多数公司内部系统是 Java 技术栈HR 政策问答这种需求最终要落到哪里要么是钉钉或企业微信里的小程序要么是 OA 系统里的一个入口要么是内部告警群里被 的机器人。这些系统的后端接口全部是 Java 写的用 Spring Boot 直接接大模型前后端就能共用一套消息结构和身份体系不需要额外维护一个 Python 中间层。维护成本这件事只有踩过坑的人才明白多一个服务就多一套日志、多一套监控、多一套部署流程。我见过不少团队在这种场景下选择“让 Python 服务去调大模型Java 再请求 Python”结果每次模型接口升级、超时时间调整、提示词更新得同时改两个项目。其实为一个 HTTP 调用引入跨语言服务性价比很低。1.2 先别急着上框架自己封装一次 HTTP 调用现在很多人一上来就在项目里引入 Spring AI 这类框架。我建议第一次做的时候先自己用 RestClient 封装一遍底层的对话补全接口。原因很简单大模型对话接口的协议非常轻核心就是“把 messages 数组发过去把 choices 里的 content 取回来”。自己封装一次之后你会清楚地知道请求体长什么样、响应体里哪些字段能用后面再去理解 Spring AI 之类的封装就有了具象的认知基础。框架确实能帮规范一些细节但它同时也会替你做了很多决定某些决定在你场景下反而是错的比如默认的流式处理方式、默认的 JSON 映射策略、超时重试策略。我这次就是用的 Spring Boot 内置的 RestClient没引任何 AI 框架。依赖少、逻辑透明、排查问题容易。2. 开工前的工程准备最小工程与第一个接口2.1 项目骨架和依赖直接用 Spring Initializr 建工程Java 版本我选了 17Spring Boot 用的 3.2 以上版本。因为 RestClient 是 Spring 6.1 引入的能力老版本没有还得自己配 RestTemplate麻烦。pom.xml 里核心依赖其实就一个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency如果你要写单元测试再加一个 spring-boot-starter-test。这里有个容易忽略的点很多教程会顺手加上 spring-boot-starter-webflux说是为了响应式、为了以后支持流式输出。如果暂时不需要流式加了它之后 Spring 会同时存在 MVC 和 WebFlux 两套容器启动时有标记提示偶尔还会出现出乎意料的 Bean 冲突。第一次跑通阶段不要加。2.2 密钥管理和配置项API Key 这种东西绝对不允许写死在代码里。我习惯放在 application.yml 里用环境变量占位这样本地开发换成自己的变量就行部署到测试环境、生产环境各自设置环境变量代码完全不用动。ai: api-key: ${AI_API_KEY:sk-your-key-here} base-url: ${AI_BASE_URL:https://api.example.com/v1} model: ${AI_MODEL:your-model-name} temperature: 0.2 max-tokens: 1024 timeout-seconds: 30这里我故意没写死具体的模型名因为大模型开放平台越来越多各自命名的模型五花八门deepseek-chat、qwen-plus、glm-4-flash 我都测过协议基本兼容 OpenAI 的 /chat/completions 格式你只需要确认自己账号开通了哪个模型把名字换进去。注意 base-url有的平台给的是根域名有的给的是带 /v1 的地址以你自己平台控制台显示的为准。这个填错是最常见的 404 原因。2.3 调用链路的三个核心类先建一个 RestClient 配置类统一管理超时时间和公共请求头Configuration public class AiRestClientConfig { Bean public RestClient aiRestClient( Value(${ai.base-url}) String baseUrl, Value(${ai.api-key}) String apiKey, Value(${ai.timeout-seconds}) int timeoutSeconds) { SimpleClientHttpRequestFactory factory new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(Duration.ofSeconds(10)); factory.setReadTimeout(Duration.ofSeconds(timeoutSeconds)); return RestClient.builder() .baseUrl(baseUrl) .requestFactory(factory) .defaultHeader(Authorization, Bearer apiKey) .defaultHeader(Content-Type, application/json) .build(); } }connectTimeout 是建立 TCP 连接的超时时间readTimeout 是等待响应数据的超时时间。大模型接口因为要做推理响应速度普遍比普通业务接口慢readTimeout 我建议 30 秒起步如果选了较大的模型60 秒也不夸张。接下来定义请求和响应的数据结构。请求最核心的就是 messages 数组每一项包含角色和内容public record ChatMessage(String role, String content) {} public record ChatCompletionRequest( String model, ListChatMessage messages, Boolean stream, Double temperature ) {} public record ChatCompletionResponse(ListChoice choices) { public record Choice(ChatMessage message) {} }响应体里的 choices 数组通常只有一个元素取 choices[0].message.content 就是模型生成的文本。有少数模型会返回多个 choices那是多候选场景一般用不到。调用封装的 ServiceService public class AiChatService { private final RestClient restClient; private final AiProperties aiProperties; public AiChatService(RestClient aiRestClient, AiProperties aiProperties) { this.restClient aiRestClient; this.aiProperties aiProperties; } public String chat(ListChatMessage messages) { ChatCompletionRequest request new ChatCompletionRequest( aiProperties.getModel(), messages, false, aiProperties.getTemperature()); ChatCompletionResponse response restClient.post() .uri(/chat/completions) .body(request) .retrieve() .body(ChatCompletionResponse.class); if (response ! null response.choices() ! null !response.choices().isEmpty()) { return response.choices().get(0).message().content(); } throw new RuntimeException(AI response empty); } }AiProperties 就是绑定 application.yml 里 ai 前缀的 ConfigurationProperties 类。这里不做展开你应该见过。这里我需要提醒一下BusinessException 和 RuntimeException 的选择。实际项目里我建议自定义一个 ChatException 并配合全局异常处理器返回给前端的结构统一。我在 demo 里先用了 RuntimeException是为了让排查问题的时候堆栈更直观真实项目请替换。2.4 用 curl 验证接口通没通代码写完先别急着写 Controller先用 curl 验证网络链路和鉴权curl https://api.example.com/v1/chat/completions \ -H Authorization: Bearer $AI_API_KEY \ -H Content-Type: application/json \ -d { model: your-model-name, messages: [{role: user, content: 你好}], stream: false }这一步的意义是隔离问题域如果 curl 通但 Java 代码不通问题一定在配置或代码如果 curl 都不通那是网络、密钥、模型名的问题根本不用去检查代码。实测下来很多同事在这一步卡很久都是因为直接写代码调出了问题还要在“网络”和“代码”两个方向同时排查。3. 从单轮问答到多轮会话消息结构与上下文管理3.1 大模型接口的“无状态”特性决定了你必须自己记上下文大模型对话接口本身是无状态的。它不记得你上一次问了什么唯一能影响它“记忆”的就是你在请求体 messages 里带了哪些历史消息。这也是很多人第一次做对话应用时的认知落差以为服务端保存 session模型就自动有记忆了。实际上上下文是你每次请求时手动拼进 messages 数组的。messages 的顺序有讲究。第一项是 system用来设定角色和规则随后交替出现 user 和 assistant 消息user 是你说的话assistant 是模型之前的回复。保持这个交替顺序非常重要否则模型容易把角色搞混回一些奇怪的东西。所以设计接口时Controller 不能只把用户当前一句 question 转发给大模型还需要做两件事取历史会话、追加新问题、调模型、追加模型回答、存回历史。3.2 一个够用的会话管理器内存版与缓存版先给一个最表层的实现用 ConcurrentHashMap 保存每个会话的消息列表Component public class HrSessionStore { private final MapString, ListChatMessage sessions new ConcurrentHashMap(); public ListChatMessage getHistory(String sessionId) { return sessions.computeIfAbsent(sessionId, k - new ArrayList()); } public void append(String sessionId, ChatMessage message) { sessions.computeIfAbsent(sessionId, k - new ArrayList()).add(message); } }Controller 端实现RestController RequestMapping(/api/ai/hr) public class HrChatController { private final AiChatService aiChatService; private final HrSessionStore sessionStore; public HrChatController(AiChatService aiChatService, HrSessionStore sessionStore) { this.aiChatService aiChatService; this.sessionStore sessionStore; } PostMapping(/chat) public MapString, String chat(RequestBody ChatRequest request) { String sessionId request.sessionId() null ? UUID.randomUUID().toString() : request.sessionId(); ListChatMessage history sessionStore.getHistory(sessionId); history.add(new ChatMessage(user, request.message())); String reply aiChatService.chat(history); history.add(new ChatMessage(assistant, reply)); return Map.of( sessionId, sessionId, reply, reply ); } }这段代码能跑但只能用于 demo。因为会话数据放在进程内存里一重启全丢多实例部署时如果负载均衡把同一个 sessionId 的请求分到不同机器上下文就串了。生产环境最简单的方案把 List 序列化成 JSON存 Rediskey 就是 sessionId。设置一个合理的过期时间比如 30 分钟无交互就清理会话。我这个内存版只是为了把逻辑模型跑通你替换存储层时只改 HrSessionStore 的实现即可Controller 和 Service 完全不用动。3.3 消息截断策略你的消息列表不可能无限长随着对话轮数增加messages 数组会越来越长。每个模型都有上下文窗口限制超过之后接口会直接报错或者你自己花钱买 token。因此必须做截断。我的做法比较朴素始终保留 system 消息在第一条然后只保留最近 N 条对话。为了让对话尽量连贯我通常保留最近的 10 到 12 条超出部分直接丢弃。public ListChatMessage trimHistory(ListChatMessage messages, int keepCount) { ListChatMessage systemMessages messages.stream() .filter(m - system.equals(m.role())) .toList(); ListChatMessage tail messages.subList(Math.max(messages.size() - keepCount, 0), messages.size()); ListChatMessage merged new ArrayList(); merged.addAll(systemMessages); merged.addAll(tail); return merged; }注意合并顺序system 必须在数组的最前面。我曾经踩过坑截断后 system 跑到中间模型对指令的遵循度明显下降。尽量把 system 提到的规则控制在 800 token 以内其余知识内容后面单独讲注入方式。4. HR 政策问答的关键 30 分钟提示词设计与知识注入4.1 为什么 HR 政策问答必须限制模型的自由度如果你直接把员工的问题扔给裸模型它的回答会有两个问题第一它根据训练语料“猜”政策猜错了就是误导第二它特别愿意补全细节比如你问“产假多少天”它能答出劳动法框架内的一般性规定但这跟你公司实际制度完全可能是两码事。HR 政策问答的本质是在“有限的政策知识库”里做检索和摘录模型负责的是语言组织和格式整理而不是知识生成。这就要求在提示词层面做双重约束回答内容必须来自给定材料如果材料没有覆盖必须明说不知道并引导找 HR。4.2 提示词模板角色、边界、拒答规则我的 system 提示词长这样public static String buildSystemPrompt(String policyText, String faqText) { return 你是【云帆科技】内部的 HR 政策问答助手你的唯一职责是解答员工关于公司人事政策的问题。 回答规则 1. 严格遵守以下政策原文和常见问题速查只能依据这两份材料作答。 2. 如果材料未覆盖该问题请直接回答公司现行政策暂未覆盖该情况建议联系 HR 部门hrcloudsail.example。 3. 涉及数字、日期、资格条件的回答必须引用材料原话不要自行推算。 4. 与公司政策无关的问题如编程、写作、生活闲聊礼貌拒绝并提示员工访问正确的咨询渠道。 5. 回答尽量简洁分点输出避免空话。 政策全文 %s 常见问题速查 %s .formatted(policyText, faqText); }几个细节值得展开。温度参数在这里被我压到 0.2。原因是政策问答要求确定性回答太发散就是事故。temperature 调到 0 会让模型过于机械0.2 是一个平衡值。如果是闲聊机器人可以放开到 0.7 以上但这里是问答稳定性优先。第 3 条“不要自行推算”特别重要。比如员工问“我今年 10 月入职明年能休几天年假”模型如果自己拿入职月份和工龄规则去算很容易算错。正确做法是让它输出政策原文的计算公式或者直接提示员工按系统入口查询。宁可答得笨一点不能答得错一点。4.3 轻量知识注入政策速查 粗糙召回政策全文往往很长动辄几万字不可能全部塞进 system。一个务实的做法是分两层高频 FAQ 速查 文档分块粗糙召回。第一层把 HR 部门日常被问得最多的 20 个问题整理成“问题-答案”对拼进 system。这 20 条能覆盖大概 70% 的咨询量。第二层把完整政策文档按章节拆成若干个小块每块编上关键词。收到用户问题后先做一个极简的词频匹配召回两块最相关的片段拼在 system 的“政策全文”位置。这样既不超 token又能保证针对性。这套方案严格来说不算真正意义的 RAG——它没有向量化没有相似度检索只是基于关键词的粗糙匹配。但效果已经能撑起中小公司的内部问答需求。真要追求检索质量后续可以引入向量库和 Embedding 模型但那是另一个项目的事。4.4 效果对比没注入知识时模型在瞎编我测试时故意跑了两个对照。不注入任何 HR 政策知识只给系统提示“你是 HR 助理”问“云帆科技试用期多久”模型一本正经地答“根据《劳动合同法》第十九条试用期最长不得超过六个月”理直气壮但这不是公司制度。注入政策知识后同样的问题回答变成“根据公司员工手册第 3 章第 2 条云帆科技试用期统一为 1 个月表现优秀者可提前转正。具体以 HR 录用通知为准。”后者才是员工真正需要的内容。这个对照组是你验收知识注入效果的通用思路整理 20 个真实问题分别看注入前和注入后的答案能直观看到差距。5. 接口“通”了之后的事超时、重试、限流与缓存5.1 同步调用对 Tomcat 线程池的压力比你想的大这里要开始讨论生产化。Spring Boot 默认的 Tomcat 工作线程池在 200 左右大模型接口单次响应 3 到 10 秒如果内部系统同时有 50 个人提问相当于 50 个线程全挂在对外的慢请求上其他所有接口的响应都会变慢。所以第一次接完大模型接口需要立刻想清楚这件事。最简单的处理是给 AI 调用单独配置线程池隔离。把大模型请求丢到一个独立线程池执行业务线程等待时释放 Tomcat worker同时对 AI 服务的并发度设一个上限超出部分直接拒绝或排队。Configuration public class AiThreadPoolConfig { Bean(aiTaskExecutor) public Executor aiTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setQueueCapacity(30); executor.setThreadNamePrefix(ai-chat-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); return executor; } }CallerRunsPolicy 的意思是线程池满时由提交任务的线程自己执行——对内部系统来说宁可让调用者稍微慢一点也不要丢弃请求。5.2 重试要谨慎不是每次失败都值得重试网络超时可以重试这个大家都有共识。但大模型接口有一个特殊性每次调用都消耗 token重试直接意味着成本增加而且如果接口的写入操作因为超时已经执行了重试可能产生重复数据。好在这个场景是纯问答幂等性天然成立重试没有副作用。我的策略是只对“连接超时”和“5xx”做重试最多重试一次“4xx”一概不重试——401 是密钥错了重试一百次也是失败400 是请参数写错了要排查代码而不是重试。建议用 Spring Retry 或者 Resilience4j 来配置别自己在 catch 里写 for 循环那样重试间隔、退避策略都不可控。5.3 高频问题不做缓存就是在烧钱HR 政策问答有非常强的重复性“年假几天”“产假多少天”这类问题可能一天被问几十次。每一次都调大模型接口响应慢还费钱。最简单优化方案就是加缓存把用户问题做归一化处理后作为 Key模型回答作为 Value设置一个较短的过期时间。我做的归一化非常简单去掉首尾空格、去掉多余标点、连续繁体转简体这里只是概念化说明。正式落地可以把 Key 设计成主题分类加时间窗口比如“产假-202511”某段时间内相同问题直接命中缓存。实测这种方案能把高热点问题的接口调用量降低 60% 以上。缓存放 RedisTTL 设置 24 小时政策更新后通过手动清理缓存让新政策立即生效。6. 本地实测与排障记录6.1 完整跑通的实测案例我拿一个模拟的员工手册做了一组真实问答。sessionId 为空时先提问{ message: 我入职才 3 个月公司裁员的话有补偿吗 }返回结果中有一段根据员工手册第六章离职与补偿规定入职不满 6 个月且因业务收缩被协商解除劳动合同的员工公司按 N1 支付补偿N 按实际工作月数折算不足 1 个月按 1 个月计。详细金额建议通过 HR 系统查看个人薪资信息。这个回答是注入知识后得到的数字依据都能从政策原文找到。如果没注入知识大概率是泛泛而谈“经济补偿按劳动者在本单位工作的年限每满一年支付一个月工资”看着正确但缺少公司自己的额外补偿条款。然后是追问测试——用来验证上下文管理是否有效{ sessionId: session-001, message: 那试用期被裁也一样吗 }模型能根据前面聊过的“入职 3 个月”“试用期”自动关联回答试用期也是劳动关系存续期间公司需按前述条款支付补偿。员工手册第三章明确试用期薪资按转正薪资的 80% 发放但不影响补偿计算基数。这个“不影响”的结论挺关键说明多轮上下文基本没丢。如果这里模型答非所问优先检查 session 里历史消息是否完整拼接以及 system 消息是否跑到了消息列表末尾。6.2 高频错误对照表与排查思路把最近测试过程中遇到的错误整理一下大部分问题可以在两分钟之内定位错误现象最常见原因处理办法401 UnauthorizedAPI Key 写错、环境变量没生效检查启动日志中的配置curl 带同样的 key 验证404 Not Foundbase-url 路径不对多写少写 /v1对照平台文档统一以平台给的调用地址为准模型名相关报错账号未开通该模型或模型名拼写错误在平台控制台确认实际可用模型名连接超时响应慢、网络链路问题调大 readTimeout对症区分是哪一侧慢返回内容被截断max-tokens 设置过小调大 max-tokens并在提示词里要求“简明回答”回答明显在编知识未注入、温度过高检查 system 是否带入了政策全文温度调低到 0.2偶发性 429触发平台速率限制控制并发、做缓存、按平台要求退避重试6.3 一个容易忽略但很关键的顺序问题前面提到过 system 必须在 messages 的第一条这里展开讲一下为什么。很多大模型对消息列表的位置比较敏感system 被放在中间或末尾时对整体行为的约束力会下降甚至会让模型误以为这是一段普通对话内容而尝试“回复 system”。我排查过一个案例同事的机器人经常忘记“只能依据政策原文回答”的指令后来发现他每次拼接历史时都是直接往 List 末尾追加 system把 system 挤到了第 9 条。改成始终在首位插入之后行为立刻正常了。如果你发现自己的提示词“好像不生效”先排查位置再排查写法。还有一个相关的坑开头的 system 内容不要太长。即使拼接了知识材料也要把核心规则写在前部。我习惯把 5 条规则放在整个 system 的最前面知识材料放在规则之后。模型在长系统提示下更容易出现“越后面的内容权重越低”的情况核心约束前置是有效经验。7. 收尾一个 token 估算小技巧和一点个人体会最后分享一个不起眼但很有用的技巧估算提示词里的 token 数。中文场景下1 个 token 大约对应 1 到 1.5 个中文字具体因分词器而异。当你担心政策材料太长、不确定会不会超上下文窗口时可以先做粗算字符串长度除以 1.2得到大致的 token 数再按这个规模设计截断策略和召回块大小。这个估算精度足够日常开发用了。我个人做完这个项目的体会是跑通接口只花了一个多小时后面调提示词和知识注入用了一个下午生产化改造又用了差不多一天。所以别被“第一个 AI 对话接口”这个说法骗了接口只是一个入口真正决定项目价值的是你对领域的理解和对模型行为的约束能力。做这类内部知识问答项目建议先整理出 20 个真实问题当作回归测试集每次改动提示词和知识注入都把 20 个问题重新跑一遍凭感觉调提示词很容易陷入“改好了这个问题、搞坏了那个问题”的死循环。