1. 为什么“写好”和“测好”是两件必须拆开做的事
很多人第一次接触 Agent Skills,脑子里想的都是“我写个脚本让 Agent 跑起来就完事了”。我一开始也这么想,结果上线第二天就被现实教育了。一个 Skill 从能跑到好用,中间隔着的不是代码量,而是测试维度和权限边界这两道坎。标题里说的“写好、测好、安全上线”,其实对应的是三个完全不同的工程阶段,混在一起做,最后一定是返工。
先把概念对齐一下。Agent Skills 本质上是给 Agent 挂载的可复用能力单元,它可能是一个脚本、一段提示词模板、一个 API 封装,或者几者的组合。它和 Agent 的关系,有点像插件和宿主程序:Agent 负责决策和调度,Skill 负责在特定场景下执行确定性动作。热词里有人问“skill 和 agent 的区别”,一句话概括就是——Agent 是大脑,Skill 是肌肉记忆。大脑可以灵活应变,肌肉记忆必须稳定可靠,所以对 Skill 的要求天然比 Agent 更“死板”:输入输出要可预期,失败要可捕获,权限要可收敛。
那为什么要把“写好”和“测好”拆开?因为写的时候你的目标是表达意图,测的时候你的目标是证伪意图。这两个心态是冲突的。写的时候你会不自觉地假设“用户会按我想的方式输入”“环境是干净的”“网络是通的”;测的时候你必须假设“用户会乱输”“环境是脏的”“依赖会挂”。我见过太多 Skill 在作者本机跑得飞起,一到别人机器上就报权限错误,典型的就是热词里那个“你需要来自 administrators 的权限才能删除”——写的时候没考虑权限,测的时候才发现删不掉文件。
所以这篇内容我按四个层次来拆:先讲设计思路(怎么写才不容易翻车),再讲核心细节(每个环节的坑在哪),然后是完整实操(从零到上线的流程),最后是问题排查(出事了怎么救)。适合已经写过一两个 Skill、但还没形成工程化习惯的人,也适合准备把 Skill 交付给团队用的开发者。小白也能看,因为我会把每个“为什么”都讲透,不堆术语。
2. 内容整体设计与思路拆解
2.1 先定边界:Skill 到底该管多宽
写 Skill 第一个决策不是技术选型,而是职责边界。我踩过的最大的坑,就是一开始把 Skill 写得太“聪明”。比如做一个“文件整理 Skill”,我让它自己判断哪些文件该删、哪些该留,结果测试时它把一个测试目录整个清空了。后来我改成:Skill 只负责“按给定规则移动文件”,判断逻辑交给 Agent 或用户。这样 Skill 的输入输出变得极其清晰——输入是规则列表,输出是移动结果报告。
这个思路背后的逻辑是:Skill 的确定性越高,测试成本越低。一个 Skill 如果内部有大量条件分支和模糊判断,你要覆盖的测试用例会指数级增长。反过来,把判断权上移,Skill 只做“执行器”,测试就变成对输入输出的枚举验证。热词里“自动化测试”和“安全测试”之所以经常一起出现,就是因为边界清晰的 Skill 才能被自动化测试覆盖。
具体怎么定边界?我常用一个“三问法”:
- 这个 Skill 的输入能不能用一句话描述清楚?如果不能,说明它管太宽了。
- 这个 Skill 失败时,能不能明确告诉调用方“哪一步失败了、为什么”?如果不能,说明它内部逻辑太黑盒。
- 这个 Skill 能不能在不依赖外部状态的情况下被重复执行?如果不能,说明它有隐藏副作用。
三个问题都过了,边界基本就合理了。
2.2 选型取舍:脚本、提示词还是混合
Agent Skills 的实现形态大致分三类:纯脚本型、纯提示词型、混合型。选哪种,取决于你的场景对确定性和灵活性的权重。
纯脚本型适合确定性要求极高的场景,比如文件操作、数据转换、API 调用。优点是行为完全可预测,测试就是跑单元测试;缺点是灵活性差,输入格式一变就得改代码。纯提示词型适合需要语义理解的场景,比如“把这段文字改写成正式语气”。优点是灵活,缺点是输出不稳定,测试要靠断言和人工抽检。混合型是主流做法:用提示词做输入解析和意图理解,用脚本做实际执行。
我个人的经验是:能用脚本兜底的地方,绝不用提示词。因为提示词的输出是概率性的,你没法保证它每次都返回同样的结构。热词里“鹈鹕测试提示词”这类东西,本质就是在测提示词的稳定性,但再稳的提示词也不如一个if-else可靠。所以我的混合型 Skill 通常是:提示词负责“把自然语言转成结构化参数”,脚本负责“拿着参数去执行”。
这里有个细节要注意:提示词和脚本之间的接口契约必须写死。比如提示词必须输出 JSON,字段名、类型、必填项都固定,脚本只认这个 JSON。这样测试时你可以单独测提示词的输出格式,也可以单独测脚本对固定 JSON 的处理,两边解耦。
2.3 权限设计:从第一天就当成一等公民
热词里权限相关的词特别多——“文件权限修复”“注册表权限问题”“trustedinstaller 权限怎么获得”“创建视图权限不足”“minio mc 命令给 buckets 设置 public 权限”。这说明权限问题是 Skill 上线后最高频的故障源。我的做法是:在写第一行代码之前,先把权限矩阵画出来。
权限矩阵要回答三个问题:
| 问题 | 说明 | 示例 |
|---|---|---|
| Skill 需要读什么 | 列出所有读取的资源 | 配置文件、输入目录、环境变量 |
| Skill 需要写什么 | 列出所有写入的资源 | 输出目录、日志文件、临时文件 |
| Skill 需要调用什么 | 列出所有外部依赖 | API、数据库、系统命令 |
画完矩阵后,逐项问:这个权限是必需的吗?能不能用更小的权限替代?比如只需要读文件,就不要申请写权限;只需要调用某个 API 的 GET 接口,就不要给它 POST 权限。这就是最小权限原则。
我见过一个反面案例:某个 Skill 为了“方便”,直接申请了管理员权限,结果测试时误删了系统文件。后来改成只对特定目录有写权限,问题就消失了。热词里“你需要来自 system 的权限才能对此文件夹进行更改”这种报错,很多时候就是因为 Skill 的权限设计太粗放,要么全给要么全不给,没有中间态。
3. 核心细节解析与实操要点
3.1 输入校验:把脏数据挡在门外
Skill 的第一道防线是输入校验。我见过太多 Skill 直接拿用户输入去拼命令或拼路径,结果遇到特殊字符就崩。比如用户输入一个带空格的路径,脚本没做转义,直接rm -rf $path,后果不堪设想。
输入校验要分三层:
- 格式校验:类型对不对、必填项有没有、长度超没超。比如路径必须是绝对路径,参数必须是数字。
- 语义校验:值合不合理。比如“删除天数”不能是负数,“端口号”必须在 1-65535 之间。
- 安全校验:有没有注入风险。路径里有没有
..,命令里有没有;|&这些 shell 元字符。
实操上,我习惯在 Skill 入口处写一个validate_input函数,所有校验逻辑集中在这里,校验不过直接返回结构化错误,不往下走。这样测试时只需要针对这个函数写用例,覆盖各种边界值。
注意:校验失败的错误信息要足够具体,但不要泄露内部路径或敏感信息。比如“路径格式不正确”就够了,不要返回“/home/admin/secret/config.yaml 不存在”。
3.2 输出契约:让调用方知道发生了什么
Skill 的输出必须是结构化的,不能是一坨自然语言。我推荐统一用 JSON,包含三个字段:status(成功/失败)、data(成功时的结果)、error(失败时的原因)。这样调用方(Agent 或其他系统)可以程序化地判断结果,而不是去解析文本。
为什么这点重要?因为 Agent 调度 Skill 时,需要根据 Skill 的返回决定下一步。如果 Skill 返回的是“操作完成了,但是有几个文件没处理”,Agent 根本没法判断该不该继续。结构化输出让 Agent 能做确定性决策。
输出契约还要考虑幂等性。同一个输入重复执行,结果应该一致。如果 Skill 有副作用(比如写文件),要保证重复执行不会产生重复数据。常见做法是用唯一 ID 去重,或者先检查目标状态再执行。
3.3 日志与可观测性:出事时能查到原因
Skill 上线后,你不可能盯着每一次执行。所以日志是唯一的“事后现场”。我的日志规范是:
- 入口日志:记录输入参数(脱敏后)、调用时间、调用方标识。
- 关键步骤日志:记录每一步的开始和结束,以及耗时。
- 出口日志:记录输出结果、状态码、总耗时。
- 错误日志:记录异常堆栈、上下文信息。
日志级别用 DEBUG/INFO/WARN/ERROR 四档。生产环境默认 INFO,排查问题时临时开 DEBUG。日志要写到文件或标准输出,方便被采集系统收集。
热词里“bqueues 查看队列权限”这类操作,本质就是在做可观测性检查。Skill 的日志也应该支持类似的查询能力——比如按调用方、按时间段、按状态筛选。
3.4 测试分层:单元、集成、端到端
测试是“测好”的核心。我把 Skill 的测试分成三层:
- 单元测试:测每个函数,尤其是输入校验、核心逻辑、输出格式化。用 mock 隔离外部依赖。
- 集成测试:测 Skill 和外部系统的交互,比如 API 调用、文件读写、数据库操作。用测试环境或容器。
- 端到端测试:模拟真实调用场景,从 Agent 发起调用到 Skill 返回结果,全链路验证。
三层测试的投入比例大概是 6:3:1。单元测试写得越多,后面越省心。我见过有人跳过单元测试直接做端到端,结果每次改代码都要跑一遍完整流程,慢且难定位问题。
测试用例的设计要覆盖:正常路径、边界值、异常输入、外部依赖失败、并发调用。尤其是外部依赖失败,很多人不测这个,上线后 API 一挂 Skill 就崩。正确做法是给外部调用加超时和重试,测试时用 mock 模拟超时和错误响应。
3.5 权限收敛:从宽到窄的渐进式收紧
权限设计不是一次性的,而是渐进式收紧的过程。我的做法是:
- 开发阶段:给足权限,方便调试。
- 测试阶段:按权限矩阵收敛到最小集,验证功能是否正常。
- 上线阶段:用独立的低权限账号运行,再次验证。
- 运维阶段:定期审计权限使用情况,发现多余权限就收回。
这个过程中最容易出问题的是“测试阶段能跑,上线阶段跑不了”。原因通常是测试环境用了高权限账号,上线环境用了低权限账号,某些操作被拒绝了。所以测试阶段就要用和上线一致的权限配置,别偷懒。
热词里“cursor 上怎么完全放开权限”这种需求,我理解是想省事,但我的建议是:开发环境可以放开,生产环境必须收紧。而且放开也要有边界,比如只对特定目录放开,而不是全局放开。
4. 实操过程与核心环节实现
4.1 从零写一个 Skill 的完整流程
我以一个“日志清理 Skill”为例,走一遍完整流程。这个 Skill 的功能是:给定一个目录和保留天数,删除该目录下超过保留天数的日志文件。
第一步:定义接口契约。
输入:
{ "target_dir": "/var/log/myapp", "retain_days": 7, "dry_run": false }输出:
{ "status": "success", "data": { "scanned": 120, "deleted": 15, "skipped": 105, "errors": [] }, "error": null }第二步:画权限矩阵。
| 资源 | 权限 | 说明 |
|---|---|---|
| target_dir | 读+写 | 需要扫描和删除文件 |
| 日志文件 | 写 | 记录操作日志 |
| 系统时间 | 读 | 计算文件年龄 |
不需要的权限:网络访问、其他目录读写、系统命令执行。
第三步:写输入校验。
import os import re def validate_input(params): errors = [] target_dir = params.get("target_dir") if not target_dir or not isinstance(target_dir, str): errors.append("target_dir 必须是非空字符串") elif not os.path.isabs(target_dir): errors.append("target_dir 必须是绝对路径") elif ".." in target_dir: errors.append("target_dir 不能包含 ..") retain_days = params.get("retain_days") if not isinstance(retain_days, int) or retain_days < 1: errors.append("retain_days 必须是大于 0 的整数") dry_run = params.get("dry_run", False) if not isinstance(dry_run, bool): errors.append("dry_run 必须是布尔值") return errors第四步:写核心逻辑。
import time import logging def clean_logs(params): target_dir = params["target_dir"] retain_days = params["retain_days"] dry_run = params.get("dry_run", False) cutoff = time.time() - retain_days * 86400 scanned = 0 deleted = 0 skipped = 0 errors = [] for root, dirs, files in os.walk(target_dir): for name in files: if not name.endswith(".log"): continue scanned += 1 path = os.path.join(root, name) try: mtime = os.path.getmtime(path) if mtime < cutoff: if dry_run: logging.info(f"[dry-run] 将删除 {path}") else: os.remove(path) logging.info(f"已删除 {path}") deleted += 1 else: skipped += 1 except PermissionError as e: errors.append({"path": path, "reason": "权限不足"}) logging.error(f"权限不足: {path}") except Exception as e: errors.append({"path": path, "reason": str(e)}) logging.error(f"处理失败: {path}, {e}") return { "status": "success" if not errors else "partial", "data": { "scanned": scanned, "deleted": deleted, "skipped": skipped, "errors": errors }, "error": None }第五步:写测试。
import pytest import tempfile import os import time def test_validate_input(): assert validate_input({"target_dir": "/tmp", "retain_days": 7}) == [] assert validate_input({"target_dir": "relative", "retain_days": 7}) != [] assert validate_input({"target_dir": "/tmp", "retain_days": 0}) != [] assert validate_input({"target_dir": "/tmp/../etc", "retain_days": 7}) != [] def test_clean_logs_dry_run(): with tempfile.TemporaryDirectory() as tmpdir: old_file = os.path.join(tmpdir, "old.log") with open(old_file, "w") as f: f.write("test") os.utime(old_file, (time.time() - 10*86400, time.time() - 10*86400)) result = clean_logs({"target_dir": tmpdir, "retain_days": 7, "dry_run": True}) assert result["data"]["deleted"] == 1 assert os.path.exists(old_file) # dry-run 不实际删除第六步:集成测试。
在容器里跑,用低权限账号,验证权限不足时的行为。比如把目录权限设为只读,看 Skill 是否正确返回错误而不是崩溃。
第七步:上线前检查。
- 权限矩阵是否最小化
- 日志是否脱敏
- 错误处理是否覆盖所有外部调用
- 是否有超时和重试
- 是否有 dry-run 模式
- 文档是否完整
4.2 参数计算与选择过程
上面例子里的retain_days为什么用整数天而不是秒?因为用户心智模型是“天”,用秒容易算错。但内部计算要转成秒,retain_days * 86400。这里有个细节:86400 是 246060,不要硬编码,用常量或datetime.timedelta更清晰。
再比如dry_run默认值设False还是True?我倾向设False,因为大多数调用是正常执行。但如果是高风险操作(比如删除),默认设True更安全,强制调用方显式传False才真删。这个取舍取决于操作的危险程度。
4.3 上线流程与灰度策略
Skill 上线不是“写完就发”,而是灰度发布。我的流程是:
- 内部环境验证:开发者和测试人员用,跑一周。
- 小流量灰度:选 1-2 个非关键调用方接入,观察日志和错误率。
- 扩大灰度:逐步增加调用方,每次观察 24 小时。
- 全量上线:所有调用方接入。
- 回滚预案:准备好一键回滚的脚本和文档。
灰度期间重点看三个指标:错误率、耗时、权限拒绝次数。错误率超过 1% 就暂停,耗时超过预期 2 倍就排查,权限拒绝次数不为零就检查权限矩阵。
热词里“区分年末和年中上线”这个说法,我理解是不同时间点的上线策略可能不同。比如业务高峰期上线风险更高,应该避开。这个思路可以借鉴:Skill 上线要选低峰期,并且避开其他系统变更窗口。
5. 常见问题与排查技巧实录
5.1 权限类问题速查
权限问题是最高频的,我整理了一个速查表:
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 文件删不掉 | 文件被占用或权限不足 | ls -l看权限,lsof看占用 | 改权限或先释放占用 |
| 目录写不了 | 目录权限或磁盘满 | df -h看磁盘,ls -ld看权限 | 清理磁盘或改权限 |
| API 调用 403 | 凭证过期或权限不足 | 看 API 返回的具体错误 | 更新凭证或申请权限 |
| 注册表改不了 | 权限不足 | 看注册表项权限 | 用管理员权限或改权限 |
| 创建视图失败 | 数据库权限不足 | 看数据库错误日志 | 授予 CREATE VIEW 权限 |
排查权限问题的通用思路是:先确认“谁在什么资源上做什么操作被拒绝了”。这三个要素定位清楚,问题就解决了一半。
5.2 测试类问题排查
测试中最常见的问题是“本地能过,CI 不过”。原因通常是环境差异:本地有某个依赖,CI 没有;本地是管理员,CI 是普通用户;本地网络通,CI 不通。解决办法是用容器统一环境,把依赖、权限、网络都固化下来。
另一个问题是“测试通过但上线失败”。这通常是测试覆盖不足,没测到某个边界。我的经验是:每次线上出问题,都要补一个对应的测试用例,防止回归。这样测试集越来越全,线上问题越来越少。
5.3 上线后问题排查
上线后出问题,第一件事是看日志。日志里通常有错误堆栈和上下文。如果日志不够,就临时开 DEBUG 级别,复现问题。复现不了就加埋点,等下次出现。
第二件事是确认影响范围。是单个调用方受影响,还是全部?是特定输入触发,还是随机?范围清楚了,才能决定是回滚还是热修。
第三件事是回滚。如果影响面大,先回滚止损,再慢慢排查。回滚要快,所以上线前就要准备好回滚脚本,别临时写。
提示:Skill 的版本号要规范,每次上线打 tag,回滚时直接切到上一个 tag。不要用“最新版”这种模糊的版本标识。
5.4 独家避坑技巧
几个我踩过坑才总结出来的技巧:
- 永远提供 dry-run 模式。任何有副作用的 Skill,都要支持“只说不做”,让调用方先预览结果。
- 错误信息要可操作。不要只说“失败了”,要说“失败了,因为 X,你可以尝试 Y”。
- 限制并发。Skill 被多个调用方同时调用时,要加锁或限流,防止资源竞争。
- 超时必设。任何外部调用都要设超时,默认 30 秒,可配置。
- 日志脱敏。路径、用户名、token 这些敏感信息,日志里要打码。
- 测试用真实数据。用 mock 数据测不出真实问题,尽量用脱敏后的生产数据。
- 文档和代码同步更新。接口变了,文档没变,调用方就会踩坑。
6. 把 Skill 当成产品来运营
写到这里,我想说的是:Skill 不是写完就结束的代码,而是一个需要持续运营的“产品”。它有用户(调用方)、有版本(迭代)、有故障(线上问题)、有生命周期(上线到下线)。你用做产品的态度对待它,它才会上线后少给你惹麻烦。
我自己的习惯是给每个 Skill 建一个“健康档案”,记录:版本历史、已知问题、权限矩阵、测试覆盖率、上线时间、负责人。每次出问题就更新档案,每次迭代就回顾档案。这样时间长了,你对每个 Skill 的状态都心里有数,不会出现“这个 Skill 谁写的、还能不能跑”这种尴尬。
最后分享一个小技巧:新 Skill 上线前,先让一个不了解它的人按文档跑一遍。如果他能跑通,说明文档和接口设计没问题;如果他跑不通,说明你还有隐藏假设没写出来。这个“小白测试”比任何自动化测试都能发现文档和易用性问题。