
大概半年前我接手维护一个智能体项目系统提示词堆到了 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, keylambda x: (x[1], x[2]), reverseTrue) 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(stripTrue)) if score best_score: best_score score best p.get_text(stripTrue) 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 这个项目里得到的最实际的一条收获。