不到 400 行代码,从零构建一个优雅的 Agent 教程

编译丨特工小氧
审核丨特工少女
原文:https://ampcode.com/how-to-build-an-agent,阅读本文需要一定的代码基础,后续特工们会考虑出一个手把手视频教学。如果看不懂代码,也可以跟着流程过一遍,感受一下。

构建一个功能完备、能够编写代码的 Agent 其实并非难事。

当你看到一个 Agent 正在编辑文件、执行命令、自动纠错、尝试不同策略时——似乎总感觉这背后隐藏着什么秘密。

其实并没有。它不过是一个大语言模型、一个循环,再加上足够多的 token 而已。至于剩下的部分不过是些苦力活。

然而,构建一个小的却极强大的 Agent 甚至都不需要这些苦力活。用不到 400 行代码就能搞定,其中大部分还只是基础框架代码。

接下来,让我们将一同编写代码,从零开始,直至达到让你惊叹“哇哦,这简直…颠覆…”的程度。

强烈建议跟着动手实践。你或许认为光看文章就够了,不必亲自敲代码,但这其实总共还不到 400 行。希望你能切身感受这代码量是多么的微乎其微,希望你能在自己的终端、自己的文件夹里亲眼见证这一切。

开始前我们需要准备:

1. Go 语言环境;

2. Anthropic API 密钥,需要将其设置为名为 ANTHROPIC_API_KEY 的环境变量。

还有个网友二创的 Python+Gemini 的方案:

https://github.com/voxmenthe/coding-agent/blob/main/tutorial.md

准备开始!

让我们直接开始,用四个简单的命令来搭建一个新的 Go 项目:

mkdir code-editing-agentcd code-editing-agentgo mod init agenttouch main.go

现在,打开 main.go 文件,第一步,先把我们需要的基本结构放进去:

// main.gopackage mainimport (    "bufio"    "context"    "fmt"    "os"    "github.com/anthropics/anthropic-sdk-go")func main() {    // 创建 Anthropic 客户端    client := anthropic.NewClient()    // 设置从标准输入读取用户消息的扫描器    scanner := bufio.NewScanner(os.Stdin)    getUserMessage := func() (stringbool) {        if !scanner.Scan() {            return ""false // 如果读取失败或结束,返回 false        }        return scanner.Text(), true // 返回读取到的文本和 true    }    // 创建一个新的 Agent 实例    agent := NewAgent(&client, getUserMessage)    // 运行 Agent    err := agent.Run(context.TODO()) // 使用 context.TODO() 作为临时上下文    if err != nil {        fmt.Printf("错误: %s\n", err.Error()) // 打印错误信息    }}// NewAgent 是 Agent 的构造函数func NewAgent(client *anthropic.Client, getUserMessage func() (stringbool)) *Agent {    return &Agent{        client:         client,         // Anthropic API 客户端        getUserMessage: getUserMessage, // 获取用户输入的函数    }}// Agent 结构体定义type Agent struct {    client         *anthropic.Client              // 用于与 Anthropic API 交互的客户端    getUserMessage func() (stringbool)         // 用于从用户获取输入的函数}

这段代码目前还无法编译。但我们已经有了一个 Agent,它能够访问 Anthropic Client(默认情况下会查找 ANTHROPIC_API_KEY 环境变量),并且可以通过读取终端的 stdin 来获取用户消息。

现在,我们来添加缺失的 Run() 方法:
// main.go// Run 方法是 Agent 的主执行循环func (a *Agent) Run(ctx context.Context) error {    // 初始化对话历史    conversation := []anthropic.MessageParam{}    fmt.Println("开始与 Claude 聊天 (按 'ctrl-c' 退出)")    // 无限循环,处理对话    for {        fmt.Printf("\u001b[94m你\u001b[0m: "// 打印蓝色提示符 "你: "        // 获取用户输入        userInput, ok := a.getUserMessage()        if !ok {            break // 如果无法获取输入(例如用户按了 ctrl-c),则退出循环        }        // 将用户输入包装成 Anthropic API 需要的消息格式        userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput))        // 将用户消息添加到对话历史中        conversation = append(conversation, userMessage)        // 调用 runInference 方法将对话发送给 Claude 并获取回复        message, err := a.runInference(ctx, conversation)        if err != nil {            return err // 如果 API 调用出错,返回错误        }        // 将 Claude 的回复添加到对话历史中        conversation = append(conversation, message.ToParam())        // 处理 Claude 回复中的内容块        for _, content := range message.Content {            switch content.Type {            case "text"// 如果是文本内容                fmt.Printf("\u001b[93mClaude\u001b[0m: %s\n", content.Text) // 打印黄色提示符 "Claude: " 和回复内容            }        }    }    return nil // 正常退出时返回 nil}// runInference 方法负责调用 Anthropic APIfunc (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) {    // 调用 Anthropic 客户端的 Messages.New 方法    message, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{        Model:     anthropic.ModelClaude3_7SonnetLatest, // 指定使用的模型        MaxTokens: int64(1024),                       // 设置最大生成 token 数量        Messages:  conversation,                       // 传入当前的对话历史    })    return message, err // 返回 API 的回复消息和可能的错误}

代码量不大,对吧?总共九十行,其中最核心的就是 Run() 方法里的那个循环,它让我们能够与 Claude 持续对话。但这已经是这个程序运转的核心了。

就其核心功能而言,这个流程相当直接明了:我们首先打印一个提示,等待用户输入内容,将其加入对话记录,发送给 Claude,再将 Claude 的回应加入对话记录,打印出回应,然后周而复始。

这其实就是你用过的所有 AI 聊天应用的基本模式,只不过这次它运行在你的终端里。

我们来运行一下:

export ANTHROPIC_API_KEY="我最后再说一次,务必设置这个环境变量"# 下载所需的依赖包go mod tidy# 运行程序go run main.go

然后你就可以像下面这样和 Claude 聊天了:

$ go run main.go Chat with Claude (use 'ctrl-c' to quit) You: Hey! I'm Thorsten! How are you?Claude: Hi Thorsten! I'm doing well, thanks for asking. It's nice to meet you. How are you doing today? Is there something I can help you with?You: Can you come up with any horse-related nicknames that make fun of my first name?Claude: I can try to come up with some playful horse-related nicknames based on "Thorsten":* Thorough-bred Thorsten* Trotsten* Thorsten the Stallion* Neighsten* Horseten* Thorsten the Galloper* Saddle-sten* Thorsten of the Mane* Canter-sten* ThorsteedThese are meant to be lighthearted - I hope they give you a chuckle!You: 

注意,我们在多轮对话中一直保持着上下文。它从第一条消息就记住了我的名字。conversation 这个切片在每一轮对话后都会增长,我们每次都会把整个对话历史发送出去。The Server——也就是 Anthropic 的服务器——本身是无状态的。它只知道当前 conversation 切片里的内容。维护对话状态是我们客户端的责任。

好了,我们继续往下看,目前这还算不上一个 Agent。那什么才算是 Agent 呢?

这是我的定义:一个能够接入并使用工具的 LLM,这赋予了它修改其自身上下文窗口之外信息的能力。

第一个工具

一个能够接入并使用工具的 LLM?什么是工具?

基本思路是这样的:你向模型发送一个 Prompt,明确告知如果它想使用“某个工具”,就应该以某种特定的方式进行回复。然后,你作为接收这条回复的一方,通过执行相应的操作来“使用该工具”,并将执行结果回复给模型。就这么简单。我们接下来将看到的所有内容,都只是建立在这个基础之上的抽象。

想象一下,你正和朋友聊天,你对他说:“在接下来的谈话里,如果你想让我抬起胳膊,你就眨一下眼睛。”这话听起来有点怪,但这个概念很容易理解。

我们现在就可以在不修改任何代码的情况下尝试一下。

$ go run main.go Chat with Claude (use 'ctrl-c' to quit) You: You are a weather expert. When I ask you about the weather in a given location, I want you to reply with `get_weather(<location_name>)`. I will then tell you what the weather in that location is. Understood?Claude: I understand your instructions. When you ask me about the weather in a specific location, I will reply with `get_weather(<location_name>)`. Then you'll provide me with the weather information for that location, and I can discuss it further with you. I'm ready whenever you'd like to ask about the weather somewhere.You: Hey, what's the weather in Munich?Claude: get_weather(Munich)

效果非常好,第一次尝试就成功了,不是吗?

这些模型都经过了专门的训练和微调,能够熟练使用“工具”,并且它们非常乐意这样做。到了 2025 年的今天,它们在某种程度上已经“意识”到自己并非全知全能,并且可以利用工具来获取更多信息(当然,这并非其内部运作的精确描述,但作为当前的解释已经足够形象了)。

总结来说,关于工具和工具使用的核心要素只有两点:

1. 你告知模型有哪些可用的工具。

2. 当模型表示想要执行某个工具时,它会通知你,你来执行这个工具,并将执行结果返回给模型。

为了让第一步(告知模型工具信息)更简便,主流的模型提供商都内置了相应的 API,允许你将工具的定义信息随请求一起发送。

Read_file 工具

为了定义 read_file 这个工具,我们将遵循 Anthropic SDK 推荐的类型结构。但请记住:归根结底,这一切最终都会转化为字符串发送给模型。本质上还是那套“如果你想让我用 read_file,就眨眨眼”的逻辑。

我们接下来要添加的每一个工具都需要包含以下要素:

1. 一个名称(Name);

2. 一段描述(Description),用于告知模型该工具的功能、适用场景、不适用场景、返回值等信息;

3. 一个输入模式(Input Schema),采用 JSON Schema 格式,精确描述该工具期望接收的输入参数及其形式;

4. 一个函数(Function),该函数负责接收模型传来的输入,实际执行工具对应的操作,并返回执行结果。

那么,让我们将这些要素添加到代码中:

// main.go// ToolDefinition 结构体用于定义一个工具type ToolDefinition struct {    Name        string                         `json:"name"`         // 工具的名称    Description string                         `json:"description"`  // 工具的描述    InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"` // 工具的输入参数模式 (使用 Anthropic SDK 类型)    Function    func(input json.RawMessage) (stringerror// 执行工具逻辑的函数,输入为原始 JSON,输出为字符串结果和错误}

现在,我们让 Agent 结构体能持有这些工具定义:

// main.go// Agent 结构体定义,增加了 tools 字段type Agent struct {    client         *anthropic.Client      // Anthropic API 客户端    getUserMessage func() (stringbool// 获取用户输入的函数    tools          []ToolDefinition       // Agent 可用的工具列表}// NewAgent 构造函数,增加了 tools 参数func NewAgent(    client *anthropic.Client,    getUserMessage func() (stringbool),    tools []ToolDefinition, // 接收工具定义列表) *Agent {    return &Agent{        client:         client,        getUserMessage: getUserMessage,        tools:          tools, // 将传入的工具列表存储到 Agent 实例中    }}func main() {    // [...之前的代码...]    // 初始化一个空的工具列表 (稍后会添加具体工具)    tools := []ToolDefinition{}    // 创建 Agent 实例时传入工具列表    agent := NewAgent(&client, getUserMessage, tools)    // [...之前的代码...]}

并且在 runInference 方法中,将这些工具定义发送给模型:

// main.gofunc (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) {    // 将我们定义的 ToolDefinition 转换为 Anthropic API 需要的格式    anthropicTools := []anthropic.ToolUnionParam{}    for _, tool := range a.tools {        anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{            OfTool: &anthropic.ToolParam{                Name:        tool.Name,                            // 工具名称                Description: anthropic.String(tool.Description),   // 工具描述 (使用 anthropic.String 包装)                InputSchema: tool.InputSchema,                   // 工具输入模式            },        })    }    // 调用 Anthropic API,这次在参数中加入了 Tools 字段    message, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{        Model:     anthropic.ModelClaude3_7SonnetLatest,        MaxTokens: int64(1024),        Messages:  conversation,        Tools:     anthropicTools, // 将转换后的工具列表传递给 API    })    return message, err}

我们将工具定义发送过去,Anthropic 的服务器端会用这个 system prompt 将这些定义包装起来,然后加到我们的 conversation 对话历史中。之后,如果模型决定要使用某个工具,它就会以一种特定的格式进行回复。

好了,工具定义现在能够被发送了,但我们还没有真正定义任何工具。让我们现在就来定义 read_file 工具:

// main.go// 定义 read_file 工具var ReadFileDefinition = ToolDefinition{    Name:        "read_file"// 工具名称    Description: "读取指定相对文件路径的内容。当你需要查看文件内部有什么时使用此工具。请勿对目录名称使用此工具。"// 工具描述    InputSchema: ReadFileInputSchema, // 输入模式    Function:    ReadFile,            // 执行函数}// 定义 read_file 工具的输入结构体type ReadFileInput struct {    Path string `json:"path" jsonschema_description:"工作目录下文件的相对路径。"` // 文件路径参数,并附带 JSON Schema 描述}// 使用泛型函数 GenerateSchema 生成 ReadFileInput 的 JSON Schemavar ReadFileInputSchema = GenerateSchema[ReadFileInput]()// ReadFile 函数实现了读取文件的具体逻辑func ReadFile(input json.RawMessage) (stringerror) {    // 解析 JSON 输入到 ReadFileInput 结构体    readFileInput := ReadFileInput{}    err := json.Unmarshal(input, &readFileInput)    if err != nil {        panic(err) // 简单处理错误,实际应用中应更健壮    }    // 使用 os.ReadFile 读取文件内容    content, err := os.ReadFile(readFileInput.Path)    if err != nil {        return "", err // 如果读取出错,返回错误    }    // 返回文件内容字符串    return string(content), nil}// GenerateSchema 是一个泛型函数,用于从 Go 结构体生成 Anthropic 兼容的输入模式func GenerateSchema[T any]() anthropic.ToolInputSchemaParam {    // 使用 jsonschema 库来反射结构体并生成 JSON Schema    reflector := jsonschema.Reflector{        AllowAdditionalProperties: false// 不允许额外的属性        DoNotReference:            true,  // 不使用 $ref 引用    }    var v T // 创建一个类型 T 的零值实例    schema := reflector.Reflect(v) // 生成 JSON Schema    // 将生成的 Schema 转换为 Anthropic SDK 需要的格式    return anthropic.ToolInputSchemaParam{        Properties: schema.Properties, // 只取其中的 Properties 部分    }}

代码不多,不是吗?只有一个核心函数 ReadFile,以及两个模型会看到的描述信息:一个是描述工具本身的 Description ,另一个是描述该工具唯一输入参数(文件路径)的 jsonschema_description

而 ReadFileInputSchema 和 GenerateSchema 这部分代码?我们需要它们来为工具定义生成一个 JSON Schema,这个 Schema 会被发送给模型。为了实现这个功能,我们引入了 jsonschema 这个包,所以需要先导入并下载它:

// main.go package main import (     "bufio"     "context"     // 添加这个导入:+    "encoding/json"     "fmt"     "os"     // 需要添加对 path/filepath 的导入,用于处理路径 (译者注:原文未加,但 ListFiles 会用到)+    "path/filepath"     // 需要添加对 strings 的导入,用于 EditFile (译者注:原文未加,但 EditFile 会用到)+    "strings"     // 需要添加对 path 的导入,用于 EditFile 中的 createNewFile (译者注:原文未加)+    "path"     "github.com/anthropics/anthropic-sdk-go"     // 添加这个导入:+    "github.com/invopop/jsonschema" )
然后运行:
go mod tidy

接着,在 main 函数中,确保我们把刚才定义的 ReadFileDefinition 加入到工具列表中:

func main() {    // [...之前的代码...]    // 将 ReadFileDefinition 添加到 tools 列表中    tools := []ToolDefinition{ReadFileDefinition}    // [...之前的代码...]}
是时候检验成果了!
$ go run main.go Chat with Claude (use 'ctrl-c' to quit) You: what's in main.go?Claude: I'll help you check what's in the main.go file. Let me read it for you.You: 

等等,发生了什么?它表示要使用工具了!当然,你的具体输出可能会略有不同,但这听起来确实像是 Claude 知道自己可以读取文件了,对吧?

问题在于,当 Claude “眨眼”示意(即返回工具使用请求)时,我们的程序并没有做出响应。我们需要修复这一点。

将 Agent 的 Run 方法替换为以下版本:

// main.gofunc (a *Agent) Run(ctx context.Context) error {    conversation := []anthropic.MessageParam{}    fmt.Println("开始与 Claude 聊天 (按 'ctrl-c' 退出)")    readUserInput := true // 标志位,指示下一轮是否需要读取用户输入    for {        // 仅当需要时才读取用户输入        if readUserInput {            fmt.Printf("\u001b[94m你\u001b[0m: ")            userInput, ok := a.getUserMessage()            if !ok {                break            }            userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput))            conversation = append(conversation, userMessage)        }        // 调用 API 获取 Claude 的回复        message, err := a.runInference(ctx, conversation)        if err != nil {            return err        }        // 将 Claude 的回复(可能是文本或工具使用请求)添加到对话历史        conversation = append(conversation, message.ToParam())        // 用于存储本轮需要执行的工具结果        toolResults := []anthropic.ContentBlockParamUnion{}        // 遍历 Claude 回复的内容块        for _, content := range message.Content {            switch content.Type {            case "text"// 如果是普通文本回复                fmt.Printf("\u001b[93mClaude\u001b[0m: %s\n", content.Text)            case "tool_use"// 如果是工具使用请求                // 调用 executeTool 执行工具                result := a.executeTool(content.ID, content.Name, content.Input)                // 将工具执行结果添加到 toolResults 列表中                toolResults = append(toolResults, result)            }        }        // 检查是否有工具被执行        if len(toolResults) == 0 {            // 如果没有工具执行,说明 Claude 只是纯文本回复,下一轮需要读取用户输入            readUserInput = true            continue // 继续下一轮循环        }        // 如果有工具被执行        readUserInput = false // 下一轮不需要读取用户输入,因为要先处理工具结果        // 将所有工具的执行结果包装成一条用户消息,添加到对话历史中        // 这相当于告诉 Claude:“这是你刚才请求的工具的执行结果”        conversation = append(conversation, anthropic.NewUserMessage(toolResults...))    }    return nil}// executeTool 方法负责查找并执行指定的工具func (a *Agent) executeTool(id, name string, input json.RawMessage) anthropic.ContentBlockParamUnion {    var toolDef ToolDefinition    var found bool    // 在 Agent 的工具列表中查找名称匹配的工具定义    for _, tool := range a.tools {        if tool.Name == name {            toolDef = tool            found = true            break        }    }    // 如果找不到对应的工具    if !found {        // 返回一个表示错误的工具结果块        return anthropic.NewToolResultBlock(id, "未找到指定工具"true// isError 设置为 true    }    // 打印工具执行信息(绿色提示符)    fmt.Printf("\u001b[92m工具\u001b[0m: %s(%s)\n", name, input)    // 调用工具定义中的 Function 来执行实际操作    response, err := toolDef.Function(input)    if err != nil {        // 如果工具执行出错,返回包含错误信息的工具结果块        return anthropic.NewToolResultBlock(id, err.Error(), true// isError 设置为 true    }    // 如果工具执行成功,返回包含结果的工具结果块    return anthropic.NewToolResultBlock(id, response, false// isError 设置为 false}

眯起眼睛仔细看,你会发现这大部分都是模板化的代码:当我们从 Claude 那里得到 message 回复时,我们检查 Claude 是否要求我们执行工具 (content.Type == “tool_use”),如果是,就转交给 executeTool 方法处理。该方法查找对应的工具定义,反序列化输入参数,执行工具函数,然后返回结果。如果执行过程中发生错误,我们会在结果中标记出来。最后,我们将工具的执行结果(无论成功还是失败)发送回给 Claude,然后继续对话循环。

千真万确,核心逻辑就这些。让我演示给你看。

# 在当前目录下创建一个名为 secret-file.txt 的文件,并写入一个谜语echo 'what animal is the most disagreeable because it always says neigh?' >> secret-file.txt

这条命令会在你的工作目录下创建一个 secret-file.txt 文件,里面包含一个神秘的谜语。

就在这个目录下,运行我们更新后的、具备工具使用能力的 Agent,让它去看看这个文件里的内容:

$ go run main.go Chat with Claude (use 'ctrl-c' to quitYou: Claude, buddy, help me solve the riddle in the secret-file.txt fileClaude: I'll help you solve the riddle in the secret-file.txt file. Let me first read the contents of this file to see what the riddle is.tool: read_file({"path":"secret-file.txt"})Claude: Great! I've found the riddle.The answer to the riddle is: **A horse**The riddle is playing on wordplay where "neigh" (the sound a horse makes) sounds like "nay" (a way to say "no" or disagree with something). So horses are described as disagreeable because they always say "neigh/nay" - implying they're always disagreeing.You:

让我们深吸一口气,一起惊叹吧。

你只是给了它一个工具,它就…在认为这个工具有助于完成任务的时候主动去使用它。记住:我们自始至终都没有明确指示,比如“如果用户问起某个文件,你就去读取它”。我们也没有说“如果看到像文件名一样的字符串,就想办法读取它”。不,完全没有。我们只是说“帮我解开这个文件里的谜题”,然后 Claude 就自己意识到可以通过读取文件来回答这个问题,并且就这样去做了。

当然,我们给出更明确的指令,它基本上能够自主完成任务。

$ go run main.go Chat with Claude (use 'ctrl-c' to quit) You: What's going on in main.go? Be brief!Claude: I'll check the main.go file to see what's going on.tool: read_file({"path":"main.go"})Claude: Based on my review, main.go implements a Claude AI assistant agent that can use tools. Here's a brief summary:- It creates a command-line chat interface with Claude using the Anthropic Go SDK- Implements a tool system with a single "read_file" tool for reading file contents- Manages the conversation flow with Claude, handling both regular text responses and tool use requests- When Claude requests to use a tool, the agent executes it and returns the results- The program loops continuously to maintain a conversation until the user exitsThe code essentially builds a simple CLI-based chatbot that can interact with the file system when requested by Claude.You: 

分析得非常到位。既然我们已经掌握了如何让 Claude 使用工具,那就让我们再添加几个吧。

list_files 工具

如果你和我一样,每次登录到一台新电脑,第一件事通常是运行 ls 命令——列出文件——来快速了解当前环境。

让我们也赋予 Claude 这种能力。下面是一个 list_files 工具的完整实现:

// main.go// 定义 list_files 工具var ListFilesDefinition = ToolDefinition{    Name:        "list_files"// 工具名称    Description: "列出指定路径下的文件和目录。如果未提供路径,则列出当前目录的内容。"// 工具描述    InputSchema: ListFilesInputSchema, // 输入模式    Function:    ListFiles,            // 执行函数}// 定义 list_files 工具的输入结构体type ListFilesInput struct {    // omitempty 表示如果该字段为空,则在 JSON 序列化时忽略它    // jsonschema_description 提供给模型的参数描述    Path string `json:"path,omitempty" jsonschema_description:"可选的相对路径,用于指定列出文件的起始位置。如果未提供,则默认为当前目录。"`}// 生成 ListFilesInput 的 JSON Schemavar ListFilesInputSchema = GenerateSchema[ListFilesInput]()// ListFiles 函数实现了列出文件和目录的逻辑func ListFiles(input json.RawMessage) (stringerror) {    // 解析 JSON 输入    listFilesInput := ListFilesInput{}    err := json.Unmarshal(input, &listFilesInput)    if err != nil {        panic(err) // 简化错误处理    }    // 确定要列出的目录,默认为当前目录 "."    dir := "."    if listFilesInput.Path != "" {        dir = listFilesInput.Path    }    // 用于存储文件和目录路径的切片    var files []string    // 使用 filepath.Walk 遍历目录    err = filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {        if err != nil {            return err // 处理遍历过程中的错误        }        // 获取相对于起始目录的路径        relPath, err := filepath.Rel(dir, path)        if err != nil {            return err        }        // 忽略起始目录本身 (".")        if relPath != "." {            if info.IsDir() {                // 如果是目录,在末尾添加斜杠 "/"                files = append(files, relPath+"/")            } else {                // 如果是文件,直接添加相对路径                files = append(files, relPath)            }        }        return nil    })    if err != nil {        return "", err // 返回遍历过程中遇到的错误    }    // 将文件列表序列化为 JSON 字符串    result, err := json.Marshal(files)    if err != nil {        return "", err // 返回 JSON 序列化错误    }    // 返回 JSON 字符串形式的文件列表    return string(result), nil}

这里没什么花哨的操作:list_files 函数返回指定文件夹(默认为当前文件夹)下的文件和目录列表。如果这是一个正式的项目,我们可能(而且应该)进行成百上千项优化,但只是想揭示魔法帽里的秘密,这样实现就足够了。

需要注意的一点是:我们返回的是一个字符串列表,并且用末尾的斜杠来标识目录。这里没有固定的格式要求;只要 Claude 能够解析,任何格式都可以。

当然,我们还需要把 list_files 这个新工具告知 Claude:

func main() {    // [...之前的代码...]    // 将 ListFilesDefinition 添加到工具列表中-   tools := []ToolDefinition{ReadFileDefinition}+   tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition}    // [...之前的代码...]}

就这样。让我们问问 Claude 这个目录里都有些什么。

$ go run main.go Chat with Claude (use 'ctrl-c' to quit) You: what do you see in this directory?Claude: I'll help you see what'in the current directory. Let me list the files and directories for you.toollist_files({})Claude: I can see several files and directories in the current directory:1. Git-related files and directories:   - `.git/` directory with various Git configuration and tracking files   - `.gitignore` file2. Go project files:   - `main.go` - the main Go source code file   - `go.mod` - Go module definition file   - `go.sum` - Go module checksum file3. Documentation:   - `blogpost.md` - a markdown file, likely containing blog content4. Environment configuration:   - `.envrc` - likely an environment configuration fileWould you like me to examine the contents of any specific file from this list?You

搞定!它成功读取了目录内容。

但更厉害的是:Claude 知道如何组合使用这些工具。我们只需要提出一个能够引导它进行组合操作的问题即可:

$ go run main.go Chat with Claude (use 'ctrl-c' to quit) You: Tell me about all the Go files in here. Be brief!Claude: I'll examine the Go files in the current directory for you. Let me check what files are available first.tool: list_files({})Claude: I found a Go file in the current directory. Let me examine it:tool: read_file({"path":"main.go"})tool: read_file({"path":"go.mod"})Claude: Here's a brief overview of the Go files in this project:**main.go**:- Implements a Claude AI agent that can interact with the local filesystem- Uses the Anthropic SDK for Go to communicate with Claude AI models- Provides two tools: `read_file` and `list_files` that allow Claude to read file contents and list directory contents- Manages a conversation flow between the user and Claude, handling tool execution when Claude needs to access files**go.mod**:- Defines the module dependencies- Main dependencies are the Anthropic SDK for Go and a JSON schema library- Uses Go 1.24.1This appears to be a simple terminal-based application that allows users to chat with Claude while giving the AI access to read local files and list directories.You: 

它先调用了 list_files,然后对找到的 Go 相关文件(main.go 和 go.mod)分别调用了 read_file。就像一个真人开发者会做的那样。

我们这个项目用的是哪个 Go 版本呢?看看 Claude 是如何找到答案的:

$ go run main.go Chat with Claude (use 'ctrl-c' to quitYou: What go version are we using in this project?Claude: I'll help you find the Go version being used in this project. Let me check the files to locate where the Go version might be specified.tool: list_files({})Claude: I can see there's a Go project here with several important files like `go.mod`, `go.sum`, and `main.go`. Let me check the `go.mod` file first, as it typically contains the Go version information.tool: read_file({"path":"go.mod"})Claude: According to the `go.mod` file, this project is using Go version 1.24.1.

Claude 先查看了目录内容,然后读取了 go.mod 文件,轻松找到了答案。

到现在为止,我们的代码量大约是 190 行。仔细体会一下这个数字。等你消化完,我们再来添加最后一个工具。
edit_file 工具
我们要添加的最后一个工具是 edit_file,一个能让 Claude 编辑文件的工具。
首先,为我们的新 edit_file 工具添加定义:
// main.go// 定义 edit_file 工具var EditFileDefinition = ToolDefinition{    Name: "edit_file"// 工具名称    Description: `编辑文本文件。在指定的文件中,将所有出现的 'old_str' 替换为 'new_str''old_str' 和 'new_str' 的值必须不同。如果通过 path 指定的文件不存在,将会创建该文件(此时 old_str 必须为空字符串)。`, // 工具描述,详细说明了功能、约束和创建文件的条件    InputSchema: EditFileInputSchema, // 输入模式    Function:    EditFile,            // 执行函数}// 定义 edit_file 工具的输入结构体type EditFileInput struct {    Path   string `json:"path" jsonschema_description:"要编辑或创建的文件的路径"`    OldStr string `json:"old_str" jsonschema_description:"要搜索并替换的文本 - 必须精确匹配。如果文件不存在,则此项必须为空字符串。"`    NewStr string `json:"new_str" jsonschema_description:"用于替换 old_str 的新文本。"`}// 生成 EditFileInput 的 JSON Schemavar EditFileInputSchema = GenerateSchema[EditFileInput]()
没错——我们通过字符串替换来实现文件编辑。Claude 3.7 模型特别擅长处理字符串替换任务,所以我们就告诉 Claude,它可以通过替换文本的方式来编辑文件,以此来实现 edit_file 工具。
下面是 EditFile 函数的实现代码:

// EditFile 函数实现了编辑或创建文件的逻辑func EditFile(input json.RawMessage) (stringerror) {    // 解析 JSON 输入    editFileInput := EditFileInput{}    err := json.Unmarshal(input, &editFileInput)    if err != nil {        // 如果 JSON 解析失败,返回错误        return "", fmt.Errorf("无法解析输入参数: %w", err)    }    // 输入参数校验    if editFileInput.Path == "" || editFileInput.OldStr == editFileInput.NewStr {        // 路径不能为空,且新旧字符串必须不同(允许 old_str 为空以创建文件)        return "", fmt.Errorf("无效的输入参数:路径不能为空,且 old_str 和 new_str 必须不同")    }    // 尝试读取文件内容    content, err := os.ReadFile(editFileInput.Path)    if err != nil {        // 如果读取文件时出错        if os.IsNotExist(err) && editFileInput.OldStr == "" {            // 如果错误是“文件不存在”且 old_str 为空,则尝试创建新文件            return createNewFile(editFileInput.Path, editFileInput.NewStr)        }        // 其他读取错误,直接返回        return "", fmt.Errorf("读取文件 '%s' 失败: %w", editFileInput.Path, err)    }    // 文件存在,执行替换操作    oldContent := string(content)    // 使用 strings.Replace 进行全局替换(-1 表示替换所有匹配项)    newContent := strings.Replace(oldContent, editFileInput.OldStr, editFileInput.NewStr, -1)    // 检查是否有实际替换发生    if oldContent == newContent && editFileInput.OldStr != "" {        // 如果内容未改变且 old_str 不为空,说明没有找到要替换的字符串        return "", fmt.Errorf("在文件 '%s' 中未找到要替换的字符串 '%s'", editFileInput.Path, editFileInput.OldStr)    }    // 将修改后的内容写回文件 (使用 0644 权限)    err = os.WriteFile(editFileInput.Path, []byte(newContent), 0644)    if err != nil {        return "", fmt.Errorf("写入文件 '%s' 失败: %w", editFileInput.Path, err)    }    // 操作成功,返回 "OK"    return "OK"nil}
这个函数会检查传入的参数,读取文件(如果文件不存在且符合创建条件则创建新文件),用 NewStr 替换所有出现的 OldStr,将修改后的内容写回文件,最后返回 "OK" 表示成功。
还缺少一个辅助函数 createNewFile
// createNewFile 函数负责创建新文件及其可能需要的父目录func createNewFile(filePath, content string) (stringerror) {    // 获取文件所在目录的路径    dir := path.Dir(filePath)    // 如果目录不是当前目录 "."    if dir != "." {        // 尝试创建所有必需的父目录 (使用 0755 权限)        err := os.MkdirAll(dir, 0755)        if err != nil {            // 如果创建目录失败,返回错误            return "", fmt.Errorf("创建目录 '%s' 失败: %w", dir, err)        }    }    // 将内容写入新创建的文件 (使用 0644 权限)    err := os.WriteFile(filePath, []byte(content), 0644)    if err != nil {        // 如果写入文件失败,返回错误        return "", fmt.Errorf("创建文件 '%s' 失败: %w", filePath, err)    }    // 文件创建成功,返回成功消息    return fmt.Sprintf("成功创建文件 %s", filePath), nil}
最后一步:将新定义的工具添加到 main 函数的工具列表中:
func main() {    // [...之前的代码...]    // 将 EditFileDefinition 添加到工具列表中-   tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition}+   tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition, EditFileDefinition}    // [...之前的代码...]}
好了… 一切就绪。你准备好让这个具备编辑能力的 Agent 大展身手了吗?
让我们指示 Claude 创建一个用 JavaScript 编写的 FizzBuzz 函数。
$ go run main.go Chat with Claude (use 'ctrl-c' to quit) You: hey claude, create fizzbuzz.js that I can run with Nodejs and that has fizzbuzz in it and executes itClaudeI'll create a fizzbuzz.js file that you can run with Node.js. Firstlet me check if the file already exists:tool: list_files({})ClaudeI don't see a fizzbuzz.js file, so I'll create one with a classic FizzBuzz implementation. This program will print numbers from 1 to 100, but for multiples of 3 it will print "Fizz"for multiples of 5 it will print "Buzz", and for numbers that are multiples of both 3 and 5 it will print "FizzBuzz".tool: edit_file({"path":"fizzbuzz.js","old_str":"","new_str":"/**\n * FizzBuzz implementation in JavaScript\n * Prints numbers from 1 to 100, but:\n * - For multiples of 3, print \"Fizz\" instead of the number\n * - For multiples of 5, print \"Buzz\" instead of the number\n * - For multiples of both 3 and 5, print \"FizzBuzz\"\n */\n\nfunction fizzBuzz(n) {\n    for (let i = 1; i <= n; i++) {\n        let output = '';\n        \n        if (i % 3 === 0) output += 'Fizz';\n        if (i % 5 === 0) output += 'Buzz';\n        \n        console.log(output || i);\n    }\n}\n\n// Execute FizzBuzz for numbers 1 to 100\nconsole.log('Running FizzBuzz:');\nfizzBuzz(100);"}Claude: I've created a fizzbuzz.js file that you can run with Node.js. The program:1Defines a function called `fizzBuzz` that takes a parameter `n` for the upper limit2Loops from 1 to n (set to 100 in the execution)3For each number:   - If it's divisible by 3, it adds "Fizz" to the output   - If it's divisible by 5, it adds "Buzz" to the output   - If it's divisible by both, it adds both resulting in "FizzBuzz"   - If it's not divisible by either 3 or 5, it outputs the number itself4The script automatically executes the function for numbers 1 to 100You can run this script using Node.js with the following command in your terminal:node fizzbuzz.jsThis will output the FizzBuzz sequence from 1 to 100 to your console.
对吧?!很厉害吧?这大概就是你能想到的 edit_file 最基本的实现 ,或者说,最基本的 Agent 实现。
tool: $ node fizzbuzz.jsRunning FizzBuzz:12Fizz4BuzzFizz78FizzBuzz11Fizz1314FizzBuzz16[...]
不过,让我们来让它实际编辑一个已存在的文件,而不只是创建新文件。这样问它:请修改 fizzbuzz.js,让它只打印到 15。
$ go run main.go Chat with Claude (use 'ctrl-c' to quit) YouPlease edit fizzbuzz.js so that it only prints until 15ClaudeI'll edit the fizzbuzz.js file to print only until 15 instead of its current limit. Firstlet me check the current content of the file to understand what changes are needed.tool: read_file({"path":"fizzbuzz.js"})ClaudeI can see that the file currently runs FizzBuzz up to 100I'll modify it to only run up to 15 instead by changing the parameter in the fizzBuzz function call.tool: edit_file({"path":"fizzbuzz.js","old_str":"// Execute FizzBuzz for numbers 1 to 100\nconsole.log('Running FizzBuzz:');\nfizzBuzz(100);","new_str":"// Execute FizzBuzz for numbers 1 to 15\nconsole.log('Running FizzBuzz:');\nfizzBuzz(15);"})ClaudeI also need to update the comment at the top of the file to reflect this change:tool: edit_file({"path":"fizzbuzz.js","old_str":"/**\n * FizzBuzz implementation in JavaScript\n * Prints numbers from 1 to 100, but:","new_str":"/**\n * FizzBuzz implementation in JavaScript\n * Prints numbers from 1 to 15, but:"})ClaudeThe changes have been successfully made to the fizzbuzz.js file. The program will now:1Only print FizzBuzz from 1 to 15 (instead of 1 to 100)2The documentation has been updated to reflect this changeThe function itself remains the same, as it takes a parameter to determine the upper limit, and we've simply changed that parameter from 100 to 15.

修改后的代码依然能正常工作:

$ node fizzbuzz.jsRunning FizzBuzz:12Fizz4BuzzFizz78FizzBuzz11Fizz1314FizzBuzz

临别前再来最后一个例子:创建一个名为 congrats.js 的脚本,用 rot13 算法解码下面的字符串‘Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!’,然后打印出解码结果。

$ go run main.go Chat with Claude (use 'ctrl-c' to quit) YouCreate a congrats.js script that rot13-decodes the following string 'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!' and prints itClaudeI'll create a congrats.js script that decodes the ROT13-encoded string and prints it. ROT13 is a simple letter substitution cipher that replaces a letter with the 13th letter after it in the alphabet.Let me create this file for you:tool: edit_file({"path":"congrats.js","old_str":"","new_str":"/**\n * A script to decode a ROT13-encoded string and print the result\n */\n\nfunction rot13Decode(encodedStr) {\n  return encodedStr.replace(/[a-zA-Z]/g, function(char) {\n    // Get the character code\n    const charCode = char.charCodeAt(0);\n    \n    // Handle uppercase letters\n    if (charCode >= 65 && charCode <= 90) {\n      return String.fromCharCode(((charCode - 65 + 13) % 26) + 65);\n    }\n    \n    // Handle lowercase letters\n    if (charCode >= 97 && charCode <= 122) {\n      return String.fromCharCode(((charCode - 97 + 13) % 26) + 97);\n    }\n    \n    // Return non-alphabetic characters as is\n    return char;\n  });\n}\n\nconst encodedMessage = 'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!';\nconst decodedMessage = rot13Decode(encodedMessage);\n\nconsole.log(decodedMessage);"})ClaudeI've created a JavaScript file called congrats.js that:1Defines a `rot13Decode` function that takes an encoded string and applies the ROT13 cipher to decode it2Takes the provided string 'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!'3Decodes it using the function4Prints the decoded message to the consoleYou can run this script using Node.js by typing `node congrats.js` in your terminal. The script will decode and display the hidden congratulatory message!

有用吗?我们来试试:

$ node congrats.jsCongratulations on building a code-editing agent!

是的!可行!

如果你和我在过去几个月里交流的所有工程师一样,那么在阅读这篇文章的过程中,你可能一直在期待我会在某个时刻说:“嗯,好吧,现实情况其实要复杂得多得多。”但事实并非如此。

这基本上就是构成一个代码编辑 Agent 核心循环的全部要素。诚然,要将它集成到你的编辑器里、精心调整系统提示、在恰当的时机提供反馈、设计用户界面、开发更完善的工具、支持多个 Agent 同时运行——这些都需要扎实的工程实践和大量的辛勤付出,而非依赖什么天才般的灵光乍现。

如今的这些大语言模型已经拥有了令人难以置信的能力。仅仅三百行代码和三个简单的工具,你现在就能与一个能够编辑你代码的“外星智能”进行对话了。如果你还在想“但是我们其实并没有真正做到什么了不起的…”。

——那就亲自去试试看!看看用我们刚才构建的基础你能走多远。我敢打赌,结果会远远超出你的预期。

这,就是我们坚信一切都将因此而改变的原因所在。

(文:特工宇宙)

发表评论

×

下载每时AI手机APP

 

和大家一起交流AI最新资讯!

立即前往