练习 5:第一个工具——agent loop 完整闭环
前四章的一切都是铺垫:你会发请求了、会接流式输出了、会维护历史了、见过两种协议了。 但到目前为止,模型只是在说话。它知道的比你少(练习 1 它连 harness 是什么都说错), 它碰不到你的文件、你的终端、你的世界。
这一章,模型第一次伸手。你给它一只手——read_file——然后看着它自己决定
什么时候用、怎么用、失败了怎么办。这个循环就是 agent loop,全书的心脏。
敲完这一章,你写的东西第一次配得上"agent"这个词。
敲进去
新建目录 ex05,go mod init ex05,新建 main.go。
回到主线 OpenAI 协议,非流式(理由和练习 4 一样:让新东西裸露):
// Learn Agent the Hard Way — 练习 5:第一个工具
//
// 前四章的一切都是铺垫。这一章,模型第一次碰到你的世界:
// 声明工具 → 模型请求调用 → 你执行 → 结果回填 → 再问模型。
// 这个循环就是 agent loop——全书的心脏,今天完整闭环。
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
// tool 是发给模型的工具声明。Parameters 是一份 JSON Schema——
// 模型看到的全部就是这些字段:叫什么、干什么、怎么传参。
type tool struct {
Type string `json:"type"` // 固定 "function"
Function toolSpec `json:"function"`
}
type toolSpec struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
}
// message 长出了两个新字段。它现在能表达三件事:
// 人说的话(Role=user)、模型的回话或调用请求(Role=assistant,可能带 ToolCalls)、
// 工具的汇报(Role=tool,带 ToolCallID)——第四种也是最后一种 role,到齐了。
type message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []toolCall `json:"tool_calls,omitempty"` // assistant 请求调用工具时非空
ToolCallID string `json:"tool_call_id,omitempty"` // role=tool 时必填:这是对哪次调用的答复
}
// toolCall 是模型发起的一次工具调用。注意 Arguments 是 JSON **字符串**,不是对象——
// 模型逐字生成它,协议原样转交,解析是你的事。
type toolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
type request struct {
Model string `json:"model"`
Messages []message `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Tools []tool `json:"tools,omitempty"`
}
type response struct {
Choices []struct {
Message message `json:"message"`
FinishReason string `json:"finish_reason"` // 新值登场:"tool_calls"
} `json:"choices"`
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
} `json:"usage"`
Error *struct {
Message string `json:"message"`
Type string `json:"type"`
} `json:"error"`
}
// tools 声明我们的第一个工具:read_file。
var tools = []tool{{
Type: "function",
Function: toolSpec{
Name: "read_file",
Description: "读取一个本地文件,返回它的文本内容。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "要读取的文件路径(相对或绝对)",
},
},
"required": []string{"path"},
},
},
}}
// execute 按名字分发工具调用。未知工具返回干净的错误文本而不是崩溃——
// 错误也是回填给模型的合法结果,它看得懂,还会自己想办法。
func execute(name, args string) string {
switch name {
case "read_file":
return readFile(args)
default:
return "错误: 未知工具 " + name
}
}
func readFile(args string) string {
var in struct {
Path string `json:"path"`
}
if err := json.Unmarshal([]byte(args), &in); err != nil {
return "错误: 参数不是合法 JSON: " + err.Error()
}
data, err := os.ReadFile(in.Path)
if err != nil {
// 不要 panic,不要 os.Exit——把失败告诉模型,它会调整。
return "错误: " + err.Error()
}
return string(data)
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, `用法: ./ex05 "你的任务"`)
os.Exit(1)
}
apiKey := os.Getenv("OPENAI_API_KEY")
model := os.Getenv("MODEL")
if apiKey == "" || model == "" {
fmt.Fprintln(os.Stderr, "需要环境变量 OPENAI_API_KEY 和 MODEL")
fmt.Fprintln(os.Stderr, `例: export OPENAI_API_KEY=sk-xxxx`)
fmt.Fprintln(os.Stderr, ` export MODEL=deepseek-v4-flash`)
fmt.Fprintln(os.Stderr, ` export OPENAI_BASE_URL=https://api.deepseek.com/v1 # 不设则默认 OpenAI 官方`)
os.Exit(1)
}
base := os.Getenv("OPENAI_BASE_URL")
if base == "" {
base = "https://api.openai.com/v1"
}
history := []message{{Role: "user", Content: os.Args[1]}}
// agent loop。注意它和练习 3 的 REPL 是同一个循环,
// 只是对话的另一方从"人"换成了"工具"。
const maxRounds = 10 // 保险丝:防模型在工具里打转,烧光你的钱包
for round := 1; round <= maxRounds; round++ {
r, err := send(base, apiKey, model, history)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
msg := r.Choices[0].Message
// 模型的回复原样塞回历史——包括 tool_calls。
// 少了它,下一轮模型看不到自己发起过调用,协议直接报错。
history = append(history, msg)
// 练习 1 的纪律在这里派上大用场:循环走哪条路,看 finish_reason。
if r.Choices[0].FinishReason != "tool_calls" {
fmt.Println(msg.Content)
fmt.Fprintf(os.Stderr, "\n[共 %d 轮 · 最后一轮输入 %d tokens · finish_reason=%s]\n",
round, r.Usage.PromptTokens, r.Choices[0].FinishReason)
return
}
// 模型要调工具。逐个执行,每个调用回填一条 role:"tool" 消息。
for _, tc := range msg.ToolCalls {
fmt.Fprintf(os.Stderr, "[round %d] %s(%s)\n", round, tc.Function.Name, tc.Function.Arguments)
result := execute(tc.Function.Name, tc.Function.Arguments)
history = append(history, message{
Role: "tool",
ToolCallID: tc.ID, // 一次调用一张回执,靠这个 ID 对上号
Content: result,
})
}
}
fmt.Fprintf(os.Stderr, "达到 %d 轮上限,停止。\n", maxRounds)
os.Exit(1)
}
// send 就是练习 1 的非流式请求,多带一个 tools 字段。
func send(base, apiKey, model string, history []message) (response, error) {
var r response
body, _ := json.Marshal(request{
Model: model,
MaxTokens: 4096,
Messages: history,
Tools: tools,
})
req, err := http.NewRequest("POST", base+"/chat/completions", bytes.NewReader(body))
if err != nil {
return r, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return r, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return r, fmt.Errorf("HTTP %d: %s", resp.StatusCode, raw)
}
if err := json.Unmarshal(raw, &r); err != nil {
return r, fmt.Errorf("解析失败: %w\n原始响应: %s", err, raw)
}
if r.Error != nil {
return r, fmt.Errorf("API 错误 [%s]: %s", r.Error.Type, r.Error.Message)
}
if len(r.Choices) == 0 {
return r, fmt.Errorf("空响应: %s", raw)
}
return r, nil
}
先别问为什么。敲完,跑起来,我们再回头讲。
跑起来
环境变量照旧。在 ex05 目录里跑——问它一个只有读了文件才答得出的问题,
但别提"读",也别提"文件":
go build -o ex05 . && ./ex05 "告诉我当前目录的 Go 模块名和 Go 版本"
你应该看到什么
[round 1] read_file({"path": "go.mod"})
当前目录的 Go 模块信息如下:
- **模块名(module)**:`learnharness/ex05`
- **Go 版本(go)**:`1.26.3`
[共 2 轮 · 最后一轮输入 478 tokens · finish_reason=stop]
停一秒,想想刚才发生了什么。任务里没有"读"字,没有"文件"两个字, 更没有 go.mod——是它自己知道答案写在 go.mod 里,自己决定去读的。 你只是声明了这个工具的存在,它看了一眼任务,判断用得上, 生成了参数,拿到结果,组织成了回答。两轮,全自动。
现在做第二个实验,让它读一个不存在的文件:
./ex05 "读一下 notes.txt 的内容并总结"
这是我用 DeepSeek 跑出来的真实过程:
[round 1] read_file({"path": "notes.txt"})
[round 2] read_file({"path": "./notes.txt"})
[round 2] read_file({"path": "/notes.txt"})
[round 2] read_file({"path": "/tmp/notes.txt"})
很抱歉,我没能找到 notes.txt 这个文件。
我尝试了几个常见路径,都返回了「文件不存在」的错误:
- notes.txt(当前目录)
- ./notes.txt
- /notes.txt
- /tmp/notes.txt
我目前只有读取文件的能力,无法浏览目录来搜索文件位置。
第一轮失败后,它自己换了三个路径重试——第二轮一口气发了三个调用。 全部失败后,它如实汇报,然后说了一句值得裱起来的话: "我目前只有读取文件的能力,无法浏览目录来搜索文件位置。"
模型自己说出了它的能力边界。而这个边界,是你划的——你只给了它一只手。
发生了什么
工具是声明出来的。 tools 字段里没有一行实现代码,只有名字、描述、
和一份 JSON Schema。模型看到的全部就是这些——所以 description 不是注释,
是你写给模型的说明书,它读得比人类用户认真得多。这是全书反复要讲的
第一课:设计 agent 就是设计工具,而设计工具从写这份说明书开始。
循环走哪条路,还是看 finish_reason。 模型想动手,就返回 finish_reason: "tool_calls",回复的 message 里躺着 tool_calls 数组。练习 1 让你从第一天
盯着的那个字段,今天成了 agent loop 的分支条件:stop 走人,tool_calls 干活。
第四种 role 到齐了。 tool 是工具的汇报,一次调用一张回执,
靠 tool_call_id 对上号。注意顺序契约:带 tool_calls 的 assistant 消息
必须先塞回历史,后面紧跟每个调用的 tool 消息——丢了前者,服务端直接拒收:
Messages with role 'tool' must be a response to a preceding
message with 'tool_calls'
错误不是异常,是养料。 readFile 失败时没有 panic,没有 exit,
只是把"错误: 文件不存在"当成正常结果回填。然后你就看到了:模型换路径重试、
调整策略、如实汇报。前言说 Reflexion"内化进了模型"——不用你搭任何反思脚手架,
错误进上下文,纠错自己发生。你回填的错误信息写得越清楚,它纠得越聪明;
这也是工具设计的一部分。
一轮可以并发好几次调用。 第二轮那三个调用是同一条 assistant 消息里的——
tool_calls 是数组,所以执行端写的是 for 循环。模型自己会决定什么时候
并行探路。(octo 在这里还做了一层调度:只读工具真并发跑,有副作用的串行跑——
你现在的版本顺序执行,够用。)
这个循环你已经写过两次了。 练习 3 的 REPL:人说话 → 模型回话 → 塞回历史 → 再来。今天的 agent loop:模型调工具 → 工具汇报 → 塞回历史 → 再来。 同一个循环,对话的另一方从人换成了工具。 从今天起,你的程序里有两个 对话者陪模型说话,Part 5 会出现第三个——另一个模型。
最后是那根保险丝。maxRounds = 10 看着土,但没有它,一个在工具里打转的
模型会烧光你的钱包。生产 harness 的保险丝精细得多——octo 除了轮数上限,
还会对连续重复的工具调用做指纹检测,识别"原地打转"。土办法先用着,
你已经知道它保护的是什么了。
常见问题
Messages with role 'tool' must be a response to ...:历史里丢了那条 带tool_calls的 assistant 消息,或者tool_call_id对不上号。 回执必须跟在调用后面。- 模型不调工具,直接编了一个答案:先看
description写清楚了没—— 它是模型判断"该不该用这个工具"的唯一依据。还不行就是模型太弱,换一个。 参数不是合法 JSON:小模型偶尔会生成残缺的 arguments。把错误回填 (我们已经这么做了),多数模型下一轮会自己修正。- 同一个实验,你的模型只试了一次就放弃:正常。用 qwen3:4b-instruct 跑 notes.txt 实验,它老实报告"文件不存在"就收工,不会像大模型那样换三个路径 再试——同一个循环,模型的强弱决定它把工具用得多灵。循环代码不用改, 换个强模型,行为自己变。
加分练习
- 删掉
history = append(history, msg)那行再跑。你会亲眼看到上面 常见问题里的第一个报错——顺序契约不是建议,是协议。 - 给
readFile加一个上限:超过 4KB 只返回前 4KB 加一行[截断:文件共 N 字节]。然后想想为什么该这么做——工具结果会原样进上下文, 一个 10MB 的日志文件能一口吃光整个上下文窗口。Part 3 的战争今天就埋下了。 - 加一个新工具:
list_files(列出目录内容),然后重跑 notes.txt 实验。 看看这次模型怎么办——你把边界往外挪了一格,它的能力跟着长了一格。 这就是"设计 agent 就是设计工具"的意思。 - 把
readFile里错误返回改成空字符串""再跑 notes.txt 实验。 模型拿到一个"空文件",它会怎么理解?对比之后你会明白: 错误信息的质量,就是模型自我纠错的质量上限。