HKUDS 开源项目 Vibe-Trading 的 mandate:Agent 先提授权再动手

2026-08-05

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

这里说的 Vibe-Trading,是 HKUDS 放出的那个开源仓库(一个专有项目名,不是中文语境里「凭感觉交易」那种泛指)。它最值得抄的一段代码不是那堆金融工具,而是 agent/src/live/mandate/ 下的几个文件:它把「这个 Agent 被允许做什么」从提示词里的一句叮嘱,改造成了一份用户签发、落到磁盘、有过期时间、能被逐条回溯的授权文件,并且用代码结构保证 Agent 自己够不到写它的那支笔。

权限设计通常有两种做法:给模型一份规则指望它遵守,或者在工具层拦一道闸越界就拒。这个项目走第三种——越界当然要拦,但更靠前的一步是,闸门参数本身必须由人一次性签发,Agent 只有提议权没有签发权,这两半代码被刻意放在互不可达的路径上。

站内已有几篇相邻的题目:Hermes 的操作审批闸门 讲运行时逐次拦截,opencode 的权限审批链路 讲编码 Agent 怎么把危险动作交回给人,Agent 权限模型怎么设计 讲通用分层框架。这篇只盯一个位置:动作发生之前,那份授权本身是怎么被生产、审阅、存档、作废的

一、它想解决的是「谁给的许可」

先说清楚挂在哪。Vibe-Trading 的 agent/src/ 下有 23 个模块目录,agent/src/tools/ 有 72 个文件,agent/src/skills/ 铺了 88 个技能目录,agent/src/swarm/presets/ 放着 30 份多智能体编制 yaml。agent/src/live/ 是它接实盘的那一支,mandate 是这一支最前面的一环。

问题的形状是:一个能自己发现标的、自己排计划、自己调工具的 Agent,如果同时持有下单能力,那么「它能动多少钱、能碰哪一类资产、一天能下几单」这些参数放在哪,就是整套系统安全性的原点。放提示词里模型可以被绕过;放配置文件但代码留了写路径,Agent 能改文件就等于能给自己加权限;放环境变量里,缺审阅也缺追溯。

它的答案是把这些做成数据结构 Mandate,定义在 agent/src/live/mandate/model.py。模块注释写得直白:用 frozen dataclass 而不用 Pydantic,理由是这份授权在会话启动时读一次、之后永不修改,冻结数据类由语言层直接保证不可变,且不留任何可被 Agent 利用的校验入口。这个取舍值得抄——只从受保护路径读、只读一次、读完不改,校验面本身就是攻击面,越薄越好。

二、这份授权里写了什么

Mandate 是三层加一个开关。

第一层 HardCaps 是量化硬上限,六个字段:account_funding_usd(专用账户圈定的资金)、max_order_notional_usd(单笔名义上限)、max_total_exposure_usd(成交后全部持仓合计市值上限)、max_leverage(毛杠杆倍数,1.0 表示纯现金)、allowed_instruments(可交易工具类型白名单)、max_trades_per_day(按 UTC 自然日计的下单次数)。注释标注了每项由谁执行:资金那项由券商侧执行,是物理上无法突破的绝对边界,本地只镜像一份做事前算术;其余由项目自己的闸门执行。allowed_instruments 为空等于全拒。

第二层 UniverseConstraint 是 Agent 挑标的时的活动范围:asset_classesmin_market_cap_usdmin_avg_daily_volume_usdNone 表示不设下限)、exclude_symbols(硬性排除名单,优先级高于其它全部范围规则)。注释里明确写了这不是 ticker 白名单,因为白名单会杀死 Agent 的发现能力。把边界写成结构性条件而不是枚举,是让「有边界」和「有自主性」同时成立的关键——白名单一上,系统就退化成执行器。

第三层 ConsentMeta 是这份授权的出身证明:created_atconsent_token_sha256(把文件绑定到一次明确的人类批准)、brokeraccount_ref(券商账户的不透明标识,注释写明不是凭据)、expires_at。默认寿命 30 天,这句话在 model.py 的注释里,对应的常量 DEFAULT_MANDATE_LIFETIME_DAYS = 30 定义在 commit.py,作为提交函数 lifetime_days 参数的默认值;注释还补了一句「一份实盘授权不能永久存活」。

最后是 flatten_on_halt 布尔开关,默认 False:急停时默认只撤未成交挂单,只有用户在提交时显式勾选才会同时提交平仓单,老文件缺这个字段也读成 Falseschema_version 当前是 MANDATE_SCHEMA_VERSION = 1,读取端不为兼容而静默强转,原样带出去交给闸门比对后 fail-closed。

三、提议和提交是两套代码

提议这一半是正经的 Agent 工具:agent/src/tools/propose_mandate_tool.py 里的 ProposeMandateProfilesTool,工具名 propose_mandate_profiles,类属性上写着 is_readonly = True。它接受必填的 brokerceilings(另有 intentsession_idreauth_forflatten_on_halt 选填),合成一组编号候选方案,每份都夹在账户上限之内——工具描述里写的是 2 到 4 份,内置模板实际是三档,标签分别是稳健、均衡、激进,对应资金比例 0.05、0.15、0.30 和每日 2、5、10 单,再逐项与 ceilingsmin。返回的载荷类型叫 mandate.proposal,带着 proposal_idceilings_ref、完整的 ceilings 快照和 profiles 列表,另有 funding_note 说明资金由用户自己在券商的专用账户里设定、Agent 无法转移资金,halt_note 说明随时一句「停」就是急停开关。

提交这一半agent/src/live/mandate/commit.pycommit_mandate。模块注释交代得毫不含糊:它刻意不是工具,也不能被工具注册表发现,因为注册表只发现 BaseTool 的子类而这里没有任何东西继承它。真正调用它的是 API 侧的 POST /mandate/commit 端点(挂载在 agent/src/api/live_routes.py)。Agent 循环里根本没有 commit_mandate 的引用,所以哪怕模型被污染或产生幻觉也生不出一份授权——唯一的写入路径需要一个来自界面层的 consent_ack,而模型永远产不出这个东西。函数第一行就是 if consent_ack is not True 直接抛错,注意是 is not True 而不是 falsy 判断。

读取这一半agent/src/live/mandate/store.py,只暴露一个公开函数 load_mandate,注释明说这里以及任何 Agent 可导入的模块里都不存在 save_mandate / set_mandate。读取 fail-closed:文件缺失、JSON 损坏、结构非法一律返回 None,让下游闸门拒绝全部订单,而不是猜一个默认值。

这条不变式不靠人自觉维持。agent/tests/test_no_set_mandate_tool.py 用 AST 扫描 src/live/ 下所有函数名,任何匹配 ^(_)?(set|write|update|grant|authorize|enable|widen)_(mandate|limit|live|authorization) 的命名都会让测试失败;合法的那个写入函数被特意命名成 commit_mandate,正好不落进这个正则。它还断言存储模块不导出任何 save / set 符号。把「不许自授权」写成一个会挂的测试,比写进文档强得多。

组成部分它负责什么仓库位置你什么时候会碰到它
Mandate 数据模型定义授权的三层结构与冻结语义agent/src/live/mandate/model.py设计自己 Agent 的授权字段时
propose_mandate_profiles 工具合成被夹紧的候选方案,只读agent/src/tools/propose_mandate_tool.pyAgent 需要向用户要权限时
commit_mandate 写入函数唯一写入路径,需界面层确认agent/src/live/mandate/commit.py接界面层提交端点时
load_mandate 读取函数启动时只读加载,失败即拒agent/src/live/mandate/store.py闸门每次判断权限时
事前闸门按固定顺序逐条核对订单意图agent/src/live/enforcement.pyorder_guard.py排查某笔请求为什么被拒时
急停哨兵与审计账本文件级停机;追加式动作记录agent/src/live/halt.pyaudit.py要能证明它到底做了什么时
命名不变式测试AST 扫描堵死自授权命名agent/tests/test_no_set_mandate_tool.py想把约定变成会挂的测试时

四、提交时的复核,和一个真实踩过的坑

commit_mandate 拿到 proposal_id 之后不是照单写入。它先把落盘的提议记录读回来,读不到就报错说这份提议已不再有效;按 ordinal 取出选中的那一档;如果带了 adjustments,数值型字段只允许收窄,一旦某个值大于渲染给用户看的那个值就直接拒绝,注释写「放宽必须走一份新的提议」;最后把解析出的方案再和当初的上限快照比一遍。

坑在这一步的比对上。提议侧的方案字段用给人看的名字(max_order_usddaily_trade_capinstruments),上限快照可能用 schema 侧的拼法(max_order_notional_usdmax_trades_per_dayallowed_instruments)。早期实现直接比同名键,结果是两边拼法不一致的字段压根没被比过——提交时的复核对那个字段而言是空转。代码注释把这个记成 audit H9。修法是引入 _CEILING_ALIASES 这张别名到规范名的映射表,两侧都先过 _normalize_limits 归一再比,并规定规范名与别名同时出现时以规范名为准,防止用第二种拼法夹带更宽的值;提议侧那个工具直接从 commit.py 导入同一个 _normalize_limits,为的是让两边对「哪条上限管哪个字段」的理解永远一致。

这个坑的普适性很强:任何「先展示可选项、再在提交时复核这个可选项」的双阶段设计,只要两阶段各自维护一份字段名,复核迟早变成空转,而且失败是静默的——不报错,只是没检查。

写入顺序也有讲究:先写同意记录再写授权文件,注释说明原因是先落证据再放权限——同意记录写失败则没有任何授权变得可用;反过来若授权文件发布失败,孤立的同意记录无害且能用于审计诊断。两次写入都走 _atomic_write_json:父目录 0700,同目录临时文件加 os.replace,权限 0600。提交成功后那份提议会被删除,永远无法被提交第二次。绑定靠 consent_token_sha256,材料是 proposal_id、选中序号、同意记录 id、创建时间拼成的串取 SHA-256;配合 agent/src/live/audit.py 追加写到 live/audit.jsonl 的动作账本,每次实盘动作都能追回到具体哪一次人类点击。状态存放位置见 agent/src/live/paths.py:挂在运行时根目录(默认 ~/.vibe-trading)下的 live/,每家券商一个子目录,授权是 mandate.json,提议在 proposals/,同意记录在 consent/,急停哨兵是 live/HALT

有效授权也不等于放行。agent/src/live/order_guard.py 的注释列了每次下单工具被调用时的固定顺序,每步 fail-closed:加载授权、检查过期、检查急停哨兵、解析订单意图、读取持仓与余额,最后交给 agent/src/live/enforcement.pycheck_mandate。判定结果是 BreachEventkind 分三类且路由不同:universeinstrument 是结构性违规直接拒,因为 Agent 不能改授权、也就没有任何放宽能允许它;quantitative 是量化超限,暂停等待重新授权,此时可以把 reauth_for 传回提议工具去偏置候选方案,但仍夹在账户上限内。

五、边界与代价

它换掉的是即时性。 每次放宽额度都得走一遍提议、渲染、用户挑选、界面提交的往返,提议一旦提交即作废不能重放。想临时加个额度救场做不到,对追求低延迟自动化的场景这是实打实的代价。

它把安全性押在界面层的诚实上。 结构性保证是「Agent 够不到写入路径」,前提是那个 consent_ack 真来自人的操作。部署方自己写脚本去打端点并填 true,保证就绕过去了,代码管不了这件事,只能靠部署纪律。

它不管额度设得对不对,上限由用户填、券商侧兜底,代码只保证不越过用户填的数。明确不管的还有几件:资金转移不归它管;凭据不进授权文件;市值与流动性下限在数据拿不到时是拒绝而非放行,数据源不稳会直接表现为下不了单;cn_equity 没接数据加载器,A 股的这两个下限会 fail-closed 拒绝,注释写明有意为之。

实盘部分的现实风险必须说清。 真接上券商之后面对的就不只是代码问题:券商凭据存在本机,任何能读到运行时目录的进程都是暴露面,文件 0600、目录 0700 只是最低限度防护;下错的单在成交后不可撤销,急停能停住后续动作但停不住已成交部分;程序化交易在不同司法辖区的申报与合规义务差别很大,能不能这么用,以你所在司法辖区的监管要求与你和券商签的协议为准。

另外,仓库 agent/src/factors/ 下有 482 个文件的因子实现,这些不是项目自研的方法:根据仓库根目录的 NOTICE,其中 Qlib 的特征定义来自 Microsoft Qlib、走 Apache 2.0 许可,另有几组公式来自公开论文与券商研报,项目把它们当作数学事实重新实现,各子目录下另有 LICENSE.md。历史表现不代表未来,本文只讨论工程实现;能不能商用以许可证原文为准。项目整体是 MIT 许可(Copyright 2026 Vibe-Trading Contributors)。

六、想抄这套抽象的避坑清单

别把提议工具和写入函数放进同一个可导入路径。 会踩是因为大多数人会把 proposecommit 写进同一个模块,觉得内聚;这样一来只要 Agent 能加载那个模块,结构性保证就退化成命名约定。避法是让写入函数不继承工具基类、不被注册表发现,调用方限制在服务端端点。抄之前先问:我的 Agent 能 import 到它吗。

别用 falsy 判断接收同意标志。 会踩是因为 if not consent_ack 看起来等价,但字符串、数字、非空对象都会让它意外通过。避法是像项目里那样写 is not True,只认布尔真值。

双阶段复核的两侧必须共用同一份字段归一化代码。 会踩是因为提议面向人、提交面向 schema,两套命名几乎必然分裂,分裂之后复核静默变空转、测试还都是绿的。避法是把映射表和归一函数放一处、两侧都 import 它。

调整只允许收窄要写成显式检查。 会踩是因为「选完还能微调」是很自然的需求,实现时容易写成直接覆盖字段。避法是对数值型字段逐个比对,大于渲染值就抛错,放宽一律回到提议流程重来。

授权要有过期时间、读取端要 fail-closed。 会踩是因为过期逻辑不写也能跑通、「文件不存在就用默认配置」又是配置加载的常见写法,两者合起来等于一次授权永久有效、没授权也能跑。避法是给默认寿命(项目取 30 天)并让读取函数任何异常都返回空值、下游一律拒绝。

急停不要只做在应用层。 会踩是因为把停机做成内存标志或一条消息看起来够用,但循环卡死、进程失联、总线挂掉时就不生效。避法是用文件系统哨兵,存在即停机、内容坏了也算停机,外部看门狗 touch 一个文件就能拉闸。

收尾

如果你手上有一个能碰到不可逆资源的 Agent——下单、转账、删库、发布、给外部发消息都算——这套抽象值得照着自查四条:你的授权参数是数据还是提示词;Agent 能不能 import 到写入它的那支笔;提交时有没有真的复核用户看到的那份内容,还是被字段命名分裂搞成了空转;断电式停机是不是不依赖模型配合。

往下读的顺序建议:先 model.py 看授权长什么样,再 commit.py 看两半怎么被隔开、复核怎么做,然后 store.py 体会 fail-closed 的写法,最后到 enforcement.pyorder_guard.py 看这份授权在运行时怎么被逐条兑现;想知道这些约束会不会被后人改坏,agent/tests/test_no_set_mandate_tool.py 一个文件就能说明白。人工介入这一环更一般的写法,可以接着看 Agent 流程里的人工确认点怎么放Agent 最小权限怎么设计

本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 开源交易 Agent 的四道下单闸门:从强制执行层到日频计数Vibe-Trading 开源交易 Agent 的下单闸门:分类、拦截与审计三件套

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