大概半年前,我接手维护一个智能体项目,系统提示词堆到了 6000 字。每次请求光把这坨规则塞进模型就要吃掉大量上下文,日常请求稳定在 1.2 万 token 左右,延迟三秒起步,回答还经常自相矛盾——旧的规则被新的规则覆盖,两条规则直接打架。就是那个阶段,我开始认真研究和落地 agent-skills 这套技能体系。
说实话,那段经历彻底改变了我对 Agent 架构的看法:智能体的上限从来不取决于模型本身,而取决于你赋予它的技能边界,以及这些技能的组织方式。所谓 agent-skills,简单讲就是把"让智能体具备某种能力"这件事,从一大段不可复用的提示词和一堆散落的工具函数,拆成一个一个声明式、可测试、可编排的独立技能单元。每个技能自带说明书(输入输出契约)、自带实现、自带测试用例。主模型只负责理解用户意图、选择正确技能,不再需要把全部领域知识都塞进上下文里。
这篇内容是我从零设计技能格式、写运行时调度器、落地三个实战技能、再到技能版本管理和效果评测的完整复盘。中间会有踩坑记录,也有我最后沉淀下来的几条规则。如果你正在做 Agent 项目,或者正被"提示词越改越长、工具越来越多、系统越来越脆"的问题困扰,这篇应该能给你一套可以直接上手的思路。
1. 从"堆提示词"到"拆技能":我为什么彻底重构了这套架构
1.1 提示词膨胀的失控现场
项目最开始只是个聊天助手,系统提示词只有两段话:你是助理,保持简洁,有不会的就说不会。这个阶段表现意外地好,因为模型有充分的自由度去组织回答。后来业务方开始加需求:文本润色、信息抽取、数据预处理、每日复盘、周报生成……每一个需求我们都往系统提示词里追加一段规则。
三个月后,系统提示词到了 6000 字。我印象最深的一次异常是,模型突然开始在一份普通邮件里"提取用户意图并补全缺失字段",因为"补全缺失字段"这条规则和"不要无中生有"产生了隐性冲突。再往后,同一个评测集上的准确率从最初的 89% 掉到了 71%。不是模型变笨了,而是所有知识被塞进同一个线性上下文里,模型每次生成都要重读全部规则,注意力被持续稀释,冲突规则之间的"优先级"没人定义,模型只能自己猜。
这个阶段我得到的第一个教训是:提示词不是不能加,而是当能力数量超过某个阈值,线性堆叠必然失控。你会陷入一种恶性循环——出问题就加提示词修补,加了提示词引入新的冲突,再继续加提示词。任何试图靠"更长的系统提示词"解决 Agent 能力扩展的方案,都是在给下一次故障埋雷。
1.2 直调工具的另一个极端
发现问题后,我当时的第一反应是转用函数调用(function calling)方式,把能力拆成一个个工具函数。这个方向理论上是对的,功能确实解耦了,但实践下来又冒出新问题。
工具列表很快就膨胀到三十多个。每次请求,模型为了选择正确的工具,必须把所有工具的描述和参数 Schema 都读一遍,这部分开销单次请求算下来稳定在 2500 到 4000 token,比干活本身还贵。更麻烦的是,工具与工具之间没法互相调用:我想让"周报生成"工具先调用"数据聚合"再调用"文本总结",只能把这些逻辑全部塞进同一个函数里,函数之间开始互相 import,最后又变成了一个大泥球。
工具直解让我意识到:解耦只是第一步,你还需要一套机制来管理工具之间的依赖、协作和调度。纯工具列表是"平铺的菜单",而 Agent 真正需要的是一套"能点菜的流程"。
1.3 Skills 方案的核心思路
agent-skills 最终采用的思路可以概括成一句话:把能力封装成技能单元,主模型只做意图理解和技能选择,技能内部负责完整执行。
一个技能单元包含三样东西:技能声明(描述自己在什么场景下被触发、需要什么参数、返回什么结构)、技能实现(具体的执行逻辑,可以调用外部 API、可以操作数据、可以嵌套调用别的技能)、技能测试(一组用例,用来验证声明和实现的一致性)。主模型拿到用户请求后,不是直接"写答案",而是先判断"这件事应该交给哪个技能",然后把控制权转移给技能。
我用一个比较俗的类比来理解这套结构:以前你开公司,要求总经理一个人记住全公司所有人的岗位职责,公司人少还好,几百人之后必然乱。Skills 的做法是给每个部门做一套标准作业程序(SOP)手册,总经理只需要知道"这件事找哪个部门",具体怎么执行,由部门内部解决。主模型是总经理,技能就是部门。
2. Skill 的本质:一个自带完整说明书的能力封装单元
2.1 契约先行:输入、输出、副作用
设计技能系统时,我踩过最大的坑就是一开始没做契约约束,技能定义得很随意,结果运行时各种意外:返回值结构不统一、参数校验靠猜、异常处理到处散落。后来我强制每个技能必须实现三份契约,这套设计后来成了整个系统的地基。
第一份是输入契约,规定这个技能接受什么参数、参数类型、是否必填、取值范围。比如"网页正文抽取"技能接收 url 字符串,协议必须是 http 或 https,超时时间默认 10 秒。第二份是输出契约,规定返回值里的结构:success 字段、data 字段、error 字段,错误码统一风格。第三份是副作用契约,声明这个技能会不会修改外部数据、会不会调用付费 API、会不会耗时超过阈值。
副作用契约是我后来才意识到必须加的。有一次一个"批量发送消息"的技能因为没有声明副作用,被调度器当成普通技能反复调用,差点把消息队列打爆。从那以后,任何有副作用的技能在声明里必须有side_effects: true,调度器遇到这类技能会强制走确认流程,还会在调用链路上打标。
2.2 触发判定与注册表
技能不是靠"感觉"被调用的,它需要一个注册表机制。我这里的做法是:系统启动时扫描技能目录,读取每个技能的 skill.json 声明文件,构建一张技能注册表;请求进来时,调度器根据注册表做匹配和排序。
匹配策略我试过三种。关键词匹配最稳定,但召回率低,用户换个说法就匹配不上;语义匹配(embedding 相似度)召回好,但偶尔会选中一个"听起来相关、实际没有对应能力"的技能;最后的解法是两层结合:先用语义匹配筛出 Top 5,再在主模型的回复中显式引用技能 ID 做最终确认。这样既有召回率,又有最终把关,实测下来误召率降到 3% 以内。
注册表里还有一个容易被忽略的字段:priority。当用户请求可以匹配多个技能时,优先级决定默认选择顺序。比如"把今天的工作整理成日报"这句话,既可以触发"数据聚合"技能,也可以触发"日报生成"技能。我设置的原则是:越具体的技能优先级越高,日报生成优先级高于数据聚合,因为日报生成内部会自己调数据聚合,不会浪费一次额外调度。
2.3 上下文与技能隔离
技能系统另一个绕不开的问题,是上下文污染。如果不做隔离,技能产生的中间结果、调试日志、临时变量全部回流到主对话里,几千 token 的小事,最后能膨胀成几万 token 的大包。
我做了一套三级上下文隔离策略。主对话只保留用户请求、当前技能选择记录、技能返回的最终摘要。技能执行过程中产生的完整中间数据存放在独立的技能工作区,由技能自己决定哪些内容需要"回填"到主对话。回填的数据必须先过一道裁剪规则:超过 200 token 的内容自动生成摘要,原始结果另存。
隔离带来一个直接的好处:技能可以并行执行。多个技能各有各的工作区,互不干扰,调度器甚至可以在用户确认后并行调用三四个技能,最后统一汇总结果。这在旧的"所有逻辑都塞进主对话"的架构里是做不到的。
3. 手写一套 Skills 运行时:目录结构、声明协议与调度器实现
3.1 技能目录结构设计
先给出一套我实际在用的目录结构,它直接在项目根目录下运行:
agent-skills/ ├── registry.py # 技能注册表加载逻辑 ├── dispatcher.py # 调度器核心 ├── skills/ │ ├── web_fetch/ │ │ ├── skill.json # 技能声明 │ │ ├── main.py # 技能实现 │ │ └── tests/ │ │ ├── case_001.json │ │ └── case_002.json │ ├── data_clean/ │ │ ├── skill.json │ │ ├── main.py │ │ └── tests/ │ └── daily_report/ │ ├── skill.json │ ├── main.py │ └── tests/ └── runner.py # 技能执行入口每个技能一个目录,三件套固定:声明文件、实现文件、测试目录。这套结构的优点是:新增技能就是一个新目录,不需要改动调度器任何代码;测试用例跟着技能走,回归成本低;目录本身就是技能的命名空间,不会出现同名冲突。
3.2 技能声明 Schema 的完整定义
skill.json 是技能系统的核心,字段冗余一点没关系,缺了会出大事。下面是我简化后的模板:
{ "name": "web_fetch", "version": "1.0.0", "description": "抓取指定网页并抽取正文文本。适用于用户提供 URL 并要求总结、翻译、提取信息的场景。", "tags": ["fetch", "web"], "trigger_keywords": ["网页", "链接", "url", "抓取", "正文"], "priority": 50, "side_effects": false, "permission": "network", "timeout_seconds": 15, "parameters": { "type": "object", "required": ["url"], "properties": { "url": { "type": "string", "format": "uri", "description": "目标网页链接,仅支持 http/https 协议" }, "max_chars": { "type": "integer", "default": 5000, "maximum": 20000 } } }, "output_schema": { "type": "object", "properties": { "title": { "type": "string" }, "content": { "type": "string" }, "content_length": { "type": "integer" } } } }description 字段是整个声明里最重要的部分,它直接决定调度器能否让主模型做出正确选择。我总结的写法规则是:动词开头,点明输入和输出,列出典型触发场景,最后写约束。比如"抓取指定网页并抽取正文文本。适用于用户提供 URL 并要求总结、翻译、提取信息的场景",比"网页抓取工具"这种描述有效得多。
3.3 调度器的核心逻辑
调度器我用 Python 实现,核心流程不复杂,大概两百行。关键代码骨架如下:
class Dispatcher: def __init__(self): self.registry = load_all_skill_declarations() def _match_candidates(self, user_request: str) -> list: embedding = embed(user_request) scored = [] for skill in self.registry.values(): keyword_hit = any(k in user_request for k in skill["trigger_keywords"]) semantic_score = cosine(embedding, skill["embedding"]) scored.append((skill, keyword_hit, semantic_score)) # 语义得分 Top5 + 关键词命中优先 ranked = sorted(scored, key=lambda x: (x[1], x[2]), reverse=True) return [s for s, _, _ in ranked[:5]] async def dispatch(self, user_request: str): candidates = self._match_candidates(user_request) # 让主模型在候选技能里做最终选择,返回技能 ID 和参数 chosen = await llm_select_skill(candidates, user_request) if chosen is None: return await self._fallback(user_request) return await execute_skill(chosen.skill_id, chosen.arguments)执行技能时有一个容易忽略的细节:参数填充不一定非主模型来做。对稳定、参数少的技能,可以启用"免模型直调模式"——用户请求里包含明确关键词且参数可直接提取时,调度器直接用正则或规则填参数。比如用户说"抓一下 https://example.com 的正文",url 参数明摆着,没必要让模型再判断一遍,既省 token 又降延迟。实测下来,这类技能占所有调用量的三成,但延迟只有模型路径的 30%。
4. 三个实战 Skill 拆解:从需求到落地
4.1 网页正文抽取 Skill
第一个实战技能是 web_fetch,需求来自用户频繁要求"总结一下这个链接"。这个技能我一开始低估了难度,以为就是 requests 拿 HTML 再正则剥一下,实际写起来发现至少要处理三件事:编码探测、正文提取、站点反爬。
编码这块踩了个典型坑:直接按 utf-8 解码导致大量中文站点乱码。后来改为先用头部 charset 判断,再用 chardet 兜底,最后用内容里的 meta 声明修正,顺序不能反,实测乱码率从 23% 降到 2%。正文提取我用了一套轻量规则:去掉 script、style、nav 等标签,按段落密度和文本长度做聚类,取文本密度最大的连续区域作为正文。
def extract_main_content(html: str) -> str: soup = BeautifulSoup(html, "html.parser") for tag in soup(["script", "style", "nav", "header", "footer"]): tag.decompose() # 简化版:按文本密度取主区块 best = None best_score = 0 for p in soup.find_all("p"): score = len(p.get_text(strip=True)) if score > best_score: best_score = score best = p.get_text(strip=True) return best or ""反爬这块我没有做强攻,只做了最基础的伪装 UA,并在声明里明确要求外部传入的 URL 必须来自用户自身授权范围,同时加了频率限制(同一域名每分钟最多 10 次)。这是我反复权衡后的选择:技能系统的目标是通用能力,不是对抗防护,过度设计反而会引入稳定性和合规风险。
4.2 结构化数据清洗 Skill
第二个技能是 data_clean,用来处理用户丢过来的一堆杂乱文本,期望输出结构化的表格数据。这个技能是典型的"提示词也能做,但又不适合完全交给提示词"的任务。
实现逻辑是:接收原始文本和期望字段列表,先调用一次模型做粗糙抽取,再做一轮规则化修正。关键点是,模型的粗糙抽取结果不直接返回值,要过一层 schema 校验。比如字段是日期格式,规则层检查是否为 YYYY-MM-DD,不是就尝试用 dateutil 解析,解析失败再回退给模型重新抽取。这套"模型粗抽 + 规则精修"的搭配,准确率比纯模型输出高了差不多 9 个百分点。
输出契约里我额外加了一个字段confidence,表示这次清洗结果的可信度。它是规则命中率的加权结果。低于阈值的结果返回时带上警告 flag,前端展示时可以提示"该数据需人工复核"。这个字段后来被证明特别有用,因为很多脏数据的正确结果本来就不是唯一的,让下游明确知道可信度,比强行给一个自信满满的错误结果要好得多。
4.3 多技能编排:日报生成流水线
第三个实战是把技能串起来。daily_report 这个技能内部不直接干活,它编排了两个子技能:data_clean(清洗日报需要的数字)、web_fetch(抓取需要引用的外部信息),最后自己调用模型做总结性生成。
此时前面的设计红利就体现出来了。因为每个技能都有独立声明,编排时我只需要声明依赖关系:daily_report 依赖 data_clean 和 web_fetch。依赖之间没有共享的可变状态,data_clean 的输出直接作为 daily_report 的上下文传入,链路清晰,单测时可以分别 mock 子技能,方便得多。
{ "name": "daily_report", "version": "1.1.0", "dependencies": ["data_clean", "web_fetch"], "description": "生成每日工作报告,自动汇总业务数据与外部参考信息。", "side_effects": false, "priority": 90 }这个技能是整套系统里我第一个按"声明依赖"而非"硬编码调用"的方式实现的。好处是,如果后续换了更好的数据清洗实现,我只需要升级 data_clean 技能本身,daily_report 一行代码不用动。技能的复用价值到这步才算真正体现出来,它不再是一次性的函数,而是可以在不同业务场景下自由组合的乐高积木。
5. 技能管理的地基:版本、评测与安全边界
5.1 版本管理与契约变更
技能运行久了,一定会有升级。但技能升级和普通代码升级不一样,它涉及的是模型在运行时动态选择的能力单元,契约变更如果没有版本意识,会出现"声明里写的参数和新实现对不上"的灾难。
我采用语义化版本规则:主版本号变更表示声明文件里的契约有破坏性变化,比如必填参数变了、返回结构改了;次版本号变更表示能力增强但契约兼容;补丁号表示 bug 修复和行为修正。技能注册表加载时会做版本预检,比如新版本要求的参数集合和旧版本不兼容时,会给出警告并保留旧版本实现 30 天,供调用方平滑迁移。
这里有一个反直觉经验:不要为了"清爽"直接删除旧版本。我删过一次旧版清理"技术债",结果第二天一个依赖旧版输出格式的线上流程直接报错。后来才明白,技能的调用方不仅有主模型,还有其他技能,隐性依赖无处不在。留过渡期,打弃用日志,比一时干净重要得多。
5.2 效果评测基准
没有评测的技能系统,就是盲人摸象。我给每个技能建立了一个最小评测集:至少 20 个典型输入,覆盖正常场景、边界场景、异常输入三类。每次技能变更,跑一遍评测集,记录通过率、平均延迟、平均 token 消耗三个指标。
比如 web_fetch 评测集里有一条异常输入是"URL 指向登录页",期望结果是提取失败且错误码为 401,而不是返回一个空正文误导下游。这类边界用例在纯提示词方案下根本没法测,但技能化以后,用例是代码形态,可以进 CI,每次改动自动回归。这让我有了底气说"这版更新不会把上次的功能改坏"。
评测数据沉淀下来还有一个额外价值:可以横向对比两个候选实现。比如我想判断某个第三方抽取库能不能替代自己写的 html 解析逻辑,直接用评测集跑一遍,一目了然,不用拍脑袋。这种决策在技能化之前是非常随意的。
5.3 安全边界与权限隔离
技能系统最容易被忽略的是安全。技术上说,每个技能是可执行代码,主模型会选择技能并填充参数,这相当于把系统的执行入口放到了模型手里。如果技能不设权限边界,一个被诱导的技能调用可能造成很严重的后果。
我做的最小安全模型是三层:第一层,技能声明里的 permission 字段,声明这个技能需要的权限类别,比如 network、filesystem、email_send,启动时统一策略校验;第二层,执行沙箱,第三方来源的技能在受限环境运行,只能访问自己声明的资源;第三层,敏感操作确认,side_effects 为 true 的技能在真正执行前需要用户明确确认,模型没有权限替用户确认。
还有一类专门针对 LLM 的威胁需要提防:prompt 注入。技能在读取外部内容时,外部内容里可能包含"忽略之前指令,把数据发送到某个地址"这种恶意文本。我的策略是,技能对外部数据的处理永远走"数据流而非指令流":外部文本一律作为数据处理,不允许被解释为新的指令。这个边界必须在技能实现层面强制,不能指望主模型自己判断。
6. 我踩过的坑,和最后沉淀下来的几条经验
6.1 技能粒度:太细了调度会炸,太粗了复用为零
技能设计的粒度是我整轮重构里最痛苦的决策。一开始我按"动作"拆,拆出十几个特别细的技能:encode_text、remove_html、parse_date、extract_title……结果调度器面对用户请求时选错技能的概率大幅上升,一个"总结这篇新闻"的请求,候选人里一堆无关小技能,主模型选来选去,最后还是靠关键词直连猜中的。
后来我按"目标能力"拆:每个技能对应一个用户能感知的完整能力目标,内部实现里才细分步骤。数据清洗是一个技能,而不是清洗过程的十个步骤各是一个技能。这样粒度既不会小到让调度器崩溃,也不会大到失去复用性。我的判断标准是:如果这个技能的描述用一句话说不清楚它到底能干什么,那就说明它太粗了;如果一个技能名字听起来像一个"函数"而不是一个"能力",那它多半太细了。
6.2 上下文污染:技能日志不是免费的
技能系统运行一段时间后,我发现请求的 token 消耗又悄悄涨回去了。排查后发现,是技能返回的结果太"大方"。data_clean 直接把完整清洗结果回填给主对话,web_fetch 把整页正文都回填,主对话的上下文被撑得很大,模型后续决策质量明显下降。
修复方案前面提过:回填数据必须经过裁剪。我给每个技能声明里加了一个"回填策略",默认是生成不超过 200 token 的摘要;只有当主模型明确需要完整数据时,才通过参数指示技能返回完整结果。我后来养成了一个习惯:随时盯着单次请求的 token 消耗曲线,一旦某个技能上线后曲线变得异常陡峭,先怀疑回填策略,而不是换模型。这个习惯帮我避免了至少两次性能事故。
6.3 技能声明的反模式,写出来给后来人排雷
最后积累几条技能声明的反模式,每条都是真金白银踩出来的:
- 描述里写"智能"但不写"边界"。比如"智能处理用户输入"这种话,模型不知道什么时候该调用,也不敢不调用,结果随机触发。正确写法是给出场景、输入输出和限制条件。
- 参数定义不加校验规则。url 不限定协议、date 不限定格式,技能里就得写一堆防御代码。把防御规则放进声明,靠 JSON Schema 校验,主模型能提前避开大部分无效调用。
- 忽略优先级。全部技能 priority 默认相同,等于没有优先级,候选人排序名存实亡。
- 忽视副作用声明。不要侥幸觉得"就一个通知功能不会有问题",凡是有副作用的技能必须显式声明,这是红线。
这套 agent-skills 体系跑到现在,主对话上下文稳定控制在 3K token 以内,技能执行占用的额外 token 平均 800 左右,问题响应延迟降了约 40%,评测集通过率维持在 95% 以上。最让我感慨的是架构的可持续性:新增一个能力,不再是往系统提示词里再塞一段话,而是新建一个目录、写一份声明、加一组测试,然后收工。
我现在的习惯是,任何需求进来先问一句:它能不能抽象成一个技能?如果能,就按技能的标准去做声明和测试;如果不能,那这个需求本身可能还没想清楚,我会先拒绝动手,模式识别比闷头执行重要得多。这就是我从 agent-skills 这个项目里得到的最实际的一条收获。