年初做企业级项目时,我需要把 GLM-5 接进 Java/SpringBoot 后端,说实话,刚开始我觉得这事很简单,无非是发个 HTTP 请求,再把 JSON 解析一下。结果真正动手才发现,模型对接的细节远比想象中多:流式返回怎么接、超时怎么控制、并发怎么隔离、Token 成本怎么统计,任何一个点处理不好,上线都是坑。这篇文章把我从零搭建到跑通完整业务的全过程整理出来,包含核心源码、踩坑记录和排查思路,适合正在用 SpringBoot 对接大模型、或者打算把 GLM-5 集成进内部系统的人。我会尽量用说人话的方式把每个关键决策背后的理由讲清楚,保证你照着做能少走很多弯路。
1. 项目整体思路与选型考量
1.1 一句话看清 GLM-5 对接的本质
无论 GLM-5 本身能力多强,对你的 SpringBoot 后端来说,它只是一个远程服务。你要做的核心工作就三件事:构建符合协议要求的请求体,把 API Key 安全地放到鉴权头里,把返回结果解析成 Java 对象。听起来很简单,但真正决定这个项目体验的,是你在“如何封装”这件事上花的功夫。
我在动手前定了一个原则:对业务代码屏蔽底层细节。上层业务只需要传入消息列表,拿回来一个模型回复,至于服务地址、鉴权方式、超时策略、重试逻辑,全部收敛到 Client 模块内部。这样做的最大收益是,未来模型服务升级接口、更换服务商,甚至从单模型切换到多模型路由,业务层代码几乎不用动。
具体到架构上,我选择在 SpringBoot 服务里新增一个独立模块来承担对接职责,而不是单独拉起一个网关服务。原因有三:第一,中小型团队维护的子系统越少越好,多一个网关就多一套部署和监控成本;第二,本地调用少一跳网络,延迟更可控;第三,调用来源就是你自己服务的业务代码,不需要对外暴露模型服务域名,安全性更好。
1.2 方案选型:同步、流式、还是两者都要
对接大模型时,第一个要做的技术决策是选同步调用还是流式调用。我一开始图省事,只做了同步调用:Controller 收到请求,后端调模型服务,等几十秒拿完整回复再返回给前端。结果产品体验非常差,用户面对一个无响应的页面,基本等于不可用。
后来我调整成了双轨方案,也是我认为最符合真实业务的做法:
- 保留同步接口,用于后端到后端、非交互场景,比如批处理、定时任务、离线摘要。这些场景需要的是完整结果,不需要逐字输出。
- 用户直接对话的接口,使用 SSE 流式响应(Server-Sent Events),模型生成一点,就往浏览器推一点。
技术选型上,流式我用的是 Spring WebFlux 的 WebClient,而不是传统的 RestTemplate。RestTemplate 处理“边读边推”非常别扭,你需要自己解析 InputStream,还要处理字符边界。而 WebClient 天然支持 Flux ,配合响应式流,代码量少,背压处理也完善。同步调用我则用 Spring 6 内置的 RestClient,因为同步场景用同步工具最直观,代码可读性也最好。
1.3 项目环境与依赖清单
我的工程环境如下,供你参考:
- JDK 17(Spring Boot 3.x 的硬性要求)
- Spring Boot 3.2.x
- Maven 3.8+
- WebFlux(仅作为 HTTP 客户端,不干扰 MVC 路由)
- Lombok,减少样板代码
pom.xml 里我加的关键依赖是这几个:
<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> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>这里有个细节要说明:spring-boot-starter-web 和 webflux 同时存在时,Spring MVC 依然是默认的 Web 框架,WebClient 只是作为响应式 HTTP 客户端使用,不会抢占你的路由。我实测过多个项目,这个组合是稳定的,但如果你完全不需要 MVC,只想做纯响应式服务,就不要引入 web 依赖。
2. 核心代码实现与详细解读
2.1 配置绑定:密钥与参数不要散落各处
模型服务相关的配置项,我全部放在 application.yml 里,用环境变量覆盖敏感信息:
glm: api-key: ${GLM_API_KEY} base-url: https://your-model-gateway.example.com/v1 model: glm-5-chat timeout: 60s max-tokens: 2048然后写一个配置类来绑定:
@Data @Component @ConfigurationProperties(prefix = "glm") public class GlmProperties { private String apiKey; private String baseUrl; private String model; private Duration timeout = Duration.ofSeconds(60); private Integer maxTokens = 2048; }把配置集中管理的价值,要到部署阶段才能充分体会。不同环境用不同的 API Key 或者模型端点是常有的事,如果这些值硬编码在代码里,每次发布都要改代码重新打包,这是绝对不可接受的。另外,API Key 永远不要提交到 Git 仓库。我见过不止一次有人把生产 Key 直接写在配置里推上远程仓库,导致整个团队被迫换 Key,既丢人又麻烦。
2.2 请求与响应模型设计
GLM-5 对外暴露的是一个标准的 Chat Completions 风格接口。请求体最基本的字段包括 model、messages、temperature、max_tokens、stream。messages 是一个数组,每个元素包含 role 和 content,role 一般有 system、user、assistant 三种。
我建议用 DTO 来定义请求体,而不是在代码里拼 Map。DTO 的好处是字段清晰、有类型约束、也方便设置默认值。我的核心 DTO 如下:
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class ChatMessage { private String role; private String content; } @Data @Builder @NoArgsConstructor @AllArgsConstructor public class ChatRequest { private String model; private List<ChatMessage> messages; private Double temperature; private Integer maxTokens; private Boolean stream; }注意一点:JSON 序列化时,maxTokens 会被 Jackson 按照驼峰命名转成 maxTokens,但很多模型服务接口期望的是下划线形式 max_tokens。所以要么你在字段上加 @JsonProperty("max_tokens"),要么在配置里指定 PropertyNamingStrategies.SNAKE_CASE 策略。我习惯只对模型相关 DTO 单独配置 ObjectMapper,避免影响全局的序列化行为。
响应体的结构我也做了对应的 DTO。模型返回的 JSON 会包含 id、object、created、model、choices、usage 等字段。choices 是一个数组,每个元素包含 message 和 finish_reason。usage 里有 prompt_tokens、completion_tokens、total_tokens,这是后面做成本统计的核心数据。
解析响应时,最稳妥的做法是开启忽略未知字段:
ObjectMapper mapper = JsonMapper.builder() .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) .build();这样做的原因很现实:模型服务升级时可能增加新字段,比如新增一个 usage 子字段或者扩展 choices 结构,如果强制失败,一个未知字段就能让整个接口挂掉,这显然不合理。
2.3 同步调用:主链路的三要素
先看一段最基础的同步调用代码,用 Spring 的 RestClient 实现:
@Service public class GlmClient { private final RestClient restClient; private final GlmProperties properties; public GlmClient(RestClient.Builder builder, GlmProperties properties) { this.properties = properties; SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(Duration.ofSeconds(10)); factory.setReadTimeout(properties.getTimeout()); this.restClient = builder .requestFactory(factory) .baseUrl(properties.getBaseUrl()) .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + properties.getApiKey()) .build(); } public ChatResponse chatSync(List<ChatMessage> messages) { ChatRequest request = ChatRequest.builder() .model(properties.getModel()) .messages(messages) .temperature(0.3) .maxTokens(properties.getMaxTokens()) .stream(false) .build(); return restClient.post() .uri("/chat/completions") .contentType(MediaType.APPLICATION_JSON) .body(request) .retrieve() .body(ChatResponse.class); } }这段代码里有三个关键点,每一个都有实际踩坑的教训。
第一,鉴权头格式。绝大多数模型服务接受的格式是 Authorization: Bearer <API_KEY>,不是 Basic。我踩过最蠢的坑就是把 Bearer 写成 Basic,服务端一直返回 401,查了二十分钟才发现。所以遇到 401,第一时间检查鉴权头的格式。
第二,超时配置。大模型响应普遍慢,简单请求也要几秒,复杂生成几十秒都可能。我把连接超时设 10 秒,读取超时从配置读取默认 60 秒。注意读取超时不能设太短,否则模型还没答完,客户端这边就主动断开了。
第三,temperature 的选择。它不是越高越好,而是看场景。做知识问答、文档摘要这类事实性任务,我一般设 0.1 到 0.3,让输出更稳定;做文案创意、头脑风暴,可以调到 0.7 到 0.9。如果你在对接时没想清楚用途,建议默认 0.3,比默认值更可控。
同步场景里还应考虑重试。我坚持一个原则:只在网络异常和 5xx 响应时重试,4xx 永远不重试。4xx 说明你的请求有问题,重试多少次都一样。5xx 和网络超时可能是瞬时抖动,重试有价值。重试次数设 2 次,间隔 200 毫秒,用循环实现即可,没必要引入 Spring Retry。
2.4 流式应答:SSE 的正确打开方式
流式是体验提升最大的一个环节。GLM-5 这类服务支持 SSE 返回:请求体里带 stream=true,服务端就会把结果切成很多小块推过来,每行格式一般是 data: {...}\n\n,最后一行是 data: [DONE]。
用 WebClient 可以这样封装:
public Flux<String> chatStream(List<ChatMessage> messages) { ChatRequest request = ChatRequest.builder() .model(properties.getModel()) .messages(messages) .temperature(0.2) .maxTokens(properties.getMaxTokens()) .stream(true) .build(); return webClient.post() .uri("/chat/completions") .header(HttpHeaders.AUTHORIZATION, "Bearer " + properties.getApiKey()) .contentType(MediaType.APPLICATION_JSON) .body(Mono.just(request), ChatRequest.class) .retrieve() .bodyToFlux(String.class) .filter(line -> line.startsWith("data:")) .mapNotNull(line -> parseDelta(line)); }Controller 层暴露 SSE 接口也很直接:
@PostMapping(value = "/api/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestBody ChatRequestDTO dto) { return glmClient.chatStream(dto.getMessages()); }前端用 EventSource 或者 fetch 配合 ReadableStream 都能消费。要注意,接口的 produces 必须是 text/event-stream,否则浏览器会以普通 JSON 解析,页面拿不到预期数据。
流式处理里我最想强调的是数据不完整的问题。网络传输时,一行 data 可能被拆成两次到达,也可能多条 data 粘在一起。如果你只是简单地按行 split,很可能遇到 JSON 解析失败,表现为流式输出突然中断或者丢字。我见过不少新手在本地用 curl 测试完全没问题,一上页面就开始丢字,原因就在这里。
稳妥的解决方法是做一个简单的 SSE 缓冲解析器:用一个 StringBuilder 累积数据,按 data: 前缀切分,遇到 [DONE] 就结束。如果你用的是 WebClient 按字符串接收,则要特别注意 Flux 的每个元素可能不是一个完整的行,不能直接当作一行处理。
3. 完整实战流程与源码解析
3.1 从空项目跑到第一个对话
如果你也想快速跑通,可以按我这套流程走。
第一步,创建 Spring Boot 工程,JDK 17,打包方式 jar。第二步,引入 web、webflux、lombok、validation 四个依赖。第三步,在 application.yml 里配置 glm 参数,API Key 从环境变量注入。第四步,创建前面提到的 GlmProperties、ChatMessage、ChatRequest、GlmClient、ChatController。
启动项目后,先用同步接口做一个最小验证:
curl -X POST http://localhost:8080/api/chat \ -H "Content-Type: application/json" \ -d '{"messages":[{"role":"user","content":"你好,请用一句话介绍自己"}]}'如果返回的是模型回复,链路就算通了。接着测试流式接口:
curl -N -X POST http://localhost:8080/api/chat/stream \ -H "Content-Type: application/json" \ -d '{"messages":[{"role":"user","content":"给我讲一个程序员的笑话"}]}'加上 -N 参数是为了让 curl 关闭缓冲,实时显示 SSE 数据。两个接口都通过,说明同步和流式链路都正常,后面就可以进入业务集成了。
3.2 把 GLM-5 嵌进真实业务:摘要助手 Demo
我这次实战的背景是给一个内部知识库做摘要助手。用户传一段长文档,后端做文本截断或分段,然后构建 prompt,调用 GLM-5 生成摘要,最后把结构和原文一起返回给前端展示。
这个 Demo 让我感受最深的一点是:模型对接最难的不是 HTTP 调用,而是 Prompt 组织。同样的模型,用不同的 prompt 写法和结构,结果差异可能巨大。我在项目里把 prompt 模板放到 resources 目录下的独立模板文件,而不是硬编码在 Java 字符串里:
你是知识库摘要助手。请从用户提供的文本中提取核心要点,输出不超过5条,每条不超过30个字。要求简洁、准确、不遗漏关键信息。 文本内容: {text}读取模板后,通过 String.replace 替换占位符,再拼进系统消息。用模板文件的好处是,后期运营同学想调整提示词,直接改文件就行,不需要重新编译 Java 代码。这个习惯建议所有人都养成,比在代码里改字符串效率高得多。
另外,在请求体里除了 user 消息,我还会放一条 system 消息。模型对 system 消息的遵循度通常更高,适合放全局性的角色设定和输出约束。把”你是摘要助手”写在 system 里,比写在每条 user 消息里效果更稳定。
3.3 上下文管理:记忆与窗口的平衡
真实业务里,用户不会只问一轮问题。对话助手必须能理解前面的对话内容,这意味着每次请求都要携带历史消息。我的做法是在内存里维护一个 sessionId 到消息列表的映射,每次请求把最近 N 轮消息全部带上。
这里有个绕不开的问题:上下文窗口有限,消息太多会超出限制。我采用的策略是滑动窗口加摘要压缩双保险:
- 当消息数量少于 20 条时,直接全部带上;
- 超过 20 条时,只保留最近 5 条原始消息,更早的历史调用一次模型压缩成摘要,放进 system 消息里。
压缩摘要会额外消耗一次调用和 Token,但换来的效果是长会话不丢失最关键的早期信息。上线后发现这个方案在成本和体验之间比较平衡。
还需要注意,GLM-5 返回的内容本身要记得追加进消息列表,否则模型上一轮说的话,它自己下一轮就看不见了。这个细节虽然简单,但在自测阶段很容易忽略,导致多轮对话时模型答非所问。
3.4 源码结构与分层原则
我最终工程源码目录大致是这样:
com.example.glm ├── GlmApplication.java ├── config │ ├── GlmProperties.java │ └── WebClientConfig.java ├── dto │ ├── ChatMessage.java │ ├── ChatRequest.java │ ├── ChatResponse.java │ └── ApiError.java ├── client │ └── GlmClient.java ├── service │ ├── ChatService.java │ └── SummaryService.java └── controller └── ChatController.java我的分层原则非常明确:client 包只负责跟模型服务说话,不关心业务;service 包负责 prompt 组装、上下文管理、结果后处理;controller 只做参数校验和协议转换。分层清晰以后,后续加缓存、加拦截、换模型都变得很容易。
打个比方,client 层就像是电话线,service 层是接线员,controller 是前台。电话线只管接通,接线员决定接给谁,前台负责接待客户。每一层的职责都单一时,出了问题你不需要翻遍整个项目就能定位。
4. 常见问题排查与性能优化实录
4.1 高频报错对照速查表
实际对接过程中,有一批问题是出现频率最高的。我把它们整理成一张表,方便你排查时对照:
| 现象 | 可能原因 | 解决手段 |
|---|---|---|
| HTTP 401 | API Key 错误或过期,或鉴权头格式写错 | 检查环境变量,检查 Authorization 是否 Bearer |
| HTTP 429 | 触发限流,并发过高 | 本地重试 + 加退避,降低并发 |
| HTTP 400 | messages 结构不对,role 值非法 | 校验 role 只能是 system/user/assistant |
| 响应超时 | readTimeout 太短 | 调大 readTimeout,大问题上流式接口 |
| JSON 解析异常 | 接口字段新增或变化 | 开启 FAIL_ON_UNKNOWN_PROPERTIES=false |
| 输出乱码 | 编码不一致 | 全链路统一 UTF-8,连接字符串也检查 |
排查 401 时有一个经验:不要只看最终异常,把实际发出的请求头打出来看一眼。你可以临时加一行日志,打印 Authorization 前的几个字符,确认 Bearer 后面不是空。很多“诡异”的鉴权失败,最后都是多了一个空格或者少了前缀。
4.2 超时、并发与线程池隔离
把大模型调用放进业务系统,最怕的情况只有一个:所有接口共用同一个 HTTP 客户端连接池,某个上游变慢,线程池被占满,整个应用跟着不可用。这种情况我真实遇到过一次,模型服务过载,响应从几百毫秒涨到几十秒,业务接口超时率直线上升,最后把整个应用拖垮了。
解决思路是给大模型调用做独立线程池,和其他业务线程池隔离。核心线程数不用多,8 到 16 就够,队列长度按业务峰值设定,拒绝策略可以是 CallerRunsPolicy 或者抛异常。同时把 HikariCP 之外的 HTTP 连接池也单独配置,避免连接相互争抢。
高并发场景下,我还加了熔断机制。我用 Resilience4j 做简单配置:5 秒内失败率超过 50%,自动开门熔断 10 秒。熔断期间请求不再打向模型服务,直接返回降级提示,等模型恢复后再自动闭合。注意熔断阈值要结合你的业务容忍度来调,50% 失败率只是一个起点,保守系统可以设 30%,激进系统可以设 70%。
4.3 Token 成本统计与降级兜底
大模型系统上线后,运营最关心的问题之一就是钱花在哪了。如果你不在代码里做统计,这就是一笔糊涂账。我的做法是在响应解析时,把 usage 字段里的 prompt_tokens、completion_tokens、total_tokens 全部取出来,连同 sessionId、业务类型、模型名记录到一张单独的成本统计表里。
流式接口的 usage 统计容易忽略。最后一个 data 块通常也包含 usage 信息,但很多人的解析逻辑一遇到 [DONE] 就停止了,把 usage 数据漏掉。修复方式是在处理 data 事件时,如果 JSON 里包含 usage 字段,就把它暂存下来,等流程结束后统一入库。
成本统计表大致包含这些字段:
| 字段 | 含义 |
|---|---|
| session_id | 会话标识 |
| biz_type | 业务类型,比如 summary 或 chat |
| model | 模型名 |
| prompt_tokens | 输入 Token 数 |
| completion_tokens | 输出 Token 数 |
| total_tokens | 总 Token 数 |
| created_at | 请求时间 |
有了这套数据,你就能回答“哪些功能最烧钱”“单次会话平均成本是多少”这类问题了。另一个容易被忽视的点是降级兜底。模型服务毕竟是外部依赖,总会有不稳定的时候。我在接入层做了一层简单缓存:对可复用的结果(比如固定话术、策略文案),如果模型接口异常,可以返回上一次的成功结果或者在缓存池里匹配的答案,而不是直接抛一个 500 给用户。
最后分享一个我觉得很实用的小技巧。上线前,对流式接口做一次弱网模拟测试,你会发现大量本地根本不会出现的问题,比如行被拆包、连接被重置、心跳超时。提前发现这些,比上线后被用户投诉再回来看日志要舒服得多。如果你也在做类似的大模型集成,建议在工程结构上多花点功夫,把 client 层做薄、service 层做厚、prompt 模板独立维护,这套体系一旦建立起来,后续业务扩展会顺很多。