
做Agent开发久了你会发现一个很微妙的事实真正让Agent有用的关键往往不是模型本身多聪明而是它能调用多少稳定、可靠、可复用的技能。我去年经手过好几个项目团队从零开始搭Agent前两周还在兴奋地聊规划和记忆第三周开始就全员陷入能力怎么写才不重复造轮子的泥潭。不同Agent要用的能力高度重叠——网页抓取、文档解析、代码执行、API调用、内容总结——但代码散落在各个业务模块里改一处要牵连三四个文件。后来我们把整层能力抽出来单独做了一套agent-skills体系所有Agent的能力都从这层动态加载整体维护成本降了一个量级。这篇就是把当时的设计思路、踩过的坑、以及目前跑得很稳的实践方案整理出来给同样在做Agent能力层建设的团队一个参考。1. 为什么需要一套独立的Skills层从几段痛苦的Agent开发经历说起1.1 第一次做Agent时我把能力写死在代码里最早我做的Agent很简单一个对话机器人需要联网查资料。我当时直接在Agent的execute()方法里写了请求外部搜索API的逻辑然后让模型在回复时调用。功能跑通了但问题马上来了第二个Agent要查数据库第三个Agent要操作本地文件于是每个Agent都各自复制了一份调用外部资源的代码只是改了下URL和参数格式。这还不算最要命的。最要命的是同样的能力在两个Agent里的行为不一致。搜索这个动作Agent A做了超时重试Agent B没有Agent A限制了返回条数Agent B没有。模型的Prompt里对工具的描述也是各写各的有的写搜索网络获取信息有的写查询互联网内容模型经常因为描述含糊而误选工具。到这一步问题已经不是代码重复这么简单了而是Agent的行为不可预期。我去追根因发现本质是我们把能力和业务混在了一起。能力是通用的业务是具体的两者应该分层。工具函数能被多少Agent复用、怎么被复用取决于能力层设计得好不好。所以后面我重构时做的第一件事就是把所有通用能力抽出来单独成一个Skills层。1.2 Skills层要解决的三个核心问题重做Skills层之前我给自己列了三个必须解决的问题后来发现这也是所有Agent系统绕不开的三个问题第一能力的标准化描述。模型需要通过自然语言理解这个技能是干什么的然后决定是否调用。所以每个Skill必须有一份标准化的描述信息包括名称、功能说明、输入参数、输出格式。这份描述最终会拼进Prompt里描述质量直接决定模型调用的准确率。第二能力的动态加载。我不希望把几十个Skill全部塞进每个Agent的Prompt里那会冲散模型的注意力还会浪费上下文窗口。更合理的做法是Agent启动时按需加载一部分Skill运行中根据任务动态补加载其他Skill。这就要求Skill层具备注册-发现-加载的完整机制。第三能力的运行隔离。Skill不应该和Agent的主流程强耦合也不应该和某个具体的业务数据结构强绑定。每个Skill应该是一个独立的执行单元有自己的输入校验、错误处理、返回协议。这样即使某个Skill内部崩溃也不会拖垮整个Agent。这三个问题想清楚Skills层的边界就划定了。剩下的都是实现细节。2. Skill的定义模型与目录结构先想清楚一个技能到底是什么2.1 元信息、输入输出约定与资源依赖我花了不少时间定义Skill的标准长相。一个Skill本质上是一个可复用的能力封装它包含四部分元信息、执行逻辑、输入输出约定、资源依赖。元信息是给模型和调度器看的包含name技能名、description做什么用、parameters参数Schema、returns返回结构。其中description是给LLM看的要写得具体、能区分边界比如搜索互联网获取实时信息并返回结果列表就比搜索好得多。parameters我直接沿用JSON Schema格式这样既能校验输入也能把Schema转成模型需要的工具参数格式。执行逻辑是Skill真正干活的代码。我最初用Python写后来为了跨语言调用把每个Skill封装成独立的可执行模块通过标准输入输出或HTTP接口对外暴露。这个决定在后期帮了大忙因为团队里有人用TypeScript写Agent有人用Python统一走接口协议谁都能调。输入输出约定是Skill的契约。每个Skill必须声明自己接收什么、返回什么。我踩过的教训是输出格式一定要稳定最好是一个固定的JSON结构包含status成功/失败、data业务数据、error错误信息三块。因为Agent拿到Skill的结果后还要交给LLM做进一步推理如果每次返回的结构都不一样LLM的理解成本会急剧上升推理错误率也会肉眼可见地增加。资源依赖是指Skill运行需要的外部条件比如API Key、数据库连接、文件系统权限。这部分必须显式声明不能藏在代码里。我是通过一个manifest.json统一声明的Skill在注册时就会检查依赖是否满足不满足就直接标记为不可用而不是等运行时报错才发现问题。2.2 目录规范与命名约定Skills层的目录结构我采用了一个技能一个文件夹的约定skills/ web_search/ manifest.json skill.py requirements.txt web_extract/ manifest.json skill.py requirements.txt code_exec/ manifest.json skill.py requirements.txt每个文件夹就是一个独立Skillmanifest.json描述元信息和依赖skill.py是执行入口requirements.txt列出依赖包。这个结构的优点有两个一是每个Skill可以独立开发、独立测试甚至独立发布二是扫描器可以很方便地遍历目录完成注册。命名上我也定了规矩Skill名统一用动词_对象或者领域_动作的格式比如web_search、doc_summarize、api_call。不要用tool1、func2这种没语义的名字因为Skill名会出现在模型可调用的工具列表里名字起得含糊模型就容易选错。3. 核心机制技能如何被发现、注册与加载3.1 扫描、加载与注册流程Skills层最核心的机制是注册中心。我把注册中心实现成一个轻量的服务启动时扫描Skills目录逐个读取manifest.json校验依赖然后注册到内存中的一张技能表里。注册完成后Agent可以通过注册中心查询当前可用的Skill列表也可以单独查询某个Skill的详细信息。class SkillRegistry: def __init__(self, skills_dir: str): self._skills_dir skills_dir self._skills {} self._scan_and_register() def _scan_and_register(self): for entry in os.listdir(self._skills_dir): skill_path os.path.join(self._skills_dir, entry) manifest_path os.path.join(skill_path, manifest.json) if not os.path.isfile(manifest_path): continue manifest self._load_manifest(manifest_path) if self._check_dependencies(manifest): self._skills[manifest[name]] { manifest: manifest, path: skill_path, status: ready, } else: self._skills[manifest[name]] { manifest: manifest, path: skill_path, status: dependency_missing, } def list_skills(self) - list: return [ {name: name, description: info[manifest][description]} for name, info in self._skills.items() if info[status] ready ] def get_skill(self, name: str) - dict: info self._skills.get(name) if not info or info[status] ! ready: raise SkillUnavailableError(fSkill {name} is not ready) return info这个流程看起来简单但有一个细节值得强调注册时只加载元信息不加载执行代码。也就是说Skill的执行模块是懒加载的只有真正被调用时才导入。原因很简单有些Skill依赖的第三方库很重比如数据处理类的库启动时全部导入会让Agent的冷启动时间翻几倍。懒加载能让注册中心轻量也能让Agent启动更快。3.2 运行时动态绑定LLM怎么知道该调用哪个Skill注册中心只是地基真正的关键是运行时怎么让LLM选对Skill并且调用它。我在实践中采用的是两级候选策略。第一级根据Agent当前任务的关键词做粗筛比如任务里出现查一下找资料就把搜索、抽取类Skill提到候选列表前面。第二级把候选Skill的描述和参数Schema拼进Prompt让LLM基于语义选择最匹配的一个。粗筛是为了缩短候选列表、减少LLM的决策负担细选则是发挥LLM对自然语言的理解优势。调用流程上我用了一个统一的中介层SkillRunner。LLM按约定的JSON格式返回要调用哪个Skill、传什么参数SkillRunner负责解析、路由、执行、返回结果。这里有个很实用的技巧执行结果返回给LLM时我会同时附上Skill自带的result_summary字段让LLM不用重新读一遍原始数据就能理解结果。举个例子web_search返回的不只是一串链接还会自动生成一段共找到X条结果前3条标题分别为…的摘要。这个摘要直接给LLM原始链接给用户或后续流程。实测下来这种摘要先行的方式能把LLM的后续推理质量提升不少因为它减少了长上下文中的信息噪音。4. 从零实现一个可复用的Skill我踩过的细节坑4.1 写一个网页内容提取并总结的Skill全过程空谈设计太虚我拿一个真实的Skill举例web_extract功能是抓取指定网页并生成内容摘要。第一步写manifest.json{ name: web_extract, description: 抓取指定URL的网页正文提取主要文本内容并生成摘要。适用于查看文章、新闻、博客等内容型页面。, version: 1.0.0, parameters: { type: object, properties: { url: { type: string, description: 需要抓取的网页URL }, max_chars: { type: integer, description: 最多返回的文本长度默认8000, default: 8000 }, summarize: { type: boolean, description: 是否生成摘要默认true, default: true } }, required: [url] }, returns: { type: object, properties: { status: { type: string }, title: { type: string }, content: { type: string }, summary: { type: string } } }, dependencies: { python: 3.9, packages: [requests, beautifulsoup4], env_keys: [] } }description这里有个容易忽略的点不要只写抓取网页而要写清楚什么场景适合用、什么场景不适合。我在这个字段里加了适用于查看文章、新闻、博客等内容型页面就是想让模型明白如果用户问的是某个页面里的登录框怎么填这个Skill并不合适。第二步写执行代码。核心逻辑很简单发请求、解析正文、清洗HTML标签、截断长度、可选生成摘要。但我在这里踩了一个很典型的坑最初我直接用requests.get(url)去抓结果很多网站返回的是反爬页面正文提取出来全是验证码提示。后来我改成自定义UA头、加超时和重试、用beautifulsoup4去定位article标签或main标签提取成功率才从六成提到九成以上。第三步本地测试。我给每个Skill配了一个简单的自测入口直接用命令行传参运行验证特定URL的抓取效果。这一步看似朴素但对排查问题特别有用——不用启动整个Agent就能单独调试一个能力。4.2 参数校验与错误处理比功能本身更影响体验做Skills层时有一句话我一直挂在嘴边不要相信LLM传进来的参数。LLM生成参数时经常会出现漏传、传错类型、传了不存在的枚举值的情况。所以每个Skill执行前必须做严格的参数校验校验不过就返回结构化的错误信息让上层决定是纠正参数后重试还是告知用户。def validate_and_run(skill_func, params_schema, raw_params): validator jsonschema.Draft7Validator(params_schema) errors list(validator.iter_errors(raw_params)) if errors: return { status: error, error: { type: invalid_params, message: str(errors[0].message) } } return skill_func(**raw_params)错误处理也一样每个Skill的输出都必须遵守失败也是结构化的原则。我见过很多Agent翻车就是因为工具异常时返回了一段长长的traceback把LLM直接绕晕了。正确的做法是抛异常时统一捕获转成{status: error, error: {type: timeout}}这种简洁结构。LLM看到timeout就知道该提示用户稍后重试而不是对着一屏报错发呆。5. Skill间的组合与隔离不是所有能力都应该揉在一起5.1 技能组合的三种模式链式、并行、路由单独的一个Skill能解决单点问题但一个复杂的Agent任务往往需要多个Skill协作。我在实践中总结出三种组合模式分别应对不同场景链式组合是最常见的。比如总结这篇新闻并发送到邮箱这个任务需要web_extract先抓取内容再由doc_summarize生成摘要最后由email_send发送。前一个Skill的输出是后一个Skill的输入必须保证输出结构完全可对接。这也就是为什么我强调输出协议要固定链式组合中对协议的一致性要求远高于单个Skill。并行组合用于几个互不依赖的子任务。比如用户问对比A公司和B公司的市值可以同时调用两个搜索Skill分别查A和B最后合起来给LLM做比较分析。并行能显著缩短整体耗时但要注意控制并发数量我在SkillRunner里设置了最大并发数5防止大量Skill同时执行拖垮资源。路由组合是根据任务类型动态选择不同的Skill分支。比如Agent检测到用户发来一段代码就路由到code_exec检测到用户发来一个文件路径就路由到file_read。路由的判断我倾向于让LLM来做但会给它一个固定的决策规则提示而不是让它自由发挥。这三种模式不是互斥的实际任务经常是它们的混合体。关键是在设计Skill接口时就要考虑被组合的可能性每个Skill的输入输出都要做到能被其他Skill安全地消费。5.2 安全边界与权限控制Skill越丰富权限边界越重要。我在Skills层里给每个Skill配置了执行级别safe只读操作不接触用户隐私不产生副作用。比如web_search。medium会写数据或调用外部API但影响范围有限。比如file_write。high执行代码、操作系统操作、访问敏感信息。比如code_exec。Agent默认只启用safe级别的Skillmedium和high需要用户在会话中显式授权或者由Agent的行为策略触发授权请求。这个设计能挡住大部分Agent被诱导执行危险操作的问题。另外还有一个常被忽略的点Skill访问外部服务时不应该直接使用Agent进程的全局凭证。每个Skill应该从自己的配置中读取专属凭证并且这个凭证的权限范围要尽量小。比如web_extract只需要一个读权限的API Key不给写权限。我意识到这一点是在一次排查Agent为什么删掉了用户云存储里的文件之后。根因就是某个Skill复用了全局密钥而该密钥拥有过高的权限。从那以后凭证最小化成了硬性规范。6. 测试、调试与效果评估上线前必须做的事6.1 离线测试集怎么建Agent的Skill不太适合用传统的单元测试思路去覆盖因为LLM的行为有随机性同样的输入可能产生不同的调用。但Skill本身是确定性代码是可以严格测试的。我把测试分成两层第一层是Skill逻辑层的单元测试。每个Skill都有固定的输入输出我针对正常输入、边界参数如空字符串、超长文本、非法URL、外部服务异常超时、返回500、被反爬分别写测试用例确保Skill的执行逻辑稳定。第二层是Agent集成层的回归测试。我会准备一组典型任务比如查询明天的天气总结这篇文档计算某段代码的运行结果然后把任务跑一遍记录Agent是否选择了正确的Skill、参数是否合理、最终答案是否让人满意。这一层我允许一定比例的波动但核心任务是必须稳定通过的。我自己的经验是核心任务集别贪多20到30条能覆盖大多数业务场景就够了跑一次大概三五分钟每次改完代码都跑一遍能拦住大部分回归问题。6.2 线上日志与追溯调试Agent的Skill调用链比调试传统代码麻烦得多因为你面对的不只是代码栈还有一次LLM的决策过程。为了能事后追溯我给每个Skill调用设计了结构化的日志格式{ request_id: abc123, agent_id: assistant_01, skill: web_search, params: {query: 2024年新能源汽车销量}, result_status: success, result_summary: 共找到12条结果前3条来自…, latency_ms: 842, model: gpt-4o, timestamp: 2024-05-20T10:00:00Z }每条日志都关联了request_id这样当Agent最终回复出错时我可以沿着request_id把整条调用链拉出来看模型在哪一步选了错Skill、参数是怎么生成的、Skill返回了什么、LLM基于这个结果做了什么判断。这套追溯体系帮我解决了很多看起来莫名其妙的问题。比如说有一次Agent在总结文档时反复输出空内容排查到最后发现是doc_summarize在输入超过2万字符时静默截断了文本导致LLM拿到的内容不完整。这种问题不靠日志追溯几乎不可能定位。7. 我的一些幕后心得哪些设计当初觉得对、后来发现要改做Skills层这段时间有几个设计决策是我回看时觉得特别想分享的因为它们在事后被证明至关重要。第一个是关于描述即文档的认知。Skill的description是给LLM看的接口文档它的重要性远高于给程序员看的注释。我后来专门安排人逐条打磨每个Skill的描述把模糊的处理数据改成精确的从上传的CSV文件中提取指定列并计算统计指标。描述改完之后模型选错Skill的概率肉眼可见地下降了。如果你的Agent经常选错工具优先检查的不是模型而是工具描述的质量。第二个是不要追求Skill数量多要追求场景覆盖准。我见过一些团队把Skill堆到上百个但真正被频繁调用的可能只有十几个。Skill数量太多还会拖慢扫描注册时间并且让LLM在决策时更难选择。我现在倾向于控制核心Skill在二三十个以内并且定期用调用日志分析哪些Skill是僵尸Skill该删就删。第三个是关于失败反馈要带下一步建议。Skill返回给LLM的错误信息里最好包含这个错误大概是什么原因、建议怎么做。比如web_search超时了错误信息里建议可以尝试缩小搜索范围或稍后重试。这个细节让LLM在面对失败时不再反复尝试同一个错误操作而是能给出合理的用户提示或备选方案。第四个是关于版本与灰度。Skill不是写一次就永远不变的外部API会更新爬虫策略要调整LLM对工具描述的理解也在变化。我后来给Skill加了简单的版本号并在注册中心保留了启用/停用开关。要做改动时先小流量灰度一个版本确认没有引入回归再把旧版本停掉。这个流程虽然听起来很常规但对Agent系统尤其重要因为一个Skill的错误会通过LLM的决策被放大到整个任务链路里。最后再分享一个小技巧给每个Skill配一个最典型使用场景的示例写在manifest.json里。比如web_extract的示例是用户提供一个新闻链接要求总结新闻内容。这个示例有两个用处一是让Skill开发者自己确认技能定位没有跑偏二是可以自动生成测试用例反复验证。很多时候我们以为Skill写完了结果一跑示例发现连最典型的场景都处理不好。这个最朴素的检查反而帮我拦住了最多的隐性缺陷。