把提示词写到三千字,Agent仍然会在关键时刻来一句“这个我目前还做不到”。我一开始以为是模型能力不行,直到我把同一段任务换成一个小技能模块,几秒钟就跑通了。这个对比让我彻底想明白一件事:大语言模型缺少的从来不是“话术”,而是能动手执行的“技能模块”。
“agent-skills”是我近期在整理的一套智能体技能管理实践,核心思路很简单——把Agent需要的能力拆成一个个可注册、可路由、可复用的技能包,让模型只负责“理解意图”和“组装任务”,真正干活的部分交给独立技能模块去执行。它解决的是智能体开发里一类非常普遍的问题:Prompt写得再花哨,Agent也只能做到“答得漂亮”,一旦任务需要真正操作外部系统、读写文件、调用接口,没有技能模块就只能原地空谈。
这篇文章把我在设计这套技能体系时的完整思路、代码骨架、调度策略和血泪教训一并写出来。适合正在做AI Agent、工作流自动化,或者被“万能Prompt”困住手脚的开发者参考。下面讲的全是我实际跑过、踩过、改过的方案,不是理论推演。
1. agent-skills 到底在解决什么问题:智能体的“会说话”和“会做事”是两回事
很多人把一个Agent做得“看起来聪明”,是因为模型本身会说话。但真实业务里,用户要的不是“说得对”,而是“做得成”。
1.1 提示词是“说明书”,技能是“手脚”
要理解skill机制的意义,先要分清两个概念:提示词和技能。
提示词的本质是约束模型输出格式和思路的文字协议。它改变的是模型生成内容时的策略,但模型本身仍然只是一个文本生成器。你让它“查询订单状态”,它不会真的去查数据库,它只会生成一段看起来像查询结果的文字。这在Demo里没问题,一旦涉及真实数据校验,立刻露馅。
技能模块则完全不同。它是一个真实的可执行程序,有自己的输入参数、输出结构、错误码和执行逻辑。比如“查询订单状态”这个技能,内部可以封装HTTP请求、数据库读取、缓存策略和结果格式化。模型要做的只是根据用户的话,决定调用哪个技能、传入什么参数,然后把技能返回的结构化结果整理成自然语言回答。
我把这个关系类比成:提示词是交给员工的“工作手册”,技能是员工的“手和工具”。工作手册写得再细,员工没有工具就无法交付实物。
1.2 从单体Agent到技能库的演进路径
大多数Agent项目都走过这样一条路:最开始只有一个系统提示词,状态和逻辑全放在一段超长Prompt里;后来发现输出不稳定,开始给模型加工具调用(Tool Calling);工具多了以后,又要解决模型选错工具、参数传错、工具之间互相影响的问题。
agent-skills正是针对第三个阶段的设计。它不解决“模型能不能生成文本”的问题,它解决的是“模型如何稳定、正确、安全地使用外部能力”这一问题。它的核心是把“技能”做成系统的一等公民,每一个可复用能力都单独封装、单独注册、单独测试,由调度器统一路由,而不是让模型在几十个函数签名里蒙着选。
这一步走通之后,调试Agent任务的方式也会变化:以前是改Prompt试输出,现在是看调度日志、查技能返回、修技能代码。问题的定位边界清晰了很多。
2. 技能包的项目骨架与注册机制:为什么每项能力都要写成独立模块
技能模块不是简单写个Python函数就够了。想让调度器、Agent主流程和测试工具都能统一处理技能,就必须给每个技能包一套固定的结构和注册协议。
2.1 一个技能包的标准目录
我在项目里建议每个技能独立成目录,包含元信息文件、实现代码和测试文件:
agent_skills/ skills/ web_fetch/ __init__.py skill.yaml main.py test_main.py markdown_format/ __init__.py skill.yaml main.py test_main.py registry/ registry.py scheduler/ router.py agent/ runner.pyskill.yaml是这个技能的身份证,用于注册和路由。一个最小示例:
name: web_fetch version: 0.1.0 description: 从公开网页中提取正文并转换为纯文本。仅适用于站点允许自动访问的页面。 params: url: type: string required: true description: 完整的目标页面地址 max_chars: type: integer required: false default: 5000 description: 返回正文的最大字符数 timeout_seconds: 20 idempotent: true safe: true这里有个细节容易被忽略:description字段非常关键,但不能写成功能说明书,而要写成“路由索引”。调度器需要靠这段描述判断什么场景该调用它。如果你写的是“网络请求工具”,模型完全不知道它到底适合抓网页还是查天气。后面讲调度的时候会再展开。
2.2 注册中心与统一Skill接口
注册中心负责收集所有技能包,统一管理生命周期。运行时通过注册表查找技能、校验参数、执行并返回结构化结果。
一个简化的Skill基类定义可以是这样的:
# registry/base.py from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any @dataclass class SkillResult: success: bool data: Any = None error: str = "" duration_ms: int = 0 metadata: dict = field(default_factory=dict) class BaseSkill(ABC): name: str = "" description: str = "" params_schema: dict = {} @abstractmethod def execute(self, params: dict) -> SkillResult: """执行具体的技能逻辑,返回结构化结果""" pass def validate_params(self, params: dict) -> dict: """参数校验:只保留合法字段,给缺失项补默认值""" # 实际项目可以用 JSON Schema 库来做 return params所有技能都必须返回SkillResult,而不是自由格式的字符串。这个设计不是一个风格偏好,而是调度稳定性的关键:结构化返回能让后续决策逻辑准确判断“技能到底成功没有”,而不是靠正则去文本里捞信息。
注册逻辑也一样简单,启动时扫描技能目录,把每个技能实例放进程内的注册表里:
# registry/registry.py class SkillRegistry: def __init__(self): self._skills = {} def register(self, skill: BaseSkill): self._skills[skill.name] = skill def get(self, name: str) -> BaseSkill: return self._skills.get(name) def all_skills(self): return list(self._skills.values())到这里,技能包只是被“收编”了,真正决定Agent能否高效干活的是下一步:调度器如何处理用户任务并选择技能。
3. 把“选技能”和“用技能”拆开:调度器的设计
在最原始的Tool Calling模式里,模型会直接看到所有工具的函数签名和说明,自己选一个来调用。技能数量少的时候没问题,一旦超过十个,模型的选择准确性就明显下降,而且每次请求都要把所有技能描述塞进上下文,token成本也高。
agent-skills的解法是把“选择技能”这件事从模型手里部分抢回来,交给一层独立的调度器。
3.1 为什么调度器必须独立
调度器独立带来的最直接好处是可控和可测。
在纯模型路由模式下,Agent为什么选A不选B是一个黑盒。你只能不断改工具描述去碰运气。但独立调度器可以把路由逻辑拆成规则、评分、模型决策的交集,每一步都可观测、可记录、可回放。我在实践中最直观的感受是:以前排查“Agent为什么调了错误的工具”靠猜,现在直接看调度日志里的命中和评分记录,问题一目了然。
另一个好处是节省了上下文空间。调度器只在内部执行路由计算,模型看到的工具列表可以限定为“已命中技能的精确参数说明”,而不是全量技能目录。
3.2 三种路由策略以及混合方案
我在项目里实验过三种路由策略,各有适用范围,这里直接给出对比:
| 路由方式 | 实现成本 | 稳定性 | 适用场景 |
|---|---|---|---|
| 关键词规则路由 | 低 | 中 | 技能命名和用户问法高度固定 |
| 向量语义路由 | 中 | 中高 | 用户意图多样、说法不固定 |
| 模型二次决策 | 高 | 高 | 上述两者都有歧义时做兜底 |
实际代码里,“推荐技能”这一步可以用关键词候选加语义评分合并实现,比如给每个技能预置一组触发词,再配合简单评分函数:
# scheduler/router.py def route_intent(intent_text: str, threshold: float = 0.6) -> str: candidates = [] for skill in registry.all_skills(): trigger_words = SKILL_TRIGGER_WORDS.get(skill.name, []) hit_score = sum(1 for w in trigger_words if w in intent_text) # 语义相似度可调用 embedding 接口,这里简化为示例 semantic_score = embedding_similarity(intent_text, skill.description) total = hit_score * 0.4 + semantic_score * 0.6 candidates.append((total, skill.name)) best_score, best_skill = max(candidates, key=lambda x: x[0]) if best_score < threshold: return "fallback_to_model" return best_skill这里设置一个阈值,低于阈值就把决定权交还给模型,让模型结合完整上下文做最终判断。这样既不放弃规则的可控性,也不会在读不懂用户意图时硬来。
3.3 技能冲突:两个技能都“看起来合适”怎么办
做时间长了你会发现,真正难处理的是技能之间的边界重叠。比如有了一个“天气查询”技能,又加了一个“旅行攻略生成”技能,用户问“去杭州适合穿什么衣服”,两个技能都沾边。
我的做法是在技能元数据里增加一个priority字段,标明“当与其他技能同时命中时,谁优先”。同时,在调度逻辑里增加歧义检测:如果前两名评分接近,强制走模型二次决策,而不是默认取最高分。
这类冲突想靠代码一次性解决是不现实的,但把决策过程做成可观测的之后,至少可以持续迭代触发词和技能描述,逐步降低冲突率。
4. 技能粒度、幂等重试与安全边界:三个容易被低估的设计决策
很多技能库项目死掉,不是模型不够聪明,是技能模块本身设计得不够扎实。下面这三个点是我经历过的教训中最重要的。
4.1 技能粒度:小而专永远优于大而全
一个典型的反例是写一个“全能数据处理技能”,把读取、清洗、聚合、报表全塞进去。表面上看技能数量少了、路由简单了,实际上参数列表变得极其复杂,描述写不清楚,模型根本不知道该传哪些参数、走哪条分支。
我最后用下来的原则是:一个技能只做一件事,并且通过参数控制行为差异。比如数据读取和数据清洗拆成两个技能,各自维护自己的错误处理逻辑。这样不仅路由准确率更高,单个技能的故障也不会把整个任务链路击穿。
判断粒度是否合适的经验法则是:如果一个技能的描述需要超过三句话才能讲清楚“什么情况下调用它”,就说明它还是太复杂,需要拆。
4.2 幂等重试:失败重试不能造成重复副作用
Agent任务链路里,技能失败是常态。模型通常会尝试重试或换技能。这时候最危险的问题不是重试本身,而是重试可能产生重复副作用。
我举一个自己做过的例子:一个“发送周报”技能,第一次调用时网络超时了,但服务端实际已经收到请求,邮件已经发出。调度器发现超时后自动重试,结果同一封邮件发了两遍。排查之后,我给所有可能产生副作用的技能加了一个强制要求:必须支持幂等键(Idempotency Key)。调用方生成一个全局唯一的task_id,技能在执行业务前先检查这个task_id是否前面处理过,处理过就直接返回上一次的结果。
def execute_with_idempotency(skill_name, task_id, params): cached = get_processed_record(skill_name, task_id) if cached is not None: return SkillResult(success=True, data=cached, metadata={"dup": True}) result = do_actual_work(params) save_processed_record(skill_name, task_id, result.data) return result读操作类技能天然安全,可以不做;写操作类技能,特别是涉及消息发送、文件覆盖、数据写入的,必须强制支持。
4.3 安全边界:技能不应该是“给模型随便玩的函数”
这是成套设计里最容易被忽略的一层。你注册的技能越多,Agent身上捆绑的权限就越大。一个典型场景是给了Agent一个“执行Shell命令”的技能,那它理论上就能删库。我在实践中严格控制了高危类技能的启用范围:
- 文件系统技能只允许操作白名单目录,路径参数要做归一化和前缀校验;
- 网络请求技能统一走内部封装的HTTP客户端,限制只允许访问特定协议和域名白名单;
- 涉及资源修改的操作必须有显式的二次确认机制,默认拒绝高风险行为。
安全边界这事不用做得非常复杂,但至少要保证:技能调用时的权限不能大于创建者肉眼核验过的范围。我的习惯是,任何内置技能里只要出现eval、os.system、rm -rf这类危险动作,一律单独标记为高危并默认禁用,只有需要时才显式开启。
5. 实战:给技术内容整理助手补齐三个技能并跑通完整任务
理论讲多了容易飘,落到一个具体Demo上最好理解。我做一个“技术文章整理助手”的完整技能链路,整条流程走一遍。
5.1 技能设计:抓取、清洗、摘要
三个技能目标明确:
| 技能名 | 输入 | 输出 | 说明 |
|---|---|---|---|
| web_fetch | 一个公开文章页面URL | 页面正文纯文本 | 抓取前检查站点是否允许自动访问 |
| text_clean | 原始文本 | 清洗后的结构化正文 | 去广告、去无用标签、去重 |
| summary | 长文本 | 摘要结果 | 调用模型生成三句话摘要 |
按照前面讲的粒度原则,这三件事绝不是一个技能。它们之间有调用依赖,但各自职责独立。
web_fetch简化为:
class WebFetchSkill(BaseSkill): name = "web_fetch" description = "抓取一个公开文章的正文内容并转成纯文本,仅适用于允许自动访问的页面" def execute(self, params): url = params["url"] if not is_auto_access_allowed(url): return SkillResult(success=False, error="该页面不允许自动抓取") text = fetch_and_extract_main_content(url) if len(text) > params.get("max_chars", 5000): text = text[:params["max_chars"]] return SkillResult(success=True, data={"content": text})text_clean和summary同样返回SkillResult。这里不展开全部代码,重点看调度链路怎么把它们串起来。
5.2 任务编排:Agent主流程如何调度
用户说一句话:“把这篇文章整理成三句话摘要,给个标题建议。”
主流程调度大致如下:
def handle_task(user_input): intent = parse_intent(user_input) # 1. 路由:判断需要哪些技能 skill_chain = ["web_fetch", "text_clean", "summary"] if "标题建议" in user_input: skill_chain.append("title_suggest") # 2. 逐级执行,后一个技能依赖前一个技能的输出 current_data = {"url": extract_url(user_input)} for skill_name in skill_chain: skill = registry.get(skill_name) result = skill.execute(current_data) if not result.success: return handle_failure(skill_name, result.error) current_data.update(result.data) # 3. 最终结果整理 return format_final_answer(current_data)真实项目里的编排会比这个复杂,可能要根据用户补充信息动态调整技能链。但核心逻辑就是:意图拆解、技能路由、顺序执行、失败兜底。
我实际跑下来,这种模式的稳定收益非常明显。技能返回的数据结构是固定的,后续步骤不需要解析自然语言,每个环节的输入输出都能记录下来,哪里断了排查哪里。
6. 跑通Demo后会撞的四个坑以及补救方案
下面这些是我在真实项目中反复踩过的坑,每条都让我花过不少时间。写出来给你省点路。
6.1 技能返回数据太大,一波对话直接“撑爆”上下文
第一次让web_fetch返回整篇文章内容给模型,几千个token直接把上下文打满,后续问答质量急剧下降。
补救方案是在技能内部就做裁剪。web_fetch默认只返回前5000字符,并且额外输出一个truncated字段标记是否被截断。如果需要完整内容,由另一个“全文读取”技能显式处理。让“预处理尽可能多的逻辑下沉到技能内部”是一个通用原则,能用代码完成的绝不消耗模型的Token。
6.2 两个技能互相调用形成死循环
技能之间是有依赖关系的,比如summary内部可能又去调web_fetch,如果链路设计不谨慎,就会出现A调B、B又调A的情况。模型甚至会因为“看到上一步用了某个技能”而反复调用它。
我在调度器里加了一个直接的防呆设计:每个任务记录已调用技能清单,同一个技能最多调用3次;同时维护一个调用深度计数,超过10层立即熔断。不要指望模型自己有节制,需要靠运行时硬约束。
6.3 技能描述写得太抽象,模型选错技能
这个问题最隐蔽。我把技能描述写成“该技能用于获取和处理外部信息”,结果模型把它调用来处理本地文件,错误得很离谱。
之后我调整了写描述的方式,核心是“触发场景 + 边界范围 + 参数示例”。例如:
当用户要求获取远程页面的正文时调用该技能。 不适用于解析本地文件,不适用于总结归纳文本。 参数示例:{"url": "https://example.com/article"}把描述当成搜索引擎的索引页来写,而不是写功能总结。这样路由准确率提升非常明显。
6.4 只测试单个技能,组合链路全是问题
单个技能单独测全是好的,一旦串起来跑,参数传递、输出格式、时序问题都出来了。后来我给项目加了一层“任务级回归测试”:准备20个典型任务样本,每次改完技能代码就全量跑一遍,重点看整条链路是否走通。
以下几个指标是我每次都重点盯的,分享给你:
| 指标 | 含义 | 我目前的可接受线 |
|---|---|---|
| 首轮路由准确率 | 不经过重试,第一次就选对技能 | 80%以上 |
| 任务完成率 | 整条链路最终成功完成 | 90%以上 |
| 平均失败重试次数 | 出错后需要重试的次数 | 1次以内 |
| 单任务Context消耗 | 完整一次任务消耗的Token | 越少越好 |
首次改造agent-skills时,我的路由准确率只有不到60%,就是把描述按上面提到的方式重写之后才涨到接近85%。这个数字说明,技能库优化的杠杆大多藏在这些看起来不起眼的地方。
我自己的体会是,这类技能体系一旦跑顺,后续加新功能会轻松很多:新需求来了,先看有没有现成技能,没有就写一个模块并注册,老流程几乎不动。真正值钱的部分不是那几段调用代码,而是那些不起眼的元数据、边界条件和失败处理。希望这篇分享能让你在设计自己的Agent技能库时,少走半天的弯路。