标签 编译器 下的文章

解读“Cheating the Reaper”:在Go中与GC共舞的Arena黑科技

本文永久链接 – https://tonybai.com/2025/05/06/cheating-the-reaper-in-go

大家好,我是Tony Bai。

Go语言以其强大的垃圾回收 (GC) 机制解放了我们这些 Gopher 的心智,让我们能更专注于业务逻辑而非繁琐的内存管理。但你有没有想过,在 Go 这个看似由 GC “统治”的世界里,是否也能体验一把“手动管理”内存带来的极致性能?甚至,能否与 GC “斗智斗勇”,让它为我们所用?

事实上,Go 官方也曾进行过类似的探索。 他们尝试在标准库中加入一个arena包,提供一种基于区域 (Region-based) 的内存管理机制。测试表明,这种方式确实能在特定场景下通过更早的内存复用减少 GC 压力带来显著的性能提升。然而,这个官方的 Arena 提案最终被无限期搁置了。原因在于,Arena 这种手动内存管理机制与 Go 语言现有的大部分特性和标准库组合得很差 (compose poorly)

官方的尝试尚且受阻,那么个人开发者在 Go 中玩转手动内存管理又会面临怎样的挑战呢?最近,一篇名为 “Cheating the Reaper in Go” (在 Go 中欺骗死神/收割者) 的文章在技术圈引起了不小的关注。作者 mcyoung 以其深厚的底层功底,展示了如何利用unsafe包和对 Go GC 内部运作机制的深刻理解,构建了一个非官方的、实验性的高性能内存分配器——Arena。

这篇文章的精彩之处不仅在于其最终实现的性能提升,更在于它揭示了在 Go 中进行底层内存操作的可能性、挑战以及作者与 GC “共舞”的巧妙思路需要强调的是,本文的目的并非提供一个生产可用的 Arena 实现(官方尚且搁置,其难度可见一斑),而是希望通过解读作者这次与 GC “斗智斗勇”的“黑科技”,和大家一起更深入地理解 Go 的底层运作机制。

为何还要探索 Arena?理解其性能诱惑

即使官方受阻,理解 Arena 的理念依然有价值。它针对的是 Go 自动内存管理在某些场景下的潜在瓶颈:

  • 高频、小对象的分配与释放: 频繁触碰 GC 可能带来开销。
  • 需要统一生命周期管理的内存: 一次性处理比零散回收更高效。

Arena 通过批量申请、内部快速分配、集中释放(在 Go 中通常是让 Arena 不可达由 GC 回收)的策略,试图在这些场景下取得更好的性能。

核心挑战:Go 指针的“特殊身份”与 GC 的“规则”

作者很快指出了在 Go 中实现 Arena 的核心障碍:Go 的指针不是普通的数据。GC 需要通过指针位图 (Pointer Bits) 来识别内存中的指针,进行可达性分析。而自定义分配的原始内存块缺乏这些信息。

作者提供了一个类型安全的泛型函数New[T]来在 Arena 上分配对象:

type Allocator interface {
  Alloc(size, align uintptr) unsafe.Pointer
}

// New allocates a fresh zero value of type T on the given allocator, and
// returns a pointer to it.
func New[T any](a Allocator) *T {
  var t T
  p := a.Alloc(unsafe.Sizeof(t), unsafe.Alignof(t))
  return (*T)(p)
}

但问题来了,如果我们这样使用:

p := New[*int](myAlloc) // myAlloc是一个实现了Allocator接口的arena实现
*p = new(int)
runtime.GC()
**p = 42  // Use after free! 可能崩溃!

因为 Arena 分配的内存对 GC 不透明,GC 看不到里面存储的指向new(int)的指针。当runtime.GC()执行时,它认为new(int)分配的对象已经没有引用了,就会将其回收。后续访问**p就会导致 Use After Free。

“欺骗”GC 的第一步:让 Arena 整体存活

面对这个难题,作者的思路是:让 GC 知道 Arena 的存在,并间接保护其内部分配的对象。关键在于确保:只要 Arena 中有任何一个对象存活,整个 Arena 及其所有分配的内存块(Chunks)都保持存活。

这至关重要,通过强制标记整个 arena,arena 中存储的任何指向其自身的指针将自动保持活动状态,而无需 GC 知道如何扫描它们。所以,虽然这样做后, *New[*int](a) = new(int) 仍然会导致释放后重用,但 *New[*int](a) = New[int](a) 不会!即arena上分配的指针仅指向arena上的内存块。 这个小小的改进并不能保证 arena 本身的安全,但只要进入 arena 的指针完全来自 arena 本身,那么拥有内部 arena 的数据结构就可以完全安全。

1. 基本 Arena 结构与快速分配

首先,定义 Arena 结构,包含指向下一个可用位置的指针next和剩余空间left。其核心分配逻辑 (Alloc) 主要是简单的指针碰撞:

package arena

import "unsafe"

type Arena struct {
    next  unsafe.Pointer // 指向当前 chunk 中下一个可分配位置
    left  uintptr        // 当前 chunk 剩余可用字节数
    cap   uintptr        // 当前 chunk 的总容量 (用于下次扩容参考)
    // chunks 字段稍后添加
}

const (
    maxAlign uintptr = 8 // 假设 64 位系统最大对齐为 8
    minWords uintptr = 8 // 最小分配块大小 (以字为单位)
)

func (a *Arena) Alloc(size, align uintptr) unsafe.Pointer {
    // 1. 对齐 size 到 maxAlign (简化处理)
    mask := maxAlign - 1
    size = (size + mask) &^ mask
    words := size / maxAlign

    // 2. 检查当前 chunk 空间是否足够
    if a.left < words {
        // 空间不足,分配新 chunk
        a.newChunk(words) // 假设 newChunk 会更新 a.next, a.left, a.cap
    }

    // 3. 在当前 chunk 中分配 (指针碰撞)
    p := a.next
    // (优化后的代码,去掉了检查 one-past-the-end)
    a.next = unsafe.Add(a.next, size)
    a.left -= words

    return p
}

2. 持有所有 Chunks

为了防止 GC 回收 Arena 已经分配但next指针不再指向的旧 Chunks,需要在 Arena 中明确持有它们的引用:

type Arena struct {
    next  unsafe.Pointer
    left, cap uintptr
    chunks []unsafe.Pointer  // 新增:存储所有分配的 chunk 指针
}

// 在 Alloc 函数的 newChunk 调用之后,需要将新 chunk 的指针追加到 a.chunks
// 例如,在 newChunk 函数内部实现: a.chunks = append(a.chunks, newChunkPtr)

原文测试表明,这个append操作的成本是摊销的,对整体性能影响不大,结果基本与没有chunks字段时持平。

3. 关键技巧:Back Pointer

是时候保证整个arena安全了!这是“欺骗”GC 的核心。通过reflect.StructOf动态创建包含unsafe.Pointer字段的 Chunk 类型,并在该字段写入指向 Arena 自身的指针:

import (
    "math/bits"
    "reflect"
    "unsafe"
)

// allocChunk 创建新的内存块并设置 Back Pointer
func (a *Arena) allocChunk(words uintptr) unsafe.Pointer {
    // 使用 reflect.StructOf 创建动态类型 struct { Data [N]uintptr; BackPtr unsafe.Pointer }
    chunkType := reflect.StructOf([]reflect.StructField{
        {
            Name: "Data", // 用于分配
            Type: reflect.ArrayOf(int(words), reflect.TypeFor[uintptr]()),
        },
        {
            Name: "BackPtr", // 用于存储 Arena 指针
            Type: reflect.TypeFor[unsafe.Pointer](), // !! 必须是指针类型,让 GC 扫描 !!
        },
    })

    // 分配这个动态结构体
    chunkPtr := reflect.New(chunkType).UnsafePointer()

    // 将 Arena 自身指针写入 BackPtr 字段 (位于末尾)
    backPtrOffset := words * maxAlign // Data 部分的大小
    backPtrAddr := unsafe.Add(chunkPtr, backPtrOffset)
    *(**Arena)(backPtrAddr) = a // 写入 Arena 指针

    // 返回 Data 部分的起始地址,用于后续分配
    return chunkPtr
}

// newChunk 在 Alloc 中被调用,用于更新 Arena 状态
func (a *Arena) newChunk(requestWords uintptr) {
    newCapWords := max(minWords, a.cap*2, nextPow2(requestWords)) // 计算容量
    a.cap = newCapWords

    chunkPtr := a.allocChunk(newCapWords) // 创建新 chunk 并写入 BackPtr

    a.next = chunkPtr // 更新 next 指向新 chunk 的 Data 部分
    a.left = newCapWords // 更新剩余容量

    // 将新 chunk (整个 struct 的指针) 加入列表
    a.chunks = append(a.chunks, chunkPtr)
}

// (nextPow2 和 max 函数省略)

通过这个 Back Pointer,任何指向 Arena 分配内存的外部指针,最终都能通过 GC 的扫描链条将 Arena 对象本身标记为存活,进而保活所有 Chunks。这样,Arena 内部的指针(指向 Arena 分配的其他对象)也就安全了!原文的基准测试显示,引入 Back Pointer 的reflect.StructOf相比直接make([]uintptr)对性能有轻微但可察觉的影响。

性能再“压榨”:消除冗余的 Write Barrier

分析汇编发现,Alloc函数中更新a.next(如果类型是unsafe.Pointer) 会触发 Write Barrier。这是 GC 用来追踪指针变化的机制,但在 Back Pointer 保证了 Arena 整体存活的前提下,这里的 Write Barrier 是冗余的。

作者的解决方案是将next改为uintptr:

type Arena struct {
    next  uintptr // <--- 改为 uintptr
    left  uintptr
    cap   uintptr
    chunks []unsafe.Pointer
}

func (a *Arena) Alloc(size, align uintptr) unsafe.Pointer {
    // ... (对齐和检查 a.left < words 逻辑不变) ...
    if a.left < words {
        a.newChunk(words) // newChunk 内部会设置 a.next (uintptr)
    }

    p := a.next // p 是 uintptr
    a.next += size // uintptr 直接做加法,无 Write Barrier
    a.left -= words

    return unsafe.Pointer(p) // 返回时转换为 unsafe.Pointer
}

// newChunk 内部设置 a.next 时也应存为 uintptr
func (a *Arena) newChunk(requestWords uintptr) {
    // ... (allocChunk 不变) ...
    chunkPtr := a.allocChunk(newCapWords)
    a.next = uintptr(chunkPtr) // <--- 存为 uintptr
    // ... (其他不变) ...
}

这个优化效果如何?原文作者在一个 GC 压力较大的场景下(通过一个 goroutine 不断调用runtime.GC()模拟)进行了测试,结果表明,对于小对象的分配,消除 Write Barrier 带来了大约 20% 的性能提升。这证明了在高频分配场景下,即使是 Write Barrier 这样看似微小的开销也可能累积成显著的性能瓶颈。

更进一步的可能:Arena 复用与sync.Pool

文章还提到了一种潜在的优化方向:Arena 的复用。当一个 Arena 完成其生命周期后(例如,一次请求处理完毕),其占用的内存理论上可以被“重置”并重新利用,而不是完全交给 GC 回收。

作者建议,可以将不再使用的 Arena 对象放入sync.Pool中。下次需要 Arena 时,可以从 Pool 中获取一个已经分配过内存块的 Arena 对象,只需重置其next和left指针即可开始新的分配。这样做的好处是:

  • 避免了重复向 GC 申请大块内存
  • 可能节省了重复清零内存的开销(如果 Pool 返回的 Arena 内存恰好未被 GC 清理)。

这需要更复杂的 Arena 管理逻辑(如 Reset 方法),但对于需要大量、频繁创建和销毁 Arena 的场景,可能带来进一步的性能提升。

unsafe:通往极致性能的“危险边缘”

贯穿整个 Arena 实现的核心是unsafe包。作者坦诚地承认,这种实现方式严重依赖 Go 的内部实现细节和unsafe提供的“后门”。

这再次呼应了 Go 官方搁置 Arena 的原因——它与语言的安全性和现有机制的兼容性存在天然的矛盾。使用unsafe意味着:

  • 放弃了类型和内存安全保障。
  • 代码变得脆弱,可能因 Go 版本升级而失效(尽管作者基于Hyrum 定律认为风险相对可控)。
  • 可读性和可维护性显著降低。

小结

“Cheating the Reaper in Go” 为我们呈现了一场精彩的、与 Go GC “共舞”的“黑客艺术”。通过对 GC 原理的深刻洞察和对unsafe包的大胆运用,作者展示了在 Go 中实现高性能自定义内存分配的可能性,虽然作者的实验性实现是一个toy级别的。

然而,正如 Go 官方的 Arena 实验所揭示的,将这种形式的手动内存管理完美融入 Go 语言生态,面临着巨大的挑战和成本。因此,我们应将这篇文章更多地视为一次理解 Go 底层运作机制的“思想实验”和“案例学习”,而非直接照搬用于生产环境的蓝图。

对于绝大多数 Go 应用,内建的内存分配器和 GC 依然是最佳选择。但通过这次“与死神共舞”的探索之旅,我们无疑对 Go 的底层世界有了更深的敬畏和认知。

你如何看待在 Go 中使用unsafe进行这类底层优化?官方 Arena 实验的受阻说明了什么?欢迎在评论区分享你的思考! 如果你对 Go 的底层机制和性能优化同样充满好奇,别忘了点个【赞】和【在看】!

原文链接:https://mcyoung.xyz/2025/04/21/go-arenas


商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。

代码Agent没有护城河?我用Go标准库和DeepSeek证明给你看!

本文永久链接 – https://tonybai.com/2025/04/18/reproduce-thorsten-balls-code-agent

大家好,我是Tony Bai。

人工智能Agent风头正劲,但构建它们真的那么难吗?本文深入解读Thorsten Ball 的“皇帝新衣”论,并通过一个 Go 标准库 + OpenAI Compatible API + DeepSeek的实战复现,揭示代码编辑 Agent 的核心简洁性,探讨真正的挑战与机遇。

引言:AI Agent 的神秘光环与现实

近来,AI Agent(人工智能代理)无疑是技术圈最炙手可热的话题之一。从能自主编码的软件工程师,到能规划执行复杂任务的智能助手,Agent 展现出的潜力令人兴奋。但与此同时,它们往往被一层神秘的光环笼罩,许多人觉得构建一个真正能工作的 Agent,尤其是能与代码交互、编辑文件的 Agent,必然涉及极其复杂的技术和深不可测的“炼金术”。

事实果真如此吗?Agent 的核心真的那么难以企及吗?

戳破泡沫:Thorsten Ball 的“皇帝新衣”论

著名开发者、“Writing A Compiler In Go”和“Writing An Interpreter In Go”两本高质量大作的作者Thorsten Ball最近发表了一篇振聋发聩的文章——《How To Build An Agent》(如何构建一个Agent),副标题更是直言不讳:“The Emperor Has No Clothes”(皇帝没有穿衣服)。

Thorsten 的核心观点非常清晰:构建一个功能齐全、能够编辑代码的 Agent,其核心原理并不神秘,甚至可以说没有所谓的“护城河”。他认为,那些看似神奇的 Agent(自动编辑文件、运行命令、从错误中恢复、尝试不同策略)背后并没有什么惊天秘密。

核心不过是:一个强大的大语言模型 (LLM) + 一个循环 (Loop) + 足够的上下文额度 (Tokens) + 工具调用 (Tools)

而那些让Agent产品(如Cursor等)令人印象深刻、甚至上瘾的特性,更多来自于务实的工程实践和大量的“体力活” (Elbow Grease)——UI 设计、编辑器集成、错误处理、提示词工程、工具链优化等等。

为了证明核心逻辑的简单性,Thorsten 在文章中手把手地用不到 400 行 Go 代码,基于Anthropic Claude模型和其Go SDK,实现了一个具备基本代码编辑能力的Agent Demo。这个Demo 包含了三个关键的工具:

  • read_file: 读取文件内容。
  • list_files: 列出目录内容。
  • edit_file: 编辑文件内容——令人惊讶的是,这个核心的编辑功能,其实现方式极其基础,仅仅是基于字符串替换

就是这样一个看似“简陋”的Agent,却能在实验中成功完成创建JavaScript文件、修改代码逻辑、解码字符串等任务,展现了自主规划和调用工具的能力。

不止于理论:我们用标准库 + OpenAI Compatible API + DeepSeek复现并验证!

Thorsten 的文章和示例极具启发性。但为了进一步验证其观点的普适性(其实主要是我没有Claude的API key)——即这种 Agent 的核心逻辑是否独立于特定的 LLM 提供商或 SDK——我们进行了一项挑战:

在不使用任何第三方 LLM SDK 的情况下,仅依靠Go标准库 (net/http, encoding/json 等),将 Thorsten 的示例移植到使用通用的 OpenAI Compatible API(主要是Chat Completions API)。

这意味着我们需要:

  • 手动构建 HTTP 请求。
  • 处理 API 认证 (Bearer Token)。
  • 定义匹配 OpenAI API 格式的 Go 结构体。
  • 处理 JSON 的序列化与反序列化。
  • 实现 OpenAI 的工具调用 (Tool Calling) 规范,包括函数定义、参数传递和结果返回。

经过一番努力,我们成功了!这个纯标准库版本的 Go Agent 不仅编译通过,而且完美地复现了 Thorsten 文章中的所有实验,无论是文件读写、列表,还是代码创建与修改,其行为和效果与原版几乎一致。

这有力地证明了:代码 Agent 的核心交互范式(请求 -> LLM 思考/工具调用 -> 执行工具 -> 返回结果 -> LLM 再思考…)确实是通用的,不依赖于特定的 SDK 或 API 提供商。 掌握了底层的 HTTP 通信和 API 协议规范,用任何语言、任何网络库都可以构建类似的核心。

亲手体验:一步步复现你的代码编辑Agent

理论和别人的成功固然鼓舞人心,但亲手实践才能带来最真切的感受。“纸上得来终觉浅,绝知此事要躬行”。下面,我们将结合关键代码片段,指导你一步步复现这个使用 Go 标准库和 OpenAI Compatible API 构建的代码编辑 Agent 实验。

(注意:你需要准备一个 OpenAI API Key 或其他兼容 OpenAI API 的服务商提供的 Key 和 Endpoint,我这里使用的是兼容OpenAI API的DeepSeek的deepseek-chat大模型。此外,这里展示的是关键代码片段,完整代码请参考code-editing-agent-deepseek)

准备工作:

  1. 环境配置: 确保安装 Go 环境。设置环境变量 OPENAI_API_KEY,以及可选的 OPENAI_API_BASE (兼容 API 地址) 和 OPENAI_MODEL (模型名称,如 gpt-4o, gpt-3.5-turbo, 或其他兼容模型,比如deepseek-chat等)。
  2. 获取并运行代码: 将完整的 main.go 代码保存到 code-editing-agent 目录,执行 go mod tidy 下载依赖。
  3. 设置环境变量(见下面),然后运行 go run main.go 启动 Agent。你应该看到程序启动并等待你的输入。
$export OPENAI_API_KEY=<your_deepseek_api_key>
$export OPENAI_API_BASE=https://api.deepseek.com
$export OPENAI_MODEL=deepseek-chat

实验 0:基础对话 (验证连接)

  • 目标: 验证 Agent 与 LLM API 的基本连接和对话流程是否正常,此时不涉及工具调用。
  • 关键代码 (简化流程): Agent 的核心 Run 方法会接收用户输入,将其添加到 conversation 历史中,然后调用 callOpenAICompletion,最后处理并打印 AI 的文本回复。
// Simplified flow within Agent.Run for basic chat
func (a *Agent) Run(ctx context.Context) error {
    // ... setup ...
    conversation := []OpenAIChatCompletionMessage{ /* system prompt */ }
    for { // Outer loop for user input
        // ... get userInput from console ...
        conversation = append(conversation, OpenAIChatCompletionMessage{Role: "user", Content: userInput})

        // --- Call API ---
        resp, err := a.callOpenAICompletion(ctx, conversation)
        if err != nil {
            fmt.Printf("\u001b[91mAPI Error\u001b[0m: %s\n", err.Error())
            continue // Let user try again
        }
        if len(resp.Choices) == 0 { /* handle no choices */ continue }

        assistantMessage := resp.Choices[0].Message
        conversation = append(conversation, assistantMessage) // Add response to history

        // --- Print Text Response ---
        if assistantMessage.Content != "" {
            fmt.Printf("\u001b[93mAI\u001b[0m: %s\n", assistantMessage.Content)
        }

        // --- Tool Handling Logic would go here, but skipped for basic chat ---
        // In a basic chat without tool calls, the inner loop (if any) breaks immediately.

    } // End of outer loop
    return nil
}
  • 解释: 这一步主要测试 callOpenAICompletion 函数能否成功打包对话历史、发送 HTTP 请求到 API 端点、接收有效的文本响应,并由 Run 方法将其打印出来。
  • 步骤:

    1. 在 You: 提示符后输入:

      You: Hey! I'm Tony! How are you?

    2. 观察 AI 是否能正常回复,确认 API 连接。

  • Agent输出:

$./agent
Chat with AI (use 'ctrl-c' to quit)
You: Hey! I'm Tony! How are you?
AI: Hi Tony! I'm just a program, so I don't have feelings, but I'm here and ready to help you with anything you need. How can I assist you today?

实验 1 & 2:read_file 工具 (读取文件)

  • 目标: 测试 Agent 调用 read_file 工具读取指定文件内容的能力。
  • 关键代码:

工具定义 (ReadFileDefinition): 告诉AI 有一个名为 read_file 的工具,它需要一个path参数,并描述了其功能。

type ReadFileInput struct { // Defines the input structure for the tool
    Path string json:"path" jsonschema_description:"The relative path..." jsonschema:"required"
}

var ReadFileDefinition = ToolDefinition{
    Name:        "read_file",
    Description: "Read the contents of a given relative file path...",
    InputSchema: GenerateSchema[ReadFileInput](), // Generates {"type": "object", "properties": {"path": {"type": "string", ...}}, "required": ["path"]}
    Function:    ReadFile,                          // Links to the Go function below
}

工具执行函数 (ReadFile): 这个 Go 函数接收 AI 提供的参数(文件路径),并使用标准库 os.ReadFile 实际执行文件读取。

func ReadFile(input json.RawMessage) (string, error) {
    readFileInput := ReadFileInput{}
    err := json.Unmarshal(input, &readFileInput) // Parse the JSON arguments from AI
    if err != nil || readFileInput.Path == "" { /* handle parse error or missing path */ }

    content, err := os.ReadFile(readFileInput.Path) // Use Go standard library to read file
    if err != nil { /* handle file read error */ }

    return string(content), nil // Return file content as a string
}
  • 解释: 当用户请求涉及文件内容时,AI 会根据 ReadFileDefinition 的描述,决定调用 read_file 工具,并提供 path 参数。Agent 的 Run 循环捕获到这个工具调用请求,找到对应的 ReadFile 函数,传入参数并执行。函数读取文件后返回内容字符串,这个字符串会被包装成 role: tool 的消息发送回给 AI,AI 再根据文件内容生成最终答复。
  • 步骤 (实验 1 – secret-file.txt):
    1. 准备: 创建 secret-file.txt 文件,内容为“what animal is the most disagreeable because it always says neigh?”
    2. 输入: buddy, help me solve the riddle in the secret-file.txt file
    3. 观察: AI 回复 -> Tool Call: read_file({“path”:”secret-file.txt”}) -> AI 给出谜底。
    4. Agent输出:
You: buddy, help me solve the riddle in the secret-file.txt file
Tool Call: list_files({})
Tool Call: read_file({"path":"secret-file.txt"})
AI: The answer to the riddle is a **horse**, because it always says "neigh" (which sounds like "nay," meaning disagreement). 

Let me know if you need help with anything else, Tony!
  • 步骤 (实验 2 – 读取main.go):
    1. 输入: What’s going on in main.go? Be brief!
    2. 观察: AI 回复 -> Tool Call: read_file({“path”:”main.go”}) -> AI 给出代码摘要。
    3. 模型输出:
You: What's going on in main.go? Be brief!
Tool Call: read_file({"path":"main.go"})
AI: The `main.go` file is a Go program that sets up an **AI agent** capable of interacting with the local filesystem (reading, listing, and editing files). Here's a brief breakdown:

1. **Purpose**:
   - The agent acts as a helper, responding to user requests by either providing text answers or using tools to interact with files.

2. **Key Features**:
   - **Tools**: It has three built-in tools:
     - `read_file`: Reads file contents.
     - `list_files`: Lists files/directories.
     - `edit_file`: Edits or creates files.
   - **OpenAI Integration**: Uses the OpenAI API (like GPT-4) to process user input and decide when to use tools.
   - **Interactive CLI**: Takes user input from the command line and displays responses.

3. **Workflow**:
   - The agent maintains a conversation history with the user.
   - If a tool is needed, it calls the OpenAI API, executes the tool, and updates the conversation.

4. **Dependencies**:
   - Requires an `OPENAI_API_KEY` environment variable to work with the OpenAI API.

In short, it's a **file-system assistant powered by OpenAI**, designed to help with file operations via natural language commands. Let me know if you'd like more details!

实验 3:list_files 工具

  • 目标: 测试 list_files 工具,让 AI 感知当前工作目录的文件结构。
  • 关键代码:

工具定义 (ListFilesDefinition): 定义 list_files 工具,路径参数可选。

type ListFilesInput struct { // Input structure, path is optional
    Path string json:"path,omitempty" jsonschema_description:"Optional relative path..."
}

var ListFilesDefinition = ToolDefinition{
    Name:        "list_files",
    Description: "List files and directories at a given path. If no path...",
    InputSchema: GenerateSchema[ListFilesInput](),
    Function:    ListFiles, // Links to the Go function below
}

工具执行函数 (ListFiles): 使用 filepath.WalkDir 遍历目录,并将结果格式化为 JSON 数组字符串。

func ListFiles(input json.RawMessage) (string, error) {
    // ... Parse optional path from input ...
    dir := "." // Default to current directory
    if listFilesInput.Path != "" { dir = listFilesInput.Path }

    var files []string
    err := filepath.WalkDir(dir, func(...) error { // Use standard library WalkDir
        // ... build relative path ...
        // ... append path to files slice (add "/" for directories) ...
        return nil
    })
    if err != nil { /* handle walk error */ }

    result, err := json.Marshal(files) // Return as JSON array string
    if err != nil { /* handle marshal error */ }
    return string(result), nil
}
  • 解释: AI 被问及目录内容时,会调用 list_files 工具(通常不带参数,使用默认当前目录)。Go 代码执行 ListFiles 函数,遍历目录,将文件和目录名(目录带 /)的列表打包成 JSON 字符串返回给 AI。AI 再将这个列表呈现给用户。
  • 步骤:
    1. 输入: what do you see in this directory?
    2. 观察: AI 回复 -> Tool Call: list_files({}) -> AI 列出当前目录文件。
    3. Agent输出:
You: what do you see in this directory?
Tool Call: list_files({})
AI: Here’s what’s in the current directory:  

1. **Files**:
   - `.main.go.swp` (likely a temporary swap file for `main.go`).
   - `go.mod` and `go.sum` (Go module files for dependency management).
   - `main.go` (the main Go program file).
   - `secret-file.txt` (the file with the riddle you solved earlier).  

2. **Directory**:
   - `agent/` (a subdirectory, possibly containing agent-related code or resources).  

Let me know if you'd like to explore any of these further!

实验 4 & 5:组合工具 (list_files + read_file)

  • 目标: 观察 Agent 如何自主地组合使用多个工具(先 list_files 发现文件,再 read_file 读取特定文件)来完成更复杂的任务。
  • 关键代码 (Agent 的 Run 方法中的内部循环): 这是实现多步工具调用的核心。
// Inside Agent.Run method
for { // Outer loop for user input
    // ... get user input, add to conversation ...

    for { // <--- INNER LOOP: Handles multi-turn tool calls ---
        resp, err := a.callOpenAICompletion(ctx, conversation) // Call API
        // ... handle response ...
        assistantMessage := resp.Choices[0].Message
        conversation = append(conversation, assistantMessage) // Add assistant's response

        // Check for tool calls in the response
        if len(assistantMessage.ToolCalls) == 0 {
            // No tools called by AI in this turn. Print text response (if any)
            // and break the INNER loop to wait for next user input.
            if assistantMessage.Content != "" { /* print content */ }
            break // Exit INNER loop
        }

        // --- AI requested tools, execute them ---
        toolResults := []OpenAIChatCompletionMessage{}
        for _, toolCall := range assistantMessage.ToolCalls {
            // ... find tool definition by toolCall.Function.Name ...
            // ... execute the tool's Go function with toolCall.Function.Arguments ...
            // ... prepare resultMsg (role: "tool", content: output/error) ...
            toolResults = append(toolResults, resultMsg)
        }
        conversation = append(conversation, toolResults...) // Add tool results to history

        // DO NOT BREAK! Continue the INNER loop immediately.
        // The conversation now includes the tool results,
        // so the next call to callOpenAICompletion will send them back to the AI.
    } // <--- End of INNER LOOP ---

} // End of OUTER loop
  • 解释: 关键在于内部循环 (INNER LOOP)。当 AI 的回复包含 ToolCalls 时,Agent 会:

    1. 执行所有请求的工具。
    2. 将每个工具的执行结果(成功输出或错误信息)构造成 role: “tool” 的消息。
    3. 将这些 tool 消息添加到对话历史 conversation 中。
    4. 不退出内部循环,而是立即用更新后的 conversation(现在包含了工具结果)再次调用 callOpenAICompletion。
      这样,AI 就能在其下一步的思考中“看到”刚刚执行的工具结果,并据此决定是继续调用工具、生成最终答案,还是进行其他操作。这个循环会持续到 AI 的某次响应不再包含 ToolCalls 为止。
  • 步骤 (实验 4 – Go 文件信息):

    1. 输入: Tell me about all the Go files in here. Be brief!
    2. 观察: list_files -> AI 回复 -> read_file (main.go) -> AI 回复 -> read_file (go.mod) -> AI 回复 -> AI 最终汇总。
    3. Agent输出:
You: Tell me about all the Go files in here. Be brief!
AI: Here’s a quick rundown of the Go files in the directory:

1. **`main.go`**:
   - The core program file. It sets up an **AI agent** that interacts with the local filesystem using tools (`read_file`, `list_files`, `edit_file`).
   - Integrates with the **OpenAI API** for natural language processing.
   - Handles user input via the command line.  

2. **`go.mod` & `go.sum`**:
   - **`go.mod`**: Defines the Go module (project) and its dependencies (e.g., `github.com/invopop/jsonschema` for JSON schema generation).
   - **`go.sum`**: Locks dependency versions for reproducibility.  

That’s it! The other files (like `.main.go.swp`) are temporary or non-Go files. Let me know if you want details on any specific part!

注意:这里DeepSeek模型并没有再调用read_file!与预期不相符,也许和模型的实现有关。比如模型缓存了这些文件的内容。

  • 步骤 (实验 5 – Go 版本):
    1. 输入: What go version are we using in this project?
    2. 观察: list_files -> AI 回复 -> read_file (go.mod) -> AI 回复 -> AI 最终回答 Go 版本。
    3. Agent输出:
You: What go version are we using in this project?
Tool Call: read_file({"path":"go.mod"})
AI: The project is using **Go version 1.24.0**, as specified in the `go.mod` file.  

Let me know if you'd like help with anything else!

实验 6, 7, & 8:edit_file 工具 (创建、编辑文件)

  • 目标: 测试核心的 edit_file 工具,包括文件创建(当 old_str 为空且文件不存在时)和内容修改。
  • 关键代码:

工具定义 (EditFileDefinition): 定义 edit_file 工具,包含 path, old_str, new_str 三个参数。

type EditFileInput struct {
    Path   string json:"path" jsonschema_description:"The path..." jsonschema:"required"
    OldStr string json:"old_str" jsonschema_description:"Text to search for..."
    NewStr string json:"new_str" jsonschema_description:"Text to replace with..." jsonschema:"required"
}

var EditFileDefinition = ToolDefinition{
    Name:        "edit_file",
    Description: "Make edits to a text file. Replaces ALL occurrences...",
    InputSchema: GenerateSchema[EditFileInput](),
    Function:    EditFile, // Links to the Go function below
}

工具执行函数 (EditFile 及助手 createNewFile): 处理文件创建和修改逻辑。

func EditFile(input json.RawMessage) (string, error) {
    editFileInput := EditFileInput{}
    // ... parse input path, old_str, new_str ...

    content, err := os.ReadFile(editFileInput.Path)
    if err != nil {
        // Key logic: If file doesn't exist AND old_str is empty, try creating it.
        if os.IsNotExist(err) && editFileInput.OldStr == "" {
            return createNewFile(editFileInput.Path, editFileInput.NewStr)
        }
        return "", err // Other read error
    }

    // File exists, perform replacement
    oldContent := string(content)
    newContent := strings.Replace(oldContent, editFileInput.OldStr, editFileInput.NewStr, -1) // Replace all
    // ... check if replacement happened ...

    err = os.WriteFile(editFileInput.Path, []byte(newContent), 0644) // Write back
    // ... handle write error ...
    return "OK", nil
}

// Helper to create a new file (and parent directories if needed)
func createNewFile(filePath, content string) (string, error) {
    dir := path.Dir(filePath)
    if dir != "." && dir != "" {
        // Ensure parent directories exist
        if err := os.MkdirAll(dir, 0755); err != nil { /* handle error */ }
    }
    err := os.WriteFile(filePath, []byte(content), 0644) // Write the new file
    // ... handle error ...
    return fmt.Sprintf("Successfully created file %s", filePath), nil
}
  • 解释: edit_file 是最强大的工具。AI 根据用户的意图(创建或修改)来调用它,并提供必要的参数。Go 代码的核心逻辑在于:

    • 如果 os.ReadFile 返回“文件不存在”错误,并且 AI 提供的 old_str 是空字符串,则调用 createNewFile 来创建文件,将 new_str 作为初始内容。
    • 如果文件存在,则使用 strings.Replace(替换所有匹配项)执行内容替换,并用 os.WriteFile 写回磁盘。
      这种区分处理使得一个工具能同时处理创建和编辑两种场景。
  • 步骤 (实验 6 – 创建):

    1. 输入: hey buddy, create fizzbuzz.js…
    2. 观察: (可能 list_files) -> Tool Call: edit_file({“path”:”fizzbuzz.js”,”old_str”:”",”new_str”:”…”}) -> AI 回复成功。
    3. 验证: 退出 Agent,检查 fizzbuzz.js 是否生成。
    4. Agent输出与执行结果:
You: hey buddy, create fizzbuzz.js that I can run with Nodejs and that has fizzbuzz in it and executes it
Tool Call: edit_file({"path":"fizzbuzz.js","old_str":"","new_str":"// FizzBuzz in Node.js\nfunction fizzbuzz(n) {\n  for (let i = 1; i <= n; i++) {\n    if (i % 15 === 0) console.log(\"FizzBuzz\");\n    else if (i % 3 === 0) console.log(\"Fizz\");\n    else if (i % 5 === 0) console.log(\"Buzz\");\n    else console.log(i);\n  }\n}\n\n// Execute FizzBuzz for numbers 1 to 100\nfizzbuzz(100);"})
AI: Done! I've created **`fizzbuzz.js`** with a FizzBuzz implementation that runs in Node.js. Here's what it does:  

1. **Function**:
   - `fizzbuzz(n)` prints numbers from 1 to `n`, replacing multiples of 3 with "Fizz", multiples of 5 with "Buzz", and multiples of both with "FizzBuzz".  

2. **Execution**:
   - It runs for numbers **1 to 100** by default.  

### How to Run:
node fizzbuzz.js

Let me know if you'd like any tweaks!

生成的fizzbuzz.js的文件内容如下:

$cat fizzbuzz.js
// FizzBuzz in Node.js
function fizzbuzz(n) {
  for (let i = 1; i <= n; i++) {
    if (i % 15 === 0) console.log("FizzBuzz");
    else if (i % 3 === 0) console.log("Fizz");
    else if (i % 5 === 0) console.log("Buzz");
    else console.log(i);
  }
}

// Execute FizzBuzz for numbers 1 to 100
fizzbuzz(100);%

使用node运行该fizzbuzz.js的文件,输出如下:

$node fizzbuzz.js
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
... ...
97
98
Fizz
Buzz
  • 步骤 (实验 7 – 编辑):
    1. 准备: 确保 fizzbuzz.js 存在。
    2. 运行 Agent。
    3. 输入: Please edit fizzbuzz.js so that it only prints until 15
    4. 观察: read_file -> edit_file (改代码) -> (可能) edit_file (改注释) -> AI 回复成功。
    5. 验证: 退出 Agent,检查 fizzbuzz.js 是否修改。
    6. Agent输出与执行结果:
You: Please edit fizzbuzz.js so that it only prints until 15
Tool Call: edit_file({"path":"fizzbuzz.js","old_str":"// Execute FizzBuzz for numbers 1 to 100\nfizzbuzz(100);","new_str":"// Execute FizzBuzz for numbers 1 to 15\nfizzbuzz(15);"})
AI: Got it! I updated **`fizzbuzz.js`** to print FizzBuzz only up to **15** instead of 100.  

### How to Run:
node fizzbuzz.js

Now it’ll output:  

1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz

Let me know if you'd like further changes!

查看更新后的fizzbuzz.js:

$cat fizzbuzz.js
// FizzBuzz in Node.js
function fizzbuzz(n) {
  for (let i = 1; i <= n; i++) {
    if (i % 15 === 0) console.log("FizzBuzz");
    else if (i % 3 === 0) console.log("Fizz");
    else if (i % 5 === 0) console.log("Buzz");
    else console.log(i);
  }
}

// Execute FizzBuzz for numbers 1 to 15
fizzbuzz(15);%

运行更新后的fizzbuzz.js结果如下:

$node fizzbuzz.js
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
  • 步骤 (实验 8 – 创建):
    1. 输入: Create a congrats.js script that rot13-decodes…
    2. 观察: (可能 list_files) -> Tool Call: edit_file({“path”:”congrats.js”,”old_str”:”",”new_str”:”…”}) -> AI 回复成功。
    3. 验证: 退出 Agent,检查 congrats.js 是否生成,运行 node congrats.js 确认输出。
    4. Agent输出与执行结果:
You: Create a congrats.js script that rot13-decodes the following string 'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!' and prints it
AI: Here's the plan:
1. I'll create a **`congrats.js`** script.
2. It will **ROT13-decode** the string `'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!'`.
3. The decoded result will be printed to the console.  

Let me implement this for you.
Tool Call: edit_file({"path":"congrats.js","old_str":"","new_str":"// ROT13 decoder in Node.js\nfunction rot13(str) {\n  return str.replace(/[a-zA-Z]/g, function(c) {\n    const base = c <= 'Z' ? 65 : 97;\n    return String.fromCharCode((c.charCodeAt(0) - base + 13) % 26 + base);\n  });\n}\n\n// Encoded string\nconst encoded = 'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!';\n\n// Decode and print\nconsole.log(rot13(encoded));"})
AI: Done! I've created **`congrats.js`** for you. Here's what it does:  

1. **ROT13 Decoding**:
   - The script decodes the string `'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!'` using ROT13.  

2. **Output**:
   - Running it will print the decoded message to the console.  

### How to Run:  

node congrats.js

The decoded result should appear. Let me know if you'd like to tweak anything!

查看生成的congrats.js结果如下:

$cat congrats.js
// ROT13 decoder in Node.js
function rot13(str) {
  return str.replace(/[a-zA-Z]/g, function(c) {
    const base = c <= 'Z' ? 65 : 97;
    return String.fromCharCode((c.charCodeAt(0) - base + 13) % 26 + base);
  });
}

// Encoded string
const encoded = 'Pbatenghyngvbaf ba ohvyqvat n pbqr-rqvgvat ntrag!';

// Decode and print
console.log(rot13(encoded));%

运行生成的congrats.js结果如下:

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

通过这些结合了代码片段和解释的步骤,你应该能更清晰地理解 Agent 在每个实验中是如何利用其被赋予的工具和核心循环机制来完成任务的。这再次印证了 Thorsten Ball 的观点:核心很简单,但组合起来却能产生强大的效果

简单背后的深思:Agent 的真正壁垒在哪?

既然核心逻辑相对简单,那是否意味着构建一个优秀的 Agent 应用就没有门槛了呢?显然不是。Thorsten Ball 的“体力活” (Elbow Grease) 一词点醒了我们:真正的挑战和壁垒,在于核心逻辑之外的大量工程细节和产品打磨。

这包括但不限于:

  • 提示词工程 (Prompt Engineering): 如何设计出精确、高效、能引导 LLM 稳定输出预期格式和进行合理工具调用的 System Prompt 和 User Prompt?
  • 工具设计与健壮性 (Tool Design & Robustness): 如何设计出功能明确、接口清晰、并且足够健壮(能处理各种边缘情况和错误输入)的工具?简单的字符串替换编辑文件显然是不够的,更复杂的场景需要更精密的工具(如 AST 操作、diff 应用等)。
  • 状态管理与长上下文: 如何有效管理 Agent 的长期记忆、任务状态、以及在 LLM 的上下文窗口限制下处理复杂的多步骤任务?
  • 错误处理与恢复: 当 LLM 理解错误、工具执行失败或外部环境变化时,Agent 如何优雅地处理错误、进行重试或寻求用户帮助?
  • 用户体验与集成 (UI/UX & Integration): 如何将 Agent 无缝集成到用户的工作流中(如 IDE 插件、命令行工具、Web 应用)?如何提供直观、高效的交互界面?
  • 性能与成本 (Performance & Cost): 如何优化 Agent 的响应速度?如何控制频繁调用 LLM API 带来的成本?
  • 安全性: 如何确保 Agent 不会执行危险操作,或者被恶意利用?工具的权限控制至关重要。

这些才是构建一个能在现实世界中可靠、高效、安全地工作的 Agent 应用时,需要投入大量时间和精力去解决的真正工程难题。未来的 Agent 应用竞争,很可能就围绕着这些方面展开。

小结:人人皆可 Agent?拥抱实践的力量

Thorsten Ball 的文章和我们的复现实验,共同揭示了一个令人兴奋的事实:理解和开始构建 AI Agent 的门槛,比许多人想象的要低得多。 其核心概念是清晰且可及的。

这并不意味着打造卓越的 Agent 产品很容易,但它确实意味着,任何具备基本编程能力和对 LLM API 有所了解的开发者,都可以动手尝试,去探索 Agent 的可能性。

不要被表面的复杂性所迷惑,正如“皇帝的新衣”所揭示的,有时最强大的能力隐藏在最简洁的原理背后。现在,轮到你去发现、去实践、去创造了。

鼓励大家亲自尝试运行和修改这个Go Agent示例,感受一下与“你自己创造的智能体”协作编码的初步体验!

想更进一步?开启你的 Go & AI 精进之旅!

本文为你揭示了构建代码 Agent 的核心简洁性,但这仅仅是冰山一角。真正的挑战在于将这些基础概念,通过扎实的工程实践,转化为可靠、高效、能在实际场景中创造价值的应用。

如果你渴望在这条激动人心的道路上走得更远、更深,希望系统性学习如何用Go构建AI原生应用,深入探索 Agent、RAG(检索增强生成)、模型集成、向量数据库应用等前沿实践,我强烈推荐我的知识星球「Gopher的AI原生应用开发第一课」。在这里,我们不只有理论探讨,更有动手实战项目、最新的技术趋势解读、活跃的高质量社群交流,以及与我的直接互动答疑。如果你对用 Go 在 AI 时代创造真正有影响力的应用充满热情,这里将是你的最佳实践场和加速器。

扫码加入「Go & AI 精进营」知识星球,开启你的 AI 原生开发之旅! 并且,体系化Go核心进阶内容:「Go原理课」、「Go进阶课」、「Go避坑课」等独家深度专栏,将帮助你夯实Go内功

img{512x368}

你的支持,是创作的最大动力!

最后,如果你觉得本文对你有启发、有帮助:

  • 【分享】 给你的朋友、同事或技术社群,一起交流探讨。
  • 【关注】 我的公众号「[ iamtonybai ]」,第一时间获取更多Go语言、AI应用、云原生和架构思考与实践的硬核干货!

感谢你的耐心阅读与宝贵支持!期待在学习的路上与你继续同行!


img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格6$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 Go语言精进之路1 Go语言精进之路2 Go语言第一课 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats