首页
在线工具
搜索
1
如何将Virtualbox和VMware虚拟机相互转换
2
Markdown正确使用姿势
3
使用Metrics指标度量工具监控Java应用程序性能(Gauges, Counters, Histograms, Meters和 Timers实例)
4
Typora+Picgo图床使用
5
Jumpserver的MFA配置
杂谈与随笔
工具与效率
源码阅读
技术管理
运维
数据库
前端开发
后端开发
AI人工智能
Search
标签搜索
Angular
Docker
Phabricator
SpringBoot
Java
Chrome
SpringSecurity
Agent
SpringCloud
DDD
Git
Mac
K8S
Kubernetes
ESLint
SSH
高并发
Eclipse
Javascript
Vim
Jonathan
累计撰写
91
篇文章
累计收到
0
条评论
首页
栏目
杂谈与随笔
工具与效率
源码阅读
技术管理
运维
数据库
前端开发
后端开发
AI人工智能
页面
搜索到
1
篇与
的结果
2026-06-13
从 Demo 到生产:Captain 轻量级 AI Agent Runtime 的工程实践
从 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 能否真正进入生产
2026年06月13日