AI技能管理平台设计:协议化、动态加载与可观测性实践

发布时间:2026/10/18 6:29:04
AI技能管理平台设计:协议化、动态加载与可观测性实践 最早的时候我负责的 AI 应用加技能就是在一个主文件里做 if-else 分支。AI 说“帮我查天气”我们把调用路由到天气接口说“帮我算运费”再路由到计价服务。一开始只有三五个技能感觉还挺清爽。等技能堆到二三十个那个分支函数已经长得没法看路由逻辑、工具描述、参数校验、返回结果格式化四段代码散落各处每次新增能力都要在同一批核心文件里来回改漏一处就线上事故。这个项目后来单独拆出来做代号叫 HagiCode Skill。目标非常明确把“新增一个 AI 技能”从“改三处核心代码”变成“扔一个技能包进去就能被模型调用”。这篇文章就聊聊我在设计这个系统时的核心思路包括技能协议怎么定、执行引擎怎么做动态加载、模型侧如何调用、组合技能怎么编排以及最后踩过的那些观测与排障的坑。如果你也在搭类似的 AI 技能管理平台或者手头有一堆工具函数想统一接入大模型这篇文章应该能给你一份直接能用的参考。1. 从技能路由失控说起HagiCode Skill 到底要解决什么问题1.1 未协议化阶段的阵痛先说第一阶段的问题。当技能数量超过 20 个后旧的 if-else 方式已经不只是“难看”的问题了它在流程上会拖垮整个迭代速度。具体表现有三类。第一类是“改一个技能要碰多个文件”。加一个订单查询技能我要改动工具定义数组、路由分发逻辑、参数校验函数、返回结果整理函数运气不好还要动提示词里的示例。第二类是“返回结构不统一”。有的技能返回 Python dict有的返回 JSON 字符串有的内部封装成{ data: ... }模型拿到这些五花八门的结构之后自己总结时经常胡说八道。第三类是“发版过于频繁”。哪怕只是改了一个技能的超时时间都要走一次完整的应用发布流程一群下游调用方都得跟着重启。这个阶段给我的最大教训是问题不是代码写得烂而是系统里缺少一个“稳定契约层”。每个技能都应该有统一的外壳和边界否则所有能力都会直接长在主干业务里最终变成一团乱麻。HagiCode Skill 的第一步就是补上这层契约。1.2 系统四要素协议、注册、执行、观测我当时给 HagiCode Skill 定的职责不是做一个“技能仓库”而是做一个完整的技能生命周期管理平台。它由四个核心部分组成组件职责解决的核心问题协议层定义技能描述的统一格式技能长什么样、能做什么、参数怎么传注册层管理技能包的登记、启停、版本、灰度新技能如何进入平台、老技能如何下线执行层负责技能运行时调度、校验、限流、隔离技能被调用时如何安全稳定地跑起来观测层采集调用链、指标、日志、费用出问题时怎么定位、成本如何核算这四层之间是单向依赖的。执行层只认协议层给出的标准接口注册层只负责状态管理观测层则旁路采集所有关键节点的数据。单向依赖的好处是每一层的改造都不会污染相邻层。比如后面我要把执行方式从“进程内插件”改成“独立 worker”观测层和协议层完全不用动。1.3 我重新定义的“可扩展”很多人一提“可扩展”就想到微服务、插件框架这类词汇。但 HagiCode Skill 里“可扩展”我拆成了两层含义。第一层是技能数量的扩展。也就是随时能新增一个技能且主干框架零修改。这是最基础的目标靠协议层和执行层来保证。第二层是执行形态的扩展。同一个技能包今天可以跑在进程内明天可以挪到独立容器后天甚至可以在另一个远端集群上执行。也就是说“技能逻辑”和“技能运行环境”要解耦技能包本身不关心自己在哪跑。这两层扩展有一个共同的依赖技能描述协议必须足够完整、足够稳定。协议一旦稳定下来后续所有扩展都是围绕协议做文章。所以接下来我先讲协议层因为它是整套系统里最重要的那个第一块基石。2. 技能描述即接口先用一份描述文件说清技能能做什么2.1 为什么不用类定义而用元数据文件早期我试过直接用 Python 类的__init__来描述技能信息比如技能名称、参数类型写在构造函数里。这种方式对工程师来说很顺手但它有一个致命问题类定义无法被大模型直接消费。你总不能让模型去读源码里的类型注解更不能让模型理解一个 Python 对象的内存结构。技能描述必须是一份可序列化、可解析、可静态校验的元数据。最终我选择了“YAML 描述文件 JSON Schema 参数校验”的组合人类可读的部分用 YAML机器校验的部分用 JSON Schema。技能执行代码里不再声明任何“技能长什么样”只实现一个统一的执行接口。所有关于技能的意图、参数、返回值、示例全部写在描述文件里。2.2 一份技能描述协议的典型结构这是 HagiCode Skill 中某个订单查询技能的描述文件我把字段注释也一并写上name: order_query namespace: commerce.order description: 查询订单状态支持通过订单号或手机号后四位查询适合处理“我的订单到哪了”“帮我看看订单进度”这类问题。 version: 1.3.2 tags: [order, commerce, query] timeout: 8000 auth: scope: user_token parameters: type: object required: [order_id] properties: order_id: type: string description: 订单号格式如 ORD-2025-0001 example: ORD-2025-0001 phone_tail: type: string pattern: ^[0-9]{4}$ description: 手机号后四位用于辅助身份验证 examples: - description: 查询已发货订单 input: order_id: ORD-2025-0001 output: status: shipped eta: 2025-08-20 items: [机械键盘, 显示器支架] - description: 缺少订单号期望模型追问 input: {} output: error: MISSING_REQUIRED_FIELD字段设计的关键点在于name和namespace组合成全局唯一技能 ID避免不同团队把技能重名parameters用 JSON Schema 子集因为它天然适合做自动化校验examples看起来是给测试用的但后来我发现它对模型理解技能至关重要后面会专门讲。2.3 description 怎么写直接决定模型的调用成功率这是整个协议设计里最容易被低估的字段。很多工程师写description就是一句“查询订单”然后等模型真正调用时发现模型不知道什么时候该用这个技能、参数该填什么。我实际测试下来的经验是description至少需要包含三个层次的信息。触发场景用户在什么意图下会用到这个技能。写“当用户询问自己订单的物流状态或发货进度时”比单纯写“查询订单”要命中得多。参数约束参数之间是否有依赖关系。比如phone_tail只作为校验辅助不能单独作为查询条件。负面排除什么情况不要调用。我经常写一句“如果用户询问的是订单退货不要使用本技能请转到售后类技能”这句话可以让误调用率下降不少。examples的价值同样不容小觑。我对比过同一版本技能加了示例之后模型正确填参的比例从 76% 提升到 93%。示例本质上是在替模型做“少样本推理”尤其是给一个“参数缺失时返回错误”的示例能让模型在参数不齐时更倾向于追问用户而不是硬编造一个订单号。2.4 版本演进与字段兼容策略技能会不断迭代HagiCode Skill 在协议层定了一条硬规矩主版本不兼容变更必须变更name或version主号绝对不允许原地改协议字段的含义。具体实现上注册中心会同时保留多个版本。新版本技能上线时旧版本继续服务直到调用方全部迁移。version用语义化版本第二位小版本变更只允许新增可选字段第三位补丁版本只能修描述文案或示例。这套规则看起来死板但它保证了两点模型侧的工具描述集可以增量更新不用每次全量刷新执行层做到按版本路由老版本和新版本可以并存验证。这里有一个我自己踩过的坑早期为了快速迭代我允许在parameters里直接删字段。结果某个上游应用还按旧参数传值校验失败率一夜之间涨了 40%。后来痛定思痛把所有破坏性变更都交给“新技能包 新版本号”解决平台内部不做原地修改。协议稳定了扩展才有安全感。3. 插件式执行引擎新增技能如何做到“零核心改动”3.1 执行路径的两种路线内置插件还是独立服务协议层定好以后执行层要考虑的是技能代码怎么跑起来。这个地方我评估过两条路线。第一条是进程内插件加载也就是把技能代码作为动态模块加载进主进程中。优点是调用延迟低、实现简单新增技能包不用起新服务缺点是隔离性弱一段技能代码如果内存泄漏或死循环可能拖垮整个 AI 服务。第二条是独立执行单元把每个技能包放到独立 worker 或容器里执行。优点是隔离性好技能崩溃不影响主服务缺点是调用链路多一跳延迟增加运维复杂度上升。HagiCode Skill 的第一阶段选了“进程内加载 严格沙箱约束”因为当时技能数量没到需要完全隔离的量级低延迟优先。但我把执行器抽象成了标准接口所有技能代码不依赖任何主进程内部对象只依赖一个execute(context, params)签名。这个抽象是刻意的即使未来切到独立容器技能代码本身一行都不用改。3.2 技能包的结构与加载流程一个技能包在目录里长这样skills/ order_query/ skill.yaml executor.py order_create/ skill.yaml executor.pyexecutor.py里只需要实现标准执行器接口我用一个极简的 Python 示例来说明from hagicode import SkillExecutor, ExecutionContext, SkillResult class OrderQueryExecutor(SkillExecutor): name order_query async def execute(self, ctx: ExecutionContext, params: dict) - SkillResult: # params 已经由协议层的 JSON Schema 校验过 order_id params[order_id] data await ctx.call_http( methodGET, urlf/api/orders/{order_id}, headersctx.build_auth_headers(), ) return SkillResult.ok(data)整个加载流程是完全自动化的扫描文件监听服务发现新技能目录或版本变更。解析校验读取skill.yaml先用协议层校验器检查字段合法再用 JSON Schema 校验参数定义。加载执行器动态导入executor.py检查执行器类名、方法签名是否符合标准接口。探针执行调用一次内部自带的小型健康检查比如传入examples里的样例参数看返回结构是否符合returns定义。注册上线通过健康检查的技能自动进入注册中心状态设置为active。广播变更通知模型适配层刷新工具集。自研这套流程花了一周多但它把那句“新增技能不用改核心代码”变成了现实。新技能从写好代码到被模型调用只要完成一次“放目录 校验通过”剩余链路全自动。3.3 注册中心技能状态机与灰度发布注册中心管的是技能生命周期。每个技能包都有状态机我是按这几个状态设计的状态含义进入方式draft已入库但未启用上传技能包后初始状态active可以被模型正常调用手动启用或探针通过后自动化启用degraded调用失败率超标自动降级观测层报警触发disabled暂停服务人工操作或版本下线灰度发布的思路也被我搬进来了。技能包可以按session_id的哈希值划分流量先放 5% 真实流量跑一天观察成功率没有掉再逐步放量到 30%、100%。因为 AI 技能调用的不确定性比普通 API 更高直接全量上线新版本技能很容易在某类 prompt 下出现参数错误灰度能把这些风险限制在一个很小的范围内。3.4 一个真实的新增技能上线链路最后我完整走一遍“新增一个运费估算技能”的链路方便你对照自己项目的步骤步骤操作耗时1写estimate_freight/skill.yaml和executor.py约 0.5 人天2放入skills/监听目录平台自动扫描并校验秒级3校验失败会收到结构化错误报告比如“缺少 returns 定义”秒级4本地用 platform debug 命令做一次探针调用分钟级5平台内点击“灰度发布”先放 5% 流量观察 1 小时6确认无异常后全量 active一键7模型适配层自动异步刷新工具集无需发版分钟级这套链路跑通以后我们团队新增一个简单技能的耗时从“改代码 发版 联调”的三天压缩到“写技能包 探针自测”的半天。核心代码的改动次数在整个技能数量翻倍的过程中是零。4. 模型侧的工具调用接入技能元数据如何被大模型理解4.1 把技能描述映射给 Function CallingHagiCode Skill 本身不直接和大模型对话它通过一个轻量适配层把技能元数据转成模型能理解的工具描述。以 OpenAI 风格的 Function Calling 为例转换出来的结构类似{ type: function, function: { name: order_query, description: 查询订单状态支持通过订单号或手机号后四位查询适合处理“我的订单到哪了”这类问题。, parameters: { type: object, properties: { order_id: { type: string, description: 订单号格式如 ORD-2025-0001 } }, required: [order_id] } } }这个过程几乎是机械转换skill.yaml里字段的名称、描述、类型直接对应到tools数组。适配层要做的事情不是字段映射而是决定哪些技能进入这轮对话的工具集。如果 200 个技能全部塞进上下文仅系统提示词里的工具描述就有几十 KB模型响应延迟和 token 成本都会爆炸。4.2 技能预筛选上下文里塞不下那么多技能我的方案是两层筛选。第一层是硬过滤基于技能标签和用户身份。例如用户没有商家权限时namespace: merchant.*下的技能全部不进候选集和购物意图完全无关的行为技能也直接过滤掉。第二层是软排序基于用户问题的 embedding 和技能 description 的 embedding 做相似度检索取 top K 个技能进入工具集。我没有用复杂的 RAG 框架只是把所有技能描述向量化后存进内存索引每轮根据用户最新问题做一次近邻匹配。K 值控制得很保守默认 12 个。这个数字是实测权衡出来的K 太小相关技能容易被遗漏K 太大模型在多个相似工具之间做选择时反而容易搞混。4.3 调用循环与失败纠错整个模型调用过程是一个闭环循环用户输入问题系统提取上下文并完成技能预筛。组装好tools后发送给模型。模型返回结构化指令说明要调用哪个技能、参数是什么。执行引擎校验参数如果不合法把校验错误返回给模型。模型根据错误重新生成参数或者向用户追问缺失信息。技能执行完成后把真实业务结果返给模型由模型生成最终回复。这里有个细节值得展开不要把底层报错直接反馈给模型要把它转成模型能理解的错误语义。我曾经直接把 Python 异常堆栈返回给模型结果模型开始一本正经地分析代码 bug甚至试图猜测堆栈里的变量值。后来我定义了一套标准错误码比如MISSING_REQUIRED_FIELD、AUTH_FAILED、UPSTREAM_TIMEOUT并附带简要说明模型就能迅速做出正确反应要么修正参数、要么调用另一个技能、要么向用户解释。4.4 实测中看到的数据变化接入这套适配层后我记录过一组对比数据。在技能总数 45 个的条件下不做筛选时单次请求 token 消耗平均多出 3800P95 延迟增加约 1.9 秒做两层筛选后模型关注度更集中参数校验通过率从 78% 提升到 92%。真正让我意外的是“错误重试”次数的下降——预筛把不相干技能剔除后模型选择错误工具的概率从 11% 降到 3% 左右。技能描述写得好不好在这种对比下体现得特别明显。5. 组合技能与上下文管道扩展的下一步是“让技能编排技能”5.1 为什么组合技能是刚需单技能解决的是“一个动作”比如查订单、算运费、发通知。但真实业务场景往往是“一连串动作”用户申请退货系统先校验订单状态再查商品是否在退货期内然后计算退款金额最后创建退款单。如果让模型在一个回合里连续发起四五次工具调用不仅慢而且中间任何一次参数理解偏差都可能让整个流程断裂。更可靠的做法是把这些动作编排成一个组合技能作为整体注册进平台模型只需要发起一次调用。这个设计让 HagiCode Skill 的扩展方式又上了一个台阶——你不光可以加原子技能还可以把多个技能组装成编排技能向前端暴露一个稳定入口。5.2 编排设计steps 与数据映射编排技能本质上也是一个技能包只是它的执行器是一个流程引擎。我用一份简化的 YAML 来说明编排逻辑name: order_return_flow namespace: commerce.after_sale description: 处理用户订单退货申请包含订单校验、退款计算、退款单创建三个步骤。 version: 2.0.0 steps: - id: query_order skill: order_query input: order_id: ${request.order_id} - id: check_policy skill: return_policy_check input: order_status: ${query_order.output.status} item_ids: ${query_order.output.items[].id} - id: create_refund skill: refund_create input: order_id: ${request.order_id} amount: ${check_policy.output.refund_amount}${}语法就是上下文引用的约定前一步的输出可以安全地作为后一步的输入。流程引擎还支持简单的条件分支比如- id: notify_customer skill: notify_user when: ${create_refund.output.success true}这套编排格式刻意做得很克制没有引入复杂的 DAG 依赖描述因为受控的线性流程已经能覆盖绝大多数业务场景。如果你需要真正的并行执行可以再加一个parallel字段指定多步骤并发跑但我觉得在一个“技能管理平台”里编排层保持简单可靠比追求大而全更重要。5.3 失败策略与幂等设计编排技能一旦涉及多个步骤失败处理就是必修课。我在流程引擎里内置了三种策略。终止核心步骤失败后停止后续步骤整个编排返回失败。适用于校验类步骤。跳过可选步骤失败时不影响主流程。适用于通知类步骤。降级调用一个备用技能替代当前技能。适用于查询类步骤比如主接口超时后换备用的只读副本。幂等设计则是另一个大坑。组合技能创建退款单时如果网络超时导致重试很容易创建两张退款单。我的方案是给每次技能执行生成一个execution_id执行器把它作为幂等键传给下游。下游如果发现同样的execution_id直接返回上一次的结果不再重复处理。这在金融、工单系统里是基本要求但在技能管理平台里很容易被忽略值得提前设计。5.4 组合技能如何再次成为一个可注册技能这是我觉得 HagiCode Skill 设计里最妙的一点编排完成后这个组合技能本身也会生成一份新的skill.yaml注册进平台可以被其他编排技能再次引用。也就是说编排技能和原子技能在注册中心里是平权的平台根本不区分“这是底层技能还是上层流程”。这个递归特性让平台的扩展能力有了质变。团队里的运营同学可以基于两个已有技能快速拼出一个新流程不用写任何执行代码只要编辑流程定义文件即可。组合技能的元数据、版本、灰度、可观测性全部复用现有体系没有为编排增加任何新的特殊机制。扩展性从“我能加新能力”升级成“我能组合出无限能力”这正是平台化设计最值得投入的地方。6. 可观测性设计每个技能调用都要有迹可循6.1 四个必看的指标技能平台本质上是一个服务网关但它比普通 API 网关多了一层复杂性每个技能调用背后都关联着一次大模型预测。所以我在观测指标里锁定四个核心维度指标统计口径我关注的异常信号调用量按技能、版本、模型分别统计某个技能调用量骤降到零通常是被预筛过滤了成功率技能执行成功次数 / 总调用次数平均成功率下降但单技能正常可能是编排流程变更延迟P50 / P95 / P99 分位延迟技能延迟正常但总体延迟变高要查模型 token 变大费用每一轮对话的 token 消耗单次对话 token 异常增长优先怀疑工具描述过大这四个指标里我会最先看成功率但不是看平均值而是看“分技能成功率”。因为平台所有技能共享模型上下文一个技能的description写得含糊会影响其他技能的调用准确率。如果整体成功率下降而单个技能正常那大概率是最近新上线的某个技能的描述跟已有技能互相干扰。6.2 trace_id 贯穿一次对话排查问题最怕的是“用户说了一句话然后系统内部做了七八件事最后报错却不知道是哪一件坏的”。所以我从第一版起就强制要求一次用户请求从进入平台开始生成全局trace_id这个 ID 会贯穿模型调用、技能预筛、执行引擎、下游 HTTP 调用全链路。每个技能执行时观测层会发送一条结构化日志类似这样{ trace_id: trc_8f3a2b..., span_id: span_order_query_01, skill: commerce.order.query, version: 1.3.2, phase: execute, code: 0, cost_ms: 132, token_cost: 0 }收到异常报警后我通常先按trace_id拉出整条链路再看每个 span 的phase和code。如果是validate阶段失败那是参数问题如果是execute阶段失败那要查技能自身逻辑或下游服务如果是timeout频繁就要检查技能执行器的资源分配。日志里带上version字段也很关键它能让问题定位精确到具体版本——因为同一个技能可能有多个版本在灰度并存。6.3 告警与排障的小技巧在搭告警规则时我建议不要只盯着“成功率 90%”这种绝对值阈值。AI 技能的调用成功率天然受模型输出影响某天模型版本微调可能导致参数填空率下降但这不完全是技能的问题。我的做法是设置“相对波动告警”跟过去 7 天同一时段的成功率做对比如果降幅超过 8% 且持续 10 分钟才触发告警。另外还有一个很容易被漏掉的信号参数校验失败后模型自动重试的次数。这个指标如果突然升高说明模型无法理解技能的参数约束多半是description或examples需要优化而不是执行器出问题。把排障视角从“代码错误”切换到“描述与模型的理解错位”之后很多线上问题五分钟左右就能定位。6.4 费用视角隐藏的 token 黑洞最后说一个成本相关的观察。技能平台的 token 消耗不只是模型生成回复的那部分每次把工具描述塞进上下文也要算钱。技术技能多起来之后如果预筛做得不严光是每轮请求携带的工具描述文本就可能消耗几千 token。我做了两个优化来止血。第一是压缩工具描述对进入工具集的技能只保留 name、短 description、必要参数摘要把完整参数校验和 examples 放在后台执行阶段再用。第二是缓存命中同一session_id下的多轮对话如果技能集合没变工具描述可以沿用上下文缓存不重复计费。这两个措施把平均单次请求的 token 消耗降掉了约 35%而且没有明显影响模型调用准确率。回到开头那个 if-else 大函数。做 HagiCode Skill 这么长时间我的核心体会是可扩展不是靠设计一堆华丽的插件机制而是靠一份稳定的协议把新增能力的成本降到最低。协议定得好扩展是自然长出来的协议定乱了后来补的每一行约束代码都在还债。所以如果你正要搭一个 AI 技能管理平台我建议把 60% 的精力花在技能描述协议和工具描述的打磨上而不是一上来就研究容器编排和插件热加载。系统里那个旧时代的 if-else 大函数我们一直没删每次打开它都在提醒自己分层和协议化这件事值得在最早期就认真做。