
1. 从工具调用失控到技能层诞生这个项目到底解决了什么问题如果你在做一个稍微复杂一点的Agent应用大概率会撞上同一堵墙模型能力没问题工具也写好了接口但把它们拼在一起之后整体就是不稳。我最初的做法和大部分团队一样把所有工具的调用说明塞进System Prompt告诉模型“你可以使用以下工具”再给每个工具写一段JSON Schema描述。Demo阶段一切顺利模型偶尔还能自己组合出几步操作。但等工具数量过了十个问题开始集中爆发Prompt越来越臃肿模型在决策时经常拿错参数、调错工具甚至把两个工具的描述揉成一次调用。更麻烦的是新增一个工具要重新跑一遍全量Prompt评测谁改谁崩溃。agent-skills这个项目本质上就是把“工具层”升级成“技能层”的一次工程化重构。工具是纯接口层面的东西它只关心输入输出技能则包含了Agent在调用它时需要的一切上下文——什么时候该用、参数怎么填、哪些前置条件必须满足、调用失败后怎么兜底。它解决的不只是“能调用”而是“调用得对、调用得稳”。这个项目适合三类人第一类是正在做Agent产品、但工具数量一旦增多就开始失控的开发者第二类是想让现有Agent接入业务系统、又不想把业务逻辑写死在Prompt里的工程负责人第三类是单纯想理解“模型外部工具”这套机制里工程层到底该承担什么职责的研究者。如果你只是想让模型调一两个API那直接用Function Calling就行没必要上这一层。但如果你预感到后面会有几十个、上百个能力点技能层迟早要建。2. 技能定义规范让模型和代码都认同一份契约技能层的第一件事不是写执行逻辑而是定义“一份技能长什么样”。我把这件事类比成给一个新同事写工作手册只告诉他“你会用到Excel”没用你得告诉他Excel里有哪几个表、哪些列不能动、哪些操作前需要先备份。2.1 技能清单与核心字段先看一段我在agent-skills里最常用的技能定义文件用YAML写的name: weather_query description: 查询指定城市当前天气用于行程规划、穿衣建议、活动安排等需要天气信息的场景 version: 2.1.0 author: ops_team tags: [weather, location, public_api] timeout: 10s max_retries: 2 requires: - location_resolver params: city: type: string required: true description: 城市中文名或拼音如北京或beijing unit: type: string required: false enum: [celsius, fahrenheit] default: celsius description: 温度单位 on_failure: - strategy: fallback_display args: message: 天气服务暂不可用请稍后再试这里几个字段值得说清楚。name和description是给模型看的关键description决定了模型在决策时会不会选中这个技能我后面会单独讲。requires声明依赖的其他技能这样组合技能就可以像搭积木一样建起来——比如查天气看起来是个独立技能但它的参数需要先把“用户输入里模糊的地点”解析成标准城市名所以它依赖location_resolver。on_failure是兜底策略它告诉执行层“如果这个技能挂了不要硬报错降级显示一个提示也行。”2.2 参数描述决定模型填参的准确率如果要在技能定义里选一个“性价比最高”的部分我会投参数描述。很多人写参数只写类型和是否必填但这远远不够。模型对参数的理解完全来自描述文字它不像人一样能去看接口实现。我的建议是每个参数描述里必须包含三件事取值范围、值的形式、常见的错误示例。比如params: start_date: type: string required: true description: 开始日期格式为YYYY-MM-DD必须是今天或未来日期不要传过去的时间也不要传带时间的完整时间戳加了最后那句“不要传带时间的完整时间戳”模型填错的概率能降一半以上。这不是我拍脑袋说的是拿一组真实流量对比出来的只写格式要求的版本日期格式错误率在15%左右补上“不要传什么”的描述之后错误率降到了4%以下。模型是字面理解的高手你把约束写清楚它收敛得比人还快。2.3 版本与依赖复杂技能拆分的根基技能定义里最容易被人忽略的是version和requires。没有版本号你就没法做灰度、没法回溯、没法跟调用方解释“为什么昨天还能用今天就不行了”。我见过太多团队直接在原技能上改参数改完才发现有几个Agent的场景已经被影响了。requires的价值在于让技能可以分层。我现在的技能库里有一批“原子技能”比如get_user_timezone、parse_relative_date、query_poi_list一批“业务技能”比如schedule_meeting、order_delivery。业务技能不直接写实现它声明自己依赖哪些原子技能执行层在启动时会自动做依赖解析。这样当底层接口发生变化时只需要改原子技能上层业务技能不用动。3. 技能注册与发现让执行层知道“有哪些技能可用、谁来负责”技能定义文件只是静态的元数据真正让Agent跑起来的是一个动态的技能注册机制。我第一次设计时犯过一个大错误把所有技能的实现直接import进Agent主进程。前几个版本没问题等技能数到四五十个启动时间变长、内存占用变高、还动不动因为某个技能的特殊依赖把整个进程搞挂。3.1 注册表加装饰器轻量又直接的方案agent-skills的注册机制其实不复杂核心两样东西一个注册表一个装饰器。# registry.py from typing import Dict, Type SKILL_REGISTRY: Dict[str, Type] {} def register_skill(skill_class): if not hasattr(skill_class, meta): raise ValueError(f{skill_class.__name__} 缺少 meta 定义) name skill_class.meta.name if name in SKILL_REGISTRY: raise KeyError(f技能 {name} 重复注册) SKILL_REGISTRY[name] skill_class return skill_class def get_skill(name: str): if name not in SKILL_REGISTRY: raise KeyError(f技能 {name} 未注册) return SKILL_REGISTRY[name]技能实现则采用标准类结构# skills/weather_query.py from core.skill import BaseSkill, SkillContext register_skill class WeatherQuerySkill(BaseSkill): meta load_skill_meta(skills/weather_query.yaml) def __init__(self, context: SkillContext): super().__init__(context) self.http_client context.get_client(http, timeout10) async def run(self, params: dict): city await self.context.invoke_skill(location_resolver, {text: params[city]}) unit params.get(unit, celsius) result await self.http_client.get( fhttps://api.example.com/weather/{city}, params{unit: unit} ) return self.normalize_result(result)注册表做三件事查重、校验meta完整性、存类引用。装饰器只是个语法糖真正核心的是把“技能的元数据”和“技能的实现代码”绑定在一起。BaseSkill里预置了invoke_skill方法专门用于技能间的互相调用——这就是前面说requires依赖能被落地的关键。3.2 延迟加载别在启动时把所有技能都实例化有了注册表之后下一步是延迟加载。每个技能定义里加上load: lazy字段执行层不立刻实例化只有等到Agent真正要调用它时才创建实例。这样会带来三个直接好处启动时间大幅缩短不常用的技能不会占用内存某个技能实现有bug时不会影响其他技能的稳定性。光有延迟加载还不够还需要做依赖健康检查。我的做法是服务启动后会在后台对所有技能做一轮“干跑”dry-run也就是只验证依赖和参数配置不真正发起外部调用。干跑不通过就告警但不阻止进程启动。这样可以第一时间发现“某个新加的技能依赖了一个不存在的技能”这类低级问题又不会因为这个问题导致整个服务起不来。3.3 为什么不用中心化配置中心做技能注册上一版系统里技能注册信息放在一个中心化的配置中心由运维同学统一维护。后来我发现这个模式很别扭技能是自己代码库的一部分注册信息却放在另一套系统里两边经常不同步。技能更新了配置中心没改配置中心改了代码又没跟上。agent-skills改成“代码即注册源”的思路后事情简单了很多。技能注册信息直接跟着代码走提交代码就等于提交了技能定义。部署的时候服务启动时自动扫描所有带register_skill装饰器的模块完成注册。至于中心化配置我只用来做开关控制——比如在不需要某项技能时通过配置动态注册掉而不是靠改代码来摘除。4. 技能执行链路从意图识别到结果归一化的完整过程技能注册好之后接下来要解决的是执行链路的稳定性和可观测性。这是整个项目里让我踩坑最多、也最值得写的一段。4.1 委派与执行的三个关键阶段一个技能从“被模型选中”到“返回结果给模型”中间经过三个阶段委派阶段、准备阶段、执行阶段。我把它们分开实现目的是让每一步都有独立的观测点和容错点。委派阶段由调度器负责。调度器的输入是模型产出的结构化调用请求包含技能名和参数。这一阶段必须做严格的参数校验校验规则直接来自技能定义里的params。我踩过一个大坑没有在委派阶段做枚举校验结果某个技能收到了一个不在枚举范围内的参数值它默默把值透传给了第三方接口第三方接口也不校验最后落进数据库的是一条脏数据。等发现时已经跑了一周。准备阶段负责处理技能依赖。先检查requires里的依赖项是否全部满足再把依赖技能的调用方式注入到当前技能的执行上下文里。这里有个容易忽略的点依赖链路的循环检测。比如技能A依赖BB又依赖A这种循环能让调度器陷入死循环。我在调度器里加了一个resolve_timeout参数默认3秒内必须完成全部依赖解析超时直接判定失败。执行阶段才是真正干活的地方。这里需要标准化输入输出格式无论底层是HTTP调用、数据库查询还是子Agent调用最终返回给模型的必须是结构一致的结果。如果底层是外部API做一层异常翻译——超时统一返回timeout拒绝服务统一返回rate_limited业务参数错误统一返回bad_request。很老套但真的有用模型拿到标准化的错误码之后能做出正确的重试决策而不是对着一个原始堆栈胡思乱想。4.2 超时、重试与熔断不要每个技能都一套配置很多项目的技能框架只支持全局统一的超时和重试策略这在实际业务里是行不通的。查天气的HTTP接口5秒超时合理但一个耗时10秒的数据分析型技能如果也设5秒等于永远失败。agent-skills的做法是把超时、重试、熔断参数下沉到每个技能的定义里。重试这里有一个很重要的细节不是所有错误都适合重试。对于timeout和5xx类错误重试有意义对于bad_request和4xx类错误重试只会反复吃到同样的失败结果。我见过很多人用一个统一的重试装饰器把类型校验错误也重试三次浪费资源不说还把错误日志刷得很难看。熔断逻辑我放在执行层而不是技能内部。当同一个技能在短时间内失败率超过阈值执行层直接把该技能标记为不可用后续请求快速失败不再真正发起调用。恢复策略用的是半开状态过一段时间放一个探针请求进来成功了就恢复失败了继续熔断。4.3 结果归一化回归稳定性之本结果归一化是最不起眼但最重要的设计。每个技能的执行结果都统一成下面这个结构{ status: success, data: {}, human_readable: 北京市今天晴气温18~26摄氏度, meta: { skill: weather_query, latency_ms: 312, attempts: 1, fallback_used: false } }字段含义先说清楚。status是整体的成功失败标记data是结构化的业务数据给程序用human_readable是给用户看的自然语言描述给模型参考用meta是本次调用的上下文信息给调试和追踪用。human_readable这个字段在早期版本里是没有的后来发现一个现象当返回的数据量太大会干扰模型后续决策当返回数据太简略模型又不知道该怎么给用户解释。加上一段人工写好的、可以直接读给用户听的话术之后模型的输出质量和稳定性都明显好了。现在我的技能库里凡是面向用户的技能都会精心设计human_readable面向内部计算的技能则不需要直接返回结构化数据就行。5. 实测中的高频问题那些只有跑在真实流量里才看得见的坑技能框架搭起来、链路也通了之后真正的折磨才开始。以下这几个问题不是从文档里看来的都是我在真实环境中被用户的流量教育过之后才总结出来的。5.1 模型选错技能工具幻觉的根因分析“工具幻觉”是指模型调了一个不该调的工具。表面上看起来是模型推理问题但通过系统排查我发现很多案例的根因在技能定义本身。问题出在描述词的语义边界上。做技能搜索功能时我把技能描述写成了“搜索会话中涉及的文档、表格与附件内容”。结果模型在处理“帮我查一下上周的会议记录”时也选了搜索技能而不是技能库里已有的“会议记录查询”技能。原因一目了然“文档”和“会议记录”在语义上是高度重叠的。解决方案是做技能描述去重与冲突检测。我写了一个脚本遍历所有技能描述两两计算文本相似度。相似度超标的提出警告人工决定是否要加限定语。比如文档搜索技能改成“搜索用户上传的各类文件内容支持pdf、docx、xlsx不包括系统内部生成的会议记录、日程、任务”会议记录查询技能则写成“查询系统内已归档会议的内容摘要与结论仅限内部会议数据不涉及用户上传文件”。改了描述之后这两个技能的误选率几乎降到了零。5.2 技能名称里的“隐藏误导”另一个容易出问题的点是技能名称本身。name字段不仅给程序用模型在决策时也会读它的字面意思。有次我把一个“获取指定日期所有未完成任务”的技能命名为get_all_open_tasks结果模型经常在“查询明天待办”时调用它而不是调用更精确的get_tasks_by_date。后来我给所有技能名称做了一个自查清单名称是否是动宾结构名称是否精确表达了它的输入条件名称里是否带了容易跟别的技能冲突的宽泛词比如get、query、search这些动词容易产生歧义。按照这个清单逐条改完技能误选率明显下降了。5.3 并发调用与参数污染技能系统上线一段时间后我收到一个诡异的报错用户A查的天气结果返回了用户B的城市。排查过程令人头大单发正常并发必现数据库日志里找不到任何异常写入记录。最终的嫌疑落到了一个共享实例上。某个技能在初始化时用类属性缓存了城市名并发请求进来的时候后一个请求覆盖了前一个请求的缓存导致返回互串。修复不难把状态改成实例级或请求级变量但排查过程中暴露出的架构问题值得说技能实例默认情况下应该被设计成无状态的。如果有缓存需求、上下文需求必须显式声明并让执行层感知到。agent-skills里我加了一条强制规则BaseSkill的子类里凡是类属性都必须标为Immutable类型可变状态一律走SkillContext由执行层统一管理生命周期。5.4 外部依赖抖动导致的全链路雪崩某个上线周里我们依赖的一个第三方短信服务间歇性变慢正常情况下300毫秒返回出事时直接10秒超时。没做熔断前所有调用方都在同一时刻向它发起请求下游雪崩连带我们的Agent整体响应超时。熔断参数我设置成了2秒内如果有5次请求且失败率超过50%触发熔断熔断时间30秒。这个参数组合是压测试出来的不是凭空拍的。熔断生效后慢依赖对整体系统的影响被限制在了很短的时间内Agent可以先返回“短信服务暂时不稳定请稍后再试”的降级话术而不是傻等10秒然后报错。降级话术其实也是技能定义里的一部分这套体系做完善之后可维护性就上来了。6. 从能用到好用技能体系的可观测性与持续沉淀技能数量一旦超过一定规模“能跑”和“好用”之间的距离会被无限拉大。这一节说说我在可观测性和技能成长方面做的事。6.1 技能调用追踪给每一步留下证据每个技能的每次调用都会被记录成一条调用追踪记录字段包括技能名、调用方、耗时、参数摘要、命中缓存与否、错误码、降级是否启用。日常排查问题时就靠这个表用户说“我的天气没查出来”我先查这一条一眼就能定位是参数解析失败、下游超时、还是熔断降级。这个表不追求实时5秒一写足够。这里有个小技巧问题排查的时候不要只看失败记录成功记录同样重要。很多时候“为什么失败”的答案是藏在“为什么之前一直成功、突然就不成功了”的对比里。所以我的追踪表设计里成功和失败记录结构完全一致唯一的区别是状态字段这样对比起来非常顺手。6.2 技能回归集比什么都重要的护城河技能多的项目最怕一个情况改了一个底层技能导致上层十几个业务技能全挂。回归套件就干这件事。每个技能定义里可以挂一组test_cases包含输入、期望输出、期望行为。执行环境里的一个定时任务会跑一轮全量的技能回归任何技能的输出跟基准不符就报警。回归集的数据会从线上采集。我会给每个技能配上采样逻辑把真实用户调用中“表现好的结果”和“明显错误的结果”都沉淀成回归用例。比如模型选错技能这个错误是线上真实触发的修完之后当时那条调用数据顺手变成回归样例入库防止下次模型又想“帮助用户”去调用错误的技能。这个机制滚起来之后系统稳定性会随时间不断提升。6.3 一套技能多面复用技能共享与权限技能库建得深了之后发现很多技能可以被不同场景复用。比如parse_relative_date这个原子技能在行程规划里用在任务提醒里也用在日程里还用。与其每个场景各写一份不如把技能库抽成一个公共的依赖服务除了当前Agent系统自身调用也可以被其他内部系统以RPC方式调用。多面复用意味着要处理权限问题。我在技能元数据里增加了一组access_policy字段支持internal仅本进程、namespace同命名空间可用、public全局可用三级权限。默认是namespace没人有足够权限就别把技能暴露到更大的范围里。这个权限模型虽然简单但够用而且排查问题时边界非常清晰。6.4 技能淘汰机制不是越多越好技能越多模型在决策时要读的描述就越多决策质量会不升反降。我用了一套简单的淘汰策略统计每个技能最近30天的调用成功率和调用次数。如果一个技能定义存在但30天内零调用它不会自动下线但会在首次调用前触发一个额外告警提醒人确认这个技能是否还有存在必要。如果一个技能的失败率持续超过一定阈值但流量还很大说明它肯定有某个问题需要优先去review。技能库不是被动囤积的它需要有人定期清理和合并。我现在每个季度会做一次技能梳理把相似的技能合并把冗余的技能标记下架把变化的参数重新对齐。这个工作没有终点跟整理自己的知识库一样你不持续修剪它就慢慢长成一片无法穿行的灌木丛。到这一步agent-skills基本完成了从“工具调用管理”到“技能生命周期管理”的进化。回到它最核心的定位它并不是一个让人产生“神奇效果”的炫技框架而是一个把Agent调用外部能力这件事变得可定义、可发现、可观测、可维护的工程底座。你能感受到它发挥作用的那一刻通常不是在Demo演示时而是在线上出问题、你打开追踪面板、三分钟内定位到症结的时候。那才是这套设计带给我的最有成就感的一刻。