开源项目 Vibe-Trading 的技能体系:88 个目录如何按需加载
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
HKUDS 开源的 Vibe-Trading 里,这套技能体系真正解决的问题只有一个:把「这个 Agent 会做什么」和「这一轮上下文里要装什么」拆成两件事。 前者是 88 份手册,后者是 88 行描述。中间靠一个叫 load_skill 的工具把它们连起来——模型开局看到的是目录,需要哪一章再喊哪一章。
Vibe-Trading 是 HKUDS 放出的开源个人交易 Agent(仓库 https://github.com/HKUDS/Vibe-Trading ,MIT 许可证,Copyright 2026 Vibe-Trading Contributors)。这里说的 Vibe-Trading 是这个项目的专有名称,跟”凭感觉做交易”这类说法没有关系。它的 agent/src/skills/ 下有 88 个技能目录、共 404 个文件,每个目录里至少有一份 SKILL.md。本文只看这套目录怎么组织、怎么被加载,不涉及任何投资判断。
站内已经写过几种技能机制:Claude Code 的 Skills 机制讲的是官方 harness 怎么发现和触发技能,技能文件的契约怎么写讲的是单份技能文档内部的结构约定,ECC 的技能组织法讲的是另一套仓库怎么给技能分层。这篇不重复那些,它盯的是 Vibe-Trading 这一套的具体数据通路:从磁盘上的目录,到系统提示词里的那几行字,再到工具返回的那个 JSON 信封,中间每一步的代码在哪、为什么这么写。
一、它解决的是什么问题
先看规模。agent/src/skills/ 里 88 个目录,最大的那份 tushare/SKILL.md 单文件就有十几万字节的量级;social-media-intelligence、correlation-analysis、credit-analysis 这几份也都是几万字节起步。这些不是简短的提示词片段,是完整的方法论文档,带表格、带代码模板、带参数说明。
如果全塞进系统提示词,结果是可以预见的:每一轮对话都要为 88 份手册付一次代价,而其中 87 份跟当前任务无关。这是上下文预算的经典冲突,站内在上下文预算怎么分配里展开过通用做法。
Vibe-Trading 的选择是分级披露。agent/src/agent/skills.py 的模块 docstring 把这个意图写得很直白:系统提示词只注入一行摘要(get_descriptions),完整文档按需加载(get_content,由 load_skill 工具调用)。
具体的注入点在 agent/src/agent/context.py。ContextBuilder.build_system_prompt 把几个字段填进模板,其中 skill_count 取 len(self.skills_loader.skills),skill_descriptions 取 self.skills_loader.get_descriptions()。系统提示词里给技能留的位置就一小节,标题是「Skills(use load_skill to read full docs)」——标题本身就是给模型的操作指令。
二、目录长什么样:SKILL.md 的三个键
_load_skill_dir 的逻辑很短:进一个目录,找 SKILL.md,找不到就返回 None,直接跳过这个目录。读到文件后交给 parse_frontmatter 拆成元数据和正文,然后取三个键——name(缺省用目录名)、description、category(缺省 other),连同正文和目录路径包成一个 Skill 对象。
拿 agent/src/skills/akshare/SKILL.md 当样本,它的 frontmatter 是这样:
---
name: akshare
category: data-source
description: AKShare financial data aggregator (…). Free, no API key. Covers A-shares, US, HK, futures, macro, forex. Primary fallback for tushare and yfinance.
---
(description 开头还有一句对上游开源项目热度的标注,跟加载机制无关,上面用省略号代过。)
这一行 description 是这个技能在模型眼里的全部——覆盖什么市场、要不要 API key、在数据源链条里排第几位。它没有写 AKShare 是什么,写的是”什么时候该选它”——描述面向的是调度决策,不是知识介绍。
正文里才是细节:接口清单、中文列名到英文的对照表、日期格式 YYYYMMDD、代码格式(A 股纯数字 "000001"、美股带交易所前缀 "105.AAPL"、港股五位补零 "00700")。这些东西模型不需要提前知道,需要用的时候取一次就够。
分类这一层也有讲究。SkillsLoader 里有个 _CATEGORY_ORDER 常量,按 data-source、strategy、analysis、asset-class、crypto、flow、tool、other 的顺序排,没列进去的分类统一排到末尾(ordered_cats 先取列表内的,再把剩下的排序追加)。我把 88 份 frontmatter 的 category 数了一遍:analysis 22 个、strategy 19 个、tool 和 data-source 各 10 个、asset-class 9 个、flow 8 个、crypto 7 个,另有 research 2 个、risk-analysis 1 个。后两个不在 _CATEGORY_ORDER 里,所以它们会掉到列表尾巴上。
三、按需加载:load_skill 与它的分页信封
agent/src/tools/load_skill_tool.py 里的 LoadSkillTool 是这套机制的另一半。工具名 load_skill,参数只有两个:必填的 name,选填的 offset;repeatable = True,意味着一轮里可以连着调多次。
取到的内容由 SkillsLoader.get_content 返回,格式是 <skill name="..."> 包住正文的 XML 片段。名字不对时它不是干巴巴地报错,而是返回 Error: Unknown skill 'x'. Available: ... 并把所有可用名字列出来——错误信息本身就是一次纠正机会,模型不用再单独调一次列表工具。这是工具返回设计里很值得抄的一手,站内工具返回该怎么设计展开过同类模式。
真正有意思的是分页。这个模块的 docstring 把动机写得很清楚:技能文档本身就是这个产品相当一部分的实现(债券数学、隐含波动率求解、冲击模型、A 股市场结构,全写在模型读了就执行的 markdown 里),而单次工具结果有字符上限(常量 TOOL_RESULT_LIMIT,定义在 src/config/limits.py),88 份技能里有 31 份超过这个上限。docstring 里的原话是:以前的截断是静默的,Agent 分不清自己拿到的是完整文档还是被截肢的版本,也没有办法够到剩下的部分。
于是返回值被改成了一个明确的信封,字段包括 status、content、total_chars、offset、next_offset、complete。读到一半时 complete 为假、next_offset 给出续读位置;读完时 next_offset 是 None。工具描述里也直说了:长技能分页返回,结果里报了 next_offset 就带着它再调一次。
分页的实现比想象中绕一层。上限卡的是序列化之后的整个信封,而不是正文本身,JSON 转义是内容相关的——换行密集的 markdown,一个换行要吃两个字符。所以代码里是一个收缩循环:先按 _PAGE_CHARS(上限减去 600,给 JSON 的键名和计数器留余量)切一页,序列化,超了就按溢出比例缩小再减 64 个字符,直到装得下或者触底 _MIN_PAGE_CHARS——这个下限存在的理由是防止一份转义密集的文档退化成一次一个字符地爬。
四、各部分在仓库里的位置
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 技能目录 | 一技能一目录,SKILL.md 的 name / description / category 决定它怎么被索引 | agent/src/skills/<name>/SKILL.md | 新增或改写一条方法论时 |
SkillsLoader | 启动时扫目录、解析 frontmatter、按分类拼描述行、按名字取全文 | agent/src/agent/skills.py | 排查”技能没被加载”时 |
| frontmatter 解析 | 手写的类 YAML 解析,只认字符串、[a, b] 列表和布尔 | agent/src/agent/frontmatter.py | frontmatter 写复杂了不生效时 |
ContextBuilder | 把描述行和技能总数填进系统提示词模板 | agent/src/agent/context.py | 想知道模型开局到底看见什么时 |
LoadSkillTool | 按名字取全文,带 offset 分页与完整性字段 | agent/src/tools/load_skill_tool.py | 技能长到一轮取不完时 |
| 技能 CRUD 工具 | save_skill / patch_skill / delete_skill / skill_file | agent/src/tools/skill_writer_tool.py | 让 Agent 自己沉淀或修补技能时 |
| MCP 侧入口 | 暴露 list_skills 和 load_skill 两个 MCP 工具 | agent/mcp_server.py | 从别的 Agent 宿主接进来时 |
| Swarm 白名单 | 按编制里声明的技能名过滤描述行,只给子 agent 开一部分 | agent/src/swarm/worker.py | 想收窄某个角色的可见范围时 |
agent/src/tools/ 下共 72 个文件,agent/src/swarm/presets/ 下有 30 份编制 yaml,agent/src/trading/connectors/ 下有 12 个连接器子目录——技能只是这套东西的一层,但它是模型每一轮都会看见的那一层。
五、用户技能层:覆盖、改写、落盘
skills.py 里定义了 USER_SKILLS_DIR,指向 ~/.vibe-trading/skills/user/。_load 的遍历顺序是先用户目录、后随包目录,配合一个 seen_names 集合——同名时用户版本先入座,随包版本被跳过。代码注释里给了具体场景:patch_skill 把随包技能复制到用户目录改过之后,加载的就是改过的那份。
这套 CRUD 在 agent/src/tools/skill_writer_tool.py:
save_skill把一份完整的SKILL.md写进用户目录;内容不以---开头时它会自动补一段 frontmatter。patch_skill是精确的查找替换(只替换第一处)。技能在用户目录就直接改;只在随包目录,就先复制一份到用户目录再改——仓库文件不动。delete_skill只能删用户目录下的,随包技能删不掉。skill_file管技能目录里的附属文件,写入路径必须以references、templates、examples、assets之一开头,并且用resolve().relative_to()挡路径穿越,SKILL.md本身不允许被 remove。
还有一个容易漏掉的细节:get_content 在内存里的技能列表找不到时,会回到用户目录的磁盘上再找一次,找到就补进列表。这一步是给”会话中途新建的技能”留的——SkillsLoader 只在初始化时扫盘,没有这个兜底,Agent 刚 save_skill 存下的东西同一轮里读不回来。
名字的规范化也考虑过非拉丁字符:_sanitize_skill_name 的字符白名单里显式保留了 CJK、泰文、阿拉伯字母、希伯来字母和西里尔字母的区间,注释写明理由是”不让不同的非 ASCII 名字全都塌缩成 --”。
六、边界与代价
这个设计不是免费的,它明确放弃了几样东西。
检索面只有一行字。 没有向量检索,没有关键词匹配,没有相似度排序。模型能依据的就是那 88 行 description。描述写歪了,这个技能对模型来说约等于不存在——而这种失败是静默的,不会报错,只会表现为”它没想到要用这个”。
一次只能按名字取一个。 load_skill 的参数就一个 name,没有”把跟波动率相关的都给我”这种查询。想横向对比几份技能,就是几轮往返。
长文档要多轮。 88 份里 31 份超过单次上限,分页把它们切成多次调用。每一次往返都是延迟,也都是重新进上下文的代价。
技能文本没有测试保护。 docstring 自己承认这些 markdown 就是产品的一部分实现。代码有类型检查和单测,markdown 里的公式和参数没有。patch_skill 这个工具存在本身就说明了预期:技能会过时,要能就地修。
frontmatter 不是完整 YAML。 parse_frontmatter 是正则加逐行切分,只支持单行的字符串、方括号列表和布尔。多行值、嵌套结构、注释一律不认,而且不认的表现不是报错:不含冒号的行被静默跳过,含冒号的行(比如嵌套块的子键、写在冒号后的注释)会被当成一个平铺的字符串键值塞进元数据里。写的人以为自己表达了层级,解析器看到的是一堆扁平的字符串。
它明确不管的事。 不管技能内容对不对;不管你连着 load_skill 五次之后上下文还剩多少(预算是调用方的事);不做去重,同一份技能取两次就在上下文里躺两份。
还有一条跟本文主题相邻但性质不同:技能是方法论文档,跟实盘执行是两套东西。仓库里跟券商相关的是 agent/src/trading/connectors/ 下的 12 个连接器子目录和一组 trading_* 工具,走的是连接器 profile 而不是技能。agent/SKILL.md 里对这条线的措辞相当克制:IBKR 的官方 MCP 端点只当只读探针接,通配符只对读作用域放行,写作用域要求显式的工具白名单;印度侧的 Shoonya / Dhan 连接器,实盘下单在结构上被禁用,理由写的是这些券商没有暴露 paper/live 开关。这些是工程事实,不是使用建议。真要接实盘,代价必须自己算清楚:凭据落在本地就有暴露面,下错的单不可撤销,程序化交易的合规义务因司法辖区而异——能不能这么用,以你所在司法辖区的监管要求与券商协议为准。
因子这块也顺带说明一句出处,因为很容易被误读成”项目自研”。仓库根目录的 NOTICE 写得很明确:qlib158 的特征定义来自 Microsoft Qlib,走 Apache 2.0,另有子目录 NOTICE 与 LICENSE.md;alpha101 来自 Kakushadze (2015) 的论文(arXiv:1601.00991);gtja191 来自国泰君安 2014 年的研报;academic 那组来自 Fama-French 等公开模型。NOTICE 的原话是:只重实现了数学公式这一类事实性内容,源论文与研报的正文、表格、图不在本仓库复现。所以准确的说法是”公开公式的工程化重实现”,不是”自研因子库”。历史表现不代表未来,本文只讨论工程实现;能不能商用以许可证原文为准,本文不提供法律意见。
七、上手与避坑清单
1. 技能内部的相对链接要带技能名前缀。 agent/src/skills/eastmoney/SKILL.md 里专门写了一条链接约定:文档内所有指向 references/ 的链接都以技能名开头写成 eastmoney/references/...,理由是 read_file 工具以 skills/ 为根解析路径。为什么会踩:写 markdown 的直觉是相对当前文件写路径,写成 references/xxx.md 看着更自然,但工具解析的根不在这儿,读取会失败且失败信息跟链接没关系。怎么避:新增技能时照抄这条约定,把技能名当路径前缀的一部分。
2. 文档里承诺的子目录未必真的存在。 akshare/SKILL.md 结尾写着”更冷门的接口见 references/ 子目录”,但这个技能目录下实际只有 SKILL.md 一个文件。Skill.load_support_file 的行为是文件不存在返回 None,异常也吞掉返回 None。为什么会踩:模型读到这句话会去找,找不到就只能靠自己编或者放弃,而这一步不会留下任何错误痕迹。怎么避:写技能时先建目录再写指路句,或者审技能时把 SKILL.md 里出现的所有路径过一遍。
3. 文档里的数字和代码里的数字要分开信。 agent/SKILL.md 里说有 30 个 swarm 团队,agent/src/swarm/presets/ 下确实是 30 份 yaml;但 context.py 的系统提示词模板里,这个数字是硬编码写着 29 的。相比之下 skill_count 走的是 len(self.skills_loader.skills),动态算。为什么会踩:手写在提示词里的常量没有任何机制保证它跟磁盘一致,加一份 preset 不会让它自己加一。怎么避:引用任何数量时先确认它是算出来的还是写死的,写死的那类以目录实际内容为准。同一份文件的 frontmatter 里还有版本号,同理,那是文档快照不是运行时事实。
4. category 拼错不会报错,只会掉队尾。 _load_skill_dir 对 category 的处理是取不到就给 other,取到什么就是什么,没有枚举校验。_CATEGORY_ORDER 只列了 8 个值,不在列表里的会被排到末尾。为什么会踩:risk-analysis 和 analysis 看着差不多,写错一个字这份技能就从第三组掉到最后一组。怎么避:新增技能前把现有 88 份的 category 值统计一遍,对齐已有取值,别自创。
5. offset 越界是错误不是空页。 execute 里判断 offset >= total and total 就直接返回 error,消息里带上那份技能的总字符数。为什么会踩:如果调用方按固定步长自己推进 offset,而不是用返回的 next_offset,最后一页之后必然越界。怎么避:只认 next_offset,complete 为真就停,别自己算。
6. 别绕过工具直连上游数据源。 eastmoney/SKILL.md 开头挂了一条限速红线:东方财富按源 IP 限流并会临时封禁突发请求,所有工具内部已经过共享的 per-host 节流层(backtest.loaders._http),不要绕过工具对端点发裸 HTTP 突发请求;最小请求间隔可以用环境变量 VIBE_TRADING_EASTMONEY_MIN_INTERVAL 调。为什么会踩:模型看到技能里的端点 URL,很容易顺手就用 shell 或者别的方式直接打过去。怎么避:技能文档里凡是给出端点的地方,同时写清”必须走哪个工具”,把约束和知识放在同一屏。
7. patch_skill 改的是家目录副本,不是仓库。 补丁落在 ~/.vibe-trading/skills/user/ 下,加载时优先级高于随包版本。为什么会踩:这个行为有两面——升级 pip 包之后你的修正还在,但上游对同一份技能的修复也会被你那份旧副本盖掉,而且盖得悄无声息。怎么避:定期把用户目录下的技能跟随包版本 diff 一遍,确认覆盖还是你想要的。
收束
这套东西拆到底其实就三层:磁盘上一技能一目录、系统提示词里一技能一行、要用时按名字取全文并分页。真正值得抄的不是”技能目录”这个形式,而是两个判断——描述行是唯一的检索面,所以它的写法决定技能存不存在;截断必须可见,所以返回值里得有 total_chars / next_offset / complete 这种让调用方能自己判断完整性的字段。
要动手核对,按这个顺序读四个文件就够:agent/src/agent/skills.py 看加载与索引,agent/src/tools/load_skill_tool.py 看取用与分页,agent/src/agent/context.py 看注入点,agent/src/tools/skill_writer_tool.py 看写回路径。再随便挑一份 agent/src/skills/<name>/SKILL.md,对着 frontmatter 的三个键验证一遍你的理解——这套机制的全部约定就在那三行里。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 开源项目的数据源层拆解:一个注册表、一条回退链和一份路由技能文档 和 开源交易 Agent 项目 Vibe-Trading 里怎么自己写一个技能:四个工具加一份范本。