用 Ruby 构建 AI Agent 之一:消息循环
AI Agent(人工智能体)是指以 LLM(大语言模型)作为推理引擎,能够自主调用外部工具,规划并解决实际问题的程序。
构建 AI Agent 已经成为目前最火热的开发领域。从构建通用 AI 助手,到传统应用引入 AI 功能,都需要用到构建 AI Agent 的知识。
同时 AI Agent 又和传统软件有很大不同。传统软件需要程序员设计程序运转的完整流程,AI Agent 却要将思考外包给大语言模型,由大语言模型自主决定怎么做。构建 AI Agent 的过程,就好像给一个大脑安装五官和四肢。
无论是为了业务需要,还是为了提升个人能力,学习构建 AI Agent 都会有所收益。
为什么用 Ruby
在 LLM 训练领域,Ruby 可以说毫无存在感,那是 Python 和 C++ 的主场。构建 AI Agent 则回到了 Ruby 熟悉的领域——开发应用。
AI Agent 最主要的两个操作是调用外部 API 和数据持久化,其实用什么语言开发都差不多。Ruby 的优势在于开发效率。
下面是用 RubyLLM 库调用大语言模型的最小例子:
require "ruby_llm"
RubyLLM.chat.ask "Hello!"
Ruby 社区追求优雅代码的传统让 LLM 的库比别的语言更精简。
如果要为已有的 Ruby 应用添加 AI 功能,那么用同样的语言开发可以减少技术栈的复杂度。
接下来我们会逐步学习如何构建 AI Agent。
OpenAI API
构建 AI Agent 的第一步是调用 LLM API。根据 LLM 厂商的不同,API 会有不同风格。目前应用最广的 API 风格是 OpenAI API(Chat Completions),除了 OpenAI,大多数商业或开源 LLM 都支持 OpenAI API,所以本教程会以 OpenAI API 为基础。
[!NOTE]
如果使用 RubyLLM 库,由于它增加了一层封装,那么开发时可以不接触底层的 API,类似于 ActiveRecord 和 SQL。但由于多了一层封装不利于解释原理,所以教程的前几篇会以直接调用 API 为例。
下面看一个直接请求 OpenAI API 的例子:
export OPENAI_API_KEY="sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
curl https://api.openai.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Hello!"}]
}'
[!TIP]
你需要注册 LLM 服务,并在控制台创建 API Key。
你也可以使用其他厂商的 API,例如 DeepSeek,这时要将服务器域名替换为api.deepseek.com,并且把model替换为deepseek-v4-flash。
服务器返回:
{
"id": "chatcmpl-abc1234567890xyz",
"object": "chat.completion",
"created": 1748258880,
"model": "gpt-4o-2024-11-20",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello! 👋 How can I help you today?",
"refusal": null
},
"finish_reason": "stop",
"logprobs": null
}
],
"usage": {
"prompt_tokens": 8,
"completion_tokens": 11,
"total_tokens": 19,
"prompt_tokens_details": {
"cached_tokens": 0
},
"completion_tokens_details": {
"reasoning_tokens": 0
}
},
"system_fingerprint": "fp_abc123def456"
}
可以看到,我们对 API 进行了一来一回的请求。如果需要推理的内容比较多,返回时间可能会比较长。
还有另一种响应形式叫做流式响应,可以让 LLM 一边推理一边返回内容。要打开流式响应,需要在请求参数里面加上 "stream": true:
curl https://api.openai.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
-d '{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Hello!"}],
"stream": true
}'
这时会看到 API 返回多行 JSON 数据:
data: {"id":"chatcmpl-abc123456","object":"chat.completion.chunk","created":1748259000,"model":"gpt-4o-2024-11-20","choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-abc123456","object":"chat.completion.chunk","created":1748259000,"model":"gpt-4o-2024-11-20","choices":[{"delta":{"content":"Hello"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-abc123456","object":"chat.completion.chunk","created":1748259000,"model":"gpt-4o-2024-11-20","choices":[{"delta":{"content":"!"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-abc123456","object":"chat.completion.chunk","created":1748259000,"model":"gpt-4o-2024-11-20","choices":[{"delta":{"content":" 👋"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-abc123456","object":"chat.completion.chunk","created":1748259000,"model":"gpt-4o-2024-11-20","choices":[{"delta":{"content":" How can I help you today?"},"index":0,"finish_reason":null}]}
data: {"id":"chatcmpl-abc123456","object":"chat.completion.chunk","created":1748259000,"model":"gpt-4o-2024-11-20","choices":[{"delta":{"content":""},"index":0,"finish_reason":"stop"}]}
data: [DONE]
可以看到,每行数据只包含了一部分返回内容,开发应用的时候需要自己决定如何把内容呈现给用户。
这两种响应形式都有各自的用处,非流式响应数据冗余少,可以用在用户看不到的后台任务,增加传输效率;流式响应数据冗余多,可以用在用户看得到的前台功能,加强使用体验。
实际开发中我们不需要用 curl 调用 HTTP API,而是使用官方的 Ruby gem openapi-ruby。
Chat CLI
下面我们用 Ruby 实现一个最小可用的 Chat CLI:通过命令行和 LLM 对话。
创建文件 chat.rb,内容为:
require "bundler/inline"
gemfile do
source "https://rubygems.org"
gem "openai"
end
openai = OpenAI::Client.new(
api_key: ENV["OPENAI_API_KEY"],
)
print "Me> "
input = gets.chomp
chat_completion = openai.chat.completions.create(
messages: [
{ role: "user", content: input }
],
model: "gpt-4o"
)
content = chat_completion[:choices][0][:message][:content]
puts "AI> #{content}"
运行这个脚本,可以看到:
Me>
在提示符后输入内容,得到输出:
Me> hello
AI> Hello! 👋 How can I help you today?
输出之后,脚本退出,无法和 LLM 进行进一步交流。那么怎么和 LLM 实现多个来回的对话呢?下面我们来实现对话历史。
对话历史
首先要知道一个现状,LLM 是没有记忆的。如果想要让 LLM “继续”之前的对话,那么就需要把对话的历史——包括用户消息和 LLM 返回的消息——一并发送给 LLM。
所以我们要改造程序,把所有消息暂存到一个数组里,然后每次请求的时候附带完整的对话历史。
修改程序的主体部分:
# ...前略
messages = []
loop do
print "Me> "
input = gets.chomp
# 用户输入存到消息历史
messages << { role: "user", content: input }
chat_completion = openai.chat.completions.create(
messages: messages,
model: "gpt-4o"
)
content = chat_completion[:choices][0][:message][:content]
puts "AI> #{content}"
# 响应内容存到消息历史
messages << { role: "assistant", content: content }
end
再次执行脚本,实现和 LLM 的多轮对话:
Me> hello
AI> Hello! 👋 How can I help you today?
Me> who are you
AI> I am GPT, an AI assistant created by openai ...
输入 ctrl+c 可以终止程序。
流式响应
前面的例子实现了 LLM 的非流式响应,有时响应的等待时间很长,为了优化用户体验,接下来实现流式响应。
修改程序的主体部分:
messages = []
loop do
print "Me> "
input = gets.chomp
messages << { role: "user", content: input }
print "AI> "
content = ""
# 开启流式响应
stream = openai.chat.completions.stream_raw(
messages: messages,
model: "deepseek-v4-flash"
)
# 每获得一个片段,将片段内容输出到屏幕,并且合并内容
stream.each do |chunk|
delta = chunk[:choices][0][:delta][:content]
if delta
print delta
content << delta
end
end
puts
# 将合并后的内容存到消息历史
messages << { role: "assistant", content: content }
end
再次执行脚本,可以看到这次 LLM 响应内容会在推理过程中逐个输出到屏幕。
小结
这一节我们学习了如何调用 OpenAI API,以及用 Ruby 实现一个最小可用的 Chat CLI。
下一节我们将学习如何让 LLM 调用外部工具。