M10 · FastAPI + 前端闭环#
面试向开发文档:讲「为什么这么做、取舍在哪」,不写函数签名与代码细节。 配合
ROADMAP.md的 M10、refdocs/15阅读。
一句话#
M9 把主 loop 焊成了一条能从「一句购物意图」跑到「带理由的清单」的链路,但它只能在 Python 里 被调用。M10 给它套一层「对外接口」——FastAPI 后端 + React 前端——让用户在浏览器里发起任务、 实时看到 Agent(和它 fork 出的分身)在干活、拿到清单、下载产物、中途能取消。这是整个项目 最后一块拼图:把工程能力变成「人能用的东西」。
背景:M10 要补的其实只有「一层壳」#
M9 的 run_agent 已经是后台可跑、自带 AGUI 上报、自带记忆读写的完整入口。所以 M10 的活不是
写很多新逻辑,而是把这个入口暴露成 HTTP/WebSocket 接口,再写个前端去消费它。难点不在代码量,
而在三个「接缝处」的正确性:长任务怎么异步不阻塞、过程事件怎么实时且不丢、用户可控的输入
(thread_id、文件名)怎么不被拿来越权。
关键决策与取舍#
1. 长任务异步:HTTP 立刻返回,过程走 WebSocket#
一次购物任务要跨平台并行检索、比价、精挑,几秒到几十秒。如果 POST /api/task 同步等结果,
前端就得傻等一个超长请求,体验差还容易超时。所以 POST 立刻返回一个 thread_id,真正的任务
丢进后台异步任务跑;过程通过一条 WebSocket 长连接持续推 AGUI 事件给前端。HTTP 负责「发起」,
WebSocket 负责「过程」,各司其职。
代价:多了一套「任务表 + 取消」的状态管理(哪个 thread 对应哪个后台任务、怎么取消)。这部分 是 M10 真正写的新逻辑。
2. connect-first:根治「早期事件丢失」的竞态#
这是 M10 我主动更正 refdocs 的地方,也是最值得讲的一个点。
refdocs 给的顺序是「POST 起任务 → 拿到 thread_id → 前端再去连 WebSocket」。问题在于:任务一旦
起来,run_agent 第一件事就是上报 session_created,紧接着是 planner 开始等早期事件。而这时
前端可能还没把 WebSocket 连上——这些早期事件因为「找不到连接」被直接丢掉,用户的事件流里就缺了
开头几条。这是个典型的「先开火再瞄准」的竞态。
我把顺序倒过来,叫 connect-first:前端先在本地生成 thread_id → 先连 WebSocket → 等后端回
一个「连接已登记」的确认(ws_ready)→ 才 POST 发任务。这样任务上报第一个事件时,连接保证
已经在路由表里,一条都不丢。
为什么不选另一条路——在连接层加「事件缓冲 + 连上就回放」?那也能解决,但要给 M8 的连接管理器 加缓冲、还要处理「离线脚本/没人连的任务」缓冲无限涨的内存问题,复杂且有坑。connect-first 是 零缓冲、零改动 M8、把竞态从根上关掉——用「调整握手顺序」这种便宜手段解决问题,而不是堆基础 设施。这也是「把改动做在正确的深度」的一个例子。
3. 安全:用户可控输入的两个越权口子#
前端能传两样用户可控的东西进文件系统:thread_id 和上传文件名。最初我只对文件名做了
safe_join,但代码评审(关三)抓出一个我自己漏的点——thread_id 也是用户可控的。
会出什么乱子:下载接口里 输出根目录 / thread_id 直接拼,如果 thread_id 塞个 ..(编码成
%2e%2e 绕过路由),就能在文件名那道 safe_join 生效之前先跳出输出目录——等于守门的人
守错了门。上传更危险,thread_id 来自表单、完全自由,../../etc 能直接在目录外建文件夹写文件。
修法:对 thread_id 也走 safe_join 校验,逃逸就 400。这正好印证项目的硬约束「文件路径一律
safe_join」——不是写文件名时才用,是所有用户可控的路径片段都得用。
4. 收尾产物落盘:下载接口得有东西可下#
ROADMAP 要求「可下载产物」,但 M9 的 shopping_summary 只把结果返回给模型/前端,不落任何文件,
下载接口就成了空壳。所以 M10 在任务收尾把清单写成 summary.md(人读)+ result.json(机器读)
两份产物落进会话目录。
这里有个评审抓出的细节:summary.md 该写什么?最初我写的是「模型最后一句话」(final_text)。
但模型收尾时经常会在结构化清单之后再补一句寒暄(「希望对你有帮助」),那这句寒暄就成了
下载到的全部内容——清单本体反而丢了。改成优先取 shopping_summary 结构化输出里的清单字段,
只有闲聊兜底(根本没清单)时才退回 final_text。这是「数据从哪取最可靠」的判断。
5. 商品卡:让结构化结果走事件流,而不是只给文本#
前端要画「商品卡」(每件商品的平台、到手价、选购理由),就需要结构化数据,光有一段 markdown
文本是画不出卡片的。所以我给 task_result 事件加了个可选的 items 字段,把 shopping_summary 精挑
出的结构化商品随事件一起下发——「文本清单给人读、结构化 items 给机器画卡,一条事件两用」。这个
改动对 M8 的老调用点完全兼容(不传 items 就跟以前一样)。
前端:重点不是 UI,是「怎么消费事件流」#
前端用 React + Vite,做全了 ROADMAP 列的对话框、事件流可视化、商品卡、长期偏好面板、产物下载。
但真正的工程含量集中在那个驱动 WebSocket 的状态机上(connect-first 握手、按事件类型分发渲染)。
UI 样式是次要的、可替换的;前后端的协议才是锁死的东西——前端只认 AGUI 事件的 event 字段
做分发,完全不关心后端内部怎么生成。
偏好面板需要一个「读用户偏好」的接口,而 refdocs 的五个接口里没有——这是我按 ROADMAP 需求补的
第六个接口(GET /api/preferences),直接复用 M7 的 Store。后续又加了 GET /api/history(M10.1)
和 GET /api/task/{tid}/inflight(ENH-D §5),总计 8 个 HTTP/WS 端点。
评审(关三)抓出并修掉的问题#
这一轮代码评审收获很大,8 路 finder 抓出几个我自己没注意的真问题,都修了:
- thread_id 路径穿越(见决策 3)——最该修的安全洞,下载和上传两处都补了
safe_join。 - 任务表按 key 盲删的竞态:同一个 thread_id 连发两次,旧任务被取消后它的清理逻辑会晚几拍 才跑,那时新任务已经占了同一个 key——盲删会把活着的新任务误删掉,导致它再也取消不了。 改成「按对象身份删」(只有登记的还是我自己才删),跟 M8 连接管理器防重连误删是同一手法。
- 前端 WebSocket 生命周期:解析非 JSON 帧会炸回调、连接异常断开没有兜底会让界面永远卡在
「运行中」、组件卸载不关连接会泄漏——逐个补了 try/catch、
onclose兜底、卸载清理。 - 前端 XSS 面:清单 markdown 可能夹带 web_search 回来的内容,直接渲染等于给
<img onerror>这类注入开门。加了渲染前转义裸 HTML 的最小消毒(零依赖)。 - WebSocket 连接只在正常断开时注销:其他异常会让死连接残留在路由表里,挪到
finally兜底。
后续增强(M10 之后落地)#
6. 第七、八个端点 + token 显示 + 切对话不打断#
M10 原始交付了 6 个端点(task / ws / cancel / files / upload / preferences)。后续又加了两个 + 前端增强:
GET /api/history/{tid}(M10.1):返回该 thread 的逐轮对话历史(turns.json),供前端回看。GET /api/task/{tid}/inflight(ENH-D §5 后续增强):查某 thread 是否仍有任务在后台跑——在跑则连同 query 原文 + 当前这轮已发生的事件一起回吐。这是「刷新 / 切回对话自动续看」的后端支点。后端用TaskHandle(task + query)替代原来的裸asyncio.Task,多存一个 query 是为了在前端刷新后本地已无上下文时仍能回吐提问原文。去重保护:task_result已进事件流但任务表还没摘的瞬时窗口,以「流末为终结类事件」为准判已结束,避免与历史轮重出。- token 用量显示:
task_result事件新增tokens字段(全树记账),前端在每轮右下角与「用时」并排显示。hover 看输入/输出/成本拆分。tokens 也落进 turns.json,回看还原。 - 切对话不再隐式取消任务:
teardownActive从「切走 → cancel 在跑任务」改为「只断 WS 订阅、不取消」——任务与连接解耦的语义更干净。切走再切回靠 inflight 续看、真要终止走显式「取消」按钮。
总计 8 个 HTTP/WS 端点:POST /api/task / WS /ws/{tid} / POST /api/task/{tid}/cancel / GET /api/files/{tid}/{name} / POST /api/upload / GET /api/preferences/{uid} / GET /api/history/{tid} / GET /api/task/{tid}/inflight。
没做的(诚实标注)#
- 真实端到端 demo 暂缓:本 worktree 的数据/模型还不足以稳定跑一次真链路(缺 RAG 语料等)。
所以功能验收(关二)走的是协议级端到端——
examples/10_server_e2e.py起真 uvicorn,但把主 loop 换成一个形状一致的桩,验证 connect-first 不丢事件、商品卡下发、产物下载、任务取消全通。等数据 补齐,把桩换回真实run_agent,这个脚本一字不改就是真链路 demo。 - 生产化能力:鉴权、限流、多租户隔离、上传类型白名单、大文件流式校验——refdocs 明确说这些不在 课程主线,M10 只做了能做的最小安全项(路径穿越、大小上限)。上传的大小校验是「读进内存再判」, 挡的是写爆磁盘、挡不住读爆内存,这点在代码里诚实标注了。
- 阻塞 IO:收尾写产物用的是同步文件写(产物只有几 KB,可忽略);上传落盘因为可能到 10MB, 挪到了线程池不卡事件循环。
一句话总结这一章在整条链路里的位置#
前 14 章把「AgentLoop + 多 Agent fork + 向量召回 + 上下文压缩 + 长期记忆 + AGUI」装进了同一条业务 链路,M10 给它接上「人能用的入口」。跑通后,用户在浏览器输一句话,就能亲眼看到 4 个分身同时 在不同平台搜货、合流比价、精挑、给清单——这就是前面所有工程铺垫在最后汇聚成的那段事件流。