agent-skills:智能体技能治理的注册、路由与观测实战 大概三个月前我所在的某项目组正被一堆混乱的智能体技能调用逼得发疯。也正是那次经历让我决定动手做一套名为 agent-skills 的技能治理系统。当时项目的现状是一部分工具逻辑硬编码在对话流程里一部分挂在外部的消息总线上还有一部分直接写进提示词让模型自己去猜函数名。每次新增一个技能都要动好几处代码发版前谁都不敢说自己这次的改动没有影响别的地方。做完这套系统之后新增一个技能的时间从大半天缩短到了十分钟左右调用链路、参数格式、失败回退都有据可查。这篇文就记录一下我做这个框架的完整思路、核心设计和踩过的坑希望能给正在被同类问题困扰的人一点参考。如果你是做智能体产品、Agent 应用开发的同学或者你手里的技能、工具、插件数量已经超过十个并且开始互相干扰这篇文章比较合适。哪怕你只是对“动态加载、路由分发、可观测性”这类机制感兴趣也能从里面找到可以复用的设计思路。我不打算讲那种特别玄的架构就按真实项目里一步步打磨出来的东西说。1. 技能失控的现场问题从哪来1.1 三种常见的技能实现方式以及它们各自的死法在那次重构之前项目里的技能实现方式大概能分成三类每一类都有自己让人头疼的地方。第一种是硬编码在对话流程里。代码长这样分支判断里面塞了一堆工具函数调用。一开始只有两三个技能的时候完全没问题逻辑简单、调用直接、出了 bug 也好定位。但技能一旦超过十个主流程文件就开始变成一坨巨型代码每次新增技能都要在关键路径上动刀测试回归范围越来越大谁改谁知道痛。第二种是挂在外部的消息总线上通过事件类型来路由。这种比硬编码好一点至少业务逻辑不会被全部塞在一个文件里。但它的坑在于事件类型变成了隐式契约技能一多事件名就容易冲突而且没人能说清楚哪个技能订阅了哪个事件。经常发生的情况是一个技能改了自己的事件名另一个技能静默失效直到业务方反馈才被发现。第三种是直接写在提示词里让模型自己决定调用哪个函数。这种最灵活但完全依赖模型当时的判断。描述写得太泛模型会选错工具描述写得太细又容易过拟合到特定的说法上。更麻烦的是提示词里的技能没法做参数校验下游接口收到什么全靠模型发挥出了线上问题根本没法复盘。1.2 一次真实场景的复盘新增一个天气查询技能要动多少地方当时组里接了一个需求要给平台加一个天气查询技能。我认真数了一下要改动的位置对话主流程里要加一个意图分支消息总线上要注册一个新的事件类型提示词里要补一段函数说明前端如果涉及展示还得改一下消息渲染测试用例要同步更新最后还要手动验证几个不同的问法。数下来一共六处。每一次改动都意味着至少两轮联调和一次全回归。那个需求最终花了差不多两天真正写逻辑只用了两个小时其余时间全耗在“把新技能接入旧体系”上了。这件事让我下定决心要么换掉这套组合要么就做一个统一的层把它们管起来。1.3 这套系统到底解决什么问题agent-skills 要处理的从来不是“怎么写一个技能函数”而是“技能多了之后怎么让它们不被混乱吞噬”。具体落到三个点上新增技能的开销足够低低到只写一个函数和一份描述就能跑起来技能调用的全过程可见任何一次失败都能从日志里还原现场评估和回退有依据不会因为模型选错了工具就彻底不可用。如果你现在的技能数量还在个位数坦白讲不需要上这种框架。但如果你已经能预见到半年后会有几十个技能共存越早定约束后面的成本就越低。2. agent-skills 的整体架构与核心设计取向2.1 设计目标注册、加载、路由、观测四件事我把这套系统的核心职责拆成了四块技能注册、技能加载、路由分发、调用观测。注册层负责收集所有技能的定义和描述信息加载层负责把技能函数从文件系统或远程仓库里动态拉起来路由层根据用户请求的语义特征决定把调用分发给哪个技能观测层负责记录每一次调用的输入、输出、耗时和失败原因。这四块是顺序依赖的注册是入口加载是基础路由是核心观测是保障。缺了任何一环系统都会退化回原来那种“技能散落各处”的状态。2.2 模块划分与一次调用的完整路径我用一张表简单列一下核心模块的职责模块职责关键问题Registry维护技能注册表索引所有可用技能技能如何标识、如何去重Loader从目录或仓库加载技能实现动态加载如何隔离依赖Descriptor Generator生成技能的语义描述描述如何让模型准确理解Router根据请求选择合适的技能路由错误时如何回退Observer记录调用链路、耗时、成本数据如何低成本收集一次完整的调用路径是这样的用户请求进来Router 结合请求内容和注册表里的技能描述选出最可能匹配的技能Loader 确认技能已加载如果有版本更新则在后台预热然后执行技能函数Observer 在入口和出口各打一个埋点把耗时和结果写进追踪链路。整个过程对外部调用方来说看起来就是一个函数调用。2.3 为什么不用现成框架偏要自己写当时也不是没考虑过直接用现成的 Agent 框架。但看了一圈之后发现大多数框架绑定的是某一家的模型接口或者特定的对话协议而我们这边既要兼容不同模型又要保证技能可以脱离对话引擎单独测试。与其被框架的约定绑住不如做一个轻量自研的中间层。轻量自研的代价是很多东西要自己踩但好处也很明显行为完全可控依赖极少部署简单。如果你也在做类似的东西我的建议是不要上来就搭大框架先写一个最小可用的版本让团队实际用两周再决定要不要加组件。3. 技能仓库的目录规范与注册清单3.1 目录结构一切按照约定来技能仓库是我做的第一块硬约束。每个技能必须放在独立目录下目录名就是技能名里面包含一份描述清单和一到多个实现文件。一个典型的目录结构长这样skills/ ├── weather_query/ │ ├── skill.yaml │ └── handler.py ├── calendar_query/ │ ├── skill.yaml │ ├── handler.py │ └── helper.py └── approvals/ ├── skill.yaml └── handler.py这个约定的核心价值在于任何人拿到仓库不需要额外文档就能知道技能边界在哪里。目录名和技能名一一对应新增技能就是新增一个目录删除技能就是删掉一个目录管理成本被压到了最低。3.2 skill.yaml 清单字段设计每个技能目录里必须有一份 skill.yaml这也是注册表的数据来源。我设计的最小字段集合是这样的name: weather_query description: 根据地点查询实时天气支持输入城市名称或行政区名称 version: 1.2.0 api: endpoint: weather.query method: post parameters: - name: location type: string required: true description: 城市名称例如北京、上海 - name: date type: string required: false description: 日期格式 YYYY-MM-DD默认当天 timeout: 3000 owner: team-core这里我特别想强调 description 字段。它不仅是给人看的也是给模型看的。描述信息要覆盖两个层面这个技能是做什么的以及用户通常会怎么问。比如“根据地点查询实时天气”是说功能“支持输入城市名称或行政区名称”是在告诉路由层用户可能的表达方式。3.3 技能函数骨架与输入输出约束一个技能的实现函数会被 Loader 包装成统一接口。我建议所有技能暴露一个 handle 方法接收字典、返回字典这样路由和执行层就不用关心每个技能的内部差异。一个标准的 handler.py 骨架如下from schema import SkillInput, SkillOutput def handle(params: SkillInput) - SkillOutput: location params.location data fetch_weather(location) return SkillOutput(statusok, datadata)统一输入输出约束的价值在后期会越来越明显。因为所有技能都按同一个模式对接观测层可以统一记录请求和响应路由层可以统一做参数预检新增技能的人也不用再去理解一堆不同的调用约定。3.4 描述模板让模型看懂技能的关键我在做 Router 的时候踩过一个特别典型的坑描述写得太短模型经常把两个相似技能搞混。后来我把描述模板改成三段式的结构功能定义、适用场景、示例问题。description: | 功能查询某个城市在未来几天的天气预报。 适用场景用户提到天气、气温、降雨、风速等关键词时优先选择。 示例问题北京明天会下雨吗上海这周末气温多少。改成这种写法之后路由准确率提升得非常明显。原因也不复杂模型做选择时依赖的是描述里的语义线索你把用户可能问法直接写进去等于给了模型一个更清晰的决策边界。这里要提醒一句描述不是越短越好也不是越长越好而是要精确覆盖真实用户会表达的方式。4. 动态加载与路由分发技能热更新的实现细节4.1 文件监听与依赖隔离动态加载的地基技能目录确定之后下一步是让系统能识别目录内容的变化。我在 Loader 里通过对目录文件变化做轮询发现新增、修改、删除事件后会自动触发对应的加载、更新、卸载动作。这里的关键点是依赖隔离每个技能被加载进一个独立的模块命名空间里技能之间不能直接互相 import 模块内部逻辑只能通过注册表暴露出来的接口访问。一开始我也考虑过直接用共享全局包但后来发现两个技能导入同一个第三方库的不同版本时会发生冲突。处理办法是给每个技能目录做一个依赖解析把第三方库打进各自独立的包里谁用谁自己声明。代价是会稍微多占点存储但换来的是技能之间彻底互不干扰。4.2 内存路由表组织与原子切换Router 内部维护的并不是一个简单的数组而是一张以技能名为主键的路由表路由表里存的是技能当前的版本号和入口函数指针。当 Loader 检测到技能有新版本时会先把新代码加载到一块新内存区域等确认启动成功之后再通过一次原子指针替换完成切换。这段逻辑我简化一下大概长这个样子def reload_skill(skill_name: str, new_module): new_entry RouteEntry(versionnew_module.version(), handlernew_module.handle) old_entry Router.routes.get(skill_name) Router.routes[skill_name] new_entry return old_entry这里最重要的就是顺序先准备新版本再替换指针绝不边加载边替换。否则就可能出现一部分请求走了新版逻辑另一部分还在用旧版行为不一致会非常难排查。4.3 调用失败时的降级回退策略路由不是永远正确的模型可能选错技能技能本身也可能超时或者报错。我给 Router 加了三层降级策略第一层是技能执行失败后重试一次排除掉偶发的网络抖动第二层是如果当前技能连续失败就把请求转交给描述里语义最接近的备选技能同时做好标记第三层是如果所有候选都不行就返回一个统一的可解释错误告诉用户当前没有可用的技能。这三层策略一定要配观测每一层被触发都要留下明确标识。因为降级成功和直接成功的用户体验是一样的如果日志里不区分后续你根本不知道之前的成功里有多少其实是靠兜底扛过去的。等系统跑稳定之后再决定哪些技能可以去掉降级改成直接报错反而更利于暴露问题。4.4 并发安全热更新不能影响在飞请求技能热更新最怕的是冲突如果更新发生在某次请求执行到一半的时候继续跑一半的请求没问题但新进来的请求应该立刻用新版本。这个要求靠上面说的原子指针替换可以做到。不过在并发场景下还要注意另一种问题如果多个更新事件同时到来Loader 内部要加锁避免同时加载同一技能的两个版本。我当时用一个带状态管理的加载流程解决的技能加载中、启动完成、启动失败、卸载完成四个状态按顺序流转。任何更新事件只有在“启动完成”状态下才会触发替换其他状态直接拒绝。处理并发更新的时候这个状态机救了我很多次。5. 评测集、调用追踪与成本统计技能系统的观测体系5.1 技能评测集不能只靠感觉判断好坏路由层做得再好也需要一个客观指标来证明。我做了两件事第一是构建一个评测集从历史真实请求里抽出一百条典型问题人工标注出每条请求应该命中的技能第二是每次更新 skill.yaml 里的描述或者路由参数之后自动跑一遍评测集记录准确率和耗时。评测集的意义在于它能让优化项变得可量化。比如你觉得自己把描述改得更清晰了但如果评测集上的准确率没提升这个改动就不成立。刚开始做的时候人工标注会很累但一百条样本足够暴露大部分问题而且之后可以持续补充。5.2 一次调用的完整追踪链路可观测是这套系统里我最看重的部分。我给每一次技能调用生成了一个唯一追踪ID不管是模型选技能的过程、技能函数内部的执行、还是降级回退动作都会把这个ID带着走。日志打点不做全覆盖的采样而是全量记录因为技能调用的量级不像日志系统那么恐怖全量记录的存储成本完全可控。排查问题的时候我只要拿一个用户请求的 trace_id就能看到完整链路请求文本是什么、模型选择了哪个技能、置信度多少、技能执行耗时多久、返回结果是什么、有没有触发降级。以前那种“用户说没生效后端说不报错”的对峙基本可以消失了。5.3 成本与耗时统计技能质量的一个侧面技能质量不能只看准确率成本和耗时同样重要。特别是当技能内部还会再调用模型接口时一次用户请求可能牵扯到好几次 LLM 调用。Observer 会分别记录模型调用的 token 花费、外部接口调用的耗时、整体处理耗时然后按技能维度聚合。用表格展示一个技能的健康度视角大概是这样的技能名调用次数平均耗时(ms)平均token消耗成功率weather_query125048032098.6%calendar_query83026015099.2%approvals200120086095.1%看到这种数据之后你应该第一时间去查那些耗时特别长、成功率略微偏低但又没报错时到底是什么情况。有些时候是技能内部串行调用太多有些时候是上游服务抖动被吞掉了。观测的意义就是把这些信号提前暴露出来而不是等业务方投诉。5.4 评测结果如何反哺技能描述优化观测体系跑通之后我每周会做一次简单复盘把评测集里选错的样本挑出来看看是描述不清晰、路由逻辑有问题还是评测标注本身不准确。大多数情况下问题出在描述技能之间的功能边界有重叠导致模型犹豫。遇到这种情况我的习惯是查最近几周的变化记录看看是不是有技能改过描述把别的技能的关键词“抢”过去了。这类问题你靠脑子想很难发现但结合评测集和变化记录就能很快定位。6. 实战踩坑清单最容易翻车的五个环节6.1 热更新与多进程模型的冲突第一次把动态加载机制放到测试环境里跑的时候发现一个问题明明主进程已经监听到了目录变化并触发了加载事件但子进程里面完全没反应。原因是技能执行跑在独立的子进程 worker 里主进程更新了路由表子进程的内存里还是旧数据。解决办法是不要把监听逻辑放在某个独立模块里而是做成一个统一协调事件让每个 worker 进程自己去监听更新请求。Router 表本身也从进程内维护改成了每进程独立维护、统一来源同步。这个坑不算难但如果不了解自己的部署模型很容易忽略掉。6.2 描述模板写太短模型疯狂选错技能在第五节提到过描述模板改成三段式的心得这里展开说。最初我把 weather_query 的 description 写成“查询天气”结果用户问“今天适合出门吗”的时候模型压根不会选这个技能因为描述里没有“出门”这个线索。后来加了适用场景和示例问题准确率从不到七成直接跳到九成以上。这里有个我一直遵守的原则描述的希望是用真实用户问题来写而不是用功能定义来写。功能定义是你心里的业务逻辑而真实用户问题才是模型需要识别的语义模式。6.3 参数校验过于宽松下游接口吃到了脏数据技能系统的参数校验一度只做了 type 检查比如 location 字段是字符串就够了。但后来线上出现过一个很诡异的问题有用户传了一个不存在的行政区名称技能直接拿着这个值去外部查询接口结果查询接口返回了一个错误码技能没有识别直接把错误包装成成功返回了。修复分两步第一步是在技能入口处做枚举值校验查不到就返回明确报错第二步是在技能执行完以后检查返回数据的合理性明显异常的结果不允许被透传给用户。现在我对所有技能的要求都是入口校验和出口校验两者都必须要做。6.4 技能目录权限变更导致自动化测试全红有一次提交代码之后CI 上所有技能相关测试全部失败。排查了很久才发现是我在某个版本里给技能目录加了一个新的子目录但没有给测试环境里的 daemon 用户加上权限结果 Loader 刚开始扫目录就碰到了权限拒绝。这类问题通常不会在本地开发环境暴露因为本地跑的账户对目录基本都有完全权限。现在的做法是在 CI 里专门加一个前置检查项用一个普通权限账号启动加载器确认能完整扫描技能仓库。虽然有点麻烦但能直接拦截掉权限导致的线上事故。6.5 缓存与追踪链路的数据竞争Observer 要记录调用数据最开始我是直接在全局字典里做自增统计。结果在高并发下统计值经常出现偏差明显是多个 worker 同时写同一个 key 造成的。后来把所有统计动作改成先写到本地内存队列再由专门的采集任务周期性汇总问题就消失了。这里我学到的经验是观测系统本身也要有并发意识不能因为只是记录就不考虑线程安全。特别是我这种全量采样加细粒度统计的模式很容易在不知不觉间变成数据竞争的温床。7. 从这里还能长出来的能力扩展方向与我的最终体感7.1 从技能注册到技能市场agent-skills 做到现在的程度已经解决了目录规范和动态加载两座大山。下一步我想把技能注册做成一个更开放的能力团队内不同项目可以把自己的技能发布到共享仓库里其他人通过订阅就能获取技能的最新版本。技能之间也能支持显式依赖比如一个数据分析技能直接调用一个图表渲染技能把链路串起来。这个方向对规范的要求更高因为跨项目共享之后技能命名冲突、版本兼容、责任归属都会浮现。但没有规范问题可以通过更完整的元数据来解决这也是我当初坚持在 skill.yaml 里保留 owner 字段的原因。7.2 技能血缘图谱与故障定位目前单次调用的追踪已经比较完善了但跨技能调用还没有形成全局视图。如果后续技能之间开始互相调用我希望把每次调用的父子关系也记录下来形成一张技能血缘图谱。到时候排查问题就不用逐个技能翻日志了直接在图上点开节点就能看到上下游。技术实现不算难只要在追踪链路里增加一个 parent_span_id 字段再配合汇总管道就能画出来。难的是要让大家养成这个习惯日志里所有关键节点都必须带上当前追踪ID不能只记一部分。7.3 动手前先定约束这是我最实在的建议回看这套 agent-skills 系统真正的核心价值不在于那几千行代码而在于它把“技能如何管理”这件事从隐性变成显性了。原来每个人都在按自己的理解扩展技能现在所有人都遵循同一套约定代码写起来反而更轻松了。如果你也想做类似的东西我的建议是不要一上来就追求大而全先用最小的约束跑起来一个统一目录、一份清单文件、一个简单的动态加载器就足以覆盖大部分场景。等技能真的多到要细分了再把路由、评测、观测这些模块一点点加进去。我最初就是从这么小的范围起步的做成现在这样也是十几次小步迭代堆出来的。