ARTICLE DETAIL

资讯详情

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

【AI全栈后端12-02】Spring Boot 跑通第一个 AI 对话接口:HR 政策问答机器人实战

【AI全栈后端12-02】Spring Boot 跑通第一个 AI 对话接口:HR 政策问答机器人实战 本文是「Spring Boot AI 全栈后端」系列第 02 篇。上一篇定下了用 Spring Boot 接 AI的基调这一篇直接上手用一个 HR 政策问答机器人的真实场景把第一个能上线的 AI 对话接口从 0 打到能跑。示例基于 Spring AI 2.0 / Boot 4.1。前阵子帮一家三十来人的公司做内训HR 同学倒苦水每天群里都有新人问入职要带啥材料“报销多久到账”“年假怎么算”问题就那十几个可她得一遍遍复制粘贴答。更麻烦的是两个人答的口径还偶尔不一致。这一篇就用这个真实场景带你在 Spring Boot 里跑通第一个 AI 对话接口把重复解答变成一次接口调用。一、问题拆解我们要解决什么把 HR 的痛点摊开目标其实很具体重复劳动高频政策问题占掉 HR 大量时间口径不一多人回答标准难统一随时可问员工希望 7×24 自助而不是等工作时间。对应的接口要满足三点能对话、入参要校验、模型挂了不能把堆栈甩给用户。下面一步步来。先把接口契约定下来动手写代码前V哥 建议先把这张表贴在工位上场景请求体响应体HTTP 状态正常提问{question:年假怎么算}{answer:……}200空问题 / 纯空格{question: }{error:问题不能为空}400模型超时 / 限流{question:年假怎么算}{error:模型暂未返回有效回答请稍后重试}503别小看这张表。AI 接口最容易失控的地方不是模型而是边界——问题为空时怎么办、模型没答出来时返回什么。先把状态码和文案定死前端拿到的是永远能直接展示的东西联调时才不会因为你返回了个 500 我怎么渲染吵半天。二、最小依赖复用 01 的结论项目用 Spring Boot 4.1.1 Spring AI 2.0.1靠一个起步依赖把模型接进来dependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-web/artifactId/dependencydependencygroupIdorg.springframework.ai/groupIdartifactIdspring-ai-starter-model-openai/artifactId/dependencydependencygroupIdorg.springframework.boot/groupIdartifactIdspring-boot-starter-validation/artifactId/dependencyChatModel和ChatClient别搞混这是新手最容易绕晕的一对概念一句话说清ChatModel是模型本身喂一个Prompt进去还一个ChatResponse出来一次调用、一进一出跟厂商 SDK 一一对应ChatClient是它上面的一层流式 API把系统提示词 / 用户消息 / 生成参数 / 拦截器这些散落的零件串成一条链写起来是prompt().system().user().call()这种形状。所以注入的时候拿ChatModel用的时候包成ChatClient。前者由自动配置按厂商给你装配好后者是你自己的编排层——换厂商动的是前者动不到后者。配置一行密钥是不够的spring:ai:openai:api-key:${OPENAI_API_KEY}base-url:https://api.openai.comchat:options:model:gpt-4o-minitemperature:0.2三个细节值得说密钥走环境变量不进代码库。这是底线一旦把明文 key 提交到 Git扫描机器人几分钟就能扒走账单比模型还快。base-url是留给公司网关和国产模型的。接中转、接私有化部署、接国内大模型改这一行Java 代码一行不动——这正是上一篇说的配置切换不动业务。model/temperature可以在配置里给默认值业务代码里的ChatOptions再按需覆盖改档位不用改代码。三、把对话包成一个接口先定义请求/响应两个简单的记录类型再写控制器。注意这里把业务和模型分开控制器只管收问题、回答案真正的对话逻辑交给服务层。publicrecordPolicyChatRequest(NotBlank(message问题不能为空)Stringquestion){}RestControllerRequestMapping(/api/policy)publicclassPolicyChatController{privatefinalPolicyChatServiceservice;publicPolicyChatController(PolicyChatServiceservice){this.serviceservice;}PostMappingpublicPolicyChatResponsechat(ValidRequestBodyPolicyChatRequestrequest){returnnewPolicyChatResponse(service.answer(request.question()));}}四、系统提示词口径统一靠它不靠模型这是整篇最容易被跳过、却最值钱的一节。回到开头那个痛点——两个人答的口径还偶尔不一致。很多团队的直觉是换个更强的模型就好了其实不对模型再强你没告诉它你是谁、能答什么、答不出来怎么办它就只能按通用常识自由发挥。HR 场景要的是每次都按公司政策答不是每次都答得漂亮。系统提示词就是干这个的它不参与这一轮问什么而是长期挂在对话最前面定角色、定边界、定格式。privatestaticfinalStringSYSTEM_PROMPT 你是公司 HR 政策助手只回答与人事政策相关的问题。 回答时必须遵守以下规则 1. 只依据公司已发布的政策作答政策里没有的内容直接回复这条我查不到请联系 HR 同事确认 2. 涉及天数、金额、流程的内容必须给出明确数字和步骤不用大概通常这类模糊表述 3. 用中文回答控制在 200 字以内分点列出。 ;publicStringanswer(Stringquestion){StringtextchatClient.prompt().system(SYSTEM_PROMPT).options(generationOptions()).user(question).call().content();if(textnull||text.isBlank()){thrownewAiServiceException(模型暂未返回有效回答请稍后重试);}returntext;}三条规则各有各的用处V哥 逐个说第 1 条是不许编。HR 问答最怕的就是模型自信地编出一条不存在的政策——员工拿去当依据责任算谁的明确告诉它查不到就说我查不到比事后审核便宜一百倍。第 2 条是不许糊。通常三个工作日左右这种话在 HR 场景里等于没说逼它给数字。第 3 条是不许长。既是体验也是成本——输出按 token 计费200 字封顶一次问答的成本就锁死了。五、生成参数两个值决定稳和省ChatOptions里参数不少但政策问答场景真正需要调的就是两个privatestaticChatOptions.Builder?generationOptions(){returnChatOptions.builder().temperature(0.2).maxTokens(500);}参数作用政策问答怎么取值为什么temperature控制发散程度越高越有创意0.1 ~ 0.3同一句话答十次要长得一样口径才叫统一maxTokens单次输出上限300 ~ 800既是体验上限也是单次成本上限这里有个坑要提醒ChatOptions.builder()拿到的是个可变的 Builder别图省事存成static final常量让所有线程共用——ChatClient在编排过程中会读它、合并它多线程下容易互相污染。写成方法、每次调用新建一个是最省心的做法。至于topP、frequencyPenalty这些政策问答基本用不上等你做营销文案生成再研究不迟。六、入参校验空问题直接拦在门外Valid RequestBody配合NotBlank员工发了个空问题框架直接返回 400根本不会打到模型。这一步很关键——既省 token也避免无意义调用。七、异常兜底模型挂了也不甩堆栈模型偶尔超时或限流不能把一堆异常抛给前端。用RestControllerAdvice统一兜底RestControllerAdvicepublicclassGlobalExceptionHandler{ExceptionHandler(MethodArgumentNotValidException.class)publicResponseEntityMapString,StringhandleValidation(MethodArgumentNotValidExceptionex){Stringmsgex.getBindingResult().getFieldError()!null?ex.getBindingResult().getFieldError().getDefaultMessage():参数不合法;returnResponseEntity.badRequest().body(Map.of(error,msg));}ExceptionHandler(AiServiceException.class)publicResponseEntityMapString,StringhandleAi(AiServiceExceptionex){returnResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(Map.of(error,ex.getMessage()));}}参数错误 → 400模型异常 → 503前端拿到的永远是一段能直接展示的文案。这里 V哥 还有个习惯给 AI 异常单独定义一个业务异常上面的AiServiceException不要直接把 Spring AI 抛的原生异常往外传——原生异常里可能带着你的base-url、请求头信息等于把内网地址直接透给了调用方。八、跑起来一个配置 一行启动启动后用 curl 验证curl-XPOST localhost:8080/api/policy\-HContent-Type: application/json\-d{question:入职需要带哪些材料}返回{answer:……}就说明第一个 AI 对话接口跑通了。想接通义、Ollama只改配置业务代码一行不动。九、上线前会踩的坑先列在这这一节是 V哥 陪学员联调时攒下来的按出现频率排现象常见原因怎么查启动报api-key must not be empty环境变量没注入IDE 里最常忘先看spring.ai.openai.api-key有没有解析成占位符原样输出401 / 403key 无效或base-url指向的网关要额外鉴权头用 curl 直接打一次网关排除 Spring 侧问题接口一直转圈模型侧慢且没设超时给 HTTP 客户端配 connect/read timeout别让线程池被拖死返回空字符串被安全策略拦了或maxTokens给太小先打印原始ChatResponse再判断是不是输出被截断中文变成问号请求/响应编码不一致多见于自研网关检查网关是否强制了 ISO-8859-1本地好使服务器上不通服务器出网受限先curl通一次目标域名别急着改代码十、落地要点与下篇这个最小接口已经具备上线雏形对话走服务层、口径靠系统提示词、入参有校验、异常有兜底、成本有上限。真要进生产还差限流和缓存——那是第 11 篇的事。V哥 带学员做项目时立的规矩是任何一个 AI 接口没写入参校验和异常兜底就不许往测试环境发版否则线上一个空字符串就能让你查半天日志。还有个问题这一篇故意没解决现在的接口是一问一答、不记上下文员工追问那病假呢模型不知道上一句问的是年假。这就是对话记忆ChatMemory的活等我们把 03 到 06 的能力补齐再回头收拾它。下一篇03V哥 带你用多模型路由把智能客服的成本压下来简单问题走小模型复杂问题才上旗舰。最后一句跑通第一个 AI 对话接口不难——一个RestController、一个ChatClient、再加系统提示词 校验 兜底三道防线你就能把 HR 的重复解答变成一次干净的接口调用。
返回列表