一般的 CLI 命令屬於一次性執行:當你在終端機輸入 ls 或 git status 時,作業系統會建立一個獨立的 Process,程式執行完畢就立刻結束 Process,將控制權交還給 Shell 提示符($)。這代表每次執行命令都是全新的起點,前一次執行的記憶體變數、TCP 連線或認證狀態會在 Process 銷毀時一併釋放。
REPL(Read-Eval-Print Loop,讀取-求值-輸出迴圈)則是將控制權留在同一個長命 Process 中。程式啟動後會維持運行並進入內部迴圈,印出專屬的提示符(例如 mysql> 或 claude>),等待使用者連續輸入命令。因為 Process 從未終止,前一次操作建立的資料庫連線、記憶體變數或對話上下文(Context),都能直接供下一次操作繼續使用。
舉 mysql 或 AI CLI(如 claude code)為例。執行 mysql -u root -p 就進入資料庫 Shell,執行 claude 則進入 AI 對話會話;提示符會一直等你一句接一句輸入,離開時輸入 exit 或按 Ctrl-D。這樣的好處是狀態在多次輸入之間延續:連線或上下文載入只做一次,後面每一句查詢或提問都直接沿用,不必每次重新連線或重新提供背景資訊。
claude 的 AI 對話與上下文會話:
$ claude
Welcome to Claude Code!
> 幫我檢查 main.go
已分析 main.go (45 行),發現第 12 行的 HTTP handler 缺少錯誤處理。
> 幫我修正它
已更新 main.go 的第 12 行。上一句讀取的檔案結構與對話上下文均直接保留,不必重新提供檔案背景。
> exit
Bye
$
REPL 的名字本身就是它的執行架構:
對應到 claude CLI:Read 是讀取你輸入的對話或命令,Eval 是交給模型求值與執行工具,Print 是展示回答與輸出結果,Loop 則是回到提示符等待下一句。
為了理解 REPL 在程式內部的運作機制,我們用 Go 實作一個最小的 Key-Value Store(鍵值儲存庫)。這個工具只提供兩個命令:set <key> <value> 負責存入變數,get <key> 負責讀取變數;輸入 exit 或按 Ctrl-D 則結束會話。
這個範例的重點在於記憶體狀態的持續性:我們會在迴圈外部宣告一個 map[string]string 變數用來記錄資料。因為無限迴圈持續運行,每一次輸入 set 寫入的鍵值對都會保留在同一個 Map 中,隨後輸入 get 便能立即讀出前一次操作留下的結果。
範例程式碼位於 cli-sample/repl-demo/,執行 go run . mini 即可啟動互動會話。
實際執行的互動過程如下:
$ go run . mini # 啟動並印出 kv> 提示符等待輸入
kv> set x 10 # 寫入鍵值對 x=10
OK # 印出執行結果,並回到 kv> 等待下一句
kv> get x # 讀取 x 的值;上一句 set 的 x 依然存在,因為行程與迴圈未結束
10
程式碼的實作核心是一個無限 for 迴圈搭配 bufio.Scanner。bufio.Scanner 負責從 os.Stdin(標準輸入)讀取內容,每呼叫一次 Scan() 便會暫停 Process 直到使用者按下 Enter。「停在那裡等待 Enter、按 Enter 拿回一整行」正好就是 REPL 的 Read 階段。讀取到輸入後,再透過 switch 語句解析命令並操作迴圈外的 Map 狀態:
vars := map[string]string{} // 存 set 進來的變數名和值,宣告在迴圈外,每一輪讀寫的都是同一份
scanner := bufio.NewScanner(os.Stdin)
for { // Loop:一句處理完回到這裡,等下一句
fmt.Print("kv> ")
if !scanner.Scan() { // Read:讀一行輸入;Ctrl-D(EOF)會回傳 false,結束迴圈
return
}
fields := strings.Fields(scanner.Text()) // 把 "set x 10" 拆成 ["set", "x", "10"]
switch { // Eval:判斷命令、操作 vars;Print:把結果或錯誤印出來
case len(fields) == 0:
continue
case fields[0] == "exit":
return
case fields[0] == "set" && len(fields) == 3:
vars[fields[1]] = fields[2]
fmt.Println("OK")
case fields[0] == "get" && len(fields) == 2:
if v, ok := vars[fields[1]]; ok {
fmt.Println(v)
} else {
fmt.Println("(沒有這個變數)")
}
default:
fmt.Println("不懂這個命令:", strings.Join(fields, " "))
}
}
當你在終端機操作這段程式時,很快會遇到一個真實的使用習慣:打字打到一半想放棄當前輸入,或者習慣性地按下 Ctrl-C。在命令中按下 Ctrl-C 原是為了強制終止 Process;但在 REPL 中,如果按下 Ctrl-C 導致程式直接結束,前面好不容易累積存入 Map 的狀態就會全部遺失。
按下 Ctrl-C 時,終端機會將其轉換為作業系統的 SIGINT(中斷訊號)送給 Process。Go 的預設處理機制是收到 SIGINT 就終止 Process,這會直接摧毀整個 REPL 會話。而良好的 REPL 體驗應該是:僅取消當前正在輸入的這句命令,並讓主迴圈繼續運行等待下一句輸入。
要讓 Ctrl-C 只取消當前輸入而不終止 REPL,不需要接管終端機的 Raw Mode,使用標準庫的 signal.Notify 攔截 SIGINT 即可:
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt) // 註冊之後 SIGINT 改送進這個 channel,不再觸發 Go 的預設終止行為
go func() {
for range sigCh {
fmt.Print("\n(Ctrl-C:這句不算,繼續等下一句;要離開請用 exit 或 Ctrl-D)\nkv> ")
}
}()
這裡之所以能正常運作,關鍵在於作業系統與終端機驅動程式(TTY Driver)的分工:
Ctrl-C 不會混入文字緩衝區:在終端機預設的規範模式(Canonical Mode)下,按下 Ctrl-C 時,作業系統驅動程式在最底層就會將其攔下並轉換成 SIGINT 訊號,完全不會把 Ctrl-C 這個字元寫入標準輸入(stdin)緩衝區。因此,主迴圈的 scanner.Scan() 根本讀不到任何殘留字元。scanner.Scan() 等待 Enter 的主迴圈是平行運行的。收到 Ctrl-C 訊號時,Goroutine 只是在終端機畫面上換行印出提示並重新補上 kv> 提示符,而 scanner.Scan() 依然安穩地在背景等待使用者按下 Enter。kv> set x 10
OK
kv> ^C
(Ctrl-C:這句不算,繼續等下一句;要離開請用 exit 或 Ctrl-D)
kv> get x
10
適合 REPL:
不適合 REPL(用一次性命令就好):
REPL 裡的所有輸入都在同一個 Process 中執行。如果某句命令因為非法輸入或程式 Bug 導致崩潰(panic),如果沒有在 Eval 階段抓住錯誤,整個 REPL 就會立刻退出,先前存在記憶體(Cache / 狀態)裡的資料也會全部消失。
要避免單一命令把整個 REPL 搞壞,必須在 Eval 階段攔截錯誤:即使這句命令執行失敗,也只印出錯誤提示,並繼續留在迴圈中等待下一句輸入。
在 Go 中,做法是在執行命令的邏輯外面包一層 recover(),攔截 panic 並印出錯誤訊息,讓外層的主迴圈能夠安全地進入下一輪:
// 在迴圈內部用匿名函式包住 Eval 階段
func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("命令執行崩潰,已攔截錯誤:", r)
}
}()
// 實際執行 Eval 邏輯;若發生 panic,只會跳出此匿名函式,不會炸毀外層 for 迴圈
executeCommand(fields)
}()
如果在閱讀前面程式碼時對某些 Go 語法感到陌生,以下為本篇出現的核心機制說明:
Goroutine 是 Go 語言的核心特色之一,屬於由 Go Runtime(執行期環境)管轄的輕量級執行緒(Lightweight Thread):
go 關鍵字,Go 就會在背景獨立執行這個任務,完全不會卡住當前的程式主流程。使用語法範例:
// 1. 啟動具名函式作為 Goroutine
go processTask("task-1")
// 2. 使用匿名函式(Anonymous Function)啟動 Goroutine
go func() {
fmt.Println("在背景獨立執行的 Goroutine")
}()