Manifest 锁定
published 后 manifest 不可静默修改:要改就发新版本,published 即契约
Run 级 hash 绑定
Run 启动时计算 manifest_hash 写入 RunContext,整个 Run 不变;半路换版本不影响当前 Run
灰度 + Fallback
三态生命周期 + 比例灰度 + manifest 内声明 fallback_skill,主 Skill 熔断时切备
详细设计说明
让 Skill 升级不再「把跑着的 Run 搞挂」
这份文档不讨论 Skill SDK 的实现细节,只讨论 Skill 作为可治理资产的版本生命周期:manifest 怎么写、什么算破坏性升级、Run 启动时如何锁住 hash、灰度发布如何切回、主 Skill 熔断时如何 fallback。评审时请关注:每一次工具调用是否都能定位到具体 manifest_hash、具体灰度环、具体责任人。
设计主张
Skill 版本治理的核心不是「支持多版本号」,而是让企业知道:当前 Run 用的是哪个 Skill 的哪个 manifest_hash、为什么允许使用、出问题时能不能精准回滚到上一稳定版。
评审关注点
- published 后 manifest 是否真的不可静默修改
- Run 中途版本变化是否会污染当前 Run
- 什么样的 schema 改动算破坏性
- 主 Skill 熔断时 fallback 链路是否兜底
状态:v1 草案
日期:2026-05-07
定位:Skill Hub 在 Tier 1 提供 manifest 注册 + 灰度配置 UI;Tier 2 在派 Run 时选定具体版本;Tier 3 在 Run 启动时锁定 manifest_hash 并写入 Trace
关联:与 harness-kernel-design(Run 级 hash 绑定与 Trace 留痕)、governance-hitl-design(破坏性升级触发 HITL)、agent-teams-design(团队级 Skill 包)配套
目标:让 Skill 从 draft 到 deprecated 的全生命周期可灰度、可回滚、可追溯,破坏性升级不再悄无声息地把生产 Run 搞挂
1. 定位与概念
一张图看清 Skill Hub 在三层架构里的位置,两张时序图分别展示「Run 启动锁 hash」与「灰度发布出问题切回」两个最关键的工程场景。
1.1 总体架构图
下图把 Skill Hub 的静态结构(manifest 注册 + 版本仓 + 灰度环 + Run 级 hash 关联)与三层定位叠加:横向 3 个 tier 带,T1 区是 manifest 注册 + 灰度配置 UI,T2 区把"选 Skill 版本"的决策落实,T3 区在 Run 启动时锁定 manifest_hash 并贯穿整个 Run。
1.2 两个关键场景时序图
下面 2 张时序图分别可视化「Run 启动锁 hash」和「灰度发布出问题切回」两个最关键场景。点击 lifeline 头部色块跳到下方对应章节。
Run 启动那一刻锁 manifest_hash,整个 Run 不变;中途即使 admin disable 了该版本,当前 Run 也能跑完。
v1.5.0 灰度 10% 发现错误率飙升,admin 把灰度比例改回 0%,新 Run 立即只走 v1.4.2 老版本。已经在跑的灰度 Run 不被打断(参见 Scenario A 的不变量)。
1.3 为什么要做版本治理
不治理的痛点:
| 痛点 | 具体表现 | 影响 |
|---|---|---|
| 破坏性升级把跑着的 Run 搞挂 | 开发者直接改 manifest(删字段 / 改类型 / 加 OAuth scope),生产 Agent 启动时报 schema 不兼容 | 线上事故;用户看到的是 Agent 突然不能用 |
| 升级范围不可控 | 「修个 bug」直接上 latest,所有 Agent 一夜之间换版本,无法分批验证 | 问题暴露面 100%;回滚也是 100% 影响 |
| 问题事后无法定位 | 同一个 Skill 跑了多次,每次行为不同,但日志里只有 skill_id 没有版本指纹 | 客户报问题没法复现;root cause 分析全靠猜 |
| 主 Skill 故障无兜底 | 飞书发消息 Skill 临时挂了,所有依赖的 Agent 全部中断 | 单点故障扩散;没有降级路径 |
| 下架影响面盲查 | 要 disable 一个版本,不知道哪些 Agent / 哪些 Run 在用 | 下架决策不敢做,老版本越积越多 |
版本治理解决的具体技术问题:
- 把 Skill 行为契约冻结成 manifest_hash——published 后不可改,要改就发新版本,Run 启动时锁住一个 hash 整段不变
- 把升级动作变成「显式选版本」——Agent 启用具体 SkillVersion,不吃 latest,不会被悄悄换掉
- 把发布动作变成可灰度可回滚——三态生命周期 + 比例 / 白名单 / 时间窗多维度控制 + 一键改比例切回
- 把 fallback 写进 manifest——主 Skill 熔断时按声明切备,1 次连环切换上限
- 把 Trace 强约束扩展到版本维度——每条 ToolInvocation 必带 manifest_hash,支持版本级聚合查询
1.4 三层职责定位
| 层 | 承担 | 不承担 |
|---|---|---|
| Tier 1 · Skill Hub | Skill / SkillVersion 实体的 CRUD;manifest 注册 + schema 校验;rollout 配置 UI;fallback 链路声明;前端 Skill Market 上架审核 | 不参与运行时版本选择决策;不感知 Run / RunState |
| Tier 2 · Runtime | 读 Agent 启用记录 + rollout 配置选择具体 SkillVersion;命中灰度后返回选定 manifest_hash;主 Skill 熔断时按 fallback 链路重试;影响面查询服务 | 不存储 manifest;不暴露 Skill 管理 API |
| Tier 3 · Run Kernel | Run 启动时把 Tier 2 解析出的 manifest_hash 锁进 RunContext;整个 Run 期间用同一份 hash;ToolInvocationTrace 必带 manifest_hash 字段 | 不感知 rollout / fallback 配置;不知道版本是新是旧;只认锁定的 hash |
2. Manifest 结构
列出 SkillManifest 的最小必填字段集,明确哪些字段进 hash(行为契约)、哪些字段不进 hash(治理面)。
2.1 必填字段
Skill 提交审核时 manifest 必须包含以下字段,缺一禁止 publish:
| 字段 | 类型 | 说明 | 进 hash |
|---|---|---|---|
id | string | Skill 唯一标识(namespace.name 格式) | 是 |
version | semver | major.minor.patch,published 后冻结 | 是 |
display_name | string | UI 展示名 | 否 |
description | string | 面向用户与 LLM 的能力描述 | 否 |
input_schema | JSON Schema | 入参契约,必须可机器校验 | 是 |
output_schema | JSON Schema | 出参契约 | 是 |
actions / triggers | list | 本版本暴露的动作 / 触发器列表 | 是 |
permission | object | OAuth scopes / 资源权限声明 | 是 |
side_effects | enum | read_only / write / external_send / cross_system / destructive(不可逆破坏:fs 删除 / DB drop / git reset --hard / 覆写已有文件) | 是 |
risk_level | enum | low / medium / high;high 强制 HITL | 是 |
sandbox | object | 沙箱声明:是否需要网络出站、文件系统、凭证 | 是 |
fallback | object? | 备用 Skill 声明(见 §6) | 是 |
owner | string | 责任人 / 团队 | 否 |
tags | list | 分类标签 | 否 |
2.2 沙箱声明
沙箱字段定义 Skill 的执行隔离边界,平台据此分配沙箱资源、做安全审计:
| 子字段 | 类型 | 含义 |
|---|---|---|
sandbox.network_egress | list | 允许出站的域名白名单;空 = 不允许任何出站 |
sandbox.filesystem | enum | none / read_only / scoped_write;scoped_write 必须给出可写路径 |
sandbox.credentials | list | 需要注入的凭证类型(user_oauth / workspace_secret / platform_secret) |
sandbox.timeout_seconds | int | 单次执行最大耗时;默认 30s |
sandbox.memory_mb | int | 沙箱内存上限 |
沙箱字段全部进 hash —— 因为它们直接影响 Skill 的可观察行为(能不能联网、能不能写盘),任意改动都视为新版本。
2.3 进 hash 与不进 hash 的边界
判定原则:影响 Skill 行为契约的字段进 hash;只影响治理 / 展示的字段不进 hash。
| 类别 | 典型字段 | 说明 |
|---|---|---|
| 进 hash(不可变) | schema / permission / side_effects / risk_level / sandbox / fallback | 改了就是新版本,必须重新发布 |
| 不进 hash(可热改) | display_name / description / tags / owner | 纯文案 / 责任人变更不影响行为,UI 直接改即可 |
| 外部治理面(不在 manifest) | rollout 比例 / 白名单 / 启用列表 | 属于 RolloutConfig,单独存储;可随时改 |
2.4 destructive 标记与参数级再校验
静态 manifest 标记解决不了一类边界问题:"什么都能跑"的通用 Skill(shell_exec / python_exec / file_io)—— 把整个 Skill 一刀切标成 destructive 太粗(用户大量只读命令也会被拦),不标又会漏过 rm -rf。规则分两层:
| 层 | 作用 | 示例 |
|---|---|---|
| Skill 注册时(静态) | 专用破坏性 Skill 在 manifest 里直接标 side_effects: destructive,Policy 一律走 HITL | workspace.delete_path / db.drop_table / git.reset_hard |
| Tool 调用时(动态) | 通用 Skill 在每次调用时由 Tool 控制器对入参再过一次 Policy;命中关键字模式即升级到 destructive,否则按 manifest 默认档 | shell_exec("rm -rf …") / shell_exec("mv -f …") / 任何带 > 覆写重定向 / git reset --hard |
关键字白名单 / 黑名单由 Policy 引擎集中维护(不在 manifest 里),可热更;命中后强制 HITL,文案带原始命令 + 命中规则 + 影响 path 与文件数,对接 治理与 HITL 设计 ↗ 风险矩阵 M8(用户 workspace 不可逆操作)。沙箱挂载已经把作用范围圈在用户 workspace 内(详见 沙箱边界 §F ↗),这一层是"用户私有空间内的二次确认"。
3. Semver 规则
明确什么算破坏性变更,让兼容性自动检查可机器执行;不允许开发者「随手」升 patch 但实际改了 schema。
3.1 三档变更分级
| 升级类型 | 触发条件 | 示例 |
|---|---|---|
| PATCH(1.4.1 → 1.4.2) | 行为不变的 bugfix;文案 / 错误码细化;观测埋点完善 | 修一个边界条件 bug;改 description 文案 |
| MINOR(1.4.x → 1.5.0) | 新增能力但向后兼容:input_schema 加 optional 字段、output_schema 加字段、新 action / 新 trigger | 给已有 action 加可选参数;新增一个 trigger |
| MAJOR(1.x.x → 2.0.0) | 任何破坏性变更;旧调用方按老 schema 写的代码会挂 | 删 required 字段;改字段类型;加 OAuth scope;提升 risk_level |
3.2 破坏性变更清单
以下变更自动判定为 breaking,发布时强制要求 major 升级,否则禁止 publish:
| 变更 | 为什么算破坏性 |
|---|---|
| input_schema 删 required 字段 | 旧调用方继续传该字段会校验失败 |
| input_schema 字段类型变化(string → int) | 旧调用方传的值类型不再匹配 |
| input_schema 加新 required 字段 | 旧调用方没传新字段会被拒绝 |
| output_schema 删字段 | 下游消费方读不到该字段 |
| output_schema 字段类型变化 | 下游消费方解析失败 |
| permission 增加 OAuth scope | 消费方需要重新授权,老凭证不再够用 |
| side_effects 升级(read_only → write / external_send / cross_system / destructive) | 策略层风险评估结果不同;任意升级都可能从「无需 HITL」变成「必须 HITL」 |
| risk_level 升级(low → high) | HITL 决策树命中条件变化;强制 HITL 审核 |
| sandbox.network_egress 加新域名 | 引入新外部依赖,安全边界扩大 |
| 删除某个 action / trigger | 调用方按旧 action 调会找不到 |
3.3 兼容性自动检查
开发者点击「发布新版本」时,平台自动跑 manifest diff:
- 对比当前 manifest 与上一 published 版本的 schema / permission / side_effects / sandbox
- 命中破坏性变更清单 → 强制要求 version 段升 major;如果开发者写的是 patch / minor,禁止 publish 并给出 diff 报告
- 未命中破坏性变更 → 允许 minor / patch;若开发者主动声明 major 也允许(开发者认为是重大变更)
- major 升级额外要求:HITL 审核 + 影响面查询确认 + 灰度计划填写(不允许 100% 一次性切)
核心原则:开发者只能「升得比规则要求高」,不能「升得比规则要求低」。这把"假装兼容"的路径堵死。
4. manifest_hash 锁定
说明 hash 怎么算、什么时候锁、锁住后哪里看得到,让"那次调用用的哪个版本"成为可机器查询的事实。
4.1 hash 计算
manifest_hash 是规范化后的 manifest 的 SHA-256 摘要,只覆盖 §2.3 中标记「进 hash」的字段。规范化步骤:
- 抽取所有「进 hash」字段,丢弃其余
- 对 JSON Schema / 列表类型按 key 排序,确保字段顺序变化不影响 hash
- 对字符串做 NFC 归一化、去除尾随空白
- 整体序列化为 canonical JSON,对结果取 SHA-256,截取前 16 位作为短 hash 用于 UI 展示
同一 manifest 不同机器算出来必须一致;任何「进 hash」字段改动都会让 hash 变化。
4.2 锁定时机
| 时机 | 动作 |
|---|---|
| SkillVersion publish 时 | 计算 manifest_hash,写入 SkillVersion.manifest_hash 列;published 后该列不可改 |
| Run 启动时(Tier 3) | 对 Agent 启用的每个 Skill,调用 Tier 2 Resolver 拿到当前应使用的 SkillVersion,把 manifest_hash 写进 RunContext.skill_versions |
| Run 失败重启时 | 视为新 Run,重新走解析流程,可能拿到新的 hash(比如灰度环已经切回老版本) |
| 子 Run 派生时 | 父 Run 的 RunContext.skill_versions 不透传给子 Run;子 Run 独立解析(避免父 Run 锁定的旧 hash 干扰子 Run 的灰度命中) |
4.3 RunContext 内的存储
RunContext.skill_versions 是一个 skill_id 到 manifest_hash 的映射,类型为只读字典;Run 期间任何 Tool 调用都必须用映射里的 hash,不允许绕过去查 Tier 1:
| 键 | 值 | 含义 |
|---|---|---|
| skill_id | manifest_hash | 这次 Run 期间该 skill 一直用这个 hash |
| (skill_id, version) | — | 同时记录一份 semver 字符串供 Trace 展示,但 hash 才是真正的契约 |
4.4 Trace 留痕
| 位置 | 字段 | 用途 |
|---|---|---|
| ToolInvocationTrace | skill_id / skill_version / manifest_hash | 每条工具调用都能定位到具体 hash;事后审计 / 复盘 / 对比 |
| RunTrace | skill_versions(启动快照) | 整个 Run 用了哪些版本一目了然 |
| AuditLog | skill_version_resolved 事件 | 记录解析过程:用户视角的版本 vs 实际生效的版本(灰度命中) |
Trace 留痕的核心承诺:拿到任意一条 ToolInvocationTrace,就能精确回答「那次调用用的是哪个 manifest_hash、属于哪个 published 版本、是不是命中了灰度」三个问题。
5. 灰度发布
定义新版本如何分批触达用户、如何监控、出问题如何切回,全程不需要改代码。
5.1 三态生命周期
SkillVersion 在生命周期上只有三个长期态,转换由 admin 操作驱动:
| 状态 | 含义 | 新 Run 可见性 | 已锁定 Run |
|---|---|---|---|
published | 已发布、可被启用 | 按 rollout 决定 | 正常运行 |
deprecated | 不建议新启用,但已启用的继续可用 | 不出现在 Skill Market 选择列表;已启用的 Agent 维持 | 正常运行 |
disabled | 紧急下架,所有未启动的 Run 不再可用 | 不可解析,必须 fallback | 正在跑的 Run 跑完,新 Run 走 fallback |
开发期的 draft / submitted / rejected 不属于这里讨论的范围 —— 那是上架审核流程,不是治理面。
5.2 灰度维度
同一 published 版本可按多种维度灰度,命中任一维度即纳入灰度环:
| 维度 | 配置 | 典型用法 |
|---|---|---|
| 用户比例 | rollout_percent | 10% 比例渐进放量;按 user_id hash 分流,保证同一 user 在灰度持续期内归属稳定 |
| Workspace 白名单 | rollout_workspaces | 先在内部 / 客户 dogfood workspace 上线 |
| 用户白名单 | rollout_users | 开发者自测;少量种子用户灰度 |
| 时间窗 | rollout_start / rollout_end | 到期自动停止灰度;过了 end_time 强制全量或回退 |
5.3 灰度期间的可见性约束
- 未命中灰度的用户看不到新版本 —— Skill Market 列表里不出现;Resolver 返回老版本
- 命中灰度的用户在 Run 启动那一刻被锁定到新版本的 hash;之后即使 admin 改 rollout 比例,已锁定的 Run 不受影响
- 灰度命中是幂等的:同一 user_id + 同一 rollout 配置永远得出相同结果(避免同一用户的两个 Run 一个新一个老)
5.4 灰度指标 + 告警
每个灰度发布跟踪以下指标,与 runtime-governance §2 的观测体系联动:
| 指标 | 采集 | 告警阈值 |
|---|---|---|
| 调用成功率 | 新版本 vs 老版本对比 | 新版本下降 > 20% 触发 alert |
| 平均耗时 | p50 / p95 | p95 上升 > 50% 触发 alert |
| 错误类型分布 | 异常 code 维度统计 | 出现新错误类型超过总量 5% 触发 alert |
| 用户主动回退率 | 用户从新版本切回老版本的比例 | > 10% 触发 alert |
5.5 切回开关
灰度出问题时 admin 有两档操作:
| 操作 | 动作 | 影响 |
|---|---|---|
| rollout=0(治理面切回) | 把灰度比例改为 0%,新 Run 不再命中 | 秒级生效;已锁定的 Run 不被打断;版本状态仍是 published |
| disable(紧急下架) | 把版本状态改为 disabled | 所有未启动的 Run 走 fallback;正在跑的 Run 跑完;写 audit;通知所有受影响 Agent owner |
大多数灰度异常用 rollout=0 即可;只有发现安全漏洞 / 严重数据问题时才 disable。disable 前必须先看影响面(admin UI 强制弹确认)。
6. Fallback 声明
让主 Skill 故障时有明确的备份路径,避免单点故障扩散;fallback 写在 manifest 内,是开发者声明的契约,不是平台兜底的暗箱逻辑。
6.1 manifest 内的 fallback 字段
Skill 开发者可在 manifest 里声明备用 Skill;不写则视为无 fallback,主 Skill 故障即向上抛错。fallback 字段结构:
| 子字段 | 类型 | 含义 |
|---|---|---|
fallback.skill_id | string | 备用 Skill 的 id(必须已 published) |
fallback.version_constraint | semver range | 允许的版本范围,例如 ^2.0.0;解析时取范围内最高 published 版本 |
fallback.trigger | enum | 切备触发条件:timeout / 5xx / circuit_break / disabled |
fallback.input_mapping | object? | 主 Skill 入参到备用 Skill 入参的字段映射;缺省即直接透传 |
fallback.notes | string | 给运维看的说明:为什么这个备用合适 |
6.2 触发条件
| 条件 | 判定 |
|---|---|
| 主 Skill 调用超时 | 超过 sandbox.timeout_seconds 仍未返回 |
| 主 Skill 5xx | 底层服务返回 5xx;连续 N 次后熔断器打开 |
| 主 Skill 熔断器打开 | 错误率超过阈值,本 Run 直接走 fallback 不再尝试主 Skill |
| 主 Skill 版本 disabled | Resolver 解析时发现 hash 已下架,直接选 fallback |
6.3 切换上限与防环
- fallback 链路只允许 1 跳:主 → 备 失败即抛错,不再尝试备的备
- 主 Skill 与备用 Skill 不允许互为 fallback —— publish 时校验,发现环路禁止 publish
- fallback 解析也走完整 Resolver 流程:会再次锁定备用 Skill 的 manifest_hash 进 RunContext
- 切换事件全部进 ToolInvocationTrace:标记 fallback_triggered + 触发原因,便于事后定位
6.4 Fallback 不解决的问题
fallback 是最后一道兜底,不替代以下治理手段:
| 问题 | 正确手段 |
|---|---|
| 新版本质量不稳定 | 用灰度发布渐进放量,不要靠 fallback 兜 |
| 主 Skill 经常超时 | 修主 Skill 性能,调整 sandbox.timeout_seconds,不要靠备用兜 |
| 权限模型不一致 | 主备应使用同一权限模型;不同模型不该互为 fallback |
| 跨 vendor 切换 | 由 Tier 2 Provider 抽象层处理,不应该在 Skill manifest 层做 vendor 切换 |
核心原则:fallback 解决「主 Skill 暂时不可用」的问题,不解决「主 Skill 有缺陷」的问题。