AI Agent - 基本迴圈:LLM + Tool Use 從零寫一次

基本迴圈:從零寫一次

上一篇說 Agent 的核心就是一個 for 迴圈。這篇把它真的寫出來。

目標是一個會自己查問題的 Agent:你跟它說「/var 快滿了,幫我找出是誰吃掉的」,它會自己跑 df 看全局、自己一層一層 du 往下追,最後給你結論。整個程式一百行出頭。

版本聲明

  • Go 1.24
  • anthropic-sdk-go v1.61.0
  • 模型:claude-opus-4-8
  • 寫於 2026 年 7 月,模型與 SDK 迭代很快,之後不能動請留言跟我說

安裝:

go get github.com/anthropics/anthropic-sdk-go

API key 設在環境變數 ANTHROPIC_API_KEY,SDK 會自己讀。

第一步:定義工具

給模型兩個工具:一個看整體磁碟用量、一個列出目錄下最肥的東西。

tools := []anthropic.ToolUnionParam{
	{OfTool: &anthropic.ToolParam{
		Name:        "disk_usage",
		Description: anthropic.String("列出所有掛載點的磁碟使用狀況(df -h)"),
		InputSchema: anthropic.ToolInputSchemaParam{Properties: map[string]any{}},
	}},
	{OfTool: &anthropic.ToolParam{
		Name:        "largest_entries",
		Description: anthropic.String("列出指定目錄下佔用空間最大的子目錄,依大小遞減排序。用於逐層追查磁碟空間被誰吃掉。"),
		InputSchema: anthropic.ToolInputSchemaParam{
			Properties: map[string]any{
				"path": map[string]any{"type": "string", "description": "要分析的目錄絕對路徑"},
				"top":  map[string]any{"type": "integer", "description": "回傳前幾名,預設 10"},
			},
			Required: []string{"path"},
		},
	}},
}

幾件事值得注意:

工具定義就是一份 JSON Schema。模型看得到的只有 NameDescription 和參數的 schema,看不到你的實作。所以 Description 不是註解,是給模型的使用說明書——largest_entries 的描述我特地寫了「用於逐層追查」,模型讀到這句才知道可以遞迴地往下鑽。

Required 沒列的參數就是選填。模型漏給的時候你要自己補預設值,等下實作會看到。

第二步:實作工具

模型只負責「決定呼叫什麼」,真正動手的是你的程式:

func execute(name string, input json.RawMessage) (string, bool) {
	switch name {
	case "disk_usage":
		out, err := runCmd("df -h")
		if err != nil {
			return out + err.Error(), true // 第二個回傳值:是不是錯誤
		}
		return out, false

	case "largest_entries":
		var in struct {
			Path string `json:"path"`
			Top  int    `json:"top"`
		}
		if err := json.Unmarshal(input, &in); err != nil {
			return "參數解析失敗: " + err.Error(), true
		}
		if in.Top == 0 {
			in.Top = 10 // 選填參數自己補預設值
		}
		out, err := runCmd(fmt.Sprintf(
			"du -h -d 1 %q 2>/dev/null | sort -rh | head -n %d", in.Path, in.Top))
		if err != nil && out == "" {
			return err.Error(), true
		}
		return out, false
	}
	return "未知的工具: " + name, true
}

重點是錯誤處理的方式:工具失敗不要 panic、也不要把錯誤吞掉,而是把錯誤訊息當成結果回傳,並標記 is_error。模型收到錯誤訊息後會自己調整——換個參數重試、或改走別條路。這是 Agent 跟一般程式最不一樣的地方:錯誤是對話的一部分。

第三步:迴圈本體

messages := []anthropic.MessageParam{
	anthropic.NewUserMessage(anthropic.NewTextBlock(
		"/var 的空間快滿了,幫我找出是誰吃掉的,最後給我前三大兇手和建議")),
}

ctx := context.Background()
for i := 0; i < 10; i++ { // 迴圈上限,避免燒錢的保險絲
	resp, err := client.Messages.New(ctx, anthropic.MessageNewParams{
		Model:     anthropic.ModelClaudeOpus4_8,
		MaxTokens: 16000,
		Messages:  messages,
		Tools:     tools,
	})
	if err != nil {
		log.Fatal(err)
	}

	// 關鍵一:先把 assistant 的回應「原封不動」放回對話歷史
	messages = append(messages, resp.ToParam())

	toolResults := []anthropic.ContentBlockParamUnion{}
	for _, block := range resp.Content {
		switch v := block.AsAny().(type) {
		case anthropic.TextBlock:
			fmt.Println(v.Text)
		case anthropic.ToolUseBlock:
			fmt.Printf("→ %s(%s)\n", v.Name, v.JSON.Input.Raw())
			result, isErr := execute(v.Name, json.RawMessage(v.JSON.Input.Raw()))
			toolResults = append(toolResults, anthropic.NewToolResultBlock(v.ID, result, isErr))
		}
	}

	if resp.StopReason != anthropic.StopReasonToolUse {
		return // end_turn:模型說回答完了
	}

	// 關鍵二:所有 tool_result 放在「同一則」user message 裡送回去
	messages = append(messages, anthropic.NewUserMessage(toolResults...))
}
log.Fatal("超過迴圈上限,中止")

三段拼起來就是完整程式,可以直接編譯執行。

迴圈裡的規則

跑得起來之後,來講幾條不遵守就會壞掉的規則。

stop_reason 決定迴圈的去留

API 每次回應都帶一個 stop_reason,告訴你模型為什麼停下來:

stop_reason意思你該做什麼
tool_use模型要呼叫工具執行工具,把結果送回去,繼續迴圈
end_turn回答完成跳出迴圈
max_tokens撞到輸出上限回答被截斷了,調高 MaxTokens
refusal模型拒絕回答內容觸發安全機制,不要原樣重試

迴圈的終止條件就是 StopReason != tool_use。用「有沒有文字」來判斷是常見的錯誤寫法,因為模型呼叫工具前常常也會說一段話。

tool_result 的三條規則

  1. 每個 tool_use 都要有對應的 tool_result,用 tool_use_id 配對。少一個,下一次請求整個被 API 退回 400。
  2. 模型可能一次呼叫多個工具(同一則回應裡有多個 tool_use 區塊)。所有結果要收齊後放在同一則 user message 裡送回去,不要拆成多則。
  3. 失敗也要回報,用 is_error: true 標記。不回報就是規則一違規,吞掉錯誤只回成功的部分是最難查的 bug。

保險絲

for i := 0; i < 10 不是隨便寫的。模型偶爾會鬼打牆——反覆呼叫同一個工具、或在兩個工具之間跳來跳去。沒有上限的迴圈燒的是真金白銀的 token。上限要設多少看任務複雜度,我的習慣是預期步數的兩到三倍。

我踩過的坑

忘記把 assistant 回應放回歷史。 只把 tool_result 送回去、漏掉模型那則帶 tool_use 的回應,API 會回 400,訊息是 unexpected tool_use_id。對話歷史必須是完整的:user → assistant(tool_use) → user(tool_result) → ⋯。SDK 的 resp.ToParam() 就是做這件事的,一行解決。

v.Input 直接解析。 Go SDK 的 ToolUseBlock.Inputjson.RawMessage,但正確拿到原始 JSON 字串的方式是 v.JSON.Input.Raw()。直接對 v.Input 做 unmarshal 在某些巢狀結構下會拿到二次編碼的字串。

Description 寫太省。 一開始 largest_entries 的描述只寫「列出目錄大小」,模型跑完一層就停了。加上「用於逐層追查磁碟空間被誰吃掉」之後,它才會自己遞迴往下鑽。工具描述是 prompt 的一部分,值得花力氣寫。

MaxTokens 設太小。 設 1024 的話,模型分析到一半就被截斷(stop_reason: max_tokens),迴圈邏輯又把它當成回答完成。非串流模式我現在都直接給 16000。

SDK 其實有內建迴圈

寫到這裡要老實說:anthropic-sdk-go 有一個 BetaToolRunner,上面這整個迴圈它都幫你包好了,工具的 schema 還能從 struct tag 自動生成。實務上你大概會直接用它。

那為什麼還要手寫一次?因為 runner 出問題的時候(卡住、行為不如預期、要在迴圈中間插邏輯),你得知道它肚子裡就是這一百行。之後幾篇講錯誤處理和 context 管理時,也都會回到這個手寫迴圈來改。

下一篇來講工具設計:怎麼定義 tool,模型才不會亂用。

留言