1. 思路拆解:Java 后端接入大语言模型前,先想清楚这几件事
我最近在给公司的业务系统做 AI 功能,核心需求就是把大语言模型接到现有的 Spring Boot 后端里,让用户能在聊天框里问问题、让系统自动总结工单、甚至让模型帮忙生成 SQL 查询报表。
很多人一上来就想直接调 OpenAI 的接口,或者抱着某个国产模型的 SDK 开始写代码,结果写着写着就发现一堆问题:流式输出不知道怎么接、上下文怎么带才对、超时了怎么办、并发一高就报 429、甚至还有乱码和证书问题。这篇文章就基于我实际接入的经验,把 Java 后端接入大语言模型的完整思路、代码实现和踩坑记录都梳理一遍,希望能帮你少走弯路。
先说清楚这件事的本质:Java 后端接入大语言模型,本质上就是一次普通的 HTTP 调用,只不过是一次带鉴权、带流式响应、带上下文管理的特殊 HTTP 调用。想通了这一点,接入的复杂度就能降下来一大半。你不需要理解 transformer 的注意力机制,也不需要会训练模型,你只需要把自己的后端代码写好,把模型的 API 调用封装好,把前端的交互体验处理好。
1.1 接入方式选型:SDK、HTTP 还是中转服务
目前 Java 后端接大语言模型,主要就三种方式:
第一种,直接用模型厂商提供的 Java SDK。比如 OpenAI 官方没有 Java SDK,但社区有 openai-java、spring-ai 这类封装;阿里的通义千问、百度的文心一言、智谱的 GLM 都有官方的 Java SDK。SDK 的好处是开箱即用,请求封装、签名、流式解析都帮你做好了,适合不想关心底层细节的团队。坏处是每个厂商的 SDK 风格不一样,如果以后要换模型厂商,代码改动量会比较大。
第二种,自己用 HTTP 客户端调用模型的 REST API。OpenAI 兼容接口现在几乎是事实标准,包括国内很多模型(DeepSeek、Qwen、GLM)都提供了 OpenAI 兼容格式的 API,也就是说你只需要改 base_url 和 API Key,代码完全不用动。这种方式最灵活,代码不依赖任何第三方封装,调试也直观,我个人最推荐。
第三种,通过企业内部的中转网关或 API 网关调用。有些公司在中间加了一层代理,统一管理密钥、做流控和审计,业务后端只面向网关。这种方式适合中大型团队,安全性更好,但是网关本身的开发和维护也是有成本的。
我这次的方案选的是第二种,直接用 Spring Boot 里的 WebClient 调 OpenAI 兼容接口。原因很简单:不锁定厂商、代码可维护性好、出了问题可以直接抓包排查。后面所有代码示例也都基于这个方案。
1.2 模型服务怎么选:托管 API、本地部署还是开源模型
模型服务的选择直接影响接入成本和效果,这里要分场景来看。
如果项目在线上的调用量不大,只是做个 AI 助手、知识库问答这类功能,直接使用云端托管 API 是最划算的。按 token 付费,不用管 GPU 和运维,接口稳定,效果也经过充分调优。我这次就是先用的云端 API 把整条链路打通,验证完业务效果再考虑优化成本。
如果业务对数据隐私要求高,比如医疗数据、企业内部经营数据,那就需要考虑本地部署开源模型。Java 后端去访问本地部署的模型,一般有几条路:本地起一个 vLLM、Ollama、LM Studio 这类推理服务,它们都提供 OpenAI 兼容的 HTTP 接口,Java 后端完全可以当成一个普通的 HTTP 服务来调用;或者用 LangChain4j 这类 Java 生态的 AI 框架,它会把本地模型和云端模型统一封装。本地部署的好处是数据不出内网,坏处是硬件成本高,而且推理速度和并发能力是瓶颈。
还有一种折中方案:用云端 API 来做复杂推理,用本地小模型做简单分类、抽取等任务,两者在后端做个路由。这个做起来其实不难,因为 OpenAI 兼容接口的返回结构基本一致,你把请求 URL 配成两个不同地址就行。
我个人的建议是:先跑通云端 API,把业务逻辑验证好,再评估是否需要本地部署。不要一上来就买显卡搭推理服务,等量起来了再优化也来得及。
2. 核心细节解析与实操要点:真正决定接入质量的几个关键点
接入大语言模型,外层看是几个 HTTP 调用,实际上有不少细节会决定你的接口好不好用。下面这几个点,是我在实际开发中觉得最值得注意的。
2.1 HTTP 客户端选型:RestTemplate、WebClient 还是 OkHttp
Java 后端调 HTTP 接口,可选的无非是 HttpURLConnection、RestTemplate、WebClient、OkHttp 这几类。如果是老的 Spring 项目或者不太在意性能,RestTemplate 也能用,但它默认是同步阻塞的,而且流式读取响应时处理不太优雅。
如果用的是 Spring Boot 2.x 之后的项目,我建议直接用 WebClient。它是 Spring WebFlux 的一部分,虽然最初是给响应式编程用的,但你完全可以只把它当作用起来更顺手的 HTTP 客户端,同步调用、异步调用、流式调用都支持。尤其在做流式响应(SSE)的时候,WebClient 可以拿到 Flux 数据流,逐块推给前端,体验比 RestTemplate 舒服太多。
OkHttp 也不错,支持 HTTP/2 和 WebSocket,连接池管理做得很好,是很多 SDK 的底层依赖。但是考虑到 Spring Boot 生态的整合度,以及后续需要配合 Spring Cloud Gateway、Resilience4j 这些组件,WebClient 的兼容性是最好的。
我这里选 WebClient,还有一个原因是它能天然对接响应式流,后面接 SSE 很方便。如果你用了 RestTemplate,实现“打字机效果”就得靠手动读字节流了,代码会显得比较脏。
2.2 请求参数与 Token 控制:别让你的成本和体验失控
调用大模型的 API,参数看着不多,但每一项都直接影响输出质量和成本。我列一下最常用的几个参数:
model:模型名称,比如 gpt-4o-mini、deepseek-chat、glm-4,注意不同模型的上下文窗口不一样。messages:对话消息列表,每条消息有 role(system/user/assistant)和 content 两部分。temperature:控制随机性,范围 0 到 2。做代码生成或信息抽取,建议调到 0.2 以下;做创意写作可以调到 0.7 以上。max_tokens:本次请求允许生成的最大 token 数,不是上下文总长,要注意别和模型的上下文窗口搞混。stream:是否流式返回,true 代表按增量返回,前端能实现打字机效果。top_p:核采样,一般和 temperature 二选一调整就行,默认 1 基本不用动。
这里面最容易出问题的是max_tokens。有些模型的上下文是 128K,但max_tokens默认可能只有 4096,也就是说单次回复上限只有 4K。如果你让模型生成一篇长文或者总结一大段代码,它会在 4096 token 处截断,看起来像没说完。调高max_tokens能解决,但也要注意,max_tokens是算在上下文窗口里的,如果你把历史消息塞得太多,留给回答的空间就不够了。
另外还有频率惩罚frequency_penalty和存在惩罚presence_penalty,这两个一般是做创意生成时才会去调,常规业务不用管。
我建议在封装层把参数都配置化,放到 application.yml 里,这样产品经理想调温度或者换模型的时候,改配置重启就行,不用改代码。
2.3 流式响应 SSE:从“转圈等待”到“打字机效果”
如果你只是简单地调一次接口、等完整响应、再返回给前端,那体验会很差。大模型的生成速度再快,生成几百个 token 也要好几秒,甚至几十秒。用户看着页面一直转圈,十有八九会以为服务挂了。
所以正常情况下,后端接大模型都会把stream设成 true,然后通过 SSE(Server-Sent Events)把内容增量推给前端。SSE 是基于 HTTP 的单向通信协议,服务端可以持续向客户端推送消息,前端用 EventSource 或者 fetch API 就能接收。
在 Java 后端这里,SSE 的实现一般有两种:
一种是在 Controller 里直接返回SseEmitter,然后自己开个线程去调用模型 API,把流式内容一个个写入 SseEmitter。这种方式思路简单,但线程管理、异常处理都要自己做。
另一种是用 WebClient 的retrieve().bodyToFlux(String.class)去接收模型返回的流,再把 Flux 映射成前端需要的格式,通过Flux<ServerSentEvent>返回给前端。这是响应式的做法,背压、超时、取消都处理得更好,也是我最终采用的方式。
这里有个重要细节:模型 API 的流式返回格式是一串data: {json}文本,以data: [DONE]结束,需要用 SSE 解码器来处理。WebClient 的bodyToFlux(String.class)把每一行文本当作一个元素,你再在里面解析 JSON,非常方便。
3. 实操过程与核心环节实现:从零写一个 AI 对话后端接口
下面进入正题。我会以一个 Spring Boot 3 项目为例,完整演示怎么实现一个带流式输出的 AI 对话接口。整个代码不依赖任何 AI 框架,只靠 WebClient 和 Spring MVC 就能跑通。
3.1 项目骨架与依赖配置
新建一个 Spring Boot 项目,我用的版本是 Spring Boot 3.2.x,JDK 17。核心依赖只有两个:Spring Web 和 WebFlux。可能会有同学觉得奇怪,Spring WebFlux 是不是会和 Spring MVC 冲突?其实 Spring Boot 里两者是可以共存的,WebClient 的依赖是spring-webflux,它并不强制要求整个应用改成响应式。你只要在pom.xml里加上这个依赖,Controller 还是照常用 Spring MVC 的写法。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>然后在application.yml里配置模型服务的地址、Key 和默认参数。
llm: base-url: https://api.example.com/v1 api-key: sk-xxxxxxxxxxxxxxxx model: deepseek-chat max-tokens: 2048 temperature: 0.3 timeout-seconds: 60把这些配置都放到外面,后面换模型、调参数都方便。API Key 千万别硬编码在代码里,也千万别推到 Git 仓库,建议用环境变量或者配置中心管理。
3.2 模型调用封装:请求、响应与重试
先定义请求和响应的 DTO。OpenAI 兼容接口的请求结构大致是固定的,我用 Java record 来定义,简洁也不容易出错。
public record ChatMessage(String role, String content) { public static ChatMessage user(String content) { return new ChatMessage("user", content); } public static ChatMessage system(String content) { return new ChatMessage("system", content); } public static ChatMessage assistant(String content) { return new ChatMessage("assistant", content); } } public record ChatRequest( String model, List<ChatMessage> messages, Double temperature, Integer maxTokens, Boolean stream ) { public static ChatRequest of(String model, List<ChatMessage> messages, double temperature, int maxTokens, boolean stream) { return new ChatRequest(model, messages, temperature, maxTokens, stream); } }响应的结构稍微复杂一点,尤其是流式和非流式的返回格式不一样。非流式的返回是完整的 JSON,里面包含choices[0].message.content和usage信息;流式的返回则是多行的data: {...}增量。所以我把两类响应分开处理。
下面是核心的模型调用服务类。我用 WebClient.Builder 来创建 WebClient,设置好 Base URL 和请求头,然后写两个方法:一个是同步调用,一个是流式调用。
@Service public class LlmClient { private final WebClient webClient; private final LlmProperties props; public LlmClient(LlmProperties props) { this.props = props; this.webClient = WebClient.builder() .baseUrl(props.getBaseUrl()) .defaultHeader("Authorization", "Bearer " + props.getApiKey()) .defaultHeader("Content-Type", "application/json") .build(); } public String chatSync(List<ChatMessage> messages) { ChatRequest request = ChatRequest.of( props.getModel(), messages, props.getTemperature(), props.getMaxTokens(), false); return webClient.post() .uri("/chat/completions") .bodyValue(request) .retrieve() .bodyToMono(JsonNode.class) .map(json -> json.path("choices").path(0).path("message").path("content").asText()) .block(); } public Flux<String> chatStream(List<ChatMessage> messages) { ChatRequest request = ChatRequest.of( props.getModel(), messages, props.getTemperature(), props.getMaxTokens(), true); return webClient.post() .uri("/chat/completions") .bodyValue(request) .retrieve() .bodyToFlux(String.class) .filter(line -> line.startsWith("data: ") && !line.contains("[DONE]")) .map(line -> { String json = line.substring(6); try { JsonNode node = new ObjectMapper().readTree(json); return node.path("choices").path(0).path("delta").path("content").asText(""); } catch (JsonProcessingException e) { return ""; } }) .filter(StringUtils::hasText); } }这里有几个容易踩的坑。
第一,流式返回的每一行都是data: {...}格式,最后一行是data: [DONE],这两类都要处理干净。我在 filter 里先把[DONE]过滤掉了,避免前端拿到脏数据。
第二,流式响应里content字段是在delta下面,不是message下面,这两个结构不一样。如果你按非流式的结构去解析,会发现永远解析不出文本。
第三,bodyToFlux(String.class)这种拿到的是一个字符串的 Flux,每个元素是一行 SSE 数据。如果你用bodyToFlux(ServerSentEvent.class)其实也可以,但解析不见得比手动 substring 方便,我图省事就直接处理字符串了。
3.3 流式接口实现:SSE 让前端实现打字机效果
模型那层封装好了,Controller 就简单了。我直接返回Flux<ServerSentEvent<String>>,Spring 会自动把响应转成 SSE 格式推给前端。
@RestController @RequestMapping("/api/ai") public class ChatController { private final LlmClient llmClient; public ChatController(LlmClient llmClient) { this.llmClient = llmClient; } @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<String>> chatStream(@RequestBody ChatRequestDto dto) { List<ChatMessage> messages = dto.messages().stream() .map(m -> new ChatMessage(m.role(), m.content())) .toList(); return llmClient.chatStream(messages) .map(content -> ServerSentEvent.builder(content).build()) .doOnError(e -> log.error("stream error", e)); } }前端用 EventSource 还是 fetch 都能接。如果是fetch,注意要处理ReadableStream,逐段解析data:行。这个接口返回格式也是标准的 SSE,前端不需要额外引入库。
我带的前端同学第一次接的时候,直接用 axios 去请求这个接口,结果发现拿不到数据,因为 axios 默认不处理流式响应,必须用responseType: 'stream'或者直接换 fetch。这个坑在后端联调时非常常见,提前给前端打个招呼能省不少沟通成本。
3.4 上下文管理:滑动窗口实现多轮对话
大语言模型本身是无状态的,每次调用它只认你传进去的messages。要实现多轮对话,后端就必须把历史消息拼在请求里一起传。但历史消息是无限累积的,如果一直往里面塞,很快就超出模型的上下文窗口了。
我的做法是基于 token 数做滑动窗口。核心思路是:把历史消息和当前问题都按 token 估算长度,从最早的消息开始丢弃,直到总长度小于预设阈值(比如 8000 token)。token 数不能直接数中文字符数,因为一个 token 可能对应多个字符。Google 的 BPE 分词器下,中文大概 1 个 token 对应 1 个到 2 个汉字,英文 1 个 token 大概对应 4 个字符。粗略估算的话,可以用content.length()除以 2 作为 token 数,控制精度要求不高时完全够用。
具体实现上,我会在消息列表头尾加一个 system 提示词,然后在用户请求时把历史会话从数据库或者缓存里捞出来,按时间排好,拼上当前问题,再截断。
public List<ChatMessage> buildMessages(String userId, String currentQuestion) { List<ChatMessage> messages = new ArrayList<>(); messages.add(ChatMessage.system("你是一个智能助手,请用简洁专业的语言回答用户问题。")); List<HistoryMessage> history = historyService.listByUser(userId, 50); int totalTokens = 0; int maxTokens = props.getContextWindow() - props.getMaxTokens(); List<ChatMessage> historyMessages = new ArrayList<>(); for (int i = history.size() - 1; i >= 0; i--) { HistoryMessage h = history.get(i); int tokens = h.content().length() / 2 + 4; if (totalTokens + tokens > maxTokens) { break; } totalTokens += tokens; historyMessages.add(0, ChatMessage.user(h.content())); if (h.answer() != null) { historyMessages.add(0, ChatMessage.assistant(h.answer())); } } messages.addAll(historyMessages); messages.add(ChatMessage.user(currentQuestion)); return messages; }这里有几个细节要注意:maxTokens是给回答预留的空间,contextWindow - maxTokens才是给上下文用的长度。如果上下文塞得太满,回复很容易在中间被截断。加载历史消息时先从最新的往前扫,这样可以保证最近的消息一定在上下文里,久远的消息可以丢。
另外,如果业务本身是单轮知识问答,不建议把历史消息全带上去,浪费 token 不说,还容易干扰模型理解。我只保留最近几轮,或者干脆只带当前问题加 system 提示词。
4. 常见问题与排查技巧实录:接大模型时踩过的那些坑
接入过程不可能一帆风顺,下面这份问题清单是我在实际开发里遇到过的,你大概率也会碰上。我按“现象 -> 原因 -> 解决”的顺序列一张速查表,方便直接查。
| 问题现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 请求报 401 Unauthorized | API Key 错误、过期或请求头格式不对 | 检查 Authorization 头是不是Bearer xxx格式,Key 有没有多余空格或换行 |
| 请求报 404 Not Found | base_url 或路径不对,版本号不匹配 | 确认接口文档的完整路径,有的模型是/v1/chat/completions,有的可能是/api/chat |
| 流式接口前端收不到任何数据 | 前端用了 axios 或没有设置正确的响应类型 | 改用 fetch 或者设置responseType: 'stream'处理流式 |
| 流式输出到一半停了 | 超时时间设置太短,或者服务端主动断流 | 检查 HTTP 客户端的读超时,模型首 token 延迟高,需要单独加 connect timeout 和 read timeout |
| 返回的 JSON 解析报错 | 模型返回了被截断的 JSON,或 SSL 证书问题导致乱码 | 解析前校验字符串完整性;本地测试时可用-Djavax.net.debug=ssl调试证书 |
| 出现乱码,中文变成问号 | 请求头没有指定 UTF-8,或者日志打印乱码 | 确保 Content-Type 为application/json; charset=utf-8,日志编码设为 UTF-8 |
| 并发一高就报 429 Too Many Requests | 超过模型服务的并发限制或 token 速率限制 | 加本地限流,改用批量/异步,或者联系服务商提高配额 |
| 对话超过几轮后模型“失忆” | 上下文太长,超过了窗口后又被截断 | 检查 messages 总 token 数,缩短系统提示词,做滑动窗口截断 |
| response 总是被截断 | max_tokens 设置太小 | 调大 max_tokens,并确认它和上下文窗口之间的余量 |
4.1 连接超时与线程池配置
好多人在开发时跑得好好的,一上线就超时,其实问题多半出在 HTTP 客户端的超时配置上。大模型的生成速度不稳定,首 token 可能延迟几秒,如果你把读取超时设成 5 秒,那稳定超时。我给 WebClient 设置超时时,会把 connectTimeout 设成 10 秒,readTimeout 设成 60 秒或更长,因为流式场景下整个响应时间可能持续几十秒。
@Bean public WebClient webClient(LlmProperties props) { HttpClient httpClient = HttpClient.create() .connectTimeout(Duration.ofSeconds(10)) .responseTimeout(Duration.ofSeconds(60)); return WebClient.builder() .baseUrl(props.getBaseUrl()) .defaultHeader("Authorization", "Bearer " + props.getApiKey()) .clientConnector(new ReactorClientHttpConnector(httpClient)) .build(); }如果用的是 RestTemplate,也需要设置ConnectTimeout和ReadTimeout,不要用默认的无限超时或者太短的超时。建议这些超时参数都放到配置中心,出问题的时候不用发版。
4.2 乱码与 JSON 解析异常
中文乱码这个问题比较烦,因为它可能出现在两个环节:网络传输层和应用日志层。排查时先确认 API 返回的 JSON 里中文是不是正常的,如果是正常的,说明传输没问题,问题出在日志输出或者文件编码上。把 IDEA 和 Maven 的编码都设为 UTF-8,日志配置文件里也明确指定 UTF-8,基本能解决。
JSON 解析异常一般是模型返回的结果不是合法 JSON。模型偶尔会生成残缺的 JSON,比如因为达到 max_tokens 被截断。我的做法是解析前先判断字符串是否以}或]结尾,不合法就直接走重试。还有一种情况是模型喜欢在 JSON 前后加一段解释文字,你如果直接用ObjectMapper.readTree解析,会报错。这种情况可以在调用时添加 system 提示词:“只输出 JSON,不要任何解释”,或者在解析前用正则把多余内容清掉。
4.3 流式中断与半截消息
流式接口最难受的问题就是生成到一半突然断了。原因大概率是读取超时,但还有可能是网络代理把连接断开了,或者模型服务端因为某些输入变体触发了内容过滤。排查方法是在日志里记录每个连接的“首 token 时间”和“总耗时”,如果你发现某次请求首 token 正常但中途断开,那多半是读超时设置得太紧。遇到这种情况,把 readTimeout 调大,同时给前端的 SSE 连接增加heartbeat注释消息,防止连接被中间代理判定为 idle 而断开。
另外,断流之后前端会拿到一段不完整的回答。如果业务不允许这种情况,后端就要做兜底:检测到流中断时,要么终止输出并提示用户“生成中断,请重试”,要么把已生成的内容拼接起来,继续调用一次模型续写。前者简单,后者复杂但体验更好。我目前用的是前者,因为实现成本低,用户重试一次也不麻烦。
4.4 限流与密钥管理
模型 API 都是有速率限制的,比如每分钟请求数(RPM)和每分钟 token 数(TPM)。你在后端如果不做任何控制,业务一上量就会大量报 429。我的做法是在后端加一层简单的分布式限流,用 Redis 做个滑动窗口,每个用户每分钟最多请求 N 次,超过就直接返回“请求太频繁”。
密钥管理也是重点。API Key 绝对不要写在代码注释、配置文件里再提交到 Git。我见过不少同事把密钥连同仓库一起推到 GitHub,结果几分钟内就被扫描机器人发现,被刷掉几百美元。我现在的做法是密钥从环境变量或者 Vault 里读取,配置中心里只存占位符。线上环境还可以对密钥做加密存储,但这个看团队的安全规范。
5. 从一个人写完到支持高并发:进一步的架构思考
前面写的是一个能跑通的单机方案,但真实业务场景往往不只是“能通就行”。如果你要把 AI 能力开放给整个团队或者线上用户,还有几个问题值得提前思考。
第一个问题是全局缓存。大模型的调用如果完全不做缓存,同样的用户问题会反复消耗 token 和钱。我在实际项目里会做一个 Redis 缓存,对幂等的查询类问题,用用户 ID 加问题内容的哈希做 key,命中直接返回上一次的结果。生成类问题一般不做缓存,因为每个人想要的回答风格不一样,但比如“这个接口文档帮我总结一下”这种纯功能性请求,缓存效果很好。
第二个问题是任务队列与重试机制。不是所有请求都需要实时流式返回的,比如你让模型批量生成商品描述、总结历史工单,这些耗时长的任务放到 MQ 里慢慢消费就行。用 Spring Boot 的@Async配合线程池可以做基础版本,量大就上 RabbitMQ 或 Kafka。好处是削峰填谷,坏处是代码复杂度上来了,需要额外处理任务状态和重试。
第三个问题是多模型降级。如果主模型 API 不稳定,要不要自动切换到备用模型?这个可以通过简单的时间窗口统计来实现:连续 N 个请求失败或者平均延迟超过阈值,就切换 base URL 和密钥。注意不同模型同参数下输出可能不完全一样,切换前要做好业务侧的可接受性评估。
第四个问题是内容安全和结果校验。模型生成的内容可能会有敏感信息、或者含有误导性内容。建议在返回给用户前做一道关键词过滤或者人工审核队列,尤其是面向公众用户的产品。同时对模型输出的关键字段要做结构校验,不能直接当作可靠数据入库。
6. 最后分享两个小经验
第一,调试大模型接口时,千万别用 curl 盲试。我一般会在本地用 Postman 或者直接用 IDEA 的 HTTP Client 先发一次非流式请求,确认请求结构没问题,再调试流式接口。Java 后端里打印日志时只打印状态码和耗时,不要打印完整的消息内容,因为消息可能包含用户隐私。
第二,如果你不想自己维护这些偏底层的调用逻辑,可以考虑 Spring AI 或者 LangChain4j 这类框架,它们在 Java 生态里算是比较成熟了,对 OpenAI 兼容接口的封装、Prompt 模板、向量数据库等都有现成组件。但不建议一开始就用框架,先把底层原理和调用链路摸清楚,再决定要不要引入框架,否则出了问题你会无从下手。
我个人在实际项目里最大的感受是:大语言模型接入本身并不难,真正花时间的往往是参数调优、上下文管理、错误处理这些细节。把这些细节做到位,Java 后端的 AI 能力才能从“demo 能跑”进化到“生产可用”。上面这些代码和排查思路,都是我从真实项目中沉淀下来的,希望能给你省下一些摸索的时间。