iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0

當我們為自己的 CLI 工具整合 AI 對話功能時,通常希望提供一個能持續輸入訊息、即時看到回答的互動介面。 LLM API(如 Claude 或 OpenAI)本質上是完全無狀態的(Stateless)——伺服器不會主動幫我們保留任何上下文歷史。

要讓使用者擁有流暢的連續對話體驗,CLI 工具必須處理好四項關鍵任務:

  1. 客戶端狀態掌管(Session State):後端 API 是無狀態的,由 CLI 透過記憶體 slice 掌管對話歷史與 /reset 重置。
  2. 終端機即時渲染(Streaming UI Feedback):解決終端機黑畫面等待問題,透過串流即時印出文字並同步累積資料。
  3. REPL 防禦性錯誤隔離(Error Isolation):區分輸入流結束與單輪呼叫失敗,確保單次網路或 API 錯誤不擊垮 Session。
  4. 限制 Ctrl-C 的取消範圍(Signal & Context Cancel):將 SIGINT 訊號限制在當前單輪回應,中斷輸出但不退出 CLI Process。

這種「讀取輸入、求值回應、印出結果、繼續迴圈」的互動結構,即是 Human-friendly CLI 的持續互動:REPL 模式 的真實進階應用。這篇教學的重點不在於特定 AI 廠商的 SDK 語法,而是如何掌控連續互動型 CLI(Continuous Interactive CLI)的體驗與架構設計。

完整程式位於 cli-sample/claude-chat/ 目錄:

cli-sample/claude-chat/
├── cmd/
│   ├── root.go
│   └── chat.go
├── internal/chat/
│   └── session.go
├── go.mod
├── go.sum
└── main.go

切換到專案目錄並設定 ANTHROPIC_API_KEY 環境變數後,執行 go run . chat 即可啟動對話:

cd cli-sample/claude-chat
export ANTHROPIC_API_KEY=sk-ant-...
go run . chat

啟動 chat 子命令後,終端機呈現的互動流程如下:

$ go run . chat
you› 幫我把這段 JSON 轉成 Go struct
ai › 好的,你可以這樣定義……(文字片段持續輸出)

you› 幫欄位加上 json tag
ai › (記得上一輪的 struct,直接接著修改)

you› /reset              # 清空歷史對話,重新開始
you› /exit               # 離開 REPL 迴圈

在第二輪對話中,使用者只輸入「幫欄位加上 json tag」,並沒有重新貼上 struct 程式碼。AI 之所以能接續上下文,是因為 CLI 在客戶端記憶體中幫它維護了整段對話歷史。

在客戶端維護無狀態 API 的對話歷史

Claude 的 Messages API 本身是無狀態的(Stateless),伺服器端不會保留跨請求的對話 Session。每一次發送 HTTP 請求時,都必須傳送到目前為止的完整對話歷史。

因此,對話記憶在 CLI 端必須由客戶端自行掌管。我們在記憶體中維護一個訊息 slice,每一輪對話都依序追加:

type Session struct {
	client   anthropic.Client
	messages []anthropic.MessageParam
}

func NewSession(client anthropic.Client) *Session {
	return &Session{client: client}
}

func (s *Session) Ask(ctx context.Context, userInput string) error {
	// 1. 寫入使用者輸入
	s.messages = append(s.messages,
		anthropic.NewUserMessage(anthropic.NewTextBlock(userInput)))

	// 2. 傳送完整歷史並串流輸出
	reply, err := s.stream(ctx)
	if err != nil {
		// 若 API 尚未輸出任何內容就出錯,撤回剛加入的 user message
		if len(reply.Content) == 0 {
			s.messages = s.messages[:len(s.messages)-1]
			return err
		}
		// 串流中途停止時,保留已輸出給使用者看到的半句內容
		s.messages = append(s.messages, reply.ToParam())
		return err
	}

	// 3. 把完整回覆補回歷史,供下一輪使用
	s.messages = append(s.messages, reply.ToParam())
	return nil
}

func (s *Session) Reset() {
	s.messages = nil
}

這個 Session 運作遵循三步節奏:寫入使用者訊息 → 傳送整段歷史 → 補回 AI 回覆。如果缺少第三步,下一輪傳送給 API 的歷史就會遺失 AI 剛才產生的回應。

當使用者輸入 /reset 命令時,Reset() 方法將 s.messages 設為 nil,即可在不安裝或更改任何伺服器端狀態的前提下清空對話。

串流處理

若採用非串流的 HTTP 呼叫,在模型產生回應期間終端機畫面會完全靜止,使用者無法確定程式是否正常運作。串流模式會在模型產生文字片段時立刻輸出,大幅提升回應體感速度。

在接收串流事件時,CLI 需要同時完成兩件事:即時印出文字片段,並同步累積成完整訊息存回歷史紀錄。

func (s *Session) stream(ctx context.Context) (anthropic.Message, error) {
	stream := s.client.Messages.NewStreaming(ctx, anthropic.MessageNewParams{
		Model:     anthropic.ModelClaudeSonnet5,
		MaxTokens: 4096,
		Messages:  s.messages,
	})

	reply := anthropic.Message{}
	for stream.Next() {
		event := stream.Current()

		// 同步累積成完整訊息,供後續轉存至對話歷史
		reply.Accumulate(event)

		// 即時印出收到的文字片段
		if delta, ok := event.AsAny().(anthropic.ContentBlockDeltaEvent); ok {
			if text, ok := delta.Delta.AsAny().(anthropic.TextDelta); ok {
				fmt.Print(text.Text)
			}
		}
	}
	fmt.Println()
	return reply, stream.Err()
}

reply.Accumulate(event) 與 fmt.Print(...) 在同一條迴圈中處理:累積後的完整訊息會存回歷史 slice,文字片段則立即顯示給使用者。

區分 Process 終止與單輪錯誤的防禦性迴圈

在單次命令中,只要執行過程拋出錯誤,程式通常會印出訊息並直接回傳 err 結束 Process。但在 REPL 模式中,如果單次網路抖動或 API 錯誤就導致整個程式崩潰,使用者累積的對話歷史也會一併遺失。

因此,在 REPL 主迴圈中需要建立兩層劃分明確的錯誤防線:

  1. Process 層級的界線(Process Level):只有在標準輸入關閉(如使用者按下 Ctrl-D 觸發 EOF)或輸入流發生不可逆失敗時,才允許 return 結束 CLI Process。
  2. 輪次級界線(Turn Level):單一輪次的 API 呼叫失敗(如網路連線逾時、伺服器錯誤),只在終端機印出錯誤提示,絕不能 return。回應結束後讓迴圈進入下一次迭代,等待使用者輸入下一句重試。

這兩層界線在 REPL 主迴圈中的完整實作如下:

func runChat(ctx context.Context, sess *chat.Session, in io.Reader, out io.Writer) error {
	scanner := bufio.NewScanner(in)
	for {
		fmt.Fprint(out, "you› ")

		// 進程級界線:標準輸入結束(EOF / Ctrl-D)或讀取失敗時才 return 退出進程
		if !scanner.Scan() {
			return scanner.Err()
		}

		line := strings.TrimSpace(scanner.Text())
		switch line {
		case "":
			continue
		case "/exit", "/quit":
			return nil
		case "/reset":
			sess.Reset()
			fmt.Fprintln(out, "(已清空對話)")
			continue
		}

		fmt.Fprint(out, "ai › ")

		// 輪次級界線:執行單輪對話
		err := sess.Ask(ctx, line)
		if err != nil {
			// 發生網路或 API 錯誤時僅印出提示,不 return,繼續下一次迴圈
			fmt.Fprintln(out, "錯誤:", err)
		}
	}
}

明確拆開這兩層界線後,單輪呼叫失敗只會影響當前回應,CLI Process 本身依然穩定運行,Session 歷史也不會受影響。

將 SIGINT 訊號限制在當前單輪 Context

在一般終端機程式中,使用者按下 Ctrl-C 時,作業系統會發送 SIGINT 訊號,預設會直接終止整個程式 Process。

但在 Chat CLI 中,當 AI 的長回應輸出到一半時,使用者按下 Ctrl-C 通常只是想中斷當前這輪輸出並接著輸入下一句,而不是想直接關閉 CLI 工具。

為了達到這個效果,我們必須限制 Ctrl-C 訊號的作用範圍:不讓它終止整個 CLI Process,而是將它轉化為取消單輪 Context 的訊號。詳細的訊號與取消設計原則可參考 長時間 CLI 命令的進度、逾時與取消設計。

我們為每一輪對話獨立建立可取消的 turnCtx:

turnCtx, cancel := context.WithCancel(ctx)
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
go func() {
	select {
	case <-sigCh:
		cancel() // 攔截到 Ctrl-C 時僅取消當前的 turnCtx
	case <-turnCtx.Done():
		// 當前輪次正常結束,背景 goroutine 自動退出
	}
}()

err := sess.Ask(turnCtx, line)
signal.Stop(sigCh)
cancel()

if errors.Is(err, context.Canceled) {
	fmt.Fprintln(out) // 使用者按下 Ctrl-C 時僅換行,回到下一次 prompt
	continue
}
if err != nil {
	fmt.Fprintln(out, "錯誤:", err) // 真正的 API 或網路錯誤才輸出錯誤訊息
}

背景 Goroutine 同時監聽 sigCh 與 turnCtx.Done()。當這輪對話正常完成時,cancel() 會關閉 turnCtx.Done(),使背景 Goroutine 自然退出,不會造成每一輪殘留未結束的 Goroutine。

當使用者按下 Ctrl-C 時,turnCtx 被取消,底層 API 串流停止,sess.Ask 捕獲 context.Canceled 錯誤。主迴圈經由 errors.Is(err, context.Canceled) 檢查後在終端機換行並執行 continue,順暢回到下一次輸入提示。

將 Session 注入 Cobra 子命令

API 憑證應避免寫死在程式碼中或以命令列 Flag 傳遞。SDK 會自動讀取 ANTHROPIC_API_KEY 環境變數。

在 cmd/chat.go 中,我們建立 chat 子命令並將 Session 注入核心邏輯:

func newChatCmd() *cobra.Command {
	return &cobra.Command{
		Use:   "chat",
		Short: "開啟 Claude 對話",
		Args:  cobra.NoArgs,
		RunE: func(cmd *cobra.Command, args []string) error {
			sess := chat.NewSession(anthropic.NewClient())
			return runChat(
				cmd.Context(),
				sess,
				cmd.InOrStdin(),
				cmd.OutOrStdout(),
			)
		},
	}
}

最後在 cmd/root.go 中透過 rootCmd.AddCommand(newChatCmd()) 註冊,即可透過 go run . chat 啟動終端機串流對話工具。

Go 知識補充:Slice 動態裁切、bufio.Scanner、Event 型別斷言與方法接收者

如果對本篇範例中出現的 Go 語法不熟悉,以下為相關特性的補充說明:

1. Slice 的動態追加與切片裁切(Reslicing)

Go 的 Slice 是底層陣列的視窗。呼叫 append 會在尾端追加元素;使用 slice[:len-1] 語法可以迅速裁切掉最後一個元素。這個特性非常適合用來實作對話歷史撤回:

// 追加新的使用者訊息
s.messages = append(s.messages, userMsg)

// 若單輪發送失敗,透過裁切切除剛才追加的最後一筆訊息,還原對話歷史
s.messages = s.messages[:len(s.messages)-1]

2. 方法接收者(Method Receiver):指標與值接收者

在 Go 語言中,結構體的方法可以宣告為指標接收者 (s *Session) 或值接收者 (s Session)。若方法需要修改結構體內部的欄位狀態(如向 messages 切片追加內容),必須使用指標接收者:

// 使用指標接收者 (s *Session),對 s.messages 的修改才會反映至呼叫端
func (s *Session) Ask(ctx context.Context, userInput string) error {
	s.messages = append(s.messages, userMsg)
	return nil
}

若誤用值接收者 (s Session),Go 在呼叫該方法時會複製一份完整的 Session 副本,方法內對 s.messages 的修改會在函式結束時隨副本一同丟棄。

3. bufio.Scanner 與逐行串流讀取

bufio.Scanner 是處理 io.Reader 逐行輸入的利器。每次呼叫 scanner.Scan() 都會讀取下一行並回傳 bool。當到達 EOF(如按下 Ctrl-D)或發生讀取錯誤時 Scan() 會回傳 false:

scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
	line := scanner.Text() // 取得當前行的字串內容(不含換行符)
	if line == "/exit" {
		break
	}
}
if err := scanner.Err(); err != nil {
	// 處理真正的輸入讀取錯誤
}

4. 介面型別斷言(Type Assertion)與串流 Event 轉型

在處理 SDK 的動態 Event 串流時,事件物件通常以通用介面(如 any)傳遞。透過 .(TargetType) 語法搭配 ok 檢查,能精準提取特定的 Event 結構體並取得文字片段:

// 將通用介面安全的轉型為具體的 ContentBlockDeltaEvent 結構體
if delta, ok := event.AsAny().(anthropic.ContentBlockDeltaEvent); ok {
	if text, ok := delta.Delta.AsAny().(anthropic.TextDelta); ok {
		fmt.Print(text.Text)
	}
}

上一篇
在 CLI 實作 OAuth 2.0 授權登入
下一篇
CLI 編輯器的鍵盤輸入:Raw Mode 與按鍵解析
系列文
30 天學會做一個 CLI:打造人類與 AI 都友善的現代 CLI 應用 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言