练习 6:工具注册表

练习 5 的加分练习让你加第二个工具。如果你做了,应该已经发现不对劲: execute 的 switch 要加一个 case,tools 数组要加一段声明,两处离得还挺远。 第三个工具来的时候,你就烦了。

烦,就是重构的信号。这一章做两件事:把"工具"变成一个接口,把分发交给注册表—— 从此加工具等于加一行。然后趁热干一件更有意思的事:给注册表装上第一条纪律, 让它拦住模型干傻事。

敲进去

新建目录 ex06go mod init ex06,新建 main.go。 协议层和 agent loop 都和练习 5 相同,新东西集中在"工具层"和"注册表层":

// Learn Agent the Hard Way — 练习 6:工具注册表
//
// 练习 5 只有一个工具,switch 一下就分发完了。第二个工具来的时候,
// 你要改三个地方;第三个来的时候,你就烦了。烦,就是重构的信号。
// 这一章:tool 接口 + 注册表,从此加工具 = 加一行。
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

// ---- 工具层 ----

// toolSpec 是发给模型的声明,和练习 5 相同。
type toolSpec struct {
	Name        string         `json:"name"`
	Description string         `json:"description"`
	Parameters  map[string]any `json:"parameters"`
}

// tool 是每个工具要实现的接口:一份给模型看的声明,一个真正干活的函数。
// octo 里同名接口也是这两个方法——这不是巧合,是这件事的最小形状。
type tool interface {
	definition() toolSpec
	execute(args string) string
}

// readFileTool 就是练习 5 的 read_file,装进接口的壳。
type readFileTool struct{}

func (readFileTool) definition() toolSpec {
	return 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"},
		},
	}
}

func (readFileTool) execute(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 {
		return "错误: " + err.Error()
	}
	return string(data)
}

// writeFileTool 整个写入一个文件(不存在则创建,存在则覆盖)。
type writeFileTool struct{}

func (writeFileTool) definition() toolSpec {
	return toolSpec{
		Name:        "write_file",
		Description: "把内容完整写入一个文件。文件不存在就创建,存在就整个覆盖。",
		Parameters: map[string]any{
			"type": "object",
			"properties": map[string]any{
				"path":    map[string]any{"type": "string", "description": "目标文件路径"},
				"content": map[string]any{"type": "string", "description": "要写入的完整内容"},
			},
			"required": []string{"path", "content"},
		},
	}
}

func (writeFileTool) execute(args string) string {
	var in struct {
		Path    string `json:"path"`
		Content string `json:"content"`
	}
	if err := json.Unmarshal([]byte(args), &in); err != nil {
		return "错误: 参数不是合法 JSON: " + err.Error()
	}
	if err := os.WriteFile(in.Path, []byte(in.Content), 0o644); err != nil {
		return "错误: " + err.Error()
	}
	return fmt.Sprintf("已写入 %s(%d 字节)", in.Path, len(in.Content))
}

// editFileTool 精确替换文件中的一段文本。octo 的设计原样蒸馏:
// old_string 必须在文件里恰好出现一次——多了说明定位不唯一,少了说明找错了,
// 两种都拒绝执行。这比"按行号改"可靠得多:行号在模型的记忆里会漂,原文不会。
type editFileTool struct{}

func (editFileTool) definition() toolSpec {
	return toolSpec{
		Name: "edit_file",
		Description: "在已有文件里做一次精确替换。old_string 必须与文件现有内容逐字一致," +
			"且只出现一次——不唯一时请带上足够的上下文再试。文件必须已存在(创建用 write_file)。",
		Parameters: map[string]any{
			"type": "object",
			"properties": map[string]any{
				"path":       map[string]any{"type": "string", "description": "目标文件路径"},
				"old_string": map[string]any{"type": "string", "description": "要找到的原文,必须唯一"},
				"new_string": map[string]any{"type": "string", "description": "替换成的新文本,可以为空(等于删除)"},
			},
			"required": []string{"path", "old_string", "new_string"},
		},
	}
}

func (editFileTool) execute(args string) string {
	var in struct {
		Path      string `json:"path"`
		OldString string `json:"old_string"`
		NewString string `json:"new_string"`
	}
	if err := json.Unmarshal([]byte(args), &in); err != nil {
		return "错误: 参数不是合法 JSON: " + err.Error()
	}
	data, err := os.ReadFile(in.Path)
	if err != nil {
		return "错误: " + err.Error()
	}
	text := string(data)
	switch n := strings.Count(text, in.OldString); {
	case in.OldString == "":
		return "错误: old_string 不能为空"
	case n == 0:
		return "错误: old_string 在文件里找不到——和 read_file 看到的原文逐字对一下"
	case n > 1:
		return fmt.Sprintf("错误: old_string 出现了 %d 次,无法确定改哪一处——多带几行上下文让它唯一", n)
	}
	text = strings.Replace(text, in.OldString, in.NewString, 1)
	if err := os.WriteFile(in.Path, []byte(text), 0o644); err != nil {
		return "错误: " + err.Error()
	}
	return "已替换 " + in.Path + " 中的一处文本"
}

// ---- 注册表层 ----

// registry 按名字分发工具调用,并在这一层安装横切纪律。
// 纪律装在注册表而不是某个工具里,因为它管的是工具**之间**的关系。
type registry struct {
	tools   map[string]tool
	order   []string        // 保持声明顺序,发给模型的列表要稳定
	hasRead map[string]bool // read-before-write 记录:这个会话里读过哪些文件
}

func newRegistry(ts ...tool) *registry {
	r := &registry{tools: map[string]tool{}, hasRead: map[string]bool{}}
	for _, t := range ts {
		spec := t.definition()
		r.tools[spec.Name] = t
		r.order = append(r.order, spec.Name)
	}
	return r
}

// definitions 生成发给模型的 tools 数组。
func (r *registry) definitions() []map[string]any {
	var out []map[string]any
	for _, name := range r.order {
		out = append(out, map[string]any{
			"type":     "function",
			"function": r.tools[name].definition(),
		})
	}
	return out
}

// execute 查表分发。改文件的调用先过 read-before-write 检查:
// 没读过就想改一个已存在的文件?拒绝——模型会先去读,然后带着事实回来。
func (r *registry) execute(name, args string) string {
	t, ok := r.tools[name]
	if !ok {
		return "错误: 未知工具 " + name
	}
	if name == "write_file" || name == "edit_file" {
		if path := pathOf(args); path != "" && fileExists(path) && !r.hasRead[path] {
			return "错误: " + path + " 已存在但这个会话里还没读过它。先用 read_file 看一眼,再来修改。"
		}
	}
	result := t.execute(args)
	// 调用成功就记账:读过的文件可以改;刚写完的文件模型知道最新内容,也算读过。
	if path := pathOf(args); path != "" && !strings.HasPrefix(result, "错误:") {
		r.hasRead[path] = true
	}
	return result
}

func pathOf(args string) string {
	var in struct {
		Path string `json:"path"`
	}
	_ = json.Unmarshal([]byte(args), &in)
	return in.Path
}

func fileExists(path string) bool {
	_, err := os.Stat(path)
	return err == nil
}

// ---- 协议层:和练习 5 相同 ----

type message struct {
	Role       string     `json:"role"`
	Content    string     `json:"content"`
	ToolCalls  []toolCall `json:"tool_calls,omitempty"`
	ToolCallID string     `json:"tool_call_id,omitempty"`
}

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     []map[string]any `json:"tools,omitempty"`
}

type response struct {
	Choices []struct {
		Message      message `json:"message"`
		FinishReason string  `json:"finish_reason"`
	} `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"`
}

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, `用法: ./ex06 "你的任务"`)
		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"
	}

	// 全部工具在这里注册。加第四个工具 = 在这里加一行,别处一个字不用动。
	reg := newRegistry(
		readFileTool{},
		writeFileTool{},
		editFileTool{},
	)

	history := []message{{Role: "user", Content: os.Args[1]}}

	// agent loop 的结构和练习 5 完全一样。变化只有两处:
	// 工具声明从注册表拿(reg.definitions),分发交给注册表(reg.execute)。
	const maxRounds = 10
	for round := 1; round <= maxRounds; round++ {
		r, err := send(base, apiKey, model, history, reg.definitions())
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			os.Exit(1)
		}
		msg := r.Choices[0].Message
		history = append(history, msg)

		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
		}

		for _, tc := range msg.ToolCalls {
			fmt.Fprintf(os.Stderr, "[round %d] %s(%s)\n", round, tc.Function.Name, tc.Function.Arguments)
			result := reg.execute(tc.Function.Name, tc.Function.Arguments)
			history = append(history, message{
				Role:       "tool",
				ToolCallID: tc.ID,
				Content:    result,
			})
		}
	}
	fmt.Fprintf(os.Stderr, "达到 %d 轮上限,停止。\n", maxRounds)
	os.Exit(1)
}

func send(base, apiKey, model string, history []message, tools []map[string]any) (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
}

先别问为什么。敲完,跑起来,我们再回头讲。

跑起来

环境变量照旧。先造一个实验品,再让它改:

go build -o ex06 .
printf '购物清单\n- 牛奶\n- 面包\n' > list.txt
./ex06 "把 list.txt 里的面包改成全麦面包"

你应该看到什么

[round 1] read_file({"path": "list.txt"})
[round 2] edit_file({"path": "list.txt", "old_string": "- 面包", "new_string": "- 全麦面包"})
已完成,把 list.txt 里的「面包」改成了「全麦面包」。

[共 3 轮 · 最后一轮输入 925 tokens · finish_reason=stop]

cat list.txt 看一眼:面包真的变成了全麦面包。你的程序第一次改了你的世界, 不只是读它。

然后做这个实验——故意诱导它蛮干:

./ex06 "不用管 list.txt 里现在有什么,直接把它整个覆盖成一行字:已清空"

DeepSeek 的真实过程:

[round 1] write_file({"path": "list.txt", "content": "已清空\n"})
[round 2] read_file({"path": "list.txt"})
[round 3] write_file({"content": "已清空\n", "path": "list.txt"})
已把 list.txt 整个覆盖成一行字:已清空

看懂这三轮:用户教唆它"不用管里面有什么",它听话,第一轮直接 write_file—— 被你的注册表拦下了("已存在但这个会话里还没读过它")。第二轮它乖乖去读, 第三轮带着对文件的了解重新写,成功。

练习 5 里模型靠错误信息自我纠正,那时错误来自操作系统(文件不存在)。 这次不一样:这个错误是你设计的,它是一条策略。模型撞上它、读懂它、照办了。 你第一次用错误信息教育了模型。

发生了什么

tool 接口只有两个方法,而这两个方法就是"工具"的全部定义。 definition() 是给模型看的——名字、说明书、参数表;execute() 是真正干活的。 一个负责让模型想用会用,一个负责能用。octo 的内部接口就是这两个方法, 一个字不多。以后每次设计新工具,你都在回答同两个问题:怎么向模型描述它, 怎么执行它。

注册表让"加工具"变成一行。 所有工具在 newRegistry(...) 里排队登记, definitions() 自动生成发给模型的声明,execute() 自动查表分发。 加第四个工具时,你写一个新 struct,然后在登记处加一行——switch 没了, 两处分离的维护点没了。octo 的注册表上面躺着三十多个工具,靠的就是这个结构。

edit_file 是三个工具里最讲究的,讲究在"怎么定位要改的地方"。 直觉方案是行号——"改第 3 行"。但行号是模型从上下文里推算的,文件一变就漂, 漂了它自己不知道。octo 的方案是让模型引用原文old_string 必须和文件内容 逐字一致,而且只出现一次。出现零次,说明模型记错了内容,拒绝;出现多次, 说明定位不唯一,也拒绝——错误信息里直接写着补救办法("多带几行上下文")。 我实测让模型把"牛奶"换成"燕麦奶",而文件里还有"牛奶糖"——它自己带上了 换行符做上下文("- 牛奶\n"),改完还主动重读文件核对。约束设计得好, 模型的行为自动变严谨。

read-before-write 装在注册表层,不装在任何一个工具里。 因为这条纪律管的不是某个工具,是工具之间的关系:write_fileedit_file 必须发生在 read_file 之后。单个工具看不见这层关系,站在所有工具之上的 注册表看得见。它内部一份 hasRead 记录,读过的文件才允许改—— 就这么十几行,治的是 agent 用户最常抱怨的病:乱改文件。你八成也遇到过: 让它改个配置,它凭想象把整个文件重写了一遍,格式全变、注释全丢。 病根就是"没读就写"。

最后看一眼 main:agent loop 的结构和练习 5 完全一样,变化只有两处—— 声明从 reg.definitions() 拿,分发交给 reg.execute()循环稳定,工具生长,两者互不打扰——这就是前言说的 "一个 agent 和另一个 agent 的全部差别,都在工具的设计里"。 你的工具列表现在有三行,octo 有三十多行,Claude Code 也不过如此。 差别不在循环,在工具列表。

常见问题

  • old_string 在文件里找不到:模型引用的原文和文件不一致——多半是空白、 换行或标点的细微差别。让它先 read_file 再改(我们的纪律就是干这个的)。
  • old_string 出现了 N 次:定位不唯一。错误信息已经告诉模型补救办法了, 多数模型下一轮会自己带上下文重试。
  • 模型被拦后不去读,反而放弃:弱模型可能读不懂拒绝文案。把错误信息改得 更"下一步明确"——错误信息是你和模型之间的 API,值得像写 description 一样认真写。
  • 为什么新文件不用先读:不存在的文件没有旧内容可保护。纪律保护的是 "已有的东西不被凭空想象覆盖",不是仪式。

加分练习

  1. 加第四个工具 list_files(列目录)。数一数你动了几处代码—— 如果超过"一个新 struct + 登记处一行",回头看看哪里没拆干净。 加完重跑练习 5 的 notes.txt 实验,看模型这次怎么找文件。
  2. 把 read-before-write 的检查注释掉,重跑"直接覆盖"的诱导实验—— 一轮成功,又快又听话。然后想想:快,和不凭想象乱改,你要哪个? 这类"故意让模型多走一步"的设计,后面每一章都会再见到。
  3. edit_filereplace_all 参数(octo 同款):为 true 时替换全部出现, 不再要求唯一。想清楚它的适用场景再动手——什么时候"全换"是对的?
  4. hasRead 打印出来观察一轮任务。然后想一个问题:如果模型读了文件, 之后文件又被别人改了,这份记录还准吗?octo 的 ReadTracker 记的不是"读过", 是"读过且未变化"——去想想它还需要记录什么。