最近在重构我们的智能体底层代码时,我彻底把原先那一大坨提示词工程拆了,换成了现在这套以“agent-skills”为核心的技能化组织方式。如果你也做过生产级的大模型智能体,一定懂那种痛:提示词越写越长、越写越复杂,稍微改一句就影响一大片场景,不同任务之间完全没有复用可言。而当我把“技能”这个概念真正落地成一套可注册、可加载、可编排的代码结构之后,整个系统的可维护性和稳定性都上了一个台阶。这篇博文不打算写教科书式的定义,我会以自己实际重构一个智能体技能库的经验为线索,把agent-skills从设计思路到代码实现、再到踩坑排查,完完整整给你捋一遍。适合正在做智能体应用、被提示词工程折磨得想骂人、又不想放弃沉淀复用能力的开发者和AI应用工程师。
1. 项目定位:agent-skills 到底在解决什么问题
1.1 从“堆提示词”到“攒技能”的转变
先讲个直观的例子。假设你让一个大模型智能体去完成“调研某行业的最新动态并输出周报”这个任务。传统做法是什么?把行业背景、输出格式、检索偏好、行文风格全部塞进一个一千多字的 system prompt,然后靠模型自己发挥。问题是这个提示词高度耦合,换一个行业或者调整一次输出粒度,就要动整个字符串,而且暴露给模型的内容太多,它反而不知道该重点抓什么,输出质量飘忽不定。
agent-skills 的思路是把这件事拆成独立的子能力:检索信息是一项技能、结构化落盘是一项技能、按固定模板写周报又是一项技能。每项技能通常由“描述 + 参数定义 + 执行逻辑 + 局部提示词”组成,注册到一个统一的技能库里。智能体在运行时会根据当前目标的语义,动态选择调用哪些技能,像搭积木一样拼出完整工作流。
我重构的时候体会最深的一点是:技能不是简单地把提示词分个段,而是给模型划分了清晰的能力边界。原来 system prompt 里所有内容对每个请求都生效,模型每轮都要处理大量冗余约束;而技能是被按需加载的,模型当前只需要关注最相关的那部分指令。这既省 token,又大幅降低上下文被无关信息干扰的概率。
1.2 技能与函数、工具调用之间的关系
很多读者听到 agent-skills 的第一反应是:这不就是 function calling 吗?我的回答是:部分重叠,但层次不同。函数工具描述的是“我提供一个接口,你能传参、拿结果”,而技能描述的是“我有一个可以完整完成的任务能力,里面可能包含多次工具调用、内部决策和独特的上下文”。
打个生活化的比方。函数调用相当于给你一本字典,你想查哪个字就翻到对应页;技能则是一个训练有素的助手,你吩咐它“帮我整理书架”,它会自己判断哪些书该归类、是按开本大小还是按类别排列、处理过程中遇到破损书脊该怎么登记。这个助手内部可能用了字典、可能自己拟了张表格、也可能边整理边调整策略,但对外暴露的就是一个高层的“整理书架”能力。
所以我在设计技能库时非常强调边界:底层才放原子工具,上层全部以技能形态存在。一个技能可以调用多个原子工具,也可以调度其他技能。这样智能体的调度逻辑就变得极简——它不需要关心每一步怎么调用函数,只需要按任务需求挑选技能组合,真正把“思考”和“执行”分离开来。
1.3 使用这套方案的收益
从我们自己的项目数据来看,改造后的系统在几个维度上提升非常明显:技能复用率超过七成、原先三小时才能调通的新增流程压缩到二十分钟以内、线上请求因“模型不按提示词输出”导致的失败率大幅降低。最让我满意的是,新成员接手项目时不需要再读那个几百行的 prompt 文件了,直接在技能目录里看描述就能理解系统具备哪些能力。对团队协作、对模型稳定性、对快速扩展,都是实打实的改善。
2. 技能库设计:先定好数据结构和存储规范
2.1 为什么技能不能只是一段字符串
最早我把技能设计成“名称 + prompt”的简单键值对,很快发现完全撑不住。真正跑起来你会发现,缺少结构化的参数定义,你没法在技能间做依赖校验;缺少输入输出约束,技能之间衔接的时候特别容易出现类型对不上的情况。举个实际例子:摘要技能输出的是一段 Markdown 文本,而归档技能期待的数据结构是带标题和 tag 的对象,两边没对齐就直接运行时爆炸。
所以我最后落地的技能 schema 包含这样几个核心字段:技能名称、任务描述、版本号、依赖项、输入参数的 JSON Schema、输出格式定义、以及执行时的局部提示词模板。其中任务描述不是给人看的文档,而是给模型看的调度依据。它需要写清楚“这项技能在什么情况下被选中、完成什么目标、不该做什么”,影响的是智能体顶层规划时的路由正确性。
输入输出定义我直接用了 JSON Schema 格式,好处有两个:一是天然适合做运行时校验,二是可以被大模型原生理解。像字段的枚举值、格式要求、默认值能让模型在调用技能前自行约束好生成参数,减少不必要的报错重试。输出定义我通常也鼓励枚举类型,配合上代码里的解析器,返回结果就变得非常可控。
2.2 一个可落地的目录结构
技能目录我习惯按“域(domain)”来组织,每一类业务场景占一个顶层文件夹,里面再放一个个独立技能子目录。这样做和代码模块化管理一样,避免所有技能堆在一起变成大杂烩。
agent-skills/ research/ web_search/ SKILL.yaml prompt.md execute.py extract_summary/ SKILL.yaml prompt.md execute.py report/ weekly_report/ SKILL.yaml prompt.md execute.py common/ format_with_toc/ SKILL.yaml prompt.md execute.pySKILL.yaml存元数据和参数定义,prompt.md放技能特有的提示词模板,execute.py放核心的执行逻辑。目录拆分得足够清晰之后,技能本身也能够被单独测试、单独发布、单独回滚。我们用了一段时间之后,又把技能目录直接做成了一个代码仓库,分支策略和发布流程就跟普通代码库一样走,非常顺。
2.3 用 YAML 定义技能元信息
直接给你看一个我常用的 SKILL.yaml 模板。这是研究域下的 web_search 技能,负责在接到检索需求时执行搜索并整理结果。
name: web_search version: 1.2.0 domain: research description: | 在需要获取外部实时信息、检索网页内容或佐证事实时使用。 执行搜索并返回结构化结果列表。如果用户的需求与实时数据无关,不要使用本技能。 dependencies: - serper_api - html_cleaner inputs: type: object properties: query: type: string description: 搜索关键词或自然语言查询。 num_results: type: integer default: 5 minimum: 1 maximum: 20 region: type: string enum: ["cn", "us", "jp"] default: "cn" required: ["query"] output: type: array items: type: object properties: title: {type: string} link: {type: string} snippet: {type: string}description字段要当作给模型看的路由说明书来写。项目初期我写得很马虎,都是“搜索并返回结果”,结果智能体在明明该用本地知识库回答的场景也去调搜索,既浪费时间又带偏答案。后来我把触发条件、排除条件都写清楚,路由准确率才提上来。这里有个建议:描述里尽量写“在什么情况下使用”和“什么情况下不要使用”,比你写“这是一个搜索工具”有效得多。
输入输出部分同样别糊弄。字段默认值、取值范围、枚举值写全了,模型在决策的时候可以少犯很多低级错误。我甚至遇到过模型把整数参数传成字符串的情况,最后是靠着 JSON Schema 的类型校验兜底才没让错误往下游蔓延。
2.4 版本管理与依赖关系
技能一旦多了,版本和依赖就是躲不开的坎。我们的方案比较朴素:每个技能目录是一个独立的 semver 版本号,技能之间只允许依赖固定版本范围,不允许依赖未发布的变动版本。每次修改技能时都要在变更记录里写明改动点和影响范围,代码评审时这步是强制卡点。
依赖管理我只做了一层,不允许技能之间出现循环依赖。之前设计过一个有点复杂的技能 A,它内部会调用技能 B 去提取主题标签,而技能 B 又想在结尾统一格式化摘要,跑去调了技能 A,结果运行时上下文来回套,把模型的上下文窗口都快撑爆了。后来加了个构建期的循环依赖检查脚本,再没出现过类似问题。
3. 核心实现:技能加载与执行机制
3.1 技能注册表:把元信息加载进内存
技能库的入口不能是每次调用都扫一遍硬盘,我采用进程内加载 + 缓存失效策略。服务启动时把全部 SKILL.yaml 解析成技能对象,注册进一个线程安全的注册表,供智能体路由和执行共用。
from pathlib import Path import yaml class SkillRegistry: def __init__(self, base_dir: str): self.base_dir = Path(base_dir) self._skills = {} def load_all(self): for yaml_file in self.base_dir.rglob("SKILL.yaml"): skill_meta = yaml.safe_load(yaml_file.read_text(encoding="utf-8")) skill_dir = yaml_file.parent skill = Skill( meta=skill_meta, prompt_template=(skill_dir / "prompt.md").read_text(encoding="utf-8"), executor=self._load_executor(skill_dir), ) self._skills[skill_meta["name"]] = skill self._assert_no_cycle() return self def get(self, name: str) -> "Skill": if name not in self._skills: raise SkillNotFoundError(name) return self._skills[name] def list_skill_descriptions(self) -> list[dict]: return [ {"name": s.meta["name"], "description": s.meta["description"]} for s in self._skills.values() ]executor我按 exec 函数或者接口类两种方式统一封装。如果技能简单到不需要额外代码,直接用内置的 PromptExecutor 调大模型返回 JSON;如果技能里包含搜索、代码解释、数据库查询等操作,在 execute.py 里写一个继承基础类的执行器。重要的是所有执行器对上层暴露一样的run(context, **params)调用范式,上层规划的收益才最大。
3.2 技能上下文的传递逻辑
技能执行时不是孤立运行的,它需要拿到智能体当前的任务上下文。我定义了一个 SkillContext 数据类,里面包含原始用户意图、已收集的信息片段、全局配置项、以及一组可用的基础工具句柄。
@dataclass class SkillContext: task_id: str original_query: str collected_memo: dict available_tools: dict max_tokens_limit: int = 4096这个 context 对象在每轮技能编排时被复制成浅拷贝传给具体技能。为什么要浅拷贝而不是共享同一份?因为不同技能之间经常需要隔离上下游写入,避免 A 技能误改了 B 技能写进 memo 的数据。执行完结后再把技能返回的输出合并进主上下文,让下一个技能能看到前一步的结果。
上下文传递中特别容易踩的坑是上下文越传越大。我曾见过一个检索技能把全文网页内容都塞进 memo,下一个技能又要重新读一遍大文本,token 消耗翻了好几倍。现在统一做法是:memo 只允许放结构化摘要和结果引用,原始长文本一律持久化到对象存储,memo 里只放对象路径。这条规则让我线上成本降了不少。
3.3 路由与编排:模型如何决定用哪个技能
技能的自动编排我走的是两段式方案。第一段是“意图路由”,把用户的原始请求连同注册表里的技能描述一起交给模型,让模型给出候选技能列表和顺序。第二段才是“执行推进”,每执行完一个技能,就把输出喂回给模型,由模型判断是否继续调用下一个技能,或者把结果输出给用户。
def route(registry: SkillRegistry, query: str) -> list[str]: descriptions = registry.list_skill_descriptions() prompt = f""" 你是一个技能调度器。根据用户目标和以下技能列表,选择需要依次执行的技能名称。 只返回 JSON 数组,不要额外解释。 技能列表:{descriptions} 用户目标:{query} """ response = llm.chat(prompt, response_format={"type": "json"}) return json.loads(response)路由的核心在于技能描述写得好不好。描述不清楚,再聪明的模型也排不出靠谱的流程。很多项目到这一步喜欢上各种复杂的状态机,我反而劝你先让模型自由编排,跑一段时间积累足够样本,再把稳定路径固化成显式流程。过早固定流程会牺牲灵活性,运行时会碰到各种没预料到的边角情况。
3.4 错误处理与回退策略
技能执行的真功夫全在异常处理上。我的原则是:能自动回退的绝对不直接失败。比如搜索技能调用第三方 API 超时,回退逻辑可以是先缩小查询范围重试一次,再不行就用上一轮缓存结果,最后才抛错。每层回退都记录日志,方便事后分析哪一层服务不稳定。
回退逻辑直接写在执行器内部,比放在外层编排里更合适。因为只有技能自己最清楚哪类错误是可重试的、哪类错误是参数问题无需再试。我还给每个技能加了“失败原因摘要”的输出通道,让智能体在技能失败时能拿到简短说明,从而调整后续策略而不是盲目报错。
4. 实战案例:给智能体搭建一个“研究摘要”技能链
4.1 任务场景与整体拆解
假设我们要让智能体完成这样一个任务:给定一篇长文章 URL,提取正文、生成摘要、拆解核心观点、并且输出一份 Markdown 格式的研究笔记。如果不用技能化方案,你大概会写一个又臭又长的系统提示词,让模型自己处理下载、清洗、总结、整理结构。用技能化方案,我会拆成三个技能:web_fetch_article、summarize_content、build_research_note。
拆技能的依据是能力单一、输入输出边界清晰。web_fetch_article只负责抓网页并返回清洗后的正文和标题;summarize_content不管长文本从哪来,只负责产出摘要和观点列表;build_research_note把前面的结果按照研究笔记模板重排。三者职责单一,后续任意一个环节要升级都不会牵动另两个。
4.2 第一个技能:抓取与清洗
web_fetch_article的实现里最核心的是两手功夫:一是用 HTTP 客户端抓原始 HTML,二是用内容提取算法把正文从导航、广告、评论等噪声中分离出来。这里我不展开算法细节,只强调一点:正文提取结果必须统一成干净的文本块,附带元信息,否则下游摘要技能会读入大量垃圾内容。
class FetchArticleExecutor(BaseExecutor): def run(self, ctx: SkillContext, url: str) -> dict: html = ctx.available_tools["http"].get(url, timeout=10) article = extract_main_content(html, url=url) return {"title": article.title, "content": article.text}这个技能踩过的坑主要是超长正文。有些文章正文几万字,直接传给摘要模型,不仅 token 超额,模型还会丢失开头信息。后来我在抓取和摘要之间加了一个预处理步骤:如果正文超过一万二千字,先按段落做递归截断,优先保留包含关键词和标题结构的段落。别小看这个处理,它能显著提升下游摘要的忠实度。
4.3 第二个技能:摘要与观点抽取
summarize_content技能的输入是前一个技能产出的标题和正文,输出是一个 JSON 对象,包含一句话概括、三段要点、以及三个可延伸讨论的问题。为了稳定拿到 JSON,我在 prompt 模板里给出了明确的输出示例,并且在执行器里做了一层 JSON 解析兜底:模型偶尔会在 JSON 前后追加解释性文字,解析失败时就用正则提取花括号片段再二次解析。
class SummarizeExecutor(BaseExecutor): def run(self, ctx: SkillContext, content: str, title: str) -> dict: prompt = self.render_prompt(title=title, content=content) raw_output = ctx.available_tools["llm"].chat(prompt) return robust_json_loads(raw_output)描述信息仍然很关键。SKILL.yaml里我会注明这个技能接受的正文应该在清洗后状态,最大长度约一万字,如果超过要先截断。模型看到这个限制,就会在调用摘要技能之前主动考虑需不需要先做内容压缩,进一步提升了链路的稳定性。
4.4 第三个技能:组织成研究笔记
build_research_note是个相对轻的技能,它把标题、摘要、要点、延伸问题整合成一份 Markdown 研究笔记。核心逻辑并不复杂,但它代表了技能化架构的典型收益:我可以随时替换这个技能,比如改成生成幻灯片大纲,或者改成生成邮件简报,而不必改动摘要技能的代码。
模板我用 Jinja2 写在 prompt.md 同目录下,执行器把参数填进去后交给模型做文本润色和格式统一。这个阶段使用模型不是为了生成新内容,而是为了让行文更自然、把要点之间的过渡补顺。对模型能力要求不高,所以这里我会选用更轻量、更便宜的小模型,进一步节省成本。
4.5 端到端串联效果
三个技能都注册到 registry 后,顶层只做一次路由调用。模型看到用户发来的链接和“帮我整理一份研究笔记”的诉求,会根据技能描述给出顺序列表:web_fetch_article、summarize_content、build_research_note。执行器依次跑完,返回最终 Markdown。整个过程有清晰的日志轨迹,每个技能耗时、输入输出大小都能追踪。调试问题的时候不再是对着一大段 prompt 猜测模型意图,而是看哪一步技能结果不符合预期就行了。
这套结构上线后,我们针对它做了回归测试集,包含 URL 失效、正文超长、摘要返回 JSON 解析失败、模板渲染缺字段等场景。这些测试跑一次大概两分钟,却实打实保证了每次技能改动时其他链路不被破坏。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 模型完全不调用某个技能 | 技能描述里的触发条件写得模糊 | 查看路由日志,确认描述被注册表正确加载,尝试在描述里补充“什么时候不要用”的排除句式 |
| 技能执行返回格式无法解析 | 输出定义不够严格,或模型在 JSON 前后添加了噪音 | 在 executor 里做鲁棒解析,同时检查输出 schema 是否枚举了所有可能字段 |
| 技能之间的依赖数据丢失 | 上下文对象在不同技能间传递时被误修改 | 排查是否用了浅拷贝,检查是否有技能错误覆写了 memo 里其他技能写入的数据 |
| 同一技能在相似场景表现不一致 | 技能加载时包含无关上下文,导致模型判断漂移 | 检查 prompt 模板是否依赖了变化过大的输入,或者输入字段是否标准化不足 |
| 技能库加载缓慢 | 注册表启动时执行了过长初始化逻辑 | 将技能执行器的初始化改为惰性加载,只有首次被调用时才实例化 |
5.2 一次让我印象深刻的线上事故
某个晚上收到告警,智能体的“竞品信息收集”流程成功率骤降到六成。我打开日志一看,问题出在web_search技能上。那天搜索 API 服务商调整了返回结构,原来是数组字段的results变成了嵌套对象。第一层报错被 executor 内建的重试机制遮住,重试无效后才暴露出来。
由于技能边界清晰,我定位速度很快。但这也暴露出一个问题:我们对上游服务的返回结构缺少主动校验。修复方法是给web_search的输出层增加了一个轻量校验器,如果检测结果结构与预期不符,立刻降级到本地备用数据源。那次之后我养成了一个习惯:任何对接外部接口的技能,都必须标配一个有界缓存 + 降级策略,不能把宝全押在一个服务商身上。
5.3 排查技能问题的通用三步法
第一步,先看技能路由是否选对了。路由日志会记录模型挑选了哪些技能,顺序是什么,如果技能选择就不对,问题一定出在描述文案上,不是执行逻辑上。第二步,看技能输入是否符合预期。重点确认传入参数是否被正确的技能间上下文规整过,比如 URL 是不是走了完整清洗流程。第三步,看输出是否可被下游消费。很多人习惯只看当前技能成功了没,忽略了下一个技能拿到的数据是否满足它的 schema,这种跨界错误往往最隐晦。
按这个三步法排查,百分之八十以上的技能链路故障都能在十分钟内定位。剩下一部分跑不掉的,基本都是 prompt 模板里隐含的指令冲突,需要特意组织对抗性用例来重现场景才能发现。
5.4 关于测试的独家经验
技能测试不能只测“正常路径”。我建议每个技能至少要有一组 fail-case 测试:API 超时、返回空数据、加载到异常字符、参数类型不合法、极端长度文本、模板字段缺失。这些测试不用都跑完整模型调用,大部分可以在 executor 单元内部用 mock 对象跑通,速度快还省钱。
另外,维护一份“技能变更影响图”非常有用。每次改动某个底层技能,跑一遍受影响的调用链条测试,能避免很多昨天还能用今天突然串线的尴尬。我已经把这套测试固化成 CI 的一个步骤,任何技能改动合入主干之前都必须通过全部关联用例,否则直接拦截。
最后再分享一点实际心得
从我决定把整个智能体从提示词工程重构为 agent-skills 技能库到现在,最深的感触是:精力被从“不断调咒语”中解放了出来,转移到了“能力沉淀”上。以前每接一个新场景,我都得焦虑模型会不会漏掉某个格式细节;现在只需要看看技能库里有没有现成技能,没有就补一个,有就做组合编排。对团队来说,这种模式也让经验有了可积累的载体。每次修复线上问题时沉淀下来的不是“又改了一句 prompt”,而是一段经过测试、可复用的技能版本。如果你正在做智能体应用,并且开始感觉到提示词越来越难以驾驭,我一定建议你尽早尝试这种思路。从一个小技能开始拆起,不用一次性重构完,先让系统跑起来再逐步迁移,你会很快感受到技能化带来的那种“稳”的体验。