首页
在线工具
搜索
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人工智能
页面
搜索到
3
篇与
的结果
2026-07-07
从“演示如何下单”到可控的终端引导:Captain 显示点单指南 Skill 技术实践
从“演示如何下单”到可控的终端引导:Captain 显示点单指南 Skill 技术实践 在收银端,用户说“演示如何下单”时,关键不是让模型生成一段操作说明,而是让收银界面真正进入一个可观察、可退出、不会误触发真实交易的引导状态。Captain 的“显示点单指南”Skill,也就是 show_ordering_guide,就是为这个场景设计的:模型负责理解意图,服务端负责生成受控流程,Android 收银端负责展示高亮并等待用户操作。 本文结合收银端演示截图,拆解这个 Skill 的定义、路由、Client Bridge 执行链、五个引导步骤、状态回传和安全边界。 先看最终体验:引导覆盖在真实收银页面之上 截图中的智能中心位于收银界面右侧。用户发起“演示如何下单”后,智能中心显示“正在客户端上执行”,同时收银页面被半透明遮罩覆盖,只保留当前步骤对应的可操作区域。圆形聚光区域依次指向: 左侧的商品大类入口。 商品卡片。 购物车中的确认下单入口。 结算页的 POS 支付入口。 最终订单确认按钮。 这是一种客户端辅助,而不是远程代操作。引导服务告诉客户端“下一步应该突出哪个控件”,客户端根据自己的页面结构定位目标,并由收银员完成点击。第一张截图还揭示了一个重要的运行时事实:智能中心在请求已交给收银端后显示“正在客户端上执行”,它等待的是客户端结果,而不是在服务端模拟点击。 sequenceDiagram participant O as 操作员 participant C as 智能中心 participant R as Captain Runtime participant B as Client Bridge participant P as Android 收银页面 O->>C: 演示如何下单 C->>R: 创建 Skill Operation R->>R: 路由、授权与 Schema 校验 R->>B: 创建 ui.show_guide 请求 B->>P: 下发固定引导流程 P-->>O: 遮罩、高亮、步骤文案 O->>P: 按当前步骤操作 P-->>B: completed / failed / disconnected B-->>R: 验证 nonce 并更新 ToolCall R-->>C: 更新 Operation 与状态卡片 Skill 的定位:固定工作流,而不是自由规划 关键属性如下: 属性 当前值 设计含义 slug show_ordering_guide 稳定的 Skill 标识,用于路由、快捷操作和审计 skill_type client_assist 结果由客户端展示和辅助操作 execution_mode configured_workflow 步骤由服务端配置,不交给模型临时编排 system_keys cashier 只允许收银系统使用 client_types android 只允许 Android 收银客户端执行 max_steps 1 Skill 本身只发起一个引导 Capability max_risk_level L1 低风险、只读的界面辅助动作 Capability ui.show_guide 通过 Client Bridge 启动引导 这里有一个容易混淆的地方:Skill 的 max_steps 为 1,并不代表界面只有一步。它表示 Operation 只执行一个 Capability;这个 Capability 的参数中携带了一个包含五个 UI 节点的引导流程。这样既能把一次完整引导作为可追踪、可取消的客户端请求,也避免模型逐步决定下一处页面要点击什么。 五个步骤如何对应截图 服务端的默认工作流 main.ordering.checkout 包含五个步骤: 顺序 Step ID 定位方式 截图中的交互 1 first_category resolver: main.order.first_category 高亮商品大类,提示“点击进入该商品大类” 2 first_food resolver: main.order.first_food 高亮第一个商品卡片,提示“点击将该菜品加入购物车” 3 cart_place_order target_id: main.cart.action.place_order 高亮购物车确认入口,提示校验库存并进入订单确认页 4 settle_pos target_id: main.settle.pay_method.unionpay 高亮支付方式入口,演示选择 POS 或银联支付 5 settle_confirm target_id: main.settle.action.confirm 高亮最终确认按钮,wait_for_dismiss 为 false 这五步不是模型逐步生成的推理轨迹,而是一条由服务端下发、由收银端执行的固定界面流程: flowchart LR A["启动点单指南"] --> B["first_category:解析首个商品大类"] B --> C["first_food:解析首个商品卡片"] C --> D["cart_place_order:高亮确认下单"] D --> E["settle_pos:高亮 POS 支付"] E --> F["settle_confirm:高亮最终确认"] F --> G["客户端回传引导结果"] 步骤 1:进入商品大类 客户端用 main.order.first_category 解析当前菜单中第一个可用商品大类,而不是依赖固定屏幕坐标。第二张截图中,聚光区域落在左侧“餐食”入口,文案提示操作员点击进入该商品大类。菜单调整后,解析器可以继续返回新的可用入口。 步骤 2:选择第一个商品 进入大类后,客户端用 main.order.first_food 解析可售商品卡片。第三张截图高亮“茶咖元气早餐”商品卡片,并引导操作员点击将其加入购物车。这里依然不由服务端指定商品的屏幕坐标或价格,避免菜单内容变化后引导失效。 步骤 3:确认下单 商品加入购物车后,引导改用稳定目标 ID main.cart.action.place_order。第四张截图将聚光区域定位到右下角“确认”按钮,说明该动作会校验购物车库存并进入订单确认页。客户端只展示和等待操作,真实订单状态仍由收银端自身业务流程决定。 步骤 4 与 5:选择支付方式并确认订单 结算页依次使用 main.settle.pay_method.unionpay 与 main.settle.action.confirm 两个稳定目标 ID。第五张截图展示最终确认按钮的聚光效果;在此之前,客户端会引导选择 POS 或银联支付。整个流程演示的是收银端原本就有的交互,Guide 不会替用户选择支付方式、提交订单或完成扣款。 前两个步骤使用 resolver,因为商品大类和商品卡片的位置会随门店菜单变化;后三个步骤使用稳定的 target_id,因为结算页的功能控件属于固定 UI 契约。动态内容用客户端解析器,稳定控件用版本化目标 ID,是这套终端引导的核心设计。 最后一步设置 wait_for_dismiss 为 false,客户端在展示最终按钮后不因引导遮罩被关闭而错误地把界面动作理解成服务端已经执行了支付。引导的完成表示“客户端已经调度并展示了流程”,不表示订单或支付由 Runtime 代为提交。 为什么流程参数必须由服务端生成 ui.show_guide 的输入 Schema 要求参数必须是 flow 对象,并限制: flow_id 和 display_name 必须满足长度约束。 requirements 只能声明已知的 scan_service_enabled。 步骤数量必须在 1 到 32 之间。 每个步骤必须提供 target_id 或允许的 resolver。 resolver 只能是 main.order.first_category 或 main.order.first_food。 每个步骤都不允许额外字段。 因此,模型不会因为误判或 Prompt 注入而临时构造未知页面路径。模型可以判断“用户想看点单流程”,但不能决定跳转到未知路由、执行支付动作或向客户端注入未经审核的逻辑。 configured_workflow 的执行步骤来自数据库中的 Skill 配置;默认 Provisioner 以幂等方式创建或升级 Skill、Capability 和步骤。运营人员可以管理 Skill 生命周期,Runtime 则只执行已发布、已授权的配置。 两种入口,最终走同一条执行链 自然语言入口 当操作员输入“演示如何下单”“带我走一遍点餐流程”或“教我怎么在收银机下单”时,Skill Router 会从已授权候选中选择 show_ordering_guide。路由还会检查: 当前 Assistant 是否绑定该 Skill。 当前会话是否来自收银系统。 Client Session 是否为 Android。 客户端是否声明了 ui.show_guide。 当前 Skill 是否启用且没有超出风险策略。 如果用户只问“怎么点单”,而没有表达演示或带领操作的意图,系统可以优先走知识库回答;“显示点单指南”适用于明确要求演示、学习或逐步带操作的请求。 Quick Action 入口 系统同时提供 show_ordering_guide Quick Action。点击快捷操作后,服务端直接生成同名 Skill 的执行计划,不再依赖一次 LLM Planner 判断。两种入口最后都经过 Candidate Resolver、Capability Policy、Plan Validator 和 Operation Runner,因此快捷入口不是绕过权限的后门,只是减少了意图识别的不确定性。 Client Bridge:从排队到完成的状态机 Client Bridge 请求由 Captain::ClientBridge::Request 创建为 Captain::ToolCall。它的状态迁移必须由服务端控制,客户端不能直接把任意请求标记为成功: stateDiagram-v2 [*] --> queued: 创建并通过授权和 Schema 校验 queued --> running: 客户端领取请求,服务端签发 nonce running --> completed: nonce、Capability、输出 Schema 均通过 running --> failed: 客户端失败、断开或输出不合法 queued --> failed: 客户端离线或版本不兼容 queued --> timed_out: 30 秒内未被领取 running --> timed_out: 回传超过过期时间 completed --> [*] failed --> [*] timed_out --> [*] 执行过程分为四个阶段。 1. 创建请求 Runtime 先检查 Operation 必须处于 running,再用 Capability Policy 验证 ui.show_guide 是否允许当前账号、收银系统和 Android 客户端使用,最后按输入 Schema 校验 flow。请求会保存参数摘要、幂等键和 30 秒过期时间。 2. 客户端拉取并领取 Android 客户端通过 Client Bridge 拉取待处理请求。DeliveryService 在事务中锁定最早的一条 queued 请求,将其改为 running,生成一次性 nonce,并返回请求信封,其中包含 capability_key、flow 参数和 expires_at。 一次性 nonce 的摘要只保存在服务端,客户端回传原值。这样即使请求 ID 被看见,也不能伪造一个有效的完成回调。 3. 客户端展示引导 收银端收到请求后,根据 resolver 或 target_id 在当前页面找到目标控件,绘制遮罩和聚光区域,并等待操作员完成步骤。截图里的“正在客户端上执行”就是这个阶段的运行态提示;它不是服务器正在模拟点击。 4. 回传并恢复 Operation 客户端回传 completed、failed、unsupported 或 disconnected,并附带 Capability、nonce 和结构化结果。服务端在行锁内校验请求仍为 running、Capability 与 nonce 匹配、响应状态合法、输出符合 Schema,然后更新 ToolCall。完成后,Skill Runner 恢复 Operation 并生成最终输出。 若 30 秒内没有完成回传,过期任务会转为 timed_out;客户端离线或版本不支持能力,则分别记录断开或版本不兼容错误,不会让智能中心无限等待。 异常处理不是在客户端吞掉失败后结束,而是沿同一条桥接链回到 Operation。下面的分支图说明了智能中心为什么能区分正常完成、离线、版本不支持与超时: flowchart TD A["创建 ui.show_guide ToolCall"] --> B{"客户端在线且版本支持?"} B -- 离线 --> C["failed: client_bridge_disconnected"] B -- 不支持 --> D["failed: client_bridge_unsupported"] B -- 支持 --> E["queued,等待客户端领取"] E --> F{"30 秒内领取?"} F -- 否 --> G["timed_out"] F -- 是 --> H["running,签发 nonce"] H --> I{"回传是否有效?"} I -- nonce 或 Schema 无效 --> J["failed"] I -- 客户端执行失败 --> J I -- 完成 --> K["completed,恢复 Skill Runner"] 幂等与重复点击:为什么不会重复创建引导 “显示点单指南”虽然是只读引导,但仍然需要幂等,因为消息队列可能重复投递,客户端也可能重复拉取。当前实现有三层保护: Operation 使用账号级幂等键标识一次 Skill 执行。 每个步骤按 operation_key 与 step_index 生成稳定 ToolCall 幂等键。 相同 Operation 再次执行时,ClientBridge::Request 直接复用已有 ToolCall,不再次创建客户端请求。 客户端回调也不是“收到一次就更新一次”:ToolCall 只有在 running 状态才接受响应,已处理请求再次回调会返回 client_bridge_response_already_processed。服务端还会校验 nonce、Capability 和输出 Schema,防止旧回调或伪造结果改变 Operation 状态。 这套幂等保证的是“不重复产生业务请求”,并不保证客户端界面不会因用户主动重新打开而再次展示引导。若操作员明确重新发起一条新的消息,它应当拥有新的 Operation 和新的幂等上下文。 风险边界:引导可以做什么,不能做什么 ui.show_guide 的 Capability 被定义为: Channel:client_bridge。 Risk:L1。 System:cashier。 Client type:android。 Metadata:只读。 它只能启动一个服务端配置的交互指南,不能直接修改订单、提交付款或改变打印机状态。即使路由预设改成“行动优先”,也不会绕过 Capability 授权、客户端范围、风险等级和 JSON Schema 校验。 如果未来需要“自动选择商品”“自动提交订单”或“自动发起支付”,这些都应设计成新的 Capability,并单独定义输入输出、风险等级、确认策略和幂等语义,不能把它们悄悄塞进 ui.show_guide。 失败处理和用户体验 对操作员而言,技术状态应被翻译成可理解的反馈: 技术状态 用户可见反馈 处理建议 queued 正在准备点单指南 等待客户端领取请求 running 正在客户端上执行 在收银页面完成当前高亮步骤 completed 点单指南已完成 收起遮罩,保留结果摘要 client_bridge_disconnected 收银端暂时离线 检查客户端连接后重试 client_bridge_unsupported 当前客户端版本不支持 升级收银端客户端 timed_out 引导等待超时 重新发起指南或检查页面状态 failed 点单指南启动失败 展示失败原因,不执行任何真实订单动作 状态卡片应同时保留 Skill 计划和当前步骤,方便操作员知道系统正在等待什么;取消按钮则应触发 Operation 取消,而不是只关闭前端面板。 如何验证这个 Skill 建议用三类用例验证。 路由用例 “演示如何下单”应命中 show_ordering_guide。 “教我怎么在收银机下单”应命中同一 Skill。 “商品规格怎么选”应优先走知识库,不应直接启动客户端引导。 “检查 dine_in 打印机状态”不应误命中点单指南。 客户端能力用例 Android 收银客户端在线且声明 ui.show_guide 时,能收到请求。 客户端离线时,Operation 快速进入断开状态。 客户端版本不支持时,返回明确的升级错误。 请求过期后,迟到的完成回调不能改变状态。 流程完整性用例 五个步骤按固定顺序展示。 前两个动态解析器能定位当前菜单中的大类和商品。 结算页三个固定目标能正确高亮。 最终确认步骤不会误触发真实支付。 重复消息不会产生第二个并发引导请求。 结语:把“演示”做成一个受控的产品能力 显示点单指南的核心价值,是把一个看似简单的“教我点单”请求拆成四个稳定边界: 语言理解边界:识别用户确实要求演示,而不是咨询商品规则。 工作流边界:服务端只执行已配置、已审核的五步流程。 客户端边界:只有匹配的 Android 收银端才能展示指南。 业务副作用边界:引导可以高亮和等待,但不会替用户提交订单或支付。 这也是 Agent Runtime 设计终端 Skill 的通用方法:让模型负责理解,让服务端负责约束,让客户端负责呈现,让真实业务动作继续经过独立的 Capability、风险和确认体系。
2026年07月07日
2026-06-30
Captain 配置控制面的工程设计
Captain 配置控制面的工程设计 在 AI 客服、知识库和终端助手同时存在的系统里,“选择一个模型”从来不只是下拉框操作。不同能力需要的模型接口不同,故障时的降级策略不同,是否允许执行设备动作的风险也不同。若把这些规则散落在 Prompt、环境变量和业务服务中,系统会很快变得不可解释,也无法被运营团队安全地调整。 Captain 的设置页把这些决定集中为一个账号级控制面。页面上看到的是模型、路由、开关和阈值;运行时接收到的则是一套经过验证、带版本、可追踪的决策配置。这篇文章从这张页面出发,解释它背后的设计,以及配置如何进入实际请求路径。 设置页不是“模型选择器”,而是运行时控制面 Captain 设置由四类账号级配置组成,保存在 Account.settings 中: 配置 解决的问题 运行时作用 captain_models 为传统功能保留简单的默认模型选择 作为未配置 Feature Route 时的模型回退 captain_features 控制功能是否对账号启用 开关标签建议、帮助中心搜索、语音转写等能力 llm_feature_routes 为每项能力指定 Provider、Model 和降级链 让不同能力使用匹配的模型接口和可用性策略 captain_routing 定义消息在对话、知识库、Skill 和澄清之间如何决策 约束 Agent 是否检索、是否执行、何时转人工 这四类配置的边界刻意分开。功能开关回答“这项能力是否可用”;Feature Route 回答“它要使用哪条模型链”;路由配置回答“当前请求应该走哪类能力”。一个模型可以支持多个能力,但不能因此让同一份配置承担所有决策。 flowchart LR A["Captain 设置页"] --> B["账号 settings"] B --> C["配置校验与版本化"] C --> D["FeatureRouter\n解析模型路由链"] C --> E["EffectiveConfigResolver\n解析路由策略"] D --> F["Provider / Model 请求上下文"] E --> G["对话 / 知识 / Skill / 澄清"] F --> H["模型、Embedding、Rerank、转写"] G --> H 两层模型配置:兼容旧入口,也支持能力级路由 页面上方的“模型配置”保留了 Editor、Assistant 和 Copilot 的默认模型选择。这是面向现有功能的兼容层:当某项能力没有显式 Feature Route 时,运行时仍能使用原有的模型选择逻辑。 真正决定多 Provider 行为的是“LLM Feature Routes”。每一项 Feature Route 可以配置: 一个主路由,由 Provider 配置和模型配置组成; 最多三个有序的降级路由; 与该能力匹配的模型能力要求。 例如,Assistant 路由需要 chat、json_output 和 tools;帮助中心和 Skill 检索需要 embedding;知识库和 Skill 重排需要 rerank;语音转写需要 audio_transcription,且模型元数据必须完整。页面只显示已经启用且满足这些要求的模型,后端会再次验证,不能依赖前端筛选保证正确性。 这种拆分避免了一个常见错误:将“聊天模型能用”误认为“它能承担全部 AI 工作”。Embedding、重排、函数调用和语音转写的协议与成本模型都不同,运行时应按能力选择模型,而不是按页面上最显眼的一个模型做全局复用。 路由链的优先级:本账号优先,系统配置兜底 Llm::FeatureRouter 解析某项 Feature 时按下面的顺序寻找可用路由: 账号配置的该 Feature Route; 可兼容 Feature 的账号配置。目前 captain.skill_retrieval 可以复用 help_center_search 的 Embedding 路由; 系统级 Feature Route; 可兼容 Feature 的系统级路由; Provider 中可用且兼容的模型; 传统模型配置给出的回退模型。 每一条候选路由都会校验 Provider 是否启用、模型是否启用,以及模型是否满足能力要求。因此,管理员禁用模型或移除 Provider 后,失效配置不会被静默继续使用。 降级链也不是“所有失败都自动切模型”。Llm::FallbackExecutor 只会在请求被标记为幂等、错误可重试且存在下一条路由时切换。Embedding、重排等纯计算能力可以使用这个策略;带 Tool 的 Agent 循环可能已经产生业务副作用,运行时会以非幂等方式执行,不能因 Provider 异常自动重放到另一家模型服务。 这使高可用与业务安全能够同时成立:可安全重试的计算请求尽量恢复,可能调用外部系统的动作则保留明确的业务幂等和人工可见的状态。 路由策略:把“模型判断”变成可治理的产品决策 模型路由解决“由谁推理”,Agent 路由解决“此刻该做什么”。页面的“Agent routing”区将这一层显式暴露为账号默认策略,覆盖通用对话、知识优先级、Skill 执行和语义路由。 三种预设不是风格切换,而是风险配置 EffectiveConfigResolver 内置三种预设: 预设 执行意图要求 Skill 语义阈值 适合场景 保守 必须明确请求动作 高 0.82,中 0.64,最小差值 0.08 面向生产客服与知识问答 平衡 高置信 Skill 可放宽为偏好明确动作 高 0.78,中 0.62,最小差值 0.06 已评估过的内部助手 行动优先 可选明确动作 高 0.74,中 0.58,最小差值 0.04 专用、受控的运营终端 预设会给出一组相互匹配的默认值,而不是简单地改一个开关。管理员单独调整任意字段后,配置会被标记为 custom,避免界面把“部分修改过的预设”误称为某个标准策略。 每次更新还会递增 config_version,写入 updated_by_id 和 updated_at。这使运行记录可以关联到当时的路由版本,排查“为什么这条消息昨天走知识库、今天执行 Skill”时不必依赖日志猜测。 知识优先:先区分“咨询”与“执行” 默认策略将产品说明、操作指导和排障类问题优先导向知识库。IntentClassifier 对常见的问候、感谢、身份询问和能力询问走本地通用对话;对“如何设置”“连接失败怎么处理”这类知识意图,DecisionArbitrator 会在用户没有明确要求执行动作时优先返回 search_knowledge。 这一点对终端支持尤其重要。用户问“打印机断开后怎么重新连接”时,系统应先找文档证据,而不是将其理解为“立即操作打印机”。只有用户明确提出“帮我检查某台打印机状态”,并且后续 Skill、权限与风险校验都成立时,才允许进入执行链。 页面中的几个选项对应这一策略: “优先知识”:知识类咨询优先走检索; “高置信知识覆盖中等置信 Skill”:避免中等把握的操作候选压过可靠文档; “知识无结果时”:可选择先澄清再转人工、直接转人工或使用固定兜底; “未分类对话”:选择继续检索知识或要求用户澄清。 系统的 knowledge_only 约束保持启用,路由配置不能把无证据的业务回答重新开放给模型自由生成。 Skill 执行:自然语言不是权限 页面允许分别配置自然语言触发、显式执行意图和 Quick Action。它们控制的是“是否进入候选与规划”,不是“绕过安全直接执行”。 执行前仍会经历以下边界: Skill 必须属于当前 Assistant、当前账号和当前客户端范围; Capability 必须处于可用状态,且与客户端系统和类型匹配; Operation 的风险等级必须覆盖目标 Capability; L3 动作需要确认,L4 在当前阶段被阻止; Skill 计划和 Tool 输入输出都要通过 JSON Schema 校验。 所以“行动优先”只意味着更容易识别为执行意图,绝不意味着放松 Capability、权限或确认。设置页的安全提示正是为了让运营人员理解这个边界。 语义路由的灰度开关:先观测,再接管 复杂 Skill 选择不应完全依赖一次模型规划。Captain 提供语义检索和高/中置信区间,用于从已授权的 Skill 索引中找候选。页面将这套能力拆成四个可操作的控制项: 启用语义路由:在原有 Planner 前检索可用的 Skill 候选; 启用快速路径:高置信的已配置 Skill 可以跳过复杂 Planner; Shadow mode:并行评估语义决策,但仍以既有 Planner 作为实际执行路径; 灰度比例:按稳定的客户端会话控制允许快速路径的流量比例。 阈值不只包含高分和中分,还包含候选之间的最小分差,以及中等置信时是否要求词法证据。它们共同回答一个问题:系统是否真的“足够确定”用户想要这个 Skill,而不是只找到了最相近的名字。 Shadow mode 与灰度比例提供了一个可逆的上线顺序:先收集路由差异和候选证据,再扩大真正使用快速路径的会话比例。对于可能调用设备、订单或支付能力的 Agent,这比直接全量切换更适合生产环境。 配置写入:前端只提交意图,后端负责合并与验证 设置页通过 /api/v1/accounts/:account_id/captain/preferences 获取和更新配置。前端提交的是局部变更,例如某个 Feature Route、一个路由阈值或一个功能开关;控制器在服务端完成合并,而不是让浏览器提交整份未经处理的账号设置。 写入流程如下: sequenceDiagram participant UI as 设置页 participant API as PreferencesController participant R as EffectiveConfigResolver participant V as Validators participant A as Account.settings UI->>API: 提交局部配置 API->>R: 合并默认值、预设与更新值 R-->>API: 新版本的路由配置 API->>V: 校验模型、能力、路由链与字段范围 V-->>API: 通过或返回明确错误 API->>A: 保存账号配置 API-->>UI: 返回完整生效配置 校验分为三层: Feature Route 校验限制每个能力最多三个降级路由,禁止重复 Provider/Model 组合,并要求模型能力匹配; 路由配置校验限制允许的字段、枚举和数值范围,例如灰度比例必须在 0..100,中等置信不能高于高置信; Account 模型和功能配置校验阻止未知模型或不合法功能值写入。 如果某个原有路由因 Provider 或模型被禁用而变得不合法,后端会在后续更新过程中清理无效条目。配置的目标是保存“可执行的意图”,而不是保存一份会在运行时才爆炸的历史表单数据。 路由测试:验证决策,不制造副作用 设置页提供“测试路由”入口。它会展示最终路由、候选证据、知识状态和安全检查结果,但不会创建 Operation、SkillRun、ToolCall、MCP 请求或 Client Bridge 请求。 这个设计让管理员可以在调整阈值、预设或模型路由后,使用代表性的咨询和执行语句比较结果: “如何选择商品规格”是否稳定走知识库; “检查 dine_in 打印机状态”是否识别为受控的执行请求; 模糊的“检查一下”是否要求补充对象,而不是猜测执行; 特定客户端或角色不具备权限时,安全检查是否阻止后续动作。 对 Agent 配置而言,测试入口不是演示功能,而是变更前的最小验证环境。它把最难解释的路由问题提前暴露在没有外部副作用的阶段。 结语:让模型选择和业务风险分层治理 Captain 设置页的价值不在于提供更多下拉框,而在于将多模型系统的关键决策拆分为明确、可验证的层次:能力决定模型要求,Feature Route 决定 Provider 链,路由策略决定请求意图,Capability 与确认机制决定能否实际执行。 当这些层次都以账号级配置、版本和干运行测试承载时,运营团队可以逐步调整体验,工程团队也能稳定地追踪运行时行为。模型会更换、Provider 会波动、Skill 会增加,但“先验证配置、再执行决策、最后产生副作用”的控制面原则不应该改变。
2026年06月30日
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日