开源项目 Vibe-Trading 工具层:72 个文件与上下文预算

2026-08-05

本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。

**看完这个目录你会发现一件反直觉的事:工具多不是它最难的部分,「一次工具调用能往上下文里塞多少字符」才是它写得最狠的地方。**HKUDS 开源的这个交易 Agent 项目里,agent/src/tools/ 下 72 个受版本控制的文件,注册逻辑只有几十行;而围绕「结果放不下怎么办」这件事,仓库里专门有一个共享常量模块、一个整条记录分页的工具函数、一个自述式截断通知,加上主循环里五层上下文管理。工具数量是加法,上下文预算是减法,后者才是这套设计真正的承重墙。

先声明本篇的分工:站内的 MCP 工具数量该控制在多少 讲的是「给模型挂多少工具合适」这个通用取舍,Agent 工具设计 讲的是单个工具怎么定义参数和返回,大模型工具调用机制 讲的是底层的 function calling 协议;本篇不重复这些,只做一件事——把 Vibe-Trading 这个具体仓库的工具目录摊开,看它在真实规模下的取舍长什么样。

本文只讨论工程实现,不讨论任何标的、策略或收益,也不提供投资建议。

一、72 个文件不是靠目录分组的

第一个要纠正的预期:这里没有 data/analysis/trading/ 这类分类子目录。agent/src/tools/ 是一个平铺目录,顶层 68 个 .py 文件加上唯一的子包 ocr/(内含 engine.pyllm_vision_ocr.pyrapid_ocr.py__init__.py),加起来正好 72 个文件——你自己 git ls-files agent/src/tools | wc -l 就能数出来。

分组是靠规则做的,不是靠路径。agent/src/tools/__init__.py 里的 _discover_subclasses()pkgutil.iter_modules 遍历本包所有模块,逐个 importlib.import_module,然后用一个 deque 广度优先地走 BaseTool.__subclasses__(),把所有 name 非空的具体子类收进列表,结果缓存在 _SUBCLASSES_CACHE 里。这意味着「新增一个工具」的全部操作就是:在这个目录里新建一个文件,写一个继承 BaseTool 的类。文件头的注释原话就是「Done. It’s automatically discovered and registered.」

那么真正的分组维度是哪几条?读代码能读出四条彼此正交的:

第一条,下划线前缀 = 不参与发现。 _discover_subclasses() 里有一句 if module_name.startswith("_"): continue。所以 _result_paging.py_shell_safety.py 这两个文件虽然躺在工具目录里,却永远不会变成模型看得见的工具,它们是共享基础设施。同一目录下还有几个不带下划线但也不定义工具类的模块——path_utils.pyredaction.pytrade_journal_parsers.pytushare_fallbacks.py——它们靠「不定义 name 非空的 BaseTool 子类」把自己排除在外。

顺手纠正一个容易想当然的换算:文件数不等于工具数,而且方向是反的。68 个顶层模块里只有 61 个声明了静态 name,但这 61 个文件一共声明了 89 个工具名——trading_connector_tool.py 一个文件就带 10 个,goal_tool.pyhypothesis_tool.pyshadow_account_tool.pyskill_writer_tool.pyautopilot_tool.py 各带 4 个,background_tools.pyqveris_tool.py 各带 3 个。所以数目录只能拿到一个下界;模型实际看到多少,得等下面两道门跑完才算数。

第二条,依赖可用性门。 BaseTool.check_available() 默认返回 True,子类可以覆写。仓库里覆写了它的有 fred_macro_tool.pyiwencai_tool.pyqveris_tool.pytaiwan_stock_data_tool.pyweb_search_tool.py 五个。以 web_search 为例,它的实现就是 try import ddgs,失败再 try duckduckgo_search,两个都没有就返回 False。注册循环里对 check_available() 为假的类只打一条 info 日志然后跳过——不报错、不占工具位。这条规则的价值在于:同一份代码在装了可选依赖和没装的机器上,模型看到的工具清单是不同的,而且是自动收敛的。

第三条,策略门。 __init__.py 顶部有一个集合 _SHELL_TOOL_NAMES = {"bash", "background_run", "cancel_background"}build_registry() 的参数 include_shell_tools 默认为 False,为假时这三个直接跳过。文档字符串把理由写得很直白:本地 CLI / stdin 入口可以开,联网的 server 入口除非显式选择开启,否则应该保持关闭。这不是安全审计后补的补丁,而是把「谁在调用我」当成工具可见性的一部分。

第四条,类属性上的语义标记。 BaseTool 只有四个类属性:namedescriptionparametersrepeatable,外加一个 is_readonly。后两个不影响工具是否存在,但直接影响调度,第五节会讲。

二、通用底座:换个领域也照样要写的那部分

把 72 个文件按「离金融远近」重排,最内圈是一层跟交易毫无关系的地基。

agent/src/agent/tools.py 是整个工具层的全部抽象,只有两个类。BaseTool 是抽象基类,execute(**kwargs) -> str 约定返回 JSON 字符串,to_openai_schema() 把类属性拼成 OpenAI function calling 格式。ToolRegistry 更简单:一个 Dict[str, BaseTool],加 register / get / get_definitions / execute,再实现 __len____contains__

值得单独说的是 ToolRegistry.execute() 的契约——它的注释写的是「guarantee a valid JSON return value」。工具没找到,返回 {"status": "error", "error": "Tool 'x' not found"};工具执行抛异常,logger.exception 记一笔,然后返回带 statustoolerror 三个键的 JSON。也就是说,无论下面发生什么,回到模型手上的永远是一个能解析的对象。这条约定省掉的是模型侧最难缠的一类故障:拿到半截栈回溯,然后开始自由发挥。

通用底座的工具本身也很眼熟:文件读写与编辑(read_file / write_file / edit_file)、shell 与后台任务(bash / background_run / check_background / cancel_background)、外部信息获取(web_search / read_url / read_document / analyze_image)、记忆与会话(remember / session_search / compact)、技能读写(load_skill / save_skill / patch_skill / delete_skill / skill_file)、目标留痕(start_research_goal / get_research_goal / add_goal_evidence / update_research_goal_status)、以及多智能体编排入口 run_swarm。这些名字放进任何一个通用 Agent 项目都不违和。

真正体现工程成熟度的是那几个不对模型暴露的支撑模块。path_utils.py 的文件头把三个函数按三种威胁模型分开写:safe_path(p, workdir) 是给 read_file / write_file / edit_file 用的沙箱,模型必须待在当前运行目录内;safe_user_path(p) 处理用户提供的券商导出文件,只接受显式导入根下的路径,而不是整个 home 目录;safe_document_path(p) 走同一条导入根边界。三者一律用 ValueError 拒绝。redaction.py 则把脱敏拆成三件独立的事:内部路径前缀替换成 <redacted> 而保留相对尾巴、结构化载荷里的凭据与账户字段递归清洗、以及自由文本的模式清洗;并且区分了 ARGUMENTS_SINKRESULT_SINK 两种落点——前者失败关闭,后者要放过 content 这类本来就是解释目的的字段。_shell_safety.py 更具体:它用正则拦截各种「按名字批量杀 Python 进程」的 shell 写法(taskkill /impkill、PowerShell 的 Stop-Process -Name 与管道形式),因为那会把 Vibe-Trading 自己杀掉,错误信息还顺手告诉模型改用 cancel_backgroundbackground_run 返回的 task_id

三、金融专用:领域知识是怎么落到工具签名上的

外圈是这个项目的领域部分。分工大致是这样几簇:

行情与基本面取数,代表是 market_data_tool.py。它的 nameget_market_datadescription 直接告诉模型「在你自己动手写 yfinance/OKX/Tushare 脚本之前先用这个」。参数里 source 是一个枚举,把 autolongbridgeyfinanceyahoookxccxttusharebaostocktencentaksharemootdxeastmoneysinastooqfinnhubalphavantagetiingofmpmt5pykrx 一口气列全,并在描述里按「免费无 key」「需要 key 的 REST」「需要本地终端」分档说明。这是一种把领域知识写进 schema 而不是写进提示词的做法:模型选源时不需要记住谁免费谁收费,读参数说明就够了。同簇还有 get_financial_statementsget_fundamentalsget_stock_profileget_options_chainget_sec_filingsget_macro_series

第二簇是特定市场的结构性数据,中文语境的读者会更眼熟:get_dragon_tiger(龙虎榜)、get_northbound_flowget_margin_tradingget_block_tradesget_shareholder_countget_lockup_expiryget_fund_flowiwencai_searchget_taiwan_stock_data。这一簇没法从别的 Agent 框架里抄,也没法靠模型自己推断出来,是纯粹的领域投入。

第三簇是计算与研究工程:backtestfactor_analysistechnical_indicatorspatternsentimentportfolio_risk_xrayoptions_pricingoptions_payoff,以及围绕因子库的 alpha_zoo / alpha_bench / alpha_compare。这里必须说清一件事:agent/src/factors/zoo/ 下的几组因子并非项目自研。仓库根目录的 NOTICE 写明,Microsoft Qlib 的特征定义按 Apache 2.0 引入,另有几组公式分别来自 Kakushadze 的 101 Formulaic Alphas 论文、国泰君安 191 短周期因子研报、以及 Fama-French 等学术模型,仓库把它们当作「数学公式属于事实性内容」重新实现,并明确声明没有复制原文的文字、表格与图表;各子目录下另有 LICENSE.md。要判断能不能商用,以许可证原文为准,本文不提供法律意见。另外,历史表现不代表未来,本文只讨论这些工具的工程实现,不讨论任何结果好坏。

第四簇是流程与留痕:假设管理(create_hypothesis / update_hypothesis / link_backtest / search_hypotheses)、sdm_register / sdm_status / sdm_decay_scan、影子账户四件套(extract_shadow_strategy / run_shadow_backtest / render_shadow_report / scan_shadow_signals)、以及 report_auditfinancial_rigoranalyze_trade_journalpropose_mandate_profiles

最外一簇是真正碰钱的:trading_connector_tool.py 里十个 trading_* 工具,从 trading_connectionstrading_check 这类只读查询,一直到 trading_place_ordertrading_cancel_orderagent/src/trading/connectors/ 下有 12 个连接器子目录,README 也自述 12 家。

四、一张表:谁负责什么,你什么时候会碰到它

组成部分它负责什么仓库位置你什么时候会碰到它
BaseTool / ToolRegistry工具抽象与注册表,保证返回永远是合法 JSONagent/src/agent/tools.py想新增一个工具、或想搞清楚异常怎么回到模型时
自动发现与注册扫描目录、过滤下划线模块、依赖门与 shell 策略门agent/src/tools/__init__.py工具「莫名其妙不见了」时的第一现场
领域取数工具把多数据源差异收进一个 source 枚举agent/src/tools/market_data_tool.py需要行情但不想自己写抓取脚本时
结果分页按整条记录切页,信封里带 paging 元数据agent/src/tools/_result_paging.py工具返回被截断、模型把半截当全部时
结果字符预算单点常量与自述式截断通知agent/src/config/limits.pyload_skill 拿到 next_offset
五层上下文管理主循环里的压缩与折叠策略agent/src/agent/loop.py长会话开始丢早期上下文时
路径与脱敏三种威胁模型的路径闸、三类脱敏agent/src/tools/path_utils.pyredaction.py工具报 ValueError 拒绝路径、或日志里出现 <redacted>
交易连接器工具只读查询与下单/撤单,实盘走额外闸门agent/src/tools/trading_connector_tool.py接券商、或评估授权边界时
编制预设每个角色一份 tools: 白名单agent/src/swarm/presets/(30 份 yaml)想知道某个角色为什么调不到某工具时

五、工具多了,靠什么不把上下文撑爆

这是本篇最想讲的一节。Vibe-Trading 的答案不是「少挂工具」,而是把结果体积当成一等约束,分四个层次治。

第一层,把预算变成单点常量。 agent/src/config/limits.py 的文件头交代了来历:这个上限原先写在 src.agent.loop 里,又被当成裸字面量复制进 src.swarm.worker,两边会漂。现在它住在一个自身不 import 任何东西的叶子模块里,主循环、swarm worker 和各个工具都能读,谁也不用依赖谁。TOOL_RESULT_LIMIT 目前被 loop.pyworker.pyload_skill_tool.py_result_paging.py 直接 import;taiwan_stock_data_tool.py 没有 import 它,而是在注释里点名它、然后自己定一个留了余量的 RESPONSE_CHAR_BUDGET——理由写得很清楚:它的行是最旧在前,盲切会先删掉最新的那几根 K 线,还会切出无法解析的 JSON。

第二层,截断必须自我说明。 truncate_tool_result() 的注释把这类 bug 的形态说得很准:一次静默的切割和一个完整答案在模型看来毫无区别,于是模型把前缀当成全部往下推理。它的做法是,超限时不仅切,还要在尾部拼上一段 [TRUNCATED: ...] 通知,写清送达了多少、总共多少,并直白地告诉模型「不要把这当成完整结果——缩小请求范围,或者如果这个工具有分页参数就带上它再调一次」。连极端情况都想到了:如果限额小到连通知都放不下,那就只送通知,因为一个碎片比一句说明更没用。

第三层,能分页的别截断。 _result_paging.py 里的 fit_records() 解决的是 JSON 信封被拦腰切断的问题——切口落在结构中间,模型收到的是破碎片段。它的做法是按整条记录切:从 records[offset:] 取尽可能大的一页,序列化后如果超预算,就按溢出比例缩小条数再试,收缩公式是 count = max(1, min(count - 1, int(count * limit / len(payload)))),注释解释了为什么要额外减一——让宽记录也能在两三轮内收敛。每页信封里带一个 paging 块,字段是 total / offset / returned / next_offset / complete。这样一个部分答案是自我描述的,而不是和完整答案长得一模一样。目前用上它的是 financial_statements_tool.pysec_filings_tool.pyload_skill 走的是另一套字符级分页,同样在信封里给 total_chars / offset / next_offset / complete,它的文件头说明,88 个内置技能里有 31 个单份文档超出预算,所以分页不是可选项。

第四层,主循环里的压缩阶梯。 agent/src/agent/loop.py 开头列了五层:Layer 1 _microcompact 在内存压力下把较早的工具结果内容直接换成 [cleared],只保留最近若干条完整;Layer 2 _context_collapse 是纯字符串操作、零 API 成本,把老消息的长文本保留头尾、折叠中间,并留下 ...[N chars collapsed]... 的痕迹;Layer 3 _auto_compact 才调模型做结构化摘要,带尾部保护;Layer 4 是模型自己调 compact 工具主动触发 Layer 3——这个工具的 execute 只返回一句 {"status": "ok"},真正的动作在主循环里,因为它是一个信号而不是一次计算;Layer 5 是第 N 次压缩时更新已有摘要而不是从头再来。

还有两个小但值钱的细节。一是 tool_defs = None if is_last_iteration else self.registry.get_definitions()——最后一轮干脆不下发工具定义,逼模型收口而不是再起一轮调用。二是重复调用拦截:repeatable 为假的工具一旦成功过,再被调到就直接回一个 {"skipped": true, "reason": "... already completed successfully. Use the previous result."},连执行都不执行。仓库里 42 个工具文件声明了 repeatable = True,也就是说不可重复是默认,可重复是显式选择。is_readonly 则驱动调度:连续的只读工具走线程池并行,写工具串行,这是文件头「Read/write batching」那行注释的含义。

六、边界与代价:它放弃了什么

平铺目录 + 自动发现,换来的是加工具零成本,代价是没有编译期的分组约束。72 个文件靠命名约定和文件头注释区分职责,没有任何机制阻止你在一个 *_tool.py 里写第二个不相干的工具类,也没有机制强制新工具去考虑结果体积。分页目前只有两个工具用上,其余靠 truncate_tool_result 兜底——兜底会说明自己截断了,但拿不回被丢掉的部分。

自动发现的另一面是失败很安静。注册循环里的 check_available() 为假只打 info,registry.register 抛异常只打 warning,_filter_registry 丢掉白名单里不存在的工具时也只是 warning。这些设计对运行稳定性友好,对排障不友好:模型说「我没有这个工具」,你得去翻日志才知道是依赖缺了、还是被 shell 策略门挡了、还是白名单里名字拼错。

ToolRegistry 用工具名做字典键,同名后注册的直接覆盖前者。MCP 远程工具通过 mcp_<server>_<tool> 前缀规避冲突,本地工具之间就是纯约定。

它明确不管的事情也要说清。工具层不做结果正确性判断——ToolRegistry.execute 保证的是「返回一个合法 JSON」,不是「返回一个对的答案」。数据源之间的口径差异、复权处理、停牌与除权,工具层不替你统一,那是取数实现和使用者的事。is_readonly 是类属性的自我声明,不是运行时沙箱,一个标了只读却偷偷写盘的工具,调度器不会发现。

最后是碰钱那一层的代价,必须写实。trading_place_order 的类注释说明:paper 档位打到券商沙箱账户,live 档位要过「mandate 授权 + kill switch + fail-closed 盘前检查 + 审计」这一串闸门;它同时标了 repeatable = Falseis_readonly = False,注释里写「an order must never be silently re-issued」。__init__.py 里还有一段逻辑:非交互式运行(serve 或 swarm)遇到没有缓存 OAuth token 的 live 券商通道时,直接跳过并提示去桌面会话执行 vibe-trading connector authorize,而不是卡在一个打不开的浏览器上。哪怕闸门做到这个程度,代价依然存在——凭据一旦落到本机就有暴露面,下错的单在券商侧不可撤销,程序化交易的合规义务因司法辖区而异。你能不能这么用、用到哪一步,以你所在司法辖区的监管要求与券商协议为准。

七、上手与避坑清单

「我明明写了工具,模型看不到」。 为什么会踩:_discover_subclasses() 会跳过下划线开头的模块,也只收 name 非空的类;check_available() 返回假会被静默排除;bash / background_run / cancel_background 三个名字默认被 shell 策略门挡掉。怎么避:先确认文件名没以 _ 开头、类的 name 不是空串,再把日志级别开到 INFO 看有没有那两行「unavailable, skipping」或「disabled by shell tool policy」。

「模型拿到的数据只有一小半,还当成全部」。 为什么会踩:结果超过字符预算时会被截断,而截断在语义上不可见。怎么避:新工具只要可能返回列表,就用 _result_paging.fit_records() 而不是自己 json.dumps;同时在 description 里写明分页参数的用法——load_skill 的描述就明确写了「when the result reports next_offset, call again with that offset」。

「工具只被调了一次就再也不肯调了」。 为什么会踩:repeatable 默认为 False,成功过一次的工具再被调会收到 skipped 信封。怎么避:凡是天然需要多次调用的(取多只标的行情、多轮搜索、分页读取),显式写 repeatable = True。仓库里 42 个文件这么做了,可以对着抄。

「swarm 里某个角色调不到工具」。 为什么会踩:build_swarm_registry() 按预设里的 tools: 白名单做交集,白名单要了但注册表里没有的,会被丢掉并留一条 warning,worker 不会失败。怎么避:改预设 yaml 时把工具名和实际 name 属性对齐,别凭记忆写;MCP 工具还要注意 mcp_<server>_<tool> 的前缀拼法。

「本地跑得好好的,上了 server 就崩」。 为什么会踩:include_shell_tools 在本地 CLI 和联网 server 上的默认取向不同;文件访问受 VIBE_TRADING_ALLOWED_FILE_ROOTSVIBE_TRADING_ALLOWED_RUN_ROOTS 约束,而 path_utils.py 特意提醒:MCP 客户端是自己 spawn server 的,在 shell 里 export 环境变量根本到不了那个进程,得写进客户端的 server env 块。怎么避:把两种入口当成两套配置分别验证,别假设本地能跑就等于服务端能跑。

「以为工具清单和 MCP 暴露面是一回事」。 为什么会踩:agent/SKILL.md 里那张表标题是「Available MCP Tools (55)」,它是对外暴露的那一层;内部注册表还包含 bashedit_filecompactremembersession_searchsdm_*alpha_*trading_place_order 等不在那张表里的工具。怎么避:判断「模型能调什么」以注册表构建路径为准,判断「外部 MCP 客户端能调什么」再看那张表。

八、收尾

如果你只想拿走一条可复用的判断:工具层的复杂度不在数量上,在于每个工具的结果都要挤进同一条上下文管道。Vibe-Trading 把这条约束提炼成了一个叶子常量模块、一个整条记录分页函数、一段会自报家门的截断通知,和一套分层压缩——顺序是「能分页就分页,分不了才截断,截断了必须说」。这套顺序和你做的是不是金融无关。

想自己复核,按这个顺序读四个文件:agent/src/agent/tools.py 看抽象有多薄,agent/src/tools/__init__.py 看分组规则藏在哪几个 if 里,agent/src/tools/_result_paging.py 看结果预算怎么被当成一等约束,最后 agent/src/agent/loop.py 开头那段注释看五层上下文管理的全貌。再想深一层,可以接着看站内的 Agent 上下文预算怎么算Agent 工具返回值设计

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 开源交易 Agent 项目 Vibe-Trading 里怎么自己写一个技能:四个工具加一份范本Vibe-Trading 开源仓库的回测层:多引擎共用一个 runner

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。