Vibe-Trading 开源仓库的 Web 层:一条时间线让长任务可见
本文基于 Vibe-Trading 仓库 commit 3a752d5(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/HKUDS/Vibe-Trading 最新代码与文档为准。
如果你的 Agent 一次跑十分钟,那么「进度怎么呈现」不是 UI 问题,而是一个必须由后端先解决的状态问题——前端能画出什么,取决于后端愿意把哪些中间状态当成一等公民持久化并广播出去。 Vibe-Trading(HKUDS 团队开源的那个交易 Agent 仓库,不是「凭感觉交易」这类泛称)的 Web 层,把这件事拆得相当清楚:一侧是 FastAPI 按业务域分组挂载的路由,另一侧是浏览器里一条能折叠、能计时、能在断线后接上的活动条。这套做法和它做的是不是金融没什么关系,换成任何跑长任务的 Agent 产品都成立,所以值得单独拿出来读。
先说清楚本文的位置。本文只讨论工程实现:代码怎么组织、事件怎么流、UI 怎么渲染。不涉及任何标的、策略优劣或投资判断。
短请求的 Web 交互是「发出去—等—拿到结果」,长任务不是。一次 Agent 运行里会发生一串你事先不知道数量的动作:模型在想、某个工具在跑、跑到一半失败重试、产出了一批中间文件。用户在这个过程中会做三件让人头疼的事:切走标签页、刷新页面、把网断了再插回来。
所以可见性需要同时满足几个条件:进行中的状态要能实时推;页面刷新后已经跑完的历史要能重建;断线重连后不能从头重播、也不能丢掉断开期间的事件;还要有一个统一的地方回答「现在到底跑到哪一步了」。Vibe-Trading 把这几件事分别落在了不同层:会话与事件在 agent/src/api/sessions_routes.py 和 agent/src/session/events.py,历史运行的阶段推断在 agent/src/ui_services.py,前端的呈现在 frontend/src/components/chat/ 下面几个组件里。
这里先做个分工说明,免得你在站内几篇相近的文章之间来回绕。Agent 可观察日志怎么设计 谈的是日志字段本身该记什么、怎么定位问题;Hermes 的监控与可观测实践 谈的是把运行指标接到监控体系里的做法;状态机式编排与自由式 Agent 的取舍 谈的是控制流形态的选择。本文只管一件事:一个真实开源仓库里,「后端事件 → HTTP 传输 → 前端渲染」这条链路是怎么接起来的,以及它为此付出了什么代价。
一、后端:入口只做装配,路由按域分组
agent/api_server.py 这个文件的自我定位写在模块文档串里,是一个「thin assembler」——创建 FastAPI 应用、挂中间件、注册路由模块、再把符号重新导出。它自己几乎不写业务逻辑。
注册的方式是统一的函数调用,每一组路由由自己的模块提供一个 register_*_routes(app):runs、sessions、system、settings、uploads、channels、swarm、live、alpha、auth、scheduled 各一份,另外还有一个用 app.include_router(qveris_router) 挂上去的路由器,以及一个 try_register_openbb_routes(app)——后者按注释说明,在可选依赖没装时是空操作,但两种情况下都会自报状态。
这种写法的好处不在于「更优雅」,而在于它把 FastAPI 的装饰器注册和模块导入顺序解耦了。你在 agent/src/api/sessions_routes.py 里能看到代价的另一面:路由模块需要反过来拿宿主模块的共享符号,它的做法是从 sys.modules 里取回 api_server 模块,并且把 _get_session_service、_validate_path_param 这些调用包成闭包延迟解析——注释直说了原因是 monkeypatch-safe,也就是为了让测试替换掉这些实现时仍然生效。
中间件只有三层,顺序在文件里一眼可见:CORS,然后是拒绝不受信任 loopback host、SPA 深链回退、附加安全响应头。启动与关闭走 _lifespan,启动时跑一次 preflight 与一次性的历史状态迁移,关闭时按反序停掉后台执行器。这里有个细节值得抄:状态迁移被单独包了 try/except,注释写明「must never block startup」——一次性迁移失败不应该让整个服务起不来。
会话这一组的路由划分是这样的:
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 应用装配器 | 建 app、挂中间件、按域调用各 register_*_routes | agent/api_server.py | 加一组新接口、调整中间件顺序时 |
| 会话与目标路由 | 会话增删改查、发消息、取消、目标与证据的读写 | agent/src/api/sessions_routes.py | 接自己的客户端、排查 409/501 时 |
| SSE 事件总线 | 按会话缓冲事件、多订阅者分发、断线重播 | agent/src/session/events.py | 事件丢了、重连后重复渲染时 |
| SSE 票据接口 | 用一次性短票换掉 URL 里的长期密钥 | agent/src/api/auth_routes.py | 开了鉴权之后浏览器连不上流时 |
| 前端 SSE 客户端 | 自动重连、指数退避、按 id 去重、续传 | frontend/src/hooks/useSSE.ts | 新增事件类型、调重连节奏时 |
| 活动条组件 | 一行折叠展示状态、动词、耗时、步骤 | frontend/src/components/chat/ActivityLine.tsx | 改状态图标、改自动收起时机时 |
| 回合适配器 | 新回合读持久化 activity,老回合从消息重建 | frontend/src/components/chat/ThinkingTimeline.tsx | 老会话渲染不出步骤时 |
| 提问点导航 | 右侧圆点,在长会话里跳回某次提问 | frontend/src/components/chat/ConversationTimeline.tsx | 会话很长、要回看某一轮时 |
| 运行分析服务 | 从 run 目录推断阶段、拼图表数据与日志 | agent/src/ui_services.py | 详情页缺数据、阶段徽章不对时 |
| 运行详情路由 | 按 run_id 返回结果,图表载荷按需开启 | agent/src/api/runs_routes.py | 详情接口太大、要瘦身时 |
会话侧的接口大致分三类。第一类是纯 CRUD:POST /sessions、GET /sessions、GET /sessions/{session_id},加上 PATCH 改标题、DELETE 删除。第二类是动作:POST /sessions/{session_id}/messages 发消息并启动 Agent 循环,POST /sessions/{session_id}/cancel 取消在途循环,还有一个 POST /sessions/{session_id}/title/auto 让模型给会话起个短标题。第三类是目标(goal)子组,挂在 /sessions/{session_id}/goal 下面,负责研究目标、判定标准、证据追加与状态流转。
有几个错误码的处理方式挺讲究,抄的时候别丢:会话运行时没启用返回 501 而不是 500;SessionBusyError 返回 409,代码注释特意写明这一条必须排在 ValueError 处理之前,并且要和 404 区分开——会话是存在的,只是已经在跑了。自动起标题那个接口也有一条保护:只有当前标题为空、或者仍等于建会话时截取的首条提问前缀时才会覆写,避免把用户手改过的名字冲掉。
目标创建接口里还有一条硬性拒绝:风险等级如果落在实盘交易或执行那一档,直接返回 400,附言这类目标不被支持。这是仓库里写死的约束,不是我的解读。
二、事件总线与 SSE:断线之后怎么接上
agent/src/session/events.py 里的 SSEEvent 是个 dataclass,字段就五个:event_id、event_type、data、session_id、timestamp,to_sse() 把它们拼成标准的 SSE 文本帧。event_id 默认取 uuid 十六进制的前 16 位,这是后面所有续传逻辑的锚点。
EventBus 做三件事。一是每个会话维护一个环形缓冲,默认上限 500 条,超了就丢最旧的。二是分发:订阅者各持一个容量 200 的 asyncio 队列,队列满时丢事件并打一条 warning,注释里明说这是为了不让某个卡住的订阅者拖垮整个总线。三是线程安全:发布时如果事件循环在跑,用 call_soon_threadsafe 把入队动作送回循环里执行——因为 Agent 循环跑在后台线程,直接对 asyncio 队列 put_nowait 是不安全的,这一点在模块顶部的版本注记里被单独拎出来说过。
订阅侧的空闲处理也很直接:等队列时带 30 秒超时,超时就吐一个 heartbeat 事件,并且这个心跳的 event_id 显式设成 None。这个设计是有意的——心跳不该占用续传游标,否则客户端会把心跳的 id 当成断点,重连时反而错过真正的业务事件。
重播策略写在 replay() 里,逻辑值得逐字读:带了 last_event_id 就从该 id 之后开始补;没带的话,默认返回空列表,注释解释首次连接的历史由 REST 拉取;只有显式打开 replay_all 才会把整个缓冲吐出来。而 replay_all 什么时候打开,决定权在路由层:GET /sessions/{session_id}/events 接受一个 replay=active 参数,只有在没有 last_event_id、且该会话最后一次 attempt 的状态确实是 running 时,才把 replay_all 置真。翻译成人话:只有「你刷新页面时它还在跑」这一种情况,才需要把在途过程重新灌一遍。
流响应的头也是抄得走的三件套:
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
最后一个头是给反向代理看的,少了它,Nginx 一缓冲,你后端推得再实时前端也是一顿一顿的。
鉴权这块有个绕不过去的坑:浏览器的 EventSource 不能自定义请求头。agent/src/api/auth_routes.py 的文档串把权衡写得很完整——把长期 API key 塞进 SSE 的 URL 会泄漏到浏览器历史、代理与访问日志、Referer 头里,所以它提供 POST /auth/sse-ticket,用带 Authorization 头的请求换一张一次性短票,再用 ?ticket= 开流,用过即废。同时 agent/api_server.py 在启动时装了一个访问日志过滤器,把查询串里的密钥类参数遮蔽掉。这是两道锁:既缩短暴露窗口,又堵住日志这条泄漏路径。
前端那一侧在 frontend/src/hooks/useSSE.ts:初始重试 1 秒、退避因子 2、上限 30 秒,按 lastEventId 做容量 500 的 LRU 去重,重连时把 id 拼进 URL 续传。它还维护了一个 generation 计数——每次 connect 自增,所有回调都先比对代际,代际不符直接返回。这是解决「旧连接的迟到回调污染新连接状态」的经典手法,写多页面切换的实时 UI 迟早会遇到。
有一处取舍需要你自己判断:这个 hook 只监听一份写死的已知事件类型数组(从 text_delta、reasoning_delta 到 tool_call、tool_result、tool_progress、各类 attempt.* 和 goal.*)。好处是明确,坏处是后端新加一种事件而前端忘了加进数组,事件会静默消失。
三、前端:两个都叫 timeline,职责完全不同
这是读这个仓库最容易读岔的地方。frontend/src/components/chat/ 下有两个带 Timeline 的组件,做的事根本不是一回事。
ConversationTimeline.tsx 是右侧那一列小圆点。它从消息数组里挑出所有用户消息的下标,只取最近 40 个,不足 2 个就不渲染。滚动监听用 requestAnimationFrame 节流,每次算出容器中线,遍历这些下标去 DOM 里找 [data-msg-idx="..."],取距离中线最近的那个作为高亮项;点击圆点则 scrollIntoView 平滑滚过去。圆点的 title 和 aria-label 都取该条提问的前 40 个字符。说白了,它是长会话的提问点跳转条,不承载任何执行状态。
真正承载执行状态的是 ActivityLine.tsx。一次 attempt 对应一行,折叠时只显示一行摘要,展开后才是工具步骤列表。它的状态机只有七个值:thinking、working、responding、stopped、timeout、failed、done,前三个被归为「活跃」。摘要文案按状态分支拼装,活跃时拼的是「动词 · 最近一个工具 · 已耗时 · 步数」,终态时换成对应的结束词。
动词是从最后一个工具名推出来的,映射规则在 frontend/src/stores/agent.ts 的 deriveActivityVerb 里,用四条正则依次匹配,命中校验类工具就说在核验、命中回测类就说在跑回测、命中写代码类就说在写策略、命中数据类就说在读数据,都不命中就回落到通用的「working」。这是个很实用的降级设计:工具可以随便加,UI 文案不会崩,最差也就是显示得笼统一点。
细节里有两处我认为最值得抄。一是计时器会跟着页面可见性走:监听 visibilitychange,标签页隐藏时直接停掉 1 秒一次的 interval,切回来再重新对齐时间。长任务页面被挂在后台是常态,这一下能省掉大量无谓的重渲染。二是自动收起:活跃时强制展开,一旦离开活跃态,900 毫秒后自动折叠;但组件同时记了一个用户手动开合的状态,用户显式点过之后就以用户的为准。
终态还带补救动作:stopped 时展示「继续」按钮,timeout 时展示「重新挂接」按钮,两者都是通过回调向上冒泡。这比只给一个红字提示要负责得多——长任务中断之后,用户最需要的是一个下一步。
ThinkingTimeline.tsx 则是个兼容适配器,它的文档串写得很清楚:新的回合会带一个持久化的 activity 对象,直接取用;老的会话里只有一串 tool_call / tool_result 消息,就用 rebuildLegacyActivity 就地重建成同一个结构,再交给同一个 ActivityLine 渲染。重建时的配对逻辑是从后往前找同名且仍在 running 的那一步,找不到再放宽到同名的任意一步。数据模型换代时,用一个适配器把老数据抬到新结构,而不是让渲染组件里长满 if——这个套路在 中间产物的落盘与罗盘设计 里也是同一个思路。
四、run 目录本身就是进度条
agent/src/ui_services.py 处理的是另一半问题:一次运行已经结束了,或者页面是新开的,SSE 里什么都没有,这时进度从哪来。
它的答案是从磁盘反推。infer_run_stage() 按固定顺序检查文件是否存在,命中即返回:
state_data = load_json_file(run_dir / "state.json") or {}
state_status = str(state_data.get("status") or "").lower()
if state_status == "success":
return "done"
if state_status == "failed":
return "failed"
if (run_dir / "artifacts" / "metrics.csv").exists():
return "backtest"
if (run_dir / "review_report.json").exists():
return "review"
if (run_dir / "code" / "signal_engine.py").exists():
return "coding"
后面还有 design、planning、queued 几级,全都落空则返回 unknown。这个设计的实质是:把工作流的每一步都定义成一个确定的落盘产物,于是目录结构本身就成了状态机的持久化表示。进程崩了、服务重启了、事件缓冲早被冲掉了,阶段依然能算出来。用一次 ls 就能回答「跑到哪了」,这比另建一张状态表要难写错得多。
同一个文件里还有几处一致的防御姿态。上下文加载 load_run_context() 先读 req.json,缺字段就回退去 planner_output.json 里捞。价格序列 load_price_series() 是三级回退:先找 artifacts/price_series.csv,没有就扫 ohlcv_*.csv 逐个拼,再没有才走重建。日志采集固定读 logs/ 下的三个文件(运行标准输出、标准错误、编译错误),每个文件默认只留尾部 200 行并打上来源标签。指标周期推断不出来时兜底成一组默认窗口。整个模块几乎每个入口都能在数据不全时返回一个可渲染的东西,而不是抛异常。
载荷大小也是被显式管理的。build_run_analysis() 带 include_payload 和 include_symbol_list 两个开关,关掉载荷时只回阶段、上下文、日志和标的清单;对应到 agent/src/api/runs_routes.py 的 GET /runs/{run_id},两个查询参数 chart_symbol 和 chart_payload=summary 都是 opt-in 的,代码注释直说了默认响应对既有调用方保持不变。给老接口做瘦身又不想破坏兼容时,这是个稳妥的路子。
需要框一句:这里出现的回测产物、指标文件、因子相关目录,本文只讨论它们作为工程对象怎么被读写和呈现,不涉及任何结果好坏的评价。历史表现不代表未来,本文只讨论工程实现。另外,仓库根目录的 NOTICE 对因子部分的来源交代得很明确——其中一部分特征定义来自 Microsoft Qlib,按 Apache 2.0 许可;另有几组公式来自公开论文与券商研报,仓库把公式作为数学事实重新实现,各因子库子目录下另有各自的 LICENSE.md。所以别把它笼统说成「项目自研的因子库」。本文不提供法律意见,能不能商用以许可证原文为准。
五、边界与代价
这套设计不是没有成本,几处取舍很清楚。
事件缓冲是内存里的,且有上限。默认每会话 500 条,订阅队列 200。一次工具调用密集的长任务完全可能把 500 条冲掉,此时断线重连补不回中间那段——恢复完整历史只能靠 REST 重新拉消息和从 run 目录重建。它也不是持久化队列,进程一重启,在途事件就没了。要跨进程、要多副本、要保证不丢,得换成外部消息中间件,那是另一套复杂度。
SSE 是单向的。浏览器只收不发,所有用户动作(取消、继续、改目标)都得另走一条 HTTP 请求。这换来的是实现简单、能吃住 HTTP/1.1 和现成的代理,代价是双向低延迟场景它做不了。
按会话串行是硬约束。发消息接口在会话已有在途运行时直接 409,代码里管这叫 claim 与 release 的成对操作。这让状态推理变简单了,但同一会话里没法并发跑两件事。
前端事件类型是写死的白名单,新增事件必须两头同时改,漏改会静默丢事件——这是「明确」换来的维护税。SSE 的鉴权走一次性票据,也就意味着你必须先能发出一个带 Authorization 头的请求,纯静态托管、跨域策略不完备的部署方式会更麻烦。
还有一类它明确不管的事。这个 Web 层不提供多用户隔离与角色权限,安全模型的重心在「只信任本机回环、非回环绑定要有密钥」,你能在启动入口看到那句针对非回环绑定又没配密钥的告警。它也不提供指标导出、追踪采样、告警这类运维面的东西——那属于另一层的话题。
涉及实盘的部分要单独说清楚代价。 仓库里确实存在实盘相关的路由模块与券商连接器目录(agent/src/trading/connectors/ 下有 12 家连接器子目录,README 亦自述 12 brokers),会话事件流里也有把撮合类工具结果转成 mandate.proposal、live.action 两种帧的中继逻辑——后者是从落盘的审计流水里按 id 反查一条脱敏记录再发出去。这类能力的真实代价是:任何券商凭据一旦交给本地进程,它的暴露面就等于这台机器的暴露面(含日志、临时文件、崩溃转储);下错的单在真实市场里不可撤销,没有回滚;程序化交易本身的资质与报备义务因司法辖区而异。仓库把提案与动作都落盘成可审计记录,是在给你留证据链,不是在给你兜底。要不要把这条链路接通,以你所在司法辖区的监管要求与券商协议为准。
顺带澄清一个容易误读的点:仓库的配置与工具会要求模型按固定字段输出(比如提案类结构里的各种约束项)。那是配置对模型输出格式的约束,属于工程契约,不构成本文对读者的任何建议,本文也不给出任何具体数值。
六、上手与避坑清单
先跑起来再读代码。 服务入口 serve_main() 默认绑 127.0.0.1:8000,带 --dev 会另起一个前端开发服务器,不带则从 frontend/dist 提供静态文件;没有构建产物时它会直接打印提示让你先构建。为什么会踩:不少人上来就读路由文件,结果对着一堆 register_*_routes 找不到 app 从哪来。怎么避:从入口文件从上往下读一遍,注册顺序就是模块地图。
接自己的客户端时,别只处理 200 和 500。 会话运行时没启用是 501,会话在跑是 409,路径参数非法会被 _validate_path_param 拦下。为什么会踩:409 很容易被当成失败重试,而重试只会再吃一个 409,形成刷屏。怎么避:客户端把 409 单独识别成「排队中」,等 SSE 里的终态事件再决定下一步。
SSE 断线重连一定要带续传游标,并且自己做去重。 为什么会踩:只重连不带 Last-Event-ID,服务端默认不重播,你会静默丢掉断开那几秒的事件;而如果为了不丢就一律全量重播,重复渲染又跟着来了。怎么避:照 useSSE.ts 那样,收到事件先记 id、拼进重连 URL,同时维护一个有界的已见集合去重。
心跳事件别带 id。 为什么会踩:把心跳也编号,客户端的断点游标会被心跳推着往前走,重连时服务端从心跳之后开始补,中间真正的业务事件就被跳过了。怎么避:像 events.py 那样把心跳的 event_id 显式置空。
上了反向代理记得关缓冲。 为什么会踩:本机跑得好好的,一上线就变成「憋很久然后一次性刷出来」。怎么避:响应头里带上禁用代理缓冲的那一项,并在代理配置里同步确认。
别把长期密钥放进 SSE 的 URL。 为什么会踩:EventSource 不能带请求头,最省事的写法就是把 key 拼进 query,然后它会出现在浏览器历史、代理日志、访问日志里。怎么避:照票据接口那条路走,用带头部的请求换一次性短票,并给访问日志装遮蔽过滤器。
新增后端事件类型时,同步改前端白名单。 为什么会踩:前端只对已知类型注册监听,漏了就什么都不发生,还不报错,排查时你会一直怀疑后端没推。怎么避:把事件类型收敛成一处常量,或者加一条兜底监听把未知类型打到控制台。
改工具名前先看一眼动词映射的正则。 为什么会踩:deriveActivityVerb 靠工具名的关键词匹配来选文案,重命名之后 UI 会集体回落到通用词。怎么避:改名时同步维护那几条正则,或者让工具自带一个显式的阶段标签,别让 UI 去猜。
给长任务留补救出口。 为什么会踩:只把中断状态渲染成一行红字,用户除了刷新没有别的选择。怎么避:像活动条那样,为中断与超时分别提供「继续」和「重新挂接」,把决定权还给用户——这一点和 终端 Agent 的会话时间线设计 是同一类思路。
收个尾
这套 Web 层最核心的判断只有一条:长任务的可见性,本质是把「过程」也当成需要建模、需要落盘、需要有 id 的一等公民,而不是等结果出来再补一段进度动画。后端给每个事件编号并按会话缓冲,是为了让断线可续;工作流每一步都写下一个确定的产物,是为了让状态能从磁盘反推;前端把一次 attempt 收敛成一行可折叠的活动条,是为了让长过程在视觉上仍然是一件事。
如果你要照着改自己的项目,可以对着这几条自检:断线三十秒再回来,你的界面能补上中间发生的事吗?刷新页面之后,一个仍在跑的任务还能接上吗?进程重启之后,你还能回答「上一次跑到哪一步」吗?任务中断时,界面给了用户下一步动作吗?后端新加一种事件,前端会不会静默无视?
接着往下读的话,建议按这个顺序:agent/api_server.py 看装配与中间件顺序,agent/src/session/events.py 看缓冲与重播的完整规则,agent/src/api/sessions_routes.py 看路由怎么决定重播开关,然后是 frontend/src/hooks/useSSE.ts 和 frontend/src/components/chat/ActivityLine.tsx 这一对前端收发端。
最后重申一次:本文自始至终只讨论这个开源仓库的工程实现,不构成任何投资建议;仓库里与实盘、券商连接相关的能力是否可用、能否合规使用,以你所在司法辖区的监管要求与券商协议为准。
本篇属于一个把开源个人交易 Agent 项目 Vibe-Trading逐层拆开讲的系列,整体地图见 Vibe-Trading 是什么:HKUDS 这个开源交易 Agent 项目的工程全景与边界;沿着这条线往下,还可以看 开源项目 Vibe-Trading 不装界面也能用:MCP 接入方式与边界 和 Vibe-Trading 的三层配置:结构 schema、环境变量 schema、路径与限额各管什么。