从 Demo 到生产:Captain 轻量级 AI Agent Runtime 的工程实践

从 Demo 到生产:Captain 轻量级 AI Agent Runtime 的工程实践

jonathan
2026-06-13 / 0 评论

从 Demo 到生产:Captain 轻量级 AI Agent Runtime 的工程实践

Captain AI Agent Runtime:从 Demo 到生产

核心观点

大模型接入业务系统后,最容易被低估的并不是 Prompt,而是 Runtime。模型负责推理,SDK 负责通用 Agent 循环,而业务 Runtime 要负责控制执行、限制风险,并确保结果能够安全地提交到真实业务系统。

大模型接入到业务系统后,最容易被低估的并不是 Prompt,而是运行时。

一个 Demo 里,模型能调用工具、返回答案,似乎 Agent 已经成立;但进入生产环境后,问题会迅速变成:多个租户如何使用不同模型而不串配置?工具循环如何避免失控?客户连续发来三条消息时,旧请求晚到的结果能否阻止发送?模型、检索、工具、转人工分别花了多少时间?用户取消后,后台任务怎样停止提交结果?

Captain 的做法不是重写一个通用的 Agent SDK,而是在 ai-agentsRubyLLM 之上实现一层面向客服、知识库和各个客户端场景的轻量级 Agent Runtime。它把通用 Agent 循环留给底层库,把多租户、业务状态、安全边界和可运营性收敛到应用自身。

这篇文章记录这套 Runtime 的核心设计,以及背后的工程取舍。

这篇文章分享了什么

如果你已经做过一个能调用 Tool 的 Agent Demo,那么真正进入生产环境时,大概率会遇到下面这些问题:

  • 多租户如何安全地使用不同 Provider、Model 和凭据?
  • Tool 循环如何避免无限执行、重复调用和成本失控?
  • 新消息到达或人工介入后,旧 Run 为什么不能继续提交结果?
  • RAG 无答案时,怎样阻止模型用训练语料“补全事实”?
  • Agent、Tool、检索和 Handoff 如何形成可追踪的执行时间线?
  • 哪些调用可以自动重试,哪些调用必须依赖稳定幂等键?

Captain Runtime 的设计,本质上就是围绕这些生产问题建立边界。

先定义边界:我们自研的是什么

Captain 不试图自研基础模型、函数调用协议或通用 ReAct 循环。当前实现使用 ai-agents组织 Agent 与 Handoff,使用 RubyLLM` 对接模型服务商。

自研部分是一个很薄、但很关键的运行时外壳:

  • 把 Assistant、Scenario、知识库和业务会话组装为一次可执行的 Agent Run。
  • 以账号和功能为作用域选择 Provider/Model,并在请求内隔离凭据、地址和超时。
  • 将工具执行纳入次数、重复调用、耗时、Token 和费用预算。
  • 把异步执行变成具备状态机、幂等键、取消信号和事件时间线的业务对象。
  • 让检索、Tool、转人工、Provider 健康状态和最终回复可追踪、可解释。

换句话说,模型负责推理,SDK 负责通用循环,Captain Runtime 负责让这次推理可以被安全地放进真实业务流程。

整体架构:小内核,明确的执行边界

Captain Runtime 全景架构

如果只看职责边界,Captain Runtime 可以理解为位于 Agent SDK 与业务系统之间的一层薄运行时:它不重新实现模型推理,而是统一管理 Run、上下文、预算、事件和最终提交。

下面是自动回复路径中 Agent Runtime 的主要链路。Operator、Playground 和 Copilot 也复用 Captain::Run 的统一生命周期契约,但入口和业务动作各自独立。

flowchart LR
  A["客户消息 / 业务入口"] --> B["路由决策"]
  B --> C["Captain::Run\n幂等键与状态机"]
  C --> D["AgentRunnerService"]
  D --> E["FeatureRouter\n选择 Provider / Model"]
  E --> F["请求级 RubyLLM Context"]
  D --> G["Agent + Scenario Agents"]
  G --> H["BudgetedTool\n预算与取消检查"]
  H --> I["知识检索 / 业务 Tool / 转人工"]
  F --> J["模型服务商"]
  D --> K["RunEvent + OpenTelemetry"]
  J --> G
  G --> L["提交回复或 Handoff"]
  L --> K

入口 Job 不会直接“问模型然后发送消息”。它先创建或取得 Run,校验这次执行是否仍然有效,再选择 V1 对话服务或 V2 AgentRunnerService。V2 Runner 负责构建上下文、预取知识、装配 Agent、绑定模型上下文、运行工具循环并归一化结果;Job 则负责最终消息提交、人工接管检查和 Run 状态迁移。

这种分层看似比一次 chat.ask 多了几层,但它把模型调用和业务提交分开了。模型完成不等于业务结果可以提交,这是异步 Agent 在生产环境中最重要的一条原则。

Agent 不是配置表,而是可组装的业务角色

一次 Agent Run 的执行生命周期

一次生产级 Agent Run 并不是“构造 Prompt → 调模型 → 发消息”这么简单。真正的执行链还包含上下文装配、知识检索、Tool/Handoff、结果归一化,以及最关键的 提交前有效性校验

Captain 通过 Concerns::Agentable 将业务模型转换为 Agents::Agent

Agents::Agent.new(
  name: agent_name,
  instructions: ->(context) { agent_instructions(context) },
  tools: agent_tools,
  model: agent_model,
  temperature: temperature,
  response_schema: agent_response_schema
)

这里有三个刻意的选择。

第一,Prompt 不是存成一段孤立字符串。Assistant 与 Scenario 都通过模板渲染上下文,运行时再注入会话、联系人、活动、知识检索结果和产品配置。这样能让同一个 Agent 定义服务多条会话,而不会把会话数据写回 Agent 配置。

第二,Scenario 是受控的专用 Agent,而不是任意嵌套的工作流。主 Assistant 与已启用 Scenario 彼此注册 Handoff,SDK 可以完成角色切换;Runtime 同时约束 Handoff 名称长度,以兼容模型服务商的函数名限制。业务上,它适合把“售后排障”“退款说明”“产品使用指导”等职责拆成专用角色,而不需要引入一个重量级编排引擎。

第三,结构化输出是能力协商而不是默认假设。当所选模型声明支持 structured_output 时,Runner 使用 Captain::ResponseSchema;否则 Prompt 显式要求只返回包含 responsereasoning 的 JSON。应用层最终还会做 JSON 解析与归一化,因此模型能力不同不会扩散到上游业务接口。

Scenario Handoff:角色内部的转交

Scenario Handoff 解决的是“当前应该由哪个 Agent 继续处理”。在 AgentRunnerService#build_and_wire_agents 中,Runtime 先创建主 Assistant Agent,再为账号内启用的 Scenario 创建专用 Agent,最后建立有限的双向关系:

assistant_agent.register_handoffs(*scenario_agents)
scenario_agents.each { |scenario_agent| scenario_agent.register_handoffs(assistant_agent) }

因此,主 Assistant 可以把问题交给某个 Scenario,Scenario 也可以把处理权交还主 Assistant;系统没有默认开放任意 Scenario 之间的网状跳转。这个约束很重要:角色越多,模型越容易在角色之间来回切换,有限拓扑可以降低循环和不可解释路由的概率。

Scenario 的 Handoff 名称必须同时满足两个条件:对模型来说要能表达目标角色,对 Provider 来说要是合法的函数名。Captain::Scenario#handoff_key 使用稳定的 scenario_{id}_{slug}_agent 形式,并将 handoff_to_ 前缀、总长度上限和 slug 截断规则集中处理。这样 Scenario 改名不会影响已经存在的数据库 ID,也不会因为名称过长导致工具定义被 Provider 拒绝。

Handoff 只改变当前 Agent 的控制权,不等于已经完成了一次业务动作。切换后,新的 Agent 会在同一个 Run 上继续使用请求级模型 Context、预算和会话上下文;最终是否发送回复、调用工具或转人工,仍由 Runtime 的提交检查决定。

Human Handoff Tool:从自动化切到人工

Scenario Handoff 与 Human Handoff 的边界

人工 Handoff 的语义完全不同。Captain::Tools::HandoffTool 被 Agent 调用后,会按受限的 Tool Context 找到当前会话,在 Assistant 所属账号范围内写入私有原因,并调用 conversation.bot_handoff!。非 Campaign 会话还会按现有流程触发 Out-of-office 模板。这是一次真实的业务状态变更,而不是给用户返回“建议联系人工”。

两种 Handoff 的边界可以概括为:

机制 转交对象 是否改变会话人工接管状态 典型用途
Scenario Handoff 另一个 Agent 角色 从通用客服切到售后、退款或排障角色
Handoff Tool 人工客服队列 知识不足、用户明确要求人工或自动化无法继续

Runtime 还要防止同一轮同时走 V2 和旧版 V1 Handoff。AgentRunnerService 在 Tool 完成回调中记录 captain_v2_handoff_tool_calledResponseBuilderJob 提交阶段优先处理 V2,只有在会话仍处于 pending 时才回退到 V1。V2 已经完成接管后,不再次发送 Out-of-office,也不再次调用 bot_handoff!,避免重复业务副作用。

可以把一次角色转交和人工接管理解为两条不同的状态路径:

sequenceDiagram
  participant U as 用户
  participant R as Agent Runtime
  participant A as Assistant Agent
  participant S as Scenario Agent
  participant H as Handoff Tool
  participant C as 会话

  U->>R: 新消息
  R->>A: 启动 Run
  A->>S: Scenario Handoff
  S-->>R: 继续推理/调用工具
  S->>H: 需要人工处理
  H->>C: 写入原因并 bot_handoff!
  R-->>U: 发送后续提示或等待人工

多租户的关键:Provider 必须是请求级上下文

多租户 Provider Context 隔离

为什么这很重要?

在多账号、多 Provider 的 SaaS 中,模型配置不是普通的全局应用配置,而是一次 Run 的执行上下文。API Key、API Base、Model 和 Timeout 都应该随请求隔离。

早期 Agent SDK 通常提供全局 configure。这对单模型 Demo 很方便,但在多账号、多 Provider 的 SaaS 中是危险的:一次请求修改了 API Key、API Base 或默认模型,另一个并发请求可能恰好继承这些配置。

Captain 的 Runtime 将 Provider 解析与执行绑定到一次 Run:

  1. Llm::FeatureRouter 按账号、功能能力和启用状态解析可用路由。
  2. Llm::ClientFactory 校验 Provider 后创建请求级 RubyLLM::Context,其中包含 API Key、API Base 和请求超时。
  3. AgentRunnerServiceon_chat_created 回调中,把同一个 Model 与 Context 绑定到初始 Agent Chat 和 Handoff 后创建的 Chat。
  4. 全局模型注册表只补充无凭据的模型元数据,不保存租户的连接信息。

因此,两个账号可以并发使用不同的模型和网关地址,而不需要用全局 Mutex 把所有 Agent 请求串行化。更重要的是,本次 Run 在开始时已经固定 Provider 快照,管理员随后修改配置也不会改变正在执行的请求。

Provider 的降级同样遵循副作用边界。Runtime 会在请求开始前通过熔断器跳过已经不可用的主 Provider;但 Agent/Tool 循环启动后,AgentRunnerService 以非幂等模式执行,不会因为一次异常把同一轮带 Tool 的推理自动重放到下一个 Provider。避免重复调用外部工具,比追求表面的高可用更重要。

工具调用不是“放开给模型”,而是进入预算闸门

Tool Budget Guard

Tool 不是能力清单,而是风险入口。

模型可以决定“想调用什么”,但 Runtime 必须决定“这次调用是否仍然被允许”。

Agent 的风险不只来自模型输出,还来自模型持续调用工具。Captain 在每个 Tool 外包一层 BudgetedTool:执行前先检查取消信号,再让 ExecutionBudget 校验本次调用,最后才调用真实工具。

默认预算如下,Assistant 配置可以按业务场景覆盖:

预算项 默认值 解决的问题
Agent 轮数 12 限制无边界的推理循环
Tool 调用次数 8 限制外部动作与成本
相同参数连续调用 2 次 识别无进展循环
总执行时间 90 秒 防止后台任务长期占用
输入 Token 60,000 控制长会话成本
输出 Token 8,000 控制长回复与成本
费用 可选上限 根据模型价格估算并拦截

重复调用检测不会简单比较 Ruby 对象。Runtime 会将 Tool 名称和参数规范化后生成 SHA-256 签名,只有连续相同签名超过阈值才判定为无进展。这样既能允许合理的多步调用,又能阻止模型在同一个动作上打转。

工具本身仍然保持业务语义。例如,知识库 Tool 返回经过相关性判定的证据,而不是让模型把全文当作上下文;Handoff Tool 会通过现有会话流程写入私有说明并调用 bot_handoff!,而不是只输出一句“建议人工处理”。Runtime 负责控制何时允许工具执行,领域 Tool 负责正确地完成业务动作。

对应实现:execution_budget.rbbudgeted_tool.rbfaq_lookup_tool.rbhandoff_tool.rb

RAG 不是 Agent 的附属能力,而是回答边界

客服和设备支持场景里,“模型能回答”不等于“系统应该回答”。Captain 在 Agent 运行前会对用户最新问题进行知识检索:只有支持类问题、检索结果存在且通过相关性判断时,才把检索内容作为 retrieved_knowledge_context 注入 Prompt。

注入内容有明确规则:回答产品和排障问题时只能使用已检索到的知识;知识不足时必须承认覆盖不足并建议转人工,不能用训练语料补全事实。Agent 运行中仍可通过 FAQ/文档 Tool 再检索,但 Runtime 会记录检索证据、来源、资源和耗时,并在最终回答不完整时以已检索证据做受控兜底。

这比“把向量搜索结果拼进 Prompt”多了一层约束,但它解决了生产知识库最常见的两个问题:模型假装已经查到资料,以及检索无答案时自行编造操作步骤。

Run:让一次 Agent 执行变成可恢复的业务对象

Captain Run 状态机

Captain::Run 是这套轻量 Runtime 的持久化中心。它不保存成一段黑盒日志,而是显式记录执行类型、来源、账号、Provider、Model、触发消息、结果、Usage、失败码和幂等键。

状态机只允许有限迁移:

queued -> running -> completed
                 -> failed | cancelled | superseded | timed_out
queued -> failed | cancelled | superseded | timed_out

每次迁移都在行锁内完成,并追加不可变的 Captain::RunEvent。事件覆盖路由解析、检索开始与完成、模型开始与完成、Provider fallback/熔断、Tool 开始与完成、转人工、预算超限及终态。

这样做带来三个直接收益:

  1. 幂等:同一账号、同一 Run 类型和同一幂等键只能创建一条 Run,任务重试不会重复发送回复。
  2. 防陈旧提交:自动回复采用 latest-message-wins。新消息到达、人工接管或 Assistant 解绑后,旧 Run 被标为 superseded;即使模型已经返回,提交前仍会再次校验,不会覆盖人工结果。
  3. 可取消、可定位:在已接入交互取消的入口(例如 Playground)中,用户取消会先记录 cancel_requested,运行中的检查点发现后将 Run 转为 cancelled 并中断;故障排查可以从 Run 事件还原“慢在检索、模型还是 Tool”。

正式自动回复路径写入 RunEvent 时只保留用于诊断的结构化元数据,避免将 API Key、API Base、完整消息正文和 Tool 参数/结果写进事件时间线。这是可观测性与数据最小化之间必须保留的边界。

幂等:不是一个唯一索引,而是三道防线

生产级 Agent 的三层幂等防线

幂等的目标不是让模型“只请求一次”。

真正需要保证的是:入口可以重试、Worker 可以重复投递、模型结果可以晚到,但业务副作用最多被接受一次,陈旧结果永远不能覆盖更新的业务状态。

在 Agent Runtime 中,幂等的目标不是保证模型“只请求一次”。网络重试、队列重复投递、并发 Worker、旧模型结果晚到,都可能让同一个动作被再次尝试。幂等真正保证的是:无论执行入口重复多少次,业务副作用最多被接受一次,重复请求可以读到第一次执行的结果,陈旧结果不能覆盖更新的业务状态。

第一层:Run 创建幂等

Captain::Runs::CreateService 在事务内完成 Run 的查找、创建和排队事件写入。自动回复场景会先锁定会话行,使同一会话的并发创建可以串行判断;数据库通过 (account_id, run_type, idempotency_key) 唯一索引提供最终约束。

处理顺序如下:

  1. 按账号、Run 类型和幂等键查找已有 Run。
  2. 找到且业务上下文相同,直接复用已有 Run,不重复创建。
  3. 未找到则尝试插入;并发请求若触发 RecordNotUnique,回读已存在的 Run。
  4. 同一个键被用于不同上下文时返回稳定的 run_idempotency_conflict,而不是悄悄复用错误结果。

这里的“上下文相同”也有明确判定:Playground 比较 assistant_id,其它 Run 比较 trigger_message_id。所以幂等键不能只在客户端随机生成,它必须代表一次可重放的业务请求。

第二层:最新消息胜出,阻止陈旧提交

创建新的自动回复 Run 后,Runtime 以会话中 trigger_message_id 最大的 Run 作为 winner,并将其它仍处于 queuedrunning 的自动回复 Run 转为 superseded,事件原因记录为 newer_customer_message

这还不够,因为旧 Run 可能已经完成模型调用。ResponseBuilderJob 在模型调用前后、回复提交前以及 Handoff 前后反复调用 ensure_captain_run_current!。检查项包括:

  • Run 仍是 running,而不是已取消、超时或被 supersede;
  • 会话仍允许自动回复,Assistant 仍绑定且启用;
  • 触发消息有效,且没有更新的客户消息;
  • 没有人工公开回复;
  • 当前 Run 仍然是该会话的最新 Run。

任一条件不满足,Run 会转为 superseded,旧答案不会创建为公开消息。这个设计把“模型已经返回”与“可以提交结果”明确分离,解决了慢请求覆盖新消息或人工回复的问题。

第三层:Operation 与 ToolCall 幂等

Operator 的 Skill 执行使用另一组业务对象,但沿用相同原则。Operation 以 (account_id, idempotency_key) 唯一约束标识一次技能执行;Captain::Mcp::ToolCallService 在发起远端 MCP 请求前,先按账号和幂等键查找 ToolCall:同一 Operation 直接返回已有调用,不再访问远端;如果同一键属于另一 Operation,则返回 tool_call_idempotency_conflict

每个 ToolCall 还保存请求参数、参数 SHA-256 摘要、状态、响应、耗时和错误码。这样重试不仅能避免重复执行,也能判断“重复的是同一个动作还是错误复用了一个键”。MCP 请求在执行前还要通过 Operation 状态、Capability、风险等级、确认上下文及输入/输出 JSON Schema 校验;幂等不是绕过权限和安全检查的快捷路径。

多步 Skill 的步骤键由 Captain::Skills::StepExecutor 按 Operation key 和步骤索引生成:

"#{@operation.operation_key}:step:#{@index}"

因此 Skill 在等待客户端桥接、确认或队列重试后恢复时,第 0 步仍然对应同一个 ToolCall,第 1 步也不会因为重新执行计划而重复发起。这个键是“计划中的位置”而不是随机 UUID,适合跨进程恢复。

幂等边界:哪些请求可以安全重试

Runtime 对无副作用的模型能力(例如 Embedding、FAQ 生成、重排)可以启用 idempotent: true,允许 FallbackExecutor 在 Provider 失败后切换路由。带工具循环或可能触发业务写入的 AgentRunnerService 则明确使用 idempotent: false,避免 Provider 自动重试造成重复扣款、重复发货、重复桥接或重复转人工。

可以用下面的判断来决定是否允许自动重试:

请求类型 是否可自动重试 原因
Embedding、重排、FAQ 生成 可以 只产生计算结果,没有外部业务副作用
纯文本 Agent 推理 视提交边界而定 模型调用本身可重试,但结果提交仍需 Run current 校验
带 Tool 的 Agent 循环 默认不自动重试 可能已经调用外部系统,无法假设“失败等于未执行”
MCP / Client Bridge 动作 仅依赖稳定幂等键重试 必须复用既有 ToolCall,且继续通过风险与确认校验

幂等键、数据库唯一约束、状态迁移锁和提交前的最新性校验缺一不可。唯一索引只能防止两条记录同时落库,不能阻止旧 Run 发送消息,也不能替代远端 Tool 的结果复用;真正的幂等是数据层、执行层和提交层共同完成的协议。

观测性:不要为了 Trace 再跑一次 Agent

Agent Runtime 的观测很容易踩一个隐蔽的坑:为了记录 Token 或 Trace,在 instrumentation 回调里再次调用模型。这样会造成重复调用、重复收费,甚至重复 Tool 副作用。

Captain 的做法是复用 Runner 的生命周期回调:on_agent_thinkingon_tool_starton_tool_completeon_agent_handoffon_chat_createdon_run_complete。Playground 可以把这些回调转换成实时 Trace;正式自动回复则把必要信息写入 RunEvent;启用 OpenTelemetry 时,再把动态属性注入同一条执行链。

Token 使用量由每次 Chat 结束时累计,Tool 完成回调用于记录 Handoff 和检索信息。所有这些动作都附着在已经发生的执行上,而不是新增一次推理请求。

为什么说它“轻量”

轻量不意味着功能少,而是只把必须由业务系统掌控的部分放进框架。

  • 不引入独立的流程编排平台,Agent 与 Scenario 的 Handoff 覆盖当前角色协作需求。
  • 不做无限自主执行,预算、取消和状态机定义了硬边界。
  • 不把所有 Tool 抽象为统一远程工作流,领域 Tool 保持在现有 Rails 事务和权限模型中。
  • 不通过全局 SDK 配置解决多 Provider,而是用请求级 Context 隔离。
  • 不把可观测性寄托于日志文本,而是沉淀 Run 和 Event 的稳定业务契约。

这让 Runtime 的复杂度集中在少数可测试的服务:AgentRunnerServiceExecutionBudgetFeatureRouterClientFactoryRun 状态迁移和事件服务。对于一个已经拥有会话、权限、任务和审计基础设施的业务系统,这通常比另起一个“通用 Agent 平台”更合适。

结语:把不确定性限制在模型边界内

一句话总结

一个生产级 Agent Runtime 的价值,不是让模型“更聪明”,而是让模型在不确定时仍然只能做出系统允许、可追踪、可恢复的动作。

Agent 系统的价值不只是“模型会调用工具”,而是当模型不确定、Provider 波动、用户连续输入、人工介入或任务取消时,系统仍然有可预测的行为。

Captain 的轻量 Agent Runtime 选择了一个务实路线:不抢占 SDK 擅长的通用推理能力,把工程投入集中在多租户隔离、业务提交保护、Tool 预算、知识边界和运行时可观测性上。模型能力会持续变化,但这些运行时约束会长期存在,并且决定 Agent 能否真正进入生产

评论

博主关闭了当前页面的评论