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

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

不到 400 行代码,从零构建一个优雅的 Agent 教程 不到 400 行代码,从零构建一个优雅的 Agent 教程 Modified April 19, 2025 Code block Go Copy 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: Code block Go Copy func main() { // [...之前的代码...] // 将 ListFilesDefinition 添加到工具列表中 tools := []ToolDefinition{ReadFileDefinition} + tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition} // [...之前的代码...] } 就这样。让我们问问 Claude 这个目录里都有些什么。 Code block TypeScript Copy $ 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's in the current directory. Let me list the files and directories for you. tool: list files({}) Claude: I can see several files and directories in the current directory: 1. Git related files and directories: directory with various Git configuration and tracking files file 2. Go project files: the main Go source code file Go module definition file Go module checksum file 3. Documentation: a markdown file, likely containing blog content 4. Environment configuration: likely an environment configuration file Would you like me to examine the contents of any specific file from this list? You: 搞定!它成功读取了目录内容。 但更厉害的是:Claude 知道如何组合使用这些工具。我们只需要提出一个能够引导它进行组合操作的问题即可: Code block SQL Copy $ 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: and 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.1 This 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 是如何找到答案的: Code block JavaScript Copy $ go run main.go Chat with Claude (use 'ctrl c' to quit) You: 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 , , and . Let me check the file first, as it typically contains the Go version information. tool: read file({"path":"go.mod"}) Claude: According to the file, this project is using Go version 1.24.1. Claude 先查看了目录内容,然后读取了 go.mod 文件,轻松找到了答案。 到现在为止,我们的代码量大约是 190 行。仔细体会一下这个数字。等你消化完,我们再来添加最后一个工具。 edit file 工具 我们要添加的最后一个工具是 edit file ,一个能让 Claude 编辑文件的工具。 首先,为我们的新 edit file 工具添加定义: Code block Java Copy // main.go // 定义 edit file 工具 var EditFileDefinition = ToolDefinition{ Name: "edit file", // 工具名称 Description: , // 工具描述,详细说明了功能、约束和创建文件的条件 InputSchema: EditFileInputSchema, // 输入模式 Function: EditFile, // 执行函数 } // 定义 edit file 工具的输入结构体 type EditFileInput struct { Path string OldStr string NewStr string } // 生成 EditFileInput 的 JSON Schema var EditFileInputSchema = GenerateSchema[EditFileInput]() 没错——我们通过字符串替换来实现文件编辑。Claude 3.7 模型特别擅长处理字符串替换任务,所以我们就告诉 Claude,它可以通过替换文本的方式来编辑文件,以此来实现 edit file 工具。 下面是 EditFile 函数的实现代码: Code block JavaScript Copy // EditFile 函数实现了编辑或创建文件的逻辑 func EditFile(input json.RawMessage) (string, error) { // 解析 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 : Code block Go Copy // createNewFile 函数负责创建新文件及其可能需要的父目录 func createNewFile(filePath, content string) (string, error) { // 获取文件所在目录的路径 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 } 这里没什么花哨的操作: list files 函数返回指定文件夹(默认为当前文件夹)下的文件和目录列表。如果这是一个正式的项目,我们可能(而且应该)进行成百上千项优化,但只是想揭示魔法帽里的秘密,这样实现就足够了。 需要注意的一点是:我们返回的是一个字符串列表,并且用末尾的斜杠来标识目录。这里没有固定的格式要求;只要 Claude 能够解析,任何格式都可以。 当然,我们还需要把 list files 这个新工具告知 Claude: 就这样。让我们问问 Claude 这个目录里都有些什么。 搞定!它成功读取了目录内容。 但更厉害的是:Claude 知道如何组合使用这些工具。我们只需要提出一个能够引导它进行组合操作的问题即可: 它先 调用了 list files ,然后对找到的 Go 相关文件(main.go 和 go.mod)分别调用了 read file 。就像一个真人开发者会做的那样。 我们这个项目用的是哪个 Go 版本呢?看看 Claude 是如何找到答案的: Claude 先查看了目录内容,然后读取了 go.mod 文件,轻松找到了答案。 到现在为止,我们的代码量大约是 190 行。仔细体会一下这个数字。等你消化完,我们再来添加最后一个工具。 edit file 工具 我们要添加的最后一个工具是 edit file ,一个能让 Claude 编辑文件的工具。 首先,为我们的新 edit file 工具添加定义: 没错——我们通过字符串替换来实现文件编辑。Claude 3.7 模型特别擅长处理字符串替换任务,所以我们就告诉 Claude,它可以通过替换文本的方式来编辑文件,以此来实现 edit file 工具。 下面是 EditFile 函数的实现代码: 这个函数会检查传入的参数,读取文件(如果文件不存在且符合创建条件则创建新文件),用 NewStr 替换所有出现的 OldStr ,将修改后的内容写回文件,最后返回 "OK" 表示成功。 还缺少一个辅助函数 createNewFile : 对吧?!很厉害吧?这大概就是你能想到的 edit file 最基本的实现 ,或者说,最基本的 Agent 实现。 不过,让我们来让它实际编辑一个已存在的文件,而不只是创建新文件。这样问它:请修改 fizzbuzz.js ,让它只打印到 15。 修改后的代码依然能正常工作: 临别前再来最后一个例子:创建一个名为 congrats.js 的脚本,用 rot13 算法解码下面的字符串 ‘Pbatenghyngvbaf ba ohvyqvat n pbqr rqvgvat ntrag!’ ,然后打印出解码结果。 有用吗?我们来试试: 是的!可行! 如果你和我在过去几个月里交流的所有工程师一样,那么在阅读这篇文章的过程中,你可能一直在期待我会在某个时刻说:“嗯,好吧,现实情况其实要复杂得多得多。”但事实并非如此。 这基本上就是构成一个代码编辑 Agent 核心循环的全部要素。诚然,要将它集成到你的编辑器里、精心调整系统提示、在恰当的时机提供反馈、设计用户界面、开发更完善的工具、支持多个 Agent 同时运行——这些都需要扎实的工程实践和大量的辛勤付出,而非依赖什么天才般的灵光乍现。 如今的这些大语言模型已经拥有了令人难以置信的能力。仅仅三百行代码和三个简单的工具,你现在就能与一个能够编辑你代码的“外星智能”进行对话了。如果你还在想“但是我们其实并没有真正做到什么了不起的…”。 ——那就亲自去试试看!看看用我们刚才构建的基础你能走多远。我敢打赌,结果会远远超出你的预期。 这,就是我们坚信一切都将因此而改变的原因所在。 🔗 原文链接: https://mp.weixin.qq.com/s/Hjf3xqTm... https://mp.weixin.qq.com/s/Hjf3xqTm... 宇宙编辑部 特工宇宙2025年04月18日 23:58 上海 编译丨特工小氧 审核丨特工少女 原文: 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 项目: 现在,打开 main.go 文件,第一步,先把我们需要的基本结构放进去: 这段代码目前还无法编译。但我们已经有了一个 Agent, 它能够访问 Anthropic Client(默认情况下会查找 ANTHROPIC API KEY 环境变量),并且可以通过读取终端的 stdin 来获取用户消息。 现在,我们来添加缺失的 Run() 方法: 代码量不大,对吧?总共九十行,其中最核心的就是 Run() 方法里的那个循环,它让我们能够与 Claude 持续对话。但这已经是这个程序运转的核心了。 就其核心功能而言,这个流程相当直接明了:我们首先打印一个提示,等待用户输入内容,将其加入对话记录,发送给 Claude,再将 Claude 的回应加入对话记录,打印出回应,然后周而复始。 这其实就是你用过的所有 AI 聊天应用的基本模式,只不过这次它运行在你的终端里。 我们来运行一下: 然后你就可以像下面这样和 Claude 聊天了: 注意,我们在多轮对话中一直保持着上下文。它从第一条消息就记住了我的名字。 conversation 这个切片在每一轮对话后都会增长,我们每次都会把整个对话历史发送出去。The Server——也就是 Anthropic 的服务器——本身是无状态的。它只知道当前 conversation 切片里的内容。维护对话状态是我们客户端的责任。 好了,我们继续往下看,目前这还算不上一个 Agent。那什么才算是 Agent 呢? 这是我的定义:一个能够接入并使用工具的 LLM,这赋予了它修改其自身上下文窗口之外信息的能力。 第一个工具 一个能够接入并使用工具的 LLM?什么是工具? 基本思路是这样的:你向模型发送一个 Prompt,明确告知如果它想使用“某个工具”,就应该以某种特定的方式进行回复。然后,你作为接收这条回复的一方,通过执行相应的操作来“使用该工具”,并将执行结果回复给模型。就这么简单。我们接下来将看到的所有内容,都只是建立在这个基础之上的抽象。 想象一下,你正和朋友聊天,你对他说:“在接下来的谈话里,如果你想让我抬起胳膊,你就眨一下眼睛。”这话听起来有点怪,但这个概念很容易理解。 我们现在就可以在不修改任何代码的情况下尝试一下。 效果非常好,第一次尝试就成功了,不是吗? 这些模型都经过了专门的训练和微调,能够熟练使用“工具”,并且它们非常乐意这样做。到了 2025 年的今天,它们在某种程度上已经“意识”到自己并非全知全能,并且可以利用工具来获取更多信息 (当然,这并非其内部运作的精确描述,但作为当前的解释已经足够形象了)。 总结来说,关于工具和工具使用的核心要素只有两点: 1. 你告知模型有哪些可用的工具。 2. 当模型表示想要执行某个工具时,它会通知你,你来执行这个工具,并将执行结果返回给模型。 为了让 第一步(告知模型工具信息)更简便,主流的模型提供商都内置了相应的 API,允许你将工具的定义信息随请求一起发送。 Read file 工具 为了定义 read file 这个工具,我们将遵循 Anthropic SDK 推荐的类型结构。但请记住:归根结底,这一切最终都会转化为字符串发送给模型。本质上还是那套“如果你想让我用 read file,就眨眨眼”的逻辑。 我们接下来要添加的每一个工具都需要包含以下要素: 1. 一个名称(Name); 2. 一段描述(Description),用于告知模型该工具的功能、适用场景、不适用场景、返回值等信息; 3. 一个输入模式(Input Schema),采用 JSON Schema 格式,精确描述该工具期望接收的输入参数及其形式; 4. 一个函数(Function),该函数负责接收模型传来的输入,实际执行工具对应的操作,并返回执行结果。 那么,让我们将这些要素添加到代码中: 现在,我们让 Agent 结构体能持有这些工具定义: 并且在 runInference 方法中,将这些工具定义发送给模型: 我们将工具定义发送过去,Anthropic 的服务器端会用这个 system prompt 将这些定义包装起来,然后加到我们的 conversation 对话历史中。之后,如果模型决定要使用某个工具,它就会以一种特定的格式进行回复。 好了,工具定义现在能够被发送了,但我们还没有真正定义任何工具。让我们现在就来定义 read file 工具: 代码不多,不是吗?只有一个核心函数 ReadFile ,以及两个模型会看到的描述信息:一个是描述工具本身的 Description ,另一个是描述该工具唯一输入参数(文件路径)的 jsonschema description 。 而 ReadFileInputSchema 和 GenerateSchema 这部分代码?我们需要它们来为工具定义生成一个 JSON Schema,这个 Schema 会被发送给模型。为了实现这个功能,我们引入了 jsonschema 这个包,所以需要先导入并下载它: 然后运行: • 接着,在 main 函数中,确保我们把刚才定义的 ReadFileDefinition 加入到工具列表中: 是时候检验成果了! 等等,发生了什么?它表示要使用工具了!当然,你的具体输出可能会略有不同,但这听起来确实像是 Claude 知道自己可以读取文件了,对吧? 问题在于,当 Claude “眨眼”示意(即返回工具使用请求)时,我们的程序并没有做出响应。我们需要修复这一点。 将 Agent 的 Run 方法替换为以下版本: 眯起眼睛仔细看,你会发现这大部分都是模板化的代码:当我们从 Claude 那里得到 message 回复时,我们检查 Claude 是否要求我们执行工具 (content.Type == "tool use"),如果是,就转交给 executeTool 方法处理。该方法查找对应的工具定义,反序列化输入参数,执行工具函数,然后返回结果。如果执行过程中发生错误,我们会在结果中标记出来。最后,我们将工具的执行结果(无论成功还是失败)发送回给 Claude,然后继续对话循环。 千真万确,核心逻辑就这些。让我演示给你看。 这条命令会在你的工作目录下创建一个 secret file.txt 文件,里面包含一个神秘的谜语。 就在这个目录下,运行我们更新后的、具备工具使用能力的 Agent,让它去看看这个文件里的内容: 让我们深吸一口气,一起惊叹吧。 你只是给了它一个工具,它就…在认为这个工具有助于完成任务的时候主动去使用它。记住:我们自始至终都没有明确指示,比如“如果用户问起某个文件,你就去读取它”。我们也没有说“如果看到像文件名一样的字符串,就想办法读取它”。不,完全没有。我们只是说“帮我解开这个文件里的谜题”,然后 Claude 就自己意识到可以通过读取文件来回答这个问题,并且就这样去做

在 小宇宙note 阅读完整内容