大家应该都有过这种经历:自动化脚本写的时候很爽,两个月后再看简直想删库跑路。我自己就曾用 Python 给“龙虾”这个开源自动化平台补过好几个自定义技能,也就是 Skill。它在社区里被叫“龙虾”,是因为项目仓库的代号图标有点像龙虾钳子,实际上是一套偏智能体方向的自动化任务引擎。最让我舒服的一点是:它把“写脚本”和“挂载能力”拆开了,你只需要遵循它定义好的契约,用 Python 写一个技能包,平台负责注册、发现、调度、日志和权限管理。
这篇文章不是泛泛介绍怎么写 Python,也不是教 Python 基础语法,而是围绕“为龙虾平台开发自定义技能 Skill”这条主线,讲清楚技能模型是什么、最小技能怎么写、一个真实的文件归档技能怎么落地,以及调试和发布时容易踩的坑。适合已经在跑龙虾平台、想给它扩展专属能力的人;如果你是刚接触自动化平台、也想把常用脚本“技能化”,同样可以照着一步步做。
1. 先摸清龙虾的技能模型:技能不是脚本,而是一个带契约的包
很多人第一次写技能的时候,会下意识地把它当成“一个稍微正规点的脚本”。这个理解不能说全错,但会限制后面的设计。我在前几个技能上就吃过亏——直接在 handler 里写了大量业务逻辑,结果平台升级一次版本,技能就崩一次。后来我才意识到,技能在龙虾里的定位更像“一个带契约的 Python 包”,而不是“一个能跑的脚本”。
1.1 技能的本质:清单 + 事件 + 代码入口
一个最简技能通常由三个部分组成:清单文件、事件声明、代码入口。
清单文件一般叫manifest.json,放在技能包的根目录。它告诉平台三件关键信息:这个技能叫什么、能干什么、入口在哪里。平台扫描技能目录时,第一个看的就是它,如果清单解析失败,整个技能会被直接忽略,而且日志里通常只有一句很隐晦的提示。所以我在写所有技能时,第一步永远是先把manifest.json补完整,再写业务代码。
事件声明决定了这个技能在什么时候被触发。龙虾平台有一套自己的事件总线,比如文件创建、定时任务、消息到达、外部 webhook,甚至另一个技能发出的内部信号。技能开发者要做的,是声明“我关心哪些事件”,然后为每个事件提供一个处理函数。
代码入口则是最普通的 Python 文件,通常叫handler.py或main.py。平台会按照清单里entrypoint字段指定的方式加载它。这里有一个值得注意的设计:平台不会在安装技能时就把代码全部读进内存,而是采用懒加载。只有事件第一次触发时,技能模块才会被真正 import。所以技能启动时的开销并不来自“加载技能数量”,而是来自“同时触发的事件数量”。
1.2 平台与技能的生命周期:“发现、装载、分发、回收”
深入用了一段时间后,我把整个生命周期拆成了四个阶段,分别是“发现、装载、分发、回收”。理解这四个阶段,有助于排查很多“为什么技能没反应”的问题。
发现阶段,平台扫描配置的技能目录,读取每个子目录下的manifest.json,然后把技能元数据加入内存索引。这个阶段通常会打印一行日志,比如“Skill discovered: xxx”,如果你没看到这行日志,说明清单文件本身有问题,或者目录结构不对。
装载阶段,技能模块被 import,平台会检查入口函数是否存在。此时任何顶层代码都会执行,所以我建议尽量别在模块顶层做耗时初始化,比如连接数据库、请求外部 API。最好都放到第一次事件进来时再做。否则平台扫描重启一次,你的技能就要被强制“热启动”一轮,慢的时候整个平台事件循环都会被拖住。
分发阶段,事件对象被序列化后传给技能处理函数。平台会做一层事件负载的校验,如果 payload 里缺少必填字段,会直接返回校验错误,技能代码根本不会被调用。
回收阶段,事件处理完成后,平台会回收临时资源、关闭技能创建的句柄、记录耗时和结果。如果技能内部自己开了线程池或者定时器,却没有正确关闭,这个阶段就会留下“悬挂资源”。
1.3 两种交互模式:事件监听器与动作工具
龙虾平台的技能并不只有“被动响应事件”这一种玩法,它还有第二种,也就是把能力暴露成“动作工具”,供任务流或上层 Agent 调用。这两种模式经常被混在一起用,但设计思路完全不同。
事件监听器模式适合“平台主动告诉技能发生了什么”。比如说文件落盘了、某个目录变化了,技能跟着做处理。它的核心特征是反应式,技能自己不需要主动出击。
动作工具模式适合“别人来找技能帮忙”。技能在自己的actions里声明一个动作,比如archive_files,并写清楚参数类型、必填选项、返回结构。平台的任务编排器或者对话式 Agent 在生成计划时,会把这个动作当成一个可调用的“工具”,通过工具名在技能注册表里找得到它,再按照参数 schema 自动补全参数。
这两种模式不是互斥的。一个成熟的归档技能可以同时做两件事:一方面监听文件创建事件自动归档,另一方面暴露一个手动归档动作,让用户随时可以通过对话或任务流触发。它们共用同一套底层逻辑,但入口不同。理解了这一点,后面写真实技能时就不会把代码糊成一团了。
2. 搭一个能被平台发现的最小技能骨架
有些人在上手新平台时,总喜欢一上来就写完整业务逻辑,结果先跌在环境配置上。龙虾平台对技能目录的约定比较严格,但也简单。先花二十分钟把一个最小技能跑通,后面加业务代码就会顺手很多。
2.1 环境与目录约定:让平台一眼认出技能
先确认本地环境:Python 3.10 以上版本,装好龙虾提供的命令行工具。我用的是lbt,也就是 lobster tool 的缩写。安装之后,可以用lbt version验证是否能用。
一个最基本的技能包长这样:
my_skill/ ├── manifest.json ├── handler.py ├── resources/ │ └── templates/ └── .lobster/ └── state.json这里几个目录各有用途。
manifest.json必须是 JSON 格式,而且是 UTF-8 编码。我看到有人为了写注释方便,把它写成了 JSON5 或者带注释的 JSON,平台直接不认。这是第一个坑。
handler.py是技能主逻辑。名字不一定要叫这个,可以自己在 manifest 里指定,只要保持一致就行。resources/放模板、静态资源文件,技能运行时建议只读这个目录,不要往里面写东西,因为平台升级或重新拉取技能时,这个目录可能会被覆盖。
.lobster/是平台自动创建的状态目录,用于存放技能的持久化状态。你代码里不要把状态文件写到技能根目录,否则平台打包发布时会把脏数据一起打进去。
技能根目录的命名也需要注意:它会被平台直接当作技能 ID 的一部分来识别。所以尽量用小写字母、数字、下划线,不要带空格和中文。我在一次本地测试中用了一个中文目录名,平台确实能解析,但是按 ID 查找技能的时候总是对不上,折腾了半个小时,最后改成英文目录才正常。
2.2 manifest.json 字段逐项拆解
写一个最小可用的manifest.json,内容如下:
{ "name": "hello_skill", "display_name": "Hello Skill", "version": "0.1.0", "min_engine_version": "1.8.0", "description": "一个用于验证技能开发环境的最小技能", "entrypoint": "handler.py:handle_event", "events": ["ping"], "actions": [] }这里的name是技能的唯一 ID,全平台内不能重复。display_name是展示给用户看的友好名称,可以包含中文和空格。version必须遵循三段式语义化版本号,平台做技能升级和缓存失效时依赖它。
min_engine_version是一个很多人会忽略的字段。它声明了这个技能要求的最低平台版本。如果平台版本比这个低,技能会被标记为不兼容,但不会直接报错,只是静默跳过。调试的时候容易以为技能没生效,其实是版本约束没满足。
entrypoint格式是模块路径:函数名。handler.py:handle_event表示从handler.py里导入handle_event函数,作为事件入口。这是整个清单里最常写错的字段,最常见的错误是写成handler.handle_event少了.py,平台会报ModuleNotFoundError。
events是一个字符串数组,声明技能想监听的事件名。ping是龙虾内置的测试事件,非常适合用来验证环境。
2.3 最小可运行技能:hello_skill 的上线实验
接着写handler.py:
import json def handle_event(event, context): if event.name == "ping": context.logger.info("ping received, hello from skill!") return { "status": "ok", "message": "hello_skill is alive", "payload": event.payload, } context.logger.warning("unsupported event: %s", event.name) return {"status": "ignored"}这里的事件对象event有两个常用属性:event.name是事件名,event.payload是事件携带的数据。context是平台传入的上下文对象,最常用的是context.logger,它自带 trace_id,日志会关联到当前触发链路。技能返回的 dict 会被平台记录到事件执行结果里,如果后续有编排流程拿到这个结果,就能决定下一步动作。
然后在技能目录的上一层,执行命令让平台以开发模式加载它:
lbt skill start ./hello_skill看到日志里出现类似Skill started的输出后,再开一个终端,向平台发一个 ping 事件去触发技能:
lbt event send --name ping --payload '{"source": "manual-test"}'如果一切正常,平台控制台会打印出技能返回的hello_skill is alive。到了这一步,最小技能就已经跑通了,后续的所有开发都能基于这个骨架做增量。
这里要单独说一说为什么入口函数要设计成同步风格而不是纯异步。一开始我也疑惑,既然平台是异步事件循环,为什么示例代码却用普通函数。后来看了平台文档才算明白:技能入口函数可以由平台按需包装,返回值会被平台自动处理。同步函数简单直观,出错时堆栈也更友好。只有在需要长轮询、并发 IO 的场景里才建议写异步函数,平时用同步就足够。
3. 实战:写一个下载目录自动归档技能
骨架跑通之后,就得来点真家伙了。我在本地做了一个技能,用来监控下载文件夹,把新出现的文件按类型自动归档到对应子目录。这个场景很常见,而且涉及事件监听、文件操作、异常处理、配置管理,正好覆盖技能开发的大部分知识点。
3.1 需求拆解:为什么选 file.created 事件而不是定时扫描
先说需求:本地下载文件夹里经常堆满各种文件,有 PDF、图片、压缩包、安装包,还有一堆没分类的临时文件。我曾经用 cron 做定时扫描,每五分钟全盘扫一遍,按扩展名移动文件。但这种方式有天然缺陷,一是扫描间隔造成的延迟,二是频繁遍历大目录会浪费磁盘 IO。如果只为一个“等新文件出现”的需求去轮询整个文件系统,性价比太低。
龙虾平台恰好有file.created事件,它由平台的文件监听模块发出,某个目录下新建文件时,事件会带着文件路径、大小、修改时间等基本信息,直接推送给技能。
这里需要提醒一点:file.created事件并不等于文件写入完成。很多下载工具是先创建一个.part或.crdownload临时文件,等下载完成后再重命名为真实文件名。如果我在事件回调里立刻移动文件,很可能把一个没写完整的文件搬走。所以技能里必须做“文件是否还在增长”的检查。我的方案是:遇到.part、.crdownload、.tmp等后缀直接忽略;或者对比两次检查之间文件大小是否变化。判断逻辑虽然简单,但如果没有它,技能会频繁出错,用户信任度直接归零。
3.2 清单与主逻辑代码拆解
技能目录结构沿用骨架里的组织方式,这次加上了配置项。完整清单如下:
{ "name": "download_vacuum", "display_name": "下载目录自动归档", "version": "0.2.0", "min_engine_version": "1.8.0", "description": "监听下载目录,将新文件按扩展名归档到对应子目录", "entrypoint": "handler.py:handle_event", "events": ["file.created"], "actions": ["archive.run"], "config": { "watch_dir": "/home/demo/Downloads", "target_root": "/home/demo/Downloads", "rules": { "pdf": ["Documents/PDF"], "jpg": ["Images"], "png": ["Images"], "zip": ["Archives"] } } }我在config字段里放好了默认配置。平台会把这份配置合并给技能,用户可以按需覆盖,而不用改代码。这一点非常实用,因为它把“行为差异”和“代码逻辑”彻底分离了。
主逻辑的代码我会拆成几段来讲。首先是入口函数和事件分发:
import hashlib import json import shutil from pathlib import Path SUFFIX_BLACKLIST = {".crdownload", ".part", ".tmp"} def handle_event(event, context): if event.name == "file.created": return _handle_file_created(event, context) return {"status": "ignored"}入口函数只做事件分发,业务逻辑全部放到带下划线的内部函数里。这样当技能的动作越来越多时,入口还能保持清晰。
然后是核心的文件归档逻辑:
def _handle_file_created(event, context): config = context.config file_path = Path(event.payload["path"]) context.logger.info("new file observed: %s", file_path) if not _is_watch_dir(file_path, Path(config["watch_dir"])): context.logger.debug("file is outside watch_dir, skip") return {"status": "skipped", "reason": "outside_watch_dir"} suffix = file_path.suffix.lower() if suffix in SUFFIX_BLACKLIST: context.logger.debug("ignore temp file %s", file_path.name) return {"status": "skipped", "reason": "temp_file"} target_subdir = _match_rule(suffix, config.get("rules", {})) if target_subdir is None: context.logger.debug("no rule for suffix %s, skip", suffix) return {"status": "skipped", "reason": "no_rule"} target_dir = Path(config["target_root"]) / target_subdir target_dir.mkdir(parents=True, exist_ok=True) dest = _unique_dest(target_dir, file_path.name) if dest == file_path: return {"status": "skipped", "reason": "already_in_place"} try: shutil.move(str(file_path), str(dest)) context.logger.info("moved %s -> %s", file_path.name, dest) return {"status": "moved", "dest": str(dest)} except OSError as exc: context.logger.error("move failed: %s, exc=%s", file_path, exc) return {"status": "error", "message": str(exc)}逐段解释一下我为什么这么写。
_is_watch_dir这一步看似多余,实则是安全边界。如果平台监听了多个目录,但技能只应该处理其中一个,就必须在事件回调里再次校验路径,不要天真地相信事件一定来自指定目录。这种校验能同时防止配置错误和潜在的路径穿越攻击。
SUFFIX_BLACKLIST的处理在前面的需求分析里说过,是为了排除下载中的临时文件。
_match_rule是规则匹配函数,根据后缀返回目标子目录名。我用它来实现config.rules里定义的映射,这样用户想加规则时直接改配置就行,不用来改代码。
_unique_dest是用来处理重名文件的。如果目标目录里已经有同名文件,我不会盲目覆盖,而是拼上短 hash:
def _unique_dest(target_dir: Path, name: str) -> Path: dest = target_dir / name if not dest.exists(): return dest stem = dest.stem suffix = dest.suffix digest = hashlib.sha1(name.encode("utf-8")).hexdigest()[:8] return target_dir / f"{stem}_{digest}{suffix}"这里用 SHA-1 并不涉及安全需求,只是为了生成稳定的短字符串,避免用时间戳导致每次结果都不同。文件名加上 hash 后缀后,既能保留可读性,又能降低冲突概率。
看到这里,你可能会问:为什么用shutil.move而不是os.rename。原因是os.rename在跨文件系统移动时会直接报错,而shutil.move会自动处理跨设备场景,它本质上是一个“复制加删除”的兜底方案。代价是性能稍慢,但归档场景一次性移动几个文件,完全可接受。
3.3 把归档能力注册成动作:让任务流直接调用
除了自动归档,我还想把“手动归一下某个目录”的能力暴露出来,让用户可以通过对话或任务流直接调用。这就需要注册archive.run动作。
在龙虾平台的技能方案里,动作的入参要有一个 JSON Schema 描述。我在 manifest 的模块上维护一个动作表,或者单独用一个文件声明动作 schema。这里为了避免清单文件过长,我在代码里注册动作,并把它作为一个独立入口:
ARCHIVE_ACTION_SCHEMA = { "type": "object", "properties": { "source_dir": {"type": "string", "description": "要归档的目录"}, "recursive": {"type": "boolean", "default": False} }, "required": ["source_dir"], "additionalProperties": False } def run_archive_action(context, params): source_dir = Path(params["source_dir"]) context.logger.info("manual archive triggered for %s", source_dir) moved = 0 for file_path in source_dir.iterdir(): if file_path.is_dir(): continue if file_path.suffix.lower() in SUFFIX_BLACKLIST: continue target_subdir = _match_rule(file_path.suffix.lower(), context.config.get("rules", {})) if target_subdir is None: continue target_dir = Path(context.config["target_root"]) / target_subdir target_dir.mkdir(parents=True, exist_ok=True) dest = _unique_dest(target_dir, file_path.name) if dest == file_path: continue shutil.move(str(file_path), str(dest)) moved += 1 context.logger.info("moved %s -> %s", file_path.name, dest) return {"status": "ok", "moved": moved}params是平台根据 schema 校验后传进来的参数对象。如果用户没有传必填的source_dir,平台会在调用技能之前就拦截,技能代码根本不会执行。这就是动作 schema 的价值:参数校验下沉到平台层,开发者的业务代码不用自己写一大堆 if-else。
以我的经验,动作 schema 里最容易出问题的是additionalProperties这个字段。刚开始我没把它设成false,结果调用时参数名打错了一个字母,平台照常接收,技能内部却拿到了一个缺失的 key,崩得很隐患。所以凡是动作参数,都建议明确additionalProperties: false,宁可让调用者早报错,也不要让错误值流进业务逻辑。
3.4 先用单测模拟事件,别把真实文件夹搞乱
一个需要反复调整文件移动逻辑的技能,如果每次调试都往真实下载目录里扔测试文件,很快就会把真实环境搞乱。我的习惯是先用 pytest 写好单测,把事件对象和上下文做成简单的桩。
模拟的上下文对象并不需要完整实现平台逻辑,只要够用就行:
import pytest from pathlib import Path from handler import handle_event class FakeLogger: def __init__(self): self.lines = [] def info(self, msg, *args): self.lines.append(("info", msg % args if args else msg)) def debug(self, msg, *args): self.lines.append(("debug", msg % args if args else msg)) def warning(self, msg, *args): self.lines.append(("warning", msg % args if args else msg)) def error(self, msg, *args): self.lines.append(("error", msg % args if args else msg)) class FakeEvent: def __init__(self, name, payload): self.name = name self.payload = payload class FakeContext: def __init__(self, config): self.config = config self.logger = FakeLogger() def test_pdf_file_moves_to_documents(tmp_path): config = { "watch_dir": str(tmp_path), "target_root": str(tmp_path), "rules": {"pdf": ["Documents/PDF"]}, } source_file = tmp_path / "report.pdf" source_file.write_bytes(b"%PDF-1.4 fake content") event = FakeEvent("file.created", {"path": str(source_file)}) ctx = FakeContext(config) result = handle_event(event, ctx) assert result["status"] == "moved" assert (tmp_path / "Documents/PDF" / "report.pdf").exists()tmp_path是 pytest 自带的一个临时目录夹具,每个测试用例都会拿到一个独立的临时目录,测试结束后自动清理。这样既不会污染真实下载目录,又能验证移动逻辑。
我自己的经验是,单测重点覆盖三类情况:规则内文件能被正确移动、临时后缀文件被忽略、目标目录已有重名文件时能生成新名字。这三条覆盖下来,核心逻辑就稳了一大半。
4. 本地调试与三个高频踩坑点:把技能跑起来只是第一步
技能开发中最花时间的不在写代码,而在“为什么没反应”。本地跑通 hello_skill 之后,一上真实场景,各种问题就冒出来了。踩过几次坑之后,我总结了一套调试方法,以及三个几乎每个技能作者都会碰到的坎。
4.1 用 debug 命令验证一次触发:dry-run 不落地
龙虾平台提供了一个非常关键的调试命令,可以手动向指定技能发送一个模拟事件,而不需要真实事件发生。命令大概长这样:
lbt skill debug ./download_vacuum \ --event file.created \ --payload '{"path": "/tmp/lbt-demo/report.pdf"}'在 debug 模式下,平台会把日志打到标准输出,并且会把技能执行过程中的关键节点标出来。不过这个命令默认是真实执行的:它真会移动文件。所以我一般会在命令里加一个 flag 来试跑:
lbt skill debug ./download_vacuum \ --event file.created \ --payload '{"path": "/tmp/lbt-demo/report.pdf"}' \ --dry-rundry-run 模式下,技能代码照常运行,但是文件移动操作会被日志替代。需要留意的是,有些技能代码并没有针对 dry-run 做专门适配,此时平台是通过注入一套“只读文件 API”来限制真实行为的。所以如果你的技能里用了平台之外的shutil.move,dry-run 也不能百分之百拦截真实移动。为了测试安全,我自己写技能时会在业务函数里查一个context.dry_run标志,为true时直接打印日志返回。
调试时还有一个细节:把所有 payload 路径都放到/tmp/lbt-demo/这类临时目录里,而不是真实的下载目录。这样即便移动逻辑出错,最多也只是在临时目录里挪了一堆没用的文件。
4.2 日志三件套:trace_id、阶段、耗时
技能上线后,很多时候你没法盯着终端看日志,只能靠平台的控制台做问题回溯。如果日志没有统一的关联 ID 和结构,出了问题根本不知道是哪次触发、哪一步挂的。
一开始我的日志写得非常随意,就是file moved这种没有上下文的字符串。结果有一次用户报“技能没反应”,我翻了半天日志,发现异常信息倒是有,但不知道它对应哪一次触发、哪一个文件。后来我给自己定下规矩,所有日志必须包含三样东西:trace_id、阶段、耗时。
trace_id由平台在每次事件分发时生成,context.logger会自动把它带进每一条日志后面。人工排查时,只要在控制台按 trace_id 过滤,就能拿到这一次触发的全部日志流水。
“阶段”指的是代码执行到哪一步,比如observed、matched、moving、moved、failed。我习惯在关键节点分别打一条日志,避免把多个动作揉在一行里。
“耗时”是这个阶段消耗的时间,单位毫秒。它最大的价值是发现性能拐点。比如我发现归档一个 200MB 的压缩包时,shutil.move这步如果走的是跨文件系统复制,耗时可能超过几秒,这时候就该考虑优化。
一个比较理想的日志长这样:
[2025-06-12 10:23:45.123] [INFO] [trace_id=9f3a2c] [file.created] observed file=/tmp/lbt-demo/report.pdf size=2048 [2025-06-12 10:23:45.126] [INFO] [trace_id=9f3a2c] [rule] matched suffix=pdf -> Documents/PDF [2025-06-12 10:23:45.130] [INFO] [trace_id=9f3a2c] [move] moved report.pdf -> /tmp/lbt-demo/Documents/PDF/report.pdf用结构化的 key=value 格式,方便后续接日志平台做检索。这个习惯救了我很多次,强烈建议从第一个技能就开始养成。
4.3 三个高频坑的定位套路
第一个坑:技能“不响应事件”。排查套路是先用lbt event send手动发一个事件,观察控制台有没有输出。如果没有,多半是清单里的事件名和实际事件名对不上,或者min_engine_version卡住了加载。有一次我把file.created写成了file_created,平台对这个错误事件名没有严格报错,只会在日志里打一行no subscriber,非常坑。所以要记住:事件名的连字符和下划线不能混用,必须以平台实际发布的事件名为准。
第二个坑:文件操作权限异常。平台默认会用启动它的系统账号去执行技能。如果技能想访问/home/demo/Downloads,但平台实际运行账号是lobster-agent,这个账号没有读取用户主目录的权限,事件倒是触发了,代码一访问目录就报PermissionError。这时候在日志里能看到异常堆栈,但第一次遇到的人很容易误判成平台 bug。解决办法是把技能需要访问的目录显式授权给平台运行账号,或者在平台配置里给技能指定更合适的行为身份。
第三个坑:事件风暴。下载文件夹同时落地 100 个小文件时,平台会一下子触发 100 次file.created事件。如果技能每个事件都做一次目录检查和移动,就会产生大量重复 IO,甚至把平台事件循环挤到超时。针对这种场景,我更倾向在技能里做“短窗口聚合”:把事件先丢进一个内存队列,延迟几百毫秒再批量处理。如果平台有批量事件接口,直接订阅批量事件更省事。没有的话,至少要保证技能是幂等的,这样重复触发也不会造成破坏。
5. 让技能抗造:幂等、超时、版本发布与体检清单
自动归档技能跑了几周之后,表面上看起来一切正常,但我知道它还存在好几个隐藏问题。比如重复触发会不会重复搬文件?长任务卡住会不会拖垮平台?技能升级后用户的旧配置会不会失效?这些都是从“能跑”到“能用得久”之间必须跨过的坎。
5.1 幂等设计:重复触发不重复搬文件
幂等这个概念听着玄乎,放在归档场景里就是一句话:同一个文件事件重复触发 10 次,结果应该和只触发 1 次一样。
在没有做幂等保护之前,我的技能有个缺陷:目标目录里已经有了report.pdf,如果又来了一个同名事件,_unique_dest会生成report_abc123.pdf,产生一个多余副本,或者更糟,把一个已经不存在的源文件当作归档对象。
我在代码里加了一个“源文件是否存在”的检查:移动前先判断源文件还在不在,如果已经不存在,直接返回already_processed状态。对于更复杂的业务,可以把处理过的文件指纹写到一个状态文件里,下次事件来时先查指纹。状态文件放在.lobster/state.json,平台会负责持久化。归档场景用“文件已不存在”检查就够了,但如果你处理的不是文件而是外部 API 请求,就真的需要把请求 ID 缓存下来做去重。
5.2 超时与长任务:别占用事件循环
龙虾平台对每个技能事件的执行时长是有上限的,不同的部署配置不一样,默认通常是 60 秒。超过这个时间,平台会强制中断执行,并把技能标记为“超时失败”。这个机制不是为了刁难开发者,而是为了防止一个失控技能阻塞平台的其他任务。
如果你的技能里出现了需要跑几分钟甚至更久的任务,比如大量文件的批量复制、远程数据抓取,一定不能直接在事件处理器里同步执行。正确做法是把长任务拆小:每处理一批文件就更新一次进度状态,然后返回“待继续”信号;或者在技能里启动一个后台线程,平台只负责接收“已启动”状态。我自己的原则是:事件处理函数里只做轻量动作,任何可能超过 10 秒的操作都要重新设计方案。
5.3 配置与版本的平滑演进
技能升级最怕的是“新代码读旧配置”。比如我一开始的规则里,图片压缩包的归档目录是中文名,后来觉得统一用英文更规范,于是修改了config.rules。结果老用户已经覆盖了配置,他们本地还是旧值,新版本技能跑起来就乱了。
后来我养成了两个习惯。第一个习惯是保持配置项向后兼容:新增规则时保留旧规则名称,或者提供迁移逻辑。第二个习惯是每次修改config结构时,同步提升 manifest 里的version。平台做技能更新时,会根据版本号决定是否重启加载。如果只是改了代码,version没变,有些部署模式下平台会认为技能没有更新,不会重新加载,你的新代码等于没有上线。
5.4 动手检验:技能上线前体检清单
分享完这些经验,我整理出每次发布技能前都要过一遍的体检清单,按顺序逐项检查:
| 检查项 | 具体要求 | 不满足时的典型症状 |
|---|---|---|
| 事件名 | 必须与平台事件总线里的名称完全一致 | 事件触发但技能无响应,日志提示 no subscriber |
| 动作 schema | 明确additionalProperties为 false,必填项齐全 | 参数校验不严,错误值进入业务逻辑 |
| 幂等性 | 重复触发事件,结果保持一致 | 同一事件重复执行,产生冗余文件或重复请求 |
| 超时设计 | 单次事件处理不超过平台上限,长任务拆批 | 技能被强制中断,平台事件循环阻塞 |
| 路径安全 | 所有文件操作限制在配置目录内 | 路径穿越风险,误操作其他目录 |
| 日志规范 | 包含 trace_id、阶段、耗时,结构化输出 | 出问题时无法定位具体触发链路 |
| 版本号 | 每次发布都递增version | 平台跳过重新加载,新代码不生效 |
这套清单我每次发布技能前都会快速过一遍,大概十分钟能检查完。它帮我挡掉了至少一半的线上事故。
最后再分享一点实际体会
写技能和写普通脚本最大的不同,在于你是在“为一个平台设计一个可替换的部件”。你写的每个函数都必须考虑平台的生命周期、调用约定、配置合并和失败恢复。刚开始写会觉得这些约定很烦人,但用久了你会发现,它其实帮你省掉了大量和业务无关的脏活。平台帮你管好了调度、日志、权限、参数校验,你只需要专注业务逻辑本身。我自己现在写任何一个可以被自动化的脚本时,都会先想一想,如果把它做成龙虾技能会不会更好。答案在大多数情况下是肯定的。