开源项目 Vibe-Trading 的 30 份多智能体编制怎么调

2026-08-05

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

这里说的 Vibe-Trading 不是”凭感觉做交易”这个泛指说法,而是 HKUDS 在 GitHub 上开源的那个同名项目(HKUDS/Vibe-Trading)。读它这套多智能体之前先记住一件事:它不是”多派几个 Agent 一起想想”,而是一张在文件里写死的静态 DAG——有哪些角色、谁依赖谁、每个角色能碰哪些工具,全部在运行开始前就固定,运行期一行都改不了。 所以调它等于改文件,不等于调提示词;你能拿到的可控性和你会踩的坑,都来自这个取舍。

这个项目是 HKUDS 放出来的开源个人交易 Agent,许可证 MIT(Copyright 2026 Vibe-Trading Contributors)。本文只从 Agent 工程角度拆它的 swarm 这一层,不讨论任何投资方法,也不评价它任何一份输出的好坏。历史表现不代表未来,本文只讨论工程实现。

站内已经写过多智能体的三个横切面:框架怎么选看 多 Agent 框架选型,并发编排的通用模式看 Agent 并发编排,任务该拆到多细看 任务分解粒度。这一篇不重复那些通用结论,只做一件事:把一个真实项目的 30 份编制文件读透,看它在这三个问题上分别做了什么具体选择,以及这些选择在代码里长什么样。

一、编制文件里到底写死了什么

agent/src/swarm/presets/ 目录下是 30 份 .yaml,一份就是一支队伍。每份文件三段:agentstasksvariables

agents 段定义角色。以 investment_committee.yaml 为例,四个角色分别是 bull_advocatebear_advocaterisk_officerportfolio_manager。每个角色带一段很长的 system_prompt,另外还有 tools(工具白名单)、skills(可加载的技能名)、max_iterationstimeout_secondsmax_retries、可选的 model_name。这些字段在 agent/src/swarm/presets.pybuild_run_from_preset() 里被逐个读进 SwarmAgentSpec,缺省值也在那里给:max_iterations 默认 25、max_retries 默认 2。

tasks 段才是拓扑。角色和任务是分开的两张表,任务通过 agent_id 指回角色:

tasks:
  - id: task-bull
    agent_id: bull_advocate
    prompt_template: "Conduct full bull-side research on {target} and build a complete bullish investment case. Market: {market}."
    depends_on: []

  - id: task-risk
    agent_id: risk_officer
    prompt_template: "Review bull and bear arguments on {target}; from a risk angle assess validity, suggest position size, and propose risk management."
    depends_on: [task-bull, task-bear]
    input_from:
      bull_report: task-bull
      bear_report: task-bear

这里有两个字段容易混。depends_on 决定执行顺序input_from 决定上下文键名input_from 是一个字典:键是你自己起的名字(bull_report),值是上游任务 id。这个键名会原样出现在下游角色看到的提示词里,所以它不是随便写的注释,是给模型看的段落标题。

risk_committee.yaml 展示的是另一种形状:task-drawdowntask-tailtask-regime 三个任务 depends_on 全空,同时起跑;task-aggregate 依赖这三个,input_from 收成 drawdown / tail_risk / regime 三个键。一个扇出加一个扇入,两层就完事。而 investment_committee 是扇出两路、汇到风控、再汇到 PM,三层。

variables 段声明模板变量,带 namedescriptionrequiredinvestment_committeetargetmarket 两个,risk_committee 只要一个 goal。声明和实际使用是否对得上,可以靠 presets.py 里的 inspect_preset() 检查——它会把 prompt_template 里出现的格式化字段和 variables 声明做差集,多的报”使用了未声明的变量”,少的报”声明了但没被用到”,两条都是 warning 不是 error。

30 份编制里一共 118 个 agent 条目。大部分是 4 个角色,technical_analysis_panel 是 6 个,value_investing_committee 是 5 个,另有若干 3 个的。任务数和角色数在这批文件里一一对应。

把这一层的组件摊开,大致是这样:

组成部分它负责什么对应仓库位置你什么时候会碰到它
编制 YAML声明角色、任务依赖、模板变量、工具白名单agent/src/swarm/presets/*.yaml想改分工、加角色、换工具时
预设加载与体检按名解析路径、加载、列举、DAG 干跑校验agent/src/swarm/presets.py执行 /swarm inspect <preset>
关键词路由与变量抽取把自然语言映射到预设名,正则抽出模板变量agent/src/tools/swarm_tool.py主 Agent 自动选队选错了
分层调度拓扑分层,层内并发,层间串行,事件外发agent/src/swarm/runtime.py看 run 卡在哪一层时
单 worker 循环拼系统提示、渲染任务提示、跑 ReAct、落产物agent/src/swarm/worker.py排查某个角色跑偏时
工具白名单投影把角色的 tools 列表投影成实际注册表agent/src/tools/__init__.py角色抱怨”没有这个工具”时
行情锚定块运行前预取符号数据,渲染成提示词里的固定段agent/src/swarm/grounding.py怀疑数字来自模型记忆时

二、路由层:一句话怎么变成一支队伍

主 Agent 侧的入口是 agent/src/tools/swarm_tool.py 里的 SwarmTool,工具名 run_swarm,参数只有两个:必填的 prompt 和选填的 preset_name。类属性上 is_readonly = Falserepeatable = True

没给 preset_name 时,_match_preset() 做两步。第一步是精确名匹配:把 prompt 里的空格和连字符统一成下划线、转小写,然后看有没有哪个预设名整词出现。第二步才是关键词打分,规则表 _PRESET_KEYWORDS 是一串三元组,每项是(预设名、正则列表、权重):

(
    "risk_committee",
    [
        r"risk\s+audit",
        "drawdown",
        r"tail\s+risk",
        r"stress\s+test",
        r"\bVaR\b",
        "风控",
        "风险审计",
        "回撤",
        "尾部风险",
        "压力测试",
        "风险评估",
    ],
    1.0,
),

命中一个词加一份权重,取总分最高者;全都是 0 分时回落到 equity_research_team。权重是人手调的,中英文关键词混在同一张表里。

这张表有 26 项,而磁盘上是 30 份 YAML。差出来的四份——crypto_trading_deskearnings_research_deskglobal_equities_deskmacro_rates_fx_desk——不在关键词路由表里,也就是说自然语言无论怎么写都路由不到它们,只能显式传 preset_name 才能跑。这不是 bug,是同一份代码里的设计:_normalize_preset_name() 的注释明确写了,关键词路由只覆盖那张精选表,用户自建的预设”只能靠点名到达,永远不会被关键词匹配上”。你要是自己加了一份 YAML 却发现它从来不被选中,原因就在这里。

路由层还做了一件挺细的防护。_looks_like_continuation_prompt() 用一组正则识别”continue / resume / 继续 / 接着”这类续写型 prompt;如果 prompt 既像续写、又不含任何预设信号,_resolve_preset() 直接返回错误串,明说拒绝把续写请求自动路由到 equity_research_team。这个防护的价值很实在:续写 prompt 天然缺关键词,一旦落进默认分支,你会拿到一支和上一轮完全无关的队伍。

选完队伍还要填变量。_build_variables() 是一张写死的字典,按预设名给出该填哪些键。市场标签由 _extract_market() 用正则从 prompt 里抠(识别 A 股、港股、美股、加密相关的中英文写法),抠不到默认 A-shares;风险偏好由 _extract_risk_tolerance() 抠,抠不到默认 moderate;另有 _extract_sector()_extract_review_period()_extract_target_variable()_extract_strategy_type() 几个各管一摊。少数预设的变量在这一层被写成固定字符串,跟你 prompt 里说什么无关——比如加密和商品那两支队伍的分析对象就是硬编码的。你以为自己在提需求,其实那一格根本没接你的输入。

用户自建预设放在 ~/.vibe-trading/swarm/presets/presets.py_preset_search_dirs() 把用户目录排在包内目录前面,同名时用户文件覆盖内置文件。名字先过 _validate_preset_name():空串、...、含 /\ 一律 ValueError,因为名字会直接拼成 <dir>/<name>.yaml。错误信息里的家目录被 _redact_home() 折成 ~

三、跑起来之后:分层、锚定、上游上下文

SwarmRuntime 拿到 run 之后的顺序很清楚。先 _prefetch_grounding_data(),从 user_vars 里抽符号、预取数据,符号数量有上限、超了截断并写日志;抓到的结果由 grounding.format_grounding_block() 渲成一段 “Ground Truth — Recent Market Data” 的 markdown,整个 run 只渲一次,之后原样发给每个 worker。抓不到就是空串,那一段直接不出现。

然后 topological_layers(run.tasks) 把任务分层,一层一层跑。层内用 ThreadPoolExecutor 并发,并发度由 SWARM_MAX_WORKERS 环境变量控制;层与层之间是硬同步,上一层全部落地才进下一层。每层开始发 layer_started 事件,任务结束发 task_completedtask_failed,run 层面有 run_started / run_error / run_completed,worker 层面有 worker_started / worker_failed。上游失败的任务不会被派发,直接标记为 blocked 并计入整体失败。每层结束时 _sync_run_tasks_snapshot() 把任务状态回写一次 run.json——按层写而不是按任务写,是为了不让列表接口读到的快照太旧、又不至于 I/O 刷屏。

worker 侧的系统提示由 build_worker_prompt() 拼出来,顺序是固定的:角色段、system_prompt(其中 {upstream_context} 占位符被替换)、可用技能段、行情锚定段、市场数据工具政策段、数据引用纪律段、执行规则段、当前日期段。

上游上下文的拼法就是把 input_from 的键当标题:

upstream_block = ""
if upstream_summaries:
    sections = []
    for key, summary in upstream_summaries.items():
        sections.append(f"### {key}\n{summary}")
    upstream_block = (
        "## Upstream Context (from previous agents)\n\n"
        + "\n\n".join(sections)
    )

注意替换目标是 agent_spec.system_prompt 里的 {upstream_context} 字面量。哪个角色的 system_prompt 里没写这个占位符,它就永远拿不到上游产出——即使 YAML 里给它配了 input_frominvestment_committeerisk_officerportfolio_manager 都在提示词中间显式留了这一行,bull_advocatebear_advocate 没有,因为它们是第一层。这属于必须手工对齐的两处配置,中间没有校验兜底。

任务提示词的渲染用了一个兜底字典:

class _FallbackDict(dict):
    """Dict that hints LLM to infer missing template variables."""
    def __missing__(self, key: str) -> str:
        return f"(determine the appropriate {key} based on the objective)"

变量缺失不报错,而是把一句英文提示塞进提示词让模型自己猜。好处是不会因为少填一个键就整 run 崩掉,代价是缺失会静默——你只会在最终报告里看到模型对着一个它自己编出来的对象分析了半天。

执行规则段是全局统一写死的,跟具体预设无关:先只规划不调工具,再进执行阶段,最后必须调 write_filereport.md 落盘,落盘后再输出两三句摘要,并要求用任务提示词的语言作答。段里还有一条工具调用次数硬上限,超了会被截断。另有一段”数据引用纪律”是无条件加的,要求输出里每个具体数字都能追溯到本次运行的工具结果、锚定块或上游上下文,明确禁止从训练记忆里搬数字,并且点名这条规则同样适用于没有数据工具的汇总角色。这一段在代码注释里说明了动因:自由文本型 prompt 不会触发锚定块,汇总角色又没有数据工具,两者叠加时模型很乐意凭记忆报数。

工具白名单不是直接生效的。build_swarm_registry() 先按角色请求的工具名裁剪 MCP 服务器配置,再构建完整注册表,最后做一次交集过滤。YAML 里写了但环境里没提供的工具会被丢掉并打一条面向运维的 warning,而不是让 worker 直接失败。另外,agent_config 是启动时从磁盘或环境变量解析的,调用方无法通过 prompt 注入 MCP 服务器地址——SwarmTool.execute() 里那段注释专门标了这一点。

跑完之后 run.final_report 的取法值得单说:从最后一层里找第一个有产出的任务,拿它的 summary,然后 break。最后一层要是并行的多个任务,其余几份产出不会进 final_report,得去 tasks 数组里翻。_format_result() 返回的 JSON 里有 statuswait_budget_exhaustedrun_idpresetauto_variablesfinal_reporterrortaskstoken_usage(含 total_input_tokenstotal_output_tokens)。

auto_variables 这一格建议你每次都看。它就是路由层替你猜出来的那套变量,猜错了整支队伍都在分析别的东西,而这是你唯一能当场发现的地方。

四、边界与代价:这个设计明确不管什么

编制是静态的,运行期不能改。 build_run_from_preset() 一次性把 agents 和 tasks 构造完,之后没有增删角色或改依赖的入口。想让”发现某个信号后临时加一个专门角色”这种动态编队发生,这套结构里做不到——你只能提前把角色写进 YAML,或者跑完再起一个新 run。

没有断点续跑。 CLI 暴露的子命令是 /swarm(列预设)、/swarm run/swarm inspect/swarm list/swarm show/swarm cancel,没有 resume。一个任务失败,下游被标 blocked,整个 run 判 failed,成功的那几层产出还在磁盘上但接不回去。层间硬同步也意味着最慢的那个角色决定这一层的时长。

路由是正则,不是理解。 关键词表靠人手维护,权重靠人手调。加了新预设不同步改表,它就永远不会被自动选中;关键词覆盖有重叠时,谁分高谁赢,而分数只跟命中词个数和权重有关,跟语义无关。

它不碰你的账户。 我把 30 份编制里出现过的工具名全部统计了一遍:bashread_filewrite_file 三个每个角色都有,load_skill 几乎都有,其余是 read_urlfactor_analysisget_market_databacktestoptions_pricingget_financial_statementsweb_searchfinancial_rigorget_options_chainedit_filepatternreport_audit没有任何一个下单或券商连接工具出现在编制的白名单里。 这一层产出的是文件,不是委托单。

但这条边界只覆盖 swarm 这一层。仓库 agent/src/trading/connectors/ 下有 12 个券商连接器子目录(README 也自述 12 brokers),是另一条独立的路径。你要是自己把两边接起来,代价必须提前算清楚:交易凭据一旦进入本地配置或环境变量,它的暴露面就等于整个 Agent 进程的暴露面,包括每个能跑 bash 的 worker;下错的单在市场上是不可撤销的,重试和幂等这套软件工程手段在成交这件事上不成立;程序化交易本身的报备与合规义务因司法辖区而异,能不能这么用以你所在司法辖区的监管要求与券商协议为准。

编制里的输出字段不是给你的建议。 这些 YAML 的 system_prompt 里确实要求模型产出目标价、仓位区间、止损位这类字段——那是配置对模型输出格式的约束,是一套研究流程的形状,不是任何人对任何读者的投资意见。把模型按格式填出来的数字当成建议,是使用者的误用,跟这套工程实现的质量无关。

因子那部分不是项目自研的。 仓库根目录 NOTICE 写得很清楚:Microsoft Qlib 的特征定义按 Apache 2.0 打包进来(见 agent/src/factors/zoo/qlib158/);另有几组公式来自公开论文与券商研报,仓库把数学公式当作事实内容重新实现,源文献的文字、表格、图不在仓库内;各因子库子目录下另有各自的 LICENSE.md。所以 factor_analysis 这个工具背后是一批有明确上游来源的公开公式的工程化重实现,仅此而已。本文不提供法律意见,能不能商用以许可证原文为准。

五、上手与避坑清单

先 inspect 再 run。 /swarm inspect <preset> 走的是 inspect_preset(),纯干跑,不起 worker 不调模型。它会查重复 id、agent_id 指向不存在的角色、input_from 指向不存在的任务、DAG 成环,还会把拓扑分层结果打出来。为什么会踩:YAML 手改之后一个 id 打错,直接 run 的话你要等到那一层跑到才发现,前面几层的 token 已经花掉了。

input_from 时同步改下游 system_prompt input_from 的键是下游提示词里的段落标题,改了键名而下游提示词里还在引用旧名字,模型会去找一段不存在的内容。更隐蔽的是反过来:下游 system_prompt 里忘了写 {upstream_context}input_from 配得再全也白配,替换根本不发生,而且没有任何报错。为什么会踩:这两处对齐关系在文件里隔了上百行,改一处很容易忘另一处。

inspect_preset() 的变量报告只是 warning。 未声明的变量、声明了没用的变量,都进 warnings 不进 errors,valid 仍然是 true。为什么会踩:你看到”valid”就放心了,实际上模板里那个变量运行时会走 _FallbackDict 的兜底路径,被替换成一句”让模型自己判断”的英文提示,静默生效。避法是把 warnings 也当门禁读一遍。

别指望关键词路由帮你选对队。 续写型 prompt 会被显式拒绝路由(这是好事,照着错误提示补 preset_name 就行),但更常见的情况是词命中错了队伍。为什么会踩:一句 prompt 里同时出现”风险”和”因子”,两支队伍都得分,谁赢取决于你恰好用了几个词。避法是在明确知道要哪支队时直接传 preset_name——工具描述里也是这么建议的,尤其对续写请求。

跑完第一件事是核 auto_variables 为什么会踩:市场标签抠不到时默认 A-shares,风险偏好抠不到时默认 moderate,少数预设的分析对象在工具层就是硬编码的。这些默认值不会警告你,只会安静地生效,最后你拿到一份认真但跑题的报告。

自建预设要点名调用。 放进 ~/.vibe-trading/swarm/presets/ 就能被 list_presets() 列出来(source 字段标 user),同名会覆盖内置的那份,但它进不了关键词路由表。为什么会踩:你验证过它能被列出、能被 inspect,就以为自然语言也能唤起它,结果每次都跑到别的队伍上。避法是显式传 preset_name

等待预算耗尽不等于失败。 SwarmTool 的等待循环走完 SWARM_TIMEOUT 之后不取消 run,而是把当前状态连同 wait_budget_exhausted: true 返回;后台守护线程还在跑。代码注释说明了改成这样的原因:早先在这里直接取消,把已经花掉的模型开销全扔了。为什么会踩:看到超时就以为白跑了,其实拿着 run_id/swarm show 还能捞到完整结果。

角色的 timeout_secondsmax_iterations 是每角色独立的。 这两个字段在 YAML 里逐角色写,build_run_from_preset() 读不到时才用缺省。为什么会踩:你在环境变量里调了全局默认,却发现没生效——因为 YAML 里那一格写了显式值,优先级更高。

六、什么时候单 Agent 更划算

把上面这些串起来,判断标准其实挺硬:只有当”多个视角必须彼此独立地形成、然后被第三方汇总”这件事本身有价值时,这套编制才值得启动。

investment_committee 的形状说明了这个价值在哪:多头和空头是两个并行任务,互相看不见对方的产出,各自跑完才由风控角色同时拿到两份。这种隔离用单 Agent 做不出来——同一个上下文里让模型先写多头再写空头,第二段必然被第一段带着走。risk_committee 同理,回撤、尾部、市场状态三条线独立成文再汇总,避免的是同一条推理链上的路径依赖。

反过来,下面几种情况单 Agent 明显更划算。任务本来就是线性的,A 完了才能做 B、B 完了才能做 C,那么多智能体只是把一条链拆到多个进程里跑,还额外承担了上下文在 summary 里被压缩失真的损失。任务只需要一个视角、只是步骤多,那用一个 Agent 多跑几轮循环即可。你要频繁改中间产出、边看边调方向的探索型工作也不适合——静态 DAG 没有中途插手的口子,一旦起跑就只能等它跑完或取消。以及任何”跑一次就要拿最终结论”的场景:这套结构没有断点续跑,中间任何一环失败就是整 run 判负。

三件事可以当自检清单:这个问题必须有几个互不通气的独立视角吗?中间产物用一段 summary 传下去够不够,会不会丢掉下游真正需要的细节?失败了从头再来的成本你接受吗?三个都是”是”,再去 agent/src/swarm/presets/ 里挑一份接近的编制照着改。

接下来该读哪个文件,取决于你卡在哪一层:想改分工读 investment_committee.yamlrisk_committee.yaml(一个三层、一个两层,形状差异最大);觉得选错队伍读 agent/src/tools/swarm_tool.py_PRESET_KEYWORDS_build_variables;觉得某个角色跑偏读 agent/src/swarm/worker.pybuild_worker_prompt(),那里能看到模型实际收到的完整提示词是怎么拼出来的。至于这套东西能不能接到真实交易上,那不是本文能回答的问题,以你所在司法辖区的监管要求与券商协议为准。

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 开源 Vibe-Trading 的 Alpha Zoo 因子库怎么调用开源项目 Vibe-Trading 渠道层:接进聊天软件的代价与新问题

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