开源交易 Agent 项目 Vibe-Trading 里怎么自己写一个技能:四个工具加一份范本
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
在 Vibe-Trading 里,技能文档不是”给模型的补充说明”,它就是实现本身——所以”写技能”必须做成一组带校验、带覆盖规则、带删除路径的工具,而不是让模型自由往磁盘上丢 markdown。 这个判断不是我推的,agent/src/tools/load_skill_tool.py 的模块 docstring 自己写着:债券数学、隐含波动率求解、冲击成本模型、中国市场结构,这些东西”live in markdown the agent reads and executes”。一旦文档承担了实现的角色,文档的增删改就等价于代码的增删改,配套设施的规格自然要按代码来。
先把命名说清楚:Vibe-Trading 是 HKUDS 放出的一个开源项目名(MIT 许可,Copyright 2026 Vibe-Trading Contributors),跟”凭感觉交易”这种泛指说法没有关系。本文只看它的 Agent 工程实现,不讨论任何策略、标的与收益。
站内已经写过 Superpowers 里技能文件的写法、Hermes 让 Agent 自动生成技能 和 工具描述本身怎么写,那三篇分别讲方法论、自动化生成与描述文案;这篇只盯 Vibe-Trading 这一份实现的代码细节,以及它自带的那份技能范本能给你什么参照。
一、技能机制先解决的问题:上下文放不下 88 份文档
agent/src/skills/ 下有 88 个技能目录、404 个文件。这个体量决定了不可能把全部内容塞进系统提示词。agent/src/agent/skills.py 的模块 docstring 把它的做法叫 progressive disclosure(渐进式披露),拆成两层:
- 系统提示词只注入一行摘要。
SkillsLoader.get_descriptions()按 category 分组,每个技能输出一行- {skill.name}: {skill.description}。 - 正文按需加载。模型调用
load_skill工具,才拿到SKILL.md的 body。
这个结构有个直接后果,也是写技能时最容易忽略的地方:永远在上下文里的只有 description 那一行。模型是靠这一行决定要不要花一次工具调用去读全文的。description 写成”关于 XX 的说明”,等于这个技能永远不会被想起来。
加载出来的内容还带了包裹:get_content() 返回的是 <skill name="{name}">\n{body}\n</skill>。用 XML 标签框住外部文档,是为了让模型分得清哪段是它自己的推理、哪段是被读进来的资料。
load_skill 这一层还处理了一个很实在的工程问题。工具结果有字符上限(常量定义在 src/config/limits.py 的 TOOL_RESULT_LIMIT),而 load_skill_tool.py 的 docstring 自述:88 份内置技能里有 31 份超过这个上限。早期的截断是静默的——模型拿到一份被砍掉尾巴的文档,却以为自己读完了。现在的实现改成分页信封,每次返回都带上 total_chars、offset、next_offset、complete 四个字段,模型看到 next_offset 非空就知道还得再读一轮。分页大小还会按序列化后的实际长度回缩,因为 JSON 转义是内容相关的,换行密集的 markdown 一个换行占两个字符。
这里有条可以直接搬走的经验:凡是可能被截断的工具返回,都要在返回体里显式声明”这是不是全部”。让模型自己去猜完整性,是把不确定性推给了最不该承担它的一方。这一点在 工具返回值该怎么设计 里展开过。
二、写技能被拆成四个工具
agent/src/tools/skill_writer_tool.py 一个文件里放了四个 BaseTool 子类。它们的分工是按”写整篇 / 改一处 / 删整个 / 管附件”切的,不是按 CRUD 教科书切的。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
SaveSkillTool(save_skill) | 写入或覆盖一份完整的 SKILL.md,缺 frontmatter 时自动补一段 | agent/src/tools/skill_writer_tool.py | 一个流程跑通了,想把它固化下来 |
PatchSkillTool(patch_skill) | 对已有技能做一次精确文本替换 | 同上 | 上游接口参数变了,只想改那一处 |
DeleteSkillTool(delete_skill) | 整个删掉一个用户技能目录 | 同上 | 试错留下的半成品技能要清掉 |
SkillFileTool(skill_file) | 管技能目录下的附件,动作有 write / remove / list | 同上 | 技能需要配模板、参考资料或示例 |
SkillsLoader | 扫描两个目录、解析 frontmatter、生成摘要、按名取全文 | agent/src/agent/skills.py | 排查”我写的技能怎么没被认出来” |
LoadSkillTool(load_skill) | 分页把技能正文读进上下文 | agent/src/tools/load_skill_tool.py | 长技能读不全、需要续读时 |
| frontmatter 解析器 | 解析 --- 之间的键值,支持字符串、[a, b] 列表、布尔 | agent/src/agent/frontmatter.py | frontmatter 写复杂了却没生效时 |
| 内置技能目录 | 随包分发的 88 个技能 | agent/src/skills/ | 找范本、找命名惯例 |
| 用户技能目录 | 你自己写的技能落盘处 | ~/.vibe-trading/skills/user/<slug>/ | 备份、版本管理、跨机同步 |
几个实现细节值得单独拎出来。
目录名是算出来的,不是你给的。 _sanitize_skill_name() 把名字转小写、去首尾空白、把不在白名单里的字符全替换成连字符、再截到 60 字符。白名单里除了 a-z0-9-,还刻意保留了 CJK、泰文、阿拉伯字母、希伯来字母、西里尔字母的码点区间,注释写明了原因:不这么做的话,不同的非拉丁名字会全部塌缩成 --。如果清洗完只剩连字符,就退化成名字的 SHA-256 前 8 位。
附件只能放进四个子目录。
_ALLOWED_SUBDIRS = {"references", "templates", "examples", "assets"}
skill_file 的 write 动作会检查相对路径的第一段在不在这个集合里,且路径至少两段——也就是说,你没法用这个工具往技能根目录直接扔文件。写入前还有一道 target.resolve().relative_to(skill_dir.resolve()) 的路径穿越检查,越界直接返回 Path escapes skill directory。remove 动作另外硬拦了 SKILL.md,提示你去用 delete_skill。
用户技能优先于内置技能。 SkillsLoader._load() 的遍历顺序是先用户目录、后内置目录,用一个 seen_names 集合去重,同名时先来的赢。patch_skill 改内置技能的做法正是利用这一点:先把内置的 SKILL.md 整份复制到用户目录,再在副本上改。
三、research-discipline:一份能用的技能文档长什么样
内置技能里,agent/src/skills/research-discipline/SKILL.md 特别适合当范本,因为它短、结构完整,而且它自己在最后一行就交代了自己在体系里的位置。
它的 frontmatter 只有三个键:
---
name: research-discipline
category: analysis
description: "A short self-bias checklist to run at the START of any investment research task ..."
---
description 是个长句,把触发时机(任何研究任务开始时)、内容(四类偏见加一类)、耗时(60 秒)、以及”这不是一个工作流,只是一次态度重置”全写进去了。回到第一节那个结论:这行字是唯一常驻上下文的东西,它必须同时回答”这是什么”和”我什么时候该读它”。多数写砸的技能,砸在这一行。
category 填的是 analysis,正好落在 SkillsLoader._CATEGORY_ORDER 列出的顺序里(data-source、strategy、analysis、asset-class、crypto、flow、tool、other)。不在这个列表里的分类会被排到最后。而 save_skill 在自动补 frontmatter 时的默认分类是 user——不在列表里,于是你新写的技能默认沉底。
正文结构是三段:一张表、一份操作步骤、一段交叉引用。
表格五行,每行三列:偏见名 / 它怎么表现出来 / 怎么纠正。纠正列写的是可执行动作,比如针对英文语料偏见那行,要求对硬件与供应链类命题显式用日韩台本地语言检索;针对确认偏见那行,要求做一次芒格式反演,每个多头论点都主动去搜对应的空头说法,且每个结论至少引一条反证。这些都是动词开头、能当场照做的指令,不是”要注意保持客观”。
操作步骤四条,卡在流程的两端:第一次检索之前读表、写一句话论点、逐条自问;研究做完写结论之前再回查一遍,问自己有没有引反证、有没有漏掉非英语市场的参与者、有没有哪个关键数字已经过期。
最后一段把它和另外两处对齐:financial_rigor 工具的 cross_validate 模式管数据层的数字核验,report_audit 管输出层的成稿核验,而这份技能管思考层。一份技能文档明确写出”我不管什么、谁管”,比多写两千字更值钱。
顺带说清一件事:这份文档讨论的是研究过程中的认知偏差,属于工作方法,不构成任何投资建议;历史表现不代表未来,本文只讨论工程实现。
四、边界与代价:它明确不管的事
没有版本、没有回滚。 save_skill 的写入是 mkdir(parents=True, exist_ok=True) 加 write_text,同名直接覆盖,不备份、不提示、不返回旧内容。delete_skill 是 shutil.rmtree,没有回收站。技能目录本身也不在仓库里,落在 ~/.vibe-trading/skills/user/——你想要历史,得自己在那个目录上开一个 git 仓库。
没有质量门。 四个工具里没有任何一个校验 SKILL.md 的正文结构、检查内链是否有效、或者验证里面写的接口参数是否真的存在。save_skill 只在内容不以 --- 开头时补一段最小 frontmatter,写的是 description: User-created skill——一行毫无信息量的占位描述,按第一节的逻辑,这个技能基本等于写了没用。合不合格全靠写的人。
patch_skill 只替换第一处。 代码里是 content.replace(find_text, replace_text, 1),硬编码的 count=1。同一个过时参数在文档里出现三次,你得调三次。
改过的内置技能会脱离上游。 patch_skill 一旦把内置技能复制进用户目录,之后 SkillsLoader 永远优先加载你那份副本。升级版本后内置技能的修订不会再到你手上,而且没有任何提示告诉你正在用一份分叉。
附件不会自动进上下文。 load_skill 只返回 SKILL.md 的 body。Skill.load_support_file() 存在,但按文件名从技能目录取,是留给调用方的按需入口,不是自动加载。所以 references/ 里的东西,得在 SKILL.md 正文里明确写清楚”什么情况下去读哪个文件”,否则等于没放。
它完全不管安全边界。 技能机制只负责把文本喂进上下文,至于文本里写了什么、模型照做会发生什么,是另一套机制的事。AGENT_CONTRIBUTOR_GUIDE.md 单独列了一节 High-Risk Surfaces,要求下面这些动作必须先拿到维护者或操作者的明示批准:下单、撤单、批准、平仓等影响券商委托的操作;授权券商、OAuth、MCP、交易所、支付、钱包或云账号;把真实凭据写进 agent/.env、~/.vibe-trading/ 或令牌缓存;启动可被外部访问的 API、MCP、SSE、webhook、面板服务。
这几条的代价要说透。凭据一旦落到本地文件或令牌缓存里,暴露面就从”你的账号”扩大到”任何能读这台机器这个目录的进程”,包括你后来装的其它工具;已成交的委托不存在撤销,纠错只能是反向下单,那是另一笔真实交易;而程序化交易本身在不同司法辖区有各自的报备、限速与资质要求。同一份指南还写着 broker connector 的写操作必须保持 mandate-gated、kill-switch-aware、fail-closed、audit-logged,并要求优先用脱敏 fixture 而不是真实金融数据。这些是仓库对贡献者的工程要求,能不能按某种方式使用,以你所在司法辖区的监管要求与券商协议为准。
顺便一提事实归属:agent/src/factors/ 下那些因子并非项目自研。仓库根目录的 NOTICE 写明,Microsoft Qlib 的特征定义按 Apache 2.0 引入,另有几组公式来自公开论文与研报(Kakushadze 的 101 Formulaic Alphas、国泰君安的 191 短周期因子、Fama-French 五因子与 Carhart 动量、Hou-Xue-Zhang q-factor),仓库把它们当作不受版权保护的数学事实重新实现,各子目录另有 LICENSE.md。你要判断能否商用,以许可证原文为准,本文不提供法律意见。
五、上手与避坑清单
从改一份内置技能开始,别从零写。 为什么会踩:从零写你会同时面对结构、分类、描述三件事,任何一件错了都表现为”技能没被用上”,很难归因。怎么避:先照着 agent/src/skills/research-discipline/SKILL.md 抄结构——三个键的 frontmatter、一张动作表、一份操作步骤、一段边界声明,把内容换成你的领域。
description 里写清触发条件,不要只写主题。 为什么会踩:写文档的直觉是描述内容,但这一行的真实作用是让模型判断”现在要不要读它”。怎么避:句子里必须含时机词——在什么任务开始时、在遇到什么现象时。范本里那句 run at the START of any investment research task 就是标准写法。
别用 save_skill 的默认分类。 为什么会踩:不传 category 时补的是 user,不在 _CATEGORY_ORDER 里,会被排到摘要列表最末尾。怎么避:显式传一个列表里已有的分类。
名字要短,且假定会被清洗。 为什么会踩:_sanitize_skill_name() 会小写化、把非白名单字符换成连字符、截到 60 字符,两个只在标点或大小写上不同的名字可能塌缩成同一个 slug,而 save_skill 覆盖时不会警告。怎么避:一开始就用小写加连字符的短名,写完立刻用 skill_file 的 list 动作确认目录里是你以为的那个名字。
别跟内置技能重名。 为什么会踩:SkillsLoader._load() 先扫用户目录,同名时用户版本赢,且没有任何提示。你以为在读内置文档,其实读的是自己半年前写废的那份。怎么避:写之前先看一眼 agent/src/skills/ 下的 88 个目录名。
附件只能放进那四个子目录,而内置技能不受这个约束。 为什么会踩:内置技能里确实存在 scripts/ 子目录(比如 agent/src/skills/eastmoney/scripts、agent/src/skills/ashare-pre-st-filter/scripts),也存在放在技能根目录的散文件(比如 agent/src/skills/candlestick/example_signal_engine.py)。你照着这个布局给自己的技能建 scripts/,skill_file 会拒收,因为它的白名单只有 references / templates / examples / assets。怎么避:脚本放 templates/ 或 examples/,或者认下这个限制、手工在文件系统里建目录(但那样就绕过了工具的路径检查)。
长技能记得续读。 为什么会踩:load_skill 一次只给一页,返回体里 complete 为 false 时还有后文。怎么避:在技能正文里避免把关键约束放到最末尾;调用方看到 next_offset 非空就必须带着它再调一次。
改内置技能前先想清楚要不要分叉。 为什么会踩:patch_skill 会静默地把内置技能复制到用户目录,之后升级不再影响你。怎么避:只是想临时试一下的话,改完记得用 delete_skill 把用户目录里那份删掉,加载就会退回内置版本。
六、写完之后自查什么
把这几条过一遍,基本能挡掉大部分”写了却没用上”的情况:
- description 这一行里,有没有一个明确的触发时机?
- category 是不是
_CATEGORY_ORDER里已有的分类? - 正文里的每条指令,是不是动词开头、能当场照做?
- 有没有一段写清”我不管什么”、以及那部分该由谁管?
- 引用的接口参数、字段名,是不是回代码核过而不是凭印象写的?
- 附件放的位置,
SKILL.md正文里有没有明确说什么时候去读?
想继续往下挖,按这个顺序读:agent/src/agent/skills.py 看加载与覆盖规则,agent/src/agent/context.py 看这些摘要最终怎么拼进系统提示词(里面那句 “Load the relevant skill BEFORE starting any task” 就是让模型主动调用 load_skill 的指令来源),agent/src/tools/load_skill_tool.py 看分页信封,最后回到 agent/src/tools/skill_writer_tool.py 逐个读四个工具的 parameters 定义。
技能机制的横向对比可以看 三套 Agent 技能机制的对比,那篇讲的是不同框架在同一问题上的取向差异,和本篇的单仓库细读正好互补。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 开源项目 Vibe-Trading 的技能体系:88 个目录如何按需加载 和 开源项目 Vibe-Trading 工具层:72 个文件与上下文预算。