我大概从去年开始,就把手上几个智能体项目里的“工具调用”全部重构成了一套统一技能体系。前前后后折腾了三个版本,最终留下内部代号为 agent-skills 的这套框架。这篇文章不打算从理论定义讲起,只想把我在技能注册、路由、编排、安全、评估这些环节踩过的坑,以及最后沉淀下来的做法,原原本本写出来。如果你正在做一个需要接很多真实能力、又不想被杂乱工具函数拖死的 AI Agent 项目,这篇应该能帮你省掉不少试错时间。
1. 从工具碎片化到技能化:我为什么做了 agent-skills
1.1 智能体不是“有一个模型就行”
最初做客服助手的时候,我以为只要选一个足够聪明的模型,再塞给它一批工具函数,就算大功告成。本地测试阶段只有五六个函数,模型调用得很准,我当时还挺得意。等工具数量慢慢涨到二十多个,问题就出来了:函数描述互相挤占上下文窗口,模型经常在“查订单”和“查物流”之间反复横跳,同一个功能换一种问法就调用失败。
那段时间我每天做的事就是往 prompt 里追加“请严格按照如下函数定义调用”,结果越补越乱。后来我慢慢意识到一个关键区别:函数是写给代码用的,技能是写给模型用的。表面上都是“模型能调用的东西”,背后其实是完全不同的抽象层级。函数关心输入输出类型、异常处理、返回值结构,技能关心的是“这个能力在什么场景下该出现、调用参数怎么表达、失败之后怎么办”。
所谓 agent-skills,就是把这层抽象单独拎出来,做成一套独立于具体 Agent 业务的技能编排系统。技能成为组织单位,每个技能封装一个可复用的能力,比如查订单、发短信、做计算、调知识库。模型不直接面对一堆零散函数,而是面对一组“有语义、有边界、有权限”的技能。
1.2 现有工具调用方案的三个痛点
在我决定自己搭之前,也尝试过直接复用常见工具调用方案,但始终绕不开三个痛点。
- 可发现性差:工具列表一长,模型根本不知道有哪些工具可用,即使知道,也不会主动去调用不熟悉的工具。就像给一个新人发了一份五百行的 API 文档,他大概率只会用自己见过的那几个接口。
- 可配置性弱:业务上经常需要临时调整某个能力的超时时间、权限范围、备用接口,传统函数调用方案里这些逻辑散落在代码各处的 if/else 和装饰器里,改动一次要重新走发布流程。
- 不可观测:模型到底选了哪个函数、参数解析成了什么、执行成功还是失败、花掉多少 token,传统方案很少把这些信息结构化沉淀下来。没有观测数据,就很难判断“模型选错工具”是描述问题还是底模问题。
| 对比维度 | 传统函数列表 | agent-skills |
|---|---|---|
| 组织单位 | 函数 | 技能 |
| 使用时信息 | 函数名+参数 | 场景描述+参数+权限+兜底策略 |
| 可观测性 | 基本无 | 全链路日志与指标 |
| 迭代模式 | 改代码发布 | 技能独立上线/灰度/回滚 |
| 模型友好度 | 数量一多就迷茫 | 路由机制先行筛掉无关技能 |
这三个痛点叠加之后,我决定做一个自己的技能中间层。接下来的内容会以 agent-skills 的最终实现为线索,分成注册、路由、编排、安全、性能、评估几个部分逐个讲。
2. agent-skills 的技能抽象与注册机制
2.1 一个技能到底应该包含哪些信息
技能的抽象设计是整个体系的底座,我反复改过很多遍。最初只放技能名和函数引用,后来发现完全不够。一个真正能被生产环境使用的技能,至少需要以下几个字段。
| 字段 | 作用 | 我的建议 |
|---|---|---|
skill_id | 全局唯一标识 | 用“领域.动作.对象”的方式命名,例如order.query |
name | 展示名,中文短词 | 让模型和人都能一眼看懂 |
description | 详细说明 | 包含触发场景、主要用途、参数含义、返回结果示例 |
parameters_schema | 参数 JSON Schema | 严格描述参数类型、枚举、格式、是否必填 |
executor | 执行器 | 本地函数、HTTP 调用、子 Agent 任选其一 |
timeout | 超时时间 | 防止慢依赖拖垮整个流程 |
permission | 最小权限声明 | 例如order:read、order:write |
fallback | 兜底策略 | 失败时返回什么,或者降级到哪个技能 |
这里尤其要强调description和parameters_schema这两个字段,后面模型能不能走对门,基本都靠它们撑着。description写得好不好,不是文笔问题,而是数据质量问题。一句话如果只说“查订单”,模型在“查询订单”“修改订单”“取消订单”之间就分不清;但如果写成“根据用户提供的订单号查询订单当前状态,适合在用户询问订单进度、物流状态、发货时间时使用”,模型就能比较准确地把它和“修改订单”区分开。
2.2 装饰器式注册与自动装配
注册机制我见过几种做法,有人用配置文件登记,有人扫描目录规则。最后我选的是装饰器式注册加启动时自动装配,因为它在代码层面足够直观,又能保留程序化的灵活性。举个例子,一个订单查询技能长这样。
@skill.register( name="查询订单", description="根据订单号查询订单当前状态,适合在用户询问订单进度、物流状态、发货时间时使用", parameters={ "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,通常为数字串", "pattern": "^\\d{10,20}$" } }, "required": ["order_id"] }, timeout=10, permission="order:read", fallback={"default_message": "暂时无法查到订单信息,请稍后再试"} ) def query_order(order_id: str) -> dict: return order_repo.get_by_id(order_id)装饰器内部做的事情很简单:把函数对象、元信息、参数定义打包成一个Skill对象,注册到全局技能仓库里。应用启动时,管理器会扫描所有被@skill.register标记的函数,做一个完整性和唯一性校验,然后构建路由索引和描述索引。
你可能会问,为什么不用 YAML 配置文件?我试过,确实配置更集中,但两个问题很难解决:一是参数校验逻辑很难在 YAML 里表达清楚,二是技能代码和技能配置容易出现在不同仓库,改一个技能要跨两个项目跑发布。装饰器把配置和实现放在同一个文件里,至少对于“功能型技能”来说维护成本最低。纯配置化的方式更适合“流程型技能”,这一点我会在编排部分再说。
2.3 技能描述的质量决定了模型能否走对门
技能描述是这个系统里最容易被低估的环节。一个技能上线后,你的路由向量检索、模型最终选型、没命中时的日志分析,全都依赖这段文本。描述写得不到位,后面所有的权重调参都是白费。
我自己总结了一套技能描述模板,不一定符合所有场景,但可以作为起步参照:
- 第一句:说明“是什么”,例如“根据订单号查询订单状态”。
- 第二句:说明“适合在什么情况下使用”,把用户可能出现的表述变体写进去。
- 第三句:说明“不适合在什么情况下使用”,帮模型排除歧义。
- 最后一句:给出一个调用结果的示例,让模型知道返回什么、能解决什么。
比如订单查询的完整描述可以是:
根据订单号查询订单状态。适合在用户询问“我的订单到哪了”“订单发货了吗”“订单处理中是什么情况”等场景使用。不适合用于查询物流轨迹、修改订单信息。返回字段包括 status、delivery_time、logistics_company 等。
这个描述其实就是路由索引的原始素材。注册时我会用 embedding 模型把它转成向量,同时拼接一份精简版用于模型决策。描述越结构化,后面的检索和重排就越省力气。
3. 技能路由:不让大模型在几百个技能里大海捞针
3.1 传统工具列表的问题
如果你只接十几个技能,把全部技能描述直接塞给模型,可能还行。但一旦技能超过五十个,上下文会被技能描述占掉一大半,模型的选择准确率也会明显下降,而且每次请求都要为“不相关的技能”多花 token。更麻烦的是,有些技能之间的描述本来就长得像,模型容易把 A 技能当成 B 技能调用。
我一度试图通过压缩描述文本、缩短字段说明来缓解,但效果有限。后来想明白,根本解法不是让模型从全集里挑,而是先做一个“预筛”,用非模型手段把候选集缩小到 5 到 10 个,再让模型在这几个里面做真正需要“理解能力”的最终选择。
3.2 基于向量召回的两级路由
agent-skills 最终采用了两级路由,核心思路是“先粗筛,再精选”。
第一级用稀疏向量和稠密向量结合的方式召回候选技能。所谓稠密向量,就是把技能描述和用户 query 分别 embedding,计算余弦相似度;所谓稀疏向量,是抽取关键词和实体做字面匹配。两者合并打分,能兼顾语义匹配和字面匹配。比如用户问“帮我看看我昨天买的东西发货没”,语义上应该能召回物流查询,字面上“发货”也能帮召回相关技能。
第二级是“模型精排”。召回阶段我并不是直接把完整技能描述给大模型,而是把候选技能压缩成一套轻量路由表,每行只包含:技能 ID、一句话描述、适用场景关键词。模型只需要在这份精简表里做选择题,上下文压力小得多,准确率也明显提升。
这里有个关键设计:每个技能要维护两套文本。一套是前面说的完整描述,用于向量检索和技能说明;另一套是路由用精简描述,通常控制在 40 字以内。不要试图用一套文本应付两种场景,完整描述太长,模型精排时容易注意力涣散;精简描述太短,向量召回时又容易丢失语义。
3.3 候选技能重排与阈值设计
向量召回不是终点,我在候选列表进入模型精排之前,还会加一层规则重排。重排规则包括但不限于:
- 权限过滤:用户/会话没有权限的技能直接踢出候选。
- 参数预检:query 里如果已经解析出明确的订单号,就给
order.*加权重。 - 历史偏好:同一个用户在历史会话里频繁使用某技能,可以对它做小幅加权。
- 热度惩罚:最近五分钟内该技能错误调用率明显升高,临时降权。
阈值也很重要。如果只是按相似度取 topK,某些完全不相关但文本上有点相似的技能会混进候选。我给相似度设了一个下限,低于 0.25 的技能一律不进精排。这个阈值不是拍脑袋定的,是通过一段时间的日志统计,对比“正确召回的技能”和“误召回技能”的相似度分布后确定的。每个领域的分布不一样,建议你上线后把相似度分数落库,跑一周再调。
4. 技能编排:从单技能调用到可组合的工作流
4.1 链式调用与数据流转
单技能能解决的事情,根本不需要编排。但真实场景里,用户往往一句话要同时触发多个动作。比如“帮我把这个订单取消,然后重新下一单”,这里就涉及到查订单、取消订单、创建订单三个技能的前后协作。
agent-skills 的编排层做的是“技能 DAG”,也就是把多个技能节点连接成一个有向无环图,节点是技能,边是数据依赖。执行时从入度为 0 的节点开始,一个节点跑完,输出映射成下游节点的输入,逐层推进。比如一个典型的售后流程可以描述为:用户提问 -> 查询用户身份 -> 查询订单列表 -> 定位目标订单 -> 判断订单状态 -> 执行对应售后操作。
在这个模型里,状态流转是个核心问题。我不会把每个技能的所有输出都原样放进上下文,而是维护一个结构化的状态对象,每个技能声明自己“读取哪些字段、写入哪些字段”。下游技能只从状态对象里取自己需要的部分。这样既能防止无关数据污染大模型的判断,也能减少 token 消耗。
4.2 分支、重试与兜底策略
实际跑起来你会发现,简单链路根本不够。用户的问题千奇百怪,订单查询可能返回空、物流接口可能超时、用户权限可能不足,每一个失败分支都得有明确行为。不要指望大模型在失败时“临场发挥”,那会让行为完全不可控。
我在技能 DAG 里定义了几类节点行为:
- 条件分支:根据上游技能输出或状态字段值,决定下一个执行哪个技能。
- 重试节点:针对外部 API 超时或瞬时错误,最多重试两次,间隔递增。
- 兜底节点:如果主技能失败,执行一个替代技能或返回固定文案。
- 终止节点:正常结束或失败终止时统一封装响应。
举个例子,查询物流失败后,兜底策略不是重新查一遍,而是降级为显示“订单状态更新于昨天”。这个兜底动作本身也是一个技能,拥有完整的注册信息和评估记录。因为兜底逻辑一旦写死,就无法灰度、无法观测,后续想优化会很被动。
4.3 编排配置化,别把逻辑写死在代码里
一开始我把 DAG 结构写死在 Python 流程里,后来运营同学想调整某个判断条件,需要我改代码、发版、回测,效率太低。第二次重构时我把编排流程改成了 JSON/YAML 配置,DAG 定义和技能实现解耦。
一个简单的配置示例大概长这样:
nodes: - id: verify_user skill: user.verify next: get_orders - id: get_orders skill: order.list next: find_target_order - id: find_target_order skill: order.slot next: check_status - id: check_status skill: order.check next: branch_by_status - id: branch_by_status type: condition conditions: - field: order.status if: "cancellable" next: order.cancel - field: order.status if: "pending_payment" next: order.pay default: terminal.fallback配置化之后,流程变更变成改文件、走 review、灰度发布,不再需要打扰服务端发版。不过要提醒一句,配置化不等于无代码,它只是把“流程可变的部分”和“技能实现的部分”分开。你对每个节点的输入输出字段还是要做 schema 约束,否则配置写错一个字段名,运行半天才发现所有节点都在取空值。
5. 安全边界、成本控制与可观测性
5.1 技能不是“能跑就行”:权限与沙箱
一个技能能跑通,不代表它可以上线。尤其是那些会修改数据、发送消息、调用第三方付费接口的技能,必须有明确的权限边界。agent-skills 里每个技能都声明了permission,比如order:read表示只读,order:write表示写操作。路由和编排引擎在执行前都会做权限检查,和当前会话的用户身份匹配,不匹配的直接拒绝,并记录审计日志。
沙箱方面,我把执行器分成了三类:本地函数、远程 API、子 Agent。本地函数跑在进程内,受超时和异常隔离保护;远程 API 统一走网关,由网关做限流和重试;子 Agent 是相对重量级的执行方式,每一步还需要额外的 token 预算控制。我没有去做进程级容器沙箱,原因是大多数技能只需要逻辑隔离,不需要系统隔离。如果你的技能包括用户上传脚本、执行任意代码那种,那另当别论,应该上更严格的隔离方案。
还有一点容易被忽略:技能的输出很可能包含敏感信息。比如查询订单技能返回了收货地址和手机号,如果原样拼进回复,就可能把隐私内容暴露给另一个用户。所以 agent-skills 在技能输出层内置了脱敏组件,默认把手机号、邮箱、身份证号等字段打码,只有明确声明“需要明文”且拥有对应权限的技能才允许输出原始数据。
5.2 调用配额和预算熔断
技能上线之后,成本控制是随时可能亮红灯的问题。大模型调用贵,外部 API 也贵,如果一个技能因为描述写得模糊被模型反复错误调用,预算会瞬间烧掉一大截。
我的做法是给每个技能配置了三层计数器:请求量、错误量、估算费用。按时间窗口统计,超过阈值就触发熔断,在短时间内自动禁用该技能。比如某个技能五分钟内错误调用率超过 60%,或者单小时调用费用超过预设值,执行引擎会直接把它从候选中摘掉,并返回一个“技能暂不可用”的兜底结果。
这个能力特别适合灰度期间保护线上服务。新技能刚上线时我一般会把熔断阈值调得比较保守,等错误率稳定之后再放宽。否则一个参数 Schema 写错的技能,可能在几个小时内让团队把整个月的 API 预算都花光。
5.3 全链路追踪与效果回捞
没有观测,就没有优化。agent-skills 从第一版开始就把全链路日志作为一等公民,记录的信息包括:完整用户请求、候选技能列表、每个候选的相似度分数、模型最终选中的技能 ID、调用参数、执行结果、延迟、token 消耗、错误信息、熔断状态。
我会给每次请求生成一个trace_id,贯穿 Agent 入口、技能路由、DAG 执行、外部 API 调用。出现任何线上问题,先按 trace_id 找回放,再判断是路由错了、参数错了、技能本身错了,还是外部依赖错了。这个问题定位链路,比任何调试工具都管用。
这些日志还有一个附加值:它们就是技能评估和模型调优的天然数据集。每次请求都是“用户在真实场景下想完成某件事”的样本,哪怕模型选错、参数解析失败,也是宝贵的负样本。我后面会讲怎么用这些数据做持续评估,前提是日志数据从第一天就开始沉淀,别等到需要时再补。
6. 实测中的性能瓶颈与优化
6.1 技能描述太长把上下文塞爆了
我第一次全量接入二十个技能时,发现每轮对话仅技能描述就占掉两三千 token,还不算 DAG 节点之间的状态输出。用户稍微多聊几句,上下文很容易逼近模型窗口,回答质量直线下降。
优化思路有两波。第一波是把每个技能的完整描述换成精简版,也就是我之前提到的“路由描述”,从一两百字压到三四十字。只保留“技能名 + 一句话场景 + 关键参数”,上下文占用立刻少了一半多。第二波是把 DAG 执行过程中的中间状态尽量从对话上下文里剔除,改用结构化状态对象传递。只有最终需要呈现给用户的信息,才拼进回复模板。
这两步做完,同一个任务的 token 消耗大概下降了百分之四十五,模型选型准确率不降反升。原因也简单,上下文干净了,模型的注意力更集中。
6.2 并发执行时的连接池与锁
DAG 编排不是纯串行。比如一个订单相关的流程,可能需要同时查订单、查库存、查物流,这三个节点互不依赖,可以并行执行。只做串行的话,一次用户请求的延迟会是所有技能延迟之和,体验很差。
并行改造时最容易踩的坑是连接池和线程安全问题。我一开始直接在每个技能函数里用requests.get,并发一上来就报连接超时,因为底层连接没有复用。后来统一改成使用一个共享会话池,连接池大小按并发上限配置,才稳定下来。如果你用的是异步框架,也要注意技能函数是不是阻塞型 API,把阻塞调用丢进事件循环会导致整个 Agent 卡顿。
另外一个坑是“共享状态被并发覆盖”。DAG 里下游并行节点同时往状态对象里写字段,如果没加锁,字段可能被后执行的节点覆盖。我的方案是明确每个节点写入的字段范围,执行引擎对写操作加细粒度锁,并且下游节点只能读取上游已声明的字段,不允许隐式依赖不在边上的状态。
6.3 缓存触发结果时要小心的坑
为了降本增效,我给一部分只读技能加了结果缓存。比如查询天气、查询汇率、查询订单状态这类高频读操作,在时效要求内直接返回缓存结果,能省掉大部分模型重推理和外部 API 调用。
但缓存技能结果有几个坑。第一,它不是所有技能都适用,写操作、随机结果、强时效数据绝对不能缓存。第二,缓存 key 不能只拼参数,还要拼“权限上下文”,否则一个用户请求返回另一个用户的数据就出大事故。第三,过期时间要按业务场景定,订单状态可能几分钟内就有变化,缓存 TTL 设成五分钟就是灾难。
我还吃过一次亏:给查询订单技能加了缓存,结果人工客服修改订单状态后,用户端仍然看到旧缓存。后来给状态变更类操作加了“缓存失效事件”,技能内部主动删除相关 key,才解决。这个机制在 agent-skills 里叫“缓存联动”,执行写操作技能时,可以声明自己会失效哪些读操作的缓存。
7. 技能质量评估与迭代闭环
7.1 给每个技能建需求-用例-回归用例
技能上线不是终点,而是评估的起点。我给每个技能维护了一个“用例集”,里面至少包括三类样本:用户说得很明确的标准案例、用户说得比较模糊但语义指向本技能的案例、容易混淆的边界案例。比如查询订单技能,标准案例是“帮我查一下订单 123456789”,模糊案例是“我那笔付款怎么回事”,混淆案例是“我要改收货地址”。
每次技能描述、参数 Schema 或路由权重发生变化,我都会用这套用例集做回归。回归统计四个指标:
- 路由命中率:正确技能是否进入候选列表并最终被选中。
- 参数解析成功率:模型填写的参数是否符合 Schema。
- 执行成功率:技能本身调用外部依赖是否成功。
- 回复准确率:最终回复是否满足用户意图。
这四个指标全部落库,按版本对比。只要出现明显回退,我就知道改坏了什么,可以直接回滚。
7.2 用 LLM 打分替代人工验收
用例集如果只有几十条,人工可以慢慢标。但真实线上跑一周,就会有几千条请求日志,人工根本看不过来。我后来搭了一个“自动裁判”流程:把一个请求的原始输入、候选技能列表、选中技能、参数、最终回复一起喂给一个独立评估模型,让它按一定标准打分,并给出简短理由。
裁判 prompt 的核心标准是:目标是否达成、技能选择是否合理、参数是否准确、回复是否简洁自然。裁判模型的输出不需要绝对精确,主要是为了把“明显错误”的样本筛出来。比如技能选错了、参数乱编、回复答非所问,这些一眼就能看出的问题,自动打分就能覆盖。人工只需要每天抽检一部分裁判认为“不确定”或者“失败”的样本,用来校准裁判的严苛程度。
我自己的经验是,裁判模型不要和业务主模型用同一个,否则会在同样的盲区上犯错。用另一个厂商的模型或者完全不同的提示词体系,反而能提供更独立的视角。
7.3 灰度发布与灰度禁用
技能体系必须支持独立上线、独立回滚。一个技能有问题,不能影响整个 Agent 的主流程。agent-skills 里每个技能都有一个状态字段:禁用、白名单、灰度、全量。灰度期间我可以按用户 ID 尾号或按流量百分比放量,比如先放 10% 流量,观察错误率和用户反馈。
一旦发现某个技能错误率飙升,最快止损动作不是改代码,而是直接把它标记为“禁用”。禁用状态在路由引擎里即时生效,所有请求都不会再召回这个技能,直到修复并重新灰度。这个机制在线上救过我很多次,比临时改代码发布快好几个数量级。
8. 我踩过的几个典型坑
8.1 参数 Schema 写得太宽松
这个坑我踩得特别深。最开始给查询订单技能写 Schema,只定义了一个order_id字符串,也没加格式校验。结果模型在用户说“查一下我的订单”时,居然把“我的订单”四个字当成了订单号传进去。执行器肯定不会命中,我就收到一堆报错。
后来我把 Schema 写得非常严格,加上了数字串长度限制、常见前缀,还给了两个格式示例。模型再遇到模糊输入时,倾向于先通过另一个“提取订单号技能”做解析,再用正式查询技能,调用成功率立刻上了一个台阶。参数 Schema 不是写给别人看的,是写给模型看的,它越严格,模型越不容易乱来。
8.2 技能间隐式依赖害我半夜回滚
有一次我给订单状态技能加了一个“预计送达时间”字段,上线当天没问题,第二天早上发现售后流程里的另一个技能开始拿不到数据。排查了很久,才发现那个技能内部偷偷读取了订单状态字段里的某个非契约字段,上游没有提供,所以取到了空值。
这就是技能间隐式依赖造成的。解决方法是把“字段契约”显式化。每个技能在注册时声明自己读取哪些字段、写入哪些字段,执行引擎在 DAG 运行前做字段依赖校验。如果一个技能读取的字段不在上游输出里,直接报配置错误,而不是等到运行时才暴露。现在所有技能之间的数据交换都必须走显式字段映射,不允许隐式共享全局对象。
8.3 一些实际操作建议
如果你也要做类似的东西,我的第一条建议是先把“日志和回放能力”做好,因为后面所有优化都靠它。别等技能数量多了再补日志,那时候你已经丢掉大量宝贵样本。
第二,路由、编排、安全这些机制不要一开始就全部铺开。技能数量没超过三十个时,简单的“完整列表 + 模型自选”可能更实用。过度设计会消耗你的迭代速度。我在第一版就上了全套路由和沙箱,结果大部分时间不是在解决业务问题,而是在调框架本身。
第三,技能描述要当作产品文案对待,每个字都可能影响线上效果。不要写完就上线,至少用回归用例跑一遍再放量。
这套 agent-skills 体系到现在还在持续迭代,但核心思路已经稳定:把能力抽象成技能,用路由缩小决策范围,用编排组合复杂流程,用权限和熔断守住边界,用日志和评估形成闭环。如果你也在做类似的事情,希望这些经验能帮你少踩几个坑。最后再多说一句,任何 Agent 框架都只是辅助,真正决定体验的,依然是你对用户需求的理解和对每个技能的打磨。