实战经验分享在7年老应用中引入现代AI Agent的设计方案和踩坑教训,对维护大型系统的团队有直接价值。
我是Mon Ami的工程总监,这是一家位于美国的初创公司,为老龄和残障案件工作者提供SaaS解决方案。我们在过去7年里构建了一个庞大的Ruby on Rails单体应用。
这是一个多租户解决方案,其中数据敏感性至关重要。我们有多层访问检查,但为了简化讲述,我们假设这一切都已被抽象到Pundit策略中。
虽然我不会把我们描述为处理大数据问题的团队,但我们确实有大量数据。特别是查找客户记录的操作,使用原始数据库操作的性能不够理想,所以我们构建了一个Algolia索引来解决这个问题。
我曾在《How to connect to multiple databases in Ruby on Rails 7》中单独讨论过部分数据层设置。
基于以上这些——庞大的单体应用、复杂的数据访问规则,以及我们所在业务的特性——构建AI Agent对我们来说还不是首要关注。
几周前我在旧金山的SF Ruby大会上。当然,大部分议题都深度聚焦在AI上。很多人讲述了他们如何使用Ruby和Rails将AI集成到各种产品中。
这些都是很好的演讲。但大多数都假设了一种我不工作的软件——没有强边界、没有多租户考虑、没有深度嵌入授权规则的系统。
我一直在想:这很有趣,但对我的世界来说映射不够直接。在Mon Ami,我们不能只发布一个试点版本,除非它通过严格的数据访问检查。
然后我看到了一场关于使用RubyLLM gem构建RAG类系统的演讲。对话(LLM调用)上下文通过函数调用(tools)进行增强。这时它点击了。我可以将复杂的访问逻辑编码到特定的函数调用中,并确保LLM可以访问我们的某些数据,而无需给它无限制的访问权限。
RubyLLM是一个很棒的gem,它用一个清晰的API抽象了与许多LLM提供商的交互。
gem "ruby_llm"
它通过initializer进行配置,配置你想使用的提供商的API密钥。
RubyLLM.configure do |config|
config.openai_api_key = Rails.application.credentials.dig(:openai_api_key)
config.anthropic_api_key = Rails.application.credentials.dig(:anthropic_api_key)
# config.default_model = "gpt-4.1-nano"
# Use the new association-based acts_as API (recommended)
config.use_new_acts_as = true
# Increase timeout for slow API responses
config.request_timeout = 600 # 10 minutes (default is 300)
config.max_retries = 3 # Retry failed requests
end
# Load LLM tools from main app
Dir[Rails.root.join('app/tools/**/*.rb')].each { |f| require f }
它提供了一个Conversation模型作为LLM线程的抽象。Conversation包含一组Messages。它还提供了定义结构化响应和可用函数调用的方式。
AVAILABLE_TOOLS = [
Tools::Client::SearchTool
].freeze
conversation = Conversation.find(conversation_id)
chat = conversation.with_tools(*AVAILABLE_TOOLS)
chat.ask 'What is the phone number for John Snow?'
Conversation通过传递一个模型(gpt-5、claude-sonnet-4.5等)来初始化,并有一个与其聊天的方法。
conversation = Conversation.new(model: RubyLLM::Model.find_by(model_id: 'gpt-4o-mini'))
RubyLLM提供了一个很棒的DSL来定义接受的参数(这些描述被传递给LLM作为上下文,因为它需要根据对话决定是否应该使用该工具)。工具实现一个execute方法,返回一个hash。这个hash随后被呈现给LLM。这就是所有需要的魔法。
class SearchTool < BaseTool
description 'Search for clients by name, ID, or email address. Returns matching clients.'
param :query,
desc: 'Search query - can be client name, ID, or email address',
type: :string
def execute(query:)
end
end
现在我们将构建一个适度的函数调用和消息接口。函数调用允许使用Algolia搜索客户,并确保生成的集合对用户可见(通过合并pundit策略)。
def execute(query:)
response = Algolia::SearchClient
.create(app_id, search_key)
.search_single_index(Client.index_name, {
query: query.truncate(250)
})
ids = response.hits.map { |hit| hit[:id] }.compact
base_scope = Client.where(id: ids)
client = Admin::Org::ClientPolicy::Scope.new(base_scope).resolve.first or return {}
{
id: client.id,
ami_id: client.slug,
slug: client.slug,
name: client.full_name,
email: client.email
}
end
LLM充当自然语言输入和工具之间的神奇粘合剂,决定使用哪个(如果有的话)工具来增强上下文,然后响应用户。任何模型都不应该从SaaS服务中知道Jon Snow的电话号码,但这种方法允许这种检索。
UI使用一个远程表单来排队Active Job。
= turbo_stream_from @conversation, :messages
.container-fluid.h-100.d-flex.flex-column
.sticky-top
%h2.mb-0
Conversation ##{@conversation.id}
.flex-grow-1
= render @messages
.p-3.border-top.bg-white.sticky-bottom#message-form
= form_with url: path, method: :post, local: false, data: { turbo_stream: true } do |f|
= f.text_area :content
= f.submit 'Send'
Job将处理Message。
class ProcessMessageJob < ApplicationJob
queue_as :default
def perform(conversation_id, message)
conversation = Conversation.find(conversation_id)
conversation.ask message
end
end
Conversation启用了广播刷新以在接收响应时更新UI。
class Conversation < RubyLLM::Conversation
broadcasts_refreshes
end
表单有一个stimulus控制器,用于检查是否有新消息被追加,以便滚动到对话末尾。
我为这个实现检查了几个OpenAI模型:gpt-5、gpt-4o、gpt-4。GPT-5有一个很大的上下文,意味着我们可以有长时间运行的对话,但由于有大量往返,需要3次或更多连续工具调用的查询延迟使Agent感觉迟钝。
另一方面,GPT-4有趣的是非常容易产生幻觉——急于用虚构数据响应查询,而不是调用必要的工具。GPT-4o到目前为止在速度和正确性之间取得了最好的平衡。
构建这个工具可能花了大约2-3天的Claude驱动开发(AIs building AIs)。构建这样的工具的难度和复杂性是最让我惊讶的事情。工具service object本质上是一个API控制器操作——传递输入并获得JSON返回。有趣的是。
我后来在《Building My Own Canva Over a Weekend》中为内部发布工具重用了相同的工作流模式。
在构建这个Agent之前,我查看了这个领域的其他gems。ActiveAgent(一个与LLM交互的有些类似的gem)是一个不错的竞争者,它将prompts移到一个view文件。它不适合我的需求,因为它没有内置的工具定义支持或长时间运行对话的支持。