练习 5:第一个工具——agent loop 完整闭环

前四章的一切都是铺垫:你会发请求了、会接流式输出了、会维护历史了、见过两种协议了。 但到目前为止,模型只是在说话。它知道的比你少(练习 1 它连 harness 是什么都说错), 它碰不到你的文件、你的终端、你的世界。

这一章,模型第一次伸手。你给它一只手——read_file——然后看着它自己决定 什么时候用、怎么用、失败了怎么办。这个循环就是 agent loop,全书的心脏。 敲完这一章,你写的东西第一次配得上"agent"这个词。

敲进去

新建目录 ex05go 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 实验,它老实报告"文件不存在"就收工,不会像大模型那样换三个路径 再试——同一个循环,模型的强弱决定它把工具用得多灵。循环代码不用改, 换个强模型,行为自己变。

加分练习

  1. 删掉 history = append(history, msg) 那行再跑。你会亲眼看到上面 常见问题里的第一个报错——顺序契约不是建议,是协议。
  2. readFile 加一个上限:超过 4KB 只返回前 4KB 加一行 [截断:文件共 N 字节]。然后想想为什么该这么做——工具结果会原样进上下文, 一个 10MB 的日志文件能一口吃光整个上下文窗口。Part 3 的战争今天就埋下了。
  3. 加一个新工具:list_files(列出目录内容),然后重跑 notes.txt 实验。 看看这次模型怎么办——你把边界往外挪了一格,它的能力跟着长了一格。 这就是"设计 agent 就是设计工具"的意思。
  4. readFile 里错误返回改成空字符串 "" 再跑 notes.txt 实验。 模型拿到一个"空文件",它会怎么理解?对比之后你会明白: 错误信息的质量,就是模型自我纠错的质量上限。