在AI Agent落地这件事上,我踩过最大的坑,就是“什么都想直接塞进System Prompt里”。早期做一个内部知识库问答Agent,功能越加越多,提示词从500字膨胀到3000字,最后模型开始答非所问,排错排到怀疑人生。后来接触了agent-skills这一套把能力模块化的思路,才意识到问题不在于模型不够强,而在于我没有给Agent一套结构化的“职业技能体系”。这篇博文就围绕agent-skills聊聊我对Agent技能化的理解、架构拆解、从零落地的完整过程,以及那些文档里不会写、但实战一定会遇到的坑。
1. 项目定位:agent-skills究竟在解决什么问题
1.1 从“单体智能体”到“技能编排”的必然演进
早期做Agent的方式特别粗暴:把所有工具函数、规则、知识片段一股脑写进系统提示词,再给模型挂上几个Python函数当Tools。这种“单体智能体”模式在Demo阶段跑得很欢,一旦进入生产环境,问题立刻暴露——加一个新需求,要么改提示词导致其他功能劣化,要么在工具列表里再塞一个函数,模型选工具时经常选错,定位问题无比困难。本质问题在于,我们还停留在线性指令时代,没有给智能体建立“按需调用能力”的结构。
agent-skills代表的是另一个方向:把Agent能做的事拆成细粒度、可独立维护、可复用的“技能单元”。每个技能像人身上的肌肉记忆一样,只负责一件明确的事。有的技能负责解析文档,有的技能负责提取日程,有的技能负责把结构化数据渲染成自然语言。Agent需要什么就加载什么,不需要的完全隔离。它的核心价值是一句话——从“教模型怎么做”变成“给模型一套能干活的工具箱”。
1.2 技能化设计与传统Function Calling的差异
很多人看到skill会问:这不就是Function Calling吗?还真不是。Function Calling是模型在推理时临时决定调用某个函数的机制,属于“调用层”能力;而agent-skills更像是“组织层”能力。它关注的是技能的声明、加载、调度、版本、依赖,甚至技能与技能之间如何协作。
举个例子,一份PDF采购合同,Agent要完成“提取关键条款并生成摘要”这个任务。普通人会写两个函数:一个是解析PDF,一个是生成摘要。但技能化的思路是:先将“读取PDF并转成结构化文本”做成一个底层的文档解析技能,再将“摘要生成”做成一个依赖解析技能的语义层技能,最后再通过一个“任务编排技能”负责流程控制。中间任何一层要替换算法或升级模型,都不影响其他层。这就是技能编排的组合优势——低耦合、高内聚、可独立迭代。
1.3 这个思路适合谁、不适合谁
如果你正在做这些场景,agent-skills的思路会非常有帮助:
- 办公自动化Agent:需要处理文档、表格、邮件、日历,技能天然可拆分。
- 垂直领域问答系统:在通用大模型之上叠加专有领域的处理流程。
- 数据分析助手:涉及数据库查询、代码生成、图表描述等多步骤任务。
- 客服工单系统:需要识别意图、查库、组织话术、跟进流转。
但如果是纯闲聊型助手或者单轮简单问答,硬套技能框架完全是过度设计,加了一层复杂度反而影响响应速度。工具永远是服务于场景的,别为了架构而架构。
2. 技术拆解:技能的表示、注册与调度机制
2.1 一个技能的标准结构长什么样
在agent-skills体系里,一个技能通常不只包含一段函数代码,它由三部分组成:描述文件(manifest)、执行逻辑(implementation)和校验用例(test cases)。
manifest是技能的“身份证”,一般是YAML或JSON格式。里面最重要的字段包括:
- name:技能唯一标识,比如
document_parser,全局不能重名。 - description:给大模型看的语义描述,要写清楚“这个技能在什么场景下做什么事”,因为模型不会看实现代码,它完全靠这段描述决定是否调用该技能。描述写模糊了,技能再强也是摆设。
- parameters:入参定义,包括类型、是否必填、格式约束。我强烈建议这一步用类似JSON Schema的规范,而不是自己随手定义几个字段,便于后续校验和自动生成调用代码。
- returns:返回值结构定义,同样需要明确的schema。
- dependencies:依赖的其他技能或外部服务,例如 “上传文件前先调用
file_downloader”。依赖声明可以避免运行时才发现技能链路断裂。
一个经验是:manifest的description不要太长,但一定要把“边界”说清楚。例如file_downloader的描述不能只写“下载文件”,而要写“根据给定的URL下载文件到本地临时目录并返回路径,仅支持HTTP/HTTPS协议,不支持FTP”。模型有了明确边界,才不会在收到FTP链接时还硬调这个技能,调完报错还得迭代半天。
2.2 技能注册中心与动态加载
技能放到目录里是开发态,要变成Agent可用的资产,需要经过注册中心统一管理。注册中心本质上是一个内存映射表:技能名称到加载后的执行类实例之间的映射。启动时扫描指定目录,读取所有manifest,校验合法性后懒加载执行类。这里有两个关键设计:
懒加载而不是全量加载。一个大型系统的技能可能上百个,如果启动时全部加载,光是依赖初始化就会拖慢启动流程,更重要的是,大语言模型在每次推理时看到的工具数量是有限制的,工具列表太长,选路准确率会急剧下降。所以注册中心要支持按需加载,即:
- 启动时只加载全局必需的基础技能。
- 收到任务后,先做一次意图粗筛,把候选技能范围缩小到3-8个。
- 再将范围内技能的manifest拼接到当次推理的上下文中。
版本挂载而不是覆盖升级。技能会迭代,同一天内可能更新多次。如果注册中心里一个技能名只对应一份代码,那么压测中一部分请求命中新版本、一部分命中旧版本,结果会非常不稳定。实践上我采用“name + version”双标识,在分发策略里显式指定什么时候切换流量。比如先在预发环境把新版本技能和旧版本技能同时注册,跑一个小时的影子流量对比,确认指标没问题再灰度切流量。
2.3 技能调度链路:模型怎么知道该用哪个技能
这是我把agent-skills应用到一个内部项目时最花心思的地方。最初我天真地以为把技能列表一股脑传给模型就行,结果技能超过15个之后,模型选工具的正确率明显下降。后来我调整了调度策略,分三层做决策:
- 第一层:规则过滤。根据任务类型用硬规则过滤掉明显无关的技能。比如用户输入是语音转文字任务,就不用加载图像增强技能。这一步能大幅缩小候选集。
- 第二层:语义检索。把用户意图用Embedding向量化,与技能描述的向量做相似度检索,选出Top-5相关技能。这层解决的是“描述近似但实际不同”的技能区分问题。
- 第三层:模型决策。把前两层筛选出的候选技能列表交付给大模型,让它基于对用户问题的理解选择最合适的技能或技能组合。
这种三级调度的效果非常明显,一个原本有40个技能的项目,最终单次任务实际参与决策的技能只有5个左右,工具选择准确率从76%提升到94%以上。注意,这个数据是我们的封闭测集结果,不同业务会有浮动,但思路可以复用。
2.4 技能之间如何通信、如何共享状态
技能不是孤岛。很多时候技能之间需要传递数据。我见过最糟糕的设计是用全局变量共享数据,技能执行顺序稍微一变,取到的数据就错了。在agent-skills体系里,我推荐两种共享方式:
- 显式上下文对象。每个技能接收一个
context参数,维护一个类似KV存储的状态容器。技能A执行完把结果写入context.set("parsed_docs", docs),技能B声明依赖后通过context.get("parsed_docs")读取。数据流完全显式,问题链路好追踪。 - 事件总线。适合异步场景,技能A发布一个事件,技能B订阅该事件并触发执行。这种方式解耦强,但排错难度高,小团队不建议一上来就用事件驱动。
我个人的建议是,初期先把90%的技能通信做成“显式上下文”,代码可读性强,新人接手也容易上手。事件总线等到真有必要时再引入不迟。
3. 实操落地:从零构建一个技能化Agent
3.1 环境准备与工程脚手架
看再多的架构图都不如真正动手跑一遍。我先用一个最小工程说明怎么搭,技术栈是我个人实践的方案,不一定对所有人都最优,但足够清爽:Python 3.11 + FastAPI + pydantic v2。
第一步是初始化工程目录,我通常这样组织:
agent-skills-demo/ ├── skills/ │ ├── __init__.py │ ├── calendar/ │ │ ├── skill.yaml │ │ ├── __init__.py │ │ └── impl.py │ └── document_parser/ │ ├── skill.yaml │ ├── __init__.py │ └── impl.py ├── registry.py ├── dispatcher.py ├── main.py └── pyproject.toml先把依赖管理做清爽,用uv创建一个虚拟环境,核心依赖只有三样:fastapi、pydantic、pyyaml。真正跑大模型调用的时候再按需引入openai或对应的SDK。我见过太多人一开始就把一堆依赖丢进项目,最后依赖冲突看得脑子疼,工程上先做减法总没错。
3.2 定义第一个技能:以“会议记录整理”为例
一个具体例子胜过千言万语。假设我们要做一个技能:meeting_minutes,职能是把一段会议发言转写文本整理成结构化的会议纪要。
先在skills/meeting_minutes/skill.yaml里写清楚技能描述:
name: meeting_minutes version: 0.1.0 description: > 把会议录音转写后的原始文本整理为结构化会议纪要, 包含参会人、议题、结论、待办事项四个部分。 适合输入为纯文本的会议记录,不适合直接处理音视频文件。 parameters: type: object properties: raw_text: type: string description: 会议转写文本,UTF-8编码 attendees: type: array items: type: string description: 已知参会人列表,可为空 required: - raw_text returns: type: object properties: summary: type: string description: 会议整体摘要 action_items: type: array items: type: string description: 待办事项列表 dependencies: []注意几点:description字段里我专门加了“适合纯文本”“不适合音视频”这两个边界信息,这个习惯在后续减少很多误调用;parameters里的required必须只列真正必要字段,让模型拼参数的负担尽可能小。
然后是执行逻辑impl.py,核心函数长这样:
from pydantic import BaseModel, Field class MeetingMinutesInput(BaseModel): raw_text: str = Field(..., min_length=10, max_length=50000) attendees: list[str] = Field(default_factory=list) class MeetingMinutesOutput(BaseModel): summary: str action_items: list[str] def execute(params: dict, context: dict) -> dict: data = MeetingMinutesInput(**params) llm_client = context["llm_client"] prompt = build_prompt(data) response = llm_client.chat_completion( messages=[{"role": "user", "content": prompt}], temperature=0.2, max_tokens=1024, ) result = parse_response(response) output = MeetingMinutesOutput(**result) return output.model_dump()这里有个需要补充说明的点:我在execute里用了context["llm_client"],说明LLM客户端不是技能自己new的,而是由框架通过context注入。这样技能本身不关心上游用的是哪家大模型,以后换模型或做多模型路由都方便。实际项目中不要在每个技能里都各自初始化一个OpenAI客户端,连接池和认证统一管理会比散落各处的客户端稳定得多。
3.3 注册中心与调度器的代码骨架
技能写好了,要进注册中心。注册中心的核心逻辑是扫描目录、解析manifest、建立映射。
import yaml from pathlib import Path from importlib import import_module class SkillRegistry: def __init__(self, skills_dir: str): self.skills_dir = Path(skills_dir) self._skills = {} def scan(self): for manifest_path in self.skills_dir.rglob("skill.yaml"): with open(manifest_path, encoding="utf-8") as f: meta = yaml.safe_load(f) module_path = manifest_path.parent.name impl = import_module(f"skills.{module_path}.impl") self._skills[meta["name"]] = { "meta": meta, "execute": impl.execute, } def get(self, name: str): if name not in self._skills: raise KeyError(f"skill {name} not registered") return self._skills[name] def search_by_keywords(self, query: str, top_k: int = 5) -> list[str]: # 这里可以是向量检索,也可以先用关键词粗过滤 scored = [] for name, skill in self._skills.items(): desc = skill["meta"]["description"] score = len(set(query) & set(desc)) scored.append((score, name)) scored.sort(reverse=True) return [name for _, name in scored[:top_k]]调度器则负责把用户请求路由到技能执行:
class Dispatcher: def __init__(self, registry: SkillRegistry): self.registry = registry async def dispatch(self, user_input: str, context: dict): candidates = self.registry.search_by_keywords(user_input, top_k=5) if not candidates: raise ValueError("no suitable skill found") # 在实际项目中,这里可以先让LLM从candidates里选出最合适的技能 # 再构建参数,这里为了演示直接取第一个 skill_name = candidates[0] skill = self.registry.get(skill_name) params = {"raw_text": user_input, "attendees": []} return skill["execute"](params, context)当然,生产环境不可能这么简单,你需要在这里集成语义检索、LLM意图识别、参数抽取和多轮上下文管理。但脚手架的意义在于先把链路打通。链路通了,后面都是一点一点迭代的事。
3.4 性能与成本优化:每轮调用到底花多少Token
技能化架构能控制成本,但前提是你会算Token账。我以meeting_minutes这个技能为例,粗算一次调用的消耗:
- 技能manifest拼接后约300 token。
- 用户原始文本按每字约1.5 token计算,一份30分钟会议的转写文本大概3000字,折合4500 token。
- 指令模板和输出约束说明约500 token。
- 模型的输出,会议纪要一般800-1200 token。
单次技能调用总Token在6000-6500左右。注意这里只算了LLM推理的对话上下文,还没算你上一次意图识别和技能检索的开销。如果使用三级调度,每次任务还需要额外花一轮200-400 token的“模型选技能”调用,很多项目在评估成本的时候漏掉这笔账,结果月底账单出来了吓一跳。
所以建议做好两个缓存:
- 技能选路缓存:相同意图的请求在短时间内直接复用上一次的选路结果,不需要每次都让模型重新选技能。业务场景变化不快的话,准确率下降幅度完全可以接受。
- 中间结果缓存:比如某份文档解析结果当天没变,技能A解析后可以把结构化结果缓存到Redis,技能B再次调用时直接读缓存,省掉重复解析的开销。
我在实际项目中把这两个缓存加上之后,整体Token成本下降了约40%,而且响应时延也在提升。这不是玄学,是实打实的账单数字。
4. 常见问题与排查技巧实录
4.1 技能调用冲突:两个技能都觉得自己该干活
这是技能多了之后最容易出现的问题。比如项目里同时有meeting_minutes和meeting_notes_extractor,功能高度重叠,模型对同一个输入时而选A时而选B,输出风格不一致,下游处理就会出问题。
排查思路是先找重叠:把每个技能的description和测试样例导出,算一遍相似度矩阵。两条技能描述向量相似度超过0.85,就要警惕功能重叠。解决办法有三个方向:
- 合并技能,把两个能力收编成一个更通用的技能,同时把边界写在description里。
- 职责细分,一个偏“整理结论与待办”,另一个偏“抽取关键时间点”,让模型按需选择。
- 加规则兜底,针对输入中带“时间”“排期”等关键词的请求,强制路由到后者。
另外每次技能变更后要跑一遍意图分流的回归集,防止改了一个技能的description导致其他任务被错误路由过去。这个坑我踩过不止一次,T+1回测不可省。
4.2 技能执行超时:重试机制反而让问题更严重
技能执行超时的原因通常不是大模型太慢,而是上游服务抖动,或者技能内部在等待某个同步接口响应。这时候如果不加控制的简单重试,会导致多个请求同时阻塞,服务吞吐量下降甚至雪崩。
我的做法是把超时与熔断分开设置:
- 单次技能执行:设置硬超时,比如30秒,超时直接返回错误结果,并把这次执行标记为失败。
- 技能级熔断器:1分钟内失败次数超过阈值(比如5次),熔断该技能10秒,熔断期间直接返回兜底文案,不给上游继续施加压力。
class CircuitBreaker: def __init__(self, fail_threshold=5, cooldown=10): self.fail_threshold = fail_threshold self.cooldown = cooldown self.fail_count = 0 self.opened_at = None def call(self, fn, *args, **kwargs): if self.opened_at and time.time() - self.opened_at > self.cooldown: self.opened_at = None self.fail_count = 0 if self.opened_at: raise RuntimeError("circuit opened") try: result = fn(*args, **kwargs) self.fail_count = 0 return result except Exception: self.fail_count += 1 if self.fail_count >= self.fail_threshold: self.opened_at = time.time() raise这段代码很朴素,但内部的稳定性比起裸重试好了不止一个量级。
4.3 大模型上下文窗口溢出
很多人在Agent技能链条拉长之后,会把每一轮的对话历史、每个技能的输出都一股脑塞进上下文,最后发现自己陷入“窗口溢出—截断—丢失重要信息—技能执行错误”的恶性循环。Skill场景下上下文管理要遵循一个原则——只保留当前任务最小集。
具体操作上我会做三件事:
- 技能执行完毕只把结构化结果放回上下文,原始长文本归档到外部存储。
- 对话历史做滑动窗口裁剪,只保留最近2轮的用户意图和最终结果。
- 如果技能A的输出要喂给技能B,只传递与B的入参schema匹配的字段,而不是把A的完整输出对象整个传过去。
这样即使单条回复内容再大,上下文中的有效信息始终维持在可控区间。
4.4 技能回归测试:表面跑通不等于真的没问题
技能开发最容易忽视的是测试,但这类系统一旦上线,问题往往是多技能交互时才会暴露的。我建议每个技能维护一组测试样例,分为三类:
| 类型 | 样例特征 | 期望结果 |
|---|---|---|
| 正向用例 | 典型正常输入 | 输出符合schema |
| 边界用例 | 极长输入、空字段、非法格式 | 优雅报错或安全兜底 |
| 负向用例 | 明显超出技能范围的输入 | 不调用该技能或返回明确拒绝信息 |
每次技能升级时,把这组测试样例跑一遍,同时额外跑一遍“技能分流正确率”测试——针对50条带意图标签的请求,检查最终路由选择是否符合预期。只要这轮测试通过率不下降,就可以放心走灰度。
还有个小技巧:把测试中所有技能的真实执行输入输出录成快照,下次升级时对比快照差异。模型升级、提示词调整导致的行为漂移,在快照对比下一目了然,调试效率能提升很多。
一些实操心得
讲真,把技能架构引入项目之后,前期开发速度反而变慢了。因为你需要花时间设计manifest、写用例、搭注册中心和调度器。但好处也扎扎实实体现在后面:新增一个技能不需要改动老链路,排查问题的时候按技能边界切分调试,模块复杂度被限制在局部。
如果你是第一次尝试,我强烈建议别一上来就设计几十个技能,先挑3-5个你业务里最高频的动作做技能化,比如文档解析、日程提取、信息检索,跑通一两个真实的端到端任务。等这个链路稳定了,再逐步把其他能力往这个框架里搬。技能不是越多越好,一个能被正确调用的技能,远比十个躺在目录里没人用的技能有价值。