跳到主要内容

Captain 总览 — 一次客户消息如何被 AI 接管

本章只讲主线控制流:消息从进来到 AI 回复(或转人工)中间经过哪些关卡。agent 内部怎么思考、知识怎么来,留给后面章节。

1.1 先建一个心智模型:会话是工单,Captain 是第一棒

Chatwoot 里每个客户对话是一条 Conversation,有四种状态:

# app/models/conversation.rb:75 enum status
enum status: { open: 0, resolved: 1, pending: 2, snoozed: 3 }

关键状态是 pending:它表示"这条会话正等机器人处理,人类先别插手"。Captain 只在会话 pending 时工作;一旦转人工,状态变 open,Captain 就退场。

1.2 触发点:消息钩子怎么决定叫不叫 Captain

每条消息进来后,Chatwoot 会跑一串"模板钩子"(发欢迎语、离线语等)。企业版覆盖了这个钩子,插入 Captain 的判断逻辑。

思路: 三个条件全满足才叫 Captain——会话是 pending、消息是客户发来的(incoming)、且该收件箱配了 assistant。

# enterprise/app/services/enterprise/message_templates/hook_execution_service.rb:52
def should_process_captain_response?
conversation.pending? && message.incoming? && inbox.captain_assistant.present?
end

确认要叫 Captain 后,还有一道额度闸。如果账号的 Captain 回复额度用完了(captain_active? 为 false),不是排队,而是立刻转人工:

# enterprise/app/services/enterprise/message_templates/hook_execution_service.rb:4
def trigger_templates
super
return unless should_process_captain_response?
return perform_handoff unless inbox.captain_active? # 额度不够 → 直接转人工

schedule_captain_response # 额度够 → 排后台任务
end

captain_active? 同时检查"配了 assistant"和"还有额度":

# enterprise/app/models/enterprise/inbox.rb:15
def captain_active?
captain_assistant.present? && more_responses?
end

一个巧妙细节: 当 Captain 在处理会话时,系统会抑制普通的欢迎语/离线语/邮箱采集——免得客户同时收到机器人答复和"我们已离线"两条矛盾消息。看 should_send_greeting? 等几个方法都加了 return false if captain_handling_conversation?(同文件 :12:18:24)。

1.3 异步处理:为什么要排队 + 带附件时的等待

生成 AI 回复要调 LLM,慢,所以丢进后台任务 ResponseBuilderJob。这里有个容易忽略的工程细节:如果客户消息带附件(图片等),附件可能还没上传完,直接跑会读不到。所以带附件时延迟派发,按附件数量算等待秒数:

# enterprise/app/services/enterprise/message_templates/hook_execution_service.rb:32
def schedule_captain_response
job_args = [conversation, conversation.inbox.captain_assistant]

if message.attachments.blank?
Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
else
wait_time = calculate_attachment_wait_time # 1s + 每附件 1s,封顶 4s
Captain::Conversation::ResponseBuilderJob.set(wait: wait_time).perform_later(*job_args)
end
end

1.4 主任务:ResponseBuilderJob 的三岔路

这是整条主线的心脏。它做三件事:(1)选 V1/V2 引擎生成回复;(2)按结果分三路落地;(3)出错兜底。

第一步,选引擎——一个 feature flag 决定走 V2 多 agent 还是 V1 单 chat:

# enterprise/app/jobs/captain/conversation/response_builder_job.rb:18
if captain_v2_enabled?
generate_response_with_v2 # AgentRunnerService:多 agent + handoff
else
generate_and_process_response # AssistantChatService:单 chat + 工具循环
end

喂给引擎的是整段历史消息,过滤掉私有备注,标好谁是 user(客户)谁是 assistant(机器人):

# enterprise/app/jobs/captain/conversation/response_builder_job.rb:84
def collect_previous_messages
@conversation.messages
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map { |message| { content: prepare_multimodal_message_content(message),
role: determine_role(message) } }
end

def determine_role(message)
message.message_type == 'incoming' ? 'user' : 'assistant' # incoming = 客户
end

第二步,分三路落地(process_response)。这段注释写得很细,核心是"V2 信号优先于 V1":

agent 返回的 @response

┌────┴─────────────────────────────┐
│ handoff_tool_called == true ? │ ← V2 的 handoff 工具被调了
└────┬──────────────────────┬───────┘
是 否
│ │
还 pending? response == 'conversation_handoff' ? ← V1 转人工信号
┌──┴──┐ ┌────┴────┐
是 否 是 否(普通回复)
│ │ │ │
V1兜底 发follow-up V1转人工 还pending? → 建消息 + 扣额度
转人工 消息 process_v1_handoff

对应代码:

# enterprise/app/jobs/captain/conversation/response_builder_job.rb:53
def process_response
if v2_handoff_tool_fired? # @response['handoff_tool_called']
conversation_pending? ? process_v1_handoff : process_v2_handoff
elsif v1_handoff_requested? # 'conversation_handoff' 字面量 或 分类器判定
return unless conversation_pending?
process_v1_handoff
elsif conversation_pending?
ActiveRecord::Base.transaction do
create_messages # 把 AI 回复落成 outgoing 消息发给客户
account.increment_response_usage # 扣一次额度
end
end
end

为什么 V2 要先判断: 出错时 error_response 可能同时把 V2 和 V1 两个信号都置上(handoff 工具先触发、runner 又抛错)。如果先跑 V1,会重复发离线语、重复派 bot_handoff 事件。所以代码注释明确写了"Check V2 before V1"(同文件 :53 上方)。

1.5 转人工到底做了什么

转人工的核心动作是 bot_handoff!,它把会话从 pending 拉回 open,并打上"等待时间戳":

# app/models/conversation.rb:166
def bot_handoff!
update(waiting_since: Time.current) if waiting_since.blank?
# ... 切换状态为 open + 派发 bot_handoff 事件(后续行)
end

配套动作(在 process_v1_handoff 里):发一条对客户可见的"正在转接"消息、跑 bot_handoff!、再按需发离线语(enterprise/app/jobs/captain/conversation/response_builder_job.rb:126)。

一个边界处理: 如果 Captain 跑了一半,人类坐席手动插手回复了(会话不再 pending),Captain 必须闭嘴——别在人家回复上面再贴一条过时的机器人消息。process_response 里多处 return unless conversation_pending? 就是干这个的。而且 conversation_pending?Conversation.uncached 强制读最新状态,避免缓存导致误判(同文件 :216)。

1.6 落地为一条消息

普通回复最终变成一条 outgoing 消息,sender 是这个 assistant(所以前台会显示是机器人发的):

# enterprise/app/jobs/captain/conversation/response_builder_job.rb:171
def create_outgoing_message(message_content, agent_name: nil, preserve_waiting_since: false)
@conversation.messages.create!(
message_type: :outgoing,
sender: @assistant, # ← 机器人作为发送方
content: message_content,
additional_attributes: additional_attrs, # 可带 agent_name(哪个 scenario agent 答的)
preserve_waiting_since: preserve_waiting_since
)
end

小结: 主线就是"钩子判断 → 异步 Job → 选引擎生成 → 三岔路落地"。下一章进 V2 引擎内部,看 Chatwoot 怎么把一个数据库里的 Assistant 记录,变成一个真正会 handoff 的多 agent 系统。