1. 项目概述:为什么AI应用离不开后台任务

如果你正在用Rails构建一个集成了AI功能的Web应用,比如文档总结、智能聊天或者图片生成,那你一定遇到过这个核心矛盾:AI模型的API调用太慢了。一个简单的GPT-4文本总结请求,网络往返加上模型推理,几秒钟是家常便饭;处理一份PDF文件生成向量嵌入,耗时可能以分钟计。想象一下,用户点击“总结文档”按钮,然后浏览器就转着圈圈,前端请求一直挂起,直到十几秒后才有响应——这种体验无疑是灾难性的,你的服务器线程也被长时间阻塞,整个应用的响应能力会急剧下降。

后台任务(Background Jobs)就是解决这个问题的“银弹”。它的核心理念是“异步化”和“解耦”。当用户触发一个耗时操作时,你的控制器不再同步执行它,而是立刻将一个任务描述(即一个Job)放入一个队列中,然后立即返回响应给用户,比如“任务已提交,处理中”。与此同时,一个或多个独立于Web服务器(如Puma)的后台工作进程(Worker)会持续监听这个队列,按顺序取出任务并在后台默默执行。用户无需等待,服务器资源得以释放,应用的整体吞吐量和响应速度得到质的提升。

在Rails生态中, Active Job 就是这个理念的标准实现。它不是一个具体的队列系统,而是一个统一的抽象接口层。你使用Active Job的API编写任务,然后可以自由选择具体的队列后端(Adapter),比如Sidekiq、Good Job,或者Rails 8默认集成的Solid Queue。这种设计让你无需重写业务逻辑,就能在不同后端之间灵活切换,以适应从初创公司到大规模生产环境的不同需求。

本文将深入探讨如何在Rails AI项目中运用Active Job,从基础概念到高级模式,涵盖队列选择、任务编排、进度追踪、错误处理等实战细节。无论你是在构建第一个AI小工具,还是在优化一个已有系统的性能,理解并善用后台任务都是迈向专业级应用的关键一步。

2. Active Job核心机制与配置解析

2.1 Active Job:统一的异步任务接口

Active Job的设计哲学是“一次编写,随处运行”。它定义了一套标准的作业(Job)生命周期和API,将你从具体的队列实现细节中解放出来。一个典型的Job类看起来是这样的:

# app/jobs/process_document_job.rb
class ProcessDocumentJob < ApplicationJob
  # 1. 指定队列
  queue_as :default

  # 2. 核心执行方法
  def perform(document_id, options = {})
    # 通过ID查找记录,而非直接传递对象,避免序列化问题
    document = Document.find(document_id)

    # 模拟耗时操作,例如调用AI API
    summary = call_ai_for_summary(document.content, options[:model])

    # 更新记录
    document.update!(summary: summary, processed_at: Time.current)
  end

  private

  def call_ai_for_summary(content, model = 'gpt-4')
    # 这里是调用OpenAI API的示例(需配合openai-ruby等gem)
    client = OpenAI::Client.new(access_token: ENV['OPENAI_API_KEY'])
    response = client.chat(
      parameters: {
        model: model,
        messages: [{ role: 'user', content: "请总结以下内容:\n#{content}" }],
        temperature: 0.7
      }
    )
    response.dig('choices', 0, 'message', 'content')
  end
end

关键点解析:

  1. queue_as :这是作业的第一个关键决策点。它决定了作业被放入哪个队列。将不同类型的作业(如即时邮件、AI重任务、支付处理)分到不同队列,是保障系统稳定性的基础。例如, queue_as :ai_processing 可以将所有AI任务隔离。
  2. perform 方法 :这是作业的核心逻辑所在。它接收的参数会被序列化后存入队列,因此 只能传递简单的、可序列化的数据类型 ,如字符串、数字、数组、哈希。传递Active Record对象是常见错误,应始终传递ID,在 perform 方法内部重新查询。
  3. ApplicationJob :你的作业类继承自它,而它又继承自 ActiveJob::Base 。你可以在 ApplicationJob 中设置全局默认行为,比如重试策略、错误通知等。

2.2 作业的入队与调度

创建了Job类,下一步就是触发它。Active Job提供了灵活的入队方式:

# 基础用法:立即入队
ProcessDocumentJob.perform_later(document.id)

# 延迟执行:适用于定时任务或需要缓冲的场景
ProcessDocumentJob.set(wait: 5.minutes).perform_later(document.id)
# 或者指定一个确切时间
ProcessDocumentJob.set(wait_until: Date.tomorrow.noon).perform_later(document.id)

# 指定队列:覆盖类中定义的`queue_as`
ProcessDocumentJob.set(queue: :high_priority_ai).perform_later(document.id)

# 传递参数:perform方法定义了什么,这里就传什么
ProcessDocumentJob.perform_later(document.id, model: 'gpt-4-turbo', priority: 'high')

入队时的注意事项:

  • perform_later vs perform_now perform_later 是异步的,将作业推入队列; perform_now 是同步的,立即在当前进程执行,常用于测试或调试。在生产环境调用耗时任务,务必使用 perform_later
  • 参数序列化 :再次强调,参数必须可序列化(通常为JSON)。复杂的Ruby对象(如数据库连接、文件句柄)无法安全传递。
  • 作业ID :每个入队的作业都会有一个唯一的 job_id ,你可以通过 ProcessDocumentJob.set(...).job_id 获取,用于后续的跟踪或管理。

2.3 队列后端(Adapter)选型指南

选择哪个后端,取决于你的应用规模、基础设施偏好和运维复杂度。

1. Solid Queue (Rails 8 默认) Rails 8将Solid Queue作为默认的后台任务解决方案,其最大特点是“零额外基础设施”。

  • 原理 :它利用你的应用现有的关系型数据库(PostgreSQL, MySQL等)作为作业存储队列。作业作为一条条记录存储在 solid_queue_jobs 这样的表中。
  • 优点
    • 简化部署 :无需维护Redis等额外服务,尤其适合初创项目或中小型应用。
    • 事务安全 :如果你的作业入队操作和某个数据库事务绑定,由于共用同一个数据库连接,可以保证事务提交后作业才入队,避免“幽灵作业”。
    • 与Rails深度集成 :安装配置极其简单。
  • 配置示例
    # config/solid_queue.yml
    production:
      dispatchers:
        - polling_interval: 1
          batch_size: 500
      workers:
        - queues: "default,mailers"
          threads: 5
          processes: 2
        - queues: "ai_processing"
          threads: 3 # AI任务可能更耗CPU/IO,线程数可单独配置
          processes: 1
    
  • 启动 :开发环境可以用 bin/jobs ,生产环境通常通过 bundle exec rake solid_queue:start 或使用系统服务(如systemd)来管理。
  • 适用场景 :日均作业量在万级以下,希望保持技术栈简洁,或正处于原型验证阶段的项目。

2. Sidekiq (业界标准) 当你的应用需要处理海量作业(每秒成千上万),或需要更复杂的特性时,Sidekiq是生产环境的事实标准。

  • 原理 :基于Redis的内存数据存储,性能极高。采用多线程模型,一个Sidekiq进程可以并发执行多个作业。
  • 优点
    • 高性能 :Redis的读写速度极快,能轻松应对高并发作业队列。
    • 丰富的生态系统 :拥有强大的Web管理界面、复杂的重试机制、死信队列、定时作业(Sidekiq Pro/Enterprise)等。
    • 可观测性好 :与NewRelic、Datadog等监控工具集成成熟。
  • 配置示例
    # config/sidekiq.yml
    :concurrency: 10 # 每个进程的线程数
    :queues:
      - [critical, 5] # 权重最高,处理支付等关键任务
      - [default, 2]
      - [ai_processing, 1] # 权重最低,AI任务可以慢点
      - [mailers, 1]
    
  • 启动 bundle exec sidekiq 。生产环境需要配合进程管理器(如systemd, Kubernetes)确保其常驻。
  • 注意事项 :Sidekiq作业必须是 线程安全 的。这意味着你要小心使用全局变量、类变量,以及对数据库连接的使用。对于非线程安全的代码,需要将并发数设为1或使用其他机制隔离。
  • 适用场景 :中大型生产应用,作业吞吐量要求高,需要企业级功能。

3. Good Job 另一个基于PostgreSQL的后端,采用“多进程+单线程”模型,与Solid Queue理念类似,但出现更早,功能更丰富一些,如并发控制、作业优先级等。

如何选择?

  • 从零开始的新项目(Rails 8) :直接用Solid Queue,简单够用。
  • 已有Sidekiq且运行良好的项目 :继续使用Sidekiq,无需迁移。
  • 对Redis运维有顾虑,但需要比Solid Queue更多功能 :评估Good Job。
  • 作业量极大,追求极致性能 :Sidekiq是不二之选。

3. AI场景下的高级作业模式与实战

将AI任务简单地丢到后台只是第一步。真实场景中,我们需要更精细的控制。

3.1 模式一:作业链(Chaining)与工作流

一个完整的AI处理流程往往包含多个步骤。例如:上传文档 -> 文本提取 -> 调用AI总结 -> 生成嵌入向量 -> 存入向量数据库 -> 发送通知。我们可以将这些步骤组织成作业链。

class DocumentProcessingWorkflowJob < ApplicationJob
  queue_as :ai_processing

  def perform(document_id)
    document = Document.find(document_id)

    # 步骤1: 文本提取 (假设是PDF)
    raw_text = extract_text_from_pdf(document.file_path)
    document.update!(raw_content: raw_text, status: 'text_extracted')

    # 步骤2: 总结 (触发下一个作业)
    GenerateSummaryJob.perform_later(document.id)
  end
end

class GenerateSummaryJob < ApplicationJob
  queue_as :ai_processing
  retry_on OpenAI::RateLimitError, wait: :exponentially_longer

  def perform(document_id)
    document = Document.find(document_id)
    return if document.summary.present? # 幂等性检查

    summary = call_openai_for_summary(document.raw_content)
    document.update!(summary: summary, status: 'summarized')

    # 步骤3: 生成嵌入向量
    GenerateEmbeddingJob.perform_later(document.id)
  end
end

class GenerateEmbeddingJob < ApplicationJob
  queue_as :ai_processing

  def perform(document_id)
    document = Document.find(document_id)
    return if document.embedding.present?

    embedding = call_openai_for_embedding(document.raw_content)
    document.update!(embedding: embedding, status: 'embedded')

    # 步骤4: 最终完成通知
    DocumentProcessingCompleteJob.perform_later(document.id)
  end
end

链式调用的优劣:

  • 优点 :逻辑清晰,每个作业职责单一,易于测试和维护。一个步骤失败,不会影响已完成的步骤。
  • 缺点 :作业数量会膨胀,增加了队列的负载和管理复杂度。如果中间某一步频繁失败,会导致整个链条卡住。
  • 改进方案 :对于复杂工作流,可以考虑使用专门的工作流引擎,如 Temporal Cadence ,但Active Job链对于大多数中小型AI流程已足够。

3.2 模式二:进度追踪与实时反馈

用户不喜欢黑盒操作。对于处理时间较长的AI任务(如批量处理100个文档),提供进度条能极大提升体验。结合上一篇文章讲的ActionCable,我们可以实现实时进度推送。

首先,在前端订阅一个特定的频道:

// app/javascript/channels/batch_progress_channel.js
import consumer from "./consumer"
consumer.subscriptions.create({ channel: "BatchProgressChannel", batch_id: batchId }, {
  received(data) {
    updateProgressBar(data.progress); // 更新UI进度条
    if (data.progress === 100) {
      showCompletionMessage();
    }
  }
})

然后,在后台作业中广播进度:

class BulkDocumentProcessJob < ApplicationJob
  queue_as :ai_processing

  def perform(batch_id)
    batch = ProcessingBatch.find(batch_id)
    documents = batch.documents.to_process
    total = documents.count

    documents.each_with_index do |document, index|
      # 处理单个文档
      process_single_document(document)

      # 计算并广播进度
      progress = ((index + 1).to_f / total * 100).round(1)
      ActionCable.server.broadcast(
        "batch_progress_#{batch_id}",
        {
          progress: progress,
          current: index + 1,
          total: total,
          message: "正在处理: #{document.filename}"
        }
      )
      # 小睡一下,避免广播过于频繁
      sleep(0.1) if index % 10 == 0
    end

    batch.update!(status: 'completed', completed_at: Time.current)
    ActionCable.server.broadcast("batch_progress_#{batch_id}", { progress: 100, completed: true })
  end

  private
  def process_single_document(doc)
    # ... AI处理逻辑 ...
  end
end

实操心得

  • 广播频率 :不要每次循环都广播,特别是处理成百上千个条目时。可以每处理10个或1%广播一次,或者基于时间间隔(如每秒一次),以减轻服务器和客户端的压力。
  • 状态持久化 :除了实时推送,还应将进度(如 processed_count )更新到数据库中的 batch 记录里。这样即使页面刷新或连接中断,重新加载后也能从数据库读取到最新进度。
  • 错误处理 :考虑在广播数据中加入错误信息,让前端能显示具体的失败原因。

3.3 模式三:智能重试与错误处理

调用外部AI服务(如OpenAI、Anthropic)时,网络抖动、速率限制(Rate Limit)、服务暂时不可用都是常态。一个健壮的作业必须能优雅地处理这些错误。

Active Job提供了强大的 retry_on discard_on 机制。

class CallOpenAIJob < ApplicationJob
  queue_as :ai_processing

  # 模式1: 针对特定异常进行重试
  # 指数退避:等待时间随重试次数指数增长 (默认公式:executions**4 + 2)
  retry_on OpenAI::RateLimitError, wait: :exponentially_longer, attempts: 5
  # 多项式退避:等待时间增长更平缓 (wait: :polynomially_longer)
  retry_on Faraday::TimeoutError, wait: 10.seconds, attempts: 3

  # 模式2: 达到最大重试次数后,将作业移至“死信队列”或记录日志
  retry_on Net::OpenTimeout, wait: 5.seconds, attempts: 3 do |job, error|
    # 可以在这里通知运维或记录到错误追踪系统(Sentry, Honeybadger)
    ErrorTracker.notify(error, context: { job_id: job.job_id, arguments: job.arguments })
  end

  # 模式3: 某些错误无需重试,直接丢弃(如记录已不存在)
  discard_on ActiveRecord::RecordNotFound

  def perform(prompt_id)
    prompt = Prompt.find(prompt_id)
    # 模拟可能抛出 Faraday::TimeoutError 或 OpenAI::RateLimitError 的调用
    response = openai_client.chat(...)
    prompt.update!(response: response)
  end

  private
  def openai_client
    @client ||= OpenAI::Client.new(...)
  end
end

关键参数解析:

  • wait: :exponentially_longer :这是处理 速率限制 的黄金策略。例如,第一次重试等4秒,第二次等18秒,第三次等64秒……给API足够的时间恢复。 polynomially_longer 增长更慢,适合非速率限制的临时故障。
  • wait: 5.seconds :固定间隔重试,适合你知道问题会很快恢复的场景。
  • attempts :最大重试次数。需要权衡:次数太少,可能因临时故障永久失败;次数太多,一个注定失败的作业会长时间占用队列资源。
  • discard_on :当错误表明作业永远不可能成功时(如要处理的数据库记录已被删除),直接丢弃是更干净的做法。务必配合日志记录,以便追溯。

更精细的控制: sidekiq_options 如果你使用Sidekiq,还可以在作业类中设置Sidekiq特有的选项,实现更细粒度的控制:

class CriticalAIJob < ApplicationJob
  queue_as :critical
  sidekiq_options retry: 10, dead: false # 重试10次,失败后不移入死信队列

  retry_on StandardError, wait: 5.seconds, attempts: 10 # 与sidekiq_options协同工作

  def perform(...)
    # ...
  end
end

3.4 模式四:作业去重与并发控制

在某些场景下,你需要确保同一个资源(如同一份文档)不会被多个作业同时处理,或者防止用户短时间内重复提交导致同一任务入队多次。

基于Redis缓存的简单去重:

class UniqueProcessDocumentJob < ApplicationJob
  queue_as :ai_processing
  LOCK_EXPIRY = 30.minutes

  before_enqueue do |job|
    document_id = job.arguments.first
    lock_key = "job_lock:ProcessDocument:#{document_id}"

    # 如果锁已存在,则放弃入队
    if Rails.cache.exist?(lock_key)
      Rails.logger.info "Job for Document #{document_id} is already enqueued/running. Aborting."
      throw :abort
    end

    # 设置锁
    Rails.cache.write(lock_key, true, expires_in: LOCK_EXPIRY)
  end

  after_perform do |job|
    document_id = job.arguments.first
    lock_key = "job_lock:ProcessDocument:#{document_id}"
    Rails.cache.delete(lock_key)
  end

  def perform(document_id)
    # 主要的处理逻辑
    document = Document.find(document_id)
    # ... AI处理 ...
  end
end

使用Gem进行高级控制: 对于更复杂的需求,比如“在5分钟内只运行一次”或“保证全局唯一”,可以考虑使用 sidekiq-unique-jobs (Sidekiq)或 good_job 自带的并发控制功能。

注意事项

  • 锁的粒度 :锁的键(Key)设计要合理。太粗(如 "job_lock:ProcessDocument" )会导致不必要的阻塞;太细可能起不到控制作用。
  • 锁的过期时间 :必须设置过期时间,以防作业执行失败后锁永远无法释放(僵尸锁)。过期时间应略大于作业的最大可能执行时间。
  • 清理机制 :考虑增加一个后台任务,定期清理过期的、可能残留的锁。

4. 生产环境部署、监控与问题排查

将后台任务部署到生产环境,远不止是启动一个Worker进程那么简单。

4.1 队列设计与资源隔离

合理的队列设计是系统稳定的基石。不要把所有作业都扔进 default 队列。

# config/sidekiq.yml 示例
:concurrency: 10
:queues:
  - [critical, 6]   # 支付、核心状态更新,需要最快处理
  - [default, 2]    # 普通业务逻辑
  - [mailers, 1]    # 发送邮件
  - [ai_processing, 1] # AI长任务,可以慢,但资源占用可能高
  - [low_priority, 1] # 日志清理、数据备份等

设计原则:

  1. 按优先级分离 critical 队列权重最高,确保关键业务不被阻塞。
  2. 按资源类型分离 ai_processing 作业可能大量消耗CPU或外部API额度,将它们隔离,即使积压也不会影响 default 队列里的用户交互任务。
  3. 专用Worker进程 :为 ai_processing 队列启动专用的Sidekiq进程或Solid Queue worker,并分配独立的系统资源(CPU、内存限制)。这可以通过不同的系统服务单元或Kubernetes Deployment来实现。
    # 启动一个专门处理AI任务的Sidekiq进程
    bundle exec sidekiq -q ai_processing -c 3
    

4.2 监控与告警

后台任务运行在“后台”,但不能成为“黑盒”。

基础监控:

  • 队列长度 :监控每个队列的待处理作业数。如果 default 队列持续增长,可能意味着通用Worker处理不过来;如果 ai_processing 队列暴增,可能是AI API变慢或下游服务有问题。可以使用Sidekiq Web UI、 solid_queue 仪表板或通过API将指标发送到Prometheus/Grafana。
  • 作业执行时间 :记录每个作业从入队到完成的耗时。AI作业的耗时分布可以帮助你了解API性能,并设置合理的超时时间。
  • 失败率 :监控作业失败(特别是重试后仍失败)的比例。失败率陡增是重要的告警信号。

集成错误追踪: ApplicationJob 中配置全局的错误处理,将异常上报到Sentry、Rollbar或Honeybadger。

# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
  rescue_from(StandardError) do |exception|
    # 记录错误上下文,包括job_id和参数(注意过滤敏感信息)
    ErrorTracker.notify(
      exception,
      context: {
        job_class: self.class.name,
        job_id: job_id,
        arguments: arguments,
        queue_name: queue_name
      }
    )
    # 重新抛出,让Active Job/Sidekiq的重试机制继续工作
    raise exception
  end
end

设置告警:

  • 队列积压 :如果任何队列的作业数超过阈值(如1000),触发告警。
  • Worker进程死亡 :监控Sidekiq或Solid Queue的进程状态。
  • 关键作业连续失败 :对于 ChargePaymentJob 这类作业,第一次失败就应立即告警。

4.3 常见问题排查实录

问题1:作业“消失”了,没有执行也没有错误日志。

  • 检查点1:Worker在运行吗? 运行 ps aux | grep sidekiq 或检查systemd服务状态。确认连接的后端(Redis/数据库)是通的。
  • 检查点2:作业入队成功了吗? 在入队代码后添加日志 Rails.logger.info "Enqueued job: #{job.job_id}" 。对于Sidekiq,可以查看Redis里对应队列的列表。
  • 检查点3:参数序列化问题 。这是最常见的静默失败原因之一。确保 perform 方法的参数都是简单的数据类型。可以在作业类中添加 around_perform 钩子来记录参数。
    around_perform do |job, block|
      Rails.logger.info "Performing #{job.class.name} with args: #{job.arguments.inspect}"
      block.call
    end
    

问题2:AI作业执行超时。

  • 原因 :外部API响应慢,或作业本身逻辑复杂。
  • 解决方案
    1. 设置合理的超时 :在Sidekiq中,可以在作业级别或全局设置超时。
      sidekiq_options timeout: 30.minutes # 为这个作业设置30分钟超时
      
    2. 实现心跳机制 :对于超长任务,在 perform 方法内定期更新一个“最后活跃时间”戳到数据库,让监控系统知道它还在运行,而非卡死。
    3. 任务分解 :如果是一个处理1000个文件的任务,不如拆分成100个处理10个文件的子任务,并行执行。

问题3:数据库连接池耗尽。

  • 现象 :作业失败,日志中出现 ActiveRecord::ConnectionTimeoutError
  • 原因 :每个Sidekiq线程或Solid Queue worker都需要一个数据库连接。如果并发数设置过高,可能会超过数据库的最大连接数。
  • 解决方案
    1. 调整连接池 :在 config/database.yml 中,确保 pool 值大于或等于Sidekiq的 concurrency 数加上Web服务器的最大线程数。
    2. 优化连接使用 :在作业中避免长时间持有连接。使用 ActiveRecord::Base.connection_pool.with_connection 块来确保连接在使用后及时释放。
    3. 降低并发 :适当降低Sidekiq的 concurrency 设置。

问题4:内存泄漏(Sidekiq常见)。

  • 现象 :Sidekiq进程内存使用量随时间持续增长。
  • 排查
    1. 检查作业代码中是否有未关闭的文件句柄、网络连接,或是否在全局变量中累积数据。
    2. 使用 ObjectSpace 工具分析内存中的对象。
    3. 考虑定期重启Sidekiq worker(例如使用sidekiq的 timeout 或通过进程管理器定时重启)。

4.4 测试策略

后台作业的测试需要特殊考虑。

单元测试(测试Job类本身):

# test/jobs/process_document_job_test.rb
require 'test_helper'

class ProcessDocumentJobTest < ActiveJob::TestCase
  setup do
    @document = documents(:one) # 使用fixture
  end

  test 'perform calls AI and updates document' do
    # 1. 模拟(Mock)外部API调用
    mock_client = Minitest::Mock.new
    mock_response = { 'choices' => [{ 'message' => { 'content' => 'Mocked summary' } }] }
    mock_client.expect(:chat, mock_response, [Hash])
    OpenAI::Client.stub(:new, mock_client) do
      # 2. 同步执行作业
      ProcessDocumentJob.perform_now(@document.id)
    end

    # 3. 断言
    assert_equal 'Mocked summary', @document.reload.summary
    assert_not_nil @document.processed_at
    mock_client.verify # 确保mock被调用
  end

  test 'does nothing if document not found' do
    assert_nothing_raised do
      ProcessDocumentJob.perform_now(-1)
    end
  end
end

集成测试(测试作业入队):

test 'clicking process button enqueues a job' do
  sign_in users(:admin)
  document = documents(:unprocessed)

  assert_enqueued_with(job: ProcessDocumentJob, args: [document.id]) do
    post process_document_path(document)
  end

  assert_redirected_to document_path(document)
  assert_equal '文档已开始处理', flash[:notice]
end

系统测试(可选,测试完整流程): 对于关键的用户旅程,可以使用系统测试,配合 Capybara VCR (用于录制和回放HTTP交互,如AI API调用),来测试从点击按钮到看到结果(可能通过ActionCable)的完整异步流程。但这通常运行较慢,更适合核心场景。

将后台任务与实时通信(ActionCable)结合,就构成了现代AI应用的完整异步处理管道。用户提交一个请求,控制器瞬间响应,一个后台作业被创建并排队。专用的AI Worker处理这个耗时请求,在处理过程中或完成后,通过WebSocket将状态或结果实时推回前端。这一切对用户而言是无缝的、响应迅速的体验。在接下来的实践中,我们将把Active Job、ActionCable和Turbo Streams组合起来,构建一个从提问到流式回答的完整AI聊天界面。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐