Ruby 构建 AI Agent 的核心优势与生产级架构
1. 为什么是 Ruby一个被低估的 AI Agent 开发语言“Building AI Agent from Scratch with Ruby”——看到这个标题很多人的第一反应是皱眉Ruby不是写 Rails 网站的吗搞 AI 不该用 Python 吗OpenAI SDK、LangChain、LlamaIndex……哪个不是 Python 生态一统天下连 Hugging Face 的文档首页都默认切到 Python 标签。但如果你真在一线做过三年以上 AI 工具链开发尤其是面向中小团队、内部提效、快速验证场景的 Agent 构建你就会发现Ruby 不是“不能做”而是“被刻意忽略的最优解之一”。我过去两年带过 7 个不同行业的 Agent 项目从跨境电商客服自动归因系统到律所合同条款比对助手再到本地化医疗问诊预筛 Bot其中 4 个是 Ruby 主栈实现。不是为了标新立异而是实测下来——Ruby 的语法表现力、模块组织能力、REPL 迭代效率和运维轻量性在“小而精”的 Agent 场景中综合体验远超 Python。举个最直观的例子用 Ruby 写一个能调用天气 API 解析用户自然语言意图 生成结构化响应 记录 trace 到 SQLite 的完整 Agent核心逻辑代码控制在 120 行以内且每一行都在“说人话”。而同等功能的 Python 实现光是 import 语句、async/await 声明、pydantic 模型定义、logging 配置加起来就占掉 60 行真正干活的逻辑反而被埋在模板里。这背后是语言哲学的根本差异。Python 强调“显式即正义”所以你要显式声明类型、显式处理异步、显式管理依赖注入Ruby 坚信“约定优于配置”它的 Block、Proc、yield、method_missing、ObjectSpace.trace_object_allocations 等机制天然适配 Agent 的“行为编排—状态流转—上下文感知”三重范式。比如一个典型的 Agent 决策循环observe → think → act → reflect在 Ruby 里可以抽象成一个Agent::Cycle类用define_method :observe do |input| ... end动态挂载观察器用context OpenStruct.new快速承载临时状态用Kernel#tap链式注入日志与监控——整个流程像写散文一样自然。而 Python 要么陷入装饰器嵌套地狱要么被迫引入 heavy framework如 LangChain来掩盖语言层的表达力短板。更关键的是部署现实。一个 Python Agent 服务光是requirements.txt里torchtransformerslangchain三个包基础镜像就轻松突破 1.2GB冷启动 45 秒起而 Ruby 版本用http标准库、json标准库、sqlite3gem 仅 180KB和llm_client我维护的轻量 gem仅封装 OpenAI / Anthropic / Ollama 接口Docker 镜像压到 86MB冷启动 1.8 秒。这对需要高频启停、A/B 测试多版本、甚至嵌入到 Jekyll 静态站做边缘计算的场景是质的区别。这不是理论优势是我在给某省级政务知识库做“政策解读 Agent”时用 Ruby 替换原 Python 方案后API P95 延迟从 3.2s 降到 420ms 的真实数据。所以这个标题不是怀旧也不是炫技它直指一个被主流叙事遮蔽的事实AI Agent 的本质不是模型调用而是可靠的状态机 可信的上下文管理 可控的执行边界。而 Ruby 在这三件事上提供了比 Python 更干净、更少干扰、更贴近工程师直觉的工具集。接下来的内容不会教你如何“用 Ruby 跑通一个 LLM demo”而是带你从零构建一个具备生产级鲁棒性的 Agent 内核——它能自动恢复中断、拒绝越权请求、记录完整决策链、支持热插拔工具并且所有代码你都能在 10 分钟内看懂、改懂、跑通。2. 整体架构设计不造轮子但重新定义轮子的形状2.1 为什么放弃 LangChain / LlamaIndexRuby Agent 的三层洋葱模型几乎所有 Ruby AI 教程一上来就推荐llm_client或openai-ruby然后直接跳到 prompt engineering。这就像教人盖房子先发一袋水泥再讲怎么抹墙却从不解释地基怎么打、承重墙怎么布局。真正的 Agent 构建第一步永远是划清责任边界。我们采用经典的“洋葱架构”Onion Architecture但针对 Ruby 特性做了三处关键裁剪最外层Adapter 层协议适配负责对接外部世界HTTP 请求、CLI 输入、Slack Webhook、数据库连接。它只做一件事——把原始输入标准化为InputEnvelope对象含:source,:raw_payload,:timestamp,:session_id把输出标准化为OutputEnvelope含:content,:metadata,:trace_id。这里不用任何框架纯 Ruby Class attr_readerfreeze。为什么因为协议变更太频繁今天 Slack 改了事件格式明天企业微信加了新字段如果耦合在框架里每次都要翻源码改 monkey patch而自己写的 Adapter改一个parse_slack_event方法30 秒搞定。中间层Orchestrator 层决策中枢这是 Agent 的“大脑皮层”。它不碰模型不存数据只做三件事① 根据InputEnvelope的:session_id加载/初始化AgentContext② 调用Planner模块生成下一步动作序列如[:fetch_user_profile, :query_knowledge_base, :generate_response]③ 按序调度ToolExecutor执行动作并将结果注入上下文。关键设计是所有 Planner 输出必须是 Symbol 数组而非自由文本。这强制模型在“思考”阶段就进入结构化轨道避免后续解析失败。我们用case语句匹配 Symbol而不是正则提取 JSON 字符串——前者性能高 17 倍后者在高并发下极易因 token 截断导致解析崩溃。最内层Tool Context 层能力与记忆Tool是可插拔的 Ruby Module每个 Module 必须实现call(context:)方法返回ToolResult对象含:success,:data,:error。AgentContext是一个OpenStruct子类但重写了method_missing当访问不存在字段时自动从 Redis 缓存加载带 TTL写入时自动双写到 SQLite 归档表。这样Agent 既享受内存速度又保有持久化可靠性。没有 ORM没有 ActiveRecord只有SQLite3::Database#execute和Redis.new.get的裸调用——因为 Agent 的状态数据模式极简单key-value 对或小数组ORM 的抽象层只会增加延迟和故障点。这个三层模型彻底规避了 LangChain 的两大痛点一是Chain类的继承树过深调试时堆栈 200 行起步二是Memory组件强绑定特定后端如ConversationBufferMemory只支持字符串拼接无法满足我们“按 session 分片存储 按 topic 建索引”的需求。而我们的AgentContext一行代码就能切换存储后端AgentContext.storage RedisStorage.new(url: ENV[REDIS_URL])或者AgentContext.storage SqliteStorage.new(db_path: agent.db)。这种解耦不是为了炫技是在客户现场遇到 Redis 突然不可用时能 5 秒内切到本地 SQLite 继续服务保证 SLA 不破。2.2 核心组件选型逻辑为什么是这些 Gem而不是那些Ruby 生态的 AI 相关 gem 很多但生产环境可用的极少。我们严格遵循“单职责、低依赖、有维护者”三原则筛选最终锁定以下核心依赖全部已用于线上 6 个月Gem 名称版本作用关键优势替代方案为何被弃用llm_client0.8.2统一封装 LLM API仅依赖http标准库无faraday/typhoeus等重型 HTTP 客户端自动重试 指数退避支持流式响应解析openai-ruby依赖faraday导致在 Alpine Linux 上编译失败率 37%anthropic-ruby不支持 Ollama 本地模型dry-struct1.6.1定义输入/输出 Schema零运行时开销编译期生成方法比virtus快 4.2 倍错误信息精准到字段级active_model_serializers过重启动慢json_schemer验证慢且报错不友好concurrent-ruby1.2.3并发工具原生支持 Actor 模型ThreadLocalVar完美隔离 Agent 实例状态无 GC 压力async生态不稳定v3 升级导致 3 个 gem 兼容问题celluloid已废弃sqlite31.4.4本地持久化C 扩展插入 1000 条 trace 仅需 82ms支持 WAL 模式读写不阻塞sequel抽象层增加 15ms 延迟rodauth专为认证设计不适用通用存储redis4.8.1缓存与会话连接池自动管理Redis::Namespace支持多租户隔离brpoplpush原语完美实现任务队列sidekiq过重Agent 不需要后台作业connection_pool需手动管理易泄漏特别说明llm_client的设计细节它不提供client.chat.completions.create(...)这种模仿 OpenAI SDK 的接口而是抽象为LlmClient.new(provider: :openai).ask(prompt:, tools:)。tools参数接收一个ArrayToolDefinition每个ToolDefinition是一个Dry::Struct包含:name,:description,:parameters也是Dry::Struct。当调用ask时gem 自动将 tools 转为符合各 provider 规范的 JSON SchemaOpenAI 用function_callingAnthropic 用tool_useOllama 用自定义tools字段并解析响应中的tool_calls字段为 Ruby Symbol 数组。这意味着你的 Planner 模块完全不用关心底层模型差异——写一次[:search_web, :calculate_finance]就能在 OpenAI、Claude、甚至本地 Llama3 上无缝运行。这种抽象不是银弹但它把 80% 的模型迁移成本压缩到了修改一行provider:参数。2.3 安全与可观测性从第一天就内置的防御机制很多教程把“安全”放在最后章节仿佛它是可选附加项。但在 Agent 场景安全是架构的氧气没有它一切功能都不可用。我们的设计从第一天就植入三道防线输入净化层Input Sanitization所有InputEnvelope创建时自动触发Sanitizer.clean(input)。这个Sanitizer不是简单的gsub过滤script而是基于Loofah构建的白名单解析器只允许b,i,a hrefhttps://...等 7 个标签且href必须匹配URI.parse并通过Net::HTTP.head预检超时 1.5s。为什么这么重因为 Agent 常被嵌入到 CMS 或论坛用户可能输入恶意 Markdown 链接诱导 Agent 访问钓鱼 API。我们曾在线上环境捕获过)这类 payloadLoofah能 100% 拦截而正则替换会漏掉 Unicode 变体。执行沙箱层Execution SandboxToolExecutor调度工具前先检查ToolDefinition的:scope字段:public,:internal,:admin。InputEnvelope带有:user_role来自 JWT 解析或 session若:user_role :guest却尝试调用:admin工具则直接返回{error: Permission denied}且不记录 trace防信息泄露。更关键的是所有工具调用都包裹在Timeout.timeout(8.0)中超时抛出ExecutionTimeoutError由 Orchestrator 捕获并降级为:fallback_response。这避免了某个慢工具如未优化的数据库查询拖垮整个 Agent。可观测性管道Observability Pipeline每次AgentContext更新自动触发TraceLogger.log(context:)。这个 logger 不是简单写文件而是① 将 trace 数据序列化为 MessagePack比 JSON 小 40%解析快 3 倍② 通过UDPSocket发送到本地vector-agent③vector-agent批量写入 Loki日志 Prometheus指标agent_request_duration_seconds,tool_execution_errors_total。关键设计是trace 数据不包含原始 input/output只存:session_id,:tool_name,:duration_ms,:status。这满足 GDPR 的最小数据原则也防止敏感信息意外泄露。我们在某金融客户项目中靠这个管道在 3 分钟内定位到:fetch_stock_price工具因 Yahoo Finance API 变更导致的 100% 失败而传统日志 grep 需要 20 分钟。这三道防线不是堆砌功能而是对 Agent 运行本质的理解它不是一个静态程序而是一个持续与不可信外部世界交互的活体系统。架构设计的第一要务不是让它“能做什么”而是让它“不能做什么错事”。3. 核心模块实现从零手写一个可运行的 Agent 内核3.1 InputEnvelope 与 OutputEnvelope统一协议的基石协议统一是 Agent 可靠性的起点。我们不接受“这个接口返回 hash那个返回 OpenStruct另一个返回 custom class”的混乱。所有输入输出必须是确定性、可序列化、可验证的对象。以下是InputEnvelope的完整实现已删减注释保留核心逻辑# lib/agent/input_envelope.rb require dry-struct class InputEnvelope Dry::Struct attribute :source, Types::String.enum(web, cli, slack, email) attribute :raw_payload, Types::Hash attribute :timestamp, Types::Time attribute :session_id, Types::String attribute :user_id, Types::String.optional attribute :user_role, Types::String.enum(guest, member, admin).default { guest } attribute :ip_address, Types::String.optional # 自动净化 raw_payload 中的 HTML/JS def self.from_hash(hash) sanitized hash.dup if sanitized.key?(:raw_payload) sanitized[:raw_payload].is_a?(Hash) sanitized[:raw_payload] Sanitizer.clean(sanitized[:raw_payload]) end new(sanitized) end # 为 CLI 场景快速构造 def self.from_cli(args:) new( source: cli, raw_payload: { args: args }, timestamp: Time.now.utc, session_id: SecureRandom.hex(8), user_role: admin ) end # 验证 session_id 格式防暴力枚举 def valid_session_id? session_id.match?(/\A[a-f0-9]{16}\z/) end end注意几个关键点Types::String.enum强制source和user_role只能是预设值避免前端传source: hacker导致路由错乱from_hash是工厂方法内部调用Sanitizer.clean确保净化逻辑不被绕过from_cli提供命令行快捷入口session_id用SecureRandom.hex(8)生成而非rand.to_s后者熵值不足易被预测valid_session_id?是业务规则校验不在初始化时抛异常而是在 Orchestrator 中调用便于统一错误处理。对应的OutputEnvelope更精简但承担着关键的可观测性职责# lib/agent/output_envelope.rb class OutputEnvelope attr_reader :content, :metadata, :trace_id def initialize(content:, metadata: {}, trace_id: nil) content content metadata metadata.merge( generated_at: Time.now.utc.iso8601, ruby_version: RUBY_VERSION ) trace_id trace_id || SecureRandom.uuid end # 序列化为 JSON但过滤敏感字段 def to_json { content: content, metadata: metadata.except(:raw_input, :full_context_dump), trace_id: trace_id }.to_json end # 为日志系统提供结构化字段 def log_fields { trace_id: trace_id, content_length: content.to_s.length, metadata_keys: metadata.keys } end end这里to_json的except(:raw_input, :full_context_dump)是硬性安全策略——即使开发者误在metadata中塞入原始输入序列化时也会被剥离。而log_fields方法返回的哈希直接喂给TraceLogger确保每条日志都带trace_id方便在 Loki 中用{jobagent} | trace_idxxx一键追踪完整链路。这种设计让“可观测性”不再是事后补救而是对象创建时就内建的能力。3.2 AgentContext有记忆、有状态、有边界的智能体容器AgentContext是 Agent 的灵魂。它必须同时满足① 线程安全支持并发请求② 可持久化重启不丢状态③ 可扩展动态加载工具④ 可审计所有变更留痕。我们用OpenStruct作为基类但重写其核心行为# lib/agent/agent_context.rb require ostruct require concurrent-ruby class AgentContext OpenStruct # 存储后端可插拔 storage SqliteStorage.new(db_path: agent.db) # 线程局部变量确保每个请求有独立 context 实例 ThreadLocalVar Concurrent::ThreadLocalVar def self.for_session(session_id:) # 先查内存缓存ThreadLocalVar context ThreadLocalVar.new { load_from_storage(session_id) } context.value || load_from_storage(session_id) end def self.load_from_storage(session_id) data storage.get(session_id: session_id) return new if data.nil? # 从存储加载时自动注入元数据 new(data.merge( loaded_at: Time.now.utc, storage_backend: storage.class.name )) end # 重写 method_missing实现懒加载 def method_missing(name, *args) # 如果是 setter直接赋值并保存 if name.to_s.end_with?() key name.to_s.chop.to_sym super(key, *args) save_to_storage return end # 如果是 getter先查自身再查存储 value table[name] return value unless value.nil? # 懒加载从存储中取但只取一次存入内存 stored_value storage.get_field(session_id: self.session_id, field: name.to_s) table[name] stored_value stored_value end # 保存到存储同步触发 def save_to_storage storage.set( session_id: self.session_id, data: table.to_h.merge(updated_at: Time.now.utc) ) end # 清理内存释放资源 def cleanup! ThreadLocalVar.current.value nil end end这个实现的精妙之处在于ThreadLocalVar的运用。Concurrent::ThreadLocalVar为每个线程维护独立副本AgentContext.for_session首先尝试获取线程局部的 context没有才去存储加载。这带来两个好处① 高并发下95% 的状态访问走内存无需锁竞争② 每个请求的 context 完全隔离user_id修改不会影响其他请求。而method_missing的重写实现了“读时加载、写时保存”的透明持久化——开发者调用context.user_profile时如果内存没有自动从 SQLite 查调用context.conversation_history [...]时自动序列化并保存。整个过程对业务代码完全透明没有context.load_user_profile()这样的侵入式调用。我们还为AgentContext添加了审计能力# lib/agent/agent_context.rb (续) class AgentContext OpenStruct # 记录所有字段变更 def [](key, value) audit_log { field: key.to_s, old_value: table[key], new_value: value, changed_at: Time.now.utc, thread_id: Thread.current.object_id } super end private def audit_log audit_log || [] end end每次context[:foo] bar都会在内存中追加一条审计日志。在save_to_storage时这些日志会一并写入audit_logs表。这让我们能在事后还原“为什么这个 session 的user_role从member变成了admin”——答案就藏在 audit_log 的第 3 条记录里指向某次:upgrade_role工具的执行。这种设计把“可追溯性”变成了对象的固有属性而非额外的日志分析工作。3.3 Orchestrator决策中枢的有限状态机实现Orchestrator 是 Agent 的指挥官它必须将模糊的自然语言输入转化为精确的机器指令序列。我们不依赖大模型做“端到端思考”而是用分层决策第一层用轻量模型如 Phi-3-mini做意图分类第二层用主模型如 GPT-4做工具选择。以下是核心Orchestrator类# lib/agent/orchestrator.rb class Orchestrator # 意图分类映射表可热更新 INTENT_MAP { greeting [:respond_greeting], question [:plan_answer], command [:parse_command, :execute_command], unknown [:fallback_response] }.freeze def self.route(input_envelope:) context AgentContext.for_session(session_id: input_envelope.session_id) # Step 1: 意图识别轻量模型快 intent classify_intent(input_envelope.raw_payload) actions INTENT_MAP[intent] || INTENT_MAP[unknown] # Step 2: 执行动作序列 result actions.reduce(context) do |ctx, action| begin ToolExecutor.execute(action, context: ctx) rescue StandardError e # 记录错误但不中断流程 TraceLogger.log_error( session_id: ctx.session_id, tool: action, error: e.message, backtrace: e.backtrace.first(5) ) ctx end end # Step 3: 生成最终输出 OutputEnvelope.new( content: generate_response(result), metadata: { intent: intent, actions: actions }, trace_id: result.trace_id || SecureRandom.uuid ) end private def self.classify_intent(payload) # 使用本地 Phi-3-mini 模型Ollama response LlmClient.new(provider: :ollama).ask( prompt: Classify intent of this user message: #{payload.to_s[0..200]}, tools: [ ToolDefinition.new(name: greeting, description: User says hello or thanks), ToolDefinition.new(name: question, description: User asks a factual question), ToolDefinition.new(name: command, description: User gives an instruction like search or calculate) ] ) # 解析 response.tool_calls 返回的 Symbol 数组 response.tool_calls.first.name.to_s || unknown end def self.generate_response(context) # 主模型生成自然语言响应 LlmClient.new(provider: :openai).ask( prompt: Generate a concise, helpful response based on context: #{context.to_h}, tools: [ToolDefinition.new(name: format_response, parameters: { type: object, properties: { text: { type: string } } })] ).content end end这个Orchestrator的关键创新是意图驱动的动作编排。它不把所有逻辑压给一个大模型而是用轻量模型快速分类再用专业模型执行。classify_intent方法中我们故意把payload.to_s[0..200]截断因为意图识别不需要全文——首句往往就决定了是问候、提问还是指令。这使分类延迟从 1200ms 降到 180ms。而actions.reduce的链式调用确保每个工具的输出成为下一个工具的输入形成清晰的数据流。ToolExecutor.execute是一个简单的 dispatcher# lib/agent/tool_executor.rb class ToolExecutor TOOLS { respond_greeting: -(ctx) { ctx.greeting_sent true; ctx }, plan_answer: -(ctx) { ctx.answer_plan generate_answer_plan(ctx); ctx }, parse_command: -(ctx) { ctx.parsed_command parse_natural_language(ctx.raw_payload); ctx }, execute_command: -(ctx) { run_command(ctx.parsed_command); ctx }, fallback_response: -(ctx) { ctx.fallback_used true; ctx } }.freeze def self.execute(tool_name, context:) tool TOOLS[tool_name] raise ArgumentError, Unknown tool: #{tool_name} unless tool # 权限检查 check_permission(tool_name, context.user_role) # 执行并计时 start Time.now result tool.call(context) duration ((Time.now - start) * 1000).round(1) # 记录 trace TraceLogger.log_tool( session_id: context.session_id, tool: tool_name, duration_ms: duration, status: :success ) result end private def self.check_permission(tool_name, user_role) permission_map { respond_greeting: %w[guest member admin], plan_answer: %w[guest member admin], parse_command: %w[member admin], execute_command: %w[admin], fallback_response: %w[guest member admin] } raise PermissionError unless permission_map[tool_name].include?(user_role) end end这里TOOLS是一个冻结的 Hash所有工具都是 Proc 对象没有类、没有继承、没有 DI 容器。这极大降低了复杂度——添加新工具只需往 Hash 里加一行:new_tool -(ctx) { ... }。而check_permission的硬编码权限映射比 RBAC 框架更高效、更易审计。当客户要求“禁止 guest 用户执行任何数据库操作”时我们改一行parse_command: %w[member admin]5 秒完成无需重启服务。3.4 Tool 定义与执行可插拔能力的 Ruby 原生实现Tool 是 Agent 的肌肉。我们坚持“一个文件一个 Tool”的原则每个 Tool 是一个独立的 Ruby 文件位于lib/agent/tools/目录下。以search_web.rb为例# lib/agent/tools/search_web.rb require http require json module Agent module Tools class SearchWeb # 必须实现 call 方法接收 context 并返回 ToolResult def self.call(context:) query context.search_query || context.raw_payload.dig(query) || return ToolResult.error(No search query provided) if query.strip.empty? # 调用 SerpAPI示例实际可换 Bing/Google Custom Search response HTTP.timeout(5.0).get( https://serpapi.com/search.json, params: { q: query, engine: google, api_key: ENV[SERPAPI_KEY] } ) if response.code 200 data JSON.parse(response.to_s) results data[organic_results].first(3).map do |r| { title: r[title], link: r[link], snippet: r[snippet] } end ToolResult.success(data: results) else ToolResult.error(SerpAPI failed: #{response.code}) end end end end endToolResult是一个极简的值对象# lib/agent/tool_result.rb class ToolResult attr_reader :success, :data, :error def self.success(data: nil) new(success: true, data: data) end def self.error(message) new(success: false, error: message) end def initialize(success:, data: nil, error: nil) success success data data error error end def to_h { success: success, data: data, error: error } end end为什么不用Struct或OpenStruct因为ToolResult必须是不可变的immutable。一旦创建success就不能被修改这保证了 Tool 执行的确定性。而to_h方法提供标准序列化接口方便TraceLogger记录。Tool 的注册和发现是自动化的# lib/agent/tool_registry.rb class ToolRegistry tools {} def self.register(name, klass) tools[name] klass end def self.all tools end # 自动扫描 lib/agent/tools/ 下的所有文件 def self.load_all Dir.glob(File.join(__dir__, .., tools, *.rb)).each do |file| require file # 文件名转 tool name: search_web.rb - :search_web name File.basename(file, .rb).to_sym # 类名应为 Agent::Tools::SearchWeb klass Object.const_get(Agent::Tools::#{name.to_s.split(_).map(:capitalize).join}) register(name, klass) end end end # 在应用启动时调用 ToolRegistry.load_all这种“约定优于配置”的方式让添加新 Tool 变得极其简单新建lib/agent/tools/calculate_finance.rb写好Agent::Tools::CalculateFinance.call重启应用或热重载Orchestrator就能识别:calculate_finance工具。没有 XML 配置没有 YAML 注册表只有 Ruby 代码和文件系统约定。这正是 Ruby 的力量——它把“基础设施”变成了“代码本身”。4. 实操全流程从空目录到可交互 Agent 的 12 分钟4.1 环境准备与依赖安装2 分钟我们假设你有一台 macOS 或 Linux 机器Windows 用户请用 WSL2已安装 Ruby 3.2 和 Bundler。全程无需 Docker所有步骤在终端中完成# 1. 创建项目目录 mkdir ruby-agent-demo cd ruby-agent-demo # 2. 初始化 Gemfile注意不使用 rails new echo source https://rubygems.org\n\n Gemfile echo gem llm_client, ~ 0.8 Gemfile echo gem dry-struct, ~ 1.6 Gemfile echo gem concurrent-ruby, ~ 1.2 Gemfile echo gem sqlite3, ~ 1.4 Gemfile echo gem redis, ~ 4.8 Gemfile echo gem loofah, ~ 2.20 Gemfile # 3. 安装依赖首次约 45 秒 bundle install # 4. 创建目录结构一行命令 mkdir -p lib/{agent,{tools,adapters,orchestrator}} spec # 5. 创建入口文件 echo #!/usr/bin/env ruby\nrequire_relative lib/agent/cli\nAgent::CLI.start bin/agent chmod x bin/agent关键点说明不使用 RailsRails 的 autoloader 会干扰AgentContext的ThreadLocalVar行为导致并发下状态错乱Gemfile 精简只列核心 gem避免bundler-audit等开发依赖污染生产环境目录结构明确lib/agent/是根命名空间tools/存放能力adapters/存放协议orchestrator/存放决策逻辑bin/agent是 CLI 入口#!/usr/bin