ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Agent Skills设计实战:让大模型告别“自信的幻觉”,可靠执行任务

Agent Skills设计实战:让大模型告别“自信的幻觉”,可靠执行任务 1. 从“会聊天”到“会干活”Agent Skills到底解决了什么问题先讲一个我接手过的真实场景。团队里的Agent Demo跑得很顺演示的时候你问它“今天天气怎么样”“帮我写一首关于秋天的诗”它都能对答如流。可一旦让它干点正经活儿比如“把/data/raw目录下所有超过100MB的CSV文件转成Parquet格式然后生成一份数据质量报告”它就开始魔幻操作了要么把文件路径凭空改掉要么转了一半就假装完成要么把“超过100MB”理解成“所有文件”。最气人的是它出错的时候语气还特别笃定一副“我已经搞定”的样子。这不是模型笨而是我们当时压根没给它“干活”的正确姿势。大模型本身是一个极其优秀的“意图理解器”但它不是一个可靠的“任务执行器”。让它通过一段自由文本Prompt去操作文件、调用命令、处理表格它只能靠“猜”来完成每一步。猜对了皆大欢喜猜错了就是灾难级输出。后来我把这个问题的根源归结为一点Agent缺少一套结构化的agent-skills模块也就是把“某一件具体的事情”封装成可复用、可校验、可评测的技能单元。这篇文章就是我基于实际项目总结的一套设计思路从目录结构、协议定义、调用链路到评测方法和踩坑记录希望能给正在做Agent落地应用的人一点参考。1.1 一个典型的翻车场景当时我们的Agent接了一个“文件批处理”的任务。用户在对话里说了一句“把./incoming里所有日志文件压缩一下旧的删掉但保留最近7天的。”这句大白话模型理解起来毫无压力但执行起来全是坑模型决定用zip命令但参数怎么拼它从训练数据里“回忆”了一个用法结果压缩路径写成了绝对路径/home/user/incoming而不是项目里的相对路径“旧的删掉”它直接执行了rm -f *.log根本没做“保留最近7天”的过滤即便我们给了它一个函数调用的JSON格式它依然会在“目标目录”字段里填一个不存在的目录名因为人类口语里的“./incoming”没有一个严格的、结构化的定义。这个现象在行业里有个通俗说法模型会自信地幻觉。它在生成文本时是以“像不像人话”为目标的而不是以“命令能不能正确执行”为目标的。所以你把执行细节全部交给模型自由发挥等于让一个阅读理解满分但动手能力为零的人去操作精密仪器。1.2 Skill在Agent架构中的定位再看一张常见的Agent架构图虽然各家叫法不同但核心组件大致都是四层模型层、记忆层、工具层、编排层。agent-skills不是一个独立的组件而是介于工具层和编排层之间的一个东西。传统工具层Tool是原子的比如read_file、write_file、execute_shell、call_api每个工具只做一件事参数简单职责单一。而Skill是“有流程的工具集合”它封装的是“完成一个任务”的逻辑内部可以包含多个工具调用、条件判断、错误重试、结果校验。举例来说Toollist_files(directory)—— 列出目录下文件这是原子操作Skillcleanup_old_logs(retention_days7, target_dir./incoming)—— 内部先list_files再按修改时间过滤再压缩再删除最后输出一份清理报告。如果没有Skill层那么“清理日志”这个任务就需要模型每一次都现场编排一遍流程。它当然可能编排出来但每次都可能有不同的编法有时候漏掉结果校验有时候多删了文件。而有了Skill之后这个任务被固定成一段经过测试的代码模型只需要负责两件事识别“用户需求对应哪个Skill”、提供“这个Skill需要的关键参数”。其余的流程控制、异常处理、安全边界全部交给代码。1.3 为什么不能只靠Function Calling有人会问现在OpenAI、Anthropic的API都支持Function Calling我把每一个操作定义成一个function让模型选不也一样吗我的回答是Function Calling可以作为一个底层传输协议但它管不了“任务流程”。原因有三个。第一Function Calling的参数是扁平的。比如delete_files(paths, older_than_days)这个函数的参数看起来简单但模型根本不知道应该调用list_files先筛选再调用delete_files去删。它可能直接把整个目录路径传给delete_files造成误删。你需要在Function的description里写一大段“你必须先检查…如果…才能…”的提示本质上是在函数定义里塞流程逻辑写一次行写多了维护成本极高。第二Function Calling的返回结果是结构化数据但模型不擅长从复杂的返回结果里提取“关键信息”。比如run_sql_query返回了500行查询结果模型接下来可能要把结果汇总成报告它需要自己决定“哪些列是重要的”“哪些值是异常的”。如果这一步没有Skill内部的解析逻辑兜底模型很容易被海量数据带偏。第三Function Calling没有“校验”和“回滚”的概念。一个Skill在执行过程中如果发现失败可以定义重试策略可以做部分回滚可以返回一个“可读的失败原因”给上层。而裸Function Calling一旦出错模型只能再瞎猜一个修法。所以我的结论很明确Skill是把编程可靠性注入Agent执行链的最佳粒度。它比Tool更高一层比Workflow更灵活比自由对话更可控。2. 搭一套Skill系统的骨架目录、协议与注册机制先看最终落地的目录结构这个结构不是拍脑袋定的而是根据“模型如何理解、代码如何加载、测试如何覆盖”三个约束倒推出来的。skills/ ├── cleanup_old_logs/ │ ├── SKILL.md │ ├── manifest.yaml │ ├── run.py │ ├── scripts/ │ │ └── archive_and_purge.py │ ├── tests/ │ │ └── test_run.py │ └── assets/ │ └── cleanup_logo.png ├── data_profiling/ │ ├── SKILL.md │ ├── manifest.yaml │ ├── run.py │ └── templates/ └── registry.json每一个Skill就是一个独立的文件夹这样的好处是版本管理清晰、权限边界清晰、复用和分发也清晰。你完全可以像发布Python包一样把某个Skill单独打成压缩包分享给其他人。下面我拆开讲每个文件的职责。2.1 SKILL.md写给模型看的说明书SKILL.md是给大模型看的不是给程序员看的。很多团队一开始只写一个description字段塞给模型后来发现效果差就是因为模型需要的是“在什么情境下调用这个Skill”的说明而不仅仅是“这个Skill是做什么的”的说明。我在项目里总结出一个还算稳定的SKILL.md模板结构大致如下--- name: cleanup_old_logs description: 清理指定目录下超过保留天数的日志文件并在清理前自动压缩归档。 when_to_use: 当用户要求“清理日志”“删除旧日志”“归档过期文件”“释放磁盘空间”时使用。 when_not_to_use: 当用户只需要查看日志列表而不做删除处理时不要调用本Skill。 version: 1.2.0 --- ## 输入参数 - target_dir: 字符串必填表示目标目录的绝对路径或相对项目根目录的路径 - retention_days: 整数默认为7表示保留最近多少天的文件 ## 执行步骤 1. 检查target_dir是否存在不存在则直接返回错误 2. 扫描目录下所有后缀为 .log 的文件 3. 根据文件修改时间过滤出超过retention_days的文件 4. 逐个压缩为 .tar.gz 归档到 archive/ 子目录 5. 确认归档成功后再删除原始文件 6. 输出清理统计 ## 安全约束 - 禁止删除 .tar.gz 之外的任何文件 - 必须使用相对路径避免操作项目目录以外的文件 - 删除前必须写入操作日志这个文件的定位是“模型的Action Space说明书”当系统判断用户意图可能对应多个Skill时模型会读取这些描述来做选择。所以关键在于when_to_use和when_not_to_use字段这两个字段是当前版本大模型最容易理解和遵循的触发条件描述。2.2 manifest.yaml写给代码看的契约如果说SKILL.md是人的语言那manifest.yaml就是机器语言。它定义了这个Skill的元信息、参数Schema、依赖、运行方式、权限申请等相当于一份机器可读的契约。name: cleanup_old_logs version: 1.2.0 entrypoint: run.py inputs: - name: target_dir type: string required: true description: 目标目录相对项目根目录或绝对路径 example: ./logs - name: retention_days type: integer required: false default: 7 minimum: 0 maximum: 90 outputs: - name: deleted_count type: integer - name: archived_files type: array items: string - name: summary type: string dependencies: python: - PyYAML - pandas apt: - tar permissions: filesystem: - write: true - delete: true network: enabled: false设计manifest.yaml时我坚持了几个原则这些原则在后来都避免了麻烦第一参数必须有类型和范围约束。retention_days如果不限制最大最小值模型可能给你填一个999999那就等于把所有日志全删光。给参数设置minimum和maximum不仅在模型填参数时能提示纠偏在执行端也能做硬校验。第二输入输出必须显式声明。输出字段的声明看起来麻烦但它决定了后续调用链上其他Skill或模型的上下文构建方式。如果没有输出Schema执行完一个Skill之后模型只能再从一大段原始日志里自己找重点效率很低。第三权限必须单独声明。这一点容易被忽略等上了生产环境才后悔。一个Skill如果只需要读取文件就该在网络权限里写enabled: false在文件系统权限里写delete: false。这样执行器可以根据权限做拦截而不是等代码跑起来之后才发现问题。2.3 注册与发现机制当项目里的Skill越来越多就会遇到“模型怎么知道我有哪些Skill”的问题。总不可能把所有SKILL.md都塞进上下文token消耗会爆炸。我在项目里做了一层SkillRegistry充当所有Skill的索引和调度入口。注册流程很简单启动时扫描skills/目录下所有子文件夹解析每个文件夹下的manifest.yaml并做Schema校验将合法Skill的元信息名称、一句话描述、参数Schema抽出来生成一份精简的registry.json对有问题的Skill跳过并记录错误日志不影响其他Skill加载。关键是后面的检索策略。我在registry.json里只保留name、description、parameters_summary这三个字段用来做意图匹配。当用户发来新任务时系统先计算任务文本与每个Skill描述之间的语义相似度召回Top-5候选再把Top-5的SKILL.md全文塞给模型做精排。这样既控制了上下文长度又保证了模型能看到足够详细的触发条件说明。这一步看起来简单但它决定了整个系统的扩展上限。如果Skill数量只有三四个那直接全量塞给模型也没关系但一旦到了几十个检索质量就会成为瓶颈。我目前用的方案是简单的Embedding召回加LLM精排后续还会尝试加一层基于规则的硬性过滤比如“用户消息里出现删除这个词时必须把cleanup类Skill纳入候选”。3. 让模型真正“会调用”Skill调用链路的原理与设计骨架搭好之后真正难的是调用链路的设计。模型不是一个稳定的状态机它会受到Prompt措辞、上下文长度、甚至用户上一个问题情绪的影响。所以调用链路必须设计成“每一环都有兜底”而不是“模型聪明就行了”。3.1 意图识别与Skill选择调用链路第一环是意图识别。这一步要做的是把用户的自然语言请求映射到一个或多个Skill上。举个例子用户说“帮我把那个文件夹收拾一下太乱了”这句话里没有任何一个词直接对应cleanup_old_logs但语义上它确实在请求一个清理类操作。这种模糊请求靠关键词匹配是匹配不上的必须用语义匹配。我采用的是两阶段方案Embedding召回将用户消息向量化和所有Skill的description向量做相似度计算取Top-5。这一步快召回率高但精度不够经常会把data_profiling也召回来。LLM精排把用户消息和Top-5候选Skill的SKILL.md内容一起输入给模型让模型输出一个JSON数组每个元素包含skill_name和confidence并且必须给出选择的理由。这一步能大幅过滤掉语义相近但实际不合适的Skill。在这个环节我踩过一次很深的坑当时很多Skill的description写得太长包含了大量细节比如“支持csv、xlsx、json、parquet格式”结果模型在做精排时被这些细节带偏频繁选到一个“万能数据处理”Skill而不是更精确的专用Skill。后来我把description统一压缩成一句话把细节挪到SKILL.md的正文里准确率立刻提升了约15%。这说明意图识别阶段的信息密度需要克制不是越详细越好。3.2 参数补全与校验Skill确定了接下来就是填参数。这一步是整个链路中最容易出幻觉的地方。模型经常出现的问题包括把用户没提到的可选参数填上了一个“看似合理”的默认值把路径写错比如用户说“桌面上的文件夹”模型直接填了/home/user/Desktop/而没有验证这个路径是否存在格式错误比如retention_days填成字符串seven而不是整数7。我的对策是分层校验第一层是类型校验由执行器根据manifest.yaml里的字段类型强制执行。模型输出JSON后先做JSON解析再把每个字段转成对应类型转不了就报错让模型重新生成。第二层是值域校验比如retention_days必须在0到90之间target_dir必须存在且可读。这一层会触发预检脚本预检不通过就直接返回错误信息而不是进入执行阶段。第三层是人工确认针对有风险的操作比如删除文件、写数据库在执行前让用户确认。虽然这一步会降低自动化程度但在生产环境里安全永远高于效率。我之前见过有人为了让Agent“全自动”而放弃了确认环节结果把一个环境的配置文件误删了回滚花了一整个下午。参数补全我还有一个心得不要让模型自己编造缺失参数的默认值。如果用户没说retention_days有默认值就用默认值没有默认值就主动向用户提问“要保留最近多少天的日志”而不是让模型猜一个数字。原因很简单用户没说就是不确定不确定的东西让模型猜十猜九错。3.3 执行、结果回填与失败降级执行阶段看起来是普通代码但有两个细节值得讲。第一个是中间状态的可观测性。跑一个Skill可能耗时很长比如数据清理跑5分钟用户在此期间如果看到Agent“一言不发”会觉得它卡死了。我的做法是让run.py在执行过程中通过标准输出输出结构化的事件比如[event] scan_started {target_dir: ./logs} [event] scan_completed {found_files: 120, eligible_files: 45} [event] archive_success {file: logs/2024-01-01.log, archived_to: archive/2024-01-01.log.tar.gz} [event] purge_started {count: 45} [event] purge_success {deleted_count: 45}这些事件会被上层监听并转换成流式的进度提示发回给用户。虽然技术上不难但它对“用户感知”的影响巨大。一个带进度反馈的Agent和一个闷头执行的Agent哪怕最终结果完全一样用户对前者的信任感也会强很多。第二个是失败降级。Skill执行失败是常态但上层怎么处理失败决定了整个Agent的可靠程度。我设计的策略是失败后先把结构化错误信息返回给模型让模型判断“这是可以重试的临时错误比如文件被占用”还是“需要换一种方法的逻辑错误比如目标目录不存在”。如果是前者模型可以附带修正参数后重试如果是后者模型必须停止操作并向用户解释问题绝不能自己换一条路径强行执行。这里的边界要写死否则模型会在一个思路上反复横跳浪费时间和token。结果回填则相对简单把manifest.yaml里声明的outputs字段整理成一个摘要文本拼接到对话上下文里。注意拼接摘要而不是完整输出因为完整输出的长度不可控。比如data_profiling这个Skill可能生成几十个统计指标但回填给模型的只需要一个“数据报告已生成任务ID为xxx发现3个异常字段”这样的摘要。4. 实测Skill效果评测维度与主观感受任何没有评测的方案都是耍流氓。Agent Skill的效果不能只看“偶尔成功”的Demo必须建立一套可量化的评测体系。4.1 评测方法论建立受控测试集我在项目里准备了一个受控测试集里面包含三类用例单Skill标准用例指令非常明确比如“清理./logs目录下超过7天的日志”这类用例用于测基本功多Skill组合用例指令比较复杂需要串联两个以上Skill比如“先看看/data下的数据概况再把所有过期的临时文件清理掉”这类用例用于测编排能力对抗性用例指令有歧义、参数缺失、或者包含危险操作比如“把那个文件夹删了”没有指定是哪个文件夹这类用例用于测安全边界。每一条测试用例都会记录以下几个指标任务完成率端到端是否成功、Skill调用准确率选没选对Skill、参数修正次数模型生成的参数被系统纠正了多少次、平均执行耗时、人工介入次数。4.2 三个常用Skill的实测数据下面这张表是我在项目中期对三个Skill做的100次测试统计样本不算大但趋势已经能说明问题Skill名称测试次数端到端完成率参数修正次数平均耗时主要失败原因cleanup_old_logs4082.5%1.2次/次3.8秒目标路径不存在、误把保留天数当过滤条件data_profiling3073.3%0.9次/次12.6秒读取了CSV后忘记处理编码问题导致报告字段错位weekly_report_generator3066.7%2.1次/次45.2秒多Skill串联时模型在前一个Skill的输出摘要中丢失了关键统计口径从数据里能看到几个有意思的结论。第一完成率和参数修正次数强相关。weekly_report_generator修正次数最多完成率最低说明模型在多步编排场景里最容易在参数传递环节出错。这提醒了一个设计方向Skill之间的数据传递不要依赖模型“理解”而是要设计成显式的结构化管道比如上游Skill把输出写入临时文件下游Skill直接读取该文件而不是让模型在上下文里总结字段。第二data_profiling的失败原因很典型。读取CSV时忽略了编码问题这在纯代码项目里是个很小的问题但在Agent场景里会被放大。因为模型没有实际执行能力它看到的“成功”只是代码返回的成功。如果代码遇到编码异常时没有清晰报错模型就会认为一切正常。所以Skill内部代码要有更强的防御性遇到异常不要慌返回准确错误信息比强行继续执行重要得多。第三平均耗时不等于体验。weekly_report_generator虽然耗时最长但用户体感反而还行因为中间有流式进度反馈。而cleanup_old_logs虽然只要3.8秒但如果失败用户感知是“Agent一句话不说就失败了”体验极差。所以我后来把评测指标里加了一项“失败反馈质量”看Agent在失败时是干巴巴地说“出错了”还是会给出可操作的错误原因。4.3 主观感受模型版本变化对Skill的影响写到这里必须提醒一个所有做Agent的人都绕不开的事底层大模型版本升级可能让你的Skill突然“失灵”。我在项目中期换过一次底层模型从旧版本切到新版本没改任何Skill代码结果发现cleanup_old_logs的端到端完成率从82%直接掉到65%。排查了半天不是检索问题不是代码问题而是新模型在精排阶段对SKILL.md里“安全约束”一节的关注度降低了经常选错参数格式。后来我调整了SKILL.md里的表述把“安全约束”移到“执行步骤”前面同时把参数示例从一句话改成表格效果才恢复。这件事给我的教训是Skill的设计必须要有跨模型鲁棒性。方法是对同一个Skill同时用至少两个不同厂商的模型做评测观察行为差异然后调整SKILL.md的措辞让它对措辞变化不那么敏感。不要为某个特定模型做适配因为模型迭代太快你的适配速度永远追不上。5. 我踩过的坑与后续扩展思路最后这部分不按时间线写按“坑的价值”排序。每条都是我付过学费换来的经验。5.1 坑一在SKILL.md里写了“执行频率”早期设计weekly_report_generator时我在SKILL.md里写了这样一句话“本Skill每周一上午9点自动执行”。初衷是想让模型理解这个Skill的定时场景结果模型直接把它当成一个可执行指令在用户问“你能帮我做什么”的时候自告奋勇说“我可以每周一自动生成报告需要我现在就执行一次吗”然后真的触发了一次报告生成。问题出在哪执行频率属于调度器的职责不是Skill的职责。“周期性执行”应该由外部的Cron或调度系统负责Skill只负责“给定参数执行一次并返回结果”。把调度信息写进SKILL.md会让模型产生混乱的自主性。我后来的做法是彻底移除所有和时间频率相关的描述调度逻辑统一放在外层。5.2 坑二Skill内部依赖了全局配置最开始写run.py时我偷懒直接在代码里import global_config读取了一个全局变量里的数据库连接串。这个做法在单个Skill跑Demo时毫无问题直到我开始并行跑两个Skill其中一个需要连接生产库另一个需要连接测试库两个Skill同时在读全局配置互相覆盖数据全乱了。正确的做法是每个Skill的执行上下文必须是隔离的。manifest.yaml里定义的所有输入都应该通过参数显式传入由执行器负责组装contextSkill代码本身不允许去读全局状态。这样不仅并行安全也方便测试——你可以很容易地为同一个Skill注入不同的测试环境。5.3 坑三文件操作没做并发保护有一次两个独立的Agent线程同时调用了cleanup_old_logs都指向同一个目录结果一个线程在压缩文件时另一个线程已经把原文件删了导致压缩任务直接崩溃。这属于典型的并发冲突。我做三件事来规避每个Skill执行前在工作目录下创建一个独一无二的workspace子目录所有中间文件都往这个子目录里放对要删除的文件先重命名成.trash_timestamp的隐藏文件再异步清理而不是直接删除在manifest.yaml里增加一个concurrency: false的标记由执行器在运行时上文件锁。这套方案之后再也没有出现过“文件还在压缩却被删除”的问题。5.4 坑四调用子进程时用了shellTrue最早写cleanup_old_logs时为了图省事我在run.py里用subprocess.run(cmd, shellTrue)去执行压缩命令。后来安全审查发现如果target_dir参数被用户恶意传入比如传入; rm -rf /shellTrue就会把它当成新命令执行。虽然Demo不会出事但生产环境这是不可接受的。我后来的习惯是能用Python标准库或shutil完成的就不用子进程非得用子进程也必须用参数数组形式subprocess.run([tar, -czf, file, target], checkTrue)同时关闭shell把参数严格转义。这算是最基础的安全底线不能省。5.5 后续扩展Skill版本管理、执行沙箱与Skill市场目前这个系统的雏形已经跑通但离我理想中的状态还有不少距离。接下来的扩展方向有三个第一个是Skill版本管理。现在manifest.yaml里有version字段但还没有形成正式的发布流程。我想引入类似语义化版本机制Skill升级时主版本变化要经过重新评测避免“静默升级”导致行为变化影响上层编排。第二个是执行沙箱。目前文件类Skill还能靠权限声明控制但未来如果引入更多需要联网的Skill比如“抓取网页并总结”光靠权限声明控制不了实际风险。理想方案是每个Skill都跑在独立的容器或沙箱里通过IPC和主进程通信。这块做起来工作量不小但值得投入。第三个是Skill市场。一旦Skill的目录结构和manifest协议稳定了就可以像npm一样做一个内部共享仓库团队成员把验证过的Skill推上去其他人直接拉取使用。这个想法还在设计阶段但我认为它是agent-skill生态走向规模化的必然路径。如果这篇文章能帮你少踩一两个坑那我的目的就达到了。Agent Skills这个方向还远没到“最佳实践已经定稿”的阶段现在正是亲手折腾的好时机。
返回列表