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。模型看得到的只有 Name、Description 和參數的 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 的三條規則
- 每個
tool_use都要有對應的tool_result,用tool_use_id配對。少一個,下一次請求整個被 API 退回 400。 - 模型可能一次呼叫多個工具(同一則回應裡有多個
tool_use區塊)。所有結果要收齊後放在同一則 user message 裡送回去,不要拆成多則。 - 失敗也要回報,用
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.Input 是 json.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,模型才不會亂用。
留言