2026/10/8 5:37:37

AI Agent技能模块化:从提示词堆砌到可复用Skill的完整实践

AI Agent技能模块化:从提示词堆砌到可复用Skill的完整实践 1. 从零散提示词到可复用的Skill为什么技能模块化如此重要这些年我上手过不少AI Agent类的项目有一个感受特别明显很多人一开始写Agent都是把一大堆提示词塞进System Prompt里比如你是一个项目管理助手请帮我整理待办事项请注意输出格式请记得先分析优先级请……——开头两三天觉得挺灵活可一旦任务种类变多、调用链变长这套做法立刻就崩了。崩的原因其实很朴素提示词是线性的而业务技能是树状的。你要让Agent同时具备写周报整理会议纪要扫描代码里的敏感信息生成发布说明这几种能力把它们全堆进一个Prompt里不仅上下文被大量冗余文字占据而且模型在意图判断上会越来越模糊——它不知道当前该让位给哪个技能。更麻烦的是你想单独升级其中某个技能的提示词或校验逻辑只能整段替换Prompt改一处动全身出一行错换整个上下文。所以我在2025年中旬开始把项目里所有可复用的功能全部抽成独立的Skills模块。所谓Skill说通俗一点就是让Agent拥有一个工具箱里的独立工具每个箱子有名字、有说明、有明确的输入输出接口还有内部的一段逻辑。Agent本身只需要一个轻量的大脑在遇到任务时决定该调用哪一个箱子具体怎么干活箱子内部自己处理。这套思路可能比单独优化Prompt更值得投入时间因为它的收益是结构性的上下文变短了、职责边界清晰了、每次技能的修改范围缩小了、测试变成单元级了。如果你正在做的AI工具把功能点写成了几十条的行为准则或是经常遇到提示词越加越多但效果越改越差的情况我的建议很直接——开始把能力拆成Skill。这篇文章就把我这套拆分、实现、避坑的完整过程摊开来说适合那些已经跑通一个简单Agent、正准备让它真正承担复杂工作的人。2. 一次完整的Skill调用究竟做了什么运行链路与数据结构在动手写Skill之前得先搞清楚一件事Agent调一个Skill底层到底发生了哪几步。很多人以为Skill就是多一个函数其实它横跨了三层识别层、编排层、执行层。我把整条链路拆给你看同时附上真实项目里的数据结构设计。2.1 五个关键环节从请求到结果回填一次Skill调用在我目前的项目里走的是这条路径意图识别用户输入进来编排层也就是Agent的主循环判断当前子任务是否匹配某个Skill的职责描述。这个匹配可以是模型判断也可以是规则匹配比如用户消息里出现了某个关键词实际项目中通常两种结合。参数解析从用户输入、上下文、上游节点输出中提取Skill所需的入参。这里的参数必须严格按照Skill的输入Schema来我一般要求Agent在调用前先输出一个JSON类型的参数对象再交给执行器。权限与状态检查检查Skill有没有执行前置条件比如是否依赖某个外部API的Key、是否要求工作目录里存在某个文件。这个环节经常被省略但真正上线之后它比你想的重要。技能执行Skill本体运行。它可以是纯代码函数也可以是一个包含Prompt模板和少量脚本的组合体可以是单次调用也可以内部循环多次调用模型。结果回填执行结果按输出Schema规范化后交还给编排层。这里的规范化很关键因为Agent下一步决策需要看到结构化结果而不是一段充满废话的原始文本。链路本身并不复杂但每个环节都有对应的数据结构需要定义清楚否则Skill一多就开始互相踩踏。2.2 Skill的Manifest一个可被Agent读懂的身份证我给每个Skill都配备了一个Manifest清单文件本质上就是结构化元信息。这在我的项目里放在每个Skill目录下的SKILL.md内容包括字段作用示例值nameSkill的唯一标识调用时使用git_commit_fetcherdescription一句话描述职责供Agent做意图匹配获取指定Git仓库最近的提交记录applicable_scene使用场景的细粒度描述当用户要求查看提交历史、或统计某时间段代码变动时inputs入参列表包含名称、类型、必填性、说明repo_url,since_date,until_dateoutputs出参结构说明commit_list: [{hash, author, date, message}]dependencies依赖清单git,requestsfailure_hints失败时的常见原因与处理建议仓库不存在时返回ERROR_REPO_NOT_FOUND这个Manifest有几个隐性但极其重要的用途首先Agent不需要看Skill内部实现就能决定是否调用它这保证了轻量大脑的高效其次多层决策时可以在上层做一次Manifest过滤减少模型需要评估的Skill数量最后技能升级时只需要保证Manifest稳定内部逻辑随便改对上层完全透明。2.3 为什么说上下文压缩是Skill系统最值钱的地方我要特别强调一个常常被忽视的设计目标Skill系统存在的首要价值不是让Agent能干活而是让Agent在干活时不用背着一大堆说明文件跑来跑去。举例来说一个角色设定类的长Skill——比如代码评审专家——如果它的行为准则有三千字你不把它封装起来的话这三千字就要常驻上下文每个轮次都在消耗Token而且还会挤掉用户对话的空间。封装成Skill之后Agent每次只需要看到一行描述执行代码评审输入是MR链接输出是问题列表真正需要细节时再加载内部Prompt和规则用完即走。这一步平均能在我的项目里省掉35%到50%的Token开销连带着决策质量也上去了因为上下文噪声减少了。3. 手写一个极简Skill从Manifest到执行体完整抄作业理论讲完了直接上一份能跑的代码。我选择做一个贴近日常的背景杂音过滤工具姑且叫noise_filter功能是给一段对话文本打标区分核心信息和噪音信息。这个技能的实用场景是Agent从长篇聊天记录或会议记录中提取概述时先调用它做一轮预处理过滤掉客套话、口头禅、重复表达的碎片。3.1 目录结构与Manifest定义我的Skill项目按这个结构组织skills/ noise_filter/ SKILL.md run.py prompt_template.txt tests/ test_noise_filter.py其中SKILL.md的内容如下--- name: noise_filter description: 对一段对话/长文本进行噪音过滤输出结构化后的核心信息片段。 applicable_scene: 当用户输入包含会议记录、聊天记录、零散想法等需要提炼的场景时使用。 inputs: text: type: string required: true description: 原始文本建议不超过一万字。 noise_types: type: array required: false description: 需要过滤的噪音类型可选: greeting, filler, repetition, off_topic。默认全部过滤。 outputs: filtered_text: type: string description: 过滤后的核心文本。 removed_items: type: array description: 被移除的具体片段列表。 dependencies: [] failure_hints: - ERROR_TEXT_TOO_LONG: 输入文本超过处理上限时返回此错误。 - ERROR_EMPTY_TEXT: 输入为空时返回此错误。 ---有几个字段我想专门解释一下不是所有Skill都简单堆字段就够的。applicable_scene这一项我建议写得口语化一点、贴近真实用户会怎么说而不是写一行干巴巴的文本过滤。因为Agent做意图匹配时是拿用户的原始表述和这里的描述做语义匹配的。你写当用户希望把冗长对话浓缩成要点时比起此函数提供噪声过滤功能要有效得多。我踩过这个坑早期的Skill经常因为描述太工程化而匹配不上后来全部改成场景化描述匹配率马上提高。failure_hints这个字段更不能省略。Agent执行Skill失败后如果执行器返回的是一个错误码模型往往不知道怎么处理但如果带上一句尝试缩小输入范围后重试Agent就能做出合理的自我修复动作。这相当于给编排层写了一份危机处理预案。3.2 执行体的两种形态纯函数与模型增强我倾向于把Skill执行体分为两档能写纯函数的就写纯函数只有纯规则搞不定时才引入模型调用。noise_filter这个例子我建议第一版用纯代码实现因为规则可预期、测试简单、成本几乎为零。下面是run.py的核心逻辑import re NOISE_PATTERNS { greeting: [r^(好的|好的呢|嗯嗯|哈喽|hello)\W*, r^(在吗|在忙吗)\W*], filler: [r那个, r就是说, r嗯\.{3}, r然后\.{3}], repetition: [r(\S{4,})\s\1], off_topic: [r顺便说一句.*, r抛开.*不谈], } def run(text: str, noise_typesNone): if not text or not text.strip(): return {filtered_text: , removed_items: [], error: ERROR_EMPTY_TEXT} if noise_types is None: noise_types list(NOISE_PATTERNS.keys()) removed [] filtered text for ntype, patterns in NOISE_PATTERNS.items(): if ntype not in noise_types: continue for pattern in patterns: matches re.findall(pattern, filtered, flagsre.IGNORECASE) if matches: removed.extend(matches if isinstance(matches, list) else [matches]) filtered re.sub(pattern, , filtered, flagsre.IGNORECASE) filtered re.sub(r\s{2,}, , filtered).strip() return {filtered_text: filtered, removed_items: removed}注意这里有个很小的细节repetition这种去重要小心我的正则是针对连续重复的4字以上片段不能随意扩大范围否则会把我们今天今天开会这种句中重复的合理部分也删掉。Skill的规则宁可保守一点也不要激进因为它的输出是要喂给下一个环节的误删核心信息比留下一点噪音更致命。3.3 用Skill接入Agent编排层一个最小注册机制写完Skill本体你还得有办法让Agent知道我这个系统里有哪些Skill。我用的是一个简单的注册表SKILL_REGISTRY {} def register_skill(skill_module): manifest load_manifest(skill_module) SKILL_REGISTRY[manifest[name]] { manifest: manifest, executor: skill_module.run, }而Agent编排层里意图匹配的逻辑是这么写的def dispatch_skill(user_input): candidates [] for name, skill in SKILL_REGISTRY.items(): scene skill[manifest][applicable_scene] # 通过语义匹配打分这里可接轻量embedding或关键词加权 score semantic_match(user_input, scene) candidates.append((name, score)) return max(candidates, keylambda x: x[1])[0]如果你不想接向量模型纯规则也够用至少把可选Skill列表塞给模型让模型做决定。整个过程不要复杂化Skill系统的核心价值在于拆分不在决策算法。先跑起来再迭代智能度。4. 实测中的意外情况与避坑经验为什么绝对不该做的往往最先翻车Skill这层架构做的时候你觉得天下太平上线跑几天就开始各种花式翻车。这一章我把在noise_filter和其他几个Skill上实际踩过的坑按问题现象→根因→处理方案的格式整理出来希望能帮你省掉几晚排查的功夫。4.1 Skill匹配命中错误以为删了就能解决结果删错了有一次Agent面对用户的提问帮我把这段会议记录的闲聊去掉竟然调了git_commit_fetcher然后理所当然地执行失败。我第一反应是Manifest描述写得不够精准于是我把git_commit_fetcher的description改成仅处理与Git仓库相关的请求问题依然存在。排查链路是这样的我打印了所有候选Skill的语义匹配得分发现git_commit_fetcher的得分并不高真正的问题出在编排层——它没有设置最低匹配阈值。一个得分0.2的候选Skill按照当前逻辑max()直接就被选中了而实际上是用户输入与任何Skill都不匹配应该走默认对话分支而不是强行走Skill。修复方案是给dispatch_skill加一个阈值判断best_name, best_score max(candidates, keylambda x: x[1]) if best_score 0.5: return None # 交给默认的对话生成逻辑这个阈值需要根据你的语义匹配方法反复调我的经验是0.45到0.6之间相对安全。低于这个值硬调用Skill基本就是在赌命。这件事之后我还有个衍生习惯failure_hints里专门加了一条ERROR_NO_MATCHING_SKILL让Agent在低置信度时既能走default逻辑又能主动向用户澄清意图。4.2 上下文窗口反而变大的反向优化Skill系统做了一大半我曾在某个技能里加了巨长的Prompt模板里面有完整的行为规范、若干示例、few-shot样例当时想着反正这部分只在调用时才加载平时不占上下文。结果上线后实际Token消耗不降反升。原因是这个Skill在单个会话里被高频反复调用每次加载都要把数万字符的Prompt模板送进模型。而原来把行为规范写进System Prompt时一份内容全线复用只需要进上下文一次。后来我把模板拆成两部分核心规则固定内容做成系统级缓存和动态上下文比如当前任务的特定输入且模板文本尽量精简。举个例子与其给模型示范三个错误输出示例来防止跑偏不如明确规定输出Schema和一条硬性约束输出必须是一个JSON对象禁止包含其他文字后者实际效果好得多文本量却少得多。这条经验适合所有Skill设计不要以为按需加载等于随便多大都行。按需加载只解决了无关Skill不占内存的问题高频Skill内部的内容仍需保持硅胶级的精简。我现在的原则是任何Skill内部提示词模板超过800字就要开始优化不是砍字就是换成更强的结构化约束。4.3 参数校验不足导致的上游数据污染有一个典型事故noise_filter上线后某个上游调用方吞进来的text字段是个数组JSON解析错误导致执行器拿到非字符串直接跑re.findall就抛了TypeError。Agent把它识别成用户输入的文本处理失败然后给用户反馈了一句无法处理您的请求请重新描述。用户体验很差。问题根源不只是没有做类型检查更在于我早期的Manifest里inputs.text只写了type: string没有限定格式校验。这类Bug一旦发生在多级调用链里排查成本极高——你根本不知道是哪一层把数据类型搞坏的。现在我给所有Skill执行器加了一个统一的validate_inputs前置函数def validate_inputs(manifest, raw_inputs): errors [] for field, spec in manifest[inputs].items(): if spec.get(required) and field not in raw_inputs: errors.append(fmissing field: {field}) else: # 这里做类型强校验 expected spec[type] actual type(raw_inputs.get(field)).__name__ if expected ! actual and not (expected string and actual int): errors.append(ffield {field} expected {expected}, got {actual}) if errors: return {ok: False, errors: errors} return {ok: True, data: raw_inputs}注意一点字符串类型我放宽了可以接受int的规则因为用户或模型有时会将数字直接传入没必要为了严格而错杀。Skill系统的目标是稳定可用不是当类型警察。4.4 Skill之间互相调用的循环依赖陷阱当你开始把Skill当成微服务来编排的时候会有意无意地让某个Skill内部去调用另一个Skill。比如我在做一个weekly_reportSkill时内部想复用noise_filter这本身没问题。但后来我不小心在noise_filter的内部逻辑里又引用了weekly_report的一个工具函数结果一次普通调用直接在两个Skill之间形成循环整个Agent卡在递归里直到超时。排查时日志看起来极其诡异——两个Skill的调用记录交替出现像死循环一样刷屏。我加了一个简单的调用链深度限制在全局生效MAX_SKILL_DEPTH 4 def execute_with_depth(skill_name, inputs, depth0): if depth MAX_SKILL_DEPTH: return {error: ERROR_MAX_DEPTH_EXCEEDED} # 正常执行逻辑...除了深度限制我更推荐你在架构上做一个约束Skill内部不要跨调用其他Skill。如果多个技能都依赖同一份处理逻辑把它下沉为公共基础库而不是让Skill之间互相引用。你想微服务之间互相调用都要靠API网关控制Skill之间更不该裸调。保持每个Skill的执行路径是独立的、可单测的是这条架构能持续演进的前提。4.5 单测覆盖了功能却覆盖不了意外输入最后一个坑和测试有关。我在项目初期给noise_filter写了单测用例覆盖了常规文本、空文本、纯标点文本功能全绿。但上线后真正让它崩掉的输入是一段只包含单个字符嗯的文本、一段全角括号不闭合的文本、一段有一万个换行符的文本。单测没过关的原因很简单我只测了输入内容是否合理的情况没有测输入是否足够恶臭的情况。现在我给每个Skill的测试目录补充了tests/test_adversarial_inputs.py专门塞极端输入超长文本截断到上限、全空白、夹杂各种Unicode符号、JSON结构嵌套错乱等。对于任何Skill我个人建议至少覆盖这五类非正常输入空值、错类型、超长、全符号、硬编解码失败。这五类占了我线上故障里至少一半。单测不是为了证明逻辑对而是为了证明逻辑不会在奇怪输入面前变成一坨焦油。5. 从能用到好用Skill系统的扩展思路与维护策略如果你已经照着前面的示例实现了一个或几个Skill并且跑通了基本调用链那么恭喜你今天已经完成了90%的项目工作剩下的10%是让它从能用变成好用。这一节不写代码写我在这个阶段摸索出来的维持思路。先说说目录和命名的问题。当Skill数量超过20个你靠文件夹名找东西就有点费力了。我现在的惯例是给Skill按域分组skills/code/、skills/repo/、skills/text/、skills/collab/并在Skill名字前加领域前缀比如code_reviewer、text_noise_filter。这个前缀不是为了好看而是让Agent在联网搜索式调用时更容易缩小候选范围。再说Manifest的更新流程。每次改动Skill核心行为我都会先改Manifest的版本号加version字段再改代码再跑一遍完整用例。这个习惯帮我避免过一个很隐蔽的问题编排层缓存了旧的Manifest而执行器已经是新逻辑导致Agent看到的description和实际行为不一致。版本号配合缓存失效策略可以把这个风险降到最低。最后Skill系统的测试不要只停留在功能层最好加一条链路级冒烟测试用一个覆盖全部主要Skill入口的假任务跑通整个意图识别→Skill匹配→参数解析→执行→回填链路。所有技能都能在这个测试里拿到预期的结构化输出才算一次真正意义上的安全发布。按照我的经验Skill数量增长到20个左右时这种冒烟测试是防止改一个Skill、炸三个调用链的唯一可靠手段。这套打法我用了大半年从最初的三五个Skills一路扩到现在项目稳定性和迭代速度都有了质的改变。如果你刚开始给Agent加Skills别贪多先挑三四个高频、低耦合的功能拆出来跑通链路剩下的等验证完收益再加。架构设计最好从第一天就做对真正踩过坑之后才知道有时候花半天把结构搭好省下的是后面无数个排查Bug的深夜。