用 94 行 Ruby 代码构建 Coding Agent
Radan Skorić's website
软件开发,主要使用 Ruby
[ ](https://radanskoric.com/articles/<javascript:location.href = 'mailto:' + ['me','radanskoric.com'].join('@')>) Home Coding agent in 94 lines of Ruby Post Cancel
用 94 行 Ruby 代码构建 Coding Agent
发布于 2025 年 5 月 13 日 更新于 2025 年 5 月 15 日 作者 _Radan Skorić _ 12 分钟 阅读
“构建一个功能齐全、可以编辑代码的 agent 并没有那么难。” Thorsten Ball 我发现了一篇文章:How to Build an Agent, or: The Emperor Has No Clothes。作者 Thorsten Ball 声称构建一个 coding agent 并不难,然后用大约 400 行 Go 代码构建了一个。在阅读代码时,我一直觉得其中很多都是样板代码。当作者写道:“……其中大部分是样板代码”时,我 敏锐的 怀疑得到了证实。
样板代码?Ruby 擅长消除样板代码,只留下本质。我想:用 Ruby 创建这个肯定会更简单。所以我尝试了一下。结果确实很简单!
用 Ruby 完成这个练习让我有了两个有趣的发现,我将在文章末尾分享。
好消息是,由于 agent 非常简单,所以本文的结尾真的不远了! 这是最棒的戏剧性时刻。
有兴趣尝试一下最终的 agent 吗? 在 GitHub 上找到完整的代码:radanskoric/coding_agent。 它包含一个方便的单行命令,可以通过 Docker 构建和运行它。
构建 Agent
一个 coding agent,如果只保留其最基本的功能,就是一个具有工具访问权限的 AI 聊天 agent。
大多数现代 LLM,尤其是来自大型供应商的 LLM,都可以使用工具。 在底层,工具只是具有对其目的和预期参数的描述的函数,以 LLM 可识别的方式格式化。
AI 聊天 agent 的基础是一个聊天循环:
- 读取用户提示。
- 将提示提供给 LLM。
- 将 LLM 的响应打印给用户。
- 重复,直到用户找到更好的事情做。
为了使其成为一个 agent,你需要给它一些工具。 事实证明,对于一个非常简单的 coding agent,你只需要 3 个工具:
- 读取文件: 给定一个文件,返回该文件的内容。
- 列出文件: 给定一个目录,返回该目录中文件的列表。
- 编辑文件: 给定一个文件,原始字符串和新字符串,通过将原始字符串替换为新字符串来更新文件。
值得注意的是,仅向连接 LLM 的聊天循环添加这 3 个工具,就可以将该程序转换为一个能够构建你下一个 startup 的 coding agent。1
让我们深入研究代码。
聊天循环
我们将使用 RubyLLM gem。 我们的用法非常简单,其他 gem 也可以工作,但我喜欢它非常干净的界面。
它很容易配置:
1
2
3
4
5
6
```
| ```
require "ruby_llm"
RubyLLM.configure do |config|
config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY", nil)
config.default_model = "claude-3-7-sonnet"
end
```
---|---
`
我将使用 Anthropic,但是要使用不同的提供商,只需更改配置即可。 该 gem [支持大多数提供商](https://radanskoric.com/articles/<https:/rubyllm.com/configuration#global-configuration-rubyllmconfigure>)。
我们将把循环封装在一个 `Agent` 类中,该类具有一个 `run` 方法,我们将从主方法中调用该方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| ```
require "ruby_llm"
class Agent
def initialize
@chat = RubyLLM.chat
end
def run
puts "Chat with the agent. Type 'exit' to ... well, exit"
loop do
print "> "
user_input = gets.chomp
break if user_input == "exit"
response = @chat.ask user_input
puts response.content
end
end
end
---|---
然后从主
run.rb` 文件中调用它:
1
2
```
| ```
require_relative "src/agent"
Agent.new.run
```
---|---
`
至此,这个非常短的程序就像一个普通的 AI 聊天工具一样工作:你可以像与其他任何 AI 聊天工具一样与它交谈。
> LLM 聊天不会保留对话历史记录。 它们通过发送包含每条新消息的完整记录来模拟连续对话。
> RubyLLM gem 会自动处理此问题,因此我们无需担心。
下一步:使其能够做更多的事情,而不仅仅是与我们聊天。
### 读取文件工具 [](https://radanskoric.com/articles/<#read-file-tool>)
首先,让我们实现一个读取文件工具。 RubyLLM 将工具实现为 Ruby 类,具有结构化的工具描述和一个用于工具功能的 `execute` 方法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| ```
require "ruby_llm/tool"
module Tools
class ReadFile < RubyLLM::Tool
description "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names."
param :path, desc: "The relative path of a file in the working directory."
def execute(path:)
File.read(path)
rescue => e
{ error: e.message }
end
end
end
---|---
实际的工具实现只包含一行代码:
File.read(path)`。 其余部分描述了 LLM 的工具,以便它“知道”何时调用它。
我们还捕获了文件读取错误,并将其返回给 LLM。 如果你将错误反馈给 LLM,它通常可以自行从简单错误(例如 缺少文件)中恢复。
最后,告诉 Agent
类中的聊天对象使用该工具:
1
2
3
4
5
6
7
8
9
10
```
| ```
require_relative "tools/read_file"
class Agent
def initialize
@chat = RubyLLM.chat
@chat.with_tools(Tools::ReadFile)
end
# ...
end
```
---|---
`
试一下! 聊天 agent 现在可以读取特定文件以回答问题:
1 2 3 4 5
| ```
$ ruby run.rb
Chat with the agent. Type 'exit' to ... well, exit
> What is the name of the first gem declared in Gemfile?
The name of the first gem declared in the Gemfile is "ruby_llm".
>
---|---
`
LLM 如何了解工具?
这个小小的题外话解释了工具的工作原理。 如果你不感兴趣,请跳到下一部分。
工具描述和参数会转换为 JSON 结构,该结构与对话记录一起发送给 LLM。 每次 LLM 撰写答案时,它都会收到完成其工作所需的一切。
工具描述 JSON 结构遵循特定的格式,不同提供商之间的格式有所不同。 该 gem 抽象了这些差异。 当 LLM 调用该工具时,它会返回格式化的响应。 该 gem 识别此格式,将其转换为工具实例方法调用,并将响应传递回 LLM。
对于前面的示例,这是发送给 Claude 的工具声明,用于我们的读取文件工具:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
```
| ```
{"name":"tools--read_file","description":"Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names.","input_schema":{"type":"object","properties":{"path":{"type":"string","description":"The relative path of a file in the working directory."}},"required":["path"]}}
```
---|---
`
这是 Claude 发回的消息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| ```
[{"type":"text","text":"I'll check the Gemfile to find the name of the first gem declared in it."},{"type":"tool_use","id":"toolu_01C5m4yKNyqhsyKehhtstnLA","name":"tools--read_file","input":{"path":"Gemfile"}}]
---|---
`
这是我们刚刚实现的工具的格式化响应:
1
2
3
4
5
```
| ```
{"type":"tool_result","tool_use_id":"toolu_01C5m4yKNyqhsyKehhtstnLA","content":"source \"https://rubygems.org\"\n\ngem \"ruby_llm\"\ngem \"dotenv\"\n\ngroup :development, :test do\n gem \"debug\"\n gem \"minitest\"\nend\n"}
```
---|---
`
请注意匹配的 ID。
Claude 经过训练可以识别工具格式并以特定格式响应。 该 gem 在 JSON 格式和工具对象上的普通 Ruby 方法调用之间进行转换。
### 列出文件工具 [](https://radanskoric.com/articles/<#list-files-tool>)
下一步:允许 agent 列出文件!
给定目录路径时,该工具会返回该目录中的文件名数组。 LLM 需要区分文件和目录。 我们将在目录名称后附加 `/`。 我从原始文章中提取了此信息。 通常,你会进行实验以找到最有效的格式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| ```
require "ruby_llm/tool"
module Tools
class ListFiles < RubyLLM::Tool
description "List files and directories at a given path. If no path is provided, lists files in the current directory."
param :path, desc: "Optional relative path to list files from. Defaults to current directory if not provided."
def execute(path: "")
Dir.glob(File.join(path, "*"))
.map { |filename| File.directory?(filename) ? "#{filename}/" : filename }
rescue => e
{ error: e.message }
end
end
end
---|---
`
该工具遵循与读取文件工具相同的模式。 将其添加到聊天中:
1
2
3
```
| ```
require_relative "tools/list_files"
#...
@chat.with_tools(Tools::ReadFile, Tools::ListFiles)
```
---|---
`
立即尝试聊天,询问有关现有文件的各种问题。 它将能够列出并读取它们。 它仍然无法修改文件。
### 编辑文件工具 [](https://radanskoric.com/articles/<#edit-file-tool>)
最终将其转换为合适的 coding agent 的工具是编辑文件工具。
此界面比其他界面更复杂。 它采用 3 个参数:文件路径、旧字符串和新字符串。 LLM 通过重复告诉该工具替换字符串来编辑文件。 最重要的是,如果没有与文件匹配的路径,该工具将创建一个新文件。 这允许 LLM 通过传递一个新路径并将旧字符串设置为 `""` 来写入新文件。
我也从原始文章中采用了这种方法。 它与 Claude 配合得特别好。 同样,你将通过实验自行发现这一点。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| ```
require "ruby_llm/tool"
module Tools
class EditFile < RubyLLM::Tool
description <<~DESCRIPTION
Make edits to a text file.
Replaces 'old_str' with 'new_str' in the given file.
'old_str' and 'new_str' MUST be different from each other.
If the file specified with path doesn't exist, it will be created.
DESCRIPTION
param :path, desc: "The path to the file"
param :old_str, desc: "Text to search for - must match exactly and must only have one match exactly"
param :new_str, desc: "Text to replace old_str with"
def execute(path:, old_str:, new_str:)
content = File.exist?(path) ? File.read(path) : ""
File.write(path, content.sub(old_str, new_str))
rescue => e
{ error: e.message }
end
end
end
---|---
`
将其添加到工具列表中:
1
2
3
```
| ```
require "tools/edit_file"
# ...
@chat.with_tools(Tools::ReadFile, Tools::ListFiles, Tools::EditFile)
```
---|---
`
这样,我们就拥有了一个 agent! 让我们对其进行测试。
## 测试 Agent [](https://radanskoric.com/articles/<#testing-the-agent>)
为了进行测试,我要求它用 Ruby 实现 ASCII 扫雷游戏,这是我之前在 [“用 100 行干净的 Ruby 代码实现扫雷游戏”](https://radanskoric.com/articles/</experiments/minesweeper-100-lines-of-clean-ruby>) 中写过的练习。
令我惊讶的是,agent 一次性完成了这项任务。 它使用了 135 行代码而不是我的 100 行代码,而且我认为我的代码更好,但是游戏可以正常运行! 请查看此 [gist](https://radanskoric.com/articles/<https:/gist.github.com/radanskoric/3609d411cbc035eaaaaf314eb6c4cd9a>) 中的输出和完整提示以自行判断。
但是,它编写的测试不起作用 - 它们有两个失败。 但是请对 agent 放松一点! 它必须在不运行它们的情况下干燥地编写测试代码。
## 改进 Agent [](https://radanskoric.com/articles/<#improving-the-agent>)
至此,我们仅用 75 行 Ruby 代码就匹配了原始文章的功能。 在有剩余空间的情况下,让我们通过添加另一个工具来改进它。
### 执行 shell 命令工具 [](https://radanskoric.com/articles/<#execute-shell-commands-tool>)
到目前为止,agent 只能干燥地编写代码。 通过赋予它运行命令的能力,我希望它能测试自己的代码并对其进行迭代。
为了避免它完全变成 [Skynet](https://radanskoric.com/articles/<https:/xkcd.com/1046/>),我不会让它独立执行命令。 相反,我们将在运行任何命令之前要求用户确认。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| ```
require "ruby_llm/tool"
module Tools
class RunShellCommand < RubyLLM::Tool
description "Execute a linux shell command"
param :command, desc: "The command to execute"
def execute(command:)
puts "AI wants to execute the following shell command: '#{command}'"
print "Do you want to execute it? (y/n) "
response = gets.chomp
return { error: "User declined to execute the command" } unless response == "y"
`#{command}`
rescue => e
{ error: e.message }
end
end
end
---|---
`
将其添加到工具列表中:
1
2
3
```
| ```
require "tools/run_shell_command"
# ...
@chat.with_tools(Tools::ReadFile, Tools::ListFiles, Tools::EditFile, Tools::RunShellCommand)
```
---|---
`
这是 agent 使用它来获取今天的日期的示例:
1 2 3 4 5 6
| ```
Chat with the agent. Type 'exit' to ... well, exit
> What date is today?
AI wants to execute the following shell command: 'date'
Do you want to execute it? (y/n) y
Today's date is Wednesday, May 14, 2025 (UTC time).
>
---|---
`
这样,我们的小 agent 就完成了,总共只有 94 行 Ruby 代码!
测试改进的 Agent
我再次使用相同的 扫雷提示 运行它,只是在末尾添加了 “确保测试通过。”。
这次,agent 工作了更长的时间,要求我运行 10 次 shell 命令。 它创建了一个更全面的 191 行 Ruby 实现,甚至添加了我从未要求的地雷标记2。
而且这次测试有效! 可能是因为它要求我运行 6 次。
有兴趣了解它生成的内容吗? 它在这个 GitHub 存储库中:radanskoric/coding_agent_minesweeper_test。
总结
对我来说,有两个主要的收获:
- 构建一个 coding agent 几乎不需要专业的 AI 技能。 它主要只是常规的软件开发。 另外,请注意,我通过添加另一个工具改进了原始文章的 agent。 我没有使用任何 AI 工程知识来实现这一点。 相反,我测试自己损坏的代码的丰富经验告诉我,这应该会有所作为。 并不是什么高深的学问。
- Ruby 真的很适合这个。 RubyLLM gem 出色的样板消除并非偶然。 首先:Ruby 是为程序员的幸福而构建的。 其次:Ruby 社区非常重视可读性。 这是 Ruby 中的常态。 其效果是:英语指令不会从 Ruby 代码中脱颖而出。 一切都很好地融合在一起。
因此,如果你有 coding agent 的想法,没有什么能阻止你进行实验。 本文中的 coding agent 可在 https://github.com/radanskoric/coding_agent 下以宽松的 MIT 许可证获得。 如果你 fork 了它,我会很高兴。
脚注
- 是的,我夸大了,但不是关于这是一个 coding agent。 构建 startup 的声明是夸张的。 ↩
- 它在未经我要求的情况下自行推出的事实本身就是一个问题,但由于这是一个通用的 LLM 问题,所以现在让我们忽略它。 ↩
articles ruby ai llm coding-agent 此帖子已获得作者 CC BY 4.0 许可。 分享
Master Ruby & Hotwire
You've learned the basics. What if you could ramp up your experience faster? I research a topic and write about it concisely so you get the same lesson in a fraction of the time. Subscribe and also get a printable Turbo 8 cheat-sheet right away: Subscribe & get the cheat-sheet! I respect your privacy. Unsubscribe at any time. Built with Kit
Recently Updated
- Coding agent in 94 lines of Ruby
- Rails 8 assets: Break down of how Propshaft and importmap-rails work together
- Rails 8 Assets: Deep dive into Propshaft
- Rails 8 Assets: Combining importmaps
- Rails 8 Assets: Adding a bundled package alongside vanilla setup
Trending Tags
rails hotwire ruby turbo assets how-stuff-works morphing turbo-frames correctness meta
Contents
Further Reading
Oct 30, 2023Experiment: Fully adding Sorbet and RBS to a small project I have also published a followup to this experiment: Should I add typing to my Ruby project? TL:DR; To get a better understanding of the value of gradual typing in Ruby projects I picked a small ... Jul 15, 2024Exercise: Minesweeper in 100 lines of clean Ruby This article is part 1. Part 2 uses code from this article to make the game multiplayer using Rails and Hotwire. Ruby is such an expressive language. You can often do surprisingly much with just a... Jul 28, 2024Exercise: Multiplayer Minesweeper with Rails and Hotwire In the last blog post I implemented Minesweeper as a CLI game, in just 100 lines of clean, readable ruby. That was a fun exercise. But CLI is not a great UI for minesweeper. So let’s package it int... Rails 8 Assets: Adding a bundled package alongside vanilla setup
© 2025 Radan Skorić. Some rights reserved. Using the Chirpy theme for Jekyll.
Trending Tags
rails hotwire ruby turbo assets how-stuff-works morphing turbo-frames correctness meta