开源交易 Agent 项目 Vibe-Trading 的常驻运行时拆解
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
让一个 Agent 长期跑着,难的从来不是那个 while 循环,而是它每次醒来之前必须先回答两个问题:我睡着的这段时间外面变成什么样了,以及我上一次是正常结束还是被人一刀砍死的。 跑一次的 Agent 可以假装世界是静止的,常驻的不行。Vibe-Trading 这个开源项目把这件事拆成了四个互不越界的模块,放在 agent/src/live/runtime/ 下面,正好对应四个问题:什么时候该醒(调度)、醒来干什么(执行)、外面变成什么样了(对账)、我还活着吗(存活检测)。
先说清本文的立场:Vibe-Trading 是 HKUDS 放出的开源个人交易 Agent,是一个专有项目名,不是”凭感觉交易”这类泛指说法。本文只拆它的 Agent 工程实现,不讨论任何交易策略、不评价任何标的、不涉及投资判断。历史表现不代表未来,本文只讨论工程实现。
站内已有几篇和这个话题擦边但分工不同的文章:常驻 Agent 的部署与缩容 讲的是进程怎么起、闲下来怎么收;常驻 Agent 的可观测四块拼图 讲的是事件、指标、日志怎么导出去让人看;Agent 上线之后的日常运维 讲的是每天早上打开面板该盯哪几行。这篇补的是它们之间那一层——进程已经起来了、日志也在往外发,但运行时自己怎么决定该不该干活、怎么在重启后不重复干活。
一、四块分别是什么,你什么时候会碰到它
agent/src/live/runtime/ 目录下我用 ls 数了一遍,除 __init__.py 外有 7 个实现模块。它们的职责切分得相当干净,几乎没有互相伸手:
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 调度器 | 算出该睡多久、哪些任务到点了、到点后怎么重排 | agent/src/live/runtime/scheduler.py | 排查”为什么没按时醒”或”为什么醒来一口气跑了一堆” |
| 任务存储 | 把任务集合原子落盘,损坏时隔离而不是空启动 | agent/src/live/runtime/jobstore.py | 进程被强杀后重启,发现任务全没了或者启动直接报错 |
| 触发器 | 在纯时钟之上叠加交易时段与事件判断 | agent/src/live/runtime/triggers.py | 需要”只在某个市场开市时才醒”这类条件 |
| 执行循环 | 一次唤醒按固定顺序走完,任一步不通就整轮中止 | agent/src/live/runtime/runner.py | 想知道某一轮为什么没调用模型、被哪一步拦下了 |
| 对账 | 拿外部真实状态和本地持久化的上次已知状态做差异分类 | agent/src/live/runtime/reconcile.py | 崩溃重启后运行时拒绝继续,要人来看 |
| 存活检测 | 每轮写心跳、按阈值判断是否还活着、清理过期哨兵 | agent/src/live/runtime/liveness.py | 面板上显示 runner 是死是活,以及僵尸进程判定 |
| 紧急清场 | 观察到急停后取消挂单、按授权决定是否平掉持仓 | agent/src/live/runtime/flatten.py | 急停从”下次拒绝”升级成”立刻动手”时 |
急停开关本身不在这个目录,它在 agent/src/live/halt.py,靠文件系统上的哨兵文件表达,模块注释里写得很直白:这个开关不依赖模型配合,哨兵文件的存在本身就是急停,里面那点 JSON 只是归因用的,读不出来也照样算触发。这个设计取向值得单独记一笔——凡是安全相关的开关,都别把判定权交给会话里的模型。
二、调度:睡多久这件事本身就要防御
最朴素的调度写法是”算出最近一个任务还有多久到点,然后 sleep 那么久”。scheduler.py 没这么写,它给这个 sleep 加了个硬上限,常量叫 DEFAULT_MAX_RECHECK_MS,值是 5 分钟。注释给的理由是三条:墙上时钟可能跳变、睡着的时候可能有新任务加进来、宿主机挂起恢复会带来漂移。也就是说,即使下一个任务在三小时之后,这个循环也每 5 分钟醒一次确认一下。
计算睡多久的函数是纯函数,now_ms 从参数传进来,不在函数里读时钟:
def compute_sleep_ms(jobs: list[Job], now_ms: int, max_recheck_ms: int) -> int:
earliest = earliest_next_run(jobs)
if earliest is None:
return max_recheck_ms
delta = earliest - now_ms
if delta <= 0:
return 0
return min(delta, max_recheck_ms)
整个模块只有异步的那个 _run 循环读真实时钟,而且时钟源是通过 now_fn 注入的。这个切分带来的直接好处是:调度决策全部可以在不睡觉、不冻结时钟的情况下单元测试。你自己写常驻 Agent 时这一刀值得照抄——把”决定”和”等待”分开,测试成本差一个数量级。
第二个细节更关键。任务到点执行完之后怎么重排,advance_after_fire 是这么定的:
def advance_after_fire(job: Job, now_ms: int) -> bool:
interval = job.interval_ms()
if interval is None:
return False
job.next_run_at = now_ms + interval
return True
注意它是 now_ms + interval,不是”旧的 next_run_at + interval”。注释解释了原因:一个因为宿主机挂起或者循环忙不过来而迟到的任务,不应该在恢复后立刻把错过的那一堆时间片全补跑一遍。对定时报表这可能只是浪费,对一个会动真格产生外部副作用的 Agent,补跑积压就是灾难。
睡眠是可被打断的——add_job、remove_job、stop 都会 set 一个 asyncio.Event,所以新加的近期任务不会被卡在一段长睡眠后面。还有一处防御写在 _fire_due 里:on_fire 回调抛出的异常被 log 之后吞掉,理由是一个坏任务不能把整个循环带走、也不能饿死它的同伴。
持久化被刻意挪出了调度器,放在 jobstore.py。这个文件里有一条我认为最值得抄走的设计:任务文件解析失败时,绝不以空任务集启动。它会把损坏的文件重命名成 <name>.corrupt-<ts> 隔离掉,然后抛 CorruptJobStoreError。注释写明了为什么——对一个常驻运行时来说,“零个任务”和”任务文件坏了”在行为上完全一样(都不干活),但含义天差地别,静默空启动会让故障看起来像正常。落盘走的是同目录临时文件、fsync 临时文件、os.replace 覆盖、再 fsync 父目录这一整套,保证任何瞬间断电看到的要么是完整旧版本要么是完整新版本。
三、执行:一次唤醒的固定顺序,任一步不通就整轮中止
runner.py 里的 LiveRunner.run_once 是”醒来之后干什么”的全部。它的顺序在 docstring 里写死,而且每一步都是短路的:
- 查急停哨兵,触发了就中止、写审计、返回;
- 加载授权书,不存在或者已过期就中止;过期这一步还会主动触发急停并清掉授权;
- 对账,报告不安全或者对账本身抛异常,都中止;
- 把授权内容内联拼进提示词,调用模型;
- 写审计。
每一种结局都有一个常量码,TICK_HALTED / TICK_NO_MANDATE / TICK_EXPIRED / TICK_RECONCILE_UNSAFE / TICK_RECONCILE_ERROR / TICK_INVOKED / TICK_ERROR,返回的是一个 TickResult 冻结数据类。排障时你不用去猜某一轮为什么没动作,看这个码就知道被哪一关拦下了。
有三个判断值得单独说。
第一,失败即停是默认值,不是异常处理。 对账函数抛异常这种情况,正常思路是”重试一次”或者”先跳过”,这里写的是中止本轮并记一条审计。注释把理由讲得很清楚:这类操作不是幂等的,重发能把”丢了一次工作”这个小洞换成”重复执行一次”这个大洞。判断报告是否安全的那个函数 _report_is_unsafe 更极端——它按几个可能的字段名挨个探,全都探不到就当作不安全返回 True,宁可误停不可误放。关于重试与幂等这条线更一般的讨论,可以对照 Agent 失败重试怎么设计 一起看。
第二,授权约束是内联进提示词的,不是塞进系统上下文的。 _pin_mandate_prompt 这个函数把授权书的各项限制直接拼进本轮的用户提示词里。注释给的理由是要让这段约束扛得住会话上下文压缩——压缩会把早期消息揉掉,但每一轮新拼的提示词不会。这里要说明白:那段提示词里出现的资金上限、单笔上限、杠杆上限、每日笔数上限等等字段,是配置对模型输出格式与边界的约束,是仓库里配置长什么样的如实描述,不是本文对任何人的任何建议。本文自己不给、也不会给任何这类数字。
同一个函数的注释还写了一句边界:模型在限制之内自由行动,任何会突破限制的动作必须放弃而不是硬闯,而真正的强制拦截在模型控制之外的另一层。换句话说,提示词里的这段话是告知,不是执法。这个区分在任何带副作用的 Agent 里都成立——写进提示词的规则永远只是软约束,硬约束必须在工具调用那一层。
第三,重启靠重算,不靠断点续跑。 run_loop 在每次启动时重新加载授权、重新算出任务集合,而不是恢复某个中途快照。任务集合的来源有优先级:显式传入的最优先,其次是任务存储里持久化的(重启后接着原来的节奏),最后才是从触发器现推。授权不存在或已过期时,循环干脆不启动。这个取向和 Agent 长任务的 checkpoint 设计 里讨论的断点续跑是两条路:能重算的场景,重算比恢复快照安全得多,因为快照恢复天然带着”快照之后发生了什么我不知道”的盲区。
触发器那一层在 triggers.py,它在纯时钟之上叠了三类:固定间隔、市场时段、事件谓词。市场时段那部分维护了一张静态表,含时区、开收盘本地时间、允许的星期几和一组整天休市日期,文件注释老实交代了限制:这是手工维护的静态集合,不含半日提前收市,覆盖当年和次年,需要长跨度的部署应该换成专门的日历库。事件类触发器不走墙上时钟,_jobs_from_triggers 里会跳过它们并打一条日志。
四、对账:这块最难,因为它要面对”我不知道”
reconcile.py 的模块注释第一句就说了它没有参考实现可抄。要解决的问题是这样:进程在发出一个外部请求之后、在把结果写进本地记录之前死了。重启之后,这个请求可能已经生效、可能还挂着、也可能压根没发出去。三种可能,本地记录里都长一个样。
它的解法不是猜,而是分类并暴露。差异被归进四类,写在 DeltaKind 里:
matched:两边一致,没事;unknown_fill:外部有一笔本地毫无记录的变化——真实世界动了但本地没有对应的审计痕迹;orphan_order:本地记着一笔挂单但外部不显示了,可能被取消、被拒绝、或者已成交并清出,不自动重发,交给上层看;mid_order_ambiguous:本地记着一笔”已提交但未确认”的请求,外部既不显示它挂着、也没有匹配的成交确认。它可能在审计落盘之前就已经生效了。
后两种里的 unknown_fill 和 mid_order_ambiguous 被放进 _HALTING_KINDS,只要出现任意一条,报告的 requires_halt 就为真、is_safe 就为假,执行循环这一轮直接中止并交给人。整个 reconcile 函数只接受三个只读回调(读持仓、读余额、读挂单),结构上就没有写入通道。模块注释把这点讲成了设计保证:它连重发的能力都没有,所以不可能重发。
判断一笔记录是否”已确认”的逻辑很朴素,但值得看:
def _is_confirmed(recorded_order: Mapping[str, Any]) -> bool:
has_broker_id = any(recorded_order.get(k) for k in ("order_id", "id", "broker_order_id"))
status = str(recorded_order.get("status", "")).lower()
return has_broker_id and status not in ("", "pending", "submitted", "unconfirmed")
有对方给的 id、且状态不在这几个未定状态里,才算确认过。没确认过又对不上,就落到最危险的那一类。
还有三处细节做得比较克制。冷启动不报警:第一次运行没有本地基线时,外部状态直接成为基线,差异为零——没记录过的东西不算”未知变化”。不安全就不推进基线:只有干净通过的那一轮才会把新状态写盘,脏的那轮原样保留旧记录,因为上层还要靠那条旧记录把问题描述给人听。幂等键是有的但没定死:_client_order_id 按几个常见别名去找客户端订单号,函数里挂着一条 TODO,说明确切字段名是各家不同的,等真实字段表定下来再改成按家映射。这种”先按别名扫、把 TODO 写在代码里”的做法,比编一个看起来很确定的字段名诚实。写盘同样是临时文件加 os.replace 的原子替换。
五、存活检测:判断”它还活着吗”,但只报告不动手
liveness.py 是四块里代码最短的,思路也最直:每个 runner 每轮往 live_root()/runtime/heartbeats/<runner_id>.heartbeat 里写一个毫秒时间戳,写法还是同目录临时文件加 os.replace,保证并发读永远读不到写了一半的时间戳。
判活就是比时间差:
def is_runner_alive(runner_id, *, now_ms=None, staleness_ms=DEFAULT_STALENESS_MS) -> bool:
tick = last_tick(runner_id)
if tick is None:
return False
now = now_ms if now_ms is not None else _now_ms()
return (now - tick) <= staleness_ms
DEFAULT_STALENESS_MS 是 90 秒,注释解释了为什么定得比一轮宽松很多:宁可让一个短暂卡顿的 runner 继续算活着,也不要误判。last_tick 读不到、读坏了都返回 None,然后被判为不活——这里同样是失败即当作最差情况。
清理函数 reap_stale 扫一遍心跳目录,把过期的哨兵文件删掉并返回被清掉的 id。模块注释专门强调了它的边界:只删哨兵,不碰任何进程、不碰任何业务状态。而且它明确写了一条纪律——一个看起来死了的 runner 绝不能盲目重新拉起,必须先走对账。这条纪律的分量比代码大:存活检测告诉你的只是”心跳过期了”,它不能告诉你这个进程在心跳断掉之前做到了哪一步,那是对账的事。
还有一处很容易被忽略的对齐。LiveRunner.runner_id 直接返回券商键本身,docstring 说明这是为了和 API 服务端读心跳的键、以及命令行查状态的键保持一致。心跳键写错的后果不是崩溃,而是面板上永远显示”死了”——这类不报错的错才是最费时间的。心跳写失败在 _write_heartbeat 里被吞掉并只打日志,理由是交易决策比存活信号重要得多,写不上心跳最多让它看起来失联,而失联这件事清理逻辑本来就能安全处理。
六、边界与代价:它放弃了什么
这套设计不是免费的,取舍写得很明白:
放弃了自动恢复的顺滑。 只要对账拿不准,运行时就停下来等人。对追求无人值守的场景,这是明确的倒退——你会被叫醒。项目选择的是”宁可停、不可错”,因为在这个领域一次重复动作的代价远大于一次停机。
放弃了断点续跑。 重启只重算调度,不恢复中途进度。上一轮没走完的推理过程就是丢了。换来的是重启路径极简,不用维护一个能表达”任何中间状态”的快照格式。
放弃了单次唤醒的精确性。 迟到的任务不补跑积压时间片,所以你不能靠它做”每个时间片都必须执行一次”的严格计费型任务。
它明确不管的事: 存活检测不启动也不杀进程;对账不自动纠正、不自动重发;调度器不负责持久化;急停哨兵的判定不依赖模型是否配合。这几条边界是靠模块划分和参数签名结构性保证的,不是靠注释里的君子协定。
风险要说清的部分。 这套运行时最终会驱动真实的外部下单动作,涉及券商账号连接、凭据保管和资金授权。按 agent/src/live/paths.py 的布局说明,OAuth 令牌缓存、授权书、交易计数器、急停哨兵和审计流水都落在本地 <runtime_root>/live/ 这棵目录树下,令牌缓存目录标注的是 0700/0600、授权书标注 0600,live_root() 的 docstring 还写明目录不在这里创建、由写入方自己按 0700 建。这意味着:拿到你这台机器的本地读权限,基本就等于拿到了这条通道;也意味着这套权限是不是真落到位,取决于每个写入方有没有照做,而不是有一个地方统一兜底。另外,flatten.py 里的紧急清场会在被授权时提交平仓请求——这些请求一旦发出去就不可撤销,出错也不重试,只记录在案。程序化交易在不同司法辖区有不同的合规义务与申报要求,能不能这么用、需要办什么手续,以你所在司法辖区的监管要求与券商协议为准。本文不提供任何法律或投资意见。
顺带说一句来源问题:这个仓库根目录的 NOTICE 里写明了几个因子库各自的上游来源与许可——特征定义部分来自 Microsoft Qlib,走 Apache 2.0;另有几组公式出自公开论文与研报,仓库把它们当作数学事实重新实现,各子目录下另有 LICENSE.md。所以谈到这部分时不能笼统说成”项目自研的因子库”,它是公开公式的工程化重实现。能不能商用,以许可证原文为准。
七、上手与避坑清单
坑一:把持久化写进调度器。 为什么会踩——“任务要存下来”和”任务什么时候跑”看起来是同一件事,顺手就写一块了。怎么避——照这个项目的切法,调度器只管内存里的集合和时间计算,存储是独立模块。好处是调度逻辑全变成纯函数,测试不用碰磁盘。
坑二:数据文件读不出来时静默空启动。 为什么会踩——try/except 里返回一个空列表是最省事的写法,而且当时看起来”很稳”。怎么避——像 jobstore.py 那样,把损坏文件改名隔离,然后抛异常拒绝启动。区分”本来就没有”和”有但坏了”,前者才允许空启动。
坑三:崩溃恢复时重发上一次未确认的请求。 为什么会踩——重发在无副作用的场景里几乎总是对的,肌肉记忆会带你这么写。怎么避——先问这个操作是不是幂等的。不是的话,正确做法是分类、暴露、停下来,而不是猜。
坑四:心跳键和读取方的键对不上。 为什么会踩——写心跳的地方和读心跳的地方通常隔着好几个模块,各自拼各自的 id,谁也不报错。怎么避——把这个键收敛到一个属性上(这个项目放在 runner_id 上并在 docstring 里写明谁在读),改的时候一处改完全链路生效。
坑五:睡到下一个任务点,中间完全不醒。 为什么会踩——这么写最省 CPU,逻辑也最直观。怎么避——给睡眠时长加硬上限。笔记本合盖、虚拟机挂起、时钟同步跳变,这几种情况在开发机上很难复现,但常驻久了必然遇上。
坑六:把安全开关的判定权交给模型。 为什么会踩——“在系统提示词里告诉它不许越界”写起来最快,看上去也生效。怎么避——安全开关走进程外的信号(这个项目用的是文件系统哨兵),在工具调用入口硬拦。提示词只负责告知,不负责执法。
坑七:把提示词里的配置字段当成建议。 为什么会踩——授权书拼进提示词之后,那些字段读起来像是”应该怎么做”。怎么避——记住它是对模型输出格式与边界的约束,属于配置,跟任何人该怎么决策没有关系。
收个尾
如果你要照着这套思路给自己的常驻 Agent 做一次体检,按这四问走一遍就够了:醒得对吗(睡眠有没有上限、迟到会不会补跑积压)、每轮的顺序固定吗(有没有一个能一眼看出被哪一关拦下的结局码)、重启后能不能证明自己没重复干活(有没有一个只读的对账通道,拿不准的时候会不会停)、别人怎么知道它活着(心跳键有没有收敛、判活失败是不是按不活处理)。
接着读哪个文件,按你卡在哪一问选:卡在第一问读 agent/src/live/runtime/scheduler.py 和 jobstore.py;卡在第二问读 runner.py 里 run_once 的 docstring,那五步顺序就是全部;卡在第三问读 reconcile.py,从 DeltaKind 那个类的四条注释开始,那四条把问题空间切完了;卡在第四问读 liveness.py,一百多行,十分钟能读完。
再强调一次本文的边界:以上全部是对一个开源项目工程实现的技术拆解,不构成任何投资建议,也不涉及对任何标的、策略或收益的判断。涉及能不能在实盘环境这么用,以你所在司法辖区的监管要求与券商协议为准。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 Vibe-Trading 开源交易 Agent 的下单闸门:分类、拦截与审计三件套 和 拆解开源项目 Vibe-Trading 的券商抽象层与凭据保管。