首页
在线工具
搜索
1
如何将Virtualbox和VMware虚拟机相互转换
2
Markdown正确使用姿势
3
Typora+Picgo图床使用
4
使用Metrics指标度量工具监控Java应用程序性能(Gauges, Counters, Histograms, Meters和 Timers实例)
5
Kuboard与KubeSphere的区别:Kubernetes管理平台对比
杂谈与随笔
工具与效率
源码阅读
技术管理
运维
数据库
前端开发
后端开发
AI人工智能
Search
标签搜索
Angular
Docker
Phabricator
SpringBoot
Java
Chrome
SpringSecurity
Agent
SpringCloud
DDD
Git
Mac
K8S
Kubernetes
ESLint
SSH
高并发
Eclipse
Javascript
Vim
Jonathan
累计撰写
92
篇文章
累计收到
0
条评论
首页
栏目
杂谈与随笔
工具与效率
源码阅读
技术管理
运维
数据库
前端开发
后端开发
AI人工智能
页面
搜索到
92
篇与
的结果
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日
2026-03-19
从 0 到 1 架构 Web + Mobile 端到端自动化测试平台:Playwright + Appium + TypeScript
从 0 到 1 架构 Web + Mobile 端到端自动化测试平台:Playwright + Appium + TypeScript 当一个业务同时存在 Web、Android 和 iOS 三个客户端时,自动化测试最容易走向两个极端: 一种是三套测试工程各自发展,最终基础设施重复、测试数据重复、CI 重复; 另一种是为了“统一”而设计一个万能自动化框架,试图用同一套 Driver、Element API 同时操作浏览器和移动端,最后抽象层反而比业务代码更复杂。 本文介绍一种更适合中大型项目的架构: 执行引擎分开,业务能力共享,测试基础设施统一。 一、我们到底要解决什么问题? 假设一个产品同时拥有: Web 管理后台 Web 用户端 Android App iOS App 测试团队希望自动完成下面这些业务流程: 登录 ↓ 搜索商品 ↓ 加入购物车 ↓ 创建订单 ↓ 支付 ↓ 验证订单 这时候最直接的想法通常是: Web 自动化 Android 自动化 iOS 自动化 然后分别建立三套工程。 短期没有问题。 但当测试 Case 从几十条增长到几百甚至几千条后,就会逐渐出现这些问题: 自动化测试规模扩大 │ ┌────────────┼────────────┐ │ │ │ Web Android iOS │ │ │ 一套账号管理 一套账号管理 一套账号管理 一套测试数据 一套测试数据 一套测试数据 一套 API 一套 API 一套 API 一套日志 一套日志 一套日志 一套报告 一套报告 一套报告 问题本质不是: Playwright 和 Appium 怎么一起用? 真正的问题其实是: 如何让不同终端的自动化测试共享业务能力和基础设施,但又保持各自最合适的执行模型? 二、技术选型 这套架构采用: 语言 └── TypeScript Web └── Playwright Mobile ├── WebdriverIO ├── Appium ├── Android → UiAutomator2 └── iOS → XCUITest 工程管理 └── pnpm workspace CI ├── Jenkins ├── GitLab CI └── GitHub Actions 报告 ├── Allure ├── JUnit └── 自建测试报告平台 整体关系如下: flowchart LR A[TypeScript E2E Platform] A --> B[Web Tests] A --> C[Mobile Tests] B --> D[Playwright] D --> E[Chrome] D --> F[Firefox] D --> G[WebKit] C --> H[WebdriverIO] H --> I[Appium] I --> J[UiAutomator2] I --> K[XCUITest] J --> L[Android] K --> M[iOS] 为什么统一使用 TypeScript? 并不是因为 TypeScript 一定比其他语言更适合测试。 真正的价值是: Web 和 Mobile 可以共享类型、配置、API Client、测试数据、账号池和业务模型。 三、不要构建“万能 Driver” 架构设计中最重要的一个原则是: 不要把 Playwright 和 Appium 强行抽象成同一个底层 Driver。 很多自动化框架一开始都会尝试做类似这样的东西: class Element { async click() { if (platform === 'web') { // Playwright click } if (platform === 'android') { // Appium click } if (platform === 'ios') { // XCUITest click } } } 刚开始看起来非常优雅: element.click(); element.input(); element.wait(); 似乎 Web、Android、iOS 都统一了。 但随着业务复杂度提高,很快就会变成: if Web if Android if iOS if WebView if Native if Chrome if Safari if Element Scrollable if System Permission if Keyboard Visible if Android Version if iOS Version ... 最终得到的不是一个“统一自动化框架”。 而是: 一个比 Playwright 和 Appium 本身还复杂的中间层。 四、正确的抽象边界在哪里? 真正值得共享的是: 业务场景 测试数据 账号体系 API Client 环境配置 日志 报告 测试标签 CI 调度 而不应该强行共享: Locator Element Page Driver Gesture Browser Context Mobile Session 整体架构应该是: flowchart TD A[Business Scenario] A --> B[Web Flow] A --> C[Mobile Flow] B --> D[Page Object] C --> E[Screen Object] D --> F[Playwright] E --> G[WebdriverIO / Appium] F --> H[Browser] G --> I[Android / iOS] 一句话概括: 业务层统一,执行层隔离。 五、整体工程架构 推荐将整个自动化测试平台组织成 Monorepo。 e2e-automation/ │ ├── package.json ├── pnpm-workspace.yaml ├── tsconfig.base.json │ ├── packages/ │ │ ├── core/ │ │ │ │ └── src/ │ │ ├── config/ │ │ ├── api/ │ │ ├── data/ │ │ ├── accounts/ │ │ ├── logger/ │ │ ├── retry/ │ │ ├── assertions/ │ │ └── types/ │ │ │ ├── scenarios/ │ │ │ │ └── src/ │ │ ├── login/ │ │ ├── register/ │ │ ├── search/ │ │ ├── checkout/ │ │ └── profile/ │ │ │ ├── web-tests/ │ │ │ │ ├── playwright.config.ts │ │ │ │ │ ├── src/ │ │ │ ├── pages/ │ │ │ ├── components/ │ │ │ ├── fixtures/ │ │ │ └── helpers/ │ │ │ │ │ └── tests/ │ │ │ └── mobile-tests/ │ │ │ ├── wdio.shared.conf.ts │ ├── wdio.android.conf.ts │ ├── wdio.ios.conf.ts │ │ │ ├── src/ │ │ ├── screens/ │ │ ├── components/ │ │ ├── gestures/ │ │ ├── devices/ │ │ └── helpers/ │ │ │ └── tests/ │ ├── config/ │ ├── env/ │ ├── devices/ │ └── test-suites/ │ ├── scripts/ │ ├── run-web.ts │ ├── run-mobile.ts │ ├── prepare-test-data.ts │ └── cleanup-test-data.ts │ ├── artifacts/ │ ├── screenshots/ │ ├── videos/ │ ├── traces/ │ └── logs/ │ └── reports/ 这里最关键的是: core scenarios 这两个包不会关心: Browser 是 Chrome 还是 Safari 手机是 Android 还是 iPhone 底层是 Playwright 还是 Appium 它们主要负责: 环境 账号 测试数据 API 业务模型 日志 报告 六、Web:Page Object Web 使用 Playwright。 例如登录页: import { Page } from '@playwright/test'; export class LoginPage { constructor(private readonly page: Page) {} username = this.page.getByTestId('username'); password = this.page.getByTestId('password'); submit = this.page.getByTestId('login-button'); async login(username: string, password: string) { await this.username.fill(username); await this.password.fill(password); await this.submit.click(); } } 页面对象主要负责: Locator UI Operation Page Behavior 而不要承担业务流程。 例如下面这种代码就不建议放入 LoginPage: async loginAndCreateOrder() { // ... } 因为: Page Object 表示页面能力,不应该表示完整业务流程。 七、Mobile:Screen Object Mobile 使用: WebdriverIO ↓ Appium ↓ Android / iOS 登录页面可以写成: export class LoginScreen { get username() { return $('~username'); } get password() { return $('~password'); } get submit() { return $('~login-button'); } async login(username: string, password: string) { await this.username.setValue(username); await this.password.setValue(password); await this.submit.click(); } } Android 和 iOS 如果 UI 结构高度一致,可以共享部分 Screen。 但如果两端差异比较大,建议直接拆开: screens/ ├── android/ │ ├── LoginScreen.ts │ └── CheckoutScreen.ts │ └── ios/ ├── LoginScreen.ts └── CheckoutScreen.ts 不要为了“代码复用率”牺牲可维护性。 八、真正需要共享的是 Business Flow 假设我们有一个登录流程: export interface LoginFlow { login(user: TestUser): Promise<void>; } Web 实现: export class WebLoginFlow implements LoginFlow { constructor( private readonly loginPage: LoginPage ) {} async login(user: TestUser) { await this.loginPage.login( user.username, user.password ); } } Mobile 实现: export class MobileLoginFlow implements LoginFlow { constructor( private readonly loginScreen: LoginScreen ) {} async login(user: TestUser) { await this.loginScreen.login( user.username, user.password ); } } 于是业务测试可以变成: await loginFlow.login(user); await searchFlow.search('MacBook'); await cartFlow.addProduct(); const order = await checkoutFlow.checkout(); expect(order.success).toBe(true); 测试代码关注的是: 用户做了什么 而不是: 元素怎么点 元素怎么查 页面怎么滚 九、一条测试到底是怎么运行的? 整个执行链路如下。 flowchart TD A[开始测试] A --> B[加载运行环境] B --> C[创建测试账号] C --> D[准备测试数据] D --> E{测试平台} E -->|Web| F[启动 Playwright] E -->|Android| G[启动 Appium Android Session] E -->|iOS| H[启动 Appium iOS Session] F --> I[执行业务场景] G --> I H --> I I --> J[UI Assertion] J --> K[API / DB 验证] K --> L{是否成功} L -->|成功| M[记录测试结果] L -->|失败| N[截图 / 视频 / 日志 / Trace] N --> M M --> O[清理测试数据] O --> P[生成测试报告] 这里有一个非常重要的理念: 自动化测试不是单纯的 UI 操作。 完整测试实际上是: API + Test Data + UI + Assertion + Logging + Reporting 十、测试数据必须成为一等公民 很多 E2E 自动化项目最后不稳定,并不是 Playwright 不稳定。 也不是 Appium 不稳定。 而是: 测试数据不稳定。 例如: test('用户可以购买商品', async () => { await login('test001', '123456'); }); 这种代码隐藏了大量问题: 这个账号还存在吗? 余额够吗? 是否被其他测试占用? 是否已经购买过? 有没有被封? 环境数据是否一致? 更合理的方式是: const user = await testData.createUser({ balance: 10000, status: 'active' }); 然后: await loginFlow.login(user); 测试完成以后: await testData.cleanup(user); 测试生命周期应该变成: flowchart LR A[Test Case] A --> B[API 创建用户] B --> C[API 创建商品] C --> D[API 创建测试订单数据] D --> E[UI 执行核心业务] E --> F[API / DB 验证结果] F --> G[清理测试数据] 这样 UI 自动化只验证: 必须通过 UI 才能验证的内容。 十一、账号池设计 当 CI 开始并行运行以后,会出现另一个问题: Test 01 ─┐ Test 02 ─┼── test001 Test 03 ─┘ 三个 Case 同时使用一个账号。 后果通常是: 状态污染 订单冲突 购物车冲突 登录 Session 冲突 数据互相覆盖 因此建议设计: Account Pool 运行时: flowchart LR A[Test Worker 1] --> D[Account Pool] B[Test Worker 2] --> D C[Test Worker 3] --> D D --> E[test001] D --> F[test002] D --> G[test003] 基本接口可以是: const account = await accountPool.acquire({ type: 'normal-user' }); try { await runTest(account); } finally { await accountPool.release(account); } 这样才能真正支持稳定并发。 十二、Web 浏览器矩阵 Web 可以通过 Playwright Projects 定义浏览器矩阵: export default defineConfig({ projects: [ { name: 'chrome', use: { ...devices['Desktop Chrome'] } }, { name: 'firefox', use: { ...devices['Desktop Firefox'] } }, { name: 'webkit', use: { ...devices['Desktop Safari'] } } ] }); 执行关系: flowchart LR A[Web Test] A --> B[Chrome] A --> C[Firefox] A --> D[WebKit] B --> E[Result] C --> E D --> E 但并不意味着所有 PR 都要跑所有浏览器。 测试策略应该分层。 十三、Mobile 设备矩阵 Mobile 与 Web 最大的区别是: Web 并发资源通常是: CPU Memory Browser Worker 而 Mobile 真正受限制的是: Device Simulator Emulator 因此应该构建设备池: flowchart TD A[Mobile Test Queue] A --> B[Device Scheduler] B --> C[Android 01] B --> D[Android 02] B --> E[iPhone 01] B --> F[iPhone 02] C --> G[Test Case] D --> G E --> G F --> G 设备元数据建议维护: interface Device { id: string; platform: 'android' | 'ios'; version: string; model: string; status: 'idle' | 'busy' | 'offline'; } 以后可以进一步扩展成 Device Farm。 十四、测试标签体系 当 Case 达到几百条以后,不能再依靠目录决定执行策略。 建议采用 Tag。 例如: 运行级别 @smoke @regression 优先级 @p0 @p1 @p2 业务 @login @checkout @payment @search 平台 @web @android @ios 环境 @staging @production-safe 例如: test( '@p0 @smoke @checkout 用户可以完成购买', async () => { // ... } ); 然后执行: pnpm test --tag @smoke 或者: pnpm test --tag "@p0 && @checkout" 进一步还可以实现: pnpm test:web --tag @regression 以及: pnpm test:android --tag @smoke 十五、CI 怎么设计? 不要每次 Pull Request 都执行全部 E2E。 假设: Web Case 300 Android Case 200 iOS Case 200 如果全部执行: 700 Cases × 多浏览器 × 多设备 整个 Pipeline 会非常昂贵。 建议采用三级测试策略。 flowchart TD A[Pull Request] A --> B[P0 Smoke] B -->|Pass| C[Merge] B -->|Fail| D[Block Merge] C --> E[Core Regression] E --> F[Nightly] F --> G[Full Matrix] G --> H[Chrome] G --> I[Firefox] G --> J[WebKit] G --> K[Android Devices] G --> L[iOS Devices] 推荐: Pull Request P0 Smoke 核心链路 例如: 登录 搜索 下单 支付 目标: 5 ~ 15 分钟 Merge 执行: Core Regression 例如: 核心业务 核心页面 主要设备 Nightly 每天晚上执行: Full Regression 包括: Chrome Firefox Safari Android 多版本 iOS 多版本 十六、报告体系 报告不能只有: Passed Failed 一个成熟的 E2E 平台应该记录: Test Name Platform Environment Browser Device App Version Git Commit Start Time Duration Screenshot Video Trace Console Log Appium Log Network Log Error Stack 最终报告可以呈现: Run #10231 ────────────────────────────────── Web Chrome 145 / 147 Firefox 142 / 147 WebKit 144 / 147 Android Pixel 81 / 83 Samsung 79 / 83 iOS iPhone 15 82 / 83 ────────────────────────────────── Passed 573 Failed 11 Skipped 6 整体流程: flowchart LR A[Playwright Result] --> D[Report Aggregator] B[Android Result] --> D C[iOS Result] --> D D --> E[JUnit] D --> F[Allure] D --> G[Database] G --> H[Trend Dashboard] 十七、失败的时候到底保存什么? 一个失败 Case 至少应该留下: Screenshot Video Error Stack Console Log Device Log Web 还可以保存: Playwright Trace Network Browser Console DOM Snapshot Mobile 可以保存: Appium Server Log Device Log Screen Recording Page Source 理想状态是开发者看到: Checkout failed 之后,不需要 QA 手工复现。 而是可以直接看到: 哪一步失败 失败时页面长什么样 前端报了什么错 接口有没有失败 设备有没有异常 十八、Flaky Test 怎么处理? E2E 自动化最大的敌人不是 Fail。 而是: Flaky。 也就是: 第一次失败 第二次通过 第三次又失败 如果简单粗暴地设置: retry = 3 很多问题会被掩盖。 更合理的方法: flowchart TD A[Test Failed] A --> B[Retry] B --> C{Retry Result} C -->|Fail| D[Real Failure] C -->|Pass| E[Mark Flaky] E --> F[Flaky Database] F --> G[Trend Analysis] G --> H{超过阈值?} H -->|Yes| I[创建治理任务] H -->|No| J[继续观察] 建议给每一个 Case 维护: Owner Failure Rate Flaky Rate Average Duration Last Failure 甚至可以设置: Flaky > 5% 自动通知负责人。 十九、完整自动化测试平台 随着能力逐步增加,最终整个测试平台会演进成: flowchart TB A[Developer / QA] A --> B[Test CLI] B --> C[Test Orchestrator] C --> D[Web Runner] C --> E[Mobile Scheduler] D --> F[Playwright] E --> G[Android Device Pool] E --> H[iOS Device Pool] F --> I[Business Scenario] G --> I H --> I I --> J[Test Infrastructure] J --> K[Account Pool] J --> L[Test Data] J --> M[API Client] J --> N[Config] J --> O[Logger] I --> P[Test Artifacts] P --> Q[Screenshot] P --> R[Video] P --> S[Trace] P --> T[Logs] Q --> U[Report Platform] R --> U S --> U T --> U U --> V[Trend Analysis] U --> W[Flaky Analysis] U --> X[Alert] 这时候它已经不再只是一个: 自动化脚本仓库 而是一个真正的: E2E Automation Platform 二十、推荐实施路线 如果从 0 开始,不建议一上来就做: 设备云 测试管理后台 账号中心 报表中心 调度中心 Flaky AI 分析 第一阶段最重要的是: 跑通闭环。 Phase 1:基础能力 先实现: Playwright Appium TypeScript pnpm workspace 然后选三个 Case: Login Search Checkout 要求: Web ✓ Android ✓ iOS ✓ Phase 2:基础设施 增加: Test Data Factory Account Pool API Client Environment Config Logging Artifacts Phase 3:CI 实现: PR Smoke Merge Regression Nightly Full Regression Phase 4:设备池 逐渐支持: Android Emulator Android Real Device iOS Simulator iOS Real Device Phase 5:平台化 最后才进入: Report Platform Device Farm Flaky Dashboard Trend Analysis Quality Dashboard Alert Case Owner 整个演进路线: flowchart LR A[脚本] --> B[工程化] --> C[基础设施] --> D[CI] --> E[设备池] --> F[报告平台] --> G[质量平台] 二十一、三个最值得坚持的设计原则 最后总结一下这套架构中最重要的三个原则。 1. 执行引擎分开 Web ↓ Playwright Mobile ↓ Appium 不要为了统一而统一。 2. 业务能力共享 共享: Scenario Test Data API Account Config Logger Report 而不是: Element Driver Locator 3. UI 只负责验证 UI 能够通过 API 完成的: 创建用户 创建订单 创建商品 初始化数据 清理数据 尽量不要通过 UI 做。 UI 自动化应该把时间投入到: 用户真正看到和操作的关键路径 二十二、最终架构 最终推荐的结构可以总结为: E2E Automation Platform │ ┌───────────┴───────────┐ │ │ Playwright Appium │ │ Web WebdriverIO │ ┌──────┴──────┐ │ │ Android iOS UiAutomator2 XCUITest ────────────────────────────────────────────── Shared Infrastructure ────────────────────────────────────────────── TypeScript Business Scenario Test Data Factory Account Pool API Client Environment Config Logging Reporting Tagging Artifacts CI Orchestrator Device Scheduler 架构原则可以浓缩成一句话: 执行引擎分开,业务能力共享,基础设施统一。 当项目只有十几条自动化 Case 时,架构可能并不重要。 但当 Case 达到: 100 500 1000+ 真正决定这个项目能不能继续维护的,已经不是: 某一个 Locator 怎么写 而是: 测试数据如何管理? 设备如何调度? 测试如何并发? 失败如何定位? Flaky 如何治理? 多平台如何共享业务能力? CI 如何控制执行成本? 这也是自动化测试从: “写脚本” 走向: “工程化” 再走向: “平台化” 最关键的一步。
2026年03月19日
2026-03-12
Codex Cli Windows下 乱码问题处理
Codex Cli Windows下 乱码问题处理 步骤 1、新建脚本 Setup UTF-8 PowerShell Environment # Setup UTF-8 PowerShell Environment chcp 65001 | Out-Null [Console]::InputEncoding = [System.Text.UTF8Encoding]::new() [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new() $OutputEncoding = [System.Text.UTF8Encoding]::new() $PSDefaultParameterValues['Out-File:Encoding'] = 'utf8' $PSDefaultParameterValues['Set-Content:Encoding'] = 'utf8' $PSDefaultParameterValues['Add-Content:Encoding'] = 'utf8' Write-Host "PowerShell UTF-8 environment configured successfully." 命名为Setup-UTF8PowerShell.ps1保存到目录 2、打开目录和打开powershell窗口 执行改脚本 ./Setup-UTF8PowerShell.ps1 执行codex 测试下 如果报如下错误 在powershell会报codex : 无法加载文件 C:\Program Files\nodejs\codex.ps1,因为在此系统上禁止运行脚本。有关详细信息,请参阅 https:/go.microsoft.com/fwlink/?LinkID=135170 中的 ab out_Execution_Policies。 所在位置 行:1 字符: 1 + codex + ~~~~~ + CategoryInfo : SecurityError: (:) [],PSSecurityException + FullyQualifiedErrorId : UnauthorizedAccess 在powershell窗口执行 Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
2026年03月12日
1
2
...
19