练习 6:工具注册表
练习 5 的加分练习让你加第二个工具。如果你做了,应该已经发现不对劲:
execute 的 switch 要加一个 case,tools 数组要加一段声明,两处离得还挺远。
第三个工具来的时候,你就烦了。
烦,就是重构的信号。这一章做两件事:把"工具"变成一个接口,把分发交给注册表—— 从此加工具等于加一行。然后趁热干一件更有意思的事:给注册表装上第一条纪律, 让它拦住模型干傻事。
敲进去
新建目录 ex06,go 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 := ®istry{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_file 和 edit_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 一样认真写。
- 为什么新文件不用先读:不存在的文件没有旧内容可保护。纪律保护的是 "已有的东西不被凭空想象覆盖",不是仪式。
加分练习
- 加第四个工具
list_files(列目录)。数一数你动了几处代码—— 如果超过"一个新 struct + 登记处一行",回头看看哪里没拆干净。 加完重跑练习 5 的 notes.txt 实验,看模型这次怎么找文件。 - 把 read-before-write 的检查注释掉,重跑"直接覆盖"的诱导实验—— 一轮成功,又快又听话。然后想想:快,和不凭想象乱改,你要哪个? 这类"故意让模型多走一步"的设计,后面每一章都会再见到。
- 给
edit_file加replace_all参数(octo 同款):为 true 时替换全部出现, 不再要求唯一。想清楚它的适用场景再动手——什么时候"全换"是对的? - 把
hasRead打印出来观察一轮任务。然后想一个问题:如果模型读了文件, 之后文件又被别人改了,这份记录还准吗?octo 的 ReadTracker 记的不是"读过", 是"读过且未变化"——去想想它还需要记录什么。