前言
2026 年,AI 大模型已经不是什么新鲜事了。DeepSeek、GPT、Claude 各家模型百花齐放,但在实际项目中,怎么把 AI API 稳定地接到自己的 Spring Boot 项目里,仍然是很多开发者头疼的问题——尤其是当你需要同时兼容多种 API 协议、支持流式输出、甚至调用联网搜索能力时。
本文将以一个真实项目为例,手把手教你:
同时兼容OpenAI Chat Completions 协议和Anthropic Messages 协议
实现流式(SSE)和非流式两种调用方式
调用 Anthropic 的web_search 工具实现真正的联网搜索
不引入 OkHttp/WebFlux,仅用 JDK 原生
HttpURLConnection搞定一切
所有代码来自实际生产环境,不是玩具 Demo。
SpringBoot合集_1416套免费下载
技术选型:为什么不用 SDK?
市面上有 LangChain4j、Spring AI 等框架,但在实际项目中我选择手写 HTTP 调用,原因很简单:
轻量:不需要额外引入一堆依赖
可控:不同 API 协议的请求头、响应格式差异很大,手写更灵活
兼容:一个项目可能同时对接 DeepSeek 官网、第三方网关、OpenAI 兼容接口,每家的"方言"不同
技术栈:Spring Boot 2.x + Java 8 + Hutool JSON 工具库。
一、双通道架构设计
实际项目中,AI 的用途不止一种。以我自己的项目为例,AI 被用在两个场景:
| 场景 | 协议 | 用途 | 特点 |
|---|---|---|---|
| 采集通道 | Anthropic Messages | 联网搜索热点资讯 | 需要web_search工具 |
| 写作通道 | OpenAI Chat Completions | 生成 SEO 文案、违规检测 | 纯文本生成 |
两个通道各自独立配置(API Key、Base URL、模型名),互不影响。这种设计的好处是:采集用的模型可以换,写作的不会跟着挂。
1.1 配置存储
配置信息存在数据库的system_config表里(键值对),而不是application.yml,原因是:不同环境的 API Key 不同,后台改完不用重启。
@Autowired private SystemConfigMapper systemConfigMapper; private String cfg(String key, String def) { SystemConfig c = systemConfigMapper.selectOne( new LambdaQueryWrapper<SystemConfig>() .eq(SystemConfig::getConfigKey, key) .last("limit 1")); if (c == null || StrUtil.isBlank(c.getConfigValue())) { return def; } return c.getConfigValue().trim(); }1.2 Base URL 归一化
不同用户填的地址五花八门:有人填https://api.deepseek.com/anthropic,有人填完整路径。需要做一个归一化:
private String anthropicUrl(String baseUrl) { String b = baseUrl == null ? "" : baseUrl.trim(); if (StrUtil.isBlank(b)) { b = "https://api.deepseek.com/anthropic"; } while (b.endsWith("/")) { b = b.substring(0, b.length() - 1); } if (b.endsWith("/v1/messages")) return b; if (b.endsWith("/v1")) return b + "/messages"; if (b.endsWith("/anthropic")) return b + "/v1/messages"; if (b.equals("https://api.deepseek.com")) { return b + "/anthropic/v1/messages"; } if (b.contains("/anthropic")) return b + "/v1/messages"; return b + "/anthropic/v1/messages"; }这种"防御性归一化"在实际项目中非常实用——用户填什么格式都能兼容。
二、非流式调用:最简单的方式
先从最基础的非流式调用说起。以生成 SEO 文案为例,请求发出后等 AI 完整返回,一次性拿到结果。
2.1 构造请求体(OpenAI 协议)
JSONObject req = new JSONObject(); req.set("model", model); // 如 "mimo-v2.5" req.set("temperature", 0.7); req.set("stream", false); // 系统提示词 List<Map<String, String>> messages = new ArrayList<>(); Map<String, String> sys = new HashMap<>(); sys.put("role", "system"); sys.put("content", "你是资深中文SEO文案专家..."); messages.add(sys); // 用户消息 Map<String, String> usr = new HashMap<>(); usr.put("role", "user"); usr.put("content", "文章标题:" + title + "\n文章内容:" + plain); messages.add(usr); req.set("messages", messages);2.2 发送请求并解析响应
HttpURLConnection conn = null; try { conn = (HttpURLConnection) new URL(baseUrl + "/chat/completions").openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Authorization", "Bearer " + apiKey); conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); conn.setDoOutput(true); conn.setConnectTimeout(15000); conn.setReadTimeout(120000); conn.getOutputStream().write(req.toString().getBytes(StandardCharsets.UTF_8)); int status = conn.getResponseCode(); if (status != 200) { // 读取错误流 String err = readStream(conn.getErrorStream()); return fail("AI 服务错误(HTTP " + status + "):" + err); } // 解析响应 String aiText = JSONUtil.parseObj(readStream(conn.getInputStream())) .getJSONArray("choices") .getJSONObject(0) .getJSONObject("message") .getStr("content"); return aiText; } catch (Exception e) { return fail("请求异常:" + e.getMessage()); } finally { if (conn != null) conn.disconnect(); }关键点:
setConnectTimeout(15000):连接超时 15 秒,网络不好时快速失败setReadTimeout(120000):读取超时 2 分钟,AI 生成长文时需要足够时间必须加
User-Agent,部分 API 网关会拦截无 UA 的请求
2.3 读取流的工具方法
private String readStream(InputStream in) throws Exception { if (in == null) return ""; ByteArrayOutputStream out = new ByteArrayOutputStream(); byte[] buf = new byte[4096]; int n; while ((n = in.read(buf)) != -1) { out.write(buf, 0, n); } return new String(out.toByteArray(), StandardCharsets.UTF_8); }三、流式调用(SSE):用户体验的关键
非流式的问题是:用户点"生成"后要等十几秒才能看到结果。流式输出(Server-Sent Events)可以让 AI 的文字逐步出现在页面上,体验好得多。
3.1 后端:读取 SSE 流并转发
conn = (HttpURLConnection) new URL(baseUrl + "/chat/completions").openConnection(); // ... 基础配置同上 ... req.set("stream", true); // 关键:开启流式 conn.setRequestProperty("Accept", "text/event-stream"); // 读取 SSE 流 BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)); OutputStream out = response.getOutputStream(); StringBuilder collected = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { // 原样转发给前端 out.write((line + "\n").getBytes(StandardCharsets.UTF_8)); out.flush(); // 同时收集完整文本用于后续处理 String t = line.trim(); if (!t.startsWith("data:")) continue; String data = t.substring(5).trim(); if (data.isEmpty() || "[DONE]".equals(data)) continue; try { JSONObject obj = JSONUtil.parseObj(data); String piece = obj.getJSONArray("choices") .getJSONObject(0) .getJSONObject("delta") .getStr("content"); if (piece != null) { collected.append(piece); } } catch (Exception ignored) { } }核心思路:每一行 SSE 数据都原样转发给浏览器(前端用EventSource接收),同时把所有文本片段收集起来。收集完毕后可以做进一步处理(比如存库)。
3.2 前端:EventSource 接收
const evtSource = new EventSource("/admin/seo/generate-sse", { method: "POST", // EventSource 不支持 POST,需要改用 fetch }); // 实际用 fetch + ReadableStream: const resp = await fetch("/admin/seo/generate-sse", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title, content }) }); const reader = resp.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const text = decoder.decode(value); // 解析 SSE 行 for (const line of text.split("\n")) { if (line.startsWith("data: ")) { const data = line.slice(6); if (data === "[DONE]") break; const obj = JSON.parse(data); if (obj.result) { // 最终结果,回填表单 document.getElementById("seoTitle").value = obj.result.seoTitle; } else if (obj.ai_error) { alert("错误:" + obj.ai_error); } } } }3.3 后端 SSE 写入工具
private void sseWrite(HttpServletResponse response, String data) throws IOException { response.getOutputStream().write( ("data: " + data + "\n\n").getBytes(StandardCharsets.UTF_8)); response.getOutputStream().flush(); }注意:SSE 格式是data: xxx\n\n,结尾必须有两个换行符。
四、Anthropic Messages 协议:真正的联网搜索
OpenAI 协议不支持联网搜索,而 DeepSeek 官网的 Anthropic Messages API 支持web_search工具,可以让 AI 实时搜索互联网。
4.1 请求体构造(Anthropic 格式)
与 OpenAI 协议的主要区别:
JSONObject body = new JSONObject(); body.set("model", "deepseek-flash"); body.set("temperature", 0.3); body.set("max_tokens", 8000); body.set("stream", true); // 工具定义:联网搜索 JSONArray tools = new JSONArray(); JSONObject ws = new JSONObject(); ws.set("type", "web_search_20250305"); // Anthropic 专用工具类型 ws.set("name", "web_search"); ws.set("max_uses", 5); // 最多搜索 5 次 tools.add(ws); body.set("tools", tools); // 消息格式也不同(没有 system role,直接 user) JSONArray messages = new JSONArray(); JSONObject msg = new JSONObject(); msg.set("role", "user"); msg.set("content", "请搜索最新AI资讯..."); messages.add(msg); body.set("messages", messages);4.2 请求头差异
| 头部 | OpenAI 协议 | Anthropic 协议 |
|---|---|---|
| 认证 | Authorization: Bearer xxx | x-api-key: xxx |
| 版本 | 无 | anthropic-version: 2023-06-01 |
| Content-Type | 相同 | 相同 |
conn.setRequestProperty("x-api-key", apiKey); conn.setRequestProperty("anthropic-version", "2023-06-01"); conn.setRequestProperty("Content-Type", "application/json");4.3 解析 Anthropic 响应
Anthropic 的流式响应格式与 OpenAI 不同,delta 字段是text而不是content:
// Anthropic 格式 JSONObject evt = JSONUtil.parseObj(data); Object delta = evt.get("delta"); if (delta instanceof String) { // 直接是文本 collected.append((String) delta); } else if (delta instanceof JSONObject) { // 或者在 delta.text 里 String text = ((JSONObject) delta).getStr("text"); if (text != null) { collected.append(text); } }非流式响应的解析也不一样:
// Anthropic 非流式:内容在 content 数组里 JSONObject root = JSONUtil.parseObj(resp); JSONArray content = root.getJSONArray("content"); StringBuilder sb = new StringBuilder(); for (int i = 0; i < content.size(); i++) { JSONObject c = content.getJSONObject(i); if ("text".equals(c.getStr("type"))) { sb.append(c.getStr("text")); } } return sb.toString();五、实用技巧:让 AI 返回结构化数据
项目中大量场景需要 AI 返回 JSON,但 AI 不一定每次都乖乖输出纯 JSON。几个实用技巧:
5.1 强制 JSON 输出
OpenAI 协议支持response_format:
JSONObject rf = new JSONObject(); rf.set("type", "json_object"); req.set("response_format", rf);5.2 容错解析
AI 可能在 JSON 前后加一些说明文字,需要提取:
private JSONObject extractJson(String aiJson) { String text = aiJson.trim(); int si = text.indexOf('{'); int ei = text.lastIndexOf('}'); if (si >= 0 && ei > si) { text = text.substring(si, ei + 1); } try { return JSONUtil.parseObj(text); } catch (Exception e) { return null; } }5.3 System Prompt 技巧
让 AI 只输出 JSON 的 prompt 写法:
严格只输出一个JSON对象,不要输出JSON以外的任何文字。JSON格式: {"status":0或1,"reply":"审核意见"}关键在于明确告诉 AI 不要输出其他内容,并且给出具体的字段和格式。
六、踩坑记录
6.1 超时问题
AI 生成长文可能需要 30 秒以上,setReadTimeout至少设 120 秒。如果是联网搜索 + 生成的流式调用,建议设 180-300 秒。
6.2 连接复用
HttpURLConnection不支持连接池,每次请求都会新建连接。对于低频场景(比如后台手动触发)够用了。如果高频调用,建议换 HttpClient。
6.3 字符编码
务必用StandardCharsets.UTF_8:
conn.getOutputStream().write(req.toString().getBytes(StandardCharsets.UTF_8));不要用getBytes()(默认编码),中文内容会乱码。
6.4 网关特殊要求
部分 AI 网关(如 OpenCode)要求自定义请求头做会话路由:
private static final String SESSION_ID = UUID.randomUUID().toString(); conn.setRequestProperty("x-opencode-session", SESSION_ID); conn.setRequestProperty("User-Agent", "Mozilla/5.0 ..."); // 伪装浏览器七、完整调用链路
以"AI 生成 SEO 文案"为例,完整流程:
浏览器 → POST /admin/seo/generate-sse (title + content) ↓ Spring Controller → 构造请求体 → HttpURLConnection → AI API ↓ AI API (SSE 流) → Controller 逐行读取 → 转发给浏览器 ↓ 浏览器显示生成进度 → 结束后解析 JSON → 回填表单
以"联网采集热点"为例:
浏览器 → POST /admin/news/scan-sse ↓ Service → 构造 Anthropic 请求体(带 web_search 工具) ↓ AI 执行联网搜索 → 返回 JSON 数组 ↓ Service 解析热点 → 存入 hot_news 表 ↓ 返回采集结果(总数、批次号、耗时)
总结
Spring Boot 集成 AI API,核心就是三件事:
选协议:OpenAI Chat Completions 通用性好,Anthropic Messages 支持联网搜索
做流式:SSE 是标配,用户体验好,后端逐行转发就行
容错解析:AI 返回的不一定干净,JSON 提取要做防御性编程
不需要框架,不需要复杂架构,HttpURLConnection+ 几个工具方法就够了。
适合中小项目快速落地。如果需要高并发,再考虑连接池和异步化改造。
环境说明
Spring Boot 2.3.12.RELEASE
Java 8
JSON 工具:Hutool JSONUtil
AI 接口:DeepSeek 官网 Anthropic API + OpenCode Go API
无额外依赖,纯 JDK HttpURLConnection