跳到主要内容

V2 多 agent 引擎 — assistant + scenarios + handoff

Captain V2 不自己写 agent 循环,而是站在外部 gem ai-agents(Gemfile:198,>= 0.12.0)肩上。本章讲 Chatwoot 怎么把自己的数据模型"翻译"成这个 gem 的 Agent,以及多 agent 是怎么协作的。

2.1 要解决的小问题:一个 agent 不够用

一个客服助手往往要处理很多不同性质的请求:退款、技术故障、改地址……把所有规则塞进一个超长 prompt,既难维护又容易串味。

Captain 的方案是多 agent + handoff(转交):

  • 一个主 agent(由 Assistant 产生)当分流台,只负责"判断这属于哪个场景,然后转交"。
  • 每个场景 agent(由 Scenario 产生)是专才,带自己的指令和工具。
  • 转交对客户透明——客户不知道背后换了 agent。

2.2 核心抽象:Agentable concern

Chatwoot 用一个 Rails concern Agentable 把任何 model 变成 gem 的 Agents::AgentAssistantScenarioinclude Concerns::Agentable

# enterprise/app/models/concerns/agentable.rb:4
def agent
Agents::Agent.new(
name: agent_name,
instructions: ->(context) { agent_instructions(context) }, # 运行时才渲染 prompt
tools: agent_tools,
model: agent_model,
temperature: temperature.to_f || 0.7,
response_schema: agent_response_schema # 强制结构化 JSON 输出
)
end

几个要点:

  • instructions 是个 lambda,不是字符串。 因为系统 prompt 要在每轮运行时根据当前会话/联系人状态动态渲染(下面 2.4)。
  • response_schema 把模型输出锁成固定结构,默认是 {response, reasoning} 两个字段(enterprise/lib/captain/response_schema.rb:3)。这样下游能稳定地 @response['response'] 取值。
  • 每个 model 自己实现 agent_name / agent_tools / prompt_context,concern 提供骨架。

2.3 多 agent 怎么连起来:register_handoffs

这是 V2 的关键。AgentRunnerService 在启动前,把主 agent 和所有启用的 scenario agent 双向注册成可互相 handoff:

# enterprise/app/services/captain/assistant/agent_runner_service.rb:138
def build_and_wire_agents
assistant_agent = @assistant.agent
scenario_agents = @assistant.scenarios.enabled.map(&:agent)

assistant_agent.register_handoffs(*scenario_agents) if scenario_agents.any? # 主→各专才
scenario_agents.each { |scenario_agent| scenario_agent.register_handoffs(assistant_agent) } # 专才→主

[assistant_agent] + scenario_agents
end

怎么读这张图: 主 agent 居中,能转给任一 scenario;每个 scenario 也能转回主 agent(处理完或发现不归自己管)。

┌──────────────┐
│ 主 agent │ (Assistant)
│ "分流台" │ prompt 里列出所有场景 + handoff_to_xxx
└──┬───┬───┬───┘
转交 │ │ │ 转回
┌───▼─┐ │ ┌─▼────┐
│退款 │ │ │技术 │ ← 每个是 Scenario agent
│专员 │ │ │支持 │ 带自己的 instruction + tools
└─────┘ │ └──────┘
┌─▼────┐
│改地址│
│专员 │
└──────┘

handoff 工具的名字是 gem 自动生成的 handoff_to_<agent_name>Scenario 为此精心控制了 agent 名字长度——因为 OpenAI 函数名上限 64 字符,gem 又要加 handoff_to_ 前缀:

# enterprise/app/models/captain/scenario.rb:33
HANDOFF_TOOL_PREFIX = 'handoff_to_'.freeze
MAX_HANDOFF_TOOL_NAME_LENGTH = 60
MAX_AGENT_NAME_LENGTH = MAX_HANDOFF_TOOL_NAME_LENGTH - HANDOFF_TOOL_PREFIX.length

名字格式是 scenario_{id}_{slug}_agent,slug 会按剩余预算截断(scenario.rb:58handoff_key)。

2.4 prompt 怎么动态拼:Liquid 模板 + 运行时状态

主 agent 的系统 prompt 不是写死的,而是用 Liquid 模板渲染。每轮运行时,gem 把当前 context(会话状态、联系人、活动)回灌给 agent_instructions:

# enterprise/app/models/concerns/agentable.rb:15
def agent_instructions(context = nil)
enhanced_context = prompt_context
if context
state = context.context[:state] || {}
config = state[:assistant_config] || {}
enhanced_context = enhanced_context.merge(
conversation: state[:conversation] || {},
contact: config['feature_contact_attributes'].present? ? state[:contact] : nil, # 隐私开关:没开就不喂联系人属性
campaign: state[:campaign] || {}
)
end
Captain::PromptRenderer.render(template_name, enhanced_context.with_indifferent_access)
end

模板文件在 enterprise/lib/captain/prompts/assistant.liquid。它把主 agent 定位成编排者,并把每个 scenario 列成"碰到这种情况就用 handoff_to_xxx 转交":

```liquid
# enterprise/lib/captain/prompts/assistant.liquid:65 片段
{% for scenario in scenarios -%}
- {{ scenario.title }}: {{ scenario.description }}, use the `handoff_to_{{ scenario.key }}` tool to transfer ...
{% endfor %}
```

这个 prompt 还内置了几条硬约束(同文件 :12):"不许用你自己的训练知识回答,只能用工具拿到的信息"——这是"防瞎编"的第一道闸,配合 RAG(第 3 章)形成闭环。

2.5 跑起来:Runner 的一次 generate_response

AgentRunnerService.generate_response 是入口。它做三步:抽出最后一条用户消息当"本轮输入",把其余历史塞进 context,然后交给 Runner 跑最多 100 轮。

# enterprise/app/services/captain/assistant/agent_runner_service.rb:30
def generate_response(message_history: [])
message_to_process, context = run_payload(message_history)
result = runner.run(message_to_process, context: context, max_turns: 100)
process_agent_result(result)
rescue StandardError => e
# ... 记录异常
error_response(e.message) # 兜底:返回 'conversation_handoff' 让上层转人工
end

context 里装什么(state): 这是 agent 在工具里能读到的"世界状态"——账号 id、assistant 配置、以及会话/联系人/活动的快照。注意它只切片必要字段,不是整个对象:

# enterprise/app/services/captain/assistant/agent_runner_service.rb:114
def build_state
state = { account_id: @assistant.account_id,
assistant_id: @assistant.id,
assistant_config: @assistant.config }
build_conversation_state(state) if @conversation
state
end

CONVERSATION_STATE_ATTRIBUTES = %i[
id display_id inbox_id contact_id status priority
label_list custom_attributes additional_attributes
].freeze # :9 — 白名单切片,避免把整个 AR 对象塞进 LLM context

session_id账号_会话displayid 拼成,保证同一会话的多轮在追踪系统里归为一个 session(:61)。

2.6 结果怎么解析

Runner 返回后,process_agent_result 把 gem 的输出规整成 Chatwoot 内部的 hash,并带上两个关键元信息:是哪个 agent 答的、handoff 工具有没有被调过。

# enterprise/app/services/captain/assistant/agent_runner_service.rb:97
def process_agent_result(result)
output = result.output
response = output.is_a?(Hash) ? output.with_indifferent_access : { 'response' => output.to_s, 'reasoning' => 'Processed by agent' }
response['agent_name'] = result.context&.dig(:current_agent) # 哪个 agent 最终作答
response['handoff_tool_called'] = result.context&.dig(:captain_v2_handoff_tool_called) || false # 转人工信号
response
end

handoff_tool_called 这个布尔就是第 1 章 process_responsev2_handoff_tool_fired? 读的那个标志。它是通过一个 on_tool_complete 回调设上去的——当被调工具名等于 handoff 工具名时置 true:

# enterprise/app/services/captain/assistant/agent_runner_service.rb:196
def track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
return unless tool_name.to_s == handoff_tool_name
context_wrapper.context[:captain_v2_handoff_tool_called] = true
@handoff_tool_called = true # 同时镜像到实例变量:即使 runner 之后抛错也能把信号带出来
end

为什么要镜像到实例变量? 因为如果 runner 在返回 result 之前就抛了异常,context 就拿不到了;实例变量让 error_response 仍能报告"其实 handoff 已经触发过"(:106)。这是个很细的容错设计。

2.7 计费的巧思:handoff 不计费

Captain 的回复是要计费(扣额度)的,但转人工不算一次有价值的回复——它代表 AI 没解决问题。所以 OTEL 元数据里专门标了 credit:

# enterprise/app/services/captain/assistant/agent_runner_service.rb:206
def write_credits_used_metadata(context_wrapper)
root_span = context_wrapper&.context&.dig(:__otel_tracing, :root_span)
return unless root_span
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), @handoff_tool_called ? 'false' : 'true')
end

而真正的扣费发生在第 1 章 process_response 的普通回复分支(account.increment_response_usage)——handoff 分支根本不走到那里,自然不扣。


小结: V2 = ai-agents gem + Agentable 翻译层 + Liquid 动态 prompt + scenario 多 agent handoff。下一章看这些 agent 回答时到底从哪拿事实——RAG 知识管线。