代码精简攻略:10个Agent技巧消除重复代码,300行降至30行
重构10个Agent Skill后,我把300行重复代码压缩到30行
过去半年陆续交付了十余个Agent Skill,踩遍了工程化的各种坑。写到第三个Skill时,复制了三套HTTP封装:Cookie鉴权一套、Bearer Token一套、带网络异常兜底的一套——每个都各自维护一份http.ts。一旦鉴权策略需要调整,同一段逻辑要改三回。
这还只是HTTP层。命令路由、参数校验、错误处理、SKILL.md校验、产物同步——写到第五个Skill时,这些跟业务无关的工程问题已经吞噬了大部分精力。
把这些重复劳动收敛成一套工具链,正是skill-kits的核心目标。
先看实际解决的效果:
| 指标 | 重构前:手搓Skill | 重构后:基于skill-kits |
|---|---|---|
| 新建一个Skill | 每次重新做工程决策 | pnpm new 一行命令搞定 |
| HTTP / 错误处理等工具 | 每个Skill复制一份 | 从runtime import,零依赖内联 |
| 代码改动同步到Agent | cp -r / 手动同步 |
pnpm dev一条命令watch+同步 |
SKILL.md质量检验 |
全靠肉眼review | pnpm build自动检查 |
一、一段fetch,在3个Skill里维护了3份
写完三个API触发型Skill后,最直接的痛点是:相同的网络请求封装散落在各处,任何改动都得把同一段逻辑修改三次。这不光增加了维护成本,更可怕的是“复制粘贴”模式极易引入细微的不一致和潜在bug。
所以在skill-kits的Runtime里,我们只放了一层轻薄的fetch封装。不搞大而全的HttpClient,只解决“少写fetch样板”这件事:
import { httpGet, HttpError } from "skill-kits/runtime";
const res = await httpGet("https://api.example.com/me", {
headers: { authorization: `Bearer ${token}` },
query: { fields: "id,name" },
timeoutMs: 10_000,
});
if (!res.ok) {
throw new HttpError(res.status, url, res.statusText);
}
剩下的那层业务强相关封装,交给各个Skill自己搞定就好。
HTTP之外,错误处理也是重灾区。以前每个Skill各自throw new Error("xxx"),排查时只能靠猜message。现在我们把常见错误统一收进标准code里:
| 类 | code | 典型场景 |
|---|---|---|
UserInputError |
USER_INPUT_ERROR |
参数缺失 / 格式错误 |
AuthError |
AUTH_ERROR |
Token过期 / 权限不足 |
HttpError |
HTTP_ERROR |
上游HTTP非2xx |
BusinessApiError |
BIZ_ |
HTTP 200但业务code≠0 |
几个典型用法:
import { SkillError, UserInputError, BusinessApiError } from "skill-kits/runtime";
// UserInputError:参数校验失败
throw new UserInputError("activityId 不能为空", { field: "activityId" });
// stderr → {"ok":false,"code":"USER_INPUT_ERROR","error":"activityId 不能为空","details":{"field":"activityId"}}
// 自定义BusinessApiError
throw new BusinessApiError(-10000, "token 过期", {
hintMap: {
[-10000]: "请重新登录",
[-14]: "记录不存在",
},
});
// stderr → {"ok":false,"code":"BIZ_-10000","error":"[code=-10000] token 过期(请重新登录)"}
// 自定义业务错误:继承SkillError,code自由命名
class RateLimitError extends SkillError {
constructor(retryAfterSec?: number) {
super("RATE_LIMIT", "请求过于频繁", { retryAfterSec });
}
}
throw new RateLimitError(30);
// stderr → {"ok":false,"code":"RATE_LIMIT","error":"请求过于频繁","details":{"retryAfterSec":30}}
这样一来,无论是人工排查还是LLM后续做分支处理,都不需要再从冗长的message中猜测错误含义。
二、7个子命令,main.ts写了250行parseArgs + switch
写营销活动管理Skill时,7个子命令让main.ts变成了一大坨parseArgs + switch + usage + 参数校验。这种代码最大的问题不是长度,而是逻辑分散。参数解析、校验、USAGE、错误处理分布在不同地方,加一个命令得改好几处,特别容易遗漏。
后来把这块收成了createRouter:
import { createRouter, writeResult } from "skill-kits/runtime";
const router = createRouter({
name: "redbrick-activity",
description: "...",
commonArgs: {
domain: { type: "string", required: true, desc: "平台域名" },
appId: { type: "string", required: true, desc: "应用ID" },
token: { type: "string", required: true, desc: "SSO token" },
},
});
router.command({
name: "get-activity",
description: "查询活动详情",
args: {
activityId: { type: "string", required: true, desc: "活动ID" },
},
async handler({ domain, appId, token, activityId }) {
writeResult(await getActivity(domain, appId, token, activityId));
},
});
router.command({
name: "create-activity",
description: "创建活动",
args: {
body: { type: "json", required: true, desc: "活动字段JSON" },
},
async handler({ domain, appId, token, body }) {
writeResult(await createActivity(domain, appId, token, body));
},
});
router.run(process.argv.slice(2));
抽完这一层之后,思路变得清爽很多:不再需要盯着“参数是怎么parse的”,只需要关心“这个命令需要什么参数,拿到参数后做什么事”。
三、stdout和stderr混在一起,Agent根本分不清
之前写Skill需要打印信息时,除了错误用console.error,其他都用console.log一把梭。结果LLM要从stdout里猜哪个是JSON结果、哪个是进度日志——猜错了就是一次无效调用。
更好的做法是:stdout输出JSON结果,stderr输出进度文案;失败时通过非0退出码让Agent识别到status: failed,这比让LLM去解析stderr里的错误文本要可靠得多。
skill-kits提供了几个简单的输出函数,直接使用:
writeResult(payload); // stdout:单行JSON,给Agent用
writeError(errorOrMessage, { code?, extra? }); // stderr:结构化错误 + exitCode=1
notify("正在拉取数据..."); // stderr:进度日志
四、Agent以为进程卡死了,其实只是在等60秒回调
D2C生码、SSO登录回调这类场景,最大的问题往往不是“真的超时”,而是“看起来像超时”。比如直接睡60秒:
await new Promise((resolve) => setTimeout(resolve, 60_000));
进程在这60秒内没有任何输出,Agent很可能会认为进程已经卡死而将其终止。
所以后来补充了sleepWithHeartbeat:
import { sleepWithHeartbeat } from "skill-kits/runtime";
await sleepWithHeartbeat(60_000, {
message: (rem) => `等待生码... 剩余${rem}s`,
});
它每5秒往stderr输出一次心跳,让Agent知道进程还在运行,避免因长时间无输出而被误判为卡死。
五、SKILL.md写得“对”,不等于真的“好用”
这一点一开始其实没太当回事,后来被现实教育了。遇到过两个相关的坑:
- 触发不准:
description写得抽象、没有覆盖某些场景,LLM不知道什么时候该用它,得反复调关键词 - 内容太“全”反而难用:AI生成的
SKILL.md内容很全,但想自己动手改时,感觉无从下手
SKILL.md这东西当然不可能像代码一样完全标准化,可它也不是完全没法校验。至少这些问题,应该在本地就拦住:
name和目录名不一致- body太长,把上下文窗口塞爆
- references引错了路径
description太短,看不出触发场景
skill-kits补了一套围绕SKILL.md的lint规则,pnpm build默认先跑:
name-matches-dir:name必须等于父目录名body-line-limit:body超过500行直接报错body-line-soft:超过400行给warning,建议拆到references/description-length:description太短给warningdescription-trigger/description-negative:检查有没有写清楚“何时触发”和“不要触发”
如果对阈值有自己的习惯,也可以配.skillkitrc.json:
{
"lint": {
"triggerHints": ["何时", "trigger", "use when"],
"negativeHints": ["不要", "do not"],
"descriptionMinChars": 80,
"bodyLinesWarn": 400,
"bodyLinesFail": 500
}
}
另外,runtime之外还有一层复用也很值得抽:内部API客户端、域名常量、签名工具这些跨Skill的小工具。skill-kits init生成的workspace默认带一个packages/shared:
import { signRequest, BIZ_DOMAINS } from "@skills/shared";
构建时esbuild会把用到的部分内联进产物,最后还是单文件、零依赖。
六、改一行代码,要build → cp -r → 再试一次
开发Skill时,需要将其同步到Agent的本地Skill目录以便快速验证。以前这一步基本都是手动复制:
cp -r dist/xxx ~/.agent/skills
次数多了难免繁琐,因此添加了dev模式:
pnpm dev daily-report --out ~/.agent/skills
它会同时做两件事:
- 用esbuild watch
src/,.ts文件变化触发重编 - 监听
SKILL.md / references/ / assets/,资源变化直接同步到--out指定目录
这样一来,本地改完,Agent下次调用拿到的就是最新版本。
七、Skill坏了才知道,因为从来没跑过测试
Skill是在Agent里无人值守运行的,一个命令坏了代价会比普通CLI高——因为常常要等到某次运行失败时才发现。写点单测是值得的。
skill-kits对测试的设计很简单:测试文件放在src/**/*.test.ts,通过pnpm test执行,底层走node:test + tsx,零配置。
常见的测试目标其实就一个:断言命令的退出行为——它往stdout写的JSON是什么样的,它的退出码是0还是1,失败时stderr是什么错误。
围绕这个目标,skill-kits/testing提供了两个helper:
captureOutput(fn):抓writeResult/writeError/notify的输出,以及process.exitCodemockFetch(routes):替换全局fetch,不打真实网络
一个典型的成功路径测试大概长这样:
import { test } from "node:test";
import assert from "node:assert/strict";
import { mockFetch, captureOutput } from "skill-kits/testing";
import { createActivity } from "./commands/create-activity.js";
const ctx = { domain: "https://example.com", token: "t" };
test("create-activity 返回后端数据且ok", async () => {
const mock = mockFetch([
{ match: /\/activity\/create\//, json: { code: 0, data: { activity_id: 9001 } } },
]);
try {
const { json, exitCode } = await captureOutput(() =>
createActivity(ctx, { act_name: "test" }),
);
assert.equal(exitCode, 0);
assert.equal((json as { activity_id: number }).activity_id, 9001);
} finally {
mock.restore();
}
});
对于纯函数,两个helper都不需要,直接import后断言即可。对于错误路径,命令内部throw一个SkillError(路由层会把它映射成退出码1 + stderr JSON),在测试里用assert.rejects就能抓到。
顺便提一句:mockFetch对没匹配上的请求会故意抛错,这样漏写mock绝不会悄悄通过,避免“测试通过但线上不通”的尴尬。
日常闭环其实就是三条命令:
pnpm new daily-report
# ... 写代码 + 写测试 ...
pnpm test daily-report # 跑单测
pnpm build daily-report # lint → 打包 → zip
写在最后
skill-kits做的事情很明确:把入口、产物、运行时、定义校验这些横切问题收进一层护栏里,让你在新建第5个、第10个Skill时,脑子里想的依然是业务逻辑怎么写。
如果你也在写Agent Skill,欢迎试试:
npx skill-kits init my-skills
GitHub地址:github.com/weijhfly/sk…