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

核心观点
大模型接入业务系统后,最容易被低估的并不是 Prompt,而是 Runtime。模型负责推理,SDK 负责通用 Agent 循环,而业务 Runtime 要负责控制执行、限制风险,并确保结果能够安全地提交到真实业务系统。
大模型接入到业务系统后,最容易被低估的并不是 Prompt,而是运行时。
一个 Demo 里,模型能调用工具、返回答案,似乎 Agent 已经成立;但进入生产环境后,问题会迅速变成:多个租户如何使用不同模型而不串配置?工具循环如何避免失控?客户连续发来三条消息时,旧请求晚到的结果能否阻止发送?模型、检索、工具、转人工分别花了多少时间?用户取消后,后台任务怎样停止提交结果?
Captain 的做法不是重写一个通用的 Agent SDK,而是在 ai-agents 和 RubyLLM 之上实现一层面向客服、知识库和各个客户端场景的轻量级 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 可以理解为位于 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 并不是“构造 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 显式要求只返回包含 response 与 reasoning 的 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:从自动化切到人工

人工 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_called;ResponseBuilderJob 提交阶段优先处理 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 的 SaaS 中,模型配置不是普通的全局应用配置,而是一次 Run 的执行上下文。API Key、API Base、Model 和 Timeout 都应该随请求隔离。
早期 Agent SDK 通常提供全局 configure。这对单模型 Demo 很方便,但在多账号、多 Provider 的 SaaS 中是危险的:一次请求修改了 API Key、API Base 或默认模型,另一个并发请求可能恰好继承这些配置。
Captain 的 Runtime 将 Provider 解析与执行绑定到一次 Run:
Llm::FeatureRouter按账号、功能能力和启用状态解析可用路由。Llm::ClientFactory校验 Provider 后创建请求级RubyLLM::Context,其中包含 API Key、API Base 和请求超时。AgentRunnerService在on_chat_created回调中,把同一个 Model 与 Context 绑定到初始 Agent Chat 和 Handoff 后创建的 Chat。- 全局模型注册表只补充无凭据的模型元数据,不保存租户的连接信息。
因此,两个账号可以并发使用不同的模型和网关地址,而不需要用全局 Mutex 把所有 Agent 请求串行化。更重要的是,本次 Run 在开始时已经固定 Provider 快照,管理员随后修改配置也不会改变正在执行的请求。
Provider 的降级同样遵循副作用边界。Runtime 会在请求开始前通过熔断器跳过已经不可用的主 Provider;但 Agent/Tool 循环启动后,AgentRunnerService 以非幂等模式执行,不会因为一次异常把同一轮带 Tool 的推理自动重放到下一个 Provider。避免重复调用外部工具,比追求表面的高可用更重要。
工具调用不是“放开给模型”,而是进入预算闸门

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.rb、budgeted_tool.rb、faq_lookup_tool.rb、handoff_tool.rb。
RAG 不是 Agent 的附属能力,而是回答边界
客服和设备支持场景里,“模型能回答”不等于“系统应该回答”。Captain 在 Agent 运行前会对用户最新问题进行知识检索:只有支持类问题、检索结果存在且通过相关性判断时,才把检索内容作为 retrieved_knowledge_context 注入 Prompt。
注入内容有明确规则:回答产品和排障问题时只能使用已检索到的知识;知识不足时必须承认覆盖不足并建议转人工,不能用训练语料补全事实。Agent 运行中仍可通过 FAQ/文档 Tool 再检索,但 Runtime 会记录检索证据、来源、资源和耗时,并在最终回答不完整时以已检索证据做受控兜底。
这比“把向量搜索结果拼进 Prompt”多了一层约束,但它解决了生产知识库最常见的两个问题:模型假装已经查到资料,以及检索无答案时自行编造操作步骤。
Run:让一次 Agent 执行变成可恢复的业务对象

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 开始与完成、转人工、预算超限及终态。
这样做带来三个直接收益:
- 幂等:同一账号、同一 Run 类型和同一幂等键只能创建一条 Run,任务重试不会重复发送回复。
- 防陈旧提交:自动回复采用 latest-message-wins。新消息到达、人工接管或 Assistant 解绑后,旧 Run 被标为
superseded;即使模型已经返回,提交前仍会再次校验,不会覆盖人工结果。 - 可取消、可定位:在已接入交互取消的入口(例如 Playground)中,用户取消会先记录
cancel_requested,运行中的检查点发现后将 Run 转为cancelled并中断;故障排查可以从 Run 事件还原“慢在检索、模型还是 Tool”。
正式自动回复路径写入 RunEvent 时只保留用于诊断的结构化元数据,避免将 API Key、API Base、完整消息正文和 Tool 参数/结果写进事件时间线。这是可观测性与数据最小化之间必须保留的边界。
幂等:不是一个唯一索引,而是三道防线
幂等的目标不是让模型“只请求一次”。
真正需要保证的是:入口可以重试、Worker 可以重复投递、模型结果可以晚到,但业务副作用最多被接受一次,陈旧结果永远不能覆盖更新的业务状态。
在 Agent Runtime 中,幂等的目标不是保证模型“只请求一次”。网络重试、队列重复投递、并发 Worker、旧模型结果晚到,都可能让同一个动作被再次尝试。幂等真正保证的是:无论执行入口重复多少次,业务副作用最多被接受一次,重复请求可以读到第一次执行的结果,陈旧结果不能覆盖更新的业务状态。
第一层:Run 创建幂等
Captain::Runs::CreateService 在事务内完成 Run 的查找、创建和排队事件写入。自动回复场景会先锁定会话行,使同一会话的并发创建可以串行判断;数据库通过 (account_id, run_type, idempotency_key) 唯一索引提供最终约束。
处理顺序如下:
- 按账号、Run 类型和幂等键查找已有 Run。
- 找到且业务上下文相同,直接复用已有 Run,不重复创建。
- 未找到则尝试插入;并发请求若触发
RecordNotUnique,回读已存在的 Run。 - 同一个键被用于不同上下文时返回稳定的
run_idempotency_conflict,而不是悄悄复用错误结果。
这里的“上下文相同”也有明确判定:Playground 比较 assistant_id,其它 Run 比较 trigger_message_id。所以幂等键不能只在客户端随机生成,它必须代表一次可重放的业务请求。
第二层:最新消息胜出,阻止陈旧提交
创建新的自动回复 Run 后,Runtime 以会话中 trigger_message_id 最大的 Run 作为 winner,并将其它仍处于 queued 或 running 的自动回复 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_thinking、on_tool_start、on_tool_complete、on_agent_handoff、on_chat_created 与 on_run_complete。Playground 可以把这些回调转换成实时 Trace;正式自动回复则把必要信息写入 RunEvent;启用 OpenTelemetry 时,再把动态属性注入同一条执行链。
Token 使用量由每次 Chat 结束时累计,Tool 完成回调用于记录 Handoff 和检索信息。所有这些动作都附着在已经发生的执行上,而不是新增一次推理请求。
为什么说它“轻量”
轻量不意味着功能少,而是只把必须由业务系统掌控的部分放进框架。
- 不引入独立的流程编排平台,Agent 与 Scenario 的 Handoff 覆盖当前角色协作需求。
- 不做无限自主执行,预算、取消和状态机定义了硬边界。
- 不把所有 Tool 抽象为统一远程工作流,领域 Tool 保持在现有 Rails 事务和权限模型中。
- 不通过全局 SDK 配置解决多 Provider,而是用请求级 Context 隔离。
- 不把可观测性寄托于日志文本,而是沉淀 Run 和 Event 的稳定业务契约。
这让 Runtime 的复杂度集中在少数可测试的服务:AgentRunnerService、ExecutionBudget、FeatureRouter、ClientFactory、Run 状态迁移和事件服务。对于一个已经拥有会话、权限、任务和审计基础设施的业务系统,这通常比另起一个“通用 Agent 平台”更合适。
结语:把不确定性限制在模型边界内
一句话总结
一个生产级 Agent Runtime 的价值,不是让模型“更聪明”,而是让模型在不确定时仍然只能做出系统允许、可追踪、可恢复的动作。
Agent 系统的价值不只是“模型会调用工具”,而是当模型不确定、Provider 波动、用户连续输入、人工介入或任务取消时,系统仍然有可预测的行为。
Captain 的轻量 Agent Runtime 选择了一个务实路线:不抢占 SDK 擅长的通用推理能力,把工程投入集中在多租户隔离、业务提交保护、Tool 预算、知识边界和运行时可观测性上。模型能力会持续变化,但这些运行时约束会长期存在,并且决定 Agent 能否真正进入生产

评论