这阵子我在折腾 OpenClaw 3.13 的 Docker 版,把 Skill 编写这块从头到脚捋了一遍。如果你也是第一次接触这类 Agent 框架,应该会发现系统本身跑起来并不难,真正卡住人的,往往是“怎么写第一个能被智能体自动调用的技能文件”。这篇就围绕 3.13 + Docker 的环境,把我从目录结构、清单字段、执行脚本一直摸到日志排查和描述词打磨的完整过程做一个实操向的记录,不绕理论,直接给结论和可以抄作业的示例。
Skill 这个东西,说穿了就是对 Agent“能力边界的扩展”。系统自带的能力无非是读文件、写文件、跑命令、调接口这一层,但实际业务往往有更具体的诉求,比如“帮我整理今天的日报并生成周报素材”“拉取某个接口的数据后做成表格”。你把这类诉求沉淀成一个固定格式的技能文件,放进指定的技能目录里,Agent 在运行时会自动加载、按需调用。换句话说,框架是骨架,Skill 才是血肉。
我第一次跑通 3.13 的时候,花了整整一个下午卡在“明明文件都放对了,Agent 就是不认”这个问题上。后来才发现不是 Skill 文件的问题,而是 Docker 的挂载路径和容器内默认扫描路径没对齐。这篇文章会专门把这类坑拧出来讲清楚。
1. Skill 在 3.13 里到底是什么定位:它不是配置,也不是插件
很多人刚接触 Skill 机制时会把它跟“配置文件”或“插件”搞混,觉得写几个 YAML 字段就是技能了。实际上这是一个完整的能力封装单元。我的理解是:Skill 是一套“能被大模型感知、被你定义、由容器执行”的最小功能包,它由三件事构成——能被模型读懂的任务描述、能被模型选中的触发条件、能被系统执行的落地逻辑。
1.1 Skill 的基本构成
3.13 里一个 Skill 通常是一个独立目录,目录名本身不关键,关键的是目录里的清单文件和入口脚本。清单文件定义这个技能是干什么的、适合在什么场景下用、需要哪些参数;入口脚本则是真正干活的程序,可以用 Python、Shell,甚至是 Node 脚本。
我之前拿手机 App 做过类比:框架相当于手机操作系统,自带能力是系统应用,Skill 就是你在应用商店里装的一个个独立 App。每个 App 有自己的功能描述、图标和入口,系统不需要知道它内部怎么实现,只要按规范调用就行。Skill 也一样,模型会读你的描述,判断当前需求是否匹配,匹配后调用脚本执行,再把结果回传给你。
这里有个容易误解的点:Skill 不是简单的“关键词替换”或“模板文本”。它能够读取上下文、接收参数、执行命令,然后把执行结果作为新的观察信息反馈给模型。也就是说,技能是有状态的、可交互的,而不是静态说明。
1.2 跟插件、工作流的边界
| 类型 | 特点 | 典型使用场景 | 改动成本 |
|---|---|---|---|
| Skill | 按需调用、由模型决策是否启用 | 数据获取、内容生成、定时汇总 | 低,写一个目录即可 |
| 插件 | 深度集成到运行时、常驻加载 | 框架底层功能增强 | 高,需要理解宿主接口 |
| 工作流 | 固定顺序、一次性编排 | 流水线作业、多步骤串联 | 中,需设计状态流转 |
在 3.13 里,日常大部分“让 Agent 多一个新能力”的需求,Skill 都是最轻量的解法。插件适合动框架本身的逻辑,工作流适合应对固定流程,而 Skill 的定位是“模型感知、按需触发”,适合那些调用时机不确定、依赖自然语言意图匹配的功能。
这也是为什么开头我说它不像配置文件——配置文件改的是系统行为,Skill 改的是模型可调度的工具集。顺着这个思路,后面写的时候就清楚自己要往目录里放什么了。
2. Docker 侧的准备:镜像、数据卷、启动参数怎么搭才不出幺蛾子
3.13 官方提供了 Docker 镜像,省去了本地配环境的麻烦,但 Docker 这层反而成了很多人第一次写 Skill 时最大的拦路虎。我自己最初的失败完全出在挂载路径上,跟 Skill 本身毫无关系。先说结论:容器跑起来只是开始,你真正要关心的是“容器内扫描哪个目录、宿主机怎么把这个目录喂进去”。
2.1 数据卷与目录映射
以我用的 docker-compose 编排为例,一份能直接改的配置大概是这个形态:
version: '3.8' services: openclaw: image: your-registry/openclaw:3.13 container_name: oc-313 ports: - "127.0.0.1:8080:8080" volumes: - ./skills:/app/skills - ./config:/app/config - ./data:/app/data environment: - TZ=Asia/Shanghai - SKILLS_DIR=/app/skills restart: unless-stopped这里最关键的是把./skills这个宿主机目录映射到容器内的/app/skills,然后通过环境变量告诉框架技能目录在哪里。我见过不少朋友卡在这一步:他们直接在容器里用docker exec创建文件,结果容器一删,一切归零。正确做法是始终在宿主机维护skills目录,容器只负责读取和执行。
如果是第一次用,建议先在宿主机建好skills/config/data三个目录再启动容器。配置目录放全局参数,数据目录放运行时产生的缓存文件,技能目录自然就是后面所有 Skill 的根据地。不要图省事把所有东西塞进一个目录,后面调试日志的时候你会感谢这个分离。
2.2 启动后的健康检查
启动之后别急着写 Skill,先确认三件事:容器状态是否健康、8080 端口是否监听、技能目录是否被正确挂载进容器。检查命令很简单:
docker ps | grep oc-313 docker exec oc-313 ls -la /app/skills如果第二行能直接看到宿主机skills目录里的文件,说明挂载成功。看不到就回头检查 compose 文件里的相对路径,别急着写代码。这一步做完,后面所有排错都清爽很多。
3. 写一个最小可用 Skill:目录、清单、执行脚本一次跑通
我建议第一个 Skill 别搞太复杂,就做一个“把一段文本转成日报要点”的功能。它既能验证框架的加载机制,又能完整走一遍“模型读描述 → 匹配调用 → 脚本执行 → 结果返回”的链路。
3.1 目录结构和清单文件
按我上面的 compose 配置,在宿主机skills下新建一个目录,比如daily_summary,结构如下:
skills/ └── daily_summary/ ├── skill.yaml ├── main.py └── README.mdskill.yaml是核心,我把它称为技能的“说明书”。下面是 3.13 环境里一个能用的最简版本:
name: daily_summary version: "1.0.0" description: | 当用户需要把零散的工作内容整理成日报或周报要点时使用。 输入是一段非结构化的文字描述,输出是分条列出的结构化要点。 entrypoint: ./main.py parameters: text: type: string required: true description: 需要整理的原始工作内容这个文件里每个字段都值得说清:
name:技能的唯一标识,尽量用英文小写加下划线,避免特殊字符。description:这是模型判断“要不要调用当前技能”的核心依据,一定写清楚触发场景。我最初写的是“文本整理”,结果模型各种场景都想调用,后来改成“把零散工作内容整理成日报/周报要点”,调用准确率明显提升。entrypoint:入口脚本路径,相对 skill.yaml 所在目录即可。parameters:参数定义,声明脚本接收什么输入。
3.2 入口脚本的写法
main.py的逻辑很简单,接收一个text参数,输出整理后的要点:
#!/usr/bin/env python3 import sys import json def main(): raw = sys.stdin.read() try: payload = json.loads(raw) except json.JSONDecodeError: payload = {"text": raw} text = payload.get("text", "") lines = [line.strip("-• ") for line in text.splitlines() if line.strip()] summary = [f"- {line}" for line in lines] result = "今日工作要点:\n" + "\n".join(summary) print(result) if __name__ == "__main__": main()这段代码不复杂,但有一个容易被忽略的点:参数是通过标准输入以 JSON 形式传入的,不是命令行参数。这种设计的优势在于能传递复杂结构,同时避免命令行转义的各种坑。我第一次写的时候直接从sys.argv拿参数,结果框架传过来的是一大段带上文背景的 JSON,解析直接失败。改成读标准输入之后,问题迎刃而解。
3.3 加载与验证
文件放好之后,重启容器让框架重新扫描技能目录:
docker restart oc-313 docker logs oc-313 --tail 100 | grep skill正常情况日志里会出现类似“skill loaded: daily_summary”的记录,说明加载成功。如果日志里没有,先别怀疑脚本逻辑,大概率是 YAML 格式或目录扫描路径的问题。
验证阶段建议直接给 Agent 发一个能匹配描述的自然语言请求,比如“帮我整理一下今天的工作内容:上午开了需求评审会,下午修复了两个线上 bug,晚上写周报”。如果框架正确调用技能,日志里能看到调用记录,回复内容就是脚本产出的格式化结果。
4. 通过 Docker 日志和容器状态排查:从加载失败到调用异常
Skill 加载不成功这件事,新手十有八九会碰到。我在 3.13 上踩过的坑整理下来,大致可以分成三类。这里直接把排查链路写出来,比给单个答案要实用得多。
4.1 加载失败:先看目录,再看格式
现象是重启容器后日志里完全没有技能加载记录,或者显示“skill invalid”。
排查顺序:
- 确认容器内路径:
docker exec oc-313 ls -la /app/skills,如果看不到daily_summary目录,就是挂载路径对不上,回去看 compose 文件。 - 确认 YAML 能被解析。YAML 对缩进极其敏感,我用
key: value时少写了一个空格,框架整个跳过该文件。怀疑格式问题时,可以在宿主机用python -c "import yaml; print(yaml.safe_load(open('skills/daily_summary/skill.yaml')))"快速验证。 - 确认入口文件路径写的是相对 skill.yaml 的路径。如果把
main.py写成/abs/path/main.py,容器重启后基本必挂。
4.2 调用异常:模型没选中 vs 脚本执行报错
现象不同,处理方式完全不同。这里做了一张对照表:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 用户需求明确,但 Agent 没有调用任何技能 | 触达条件不匹配,或描述写得太模糊 | 检查 description 是否覆盖该需求场景;临时把触发条件放宽 |
| 日志显示技能被调用,但没有输出 | 脚本异常,或参数解析失败 | 进入容器手动执行脚本,喂同样的 JSON 看是否报错 |
| 输出内容乱码或缺失 | 标准输入编码不一致 | 在脚本里显式指定 UTF-8,输入文本做 strip 处理 |
| 技能工作时好时坏 | 输入参数长度超限或包含特殊字符 | 加参数长度校验,JSON 中转义特殊字符 |
排查脚本问题的最好办法是绕开框架,直接在容器里手动跑一次入口脚本。比如:
docker exec -it oc-313 /bin/bash cd /app/skills/daily_summary echo '{"text": "上午开会 下午修bug"}' | python3 main.py这一步能立刻区分问题出在“框架没调用”还是“脚本本身失败”。我自己有两次都是脚本里 Python 依赖没装全,容器里执行直接 ModuleNotFoundError,跟 Skill 机制一点关系都没有。
4.3 改完文件不生效:别幻想热加载
很多框架支持文件修改后自动生效,但 3.13 默认对 Skill 目录的扫描不是严格意义上的“热加载”。你改了skill.yaml或main.py之后,有时能自动感知,有时不能,这取决于文件系统事件机制能否在 Docker 挂载层正常工作。
我自己实测下来,最稳妥的方式是:每改一次,docker restart oc-313一次。别嫌麻烦,频繁重启换来的确定性能省海量排查时间。如果实在不想重启,可以用docker exec oc-313 touch /app/skills/daily_summary/skill.yaml触发一次文件变化,往往也能让框架重新加载,但这个操作在部分环境下并不可靠。
5. 从能用到好用:描述词和参数边界的实战心得
一个 Skill 能被写出来是第一步,能被“正确且稳定地调用”才是进阶。这里特别值得花时间打磨的是两件事:给模型看的描述、给脚本用的参数边界。
5.1 描述区的写作质量,直接决定调用准确率
我见过不少人花大量时间写脚本,最后却毁在一句话描述上。模型选技能靠的是语义匹配,不是文件目录名。描述写得模糊,模型要么不调、要么乱调。
我常用的一个技巧是“场景 + 任务 + 输入 + 输出”四段式:
description: | 当用户需要将一段非结构化的会议记录或工作流水整理成日报要点时使用。 输入是自然语言文本,输出是带分级标记的要点列表。 如果不涉及日报/周报整理场景,不要使用本技能。最后那句“不使用本技能”的负面提示特别有用,能显著降低误调用率。description里的负面约束相当于给模型划了一条红线,模型在不确定时会倾向放弃而不是误用。
5.2 参数与安全边界
参数定义不要只写类型,还要做运行时校验。我在main.py里加过这样一段:
if not text or len(text) > 10000: print("文本为空或超出处理上限,请缩小输入范围后重试") sys.exit(0)脚本里主动做防御比依赖框架更可靠。另外,涉及文件路径、Shell 命令的技能,一定要限制参数内容,不然任何参数都可能变成输入注入的入口。不要相信模型传过来的任何值,把参数当用户输入处理,该过滤的过滤、该转义的转义。
另一个容易被忽略的点是:不要在 Skill 里写死敏感凭据,比如 Token、数据库密码。技能文件通常会跟随仓库分发,一旦泄露就是安全事故。我在项目里的做法是把这类信息放到/app/config下的环境变量文件里,脚本运行时读取,不落地在技能目录。
6. 我踩过的三个坑,以及最后留下的经验清单
Skill 编写本身不难,难的是对运行环境的理解和各种阴晴不定的时序问题。挑三个印象最深的写下来,给后来者提个醒。
6.1 折腾半天,结果是文件所有权问题
第一次在 Docker 里跑通 Skill 后,我发现修改脚本后容器内死活不更新,一开始怀疑是镜像缓存,折腾了快两小时。后来才发现宿主机的文件权限漂了,容器用户读不到文件更新。解法很简单:把技能目录的所有者改成容器用户。在宿主机执行chown -R 1000:1000 skills/就行。
这类问题没有日志会明确告诉你,表现就像“技能没生效”,特别迷。
6.2 中文内容乱码,问题出在标准流编码
第二个坑是脚本输出中文在 Agent 返回时全部变成乱码。排查了很久,最后确认是 Python 在容器里默认输出编码跟主程序预期的 UTF-8 不一致。对症下药,在入口脚本最上面强制三行:
import sys sys.stdout.reconfigure(encoding='utf-8')对于 Python 3.7+,重新配置标准流编码是有效的修法。这个问题在纯文本技能里隐藏很深,因为不报错、只乱码,特别浪费时间。
6.3 强行做一个“万能技能”,反而更不可用
第三个坑是我自己设计上的问题。一开始为了省事,我写了一个“什么都能干”的通用技能,描述写得极其宽泛,把文件处理、网页抓取、文本总结全塞进去。实际运行时模型几乎不会主动调用它,因为一个模糊的技能描述很难跟具体需求产生高置信匹配。后面拆成三四个职责单一的小技能,调用率立刻上来了。
这也算是经验:宁可多写几个小技能,也别试图用一个巨无霸技能覆盖所有场景。单一职责在写 Skill 时同样适用。
最后的成稿里,我给自己定了一个清单,每次新增技能都会逐条过一遍:
- 目录独立,清单文件字段完整,入口脚本可单独执行
- 描述采用“场景 + 任务 + 输入 + 输出”结构并带负面提示
- 参数做长度和类型校验,执行路径和相关权限收敛
- 不写死任何凭据,敏感配置统一从环境变量读
- 改动后干净重启容器,并确认日志里有加载记录
这五点做完,技能基本上稳定可靠。后续如果要扩展,我估计会往“技能之间的互相调用”方向试试,比如先抓取数据再自动生成日报,两个技能串起来用。等实测跑通了再回来补充。