拆开一个开源 agent:52 个工具、41 个技能、一个手写主循环,怎么加自己的东西
约 6800 字 · 含代码块 9 段 · 前置:已经把它跑起来(见上一篇)· 版本 v0.19.2
先给结论:它的扩展点只有三个——工具(加一个动作)、技能(加一套方法)、连接器(接一个外部系统)。三个都是「加文件」而不是「改核心」,唯一不推荐动的是主循环和沙箱。判据很简单:能写成 skill.md 的绝不写代码;能包成 MCP 的绝不在工具层硬编。
这篇是实操。先看清三层怎么分工,再上手加一个工具、写一个技能、包一个连接器,最后说三条扩展纪律——那三条是决定你半年后还能不能顺利升级的关键。
项目:github.com/CatCatUncle/openworkbuddy。当前 41 个技能、52 个工具、52 个连接器,版本 v0.19.2(2026-11-06)。它是 WorkBuddy、豆包工作、千问办公这类产品的最强开源平替,也是这类产品里少数能被改成自己产品的一份底座。
一、先看清三层分工,别在错的地方写代码
一句话判据:「要不要跟外部世界交互」——要,就是工具或连接器;不要,只是「怎么想、怎么排、按什么格式输出」,那就是技能。
| 层 | 它解决的问题 | 长什么样 | 数量 |
|---|---|---|---|
| 技能 skill | 「这件事该怎么做」——步骤、口径、格式、常见错误 | skills/名称/skill.md | 41 |
| 工具 tool | 「能做哪个动作」——读写文件、跑命令、生成 PPT | tools.js 里的定义 + 执行函数 | 52 |
| 连接器 connector | 「能碰哪个外部系统」——飞书、企微、文件系统、你的 ERP | MCP 协议,配置里挂 | 52 |
这张表最重要的用法是倒着用:客户提一个需求,先想「能不能只写技能」;不能,再想「能不能包个 MCP」;两者都不行,才动工具层。经验比例是 10 个需求里 8 个技能、1.5 个连接器、0.5 个工具。
为什么强调这个顺序?因为技能的维护成本最低(一个 markdown 文件,改完刷新就生效),连接器次之(协议标准,接一次到处能用),工具最贵(要写代码、要测、升级时要跟着合)。把一个能写技能的需求写成工具,是在给自己制造长期债务。
二、工具层:定义和执行是分开的
核心设计:所有工具的定义集中在 tools.js 的 TOOL_DEFS 里,执行入口是 executeTool。定义是给模型看的(它据此决定调不调、怎么传参),执行是给机器跑的。两件事分开写,加工具时你只需要在两边各加一段。
为什么这个分离重要?因为模型的上下文里只放定义。52 个工具的定义要能塞进上下文,靠的就是定义足够简洁:一个名字、一句描述、一个参数 schema。如果定义和执行混在一起、定义里塞满实现细节,52 个工具光描述就能把上下文吃掉一半。
还有一个不那么显眼的好处:工具定义的格式和 MCP 的 tools/list 是一致的。意思是同一份定义,既能给内部主循环用,也能直接被一个 MCP server 端出去——这就是为什么有人能在它上面花一百多行代码就做出一个 MCP server,把 52 个工具全部对外暴露。这个设计不是巧合,是一开始就照着标准写的。
加一个自己的工具:完整示例
场景:公司有个内部库存服务,你想让 agent 能查库存。HTTP 接口是 GET /api/stock?sku=xxx,返回 JSON。按仓库 docs/扩展.md 的结构,两步:
第一步,写定义。放进 TOOL_DEFS:
{
name: "query_stock",
description: "查询内部库存系统的可用数量。输入 SKU 编码,返回可用库存与所在仓库。只读,不会改动任何数据。",
inputSchema: {
type: "object",
properties: {
sku: { type: "string", description: "商品 SKU 编码,例如 A-1023" },
warehouse: { type: "string", description: "仓库代码,可选。不传则汇总所有仓库" }
},
required: ["sku"]
}
}
这段描述里的三句话都是在给模型做决策依据,不是给人看的注释。「只读,不会改动任何数据」这句尤其重要:模型在决定要不要调一个工具时,会判断风险;你告诉它这是只读的,它敢用;你不说,它可能绕路去读文件。写工具描述,本质上是在写 prompt。
第二步,写执行。在 executeTool 里加一个分支:
case 'query_stock': {
// 1) 参数校验:模型给的参数不一定合法,必须自己挡
const sku = String(args.sku || '').trim();
if (!sku) return { isError: true, content: '缺少 sku 参数' };
// 2) 超时控制:外部接口一定要设上限,否则整个任务卡在这里
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 8000);
try {
const url = `http://stock.internal/api/stock?sku=${encodeURIComponent(sku)}`
+ (args.warehouse ? `&wh=${encodeURIComponent(args.warehouse)}` : '');
const res = await fetch(url, { signal: ctrl.signal });
if (!res.ok) return { isError: true, content: `库存服务返回 ${res.status}` };
const data = await res.json();
// 3) 返回给模型的是「话」,不是原始 JSON —— 它读话比读结构准
return {
content: `${sku} 可用库存 ${data.available} 件,位于 ${data.warehouse || '多仓'}`
+ (data.updatedAt ? `,数据时间 ${data.updatedAt}` : '')
};
} catch (e) {
// 4) 失败要明确说出来,别静默返回空 —— 模型会以为「查到 0 件」
return { isError: true, content: `查库存失败:${e.message}` };
} finally {
clearTimeout(timer);
}
}
这四段注释就是加工具的全部要点,值得逐条说:
- 参数校验必须自己挡。schema 里的
required只是「请模型尽量给」,不是「保证会给」。任何外部输入都要当成不可信的。 - 超时必须有。没有超时的工具,是这个系统里最容易造成「任务卡死」的原因——模型在等,任务不结束,你以为是模型慢,其实是工具没返回。
- 返回给人话,不返回原始 JSON。模型的下一步判断建立在「它读到了什么」上。返回一大坨 JSON,它会去解读字段,容易读错;返回一句「A-1023 可用库存 128 件,位于上海仓」,它直接就能用。
- 失败必须显式说出来。这个坑非常隐蔽:工具静默返回空数组,模型会理解成「查了,没有」,然后告诉你「库存为 0」——这是最坏的一类错误,因为看起来一切正常。
一个必须知道的细节:它的 executeTool 对「未知工具」和「业务失败」不抛异常,而是返回一个带 isError 的对象。你自己写工具时要遵守这个约定(用 isError: true 而不是 throw),否则主循环拿不到失败信号;反过来,如果你把这个项目当 MCP server 端出去,server 侧要主动把这个内部 isError 提升到 MCP 协议层,不然客户端会以为调用成功了。这个坑我们在做 MCP 封装时真踩过,错误被客户端吞掉,排查了半天。
加工具的验收标准
别只看「能跑」。一个工具要能上生产,至少过这四条:
- 缺参数时,返回明确错误,不是崩。
- 外部服务挂掉时,8 秒内返回错误,不是卡住。
- 连续调三次,结果一致(只读工具)或行为可预期(写工具)。
- 模型能自己想到用它——给它一个自然语言任务,看它会不会主动调,不用你在提示里点名。这一条不过,说明描述写得不对,回去改 description,别改代码。
三、技能层:一个 markdown 文件就是一项能力
机制:一个技能 = skills/<名称>/skill.md,文件头是 --- 包起来的 frontmatter,写 name 和 description。系统启动时只把这段 description 注入上下文,模型判断「这事该用这个技能」之后才去读全文。
---
name: 外贸报价单
description: 按客户询盘生成三档报价(成本/标准/优惠),自动算汇率与毛利,输出 Excel 与一页 PDF 摘要
---
# 外贸报价单
## 什么时候用
客户给了询盘(产品、数量、目标市场),需要出一份可发的报价单。
## 步骤
1. 从询盘里抽出:产品型号、数量、目标市场、客户要求的交期
2. 缺信息就列出来问,不要自己编 —— 报价单上编一个数字,业务员会当场翻车
3. 成本按「出厂价 + 包装 + 内陆运费 + 报关」四项加总
4. 汇率取当日中间价,加 1.5% 波动缓冲
5. 三档毛利:成本档 8%、标准档 18%、优惠档 12%
6. 输出 Excel:一档一个 sheet,含公式不用硬算的数字
7. 再出一页 PDF 摘要:只有三档总价、交期、有效期
## 口径(很重要)
- 报价有效期默认 30 天,写在页脚
- 所有价格保留两位小数,货币单位写在表头不要写在每个单元格
- 不要出现「大约」「预计」这类词,客户会当成可谈空间
## 常见错误
- 把 FOB 价写成 CIF 价(漏了海运和保险)
- 汇率用了上周的
- Excel 里把公式算成了死数字,客户改数量后总价不变
这个文件里,「口径」和「常见错误」两节比「步骤」更值钱。步骤模型自己能想个七七八八,口径和坑只有你知道。写技能的时间应该 60% 花在这两节上。
为什么这套机制能带 41 个技能
算一笔账就明白了:如果 41 个技能每个 2000 字全注入上下文,那是 8 万字,任何模型都撑不住,而且每次任务都在为用不到的能力付 token。现在只注入 41 条 description,每条一两句话,总共一两千字——成本降两个数量级,准确率还更高,因为模型不用在一堆无关内容里挑。
这个设计对二次开发的意义:你可以放心地堆技能。堆到 60 个、80 个,成本是线性小增,不是爆炸。这跟「把所有行业知识写成一个巨型系统提示词」是完全不同的两条路——后者第一版就撑爆,而且改一个字要重测全部。
写技能的五个要素
| 要素 | 写不好会怎样 | 写法 |
|---|---|---|
| 触发描述 | 模型想不起来用它,你再好的技能也白写 | description 里写清「什么场景 + 输入什么 + 输出什么」,用客户的原话,不用术语 |
| 信息缺口处理 | 模型自己编数字,交付物错得理直气壮 | 明确写「缺 X 就停下来问,不许猜」 |
| 口径 | 格式每次不一样,客户没法用 | 把钱、单位、小数位、有效期这类硬规矩写死 |
| 输出规格 | 生成的 Excel 好看但没法改 | 明确要公式、要 sheet 结构、要表头位置 |
| 常见错误 | 同一类错每次犯 | 把踩过的坑一条条列进去,这是技能越用越值钱的地方 |
一个实操技巧:技能不要一次写完。先写个粗糙版跑一周,把每次出错的地方补进「常见错误」一节。三周之后这个技能会变得非常准——因为它积累的是你业务的真实边界,不是想象出来的规范。
四、连接器:能包 MCP 就别写工具
判据:对方系统有 API,就包成 MCP 连接器;没有 API 的老系统,才在工具层写函数直连。MCP 的好处是「一次接、多产品用」——你给它接的连接器,别的支持 MCP 的工具也能用,不会绑死在这一个产品上。
它自带 52 个连接器,覆盖文件系统、飞书、企业微信、钉钉、知识星球这些。你内部的 ERP、OA、知识库,按 MCP 协议包一层就能挂上,不用改主循环。
什么时候该写工具而不是包 MCP?三种情况:要读一种特殊格式的文件(比如某种行业专用二进制)、要在本机执行命令、要做多步组合动作(一次调用里查三个系统再汇总——包成 MCP 也能做,但工具更直接)。
五、主循环:手写的,所以你能改
它的价值在「可读」:agent 主循环是一个能从头读到尾的文件,没有中间件层层包裹。你想在「模型说要写文件」和「真的落盘」之间插一道校验,改十几行就行——不用先搞懂一套框架的钩子机制。
这个选择在 2026 年的技术圈算「不现代」。但它换来的东西很具体:
- 排查快。出问题能直接定位到「模型判断错了」还是「工具返回错了」,不用猜是哪层中间件吃掉了异常。
- 能插钩子。前面说的「必须人工审核才能发出去」,就是在工具层加一步;「落盘核验」本身就是主循环里的一段。
- AI 能改。没有框架约定,AI 改这类代码的准确率明显高——它不需要先猜你的状态管理放在哪。
它已经内置的一个重要校验:模型声称「我写了文件」之后,去磁盘上核一遍,文件不在,这一轮直接判失败重做。为什么这件事值得单独说?因为它是这类产品最常见的翻车方式——模型非常自信地告诉你「已生成 报告.pptx」,你去目录里翻,什么都没有。企业客户最不能接受的就是这种「它说做了其实没做」。
扩展纪律第一条:主循环和沙箱不要动。除非你明确知道自己要改什么、并且准备好了每次升级都重新合。这两块是它的安全边界所在,也是改动收益最低的地方——90% 的需求都能在工具层或技能层解决。
六、三条扩展纪律(决定半年后能不能顺利升级)
纪律一:能写技能就不写代码。纪律二:能包 MCP 就不写工具。纪律三:改动只落在「界面 + 技能 + 自己加的工具文件」这三处。三条都守住,升级基本无痛。
为什么第三条这么关键
它的代码没有构建产物、没有编译步骤,所以合并冲突只会出现在你真改过的那几个文件上。改得越少,升级越轻松——这句话反过来就是设计约束。当前版本 v0.19.2、更新比较勤,你要跟上游,就必须守住这条。
具体做法:fork 一份当你的产品基线,上游更新时 merge 进来,不要把自己的改动盖回去。
git remote add upstream https://github.com/CatCatUncle/openworkbuddy.git
git fetch upstream
git checkout main
git merge upstream/main
一个可执行的目录约定
把「你的东西」和「它的东西」物理分开,升级时一眼就知道哪些文件可能要冲突:
skills/你的行业-报价单/skill.md # 你的(纯新增,永不冲突)
skills/你的行业-质检报告/skill.md # 你的
tools/extra/stock.js # 你的(新增文件,只在注册处一行改动)
web/你的logo.svg # 你的
# 其余全部属于上游,尽量不改
这个约定里,唯一必须改上游文件的地方是「工具注册」那一行——把 tools/extra/stock.js 挂进去。一行改动,冲突好合。技能是纯新增,永远不会冲突;界面上的品牌改动集中在少数几个文件里。
七、给它加一道「人工审核」:一个完整的小改造
需求:对外发的内容必须先落一份待审稿,人点了才真发。做法:把「发出去」写成一个工具,工具里先落待审稿、返回「已提交待审」;再写第二个工具「批准并发送」,只有人能调。主循环一行不用改。
这个模式在实际项目里非常常见,因为它把「风险动作」变成了两段:机器做前一半(准备),人做后一半(放行)。
// 工具 1:只落稿,不发
case 'submit_for_review': {
const dir = path.join(DATA, 'pending');
await fs.promises.mkdir(dir, { recursive: true });
const file = path.join(dir, `${Date.now()}-${args.channel}.md`);
await fs.promises.writeFile(file, args.content, 'utf8');
return { content: `已提交待审:${file}。审核通过后才能发送。` };
}
// 工具 2:人点了才走
case 'send_after_approval': {
// ...按渠道发送,这里只示意
return { content: '已发送' };
}
注意第一个工具的返回话术:「已提交待审……审核通过后才能发送」。这句话是在告诉模型「你的活干完了,别再想办法发出去」——否则它会绕路去找别的工具。这类「用返回话术约束模型下一步」的技巧,在写工具时非常实用。
八、小结:加东西的顺序
一个需求进来,按这个顺序问四遍:
- 能不能只写
skill.md?能,就半天一个,别写代码。 - 能不能包成 MCP?能,就包——接一次往后都能用。
- 非要写工具?照第二节那四段注释写:校验、超时、返回人话、失败显式。
- 非要动主循环?先停一下,九成情况下有别的办法。
守住这个顺序,你的产品会一直是「一堆技能 + 一两个自有工具」的形状——这个形状升级最省事,也最容易交给别人维护。
git clone https://github.com/CatCatUncle/openworkbuddy.git
cd openworkbuddy
bash deploy.sh
说明:文中代码为示意结构,用于说明扩展点与约定;具体函数位置与参数请以仓库 docs/扩展.md、docs/任务与连接器开发.md 和源码为准。技能数 41、工具数 52、连接器数 52、版本 v0.19.2(2026-11-06)来自仓库 docs/stats.json。
授权与联系:个人非商用、学校和公益机构免费;公司及个人商用须获商业授权。购买授权:contact@aijentra.com · 微信 agi_smith。模型与媒体服务的费用由所选服务商决定。
更多推荐


所有评论(0)