1. 从“agent-skills”这个标题说起:它到底在解决什么问题
第一次看到“agent-skills”这个标题,我脑子里蹦出来的不是某个具体框架,而是一类很实际的需求:怎么让一个智能体(agent)真正具备可复用、可组合、可评估的能力模块。说白了,就是别每次做新任务都从零写提示词、从零接工具、从零调流程,而是把“会做的事”沉淀成一个个技能包,用的时候直接挂载。
这个方向最近一年在开发者圈子里讨论得非常多。原因也很直接:大模型本身的能力已经够强了,但真正落地到业务里,卡点往往不在模型智商,而在工程化封装。一个能查天气的 agent 和一个能处理退款工单的 agent,底层调用逻辑可能完全不同,但“识别意图、选择工具、校验参数、处理异常、返回结构化结果”这套骨架是高度相似的。agent-skills 要做的,就是把这套骨架抽象出来,让技能像插件一样即插即用。
我自己的判断是,这个标题背后至少藏着三层需求。第一层是复用,同一个技能在客服、数据分析、内容审核等场景里反复出现,不应该重复造轮子。第二层是隔离,技能和技能之间要有清晰的边界,避免一个技能的错误污染整个 agent 的决策链。第三层是可观测,每个技能被调用了多少次、成功率多少、平均耗时多少,这些指标必须能单独统计,否则出了问题根本不知道是哪一环崩了。
适合看这篇内容的人,我大致分三类。一类是正在做 agent 应用的后端或全栈开发者,手里已经有几个跑通的 demo,但想把它做成可维护的系统。一类是产品经理或技术负责人,需要评估 agent-skills 这套思路能不能降低团队的交付成本。还有一类是刚接触 agent 开发的新手,想搞清楚“技能”和“工具”到底有什么区别,为什么不能直接把所有函数都塞给模型。
提示:如果你之前只写过单文件的 agent demo,建议先别急着上复杂框架。agent-skills 的核心价值在规模化之后才明显,三五个技能的时候,手写路由反而更直接。
2. 核心设计思路拆解:为什么是“技能”而不是“工具”
2.1 技能与工具的边界到底在哪
很多人第一次接触 agent-skills 会把它和 function calling 搞混。我刚开始也这样,觉得不就是把函数描述塞进提示词里让模型选吗。但实际做下来会发现,工具是原子操作,技能是带上下文的操作序列。举个例子,“查询订单状态”是一个工具,输入订单号返回状态字段。但“处理用户催单”是一个技能,它可能包含:识别用户情绪、查询订单、判断是否超时、生成安抚话术、必要时触发补偿流程。技能内部可以调用多个工具,也可以调用其他技能。
这个区分非常关键,因为它决定了你的系统架构。如果只有工具层,模型每次都要自己规划“先调A再调B”,一旦步骤多了就容易漏。有了技能层,你可以把常用流程固化下来,模型只需要选技能,技能内部按预定义逻辑执行。实测下来,任务步骤超过四步之后,技能封装的稳定性明显高于纯工具编排。
2.2 为什么选择“注册-发现-执行”三段式
我见过几种不同的 agent-skills 实现,但主流思路基本都绕不开注册、发现、执行这三个阶段。注册是把技能元信息(名称、描述、输入输出 schema、依赖项)写到一个中心化的注册表里。发现是 agent 在运行时根据当前任务上下文,从注册表里筛选出候选技能。执行就是真正调用技能逻辑,并处理返回结果。
这套设计的优势在于解耦。技能开发者不需要关心 agent 怎么选,agent 开发者不需要关心技能内部怎么实现。两边通过 schema 契约交互。我试过把技能注册表做成独立的微服务,agent 通过 HTTP 查询可用技能,这样技能更新后 agent 不用重启,热加载就能生效。当然,如果技能数量不多,直接放在同一个进程里用内存注册表也够用,启动时扫描装饰器自动注册,开发体验更顺滑。
2.3 技能描述的质量决定一切
这一点是我踩过坑之后才深刻理解的。技能注册表里最重要的字段不是代码,而是自然语言描述。因为 agent 选择技能的依据就是这段描述。如果描述写得太笼统,比如“处理订单相关操作”,模型根本分不清它和“查询订单”的区别。如果描述写得太技术化,比如“调用 order_service.get_status 方法”,模型又理解不了业务语义。
我的经验是,技能描述要包含三个要素:触发场景、输入约束、输出形态。比如“当用户询问订单物流进度且提供了订单号时使用,输入为字符串订单号,输出为包含物流节点和预计到达时间的结构化对象”。这样模型在规划时就能准确匹配。另外,描述里最好带上反例,比如“不适用于修改订单地址”,能进一步降低误选概率。
3. 核心细节解析与实操要点
3.1 技能元信息的数据结构设计
一个技能注册项通常包含这些字段,我按重要性排个序:
| 字段名 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
| name | string | 是 | 技能唯一标识,建议用蛇形命名 |
| description | string | 是 | 自然语言描述,决定模型能否选对 |
| input_schema | object | 是 | JSON Schema,定义参数类型和约束 |
| output_schema | object | 否 | 定义返回结构,便于下游解析 |
| version | string | 否 | 语义化版本,便于灰度升级 |
| tags | array | 否 | 分类标签,用于预筛选 |
| timeout_ms | number | 否 | 超时时间,默认 5000 |
| retry_policy | object | 否 | 重试次数和退避策略 |
这里重点说两个容易忽略的字段。一个是timeout_ms,技能执行时间差异很大,查缓存可能 50ms,调外部接口可能 3s,如果不设超时,一个慢技能会把整个 agent 拖死。另一个是retry_policy,对于幂等查询类技能,重试能显著提升成功率,但对于写操作类技能,重试可能导致重复提交,必须谨慎。
3.2 技能发现的两种策略:语义匹配与规则预筛
技能发现环节,我实践下来最稳的是两阶段筛选。第一阶段用规则做粗筛,比如根据当前对话的意图分类、用户角色、会话状态,把候选技能从几百个降到十几个。第二阶段用语义相似度做精排,把技能描述和当前任务描述做向量化比对,取 top-k 交给模型做最终选择。
为什么不全用语义匹配?因为纯向量检索在技能数量多的时候召回率会下降,而且对否定语义不敏感。比如用户说“不要查订单”,纯语义可能还是把“查询订单”技能排前面。加上规则预筛之后,可以显式排除某些标签的技能,准确率提升很明显。我实测过一个 200 技能的场景,两阶段筛选比单阶段语义匹配的选对率从 72% 提到了 91%。
3.3 技能执行的沙箱与隔离
技能执行最怕的是一个技能把整个 agent 进程搞崩。比如某个技能内部有死循环,或者内存泄漏,或者调用了不稳定的外部服务。我的做法是给每个技能执行加一层轻量沙箱。如果是同进程执行,至少要用独立的线程池加超时中断。如果技能是外部服务,那就走网络调用,天然隔离。
另外,技能之间的状态隔离也很重要。我见过一个案例,两个技能共用了同一个全局缓存 key,结果 A 技能写入的数据被 B 技能读到了,导致返回结果错乱。后来改成每个技能有独立的命名空间前缀,问题就消失了。这个坑不遇到很难想到,但一旦遇到排查起来非常痛苦。
注意:技能执行日志一定要带上 trace_id 和 skill_name,否则在并发场景下根本没法定位问题。我习惯在技能入口和出口各打一条日志,记录入参、出参、耗时、是否命中缓存。
4. 实操过程与核心环节实现
4.1 从零搭建一个最小可用的技能注册中心
先定义一个技能注册中心,核心就是一个字典加几个方法。下面是我常用的 Python 实现骨架:
import time import json from typing import Callable, Dict, Any, Optional class SkillRegistry: def __init__(self): self._skills: Dict[str, Dict[str, Any]] = {} def register(self, name: str, description: str, input_schema: dict, func: Callable, output_schema: Optional[dict] = None, timeout_ms: int = 5000): if name in self._skills: raise ValueError(f"技能 {name} 已存在") self._skills[name] = { "name": name, "description": description, "input_schema": input_schema, "output_schema": output_schema or {}, "func": func, "timeout_ms": timeout_ms, "call_count": 0, "success_count": 0, "total_latency_ms": 0.0, } def list_skills(self, tags: Optional[list] = None): results = [] for skill in self._skills.values(): results.append({ "name": skill["name"], "description": skill["description"], "input_schema": skill["input_schema"], }) return results def execute(self, name: str, params: dict) -> dict: skill = self._skills.get(name) if not skill: return {"ok": False, "error": f"技能 {name} 不存在"} start = time.time() skill["call_count"] += 1 try: result = skill["func"](**params) latency = (time.time() - start) * 1000 skill["success_count"] += 1 skill["total_latency_ms"] += latency return {"ok": True, "data": result, "latency_ms": round(latency, 2)} except Exception as e: latency = (time.time() - start) * 1000 skill["total_latency_ms"] += latency return {"ok": False, "error": str(e), "latency_ms": round(latency, 2)}这个骨架虽然简单,但已经包含了注册、列表、执行、统计四个核心能力。实际项目中,我会把_skills换成 Redis 或数据库,让多个 agent 实例共享技能注册表。另外,execute方法里应该加上超时控制,Python 里可以用concurrent.futures的ThreadPoolExecutor配合future.result(timeout=...)来实现。
4.2 定义一个真实可用的技能示例
光有注册中心不够,得有个具体技能来验证。我拿“计算订单折扣”这个场景举例,因为它有明确的输入输出,逻辑也不复杂:
def calc_discount(order_amount: float, user_level: str, coupon_rate: float = 0.0) -> dict: level_discount = { "normal": 1.0, "silver": 0.95, "gold": 0.90, "platinum": 0.85, } base_rate = level_discount.get(user_level, 1.0) final_amount = order_amount * base_rate * (1 - coupon_rate) final_amount = max(final_amount, 0.0) return { "original_amount": order_amount, "level_rate": base_rate, "coupon_rate": coupon_rate, "final_amount": round(final_amount, 2), } registry = SkillRegistry() registry.register( name="calc_discount", description="当用户询问订单折后价格,且提供了订单金额和会员等级时使用。" "输入订单金额、会员等级、可选优惠券折扣率,输出折后金额明细。" "不适用于修改订单或查询历史价格。", input_schema={ "type": "object", "properties": { "order_amount": {"type": "number", "minimum": 0}, "user_level": {"type": "string", "enum": ["normal", "silver", "gold", "platinum"]}, "coupon_rate": {"type": "number", "minimum": 0, "maximum": 1}, }, "required": ["order_amount", "user_level"], }, func=calc_discount, timeout_ms=1000, )注册好之后,调用registry.execute("calc_discount", {"order_amount": 200, "user_level": "gold", "coupon_rate": 0.1}),返回的final_amount应该是 162.0。这个计算过程是 200 乘以 0.90 再乘以 0.9,结果是 162。你可以自己验算一下,确保技能逻辑和预期一致。
4.3 把技能接入 agent 的决策循环
技能注册好了,接下来要让 agent 能用。核心是把list_skills的输出转成模型能理解的工具描述格式,然后在模型返回技能名和参数后,调用execute并处理结果。下面是一个简化的决策循环:
def agent_step(user_input: str, registry: SkillRegistry, llm_client): skills = registry.list_skills() skill_desc = "\n".join([ f"- {s['name']}: {s['description']}" for s in skills ]) prompt = f"""你可以使用以下技能: {skill_desc} 用户说:{user_input} 请输出 JSON,格式为 {{"skill": "技能名", "params": {{...}}}}。 如果不需要技能,skill 填 null。""" response = llm_client.chat(prompt) decision = json.loads(response) if decision.get("skill") is None: return {"type": "reply", "content": "无需调用技能"} result = registry.execute(decision["skill"], decision.get("params", {})) return {"type": "skill_result", "result": result}这个循环里有个细节值得说:技能描述拼接的顺序会影响模型选择。我试过把最常用的技能放前面,模型选中的概率会略高一些。另外,如果技能数量超过 20 个,建议先做一轮预筛选再拼进提示词,否则提示词太长会稀释模型的注意力。
4.4 技能版本管理与灰度发布
技能上线之后难免要改逻辑。直接覆盖旧版本风险很大,万一新逻辑有 bug,所有 agent 都会受影响。我的做法是技能名不变,但注册表里保留多个版本,通过version字段区分。agent 调用时默认走稳定版,需要灰度时通过配置指定一部分流量走新版本。
具体实现上,可以在注册表的 key 里加上版本后缀,比如calc_discount@1.0和calc_discount@1.1。list_skills默认只返回稳定版,灰度流量通过环境变量或请求头指定版本。这样新版本可以先在小流量上跑一两天,观察成功率和耗时指标,没问题再全量切换。这个机制在技能频繁迭代的阶段特别有用,能避免很多线上事故。
5. 常见问题与排查技巧实录
5.1 技能选错:模型总是调用不相关的技能
这是最高频的问题。我排查下来,原因通常有三个。第一是技能描述太相似,比如“查询订单”和“查询物流”都写了“查询订单相关信息”,模型分不清。解决办法是把差异点写进描述,比如“查询订单”强调返回订单状态和金额,“查询物流”强调返回承运商和轨迹节点。第二是缺少否定示例,模型不知道某个技能不适用什么场景。第三是候选技能太多,模型在几十个选项里容易眼花,这时候需要加预筛选。
我整理了一个排查清单,遇到选错问题时按顺序检查:
| 检查项 | 常见问题 | 修复动作 |
|---|---|---|
| 描述相似度 | 两个技能描述重合度高 | 补充差异化关键词和反例 |
| 候选数量 | 单次传入超过 20 个技能 | 加规则预筛,降到 10 个以内 |
| 参数 schema | 必填项不明确 | 在描述里强调必填参数 |
| 提示词顺序 | 重要技能排在后面 | 按使用频率降序排列 |
| 模型温度 | 温度过高导致随机选择 | 决策环节温度设为 0 |
5.2 技能执行超时:慢技能拖垮整个链路
超时问题我遇到过好几次,最典型的一次是某个技能调用了外部接口,对方响应时间从 200ms 涨到了 8s,导致整个 agent 的 P99 延迟飙升。后来我给每个技能都设了独立的超时时间,并且在注册表里加了熔断逻辑:连续失败超过阈值就自动降级,返回兜底结果,同时告警。
超时时间的设置也有讲究。我一般按技能的历史 P95 耗时乘以 2 来定,比如 P95 是 800ms,超时就设 1600ms。这样既能容忍偶发的慢请求,又不会让极端情况拖太久。另外,超时后的处理策略要区分技能类型:查询类可以返回缓存或默认值,写操作类必须明确报错,不能静默失败。
5.3 技能间状态污染:数据串了
前面提过全局缓存 key 冲突的问题,这里再展开说。技能之间共享状态的地方主要有三处:全局变量、共享缓存、数据库连接。全局变量最危险,因为 Python 的模块级变量在同一个进程里是所有技能共享的。共享缓存如果 key 设计不当,也会串数据。数据库连接一般没问题,但事务隔离级别要注意。
我的建议是每个技能有独立的命名空间。缓存 key 统一加skill:{skill_name}:前缀,全局变量尽量不用,如果非要用就加技能名前缀。另外,技能执行前后最好做一次状态快照对比,虽然有点重,但在排查诡异问题时非常有效。我试过在测试环境开启这个对比,抓到了两次隐蔽的状态污染。
5.4 技能统计指标失真
技能统计看起来简单,但有几个坑。第一是并发计数不准确,多线程同时更新call_count可能丢更新,需要用锁或者原子操作。第二是耗时统计包含了排队时间,如果技能执行走了线程池,从提交到开始执行的时间也被算进去了,导致耗时虚高。第三是失败重试被重复计数,一次调用重试三次,call_count加了三次,但实际只应该算一次。
我的做法是把统计拆成两层:调用层统计请求次数和最终结果,执行层统计实际执行次数和纯执行耗时。两层通过 trace_id 关联。这样既能看整体成功率,也能看单次执行的真实耗时。指标上报我习惯用 Prometheus 的 histogram,按技能名和版本号打标签,方便做分位值分析。
提示:技能统计不要只盯着成功率,P99 耗时和调用量突增往往更能提前发现问题。我设过一条告警规则,某个技能调用量 5 分钟内涨了 10 倍就触发,结果提前发现了一次上游重试风暴。
6. 技能组合与编排的进阶玩法
6.1 串行组合:把多个技能串成工作流
单个技能能做的事有限,真正复杂的业务往往需要多个技能配合。最简单的组合方式是串行,前一个技能的输出作为后一个技能的输入。比如“处理退款”可以拆成:查询订单、计算退款金额、发起退款、通知用户。这四个技能串起来就是一个完整流程。
串行组合的关键是数据映射。技能 A 的输出字段名不一定和技能 B 的输入字段名一致,中间需要一层转换。我一般用一个简单的映射配置来解决,比如{"order_id": "$.data.order_id", "amount": "$.data.final_amount"},用 JSONPath 从上游结果里取值。这样技能之间保持松耦合,换一个技能只需要改映射配置,不用改代码。
6.2 并行组合:提升多技能场景的响应速度
有些场景下多个技能之间没有依赖,可以并行执行。比如用户问“我的订单到哪了,顺便看看有没有优惠券”,查询物流和查询优惠券可以同时跑。并行执行能把总耗时从两个技能之和降到两者最大值,提升很明显。
实现上可以用asyncio.gather或者线程池的submit加as_completed。但要注意,并行技能之间不能有共享状态写入,否则会出现竞态条件。我一般只对纯查询类技能做并行,写操作类技能还是串行执行,避免顺序不确定导致的数据问题。
6.3 条件分支:根据中间结果动态选择后续技能
更复杂的编排需要条件分支。比如计算退款金额后,如果金额超过阈值,需要走人工审核技能,否则直接走自动退款技能。这种逻辑用纯提示词让模型决策不太可靠,我倾向于在技能编排层用代码写死分支条件,模型只负责触发整个工作流。
条件分支的实现可以引入一个简单的 DSL,或者直接用代码里的 if-else。我试过用 YAML 定义工作流,每个节点包含技能名、输入映射、条件表达式、成功和失败的下游节点。这样非开发人员也能看懂流程,修改起来不用动代码。但 YAML 的表达能力有限,复杂条件还是得回退到代码。
7. 技能评估与持续迭代
7.1 怎么衡量一个技能写得好不好
技能质量不能只看能不能跑通,我一般从四个维度评估。准确率是技能在正确输入下返回正确结果的比例,这个需要构造测试集来验证。鲁棒性是异常输入下的表现,比如参数缺失、类型错误、边界值,技能应该返回明确的错误而不是崩溃。耗时是 P50 和 P99 延迟,直接影响用户体验。复用度是技能被多少个不同的 agent 或工作流调用,复用度越高说明抽象得越好。
这四个维度里,我特别看重鲁棒性。因为线上环境的输入千奇百怪,用户可能传空字符串、传负数、传超长文本。技能入口必须做参数校验,校验失败返回结构化错误,而不是抛异常。我习惯用 JSON Schema 做第一层校验,技能内部再做业务逻辑校验,两层配合基本能覆盖绝大多数异常情况。
7.2 用回归测试保证技能迭代不退化
技能逻辑一改,之前跑通的场景可能就挂了。所以每次修改技能,我都会跑一遍回归测试。测试用例不用多,但必须覆盖核心路径和已知的边界情况。我一般给每个技能配 10 到 20 个用例,包括正常输入、边界输入、异常输入三类。
测试框架用 pytest 就够了,关键是把技能注册和测试解耦。测试时直接调用技能函数,不走注册中心,这样测试速度更快。另外,我会把测试用例的输入输出存成 JSON 文件,方便非开发人员补充用例。每次 CI 跑完,如果通过率低于 100%,就阻止合并。这个机制帮我拦住了好几次想当然的修改。
7.3 基于线上数据反哺技能优化
线上跑一段时间后,会积累大量真实的调用数据。这些数据是优化技能的宝藏。我会定期分析几个指标:哪些技能调用量高但成功率低,说明逻辑有问题;哪些技能几乎没人调用,说明描述写得不好或者场景不匹配;哪些技能经常被一起调用,说明可以合并成一个复合技能。
分析工具用简单的 SQL 加可视化就够了。我习惯按周做一次技能健康度报告,把成功率低于 95% 的技能列出来,逐个排查。有一次发现一个查询技能成功率只有 80%,排查后发现是上游传参时字段名拼错了,模型有时候会传orderId有时候传order_id。后来在 schema 里加了别名支持,成功率就上去了。
8. 我踩过的几个印象深刻的坑
第一个坑是技能描述里写了太多技术细节。早期我为了让模型理解,把技能内部调用的 API 地址、数据库表名都写进了描述。结果模型经常把这些内部信息泄露给用户,比如回复“我正在调用 order_service 查询”。后来我把描述改成纯业务语言,技术细节全部移到代码注释里,问题就解决了。
第二个坑是技能超时后没有清理资源。有个技能会打开文件句柄,超时中断后句柄没关闭,跑了一天之后进程报“too many open files”。后来我在技能执行外层加了try-finally,确保无论成功失败都释放资源。这个教训让我意识到,技能不只是业务逻辑,资源管理同样重要。
第三个坑是技能版本升级没有做兼容。我把一个技能的输入参数从order_id改成了order_ids支持批量,但没有保留旧参数。结果依赖旧参数的 agent 全部报错。后来我学乖了,参数变更一律向后兼容,旧参数保留至少一个版本周期,同时在描述里标注废弃。
第四个坑是技能注册表启动时扫描太慢。技能数量涨到三百多个之后,启动时逐个导入模块花了十几秒。后来改成懒加载,注册表只存元信息,真正执行时才导入模块。启动时间降到了两秒以内。这个优化在技能多的场景下非常值得做。
注意:技能开发最容易忽略的是幂等性。同一个请求重试两次,结果应该一致。查询类技能天然幂等,但写操作类技能必须显式处理,比如用请求 ID 去重。我见过因为重试导致用户被扣两次款的案例,排查了半天才发现是技能没做幂等。
9. 技能生态的扩展思路
单个项目里的技能积累到一定程度,就可以考虑跨项目复用了。我目前的实践是建一个内部技能市场,每个技能有独立的仓库、文档、测试和版本号。其他项目通过包管理工具引入,或者通过 HTTP 接口远程调用。这样技能开发者可以专注维护自己的技能,使用方按需引入,职责清晰。
技能市场要解决的核心问题是信任。使用方怎么知道一个技能靠不靠谱?我的做法是给每个技能打质量分,基于测试覆盖率、线上成功率、文档完整度、维护活跃度四个指标计算。质量分高的技能在搜索时排前面,使用方也可以设置最低质量分门槛。这套机制跑下来,技能的平均成功率从 88% 提到了 96%。
另一个扩展方向是技能自动生成。对于一些模式固定的技能,比如“查询某张表的某个字段”,可以根据数据库 schema 自动生成技能代码和描述。我试过一个原型,把表结构和字段注释喂给模型,让它生成技能注册代码,人工审核后上线。虽然还不能完全自动化,但能省掉不少重复劳动。这个方向我觉得后续还有很大空间。
10. 一些实用的小技巧收尾
技能描述里的动词很关键。我观察下来,用“查询”“计算”“生成”“校验”这类明确动词开头的描述,模型选择准确率比用“处理”“管理”这类模糊动词高不少。另外,描述长度控制在 50 到 150 字之间比较合适,太短信息不够,太长模型抓不住重点。
技能参数命名尽量用业务术语,别用技术缩写。order_amount比amt好,user_level比ulvl好。模型对完整单词的理解更准确,而且生成的参数值也更符合预期。这个细节看起来小,但实测对成功率有影响。
技能执行日志我习惯用结构化格式,每条日志包含trace_id、skill_name、skill_version、params、result、latency_ms、success这些字段。排查问题时直接按trace_id过滤,整条链路一目了然。日志量大的话,params和result可以做脱敏和截断,避免日志膨胀。
最后分享一个我常用的调试方法:给技能加一个 dry-run 模式。传入dry_run=true时,技能只做参数校验和依赖检查,不执行实际逻辑,直接返回预估的执行计划。这样在编排复杂工作流时,可以先 dry-run 一遍确认技能选择和参数映射都正确,再真正执行。这个模式帮我省了很多次调试时间。