Spring Boot集成OpenAI API实战:从RestTemplate到流式输出与上下文管理
1. 为什么选择 Spring Boot 承接 AI 对话服务以及这套方案解决什么问题先说结论如果你们团队的主栈是 Java业务系统基于 Spring Boot 构建那直接在现有工程里集成 OpenAI API 是成本最低、上线最快的一条路没有必要为了接入 AI 单独引入 Python 服务徒增运维和通信成本。拿我自己的实际经历来说前段时间有个业务方提需求要做一个人力资源问答助手员工在内部系统里提问AI 结合公司制度文档给出回答并且要能把答案嵌入到现有门户页面里。我第一反应是用 Python 写个 FastAPI 的独立服务OpenAI 官方 SDK 对 Python 支持也最完善但后来评估发现企业内部的统一认证、权限控制、审计日志、数据库访问这些能力全都在 Spring Boot 的现有工程里如果单独拆一个 Python 服务出来光是打通内部网关、统一鉴权就要额外写不少代码。最终还是决定在原 Spring Boot 工程里直接集成效果很好。1.1 集成方式选型官方 SDK 还是裸调 HTTP首先明确一点OpenAI 官方 Java SDK 目前已经比较成熟官方仓库提供openai-java包支持同步和流式请求底层封装了 HTTP 调用、SSE 解析、Builder 模式构建请求体用起来很方便。但和很多技术选项一样SDK 存在两个现实问题第一版本迭代快接口会调整第二企业内部开发中往往存在依赖冲突和包管理约束一些团队不允许引入非公司统一版本管理的第三方包。我个人的做法是优先用 Spring Boot 自带的RestTemplate或WebClient直接对 OpenAI 的 HTTP 接口做封装只依赖非常少的 JSON 序列化工具代码量完全可控而且任何 Spring Boot 版本都能跑升级 OpenAI 侧数据结构变化时改 DTO 和解析层即可。教程里我按这种方式展示因为这种思路才能让读者真正理解接口交互的本质以后 OpenAI 有任何接口调整你也能快速修。实测下来如果不做流式响应仅做一次性对话补全Chat Completion裸调 HTTP 的封装代码大约在 100 到 150 行之间加上 DTO 也就 200 行上下维护成本极低。1.2 这套方案适用的业务场景快速在现有企业内部系统如 OA、ERP、HR 门户中嵌入 AI 助手对话能力为移动端 App 或 Web 门户提供后端 AI 代理接口避免浏览器直接暴露 API Key需要把 AI 对话结果持久化到数据库场景例如保存用户提问记录、生成回答存储、后续人工审计需要结合企业内部统一登录、权限控制通过后端控制谁能调用 AI 接口不需要 DIY 大模型、不涉及模型训练的场景OpenAI API 做对话型应用就够了这大概是当下性价比最高的一种落地方式。下面我直接进入实操。2. 环境准备与 API Key 获取新手最容易卡住的前置环节很多人在写代码之前就卡住了卡住的点不是 Spring Boot 集成而是 API Key 压根没拿到或者拿到了不知道怎么安全存放。先把这关过了后面的代码全是顺着往下走的。2.1 环境依赖清单JDK 17 及以上Spring Boot 3.x 要求 JDK 17如果你还在用 JDK 8建议先升级或用 Spring Boot 2.7.xMaven 3.6 或 Gradle本文以 Maven 为例Spring Boot 3.2 或更高版本实测 3.x 稳定一个有效可用的 OpenAI API 账号并有余额或已开通的付费方式能正常访问 OpenAI 服务的网络环境此处不展开提示生产环境务必确保 GPT 接口调用的网络通道可靠稳定。开发环境测试时如果网络不通最常见的表现就是 RestTemplate 报超时错误。2.2 API Key 获取步骤与两种主流校验方式第一步登录 OpenAI 平台。 第二步在左侧菜单或者右上角进入 API Keys 管理页面点击Create new secret key创建密钥。生成之后页面只会显示一次明文一定要立刻复制保存丢失后无法再次查看只能新建。 第三步确认账户有足够余额。OpenAI API 按 token 计费新账户往往需要预先充值或绑定支付方式如果你的账号一直是 401 认证失败且 Key 没问题大概率是账户未开通付费或被风控限制了。拿到 API Key 之后验证它是否可用的最快方式是直接发一个最简 curl 请求curl https://api.openai.com/v1/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer YOUR_API_KEY \ -d { model: gpt-4o-mini, messages: [{role: user, content: 你好}], max_tokens: 50 }返回 JSON 里有choices[0].message.content就说明 Key 可用。这个 curl 请求相当于先验证网络链路和账号再进入代码阶段排错思路要清晰。2.3 API Key 在 Spring Boot 项目中的安全存储这里要注意一个新手高频踩坑直接把 API Key 写在application.yml里并上传到 Git 仓库。一旦仓库对外可见Key 就会被泄露后果不用我多说。安全做法在application.yml中引用环境变量openai: api-key: ${OPENAI_API_KEY} model: gpt-4o-mini base-url: https://api.openai.com本地开发时设置环境变量或者在.env文件中配置需要确保.env在.gitignore里。生产环境建议放到配置中心如 Nacos、Apollo 等或 K8s Secret 中按公司现有基础设施来。从架构角度看OpenAI API Key 相当于整个服务最核心的凭证谁拿到它谁就能以你的账户调用付费接口。让 Key 在代码里保持运行时从环境变量注入是底线要求。提示部分企业网络环境会做出口管控建议在工程内配置连接超时时间同时预留显式报错提示连接超时、读取超时、401 未授权、429 限流分别打印不同日志方便定位问题。3. 核心代码实现Maven 依赖、配置类、 DTO 与服务封装现在进入核心环节。这一节会给出完整可跑的代码结构并重点解释为什么这样设计分层。我的建议是你不要照抄完就跑而是把每一层的作用理解清楚因为 OpenAI 的接口结构本身并不复杂复杂的是你如何去扩展它。3.1 Maven 依赖最简方案只需要一个 Web 依赖提供 RestTemplate 和 MVC 支持parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies不需要额外引入 OpenAI SDKRestTemplate Jackson 足够。这里不用 WebClient 的原因如果要配合 WebFlux 做深入流式响应可以用 WebClient但我们先把基础版跑通同步调用的代码更直观易懂后续再往流式优化。3.2 application.yml 配置server: port: 8080 openai: api-key: ${OPENAI_API_KEY:sk-dummy-key-for-local-test} model: gpt-4o-mini base-url: https://api.openai.com max-tokens: 500 temperature: 0.7base-url我单独拎出来配置是因为国内部分公司在内网会部署统一网关或 API 代理方便未来切换到代理地址或兼容 OpenAI 兼容的第三方平台例如一些大模型的兼容网关地址。不要把这个值写死在代码里否则换环境还要重新编译部署。3.3 配置属性绑定类创建一个配置类绑定openai.*前缀的配置package com.example.ai.config; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Configuration; Configuration ConfigurationProperties(prefix openai) public class OpenAIProperties { private String apiKey; private String model; private String baseUrl; private Integer maxTokens; private Double temperature; // getter / setter 省略 }这个类的作用是把配置中心的属性统一收拢到一个 POJO 里后续任何地方要读取这些配置直接注入OpenAIProperties即可不用到处写Value(${openai.xxx})。这个设计在企业级工程里非常重要因为配置项一旦变多例如增加top_p、frequency_penalty、超时时间等等属性类的优势立刻体现出来。3.4 请求与响应 DTO 设计OpenAI 的 Chat Completion 接口请求结构长这样以主要的字段为例{ model: gpt-4o-mini, messages: [ {role: system, content: 你是一个AI助手}, {role: user, content: 你好} ], temperature: 0.7, max_tokens: 500 }响应结构里我们最关心的是choices[0].message.content和usage{ choices: [ { index: 0, message: { role: assistant, content: 你好有什么可以帮你 } } ], usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 } }按这个结构设计下面几个 DTO// ChatRequest.java public class ChatRequest { private String model; private ListMessage messages; private Double temperature; private Integer maxTokens; // 构建 messages 时常用静态方法 public static ChatRequest of(String model, ListMessage messages, Double temperature, Integer maxTokens) { ChatRequest request new ChatRequest(); request.setModel(model); request.setMessages(messages); request.setTemperature(temperature); request.setMaxTokens(maxTokens); return request; } }// Message.java public class Message { private String role; // system, user, assistant private String content; public static Message user(String content) { Message msg new Message(); msg.setRole(user); msg.setContent(content); return msg; } public static Message system(String content) { Message msg new Message(); msg.setRole(system); msg.setContent(content); return msg; } }这里有个小设计点Message类提供userMessage()和systemMessage()静态工厂方法能让调用侧代码非常清爽。不要小看这个设计当你在一个老工程里大量使用 AI 服务时调用方最烦的就是一遍遍手动new Message()然后setRolesetContent。响应 DTO 按需解析即可不需要把整个响应体完整映射到 POJO但choices和usage是必须的// ChatCompletionResponse.java public class ChatCompletionResponse { private ListChoice choices; private Usage usage; public String getFirstMessageContent() { if (choices ! null !choices.isEmpty()) { return choices.get(0).getMessage().getContent(); } return null; } // getter / setter 省略 }3.5 核心服务类OpenAIService这是整个集成最关键的一层。我用RestTemplate发起 POST 请求到/v1/chat/completions考虑到生产要求的可观测性我在这一层统一加了超时设置、异常翻译和日志输出package com.example.ai.service; import com.example.ai.config.OpenAIProperties; import com.example.ai.dto.ChatCompletionResponse; import com.example.ai.dto.ChatRequest; import com.example.ai.dto.Message; import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import java.time.Duration; import java.util.List; import java.util.Map; Service public class OpenAIService { private static final Logger log LoggerFactory.getLogger(OpenAIService.class); private final OpenAIProperties properties; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; public OpenAIService(OpenAIProperties properties, RestTemplateBuilder restTemplateBuilder, ObjectMapper objectMapper) { this.properties properties; this.restTemplate restTemplateBuilder .setConnectTimeout(Duration.ofSeconds(10)) .setReadTimeout(Duration.ofSeconds(30)) .build(); this.objectMapper objectMapper; } public String chat(String userMessage) { return chatWithSystem(userMessage, 你是一个专业、友善的AI助手。); } public String chatWithSystem(String userMessage, String systemPrompt) { ListMessage messages List.of( Message.system(systemPrompt), Message.user(userMessage) ); ChatRequest request ChatRequest.of( properties.getModel(), messages, properties.getTemperature(), properties.getMaxTokens() ); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(properties.getApiKey()); HttpEntityChatRequest entity new HttpEntity(request, headers); String url properties.getBaseUrl() /v1/chat/completions; try { log.info(调用 OpenAI APImodel{}, userMessage{}, properties.getModel(), userMessage); long start System.currentTimeMillis(); ResponseEntityChatCompletionResponse response restTemplate.exchange( url, HttpMethod.POST, entity, ChatCompletionResponse.class ); long cost System.currentTimeMillis() - start; log.info(OpenAI API 调用完成耗时 {}msHTTP 状态码 {}, cost, response.getStatusCode().value()); if (response.getBody() null) { throw new RuntimeException(OpenAI API 响应为空); } return response.getBody().getFirstMessageContent(); } catch (Exception e) { log.error(调用 OpenAI API 异常: {}, e.getMessage(), e); throw new RuntimeException(AI 服务调用失败请稍后重试, e); } } }代码里的几个关键点用RestTemplateBuilder设置超时而不是直接new RestTemplate()这样能避免之后回想哦我忘了设置超时了超时值要显式配置。setBearerAuth是 Spring 提供的便捷方法等价于手动设置Authorization: Bearer xxx。日志里不打完整请求体也不打 API Key避免日志泄露敏感信息。日志打模型名和用户消息即可但用户消息如果包含隐私建议脱敏或者只记录长度。chatWithSystem方法提供两参数版本适合需要自定义 system prompt 的业务场景一个是直接用默认 prompt 的快捷方法。很多初级教程通常会省略异常处理和超时设置但在真实生产项目中这两个环节决定你上线后是好日子还是愁日子。网络抖动、DNS 解析慢、OpenAI 接口响应慢都是常态如果没有超时兜底线程池很容易被挂起请求占满拖垮整个应用。3.6 Controller 层暴露接口package com.example.ai.controller; import com.example.ai.dto.ChatRequestDTO; import com.example.ai.service.OpenAIService; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import org.springframework.web.bind.annotation.*; RestController RequestMapping(/api/ai) public class AIController { private final OpenAIService openAIService; public AIController(OpenAIService openAIService) { this.openAIService openAIService; } PostMapping(/chat) public String chat(RequestBody Valid ChatRequestDTO request) { return openAIService.chatWithSystem(request.message(), request.systemPrompt()); } public record ChatRequestDTO( NotBlank(message 消息内容不能为空) String message, String systemPrompt ) {} }用 Java 的 record 写 DTO 很简洁Spring Boot 3 支持良好这里不需要Data那一套。注意实际企业中Controller 不应该直接返回原始大模型回答一般还要做一层包装例如统一的ResultT响应体、错误码、以及是否保存对话记录等等。上面的代码定位是最简化版的演示方便大家先跑通链路。4. 对话上下文管理从单轮问答到多轮会话消息结构到底怎么组织很多人初次跑通上面的单轮调用后紧接着就会遇到一个核心问题OpenAI API 本身是没有记忆的每次调用都是独立请求。你要想实现连续对话必须自己把上下文传进去。这是从 Demo 到真实产品的第一个分水岭。4.1 为什么 messages 数组的顺序和角色很重要OpenAI 的 Chat Completion 中的messages不仅仅是多传几句历史它的角色组合决定了模型的行为system消息设定 AI 的人格、回答风格、限制条件等往往放在数组首位user消息用户输入按对话自然顺序排列assistant消息AI 的历史回答需要从上一轮响应里拿到并拼接回下一轮请求一个多轮对话的正确请求结构大致是这样{ model: gpt-4o-mini, messages: [ {role: system, content: 你是某公司内部IT支持助手回答问题尽量简洁。}, {role: user, content: 我电脑蓝屏了怎么办}, {role: assistant, content: 请先尝试重启电脑如果依旧蓝屏请提供错误代码。}, {role: user, content: 重启过了错误代码是 0x0000001A} ] }你要把上一轮 OpenAI 返回的content原封不动拼进assistant消息里模型才能理解对话的来龙去脉。4.2 内存版会话管理实现最简单的实现思路用ConcurrentHashMapString, ListMessage以会话 ID 为 key 保存消息列表每次用户提问时把历史消息加载出来补入本轮 user 消息调用 API 后把返回的 assistant 消息追加到列表里。下面的代码展示核心逻辑package com.example.ai.service; import com.example.ai.dto.ChatRequest; import com.example.ai.dto.ChatCompletionResponse; import com.example.ai.dto.Message; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; Service public class ConversationService { private final OpenAIService openAIService; private final MapString, ListMessage sessions new ConcurrentHashMap(); private static final int MAX_CONTEXT_MESSAGES 20; public ConversationService(OpenAIService openAIService) { this.openAIService openAIService; } public String chatWithSession(String sessionId, String userMessage, String systemPrompt) { ListMessage messages sessions.computeIfAbsent(sessionId, k - List.of(Message.system(systemPrompt))); // 用 ArrayList 拷贝以便追加新消息避免并发修改已存储列表 ListMessage mutableMessages new java.util.ArrayList(messages); mutableMessages.add(Message.user(userMessage)); ChatRequest request ChatRequest.of(gpt-4o-mini, mutableMessages, 0.7, 500); String assistantReply openAIService.chatWithMessages(request, sessionId); // 将 AI 回答加入历史 mutableMessages.add(Message.assistant(assistantReply)); // 控制上下文长度只保留最近 N 条消息防止 token 超限 if (mutableMessages.size() MAX_CONTEXT_MESSAGES) { mutableMessages new java.util.ArrayList( mutableMessages.subList(Math.max(1, mutableMessages.size() - MAX_CONTEXT_MESSAGES), mutableMessages.size()) ); } sessions.put(sessionId, mutableMessages); return assistantReply; } }需要注意computeIfAbsent存在一个并发小陷阱如果一个 session 在第一次进入时被两个线程同时调用由于此时还没有完成put会走不同的初始创建路径。所以如果项目对并发要求高建议会话初始化用putIfAbsent 显式判断或者直接加synchronized, 而不是依赖computeIfAbsent的默认语义。很多人在这里踩过坑我当年排查过一个诡异 bug两个用户共享了同一个 system prompt 的初始会话原因就是并发下computeIfAbsent的初始化竞态。4.3 基于数据库的对话记录持久化内存方案重启即丢失上线没多久你就会发现必须持久化。更常见的做法是数据库表存消息记录每次请求查最近 N 条作为上下文。表结构大致如下CREATE TABLE chat_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, session_id VARCHAR(64) NOT NULL, role VARCHAR(16) NOT NULL, content TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_session_time (session_id, created_at) );查询时用session_id和created_at倒序取最近 20 条然后反转顺序拼进请求里。这个逻辑本身不复杂但要注意取出来的消息顺序一定要和对话方向一致不能倒着传否则模型语义理解会乱。这里有一个我踩过的坑多轮记忆的 token 增长是很快的。我的建议是除了条数限制之外还要做 token 预估。例如粗略按content.length()估算或者引入 tiktoken 库精确计算当总 token 超过阈值时要么丢弃最早的消息要么把早期内容压缩成摘要再继续对话。5. Function Calling 与流式输出从只能聊天到能干活如果你的服务只是把用户输入转发给 OpenAI 并返回文本那集成价值连一半都没发挥出来。OpenAI 接口真正有价值的两个进阶能力是function calling工具调用和stream流式输出。它们分别解决AI 主动调外部工具和打字机式响应体验两个问题。5.1 Function Calling 的集成思路Function Calling 的核心机制请求里声明可调用的函数列表模型判断用户意图后不是直接返回文本答案而是返回一个结构化的函数名和参数你的代码负责真正执行这个函数然后把执行结果回传给模型模型再组织最终回复。举个例子搭建一个内部 IT 助手用户说帮我查一下张三上个月的加班记录模型不直接去数据库查而是返回call_getOvertime({employee: 张三, month: 2025-06})你的系统执行查询后得到数据再把数据作为 tool 响应传回模型模型整理出人话回答。Spring Boot 集成方式如下// OpenAI 调用中传入 tools 参数 { model: gpt-4o-mini, messages: [...], tools: [ { type: function, function: { name: get_overtime, description: 查询员工加班记录, parameters: { type: object, properties: { employee: {type: string, description: 员工姓名}, month: {type: string, description: 月份格式YYYY-MM} }, required: [employee, month] } } } ] }在 Java 侧先用Map构建函数描述调用接口后判断finish_reason是否为tool_calls如果是走本地方法反射或路由分发执行对应函数。这个模式的完整代码行数大约在 300 到 400 行一旦跑通AI 就不再是只会说的聊天玩具了而是真正拥有工具的工作助手。5.2 流式输出改造流式输出是个体验级的刚需。对话服务如果没有流式用户发完消息后面对空白页面等好几秒体验很差。OpenAI 的流式响应格式是 SSEServer-Sent Events每一行返回data: {json}最终以data: [DONE]结束。在 Spring Boot 中实现流式推荐用WebClient配合Flux或者直接在 Controller 返回SseEmitter。我实际项目里用的是WebClientFluxString方案以下是核心代码片段Service public class OpenAIStreamService { private final WebClient webClient; public OpenAIStreamService(Value(${openai.api-key}) String apiKey, Value(${openai.base-url}) String baseUrl) { this.webClient WebClient.builder() .baseUrl(baseUrl) .defaultHeader(HttpHeaders.AUTHORIZATION, Bearer apiKey) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build(); } public FluxString streamChat(String userMessage, String systemPrompt) { MapString, Object requestBody Map.of( model, gpt-4o-mini, messages, List.of( Map.of(role, system, content, systemPrompt), Map.of(role, user, content, userMessage) ), stream, true ); return webClient.post() .uri(/v1/chat/completions) .bodyValue(requestBody) .accept(MediaType.TEXT_EVENT_STREAM) .retrieve() .bodyToFlux(String.class) .doOnNext(data - { // 每一行是 data: {...} 格式需要解析出增量内容 if (data.startsWith(data: )) { String payload data.substring(6); if ([DONE].equals(payload)) { return; } // 解析 JSON提取 choices[0].delta.content 并向下游发送 } }); } }Controller 层返回FluxStringSpring 6 自动按 SSE 格式推送前端用 EventSource 或 fetch stream 就能收。这个方案的难点在于 SSE 报文的解析bodyToFlux(String.class)拿到的不是一整段响应而是按事件流拆分的行所以doOnNext里的解析逻辑要写对。我建议这个解析逻辑放到一句里面完成不要用flatMap多层转接会大大增加调试成本。5.3 订阅取消与连接释放不要忽略流式连接的资源释放问题。WebClient 默认底层是 Netty 的如果前端页面关闭但服务端还在继续推送 SSE连接不会立刻断掉会持续泄漏。需要在前端断开时手动取消订阅或者给Flux加超时兜底FluxString stream streamService.streamChat(userMessage, systemPrompt) .timeout(Duration.ofSeconds(60)) .onErrorResume(e - Flux.just(【服务超时请重试】));同时在 Controller 里如果检测到客户端断开response.isCommitted()或处理ClientAbortException主动调用Disposable.dispose()释放。这块代码细节又碎又多但做好才能保证长时期稳定运行。6. 我踩过的坑超时、限流、token 计费与异常处理这一节不加修饰地列出我实际集成过程中踩过的坑以及对应的规避方案。这些经验在官方文档里往往不会直接告诉你但每一件都真实影响过我的线上系统。6.1 HTTP 超时设置不合理导致线程池耗尽最开始我用new RestTemplate()直接发请求连接超时和读取超时用的是 JDK 默认值也就是无限等待。某天 OpenAI 接口上游抖动大量请求挂起等待应用线程池被全部占满其他业务接口也全部无响应。解决方案用RestTemplateBuilder设置显式超时。连接超时给 10 秒读取超时给 30 秒大模型生成较长回答时确实会超过这个阈值非流式场景下 30 秒比较合理并且加上线程池监控和熔断降级。提示生产环境建议接入 Resilience4j 或 Sentinel 对 OpenAI 调用做熔断。OpenAI 接口一旦不稳定你首先要保证的是不拖垮自己的业务系统。6.2 429 限流与重试策略OpenAI 接口按组织Organization维度限流每分钟请求数RPM和每分钟 token 数TPM都有配额。流量上来后429 频发。我的处理实践是检测到 429 时以指数退避策略重试例如 1 秒、2 秒、4 秒、8 秒最多重试 3 次记录 429 发生的接口和触发时间便于后续分析是否要升级配额在代码层面提前做并发控制用Semaphore限制同时进行的 OpenAI 调用数示例代码private final Semaphore aiSemaphore new Semaphore(10); public String callWithLimit(String userMessage) { boolean acquired aiSemaphore.tryAcquire(); if (!acquired) { throw new RuntimeException(AI 服务繁忙请稍后重试); } try { return openAIService.chat(userMessage); } finally { aiSemaphore.release(); } }Semaphore 的 10 是经验值具体根据你的配额和场景压测调整。6.3 token 计费的隐形开销system prompt 写太长不划算有个误以为能提升效果的习惯是把大量的业务规则原封不动塞进 system prompt动辄上千 token。这在测试环境不觉得贵上线后累计成本很吓人。同时过长的 system prompt 还压缩了模型的回答空间可能触发max_tokens截断。经验做法system prompt 要求精炼、指令化核心业务规则抽成结构化描述尽量控制在 300 到 500 token 以内。详细文档类内容可以走 RAG检索增强生成按需检索相关内容拼进上下文不要让每一轮对话都携带全部信息。6.4max_tokens截断问题如果回答内容较长且到达max_tokens上限OpenAI 接口的finish_reason会返回length而不是stop。这个字段很重要很多团队忽略了判断finish_reason导致用户看到的就是一句没说完的话但以为回答结束了。在解析响应时建议把choices[0].finish_reason也取出来如果是length提示用户内容过长已截断或者做二次续写。截断问题在线下测试往往不明显因为测试问题都比较短但生产环境一旦有长上下文需求这是必须做的检查。6.5 多线程调用时的并发安全服务中如果有多处调用OpenAIService.chat例如用户请求、后台任务、定时生成要确认这些调用共享同一个RestTemplate实例不会出问题。RestTemplate是线程安全的可以全局共享但要注意请求HttpHeaders不要被多个线程修改而互相影响。建议每个请求单独构建HttpHeaders不要设为静态共享变量。7. 测试验证与后续优化方向当你按上面的代码跑通链路后建议按从简到繁的顺序做一轮完整验证避免一上来就在大工程里找 bug。7.1 本地启动与验证清单第一步启动 Spring Boot 应用。 第二步直接用 curl 测试接口curl -X POST http://localhost:8080/api/ai/chat \ -H Content-Type: application/json \ -d {message: 用一句话推荐一款适合小团队的项目管理工具}正常返回一段 AI 文本说明链路通了。第三步设计一个自定义 system prompt 的请求验证角色设定生效curl -X POST http://localhost:8080/api/ai/chat \ -H Content-Type: application/json \ -d {message: 你是谁, systemPrompt: 你是公司内部IT支持助手用中文回答语气亲切。}模型应该按你设定的身份回答而不是笼统地说我是AI助手。第四步测试多轮会话接口如果你实现了 ConversationService连续发两个相关问题第二轮回答时 AI 应能引用第一轮的信息。能正确引用就说明上下文拼接正常。7.2 日志观测点日志中至少要能看到这些信息每次调用的模型名、HTTP 状态码、耗时是否命中重试、熔断截断情况finish_reasonlength异常时的异常类型和堆栈建议在OpenAIService的调用入口和出口各打一条日志入口打用户消息摘要出口打 token 消耗和耗时。这样出了问题能快速定位。7.3 后续可以落地的优化方向引入 Spring AI 项目Spring 官方推出的 AI 集成框架它是目前把 OpenAI、Azure OpenAI、Ollama 等模型统一抽象进 Spring 生态的最佳方式比手写 RestTemplate 更进一步对话记录持久化落地到数据库并接上审计功能接入 Redis 实现会话级缓存和分布式场景下的会话共享基于 Function Calling 增加外接工具查询工单、查询排班、生成周报等在网关层统一做接口鉴权和频率控制我在生产环境落地这套方案后最大的体会是技术难点从来不在调用 API 本身而是在工程化的细节里。超时、限流、上下文管理、成本控制这些才是决定一个 AI 功能是 Demo 还是可上线产品的关键。上面的代码和避坑经验都是从实际项目里沉淀下来的希望能帮你少走一些弯路。如果你正在做同样的集成欢迎按这条路径先跑通再根据自己业务需要逐层加深。