Key Findings
  • Spring AI 提供三種 Prompt 管理策略:Fluent API(快速原型)、PromptTemplate(可重用模板)、外部 .st 檔案(生產環境最佳實踐)——根據專案規模選擇合適的方式
  • 結構化輸出(entity())讓 AI 回應直接轉換為 Kotlin data class,不需手動解析 JSON——從「AI 回傳字串」升級為「AI 回傳物件」
  • 透過動態角色切換,同一個 AI 模型可以扮演老師、面試官、架構師等不同角色,產生風格截然不同的回答
  • 結合 PromptTemplate + entity() 的資料擷取器模式是企業 AI 應用的核心——從非結構化文本中自動提取結構化資訊

為什麼需要 Prompt 模板?

在 Lesson 1 和 Lesson 2 中,我們的 Prompt 都是直接寫死在程式碼裡的字串。這在學習階段沒問題,但進入正式開發後會遇到幾個痛點:

  • 難以維護 — Prompt 散佈在各個 Service 方法中,改一個字就要改程式碼
  • 無法重用 — 相似的 Prompt 在不同地方複製貼上,修改時容易遺漏
  • 不好測試 — Prompt 和業務邏輯耦合,無法獨立測試 Prompt 的效果
  • 協作困難 — 非工程師(PM、領域專家)無法直接調整 Prompt

Prompt 模板[1]的核心思想是:把 Prompt 當作可配置的模板,用變數取代寫死的值。就像 HTML 模板引擎(Thymeleaf、Freemarker)對 Web 頁面做的事一樣。

Step 1:環境準備

開啟課程專案的 Lesson3/Lesson3_PromptAndStructuredOutput.ipynb。這堂課多引入了一個重要依賴——jackson-module-kotlin[4],用於結構化輸出的 JSON 反序列化:

@file:DependsOn("org.springframework.ai:spring-ai-openai:1.0.0")
@file:DependsOn("org.springframework.ai:spring-ai-client-chat:1.0.0")
@file:DependsOn("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2")
@file:DependsOn("org.slf4j:slf4j-simple:2.0.16")

import org.springframework.ai.openai.OpenAiChatModel
import org.springframework.ai.openai.api.OpenAiApi
import org.springframework.ai.chat.client.ChatClient
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper

val apiKey = System.getenv("OPENAI_API_KEY")
    ?: error("請先設定 OPENAI_API_KEY(參考 Lesson 0)")

val openAiApi = OpenAiApi.builder().apiKey(apiKey).build()
val chatModel = OpenAiChatModel.builder().openAiApi(openAiApi).build()
val chatClient = ChatClient.builder(chatModel).build()
val kotlinMapper = jacksonObjectMapper()

println("✓ 環境準備完成(含 Jackson Kotlin Module)")
為什麼需要 jackson-module-kotlin? Kotlin 的 data class 沒有無參數建構子(no-arg constructor),標準 Jackson 無法反序列化。jackson-module-kotlin 透過 Kotlin 反射解決了這個問題。

Step 2:方法一——Fluent API 直接組裝

最快速的方式,適合簡單場景。直接用 Kotlin 的字串模板 ${} 嵌入變數:

val language = "日文"
val text = "人工智慧正在改變世界"

val result = chatClient
    .prompt()
    .system("你是一位專業翻譯,只回傳翻譯結果,不加任何解釋。")
    .user("請將以下文字翻譯為${language}:${text}")
    .call()
    .content() ?: "(無回應)"

println("翻譯結果: $result")

預期輸出:

翻譯結果: 人工知能は世界を変えています。

這種方式簡單直覺,但 Prompt 和 Kotlin 程式碼綁在一起,適合原型開發或一次性腳本。

Step 3:方法二——PromptTemplate 模板類別

當 Prompt 變得複雜(多個變數、多行指令),使用 PromptTemplate 更好管理[3]。注意模板使用 {variable}(大括號,不是 Kotlin 的 ${}):

import org.springframework.ai.chat.prompt.PromptTemplate

val template = PromptTemplate("""
你是一位 {role},請用 {language} 回答。
請針對以下主題提供 {count} 個重點:

主題:{topic}
""".trimIndent())

val prompt = template.create(mapOf(
    "role" to "資深軟體架構師",
    "language" to "繁體中文",
    "count" to "3",
    "topic" to "微服務架構的優缺點"
))

val response = chatModel.call(prompt)
println(response.result.output.text)

預期輸出:

1. **獨立擴展性** — 每個微服務可以根據自身負載獨立擴展,不需要整體部署,
   資源利用更有效率。

2. **技術多樣性** — 不同服務可以選擇最適合的技術棧(Java、Go、Python),
   團隊可以使用最擅長的工具。

3. **複雜度提升** — 分散式系統帶來網路延遲、資料一致性、服務發現等挑戰,
   需要額外的基礎設施(API Gateway、Service Mesh)支撐。

PromptTemplate 與 Fluent API 的關鍵差異:

特性Fluent API ${}PromptTemplate {}
語法Kotlin 字串模板StringTemplate 語法
變數時機編譯期就確定執行期動態填入
可重用性低(嵌在程式碼中)高(模板物件可傳遞)
外部化不支援支援載入外部檔案
適合場景快速原型、簡單查詢多變數、需要重用的正式開發

Step 4:方法三——外部檔案模板(生產環境最佳實踐)

在正式專案中,最推薦的做法是把 Prompt 模板放在外部檔案中。這樣 PM、領域專家可以直接編輯 Prompt,不需要動程式碼:

src/main/resources/prompts/
├── translator.st          # 翻譯器
├── summarizer.st          # 摘要產生器
├── code-reviewer.st       # 程式碼審查
└── customer-service.st    # 客服回覆

檔案格式使用 .st(StringTemplate)[3]。例如一個程式碼審查模板 code-reviewer.st

你是一位嚴格的資深工程師,專精 {language} 程式語言。
請審查以下程式碼,從這些面向分析:

1. 潛在的 Bug 或例外處理問題
2. 效能優化建議
3. 程式碼可讀性與命名慣例
4. 安全性考量

請用列點方式回答,每個問題附上具體的改善建議。

程式碼:
{code}

在 Spring Boot 中載入外部模板:

import org.springframework.beans.factory.annotation.Value
import org.springframework.core.io.Resource

@Service
class CodeReviewService(
    private val chatClient: ChatClient,
    @Value("classpath:prompts/code-reviewer.st") private val reviewTemplate: Resource
) {
    fun reviewCode(language: String, code: String): String {
        val template = PromptTemplate(reviewTemplate)
        val prompt = template.create(mapOf(
            "language" to language,
            "code" to code
        ))
        return chatClient.prompt(prompt).call().content() ?: ""
    }
}

三種方法的選擇指南:

場景推薦方法原因
1~2 個變數的簡單查詢Fluent API最快,不需額外設定
多變數、需要重用的中等複雜度PromptTemplate模板物件可傳遞、可測試
正式環境、團隊協作外部 .st 檔案Prompt 與程式碼分離,非工程師可維護

Step 5:進階技巧——動態角色切換

SystemMessage 的威力在 Lesson 1 已經體驗過。現在結合 PromptTemplate,我們可以建立一個角色引擎,用同一個模型產生截然不同風格的回答:

val roles = mapOf(
    "teacher" to "你是一位耐心的程式設計老師,用生活化的比喻解釋技術概念,讓初學者也能輕鬆理解。",
    "interviewer" to "你是一位嚴格的技術面試官,回答要精確、有深度,並追問延伸問題。",
    "architect" to "你是一位資深軟體架構師,從系統設計的角度分析,強調可擴展性與維護性。"
)

val question = "什麼是 Dependency Injection?"

roles.forEach { (role, systemPrompt) ->
    val answer = chatClient.prompt()
        .system(systemPrompt)
        .user(question)
        .call().content()

    println("【${role.uppercase()}】")
    println(answer)
    println()
}

預期輸出:

【TEACHER】
想像你要做一道菜。與其自己去市場買所有食材(自己建立依賴),
不如有人幫你把食材準備好送到廚房(注入依賴)。
Dependency Injection 就是這個概念——你的程式不用自己建立需要的物件,
由框架(Spring)幫你準備好並「注入」進來。

【INTERVIEWER】
Dependency Injection 是一種實現控制反轉(IoC)的設計模式,
物件的依賴由外部容器提供而非自行建立。
追問:請比較 Constructor Injection 和 Field Injection 的優缺點,
以及為什麼 Spring 官方推薦 Constructor Injection?

【ARCHITECT】
從架構角度看,DI 是實現鬆耦合的核心機制。透過介面抽象與外部注入,
模組之間的依賴關係在編譯期可驗證,且可以在不同環境(測試、正式)
替換實作。這是微服務架構中每個服務能獨立部署的基礎前提。

Step 6:結構化輸出——讓 AI 回傳物件,不是字串

到目前為止,AI 的回應都是 String。但在實際應用中,你需要的是結構化的資料物件——書籍推薦要有書名、作者、推薦理由;API 分析要有端點、方法、參數。

Spring AI 的 entity() 方法[2]可以讓 AI 回應自動轉換為 Kotlin data class

import org.springframework.ai.converter.BeanOutputConverter

data class BookRecommendation(
    val title: String,
    val author: String,
    val reason: String
)

val bookConverter = BeanOutputConverter(BookRecommendation::class.java, kotlinMapper)

val book: BookRecommendation = chatClient
    .prompt()
    .system("你是一位技術書籍推薦專家。")
    .user("推薦一本學習 Spring Boot 的書")
    .call()
    .entity(bookConverter)!!

println("書名: ${book.title}")
println("作者: ${book.author}")
println("推薦原因: ${book.reason}")

預期輸出:

書名: Spring Boot in Action
作者: Craig Walls
推薦原因: 這本書從實戰角度出發,涵蓋 Spring Boot 的核心概念與自動配置原理,
適合有 Java 基礎的開發者快速上手。

背後的運作原理是:BeanOutputConverter 在 Prompt 中自動附加 JSON Schema 指令,告訴 AI「請按照這個格式回傳 JSON」,然後用 Jackson 反序列化成 Kotlin 物件。你完全不用手動寫 JSON 解析邏輯。

Step 7:結構化輸出——List 回應

當 AI 需要回傳多個物件時,使用 ParameterizedTypeReference 處理泛型型別:

import org.springframework.core.ParameterizedTypeReference

data class FrameworkComparison(
    val name: String,
    val language: String,
    val strengths: List<String>,
    val bestFor: String
)

val listConverter = BeanOutputConverter(
    object : ParameterizedTypeReference<List<FrameworkComparison>>() {},
    kotlinMapper
)

val frameworks: List<FrameworkComparison> = chatClient
    .prompt()
    .user("比較 Spring AI、LangChain、LlamaIndex 這三個 AI 框架")
    .call()
    .entity(listConverter)!!

frameworks.forEach { fw ->
    println("【${fw.name}】(${fw.language})")
    println("  優勢: ${fw.strengths.joinToString("、")}")
    println("  最適合: ${fw.bestFor}")
    println()
}

預期輸出:

【Spring AI】(Java/Kotlin)
  優勢: 企業級整合、Spring 生態系、型別安全
  最適合: Java 技術棧的企業 AI 應用

【LangChain】(Python)
  優勢: 豐富的 LLM 鏈組合、社群活躍、插件生態
  最適合: 快速原型開發與 LLM 應用編排

【LlamaIndex】(Python)
  優勢: 資料索引最佳化、多種資料源連接器、RAG 專精
  最適合: 知識庫建構與文件檢索應用

Step 8:實戰——AI 資料擷取器

結合 PromptTemplate + entity() 的威力,打造一個從非結構化文本中自動擷取結構化資訊的工具。這是企業 AI 應用最常見的模式之一:

data class MeetingInfo(
    val date: String,
    val participants: List<String>,
    val topics: List<String>,
    val decisions: List<String>,
    val nextActions: List<String>
)

val meetingNotes = """
上週五(3月14日)下午的技術會議,參加的有 Andy、Betty、Charlie 和 David。
主要討論了三個議題:第一是 Spring AI 整合進度,目前已完成 ChatClient 的封裝;
第二是 RAG 架構的向量資料庫選型,最終決定使用 PGVector;
第三是 API 效能問題,Betty 提出要加入快取機制。
會後決議:下週三之前 Andy 完成 PGVector 的 POC,Betty 負責設計快取策略,
Charlie 要準備 Demo 環境給客戶看。
""".trimIndent()

val meetingConverter = BeanOutputConverter(MeetingInfo::class.java, kotlinMapper)

val meetingInfo: MeetingInfo = chatClient
    .prompt()
    .system("你是一位專業的會議記錄整理專家。請從會議紀錄中精確擷取結構化資訊。")
    .user("請分析以下會議紀錄:\n\n$meetingNotes")
    .call()
    .entity(meetingConverter)!!

println("📅 日期: ${meetingInfo.date}")
println("👥 參與者: ${meetingInfo.participants.joinToString("、")}")
println("📋 議題:")
meetingInfo.topics.forEachIndexed { i, t -> println("   ${i + 1}. $t") }
println("✅ 決議:")
meetingInfo.decisions.forEach { println("   • $it") }
println("📌 後續行動:")
meetingInfo.nextActions.forEach { println("   • $it") }

預期輸出:

📅 日期: 3月14日(週五)
👥 參與者: Andy、Betty、Charlie、David
📋 議題:
   1. Spring AI 整合進度
   2. RAG 架構向量資料庫選型
   3. API 效能問題
✅ 決議:
   • 向量資料庫選用 PGVector
   • API 加入快取機制
📌 後續行動:
   • Andy:下週三前完成 PGVector POC
   • Betty:設計快取策略
   • Charlie:準備 Demo 環境給客戶

從一段自然語言的會議紀錄,AI 自動擷取出日期、參與者、議題、決議、行動項目——而且直接就是 Kotlin 物件,可以立即存入資料庫或傳給下游 API。這就是結構化輸出在企業場景中的核心價值。

Step 9:練習——打造產品評論分析器

運用本課學到的技巧,試著建立一個產品評論分析器:

data class ProductReview(
    val productName: String,
    val rating: Int,           // 1-5 分
    val pros: List<String>,    // 優點
    val cons: List<String>,    // 缺點
    val summary: String        // 一句話摘要
)

val reviewText = """
用了 iPhone 16 Pro 兩個月了,整體來說很滿意。相機提升最有感,
特別是夜拍模式簡直是黑科技,拍出來的照片比上一代好太多。
處理器很順暢,玩原神完全不卡。但電池續航還是老問題,
重度使用大概撐不到一天。價格也偏高,入門款就要三萬多。
整體給 4 顆星。
""".trimIndent()

val reviewConverter = BeanOutputConverter(ProductReview::class.java, kotlinMapper)

val review: ProductReview = chatClient
    .prompt()
    .system("你是一位產品分析師,擅長從用戶評論中擷取結構化資訊。")
    .user("分析以下產品評論:\n\n$reviewText")
    .call()
    .entity(reviewConverter)!!

println("產品: ${review.productName}")
println("評分: ${"⭐".repeat(review.rating)} (${review.rating}/5)")
println("優點: ${review.pros.joinToString("、")}")
println("缺點: ${review.cons.joinToString("、")}")
println("摘要: ${review.summary}")

預期輸出:

產品: iPhone 16 Pro
評分: ⭐⭐⭐⭐ (4/5)
優點: 相機大幅提升、夜拍模式出色、處理器效能流暢
缺點: 電池續航不足、價格偏高
摘要: iPhone 16 Pro 在影像與效能表現優異,但電池續航與定價仍是短板。

本課重點回顧

概念重點
Fluent API${} 快速嵌入變數,適合簡單場景
PromptTemplate{variable} 佔位符,可重用、可測試
外部 .st 檔案Prompt 與程式碼分離,生產環境最佳實踐
動態角色切換不同 SystemMessage 讓同一模型展現不同人格
entity()AI 回應自動轉換為 Kotlin 物件
ParameterizedTypeReference處理 List<T> 等泛型回應
資料擷取器模式非結構化文本 → 結構化物件,企業核心應用模式

下一步:Lesson 4 — Function Calling

結構化輸出讓 AI 能「回傳」物件。但如果 AI 需要主動「呼叫」你的程式碼呢?下一堂課將介紹 Spring AI 最強大的功能之一——Function Calling

  • Tool 註冊 — 讓 AI 知道有哪些工具可以使用
  • 自動呼叫 — AI 判斷何時需要呼叫外部函式,並自動執行
  • 實戰案例 — AI 直接查詢天氣 API、搜尋資料庫、計算匯率

掌握結構化輸出,繼續前進!

學會讓 AI 回傳物件後,下一步讓 AI 呼叫你的函式——查資料庫、呼叫 API、執行計算。

前往 Lesson 4:Function Calling →