Java AI 核心引擎:30 天從零打造可擴展的智慧 Agent 函式庫
本篇是 IT 鐵人賽系列文章的第四天,我們將為 AiServices 加上串流能力,實現即時的打字機效果,提升使用者互動體驗。
昨天我們體驗了 ChatLanguageModel
的同步對話方式,每次詢問 AI 都要等待完整的回應生成後才能看到結果。當 AI 思考複雜問題時,這種等待過程可能長達數十秒,使用者完全不知道 AI 是否還在處理,還是已經「當機」了。
想像一下現實生活中的對話場景:當朋友在回答問題時,我們能看到對方的表情變化、思考的過程,這種即時回饋讓對話變得自然流暢。串流模式就是要把這種體驗帶到 AI 互動中。
使用 StreamingChatLanguageModel
時,AI 的回應會像打字一樣逐字出現。這不僅讓使用者知道 AI 正在工作,更重要的是提供了「思考過程」的視覺化體驗。特別是當 AI 在生成程式碼或長篇說明時,這種即時回饋能大幅改善使用者體驗。
LangChain4j 的 TokenStream
採用回調模式設計,這是處理串流資料的經典方式:
TokenStream tokenStream = assistant.chatInStream("問題");
tokenStream
.onNext(token -> {
// 每個新字符出現時的處理
System.out.print(token);
})
.onComplete(response -> {
// 完整回應生成後的處理
System.out.println("回應完成!");
})
.onError(error -> {
// 錯誤處理
System.err.println("發生錯誤: " + error.getMessage());
})
.start();
這種設計讓我們能夠在串流的不同階段執行不同的邏輯,既靈活又易於理解。
既然要添加串流功能,最直接的做法就是在 Assistant
介面中新增一個串流版本的對話方法。在設計時我特別注意要保持向後相容性,畢竟昨天的同步版本 chat()
方法還是很實用的。
package org.example.aiagentdemo.service;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.TokenStream;
/**
* AI 助理服務介面
*
* 使用 LangChain4j 的 AiServices 功能,透過聲明式方式定義 AI 服務。
* 此介面將由 LangChain4j AiServices 框架實現,提供更優雅的 AI 互動方式。
*/
public interface Assistant {
/**
* 與 AI 助理進行對話
*
* @param userMessage 使用者輸入的訊息
* @return AI 助理的回應
*/
@SystemMessage("你是一個友善且專業的 AI 助理。請用繁體中文回應,並提供有用、準確的資訊。")
String chat(String userMessage);
/**
* 與 AI 助理進行串流對話
*
* 使用 TokenStream 提供即時的打字機效果,讓使用者能夠看到 AI 回應的逐字輸出。
* 相較於同步的 chat() 方法,此方法提供更好的使用者互動體驗。
*
* @param userMessage 使用者輸入的訊息
* @return TokenStream 物件,可透過回調函式處理逐字輸出
*/
@SystemMessage("你是一個友善且專業的 AI 助理。請用繁體中文回應,並提供有用、準確的資訊。")
TokenStream chatInStream(String userMessage);
}
chat()
方法,確保既有程式碼不受影響@SystemMessage
,確保回應風格一致chatInStream()
明確表達這是串流版本的對話方法既然介面已經定義好了,接下來就要讓我們的配置類別支援這兩種不同的模型。這裡有個重要的觀念:我們需要同時注入 ChatLanguageModel
和 StreamingChatLanguageModel
兩個 Bean,然後透過 AiServices.builder()
的方式來建立服務。
package org.example.aiagentdemo.config;
import dev.langchain4j.model.chat.ChatLanguageModel;
import dev.langchain4j.model.chat.StreamingChatLanguageModel;
import dev.langchain4j.service.AiServices;
import org.example.aiagentdemo.service.Assistant;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* AI 服務配置類別
*
* 負責建立和配置 LangChain4j AiServices 相關的 Bean。
* 使用 AiServices.builder() 模式同時支援同步和串流兩種 AI 服務。
* Spring Boot 自動配置提供 ChatLanguageModel 和 StreamingChatLanguageModel Bean。
*/
@Configuration
public class AiServiceConfig {
/**
* 建立 Assistant Bean
*
* 使用 AiServices.builder() 方法建立 Assistant 介面的實現,
* 同時支援同步 (ChatLanguageModel) 和串流 (StreamingChatLanguageModel) 兩種 AI 服務。
* Spring Boot 自動配置提供所需的模型 Bean。
*
* @param chatLanguageModel Spring 自動配置的 ChatLanguageModel Bean
* @param streamingChatLanguageModel Spring 自動配置的 StreamingChatLanguageModel Bean
* @return Assistant 實例,支援同步和串流方法
*/
@Bean
public Assistant assistant(ChatLanguageModel chatLanguageModel,
StreamingChatLanguageModel streamingChatLanguageModel) {
return AiServices.builder(Assistant.class)
.chatLanguageModel(chatLanguageModel)
.streamingChatLanguageModel(streamingChatLanguageModel)
.build();
}
}
還記得昨天我們使用的 AiServices.create()
嗎?那是最簡潔的方式,適合單純的同步對話。但現在我們需要同時支援兩種模型,就得升級到 builder()
模式了:
昨天的簡潔版本:
return AiServices.create(Assistant.class, chatLanguageModel);
今天的進階版本:
return AiServices.builder(Assistant.class)
.chatLanguageModel(chatLanguageModel)
.streamingChatLanguageModel(streamingChatLanguageModel)
.build();
這個變化其實很有意思。隨著功能越來越豐富,簡潔的 create()
方法就顯得力不從心了。Builder 模式雖然稍微囉嗦一點,但它為未來的擴展(比如加入記憶、工具等功能)預留了空間。
這裡有個關鍵點需要特別注意!Spring Boot 雖然會自動配置 ChatLanguageModel
,但對於 StreamingChatLanguageModel
就沒那麼聰明了。如果我們不明確告訴它該怎麼建立這個 Bean,程式就會在啟動時報錯。
解決方法很簡單,在 application.properties
中為串流模型加上專用的配置:
spring.application.name=ai-agent-demo
# Ollama Configuration
langchain4j.ollama.chat-model.base-url=http://localhost:11434
langchain4j.ollama.chat-model.model-name=llama3.1:8b-instruct-q4_K_M
langchain4j.ollama.chat-model.temperature=0.7
langchain4j.ollama.chat-model.timeout=PT5M
# Ollama Streaming Configuration
langchain4j.ollama.streaming-chat-model.base-url=http://localhost:11434
langchain4j.ollama.streaming-chat-model.model-name=llama3.1:8b-instruct-q4_K_M
langchain4j.ollama.streaming-chat-model.temperature=0.7
langchain4j.ollama.streaming-chat-model.timeout=PT5M
# 日誌等級設定 - 顯示詳細的 AI 互動日誌
logging.level.org.example.aiagentdemo=INFO
logging.level.dev.langchain4j=DEBUG
在實作過程中發現了一個有趣的現象:Spring Boot 對於同步模型的自動配置非常完善,但串流模型就需要我們手動指定配置。這是因為串流功能相對新穎,框架還沒有達到完全的「零配置」程度。
另一個重要觀念是,兩個模型的參數設定必須保持一致。畢竟它們背後其實是同一個 Ollama 服務和同一個語言模型,只是互動方式不同而已。如果參數不一致,可能會導致同步和串流模式的回應風格出現差異。
現在到了最讓人期待的部分!我們要建立一個專門的執行器,展示串流功能的各種可能性。這個實作會比昨天的例子更豐富,包含三個不同的測試場景,每個都能展現 TokenStream 的不同面向。
package org.example.aiagentdemo.runner;
import org.example.aiagentdemo.service.Assistant;
import dev.langchain4j.service.TokenStream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.concurrent.CompletableFuture;
/**
* 串流 AI 服務執行器 - 展示 TokenStream 打字機效果
*
* 使用 TokenStream 提供即時的串流對話體驗,展示逐字輸出的打字機效果。
* 相較於同步的 chat() 方法,TokenStream 提供:
* - 即時回應體驗,使用者可以看到 AI 思考過程
* - onNext 回調處理每個 token 的輸出
* - onComplete 回調處理完整回應
* - onError 回調處理異常情況
*/
@Component
public class StreamingAiServiceRunner implements CommandLineRunner {
private static final Logger logger = LoggerFactory.getLogger(StreamingAiServiceRunner.class);
// 注入支援串流功能的 Assistant Bean
@Autowired
private Assistant assistant;
@Override
public void run(String... args) throws Exception {
logger.info("========== 開始串流 AI 服務測試 ==========");
// 測試 1:基本串流對話
testBasicStreaming();
// 等待一段時間再進行下一個測試
Thread.sleep(2000);
// 測試 2:打字機效果展示
testTypingEffect();
// 等待一段時間再進行下一個測試
Thread.sleep(2000);
// 測試 3:串流錯誤處理
testStreamingErrorHandling();
logger.info("========== 串流 AI 服務測試完成 ==========");
}
/**
* 測試基本串流對話功能
*/
private void testBasicStreaming() throws InterruptedException {
logger.info("\n--- 測試 1:基本串流對話 ---");
String question = "請簡單自我介紹,大約 30 字以內。";
logger.info("使用者: {}", question);
logger.info("AI 助理回應 (串流模式):");
CompletableFuture<Void> future = new CompletableFuture<>();
TokenStream tokenStream = assistant.chatInStream(question);
tokenStream
.onNext(token -> System.out.print(token))
.onComplete(response -> {
System.out.println(); // 換行
String responseText = response.content().text();
logger.info("串流完成,完整回應長度: {} 字符", responseText.length());
future.complete(null);
})
.onError(error -> {
System.err.println("\n串流發生錯誤: " + error.getMessage());
future.completeExceptionally(error);
})
.start();
// 等待串流完成
future.join();
}
/**
* 測試打字機效果 - 添加延遲模擬真實打字
*/
private void testTypingEffect() throws InterruptedException {
logger.info("\n--- 測試 2:打字機效果展示 ---");
String question = "請用繁體中文解釋什麼是 Spring Boot,限 50 字以內。";
logger.info("使用者: {}", question);
logger.info("AI 助理回應 (打字機效果):");
CompletableFuture<Void> future = new CompletableFuture<>();
TokenStream tokenStream = assistant.chatInStream(question);
tokenStream
.onNext(token -> {
try {
System.out.print(token);
System.out.flush(); // 強制刷新輸出緩衝區
// 添加小延遲模擬打字機效果
Thread.sleep(30);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
})
.onComplete(response -> {
System.out.println(); // 換行
String responseText = response.content().text();
logger.info("打字機效果完成!總共輸出: {} 字符", responseText.length());
future.complete(null);
})
.onError(error -> {
System.err.println("\n打字機效果發生錯誤: " + error.getMessage());
error.printStackTrace();
future.completeExceptionally(error);
})
.start();
// 等待打字機效果完成
future.join();
}
/**
* 測試串流錯誤處理機制
*/
private void testStreamingErrorHandling() throws InterruptedException {
logger.info("\n--- 測試 3:串流錯誤處理 ---");
String question = "請告訴我關於人工智慧的發展歷史,用簡潔的方式說明。";
logger.info("使用者: {}", question);
logger.info("AI 助理回應 (含錯誤處理):");
CompletableFuture<Void> future = new CompletableFuture<>();
try {
TokenStream tokenStream = assistant.chatInStream(question);
tokenStream
.onNext(token -> {
System.out.print(token);
System.out.flush();
})
.onComplete(response -> {
System.out.println(); // 換行
logger.info("串流成功完成,錯誤處理測試通過");
future.complete(null);
})
.onError(error -> {
System.err.println("\n捕獲到串流錯誤: " + error.getMessage());
logger.error("TokenStream 錯誤處理測試:", error);
// 即使發生錯誤也標記完成,以便程式繼續執行
future.complete(null);
})
.start();
} catch (Exception e) {
logger.error("啟動串流時發生異常:", e);
future.complete(null);
}
// 等待測試完成
future.join();
}
}
TokenStream 的設計真的很巧妙,它採用了回調函式的設計模式。這種設計在處理串流資料時特別有效,因為我們永遠不知道下一個 token 什麼時候會到達,也不知道整個串流什麼時候會結束。
TokenStream tokenStream = assistant.chatInStream(userMessage);
tokenStream
.onNext(token -> {
// 處理每個 token
System.out.print(token);
System.out.flush();
})
.onComplete(response -> {
// 處理完整回應
String fullText = response.content().text();
System.out.println("\n回應完成: " + fullText.length() + " 字符");
})
.onError(error -> {
// 處理錯誤情況
System.err.println("串流錯誤: " + error.getMessage());
})
.start(); // 啟動串流
TokenStream 有一個很重要的特性:它是非阻塞的。這意味著當我們呼叫 .start()
後,主線程會立即繼續執行,不會乾等著串流結束。這種設計對於需要處理其他任務的應用程式來說非常友善。
但有時候我們確實需要等待串流完成,比如在我們的測試程式中。這時候就可以搭配 CompletableFuture
來達到同步等待的效果:
CompletableFuture<Void> future = new CompletableFuture<>();
tokenStream
.onComplete(response -> future.complete(null))
.onError(error -> future.completeExceptionally(error))
.start();
future.join(); // 等待串流完成
要做出逼真的打字機效果,其實有幾個小技巧。最重要的是理解輸出緩衝區的概念:當我們使用 System.out.print()
時,內容不一定會立即顯示在螢幕上,可能會暫存在緩衝區中。這時候 System.out.flush()
就派上用場了,它能強制把緩衝區的內容立即輸出。
另一個關鍵是延遲時間的掌握。太快的話就失去了打字機的感覺,太慢又會讓使用者不耐煩。經過實際測試,30ms 的延遲是個不錯的平衡點,既能呈現逐字輸出的效果,又不會讓人覺得太慢。
最後要注意執行緒安全的問題,因為我們在回調函式中使用了 Thread.sleep()
,必須正確處理可能的中斷異常。
執行我們的應用程式後,真的很有成就感!看著 AI 的回應一個字一個字地出現,就像真的有人在對面打字一樣。以下是實際的執行畫面:
========== 開始串流 AI 服務測試 ==========
--- 測試 1:基本串流對話 ---
使用者: 請簡單自我介紹,大約 30 字以內。
AI 助理回應 (串流模式):您好!我是 LLaMA,中文助理,我是一個友善且專業的 AI 助手,致力於為您提供快速、準確和有用的資訊和支持。歡迎使用我的服務!
串流完成,完整回應長度: 65 字符
--- 測試 2:打字機效果展示 ---
使用者: 請用繁體中文解釋什麼是 Spring Boot,限 50 字以內。
AI 助理回應 (打字機效果):Spring Boot 是一種基於 Spring Framework 的開發框架,簡化了 Spring 架構的部署和配置過程。它提供了一組預設設定、自動化配置和嵌入式容器等功能,使得開發者可以快速、輕松地建立雲端應用程式或微服務。
打字機效果完成!總共輸出: 115 字符
--- 測試 3:串流錯誤處理 ---
使用者: 請告訴我關於人工智慧的發展歷史,用簡潔的方式說明。
AI 助理回應 (含錯誤處理):人工智慧(Artificial Intelligence, AI)的發展歷史可以追溯到20世紀50年代。在這裡,我會給你一個簡要的概述:....略
串流成功完成,錯誤處理測試通過
========== 串流 AI 服務測試完成 ==========
從測試結果中能觀察到一些很有意思的現象。首先,串流的回調函式確實是在獨立的線程中執行的,從日誌的線程名稱( main 和 lhost )就能看出來。這證實了我們之前說的非阻塞特性。
其次,每個 token 的即時顯示真的讓整個互動變得更加生動。特別是在處理較長回應時,這種差異更加明顯。使用者不再是乾等著一個完整的回應突然出現,而是能夠看到 AI 的「思考過程」。
打字機效果的 30ms 延遲經過實際測試,確實提供了很自然的視覺體驗。太快會失去效果,太慢又會讓人不耐煩,這個數值是個不錯的平衡點。
最重要的是,我們的錯誤處理機制運作良好,即使在網路不穩定或其他異常情況下,程式也能正常繼續執行。
經過這幾天的實際使用,我深刻體會到了兩種模式的差異。同步模式就像是傳統的問答方式:你問一個問題,然後乾等著完整的回答出現。如果 AI 在處理複雜問題,你可能會等待好幾秒,完全不知道它是在思考還是卡住了。
相對地,串流模式就像是真實的對話。你能看到對方一邊思考一邊回答,這種即時回饋讓整個互動變得生動許多。特別是當 AI 在生成程式碼或長篇說明時,這種差異更是明顯。你不再是坐著乾等,而是能夠跟著 AI 的「思考節奏」一起前進。
從程式碼的角度來看,兩種模式各有優缺點:
同步模式 - 簡潔明瞭:
String response = assistant.chat("問題");
System.out.println(response);
串流模式 - 功能豐富但稍複雜:
assistant.chatInStream("問題")
.onNext(token -> System.out.print(token))
.onComplete(response -> System.out.println())
.onError(error -> System.err.println("錯誤: " + error))
.start();
從程式碼行數來看,串流模式確實複雜一些,但這個複雜度帶來的是更精細的控制能力。我們可以在每個 token 到達時做任何我們想要的處理,也可以優雅地處理各種異常情況。
經過實際使用,我認為兩種模式都有其適用的場景:
有趣的是,從實際測試來看,兩種模式的總處理時間其實差不多。串流模式的優勢主要體現在使用者的感知體驗上,而不是實際的處理速度。
這讓我聯想到網頁載入的概念:同樣是載入 10 秒,如果有進度條的話使用者會覺得比較能接受,而如果是純粹的白畫面就會讓人焦慮。串流模式就是提供了這種「進度條」的效果。
在實作過程中最讓人頭痛的就是這個錯誤:
No qualifying bean of type 'StreamingChatLanguageModel' available
當時真的很困惑,明明 ChatLanguageModel
都可以自動注入,為什麼 StreamingChatLanguageModel
就不行呢?後來才發現,Spring Boot 的自動配置對於串流模型還沒有那麼智能,需要我們手動在 application.properties
中明確指定:
langchain4j.ollama.streaming-chat-model.base-url=http://localhost:11434
langchain4j.ollama.streaming-chat-model.model-name=llama3.1:8b-instruct-q4_K_M
langchain4j.ollama.streaming-chat-model.temperature=0.7
這個小細節花了我不少時間才搞懂,希望能幫大家省點時間。
另一個讓我卡住的地方是這個編譯錯誤:
cannot find symbol: method length()
我原本以為 onComplete
回調會直接給我一個字串,所以寫成了 response.length()
。後來才發現,它實際上傳入的是一個 Response<AiMessage>
物件,需要透過 .content().text()
才能取得實際的文字內容:
.onComplete(response -> {
String responseText = response.content().text();
System.out.println("長度: " + responseText.length());
})
這種小錯誤雖然編譯器會提醒,但理解背後的邏輯能讓我們寫出更正確的程式碼。
一開始實作打字機效果時,發現字符輸出得太快,完全沒有打字的感覺。經過幾次調整後發現需要注意這幾個要點:
Thread.sleep(30)
是個不錯的起點System.out.flush()
確保每個字符立即顯示這個數值真的需要實際測試才能找到最佳平衡點。
當專案中有多個 CommandLineRunner
時,它們會同時執行,導致輸出內容混在一起,很難看清楚個別的效果。解決方法很簡單,暫時把不需要的 Runner 註解掉:
//@Component // 暫時註解,專注測試串流功能
public class AiServiceRunner implements CommandLineRunner {
// ...
}
這樣就能專心觀察我們想要測試的功能了。
在設計這個功能時,我特別重視向後相容性。畢竟昨天的同步版本還是很實用的,不應該因為加入新功能而破壞既有的程式碼。所以我們採取了這樣的策略:
chat()
方法完全不變chatInStream()
提供新功能這樣的設計讓使用者可以根據實際需求選擇適合的方式,不會有被強迫升級的困擾。
從 create()
升級到 builder()
模式,其實是為未來的擴展做準備。現在我們可能只需要兩種聊天模型,但將來可能會需要加入更多功能:
return AiServices.builder(Assistant.class)
.chatLanguageModel(chatLanguageModel)
.streamingChatLanguageModel(streamingChatLanguageModel)
// 未來的可能性
.tools(customTools) // 工具呼叫
.chatMemory(chatMemory) // 對話記憶
.build();
Builder 模式的好處就是讓這種擴展變得很自然。
在錯誤處理方面,我們建立了多層的防護機制:
onError
回調處理串流過程中的問題CompletableFuture
統一處理異步操作的結果這樣的設計確保了即使在各種意外情況下,程式也能優雅地處理錯誤,不會讓使用者看到莫名其妙的當機。
回顧今天的實作過程,最重要的收穫包括:
今天最大的感想是體會到了程式設計思維的轉變。同步模式讓我們習慣了「一問一答」的簡單邏輯,但串流模式開啟了「持續對話」的可能性。
這種轉變不只是技術層面的,更是使用者體驗層面的突破。TokenStream 提供的細粒度控制讓我們能夠打造更自然、更人性化的 AI 互動體驗。我覺得這正是優秀軟體的特質:不只是功能強大,更要讓人用起來舒服。
明天我們將:
感謝大家的閱讀,我們明天見!
Day 4 完成 | 明天繼續我們的 AI 冒險之旅!