☰
AI技能管理平台设计:协议化、动态加载与可观测性实践
2026/10/18 6:28:47 网站建设 项目流程

最早的时候,我负责的 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.py

executor.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( method="GET", url=f"/api/orders/{order_id}", headers=ctx.build_auth_headers(), ) return SkillResult.ok(data)

整个加载流程是完全自动化的:

  1. 扫描:文件监听服务发现新技能目录或版本变更。
  2. 解析校验:读取skill.yaml,先用协议层校验器检查字段合法,再用 JSON Schema 校验参数定义。
  3. 加载执行器:动态导入executor.py,检查执行器类名、方法签名是否符合标准接口。
  4. 探针执行:调用一次内部自带的小型健康检查,比如传入examples里的样例参数,看返回结构是否符合returns定义。
  5. 注册上线:通过健康检查的技能自动进入注册中心,状态设置为active。
  6. 广播变更:通知模型适配层刷新工具集。

自研这套流程花了一周多,但它把那句“新增技能不用改核心代码”变成了现实。新技能从写好代码到被模型调用,只要完成一次“放目录 + 校验通过”,剩余链路全自动。

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 Calling

HagiCode 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 调用循环与失败纠错

整个模型调用过程是一个闭环循环:

  1. 用户输入问题,系统提取上下文并完成技能预筛。
  2. 组装好tools后发送给模型。
  3. 模型返回结构化指令,说明要调用哪个技能、参数是什么。
  4. 执行引擎校验参数,如果不合法,把校验错误返回给模型。
  5. 模型根据错误重新生成参数,或者向用户追问缺失信息。
  6. 技能执行完成后,把真实业务结果返给模型,由模型生成最终回复。

这里有个细节值得展开:不要把底层报错直接反馈给模型,要把它转成模型能理解的错误语义。我曾经直接把 Python 异常堆栈返回给模型,结果模型开始一本正经地分析代码 bug,甚至试图猜测堆栈里的变量值。后来我定义了一套标准错误码,比如MISSING_REQUIRED_FIELD、AUTH_FAILED、UPSTREAM_TIMEOUT,并附带简要说明,模型就能迅速做出正确反应:要么修正参数、要么调用另一个技能、要么向用户解释。

4.4 实测中看到的数据变化

接入这套适配层后,我记录过一组对比数据。在技能总数 45 个的条件下,不做筛选时单次请求 token 消耗平均多出 3800,P95 延迟增加约 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 大函数我们一直没删,每次打开它,都在提醒自己分层和协议化这件事,值得在最早期就认真做。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询