昨天我們認識了生成式 AI、LLM、Prompt,以及 AI API 的基本概念。
雖然已經知道 Java 可以透過 HTTP 和 AI 模型溝通,但到目前為止,我們還沒有真正寫出呼叫 AI 的程式。
所以今天終於要來實作了!
這次會使用 Spring Boot 搭配 Java 的 HttpClient,呼叫 OpenAI API,讓智慧健康助理第一次取得 AI 產生的文字。
想到前面花了 21 天建立後端,現在終於要讓 AI 出場,還是有點期待 XD
今天的目標是:
使用 Java 發送 HTTP Request 至生成式 AI API,接收模型回覆,並建立第一個 AI 測試 API。
今天準備新增一個 API:
GET /health/ai/test
當我們透過瀏覽器或 Postman 呼叫這個 API 時,Spring Boot 會向外部 AI 服務發送請求。
整個流程如下:
使用者
↓
GET /health/ai/test
↓
Spring Boot
↓
AIService
↓
Java HttpClient
↓
OpenAI API
↓
AI 模型產生文字
↓
Spring Boot 接收回覆
↓
回傳 JSON
例如我們希望 AI 回答:
請用一句繁體中文介紹智慧健康助理。
最後可能得到:
{
"message": "智慧健康助理可以協助整理每日健康紀錄,讓使用者更方便觀察生活習慣。"
}
不過這次 AI 產生的文字不一定每次都完全相同。
因為生成式 AI 並不是使用固定答案回覆。
今天先使用簡單的測試 Prompt,確認 Java 和 AI API 可以正常溝通。
等到下一篇,再將我們之前建立的健康摘要資料交給 AI。
今天會使用以下工具:
| 技術 | 用途 |
|---|---|
| Java | 撰寫後端程式 |
| Spring Boot | 建立 REST API |
| Java HttpClient | 發送 HTTP Request |
| Jackson ObjectMapper | 處理 JSON |
| OpenAI API | 呼叫生成式 AI 模型 |
| Postman | 測試 API |
| 環境變數 | 保存 API Key |
這裡有一個好消息。
因為我們前面建立的 Spring Boot 專案已經包含 Spring Web,所以通常不需要為了今天的範例再加入額外的 HTTP Client 套件。
今天使用的是 Java 11 開始提供的:
java.net.http.HttpClient
如果目前專案使用 Java 17 或更新版本,就可以使用這個功能。
今天選擇使用 OpenAI API 作為範例。
它可以讓開發者透過程式呼叫 AI 模型。
例如我們可以傳送:
請用繁體中文介紹健康紀錄的重要性。
再取得模型產生的文字。
這次會使用 OpenAI 的 Responses API。
API Endpoint 為:
https://api.openai.com/v1/responses
我們會透過:
POST
發送請求。
基本的 Request Body 可以長這樣:
{
"model": "gpt-4.1-mini",
"input": "請用一句繁體中文介紹智慧健康助理。"
}
這裡有兩個重要欄位。
"model": "gpt-4.1-mini"
代表要使用哪個 AI 模型。
這裡使用 gpt-4.1-mini 作為教學範例。
實際使用時,需要確認自己的 API 專案是否可以使用該模型,以及目前的價格與模型供應狀況。
"input": "請用一句繁體中文介紹智慧健康助理。"
代表我們要提供給模型的輸入內容。
今天先使用簡單的文字。
未來就可以改成:
請根據以下健康紀錄產生每日摘要。
在正式呼叫 API 之前,我們需要先準備 API Key。
可以到 OpenAI API 平台:
登入後,進入 API Keys 相關頁面建立金鑰。
建立完成後,會取得一組 API Key。
這組金鑰非常重要。
因為它和 API 使用權限及費用有關。
不要將真正的 API Key 放到公開的 GitHub Repository,也不要直接寫進教學文章或截圖。
另外要注意:
使用 ChatGPT 不代表已經有 API 免費額度。
API 使用量通常會另外計費,因此開始測試前,記得確認 API 專案的計費與使用限制。
今天不會把金鑰直接寫進 Java 程式。
而是使用環境變數。
環境變數名稱設定為:
OPENAI_API_KEY
如果使用 Windows,可以在 PowerShell 輸入:
$env:OPENAI_API_KEY="你的_API_Key"
這個設定只會套用到目前的 PowerShell 工作階段,以及從該工作階段啟動的程式。
如果關閉 PowerShell,通常就需要重新設定。
可以使用:
export OPENAI_API_KEY="你的_API_Key"
同樣是設定目前終端機工作階段的環境變數。
如果是直接從 IntelliJ IDEA 執行 Spring Boot,可以到:
Run
↓
Edit Configurations
↓
Environment variables
加入:
OPENAI_API_KEY=你的_API_Key
這樣 Spring Boot 啟動時,就可以讀取金鑰。
記得不要把含有真正金鑰的設定檔提交到 Git。
接下來打開:
src/main/resources/application.properties
保留前面 H2 Database 的設定。
再加入:
openai.api.key=${OPENAI_API_KEY}
openai.api.url=https://api.openai.com/v1/responses
openai.api.model=gpt-4.1-mini
這裡使用:
${OPENAI_API_KEY}
代表 Spring Boot 會從環境變數取得 API Key。
而:
openai.api.url
則是 API 的網址。
最後:
openai.api.model
代表我們要使用的模型。
這樣以後如果想更換模型,就不用每次都修改 Java 程式。
只要調整設定檔即可。
在開始寫 AIService 之前,我們先認識今天會用到的:
HttpClient
它是 Java 提供的 HTTP Client。
可以用來發送:
GET
POST
PUT
DELETE
等 HTTP 請求。
例如:
HttpClient client = HttpClient.newHttpClient();
就可以建立一個 HTTP Client。
接著使用:
HttpRequest
建立請求。
例如:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://example.com"))
.GET()
.build();
最後透過:
client.send()
發送請求並取得回應。
今天我們會使用相同的概念,只是改成向 AI API 發送 POST Request。
今天呼叫 OpenAI API 時,會發送類似以下的 HTTP Request:
POST /v1/responses HTTP/1.1
Host: api.openai.com
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
Request Body:
{
"model": "gpt-4.1-mini",
"input": "請用一句繁體中文介紹智慧健康助理。"
}
其中最重要的是:
Authorization
這個 Header。
我們需要使用:
Bearer API_KEY
的方式提供驗證資訊。
例如:
.header("Authorization", "Bearer " + apiKey)
另外還需要:
.header("Content-Type", "application/json")
告訴 API 我們傳送的是 JSON 資料。
接下來終於開始寫 Java 程式了!
在:
service
Package 裡面建立:
AIService.java
這個 Service 將負責:
先看完整程式:
package com.example.healthassistant.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
@Service
public class AIService {
private final String apiKey;
private final String apiUrl;
private final String model;
private final HttpClient httpClient;
private final ObjectMapper objectMapper;
public AIService(
@Value("${openai.api.key}") String apiKey,
@Value("${openai.api.url}") String apiUrl,
@Value("${openai.api.model}") String model,
ObjectMapper objectMapper) {
this.apiKey = apiKey;
this.apiUrl = apiUrl;
this.model = model;
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
public String generateText(String prompt) {
try {
Map<String, Object> requestBody = Map.of(
"model", model,
"input", prompt,
"max_output_tokens", 150
);
String jsonBody =
objectMapper.writeValueAsString(requestBody);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(
HttpRequest.BodyPublishers.ofString(
jsonBody,
StandardCharsets.UTF_8
)
)
.build();
HttpResponse<String> response =
httpClient.send(
request,
HttpResponse.BodyHandlers.ofString(
StandardCharsets.UTF_8
)
);
if (response.statusCode() < 200
|| response.statusCode() >= 300) {
throw new IllegalStateException(
"AI API request failed. HTTP status: "
+ response.statusCode()
);
}
JsonNode root =
objectMapper.readTree(response.body());
StringBuilder result = new StringBuilder();
JsonNode output = root.path("output");
if (output.isArray()) {
for (JsonNode item : output) {
if (!"message".equals(
item.path("type").asText())) {
continue;
}
JsonNode content = item.path("content");
if (content.isArray()) {
for (JsonNode part : content) {
if ("output_text".equals(
part.path("type").asText())) {
result.append(
part.path("text").asText()
);
}
}
}
}
}
if (result.length() == 0) {
throw new IllegalStateException(
"AI API returned no text output."
);
}
return result.toString();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"AI API request was interrupted.", e
);
} catch (Exception e) {
throw new IllegalStateException(
"Failed to generate AI text.", e
);
}
}
}
這段程式看起來有點長,但其實就是把剛剛學過的 HTTP Request 流程寫成 Java。
接下來我們拆開來看。
首先看到:
@Value("${openai.api.key}")
這是 Spring 提供的功能。
可以從:
application.properties
讀取設定值。
例如:
public AIService(
@Value("${openai.api.key}") String apiKey,
@Value("${openai.api.url}") String apiUrl,
@Value("${openai.api.model}") String model,
ObjectMapper objectMapper)
就可以取得:
API Key
API URL
Model
這樣我們不需要把這些資訊直接寫死在程式裡。
未來如果想更換模型,也會比較方便。
接下來看到:
Map<String, Object> requestBody = Map.of(
"model", model,
"input", prompt,
"max_output_tokens", 150
);
這裡先使用 Java 的 Map 建立請求資料。
例如:
model → gpt-4.1-mini
input → 請用一句繁體中文介紹智慧健康助理。
max_output_tokens → 150
接著使用:
objectMapper.writeValueAsString(requestBody);
把 Java 物件轉換成 JSON 字串。
例如:
{
"model": "gpt-4.1-mini",
"input": "請用一句繁體中文介紹智慧健康助理。",
"max_output_tokens": 150
}
這裡的 max_output_tokens 是限制模型輸出的 Token 數量。
它不是中文字數限制,也不能保證回覆剛好在指定字數內。
另外,如果輸出上限太低,也可能導致模型無法完成回覆。
接著是今天的核心程式:
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(
HttpRequest.BodyPublishers.ofString(
jsonBody,
StandardCharsets.UTF_8
)
)
.build();
我們可以分成幾個部分理解。
.uri(URI.create(apiUrl))
代表要將請求發送到哪裡。
.timeout(Duration.ofSeconds(30))
代表這次 HTTP Request 最多等待約 30 秒。
如果外部服務沒有在期限內完成回應,程式就可能拋出逾時例外。
.header("Authorization", "Bearer " + apiKey)
提供 API 驗證資訊。
.header("Content-Type", "application/json")
表示傳送的資料格式是 JSON。
.POST(
HttpRequest.BodyPublishers.ofString(jsonBody)
)
代表使用 POST Method,並將 JSON 字串放入 Request Body。
這樣就完成一個基本的 AI API Request。
建立好 Request 後,就可以真正發送了。
程式如下:
HttpResponse<String> response =
httpClient.send(
request,
HttpResponse.BodyHandlers.ofString()
);
這裡使用:
send()
同步發送 HTTP Request。
也就是說,Java 會等待外部 API 回應後,才繼續執行後面的程式。
接著可以透過:
response.statusCode()
取得 HTTP Status Code。
以及:
response.body()
取得回傳的 JSON 字串。
例如:
int statusCode = response.statusCode();
String responseBody = response.body();
不過實際的 AI API Response 通常不會只有一段簡單文字。
所以還需要解析 JSON。
今天使用的 Responses API,回傳內容通常包含多個欄位。
以下是簡化後的示意:
{
"id": "resp_example",
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [
{
"type": "output_text",
"text": "智慧健康助理可以協助整理每日健康紀錄。"
}
]
}
]
}
這只是示意格式,不代表每次 API 都會回傳完全相同的結構。
其中我們最想取得的是:
"text": "智慧健康助理可以協助整理每日健康紀錄。"
所以今天使用:
JsonNode root =
objectMapper.readTree(response.body());
先將 JSON 字串轉成 JsonNode。
接著從:
output
裡面尋找:
message
再從:
content
裡面尋找:
output_text
最後取得:
part.path("text").asText()
這樣就可以拿到模型產生的文字。
為什麼不直接使用固定的陣列位置,例如:
root.path("output").get(0)
因為 AI API 的輸出可能包含不同類型的項目。
直接假設第一個項目一定是文字訊息,可能不夠穩定。
因此今天採用逐一檢查輸出項目類型的方式。
AIService 完成後,接下來要建立 Controller。
在:
controller
Package 裡面新增:
AIController.java
完整程式如下:
package com.example.healthassistant.controller;
import com.example.healthassistant.service.AIService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/health/ai")
public class AIController {
private final AIService aiService;
public AIController(AIService aiService) {
this.aiService = aiService;
}
@GetMapping("/test")
public ResponseEntity<Map<String, String>> testAI() {
String prompt =
"請用一句繁體中文介紹智慧健康助理,"
+ "不要提供醫療診斷或治療建議。";
String result = aiService.generateText(prompt);
return ResponseEntity.ok(
Map.of("message", result)
);
}
}
這次的 Controller 其實非常簡單。
因為呼叫 AI 的工作已經交給:
AIService
Controller 只需要:
建立測試 Prompt
↓
呼叫 AIService
↓
取得 AI 文字
↓
回傳 JSON
這也再次用到了前面一直練習的 Controller 和 Service 分層架構。
接下來終於要測試了!
先確認:
接著打開 Postman。
HTTP Method:
GET
URL:
http://localhost:8080/health/ai/test
按下:
Send
這時候 Spring Boot 就會呼叫外部 AI API。
如果請求成功,預期回傳類似:
{
"message": "智慧健康助理可以協助整理每日飲水與睡眠紀錄,讓使用者更方便觀察生活習慣。"
}
這裡要特別注意:
這是預期回覆範例,不是固定答案。
實際的 AI 回覆可能會使用不同文字。
只要可以正常取得模型產生的內容,就代表基本串接流程已經完成。
第一次串接外部 API 時,可能會遇到不少錯誤。
所以今天也整理幾個常見情況。
| 狀況 | 可能原因 | 檢查方向 |
|---|---|---|
| 401 Unauthorized | API Key 無效或未正確傳送 | 檢查環境變數與金鑰 |
| 403 Forbidden | 專案權限不足 | 檢查 API 專案權限 |
| 429 Too Many Requests | 速率限制或額度問題 | 檢查使用限制與計費 |
| 400 Bad Request | Request Body 格式錯誤 | 檢查 model 與 JSON |
| 連線逾時 | 網路或服務回應問題 | 檢查網路與逾時設定 |
| 沒有文字輸出 | 回覆未完成或輸出結構不同 | 檢查回覆狀態與內容 |
另外,今天的 AIService 已經加入基本錯誤處理。
例如:
if (response.statusCode() < 200
|| response.statusCode() >= 300) {
throw new IllegalStateException(
"AI API request failed. HTTP status: "
+ response.statusCode()
);
}
這樣可以避免 API 明明回傳錯誤,程式卻還把它當成成功結果處理。
不過目前只是教學版本。
如果未來要正式提供服務,還需要加入更完整的錯誤回應、重試策略、使用量控管和監控機制。
也要避免把 API Key 或外部服務的敏感錯誤內容直接回傳給使用者。
這裡還有一個很重要的觀念。
如果未來我們建立網頁介面,能不能直接從 JavaScript 呼叫 AI API?
技術上可以從瀏覽器發送 HTTP Request,但如果把私密 API Key 放進前端程式,就會有很大的安全問題。
因為瀏覽器端的程式碼和網路請求,使用者都可能查看。
所以我們不應該把真正的 API Key 放在:
const apiKey = "真正的_API_Key";
比較適合的架構是:
前端網頁
↓
Spring Boot API
↓
AIService
↓
OpenAI API
這樣 API Key 就可以保存在後端環境中。
未來也比較容易加入:
使用者驗證
請求次數限制
費用控管
輸入內容驗證
不過要注意,API Key 放在後端只是其中一項安全措施,不能取代完整的權限控管。
今天新增了:
AIService.java
AIController.java
目前專案結構如下:
health-assistant
│
├── src/main/java/com/example/healthassistant
│ │
│ ├── HealthAssistantApplication.java
│ │
│ ├── controller
│ │ ├── HealthController.java
│ │ ├── WaterController.java
│ │ ├── SleepController.java
│ │ ├── HealthSummaryController.java
│ │ └── AIController.java
│ │
│ ├── service
│ │ ├── BmiService.java
│ │ ├── WaterService.java
│ │ ├── SleepService.java
│ │ ├── HealthSummaryService.java
│ │ └── AIService.java
│ │
│ ├── repository
│ │ ├── WaterRepository.java
│ │ └── SleepRepository.java
│ │
│ ├── dto
│ │ ├── HealthRequest.java
│ │ ├── WaterRequest.java
│ │ ├── WaterHistoryResponse.java
│ │ ├── WaterStatisticsResponse.java
│ │ ├── SleepRequest.java
│ │ ├── SleepResponse.java
│ │ ├── SleepHistoryResponse.java
│ │ ├── SleepStatisticsResponse.java
│ │ ├── WaterSummaryResponse.java
│ │ ├── SleepSummaryResponse.java
│ │ └── HealthSummaryResponse.java
│ │
│ └── exception
│ └── GlobalExceptionHandler.java
│
├── src/main/resources
│ ├── application.properties
│ └── schema.sql
│
└── pom.xml
今天最大的改變,就是系統開始可以和外部 AI 服務溝通。
整個架構也從:
Spring Boot
↓
H2 Database
擴充成:
Spring Boot
│
┌───────┴───────┐
↓ ↓
H2 Database AIService
↓
OpenAI API
這樣後面就可以把健康紀錄和 AI 功能結合起來。
雖然今天已經建立第一個 AI 測試 API,但目前還只是初步串接。
目前我們直接在 Controller 裡面寫:
String prompt =
"請用一句繁體中文介紹智慧健康助理。";
所以每次呼叫 API,輸入的問題都是相同的。
未來可以讓 Prompt 根據健康紀錄自動產生。
今天只是確認:
Java → AI API → Java
這個流程可以正常運作。
還沒有把飲水量或睡眠紀錄傳給 AI。
目前任何能存取本機 API 的呼叫者,都可能觸發 AI API 請求。
如果未來要部署到網路上,就需要加入身分驗證、權限控管及請求限制,避免 API 被濫用。
即使模型可以正常回覆,也不代表產生的內容一定正確。
尤其是健康相關資訊,不能直接把模型輸出當成醫療診斷。
未來需要進一步限制 Prompt 的用途,並檢查輸出是否符合預期。
今天終於成功建立智慧健康助理的第一個 AI 串接功能!
從前面一直使用 Java 處理資料,到今天開始透過 HTTP Request 呼叫生成式 AI API,算是一個重要的進展。
今天學到的重點包括:
@Value 讀取 Spring Boot 設定HttpClient 發送 HTTP RequestObjectMapper 建立 JSONAIService
AIController
目前智慧健康助理已經具備:
智慧健康助理
│
├── BMI 計算與分類
│
├── 飲水量紀錄
│ ├── 新增紀錄
│ ├── 歷史查詢
│ └── 七天統計
│
├── 睡眠時間紀錄
│ ├── 新增紀錄
│ ├── 指定日期查詢
│ └── 七天統計
│
├── 每日健康摘要
│ └── 整合飲水與睡眠資料
│
└── AI 功能
└── 呼叫生成式 AI API
今天最大的收穫是:
原來 Java 不只可以建立自己的 API,也可以透過 HTTP Client 呼叫外部 AI 服務,將生成式 AI 的能力整合進 Spring Boot 專案。
而且透過 Controller 和 Service 分層,我們可以把 AI 串接邏輯獨立管理。
這樣後面要新增健康摘要功能時,就不需要重新撰寫整套 HTTP 呼叫程式。
今天已經完成第一次 Java 與 AI API 的串接。
但目前 AI 只能回答固定的測試 Prompt。
所以下一篇準備將之前建立的每日健康摘要資料,真正交給 AI 處理!
預計會學習:
讓智慧健康助理不只會計算和儲存資料,也開始能夠用自然語言整理每天的健康紀錄!