經過前幾天的實作,我們已經打造出一個具備記憶力、且能穩定輸出 JSON 格式課表的 AI 教練 API。但在準備將專案打包上線前,我們必須面對現實世界的兩大挑戰:
在將 AI 教練 API 真正推向 Production 環境前,後端必須具備「邏輯層面防禦能力」與「系統層面抗壓能力」。本文將彙整 Day 10 的核心知識,並結合開發測試過程中遇到的真實問題進行更完整的整理!
為了防止 API 額度被濫用,我們不需要寫複雜的 NLP 演算法,而是透過 Prompt Engineering 結合後端 Service 邏輯攔截,打造出堅不可摧的雙層護欄。
我們在 Prompt 範本(如 generate-marathon.md)及多輪對話的 System Instruction 中,強硬注入嚴格的教練角色與領域規則:
你是一位嚴格且專業的馬拉松教練。
你的唯一職責是處理「馬拉松訓練、課表調整、跑步技術分析、運動修復與身體放鬆」相關的問題。
【極度重要:領域防禦規則】
如果使用者詢問的內容與上述「跑步及修復領域」無關,請你**絕對拒絕回答**,並在 JSON 中回傳錯誤狀態:
- "isRelevant": (布林值,使用者問題是否與跑步領域相關)
- "errorMessage": (如果不相關,請放入一段嚴厲但幽默的教練警告)
- "scheduleData": (如果不相關,請設為 null)
Spring Boot 收到 Gemini 的 JSON 回應後,會立刻在 Service 層進行檢驗。一旦發現 isRelevant == false,就立即拋出 DomainNotSupportedException,藉此省下後續不必要的資料庫寫入運算:
private <T extends GuardrailResponseDto> T parseAndValidateGuardrail(String cleanedJson, String rawJson, Class<T> responseType) {
T dto = objectMapper.readValue(cleanedJson, responseType);
if (dto != null && Boolean.FALSE.equals(dto.getIsRelevant())) {
String msg = dto.getErrorMessage();
// 主動拋出例外,跳過後續的 saveHistory()
throw new DomainNotSupportedException(msg);
}
return dto;
}
接著,透過 @RestControllerAdvice 捕捉這個自定義例外,並優雅地回傳 HTTP 400 Bad Request 給前端:
@ExceptionHandler(DomainNotSupportedException.class)
public ResponseEntity<Map<String, Object>> handleDomainNotSupportedException(DomainNotSupportedException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of(
"status", "error",
"code", "DOMAIN_NOT_SUPPORTED",
"message", e.getMessage()
));
}
呼叫外部 LLM API 時遭遇網路超時(Timeout)是很常見的事情。透過 Spring Retry,我們可以避免因單次網路波動就直接跳出 500 Error 嚇壞使用者。
pom.xml 引入 spring-retry 與 spring-boot-starter-aop 依賴。@EnableRetry 註解。在我們的 GeminiServiceImpl 中加上重試與降級的註解:
// 遭遇 RestClientException 時,最多重試 3 次,每次間隔 2000ms
@Retryable(
retryFor = {RestClientException.class},
maxAttempts = 3,
backoff = @Backoff(delay = 2000)
)
@Override
public String generateText(String prompt) {
// 發送 RestClient 請求邏輯...
}
// 3 次重試皆失敗後觸發降級機制
@Recover
public String recover(RestClientException e, String prompt) {
log.error("Gemini API 完全失去連線,執行降級處理", e);
// 回傳預設的 JSON,安撫使用者
return "{ \"isRelevant\": true, \"errorMessage\": \"教練現在有點累,請稍後再試!\", \"scheduleData\": null }";
}
在開發與測試階段,凡走過必留下 Bug。我們遇到了很現實的問題:
Maximum upload size exceededMaximum upload size exceeded。spring.servlet.multipart.max-file-size)。現代智慧型手機的高解析度截圖隨時都可能超過 1MB,因而被 Spring 底層直接攔截。application.yml 將上限放寬至 10MB:spring:
servlet:
multipart:
max-file-size: 10MB
max-request-size: 10MB
2.在 GlobalExceptionHandler.java 捕捉例外,回傳友善提示:
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<Map<String, Object>> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of(
"status", "error",
"code", "MAX_UPLOAD_SIZE_EXCEEDED",
"message", "上傳圖片檔案過大,請提供小於 10MB 的圖片檔案"
));
}
完成上述的重試、護欄與除錯修復後,透過 API 測試工具進行了驗證。
我們不僅確認了 AI 護欄能成功擋下無關提問並阻斷 DB 寫入,也驗證了 Retry 機制能在模擬 Timeout 時精確重試 3 次並進入降級模式。更棒的是,現在系統已經能完美支援容量更大的高畫質數據圖表解析。
我們的後端 API 有逐漸變成一個穩健的鐵人了,本地端開發正式功德圓滿!
明天(Day 11)我們將準備把專案帶上雲端,進入容器化實戰:撰寫 Dockerfile,將這個 Java 21 + Spring Boot 應用程式打包成輕巧的 Docker Image,敬請期待!
我:「第10天終於結束了!」
日友:「ですよね。」(得蘇優餒)
我:「Day 10 よね..」
日友:………👏👏 ですよね!!🙄🙄