Vibe-Trading 的三层配置:结构 schema、环境变量 schema、路径与限额各管什么
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
配置分层真正省下的时间,不在写配置的时候,而在出问题的时候:你能不能在三十秒内说出“这个症状该去看哪个文件”。 Vibe-Trading 的 agent/src/config/ 目录只有八个 Python 文件,但它把三类性质完全不同的配置拆得很干净——什么能被调用(结构)、跑在什么环境里(环境变量)、状态落在哪、单次结果切多长(路径与限额)。这三类配置的失效方式不一样,所以排查入口也不该是同一个。
先把名字说清楚:这里的 Vibe-Trading 是 HKUDS 放在 GitHub 上的那个开源交易 Agent 仓库,是一个专有项目名,跟中文语境里「凭感觉交易」那类泛指没有关系。本文只讨论这套配置代码怎么组织、每一层挡住哪些错误,不涉及任何标的、策略或投资判断;历史表现不代表未来,本文只讨论工程实现。
一、先看这块要解决什么问题
一个能连外部 MCP 服务器、能跑多智能体编制、还能接券商通道的 Agent,配置压力来自三个互不相干的方向。
能力面决定哪些外部 MCP 服务器可以接进来、每个服务器允许调用哪些工具、走什么传输方式。这类配置写错的后果最重——多开一个工具就是多一份可执行权限,所以它必须在启动时被结构化校验,不能等到运行时才发现。运行环境是模型供应商、数据源凭据、超时、开关,数量大、变动频繁、大多靠环境变量传入,痛点不是“写错组合”而是“根本不知道有哪些变量、默认值是多少”。落地位置是会话记录、运行产物、上传文件、OAuth 令牌缓存写到哪个目录,外加一个容易被忽略的限额:单次工具结果交给模型之前该截到多长。第三类平时没人看,一旦出问题(升级后历史不见了、模型把半截结果当成完整答案)极难从现象反推原因。
Vibe-Trading 让这三类各占各的文件,互不越界。下面这张表是全貌,位置都是仓库里的真实路径:
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 结构化配置模型 | MCP 服务器条目与渠道全局字段的类型、合法组合、活券商红线 | agent/src/config/schema.py | 新接一个 MCP 服务器,启动直接报配置错 |
| 配置装配 | 读盘、格式分派、运行期覆盖合并、会话级越权字段剥离 | agent/src/config/loader.py | 改了配置没生效;swarm 用的不是你以为的那份 |
| 环境变量表 | 全部环境变量的类型、默认值、别名,按功能分成八组 | agent/src/config/env_schema.py | 想知道某个开关叫什么名字、默认是开还是关 |
| 单例访问器 | 缓存 EnvConfig 实例,改完环境变量需要显式重置 | agent/src/config/accessor.py | 在设置界面改完变量,行为却还是旧的 |
| 路径解析 | 运行根目录、配置文件候选顺序、各类状态子目录 | agent/src/config/paths.py | 想把状态挪到别的盘;找不到会话文件在哪 |
| 工具结果上限 | 单次工具结果交给模型前的截断,并写明截了多少 | agent/src/config/limits.py | 模型把被截断的结果当成完整结果在推理 |
| 存量数据迁移 | 把旧的代码相对目录里的状态搬进运行根 | agent/src/config/migrate.py | 升级或重装之后,历史会话去哪了 |
| 实盘状态布局 | live 子树:OAuth 缓存、授权文件、停机哨兵、审计流水 | agent/src/live/paths.py | 排查实盘通道的凭据与审计文件位置 |
注意最后一行:live/paths.py 不在 config 目录里,但它的根是从 src.config.paths.get_runtime_root 取的。这是分层做对的一个信号——下游模块自己决定子目录长什么样,但不自己造根。
二、结构层:把不该出现的组合挡在启动之前
schema.py 是一组 Pydantic 模型。基类 ConfigBase 设了三件事:alias_generator=_to_camel(内部 snake_case 字段对外接受 camelCase 键)、populate_by_name=True(两种写法都收)、extra="forbid"(多写一个键直接报错)。第三条是有代价的选择,后面会讲到它在覆盖模型上被特意放开了。
真正有意思的是校验逻辑分了两个层次。
单条服务器自己能判的,放在 MCPServerConfig 上。 resolved_transport() 先解析出实际传输方式:写了 type 就用它;没写但有 command/args/env 就当 stdio;只写了 url 却不写 type,直接抛错要求显式声明 sse 或 streamableHttp。然后 validate_transport_config 按传输方式做互斥检查——stdio 不接受 url/headers,也不接受 auth(OAuth 是 HTTP 专属);HTTP 传输不接受 command/args/env;配了 auth 就不能再手写静态 headers(授权头归 OAuth 提供方所有),并且 URL 必须是 https:// 开头,理由写在注释里:刷新令牌不能走明文。
这些都是“一条配置自己就能判”的错误,全部在模型构造时就炸掉,不会拖到第一次调用工具的时候。
需要知道键名才能判的,放在 AgentConfig 上。 validate_live_broker_servers 这个校验器解决的是另一类问题:某些 MCP 服务器是能真下单的实盘券商通道,它们的 enabled_tools 不允许写成 ["*"]——通配符会把所有写类和未知类工具重新放进来。而 MCPServerConfig 自己看不到自己在字典里的键,所以这个检查只能写在上一层。
更值得学的是它怎么判“这是不是一个实盘券商”。最朴素的做法是看键名,LIVE_BROKER_SERVER_KEYS 里确实列了两个键。但代码里明确写了这条路可以被绕过:把一个券商的 URL 挂在任意别的键下面(注释里举的例子是 rh),就同时躲开了通配符拒绝和分类闸门。所以还有一条按 URL 主机判定的路径:live_broker_key_for_url 用 urlsplit 取 hostname,只接受“等于后缀”或“是该后缀的子域”,注释里点名 robinhood.com.evil.test 这种拼接式伪造不算命中。两条路合成 is_live_broker_entry,键名路径作为 stdio 这类没有 URL 的通道的兜底。
例外也做得很克制。_allows_readonly_wildcard_probe 允许一个特定券商在首次发现工具时使用通配符,前提是它的 OAuth scope 集合包含必需的只读 scope、与写 scope 不相交、且不超出允许的额外 scope 集合。三个条件全部满足才放行,任何一条不满足就失败关闭。
这对你意味着什么:如果你自己的 Agent 也有“某几类外部连接比其它连接危险得多”的情况,这个分层是可以照抄的——能局部判的放局部,需要上下文才能判的放上层,并且识别“危险”时不要只信一个可被改写的字段。
三、装配层:这份配置最后长什么样
loader.py 管的是从磁盘到最终对象的整条路。它有四个决策点值得单独说。
读盘失败不崩,降级到空配置。 load_agent_config 里,文件不存在返回 AgentConfig();读取或校验抛了 OSError/ValueError/ValidationError,记一条 warning 再返回 AgentConfig()。异常类型名写进 warning,详细内容进 debug。这个选择的好处和代价在第五节展开。
格式分派很窄。 _read_config_file 只认 .json 和 .yaml/.yml,YAML 还要求 PyYAML 已安装,否则明确报错而不是静默跳过。解出来不是对象也报错。
运行期覆盖走一个单独的宽松模型。 AgentConfigOverride 把 extra 从 forbid 改成了 ignore,代码注释里写着这是 load-bearing 的:会话服务会把整个 session.config 字典透传进来,里面带着别的无关键;如果这里保持 forbid,任何这样的载荷都会抛 ValidationError,导致整份覆盖被丢掉——包括其中合法的 mcpServers。注释还留了对应的回归测试名。这是一处典型的“宽松是被 bug 教出来的”,值得记住:严格校验用在人写的文件上,程序透传的载荷得留活口。
合并不是简单的深合并。 _merge_agent_config_dicts 对 mcp_servers 做特殊处理:同名服务器逐条合并,其中 _override_switches_transport 会先判断这次覆盖是否切换了传输族(显式 type 变了,或者出现了 command/args/env 这类 stdio 意图)。如果切换了,就先把该条重置成 _default_mcp_server_payload 给出的传输中性载荷,再叠覆盖——只保留 tool_timeout、init_timeout、enabled_tools 这些非传输字段。不这么做的话,一个原本是 HTTP 的条目改成 stdio 后会残留 url,然后撞上第二节那条互斥校验。
还有一层是信任边界。 sanitize_session_overrides 会把 mcpServers / mcp_servers 从 API 调用方提供的会话覆盖里剥掉,理由写得很直白:这些键定义子进程的 command/args/env,等于执行级能力,必须来自运维方掌控的磁盘配置,而不是半可信的 API 调用方。想放开需要显式设 ALLOW_SESSION_MCP_SERVERS。剥离时会打一条 warning 列出被剥掉的键——这一条日志就是你排查“我明明传了 MCP 配置却没生效”的答案。
多智能体那一侧另有一条解析顺序,写在 _resolve_swarm_agent_config_path 的文档字符串里:环境变量 VIBE_TRADING_SWARM_AGENT_CONFIG 优先(即使文件还不存在也返回,让调用方自己降级而不是启动就崩);其次是运行根下的 swarm-agent.json,让 swarm 能用一套和主 Agent 不同的 MCP 允许列表;再退回主 Agent 的 agent.json/agent.yaml/agent.yml;全都没有就返回 None,保持“只用本地工具”的旧行为。load_swarm_agent_config 在此之上保证永远返回一个 AgentConfig 而不是 None,调用方不用写解包分支。
四、环境层:一张表,八个分组
env_schema.py 的模块文档字符串把动机写得很清楚:它要替换掉散落在多个文件里的大量 os.getenv 调用(文档里给出的是“约 207 处、66 个文件”这个量级)。落地方式是八个 Pydantic 子模型,在 EnvConfig 里组合起来:LLMConfig、DataConfig、APIConfig、SwarmConfig、AgentTuningConfig、PathConfig、OcrConfig、MemoryConfig。每个子模型的文档字符串还写了“这些变量是被哪些文件消费的”,这一点在排查时比字段名本身更有用。
机制上有几处细节:
- 共同基类
_EnvBase用一个mode="before"的校验器_load_from_env,按字段的 alias(也就是大写下划线的环境变量名)去os.environ里取值。构造函数显式传的参数优先,环境变量只填空缺。 - 数值字段做了安全强转:
int/float解析失败时直接跳过,让 Pydantic 落回字段默认值,而不是抛ValidationError。 - 布尔值统一走
EnvBool,背后是_parse_env_bool,真值集合是1/true/yes/on,假值集合含空串,大小写不敏感。 MemoryConfig有个更聪明的写法:一个VT_MEMORY预设变量取off/on/full三档,_PRESET_FLAGS把每档映射成七个开关的基线;单个VT_MEMORY_*变量如果被显式设置了,就不被预设覆盖。这是“给普通用户一个旋钮,给专家七个旋钮”的标准做法。EnvConfig上有个mode="after"的校验器_resolve_api_key_alias,把VIBE_TRADING_API_KEY补进api_auth_key,注释解释了原因:CLI 历史上先读前者,API 服务端只读API_AUTH_KEY,这个校验器负责抹平语义差。
配套的 accessor.py 提供 get_env_config() 单例,用模块级 threading.Lock 加双重检查锁定,因为 swarm worker 和 Agent 主循环都在线程里跑、可能并发调用。它还提供 reset_env_config()——这是最容易漏掉的一环:环境变量在运行时被改了(比如设置接口写完 agent/.env 又 patch 了 os.environ),不调用它的话,下一次 get_env_config() 拿到的还是旧实例。仓库里 agent/src/api/settings_routes.py、agent/src/preflight.py、agent/src/providers/llm.py 都调了这个函数,这不是可选礼节。
关于“AI 帮你改配置文件”这件事本身的坑,站内另有一篇专门写了让 AI 动配置文件的边界与回滚;至于外部 API 用量该怎么规划、上下文预算怎么分配,那是API 配额规划和Agent 上下文预算两篇的题目。本篇不重复那些方法论,只沿着 Vibe-Trading 这一个仓库讲它的配置代码是怎么切的。
五、路径与限额:两个没人看但最难查的东西
paths.py 短得可以一口气读完,但它是整棵状态树的根。
get_runtime_root() 的解析顺序是:传了显式配置文件路径就用它的父目录;否则读 VIBE_TRADING_HOME 环境变量;否则回落到 ~/.vibe-trading。中间夹了一条防御——如果环境变量值以 // 或 \\ 开头(UNC 路径),直接抛错而不是当成合法目录。
在这个根之上是一组一行一个的取目录函数:get_sessions_dir、get_runs_dir、get_swarm_runs_dir、get_uploads_dir、get_workspace_path。它们只拼路径,绝大多数不建目录(get_workspace_path 和 get_data_dir 例外,会 mkdir(parents=True, exist_ok=True))。
配置文件的定位分两步:get_config_candidates 按 agent.json、agent.yaml、agent.yml 的固定顺序给出候选;get_config_path 返回第一个真实存在的候选,一个都不存在就返回候选列表的第一项(也就是推荐的默认 JSON 路径)。这个“第一个存在的赢”的规则,是排查“我改的配置没生效”时第一个该验证的假设。
limits.py 是另一个极端:整个模块对外只暴露一个常量 TOOL_RESULT_LIMIT 和一个函数 truncate_tool_result(另有一段私有的提示文案模板),而且模块文档字符串专门解释了它为什么要独立存在——这个上限原先住在 Agent 主循环里,又被当成裸字面量复制进了 swarm worker,两边会漂移。现在它是一个自己不 import 任何东西的叶子模块,主循环、swarm worker 和各个工具都能读,谁也不因此依赖谁。
函数本身的关键设计是:截断必须说出来。文档字符串写得很直接——静默截断和完整答案在模型看来没有区别,于是模型会把一个前缀当成全部来汇报。所以截断时会拼上一段提示,写明交付了多少字符、总共多少字符,并明确要求模型不要当成完整结果,让它缩小请求范围或者用工具自带的翻页参数再取。连“限额被设成病态小值”这种边角情况都处理了:正文放不下时,宁可只交付那段提示,也不静默截成空。
这一节的两个文件加起来不到两百行,但它们对应的两类故障——“状态跑到别的目录去了”和“模型基于半截数据在推理”——恰好是最难从现象反推原因的两类。
六、边界与代价:这套设计放弃了什么
分层不是免费的,Vibe-Trading 这套配置有几个明确的取舍,用之前得知道。
三层不共享一个入口。 结构配置在 ~/.vibe-trading/agent.json(或 yaml),环境变量在进程环境或 agent/.env,路径由 VIBE_TRADING_HOME 决定。改了其中一处不等于另外两处跟着变。你在设置界面改了环境变量,agent.json 里的 MCP 允许列表纹丝不动;反过来也一样。
环境变量表不是全集。 env_schema.py 自称是环境变量默认值的单一事实来源,但模型供应商的密钥与基址并不在里面——它们由 agent/src/providers/capabilities.py 里的 ProviderCapabilities 逐个供应商登记(每条记录带着自己的 API key 环境变量名和 base url 环境变量名)。所以“找不到某个变量”的时候,除了 env_schema.py 还得看这一处。agent/.env.example 开头那行注释也只说默认值定义在 env_schema.py,文件里实际列出的供应商密钥要比 schema 里多。
静默降级是双刃的。 配置文件解析失败只记 warning 然后用空配置继续跑,好处是一份坏配置不会让整个进程起不来;代价是你可能以为配置生效了,实际上 Agent 正在裸奔——没有任何外部 MCP 服务器。数值型环境变量解析失败会静默落回默认值,也是同一类取舍:把 SWARM_MAX_WORKERS 写成一个非数字,不会有人拦你,只会安静地用默认值。这套设计假设你会看日志。 不看日志的话,它省下的启动失败会变成后面更贵的排查。
覆盖模型对拼错的键完全沉默。 AgentConfigOverride 的 extra="ignore" 是为透传场景服务的,副作用是运行期覆盖里写错的键不会有任何反馈。人手写的那份磁盘配置有 extra="forbid" 兜着,运行期覆盖没有。
它明确不管的事:不校验凭据本身对不对(密钥错了要等到调用远端才知道);不管远端 MCP 服务器是否可达;不管你在自己所在司法辖区做程序化交易需要满足什么监管义务。
关于最后一条,讲实盘那部分必须把话说透:这个仓库支持接入券商通道,OAuth 令牌缓存目录是配置里的一个字段,实盘状态(授权文件、停机哨兵、审计流水)落在运行根的 live 子树下。这意味着——凭据以文件形式存在你的机器上,任何能读到那个目录的进程或人就有了对应的暴露面;下单动作一旦发出去就不可撤销,不存在“重跑一遍”;程序化交易的申报与合规义务因司法辖区、因券商协议而异。能不能这么用、以什么身份用,以你所在司法辖区的监管要求与券商协议为准,不要以任何一篇技术文章(包括这一篇)为准。仓库里那些“要求模型输出目标价、仓位区间、止损位”之类的字段约束,是配置对模型输出格式的规定,不是本文对任何人的建议。
顺带一句关于因子那块:仓库根目录的 NOTICE 写明了几个因子库各自的上游来源与许可——其中一组来自 Microsoft Qlib 的特征定义,走 Apache 2.0;另外几组是公开论文与研报里的数学公式,仓库把它们当作数学事实重新实现,各因子库子目录下另有 LICENSE.md。它们不是这个项目自研的,也不是什么现成的方法,只是公开公式的工程化重实现。能不能商用以许可证原文为准,本文不提供法律意见。
七、上手与避坑清单
每条都写了为什么会踩,因为只说“注意”没用。
1. 配置改了没生效,先确认加载的是哪一份。
为什么会踩:get_config_path 返回的是 agent.json、agent.yaml、agent.yml 里第一个存在的。你新建了一份 yaml,但目录里还躺着一份旧的 json,yaml 永远不会被读到。
怎么避:先确认运行根是哪个目录(有没有设 VIBE_TRADING_HOME),再确认目录里只有一份配置文件。
2. 写了 url 却不写 type,启动直接失败。
为什么会踩:resolved_transport() 对只有 URL 的条目不做猜测,明确抛错要求你在 sse 和 streamableHttp 之间选一个。这不是 bug,是有意的。
怎么避:HTTP 类的 MCP 服务器条目一律显式写 type。
3. 混写传输字段,或者配了 OAuth 又手写 headers。
为什么会踩:从别的项目复制一段配置过来,stdio 条目带着 headers,或者 HTTP 条目留着 command,validate_transport_config 会把这两类都拒掉。OAuth 与静态头互斥则是因为授权头归 OAuth 提供方所有,两个来源必然打架;URL 还必须是 https://,刷新令牌不能走明文。
怎么避:改传输方式时把上一套字段删干净;用 auth 就别碰 headers。运行期覆盖有 _override_switches_transport 帮你重置,磁盘配置没有这个待遇。
4. 以为把券商条目改个键名就能绕过通配符限制。
为什么会踩:确实有人会这么想——把 enabled_tools: ["*"] 的报错理解成“键名撞了”,于是改成别的名字。但判定同时看 URL 主机,改名没用,而且改名本身就是在削弱一道刻意设置的防线。
怎么避:给实盘类条目写明确的只读工具列表,别写通配符。真需要发现期的通配符,先确认代码里那个只读探针例外的三个条件是不是全都满足。
5. 通过 API 会话传 mcpServers,发现它消失了。
为什么会踩:sanitize_session_overrides 默认会剥掉它,因为这些键能定义子进程命令。
怎么避:把 MCP 定义放到运维方掌控的磁盘配置里;确实需要会话级注入的,看 ALLOW_SESSION_MCP_SERVERS 这个显式开关,并且清楚你放开的是执行级能力。
6. 运行中改了环境变量,行为没变。
为什么会踩:get_env_config() 是带缓存的单例,第一次调用之后就不再读 os.environ。
怎么避:任何在运行期改环境变量的路径,改完必须调 reset_env_config()。
7. 把 VIBE_TRADING_HOME 指向一个网络共享路径,或者数值变量写错却没提示。
为什么会踩:以 // 或 \\ 开头的 UNC 路径会被显式拒绝并抛错;而 _load_from_env 对解析失败的数值是跳过处理,让 Pydantic 用默认值,一声不吭。
怎么避:运行根用本地盘符路径;改完数值型变量别靠“没报错”确认生效,去代码里核对字段默认值,或者从日志与实际行为上验证。
8. 模型基于被截断的工具结果下结论。
为什么会踩:工具结果超过 TOOL_RESULT_LIMIT 会被 truncate_tool_result 截断。它已经拼了明确提示,但如果你自己写的工具绕过了这个函数,就没有提示。
怎么避:自定义工具返回大结果时走同一个截断入口,不要在自己的模块里复制一个裸常量——limits.py 的文档字符串写的就是这个漂移故事。
八、收束
这套配置分层值得记住的不是文件划分本身,而是三条判断:能局部判的校验放局部、需要上下文才能判的放上层;识别高危对象时不要只信一个可被改写的字段;任何截断和降级都必须留下可读的痕迹。这三条跟交易没关系,你写任何一个要接外部工具的 Agent 都用得上。
给你一份自检清单,对着自己的项目过一遍:
- 配置文件的候选顺序是不是写死且可预测的?加载的是哪一份,能一句话说清吗?
- 结构校验是在启动时做的,还是拖到第一次调用才炸?
- 有没有一类连接比其它连接危险得多?识别它靠的是哪个字段,那个字段能被改写吗?
- 运行期覆盖和磁盘配置的信任级别一样吗?越权字段在哪里被剥掉、有没有留日志?
- 环境变量有没有一张表?还是散在几十个
os.getenv里?状态目录的根是一个函数还是若干处硬编码? - 交给模型的内容被截断时,模型知道自己拿到的是残缺的吗?
继续读的顺序建议:agent/src/config/schema.py 看结构与红线,agent/src/config/loader.py 看装配与信任边界,agent/src/config/env_schema.py 当字典查,agent/src/config/paths.py 和 agent/src/config/limits.py 各花五分钟。再往外一层,agent/src/config/migrate.py 讲旧状态目录怎么原子地搬进新运行根,agent/src/live/paths.py 讲实盘状态在这棵树上的位置——后者涉及凭据与审计文件,读的时候顺便把权限位和备份策略想清楚。
再重复一次这篇文章的边界:以上全部是对一个开源仓库配置代码的技术梳理,不构成任何投资建议,也不构成对能否合规使用其实盘功能的判断;后者以你所在司法辖区的监管要求与券商协议为准。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 开源仓库的 Web 层:一条时间线让长任务可见 和 开源交易 Agent Vibe-Trading 的四层安全边界拆解。