Vibe-Trading 开源交易 Agent 的下单闸门:分类、拦截与审计三件套
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
这套设计里最值得抄走的一条不是”下单前要检查”,而是:判断一个工具危不危险这件事,绝不能交给提供工具的那一方,也绝不能交给模型。 这里说的 Vibe-Trading 是 HKUDS 放在 GitHub 上的那个开源交易 Agent 项目,不是”凭感觉做交易”这种泛指。它把这个判断做成了一条有优先级的分类阶梯,把执行拦截做成一个函数级的闸门,又把记账做成一条与两者都解耦的只追加流水。三件事拆得很干净,各自能单独读懂,也各自能单独失效而不牵连其他两个。下面按你实际会碰到的顺序走一遍。
站内已有三篇相邻的文章:AI 工具支出审计讲的是账单口径,Agent 可观测日志讲的是排障用的运行时日志,AI 代码安全审计讲的是代码本身的漏洞。这篇不重复它们,它讲的是第四种东西——动作级的合规流水:不为了排障,不为了省钱,而是为了在事后能一条一条答出”这个 Agent 拿真钱做过什么、凭谁的授权做的”。
一、这道门要挡的是什么
Vibe-Trading 的实盘通道有两种接法。一种是券商跑一个 MCP 服务端,工具名和参数 schema 在运行时才发现;另一种是直接调券商的 Python SDK。前者的问题很直白:工具清单来自一台你不控制的机器,工具名是对方起的,“这个工具是只读的吗”这个问题的答案也是对方给的。
agent/src/live/classification.py 的模块注释把这层不信任写死了,它引的是 mcp.types.ToolAnnotations 自己文档里的一句话:客户端永远不应该基于来自不可信服务端的注解来做工具使用决策。所以这里不做朴素的名字匹配——不是”名字里带 place_order 就拦下”,因为名字是对方定义的,换个词就绕过去了;也不是”对方说 readOnly 就当只读”,因为对方可以撒谎。
那另一头呢,让模型自己判断行不行?更不行。模型的判断是概率性的,而这道门的语义要求是确定的:只要判断不出来,就必须按最危险处理。
二、第一步:三级阶梯,默认拒绝
classify_tool() 把每个发现到的工具解析成 READ / WRITE / UNKNOWN 三选一,顺序是固定的:
# Tier 2 wins whenever the map names the tool.
if curated is not None:
pinned = curated.get(name)
if pinned is not None:
return pinned
# Tier 1: explicit annotation. Absent (None) hint falls through.
if annotations is not None:
hint = getattr(annotations, "readOnlyHint", None)
if hint is True:
return ToolClass.READ
if hint is False:
return ToolClass.WRITE
# Tier 3: default-deny.
return ToolClass.UNKNOWN
读代码时注意三个细节。
第一,人工维护的 map 写在最前面,这是故意的。模块注释里那句话说得很清楚:一个在自己的 place_order 上标了 readOnlyHint=True 的服务端,不能把一个已经被人工钉死为 WRITE 的工具降级。注解只能”多抓”没被 map 覆盖到的写操作,不能”赦免”任何一个。
第二,readOnlyHint 是 None 时会落穿到下一层,而不是当成只读。缺省不等于安全,这是一个很容易写反的地方。
第三,兜底返回的是 UNKNOWN 而不是抛错。UNKNOWN 在下游被完全当作 WRITE 处理——这是”默认拒绝”的具体形态:一个没人认识的新工具,会被闸门包起来,而不是裸奔。
人工 map 长什么样?以 agent/src/trading/connectors/tiger/classification.py 为例,TIGER_TOOL_CLASS 就是一张字典,get_positions / get_orders 这类归 READ,place_order / cancel_order / modify_order 归 WRITE。agent/src/trading/connectors/ 下有 12 个券商连接器子目录,各自有一份这样的表。
分类的结果在 agent/src/live/registry.py 的 wrap_live_broker_tools() 里兑现:READ 的工具原样返回、只是打上 is_readonly = True;WRITE 和 UNKNOWN 的工具会被换成 LiveOrderGuardTool 重新包一层。这里还有一个我很喜欢的细节——如果急停已经触发,这批下单工具根本不会出现在给模型的工具清单里,注释里叫它 belt-and-suspenders 的注册期那一半。
三、第二步:闸门本身,全程 fail-closed
闸门有两份实现,因为接法有两种。MCP 那条路是 agent/src/live/order_guard.py 里的 LiveOrderGuardTool,直连 SDK 那条路是 agent/src/live/sdk_order_gate.py 里的 execute_live_order()。后者的模块注释直说了,它要的是和前者”一模一样的 ceremony”。
以 SDK 版为例,顺序是硬编码的:
mandate = load_mandate(broker)
if mandate is None or mandate.schema_version != MANDATE_SCHEMA_VERSION:
return _deny(broker, session_id, "no valid mandate on file", ["mandate"], mandate, intent=None)
if _is_expired(mandate):
return _deny(broker, session_id, "mandate expired — re-authorize", ["mandate", "expiry"], mandate, intent=None, reauth=True)
if halt_flag_set(broker):
return _deny(broker, session_id, "live trading halted", ["mandate", "expiry", "halt_flag"], mandate, intent=None)
授权文件、有效期、急停,三关都在任何券商调用之前,任何一关不过就直接返回拒绝。之后才是把数量折算成金额、读持仓和余额、跑 check_mandate。
几个工程上值得单独说的点:
急停不是内存里的一个 bool。 agent/src/live/halt.py 的 halt_flag_set() 是纯文件系统检查——看 <runtime_root>/live/HALT 这个哨兵文件在不在。模块注释解释了为什么:这样一来,即使 Agent 主循环卡死、模型在打转、事件总线挂了,急停照样生效。用户或者一个外部看门狗可以直接 touch 这个文件。文件内容坏了也仍然算触发,因为”存在”本身就是急停,里面的 JSON 只是记录是谁、什么时候、为什么触发的。全局哨兵优先,另有按券商的哨兵可以只停一家。
数量必须先折算成金额。 如果一张单子只给了数量不给金额,那所有以金额为单位的上限就都失效了。所以闸门会先取价(先问券商自己的 quote,再退回项目的数据加载器),然后在”显式金额”和”数量×价格”之间取大的那个去做检查。取不到价怎么办?拒绝,而不是放行。注释里管这叫 fail-closed。
这里还有个我觉得写得挺诚实的地方:_implied_notional() 说明,对于数量不是”一份”的连接器(注释举的例子是 MT5 的手,1 手 EURUSD 等于 100000 EUR),连接器要自己暴露 quantity_notional_usd() 这个钩子;钩子在就是权威的,故意不留”数量×报价”的回退路径——因为那个乘积会把按手报的单子低估掉一个合约乘数,等于悄悄把所有金额上限全废掉。宁可整条路走不通,也不要一条会静默失灵的路。
币种问题被写成了注释而不是被藏起来。 _normalize_notional() 的文档字符串直说:券商报价是本币(港股是港币、A 股是离岸人民币),而上限是美元口径,把本币数字当美元用会高估美元敞口,所以上限会偏保守地咬住——只会多拒,不会少拒。FX 归一被标为后续工作。把一个已知的不精确写清楚方向,比装作没有强得多。
并发提交有锁。 agent/src/live/daily_count.py 的 daily_order_lock() 是一把非阻塞的跨进程建议锁(POSIX 走 fcntl,Windows 走 msvcrt)。抢不到不会排队,直接抛 DailyOrderLockUnavailable,闸门把它变成一次拒绝。当日计数只在”闸门放行 + 券商返回非错误信封”时才 +1;转发失败没下成单,就不消耗额度。
四、第三步:为什么审计必须自己一条线
agent/src/live/audit.py 的开头一句话就把定位说明白了:这条流水与按次运行的研究轨迹是隔离的,因为它是那种必须在运行目录被清掉之后依然活着的记录——“把这个 Agent 拿真钱做过的事全都给我看一遍”的标准答案。
这是本文想讲的核心。审计如果只是业务代码顺手写的一行日志,它就会跟着业务代码一起被重构、被降级、被 --quiet 关掉、被 log rotate 转走。所以这个模块把三件事从业务里剥了出来:
第一,写入路径自己拥有。
record = event.to_record()
# Sink 1: dedicated compliance ledger (always, append-only).
path = audit_ledger_path()
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
line = json.dumps(record, ensure_ascii=False)
with path.open("a", encoding="utf-8") as handle:
handle.write(line + "\n")
一条一行 JSONL,追加模式打开,目录按 0700 建。追加模式的意义在注释里点了:并发写者不会互相截断。另外两个去处——按次运行的 trace(类型标成 live_action,和 tool_call / tool_result 并排)和前端事件回调(事件名 live.action)——都是可选的,没传就静默跳过,而专用账本永远写。业务方少传一个参数,不会导致合规记录消失。
第二,脱敏发生在写之前,而且只发生一次。 to_record() 里整条记录先过 src/tools/redaction.py 的 redact_payload(),然后同一个已脱敏的 dict 才被发往每一个去处。券商的请求和响应里可能带着 OAuth token、账号、个人信息,这些在到达账本、轨迹、SSE 总线之前就已经变成 [redacted]。值得一提的是 redaction.py 里对 account_ref 的特殊处理:账号相关字段是精确匹配的一份清单而不是宽泛的 account 子串匹配,注释解释了原因——宽匹配会顺手把 account_ref 也抹掉,而那个不透明引用恰恰是问责链要留的东西。
第三,问责链是记录的结构,不是附言。 每条记录带 mandate_snapshot_ref 和 consent_record_ref 两个字段,模块注释说它们合在一起让每一笔实盘动作都能回溯到”授权它所依据的那个授权文件的那一次用户点击”。记录类型是一个 Literal 白名单:order_placed、order_cancelled、order_rejected、mandate_committed、breach、halt_tripped、halt_cleared;结果字段同样是白名单:accepted、filled、rejected、error、blocked。闸门的每一个分支——放行、结构性拒绝、需要重新授权的暂停——都写且只写一条。
第四,审计失败不许阻塞决策。 两份闸门的 _audit() 都把整个写入包在 try 里,出错就记一条 warning 返回 None。这条设计有代价,后面会说。
五、三件套速查
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
classify_tool() 三级阶梯 | 把每个远端工具判成 READ / WRITE / UNKNOWN,兜底默认拒绝 | agent/src/live/classification.py | 接新券商、或对方新增了一个工具 |
| 人工维护的读写表 | 按券商钉死每个操作的类别,优先级高于服务端注解 | agent/src/trading/connectors/<broker>/classification.py | 补一个新操作的分类时 |
| 注册期换壳 | READ 原样放行,WRITE/UNKNOWN 换成闸门;急停时干脆不给模型 | agent/src/live/registry.py | 排查”为什么模型看不到下单工具” |
| MCP 闸门 | 包住远端下单工具,跑完六步再决定转发还是拒绝 | agent/src/live/order_guard.py | 券商走 MCP 接入时 |
| 直连 SDK 闸门 | 同一套流程的函数版,包住 place_order 调用 | agent/src/live/sdk_order_gate.py | 券商走 Python SDK 接入时 |
| 急停哨兵 | 纯文件系统的全局/按券商停机开关 | agent/src/live/halt.py | 想立刻全停、或写外部看门狗 |
| 当日计数与提交锁 | UTC 自然日计数,跨进程非阻塞建议锁 | agent/src/live/daily_count.py | 同一个券商跑了两个进程时 |
| 只追加审计流水 | 脱敏后写专用账本,可选再发 trace 和前端 | agent/src/live/audit.py | 复盘、对账、回答”它做过什么” |
| 共享脱敏 | 凭据与个人信息的键名与文本双重擦除 | agent/src/tools/redaction.py | 担心日志里带出 token 时 |
六、边界与代价:这套东西明确不管什么
它不管单子发出去之后。 闸门的语义只到”这一次调用允不允许发生”。下单路径写进账本的结果是 accepted,不是 filled——成交、部分成交、滑点、事后撤单,都不在这道门的职责里。
账本不是防篡改的。 追加模式解决的是并发写不互相截断,它没有做哈希链、没有做外部存证。任何拿到文件系统权限的人都能改它。如果你要的是法律意义上的不可抵赖,这一层不够,需要往外接。
审计写失败会被吞掉。 这是”审计不阻塞决策”的另一面:磁盘满了、权限错了、目录被删了,单子照下,账本里没有。所以”账本里查不到”不等于”没发生过”,这一点必须写进你的监控——盯着那条 live-action audit write failed 的 warning。
当日计数是辅助的,不是权威。 daily_count.py 的注释自己写了:这个计数器是纵深防御,真正的上限由券商执行,任何读失败都按 0 处理。它在计数这一项上是 fail-open 的。
闸门不理解意图。 它检查的是结构化字段——标的、方向、金额、工具类别、当日次数。模型给出的理由再充分也不进入判断,同样,一个字段填得合规但逻辑上很糟的单子,它也拦不住。
顾问层默认关着,而且只看不拦。 MCP 闸门里有一段 _advisory_review(),由环境变量 VIBE_TRADING_ENABLE_ADVISORY 控制,默认关闭;它的返回值只是写进审计记录的 gate_decision 里,不改变放行与否。别把它当第二道闸门。
实盘那部分的风险要如实认。 OAuth 令牌缓存、授权文件、计数器都落在 <runtime_root>/live/ 下面(默认根目录是 ~/.vibe-trading,权限按 0700/0600 建)——这意味着这台机器上任何能读这个目录的进程,都在你的凭据暴露面里。下错的单子在券商侧是既成事实,不会因为你的进程崩了就回滚。程序化交易本身要不要报备、能不能做,各地监管要求与券商协议都不一样,以你所在司法辖区的监管要求与券商协议为准。
跟因子和回测有关的一句。 仓库 agent/src/factors/ 下有 482 个文件,根目录的 NOTICE 写清了它们各自的上游:Microsoft Qlib 的特征定义按 Apache 2.0 引入,另有几组公式来自公开论文与研报(alpha101、gtja191、academic 各自目录下有 LICENSE.md),仓库把这些公式当作不受版权保护的数学事实重新实现,原文的行文、表格与图一律没有复制。它们是公开公式的工程化重实现,不是别的。历史表现不代表未来,本文只讨论工程实现,不涉及任何投资判断;能不能商用以许可证原文为准,本文不提供法律意见。
七、上手与避坑
先读三个文件,别先读引擎。 顺序是 classification.py → sdk_order_gate.py → audit.py。理由:分类那份最短、依赖最少,读完你就知道整套东西的信任模型;SDK 闸门比 MCP 闸门少一层远端调用的噪音,同一套流程看得更清楚;审计最后读,因为前两个都在往它里面写。反过来先啃 enforcement.py 会陷进具体的上限计算,看不见结构。
踩点一:新加的券商操作忘了进人工表。 会怎样:它被判成 UNKNOWN,也就是被当作写操作包进闸门。一个本来只是查持仓的接口,会因为没有有效授权文件而被拒,报出来的信息看着像”授权坏了”,实际是分类漏了。怎么避:加接口和加分类表条目当成同一个改动提交,别分两次。
踩点二:把服务端的 readOnlyHint 当真。 会怎样:如果哪天有人把人工表的优先级挪到注解后面(比如为了”让服务端能自描述”),一个撒谎的注解就能把下单工具降级成普通读工具,整条闸门被绕过而且没有任何报错。怎么避:改这段优先级的时候,把 classification.py 顶部那段注释一起读了再动,那句”注解只能抓、不能赦免”就是这一条的验收标准。
踩点三:只给数量的单子被拒,去调大上限。 会怎样:越调越迷惑,因为拒绝的原因根本不是超限,而是取不到价(消息里写着 fail-closed)。怎么避:先确认券商的 quote 通道通不通、数据加载器有没有配好;对按手报的品种,确认连接器实现了折算钩子,而不是指望回退乘法。
踩点四:把本币当美元这件事当 bug 顺手”修”了。 会怎样:如果你把港币数字除一下”改对”,方向就从保守变成激进了——原本偏紧的上限被放松,而 FX 归一并没有真正做完。怎么避:动这块之前先把 _normalize_notional() 的注释看完,它明确说了现状是 over-deny 而不是 under-deny。
踩点五:容器化之后急停失灵。 会怎样:HALT 是 <runtime_root>/live/ 下的一个文件,容器没挂持久卷、或者宿主机上 touch 的路径跟容器里解析出来的根目录对不上,你以为按了急停,进程那边什么都没发生。怎么避:部署完先做一次演练——在真实运行环境里创建哨兵,再发一单,确认拿到的是拒绝。顺便记住全局哨兵和按券商哨兵是各清各的,清了一个不影响另一个。
踩点六:多进程跑同一个券商。 会怎样:提交锁是非阻塞的,第二个进程不会排队等待,它会直接拿到一次拒绝。怎么避:把这个当成预期行为而不是故障,在上层做退避重试,或者干脆保证一个券商只有一个下单进程。
踩点七:把审计当日志看。 会怎样:你会想给它加采样、加级别、按大小轮转——然后某一天需要复盘的时候,正好缺了那几条。怎么避:把这个文件的生命周期和普通日志分开管,它的清理策略应该由你的留存要求决定,不是由磁盘占用决定。
收尾
这套结构里可以直接搬走的是那条判断链:分类回答”这个动作危不危险”,闸门回答”这一次允不允许”,流水回答”到底发生过什么”。 三个问题分给三个模块,任何一个被改坏,另外两个还能作证。这跟你在别的场景里做高危工具治理是同一件事,可以顺着最小权限设计和人在回路这两条线往下接。
给自己的自检清单,三条就够:
- 你的 Agent 里那些会改变外部世界状态的工具,是谁判定的?如果答案里出现”模型”或”工具提供方”,先把这一层收回来。
- 判定不出来的时候,默认走哪边?如果默认是放行,那你其实没有闸门。
- 你的动作记录,能不能在业务代码被重构一遍之后原样活下来?如果它写在业务函数里,答案通常是不能。
接下来该读哪个文件:想看具体上限怎么算,去 agent/src/live/enforcement.py;想看授权文件的结构和承诺是怎么落盘的,去 agent/src/live/mandate/;想看急停触发之后除了拒绝新单还会做什么,去 agent/src/live/halt.py 末尾那段预防性动作钩子,以及它指向的 agent/src/live/runtime/。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 HKUDS 开源项目 Vibe-Trading 的 mandate:Agent 先提授权再动手 和 开源交易 Agent 项目 Vibe-Trading 的常驻运行时拆解。