基于SSE的DSH Web Agent任务完成提醒实践 最近我把手上的 Agent 任务调度全部挪到了 DSH Web 上管理刚开始还挺高兴——所有 Agent 的会话、状态、日志终于在一个界面里看全了。但用了一周之后我发现自己陷入了一个特别蠢的循环任务丢进后台隔几分钟就切回浏览器刷一下会话列表看它跑完没有。一个任务还好要是同时挂了三个 Agent基本就告别专注了。有一次我跑了两个各需要四十分钟的数据清洗任务整个下午都在反复切标签页、按 F5、盯着状态字段发呆最后真正写完时我已经错过了十几分钟GPU 在空转时间在烧人的注意力还被切得稀碎。所以那周结束我就决定给 DSH Web 加一个任务完成提醒。Agent 在后台跑完任务的那一刻系统主动来告诉我而不是我跑过去问它。这篇文章就是我这次改造的完整记录包括需求分析、技术选型、后端事件接入、前端通知实现以及我后来实测踩到的几个坑。如果你也正在用 DSH 管理 Agent或者想在现有工具上做一套任务完成主动通知这篇应该能帮你省掉不少弯路。1. 先算一笔时间账守着会话列表到底亏了多少1.1 人肉轮询的一天以及被切碎的注意力我先描述一下改造之前我的一天。早上十点把代码生成 Agent 的任务提交到 DSH Web预计跑二十分钟左右。提交完我打开编辑器准备写点文档但心里始终吊着一件事——任务会不会已经跑完了是不是有报错需要我处理于是每隔五分钟我就忍不住切到浏览器看一眼会话列表里那条任务的状态。状态还是 running然后我又切回文档。人一旦开了这个头就很难停下来。十分钟后我又刷新一次还在 running。二十分钟后那次刷新任务已经 completed但我不确定它是二分钟前完成的还是刚刚完成的于是又去翻日志确认。一来二去真正写文档的时间没多少全耗在观察状态上了。这还不是最糟的。当多个 Agent 并行跑的时候会话列表里会有好几条任务状态各不相同有的在 pending有的在 running有一条可能已经 failed。我需要记住每条任务分别进行到哪一步判断现在切过去看值不值。这种多任务状态跟踪对注意力的消耗比单纯等待一个任务大得多。到了下午我脑子里像开了好几个标签页每个都在问跑完了吗。1.2 时间账单每次刷新 20 秒一个月两小时起步有人可能觉得不就是隔几分钟看一眼吗能花多少时间我一开始也这么想直到认真算了一笔账。单次去会话列表看状态这个动作看起来只要几秒但实际上包含了切窗口、等页面加载、扫一眼状态字段、判断下一步动作、再切回原工作。整个过程至少 15 到 20 秒。如果按照一天检查 20 次算就是六到七分钟。一个月二十二个工作日光是瞄一眼就花掉两个多小时。但这只是显性成本。真正贵的是隐性成本——注意力的中断与恢复。你正写一段逻辑突然切走去看任务状态回来以后要重新回忆刚才想到哪了重新进入状态这个过程通常要好几分钟。心理学上管这叫任务切换损耗切换得越频繁损耗越大。所以表面上每天只花了几分钟看状态实际上每天损失的专注时间可能有一两个小时。还有一个更实际的问题任务完成之后到你发现之间的这段空窗期计算资源一直开着但没在用。对于跑大模型推理或者长时间数据处理的任务来说这就是在烧钱。如果你跑的是按量计费的 GPU 集群任务早跑完了You还在傻等多出来的每一分钟都是实打实的费用。所以我后来跟同事说守着会话列表不是勤快是用战术上的勤奋掩盖战略上的偷懒——明明有更好的机制能解决却非要消耗自己的注意力去盯。1.3 DSH Web 原生能力盘点它其实就差一个主动推送为了不冤枉 DSH Web我先把它的原生能力盘点了一遍。DSH Web 的会话列表做得其实挺完善每条会话有 Agent 名称、任务状态、开始时间、结束时间、日志入口还能按状态过滤支持关键字搜索。我常用的是按状态筛选出所有 running 的任务把它们当成待办清单来看。但它缺的恰恰是最关键的那一步——主动通知。它是一个典型的单页应用SPA前端通过 REST 接口向后端拉数据呈现出来的所有状态都是你去看的时候的状态后端没有任何机制主动把状态变化推到浏览器。这个缺口的本质是拉模式和推模式的差别。会话列表做得再好也得靠人来刷新、来发现。而任务完成提醒要做的就是把人去找状态变成状态来找人。搞清楚这一点之后接下来的技术选型思路就清晰了我需要的不是一个更强的列表而是一条从 DSH 后端到浏览器的主动推送通道。2. 提醒方案选型轮询、WebSocket、SSE哪个配得上 Agent 任务2.1 三种方案的横向对比做主动推送摆在桌面上的无非三条路前端轮询、WebSocket、Server-Sent EventsSSE。我先列一下当时我做的对比表。方案实时性断线重连后端改造成本DSH 适配度前端轮询取决于轮询间隔有延迟天然无需处理零改造低重复请求浪费WebSocket毫秒级需自己实现高需维护连接状态低DSH 事件模型是单向的SSE秒级浏览器原生自动重连低HTTP 协议之上即可高任务事件本身就是单向流先说前端轮询。实现最简单setInterval 定时拉一次任务列表接口发现状态变成终态就弹通知。但问题很明显第一延迟不可控轮询间隔设短了浪费请求设长了发现不及时第二DSH Web 的任务列表是分页查询接口为了知道某一条任务的状态变化你需要反复拉整个列表这个成本很高第三判断任务是否完成的散落在前端每加一个页面都要写一套逻辑。轮询适合用来兜底不适合作为主方案。再说 WebSocket。实时性确实最强双向通信能做很多复杂交互。但它也带来了同等量级的复杂度后端要维护每个客户端的连接状态处理心跳、断线重连、消息积压、粘包拆包这些事。而且 WebSocket 是一个全双工协议但我们的场景其实是单向的——只有后端需要往浏览器推任务完成这一种消息浏览器几乎不需要往回发任何东西。杀鸡用牛刀后续维护成本还不低。最后是 SSE。它基于普通 HTTP服务端把事件按特定格式写成流浏览器端用 EventSource 接口消费。有几个让它在当前场景下格外合适的特性单向推送正好匹配任务状态变化 - 通知用户这个方向基于 HTTP不需要额外维护长连接协议浏览器原生支持断线自动重连。最妙的是DSH 的任务状态变化本身就是事件驱动的这跟 SSE 的模型天然契合。2.2 DSH 任务事件的实时性由谁决定选方案之前有必要先理解 DSH 底层的任务事件模型。我在命令行里执行dsh log --follow的时候看到的就是一串实时滚动的事件输出任务入队、Agent 开始执行、执行中产生中间日志、执行完成、异常退出。也就是说DSH 的后端在任务运行的每一个关键节点都会产生一条事件记录。这个模型非常关键。它意味着任务状态发生变化这件事在后端已经是实时的缺的只是一个能把事件流转发给浏览器的通道。DSH 的事件 API 是订阅—发布模型不是让你去查状态而是让你订阅变化。既然数据源已经事件化了那浏览器端最合适的技术方案自然就是 SSE——它本身就是为消费事件流设计的。我当时还确认了一件事DSH Web 在浏览器里保持会话列表的时前端已经在通过 REST 接口拉数据。如果我用轮询就是在这套拉取机制之上再加一层拉取等于双倍浪费。而如果用 SSE等于给 DSH Web 增加一条独立的事件通道不干扰原有的查询逻辑后端只是多了几个活跃的 SSE 连接负载很低。这一点最终说服了我。2.3 最终定案SSE 浏览器通知而不是硬上 WebSocket所以最终方案定为两条腿走路后端加一个很薄的 SSE 适配层负责订阅 DSH 任务事件流只把终态事件完成、失败、取消、超时过滤出来推送push给浏览器。前端用两种形态消化提醒页面可见时用页面内 toast页面不可见时用浏览器系统通知Notification API。为什么不把 WebSocket 拉进来因为我们的通信方向从头到尾是单向的。DSH 不会因为浏览器发来一句话就改变任务行为浏览器也不需要频繁地往后端发送指令。这种情况下用 WebSocket 属于主动给自己增加复杂度要处理连接生命周期、要写心跳、要处理多路复用出了问题还不好排查。SSE 把这些全部交给浏览器和 HTTP 栈处理心智负担小了一个量级。这套方案的额外好处是SSE 的断线重连是浏览器原生行为服务端只需要配合 Last-Event-ID 做断点续传就行这一点在后面踩坑部分我会详细讲。而 Notification API 也是浏览器标准能力不需要引入任何第三方 SDK纯原生就能实现。整体上整个提醒模块除了一个 Express 端点之外几乎没有引入任何新的运行时依赖。3. 后端事件接入把 DSH 任务状态翻译成 SSE 事件流3.1 先装能力dsh plugin --profile web add dshmarket 在做什么动手之前先要把 DSH Web 相关的扩展能力装好。我执行的是这条命令dsh plugin --profile web add dshmarket当时我对着dsh plugin --help翻了一会儿才完全搞明白这条命令的语义。dsh plugin是 DSH 的命令行插件管理入口--profile web表示把插件注册到 Web 这个配置档位下也就是 DSH Web 运行时读取的那套配置dshmarket是 DSH 插件市场模块装了它之后DSH Web 才能去插件市场里拉取并加载扩展组件其中就包括任务事件订阅与推送相关的扩展能力。如果你用的 DSH 版本命令命名跟我这里不完全一样不用慌先跑dsh plugin --help看一看大部分情况下只是命令措辞有差别。这个步骤做完之后DSH Web 配置文件里会多出 market 相关的配置段Web 端也能识别到新注册的插件能力了。3.2 任务状态机别把 completed 当成唯一的完成后端适配层写起来之前我先把任务的终态集合定义清楚。只盯着 completed 一个状态是不够的实际运行时任务可能走向好几种结局正常完成completed、执行失败failed、被用户取消cancelled、被超时机制杀掉timed_out。对了还有一种更隐蔽的情况Agent 进程崩溃后被 DSH 的守护机制标记成 failed但日志里不一定有明确的报错信息。所以我定义了一个终态集合const terminalStates new Set([ completed, failed, cancelled, timed_out, ]); function isTerminal(status) { return terminalStates.has(status); } function isSuccess(status) { return status completed; }这个状态机别嫌简单它是整个提醒系统的地基。如果不先想清楚哪些状态算结束、哪些状态要提醒、成功和失败怎么区分后面前端弹通知时你会被各种边界情况折磨。尤其是失败和取消提醒的文案和图标应该完全不一样用户看到任务已取消以为是系统自己取消的但实际上可能是他自己点的。另外一个我在做好第一版之后才补上的判断只认状态字段会踩假完成的坑。任务状态显示 completed但 Agent 的输出产物其实没写完或者最后的收尾步骤崩了没被监听到。这个我放在第五部分避坑环节详细说这里先埋个伏笔——状态机只是第一道闸后面还要有产物校验。3.3 一个足够薄的 SSE 适配层后端适配层我用了 Node.js Express通过 DSH 提供的 SDK 建立事件订阅。整体结构非常薄只有一件事订阅所有任务事件过滤出终态事件然后序列化为 SSE 格式写进 HTTP 响应流。const express require(express); const { DSHClient } require(dsh/sdk); const client new DSHClient({ profile: web }); const app express(); app.get(/api/task-events, async (req, res) { res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); res.flushHeaders(); // 每 30 秒发送一行注释防止空闲连接被网络设备断开 const keepalive setInterval(() res.write(: keepalive\n\n), 30000); const subscription client.onTaskEvent((event) { if (!isTerminal(event.status)) return; const payload { id: event.taskId, agentName: event.agentName, status: event.status, success: isSuccess(event.status), finishedAt: new Date().toISOString(), }; res.write(event: task_terminal\ndata: ${JSON.stringify(payload)}\n\n); }); req.on(close, () { clearInterval(keepalive); subscription.unsubscribe(); }); }); app.listen(3005);这里有几个容易被忽略的细节。第一SSE 响应头里的Content-Type必须是text/event-stream否则 EventSource 不会把它当成事件流解析第二flushHeaders()一定要先调让响应头发出去客户端才会认为连接已经建立第三Nginx 这类反向代理默认会断开空闲太久的连接所以需要每三十秒发一行注释符号开头的keepalive 数据注释行在 SSE 里不产生事件纯粹是给网络设备看的。这个适配层的最大优点在于它完全不碰 DSH Web 原本的数据查询链路。浏览器里该拉列表还是拉列表该查日志还是查日志。SSE 连接是一套完全独立的事件通道互不干扰。后续如果要调整哪些事件推到前端只需要改过滤条件前端不必跟着改。4. 前端提醒逻辑页面开着、切走、回来三种情况都要覆盖4.1 三段式状态toast、系统通知、回访横幅后端事件流通了接下来的问题是怎么让用户看到提醒。我梳理了一下用户使用 DSH Web 的三种典型状态每一种的处理方式都不一样页面可见用户正盯着浏览器用页面内 toast 气泡提示旁边会话列表里的对应条目自动滚动到可见位置。这种场景下不要弹系统通知太打扰。页面被切到后台或者浏览器标签页被隐藏用浏览器系统通知Notification因为用户此刻看的是别的窗口。用户离开期间任务完成了等他回来打开页面顶部显示一个横幅你有 3 个任务已完成其中 1 个失败点击可跳转到对应会话。为什么这么分核心逻辑是提醒的打扰程度要跟用户的注意位置匹配。页面开着的时候人本来就在看浏览器toast 就够醒目页面不在前台时唯一的触达通道就是系统通知而最容易被忽略的是第三种情况——用户走了很久才回来系统通知可能已经消失在通知中心里如果不做回访横幅这个提醒就彻底丢了。三段式设计还有个额外的好处不管用户处于什么状态他始终只会收到一种提醒不会遇到系统通知和页面 toast 同时弹出来的尴尬。4.2 Notification 权限申请别在页面加载时就弹浏览器系统通知绕不开权限申请。很多前端新手会在页面加载时直接调用Notification.requestPermission()这个做法我先劝退绝大多数浏览器会在用户没有任何交互的情况下拦截权限弹窗而且用户一进页面就被问是否允许通知第一反应是反感大概率会点拒绝。正确做法是把申请动作绑定到一个明确的用户意图上。我在 DSH Web 的右上角加了一个开启完成提醒的开关用户点这个开关的时候才发起权限申请。这样做的逻辑是用户主动点击开启说明他已经明确知道了这个功能要干什么权限申请的通过率会高很多。async function enableNotifications() { if (!(Notification in window)) { // 浏览器不支持系统通知降级为页面内横幅提醒 showFallbackBanner(); return; } const permission await Notification.requestPermission(); if (permission granted) { restoreReminderState(); } else { // 用户拒绝后依然保留页面内提示但不再弹系统通知 showFallbackBanner(); } }还有两个细节是当时踩完坑才补上的。一是 Safari 对权限状态的检测跟 Chrome 不太一样Notification.permission在旧版本里可能返回default但实际已经禁止过需要做兼容判断二是用户拒绝权限后不要反复弹申请框会招人烦。降级方案是退回页面内横幅提醒虽然触达能力弱一点但至少消息不会丢。4.3 事件接入、去重和通知风暴抑制前端核心逻辑是建立 EventSource 连接监听我们自定义的task_terminal事件const es new EventSource(/api/task-events); const pendingNotices new Map(); let flushTimer null; es.addEventListener(task_terminal, (e) { const task JSON.parse(e.data); pendingNotices.set(task.id, task); scheduleFlush(); }); function scheduleFlush() { if (flushTimer) return; flushTimer setTimeout(flushNotices, 3000); } function flushNotices() { flushTimer null; if (pendingNotices.size 0) return; const tasks [...pendingNotices.values()]; pendingNotices.clear(); if (document.visibilityState visible) { tasks.forEach((task) showToast(task)); } else { const successCount tasks.filter((t) t.success).length; const failCount tasks.length - successCount; new Notification(Agent 任务完成提醒, { body: ${successCount} 个任务已完成${failCount} 个任务失败, }); } updateReturningBanner(); }这段代码里藏着一个关键设计去重和通知风暴抑制。先说通知风暴抑制。你想想如果同时有五个 Agent 任务在相近的时间点跑完后端会连着推过来五条事件前端要是每条都弹一个系统通知用户的桌面会瞬间被五条通知刷屏这体验比不提醒还差。所以我把通知做了三秒聚合所有事件先进 Map三秒后统一结算一次。如果三秒内来了五条就合并成一条5 个任务已完成如果只来了一条就正常单条提示。再说去重。SSE 连接断开后浏览器会自动重连重连时如果服务端配合 Last-Event-ID 做了断点续传一些事件会被重新发送一遍。如果不按 task.id 去重用户就会看到重复通知。这里的 Map 天然带有去重能力——同一个任务 ID 再次进入时直接把之前的记录覆盖掉。这个设计非常省钱一行逻辑同时解决了聚合和去重。5. 实测效果、踩坑记录和后续演进思路5.1 三小时 Agent 任务实测从人肉守着到通知找我功能上线后我拿一个真实任务做了验证。那个任务是一个数据处理 Agent预计要跑三个小时左右。放在以前这三个小时我得时不时刷新会话列表什么正事都干不了。这次我把任务丢进后台之后直接去写周报了中间还开了两个会。下午六点四十二分我面前的电脑弹出一条系统通知任务已完成。我切回 DSH Web发现会话列表里那条任务的状态已经变成 completed点进去一看结束时间正好是通知弹出的时间。从 Agent 真正完成到通知到达我的桌面延迟目测不超过两秒。我又在旁边开了一个 HTTP 抓包工具观察过SSE 连接从建立到任务结束一直保持着长连接期间每三十秒一次注释心跳稳定没有断过。任务完成后约一秒服务端就把终态事件推送出来了。这个实时性是任何轮询方案都给不了的。后来一个月用下来我的实际感受是操作从主动查询变成了被动接收心理负担完全不一样。以前切回去看任务是带着焦虑去的现在收到通知是事情已经确定完成才去看。同样是查看一个是探查一个是确认体验差距非常大。5.2 排在踩坑榜前五的问题与修复方式这次改造也不是一次就成中间踩了五个比较有价值的坑按严重程度排个序。第一个坑是假完成。某次任务状态已经 completed但我打开产物目录一看文件缺了一半。查了好久才定位到问题Agent 主体任务跑完了收尾脚本却因为一个环境变量缺失静默失败状态没有被正确更新。这个问题的教训是提醒系统不能只信状态字段得加一道产物校验。后来我在终态事件推送前加了一个可选的探针逻辑——任务完成后检查输出目录是否包含预期的产物文件、日志是否有正常结束标记。校验不过的话提醒文案会标注完成但产物异常提醒用户重点关注。第二个坑是 SSE 空闲连接被网关断开。装好第一天任务跑了几分钟没结束SSE 连接就悄悄断了浏览器重连后 DSH 已经错过的事件也不会再推。根因是网络链路里某个反向代理默认的环境下 60 秒没有数据传输就断开连接。修复方式就是 3.3 节那段每三十秒一次的注释行保活。这个坑特别隐蔽因为它不影响任何页面功能只影响提醒用户很难发现。第三个坑是浏览器标签页休眠导致通知机制失效。现代浏览器为了省电会冻结后台标签页的 JS 定时器极端情况下连 SSE 回调都会延迟。这个问题的解法不是技术上的而是流程上的核心提醒优先走系统通知页面内 toast 只是辅助。系统通知是浏览器层面弹出的即使页面标签被冻结通知该弹还是会弹所以我在 4.3 里把页面隐藏时必须用 Notification写成了强制逻辑。第四个坑是 EventSource 重连后重复通知。这个我在 4.3 已经讲了用 Map 按任务 ID 去重解决。但如果你的后端没有实现 Last-Event-ID 断点续传那断线期间的任务完成事件会直接丢失而不是重复。我当时两个问题一起遇到排查了很久才分清楚是丢事件还是重复事件。第五个坑是 DSH 的 API 权限问题。我在一个没配好 Token 的 profile 下面联调后端订阅任务事件时一直报 403排查了半天才发现是当前用户只有 task:read 权限没有 event:subscribe 权限。DSH 的插件体系和权限体系是分开的装了 dshmarket 插件不代表权限自动全开记得去 check 一下当前账号的 Token 范围。5.3 下一步从提醒我到替我做这套提醒系统跑稳之后我又开始想更远的事情。现在实现的只是任务完成后通知我本质上是在告诉我该回来了干活吧。但真正的目标应该是任务完成后自动做下一步把人也从等待 决策里解出来。我目前已经在尝试的两件事。一是把 DSH Web 的提醒接入企业微信群的 webhook这样即使我完全没有开电脑手机上也能收到任务完成的消息二是结合 DSH 的定时调度能力让一个任务完成之后自动触发下一个任务比如数据处理完成之后自动启动模型训练训练完成再自动触发评测。到那个阶段提醒系统就变成一条 pipeline 的可视化节点而不再是终点。还有一个我很想做的方向把这一套提醒能力封装成 DSH 插件发布到 dshmarket 里。这样一来别人不需要像我一样自己写后端适配层一条dsh plugin --profile web add task-reminder就能装上全套提醒能力。这其实就是 dshmarket 存在的意义——把大家验证过的解决方案沉淀成插件让后来者少踩一遍我已经踩过的坑。我在这次改造里最深的体会是工具链的边界不是写死的。DSH Web 没给我提供提醒能力不代表我就要一直忍受人肉轮询。顺着已有的事件模型向外延展一小段整个使用体验就能上一个台阶。最后分享一个小技巧别把提醒做成默认全开建议加一个关注清单watchlist只对真正关心的会话开启提醒。否则手头 Agent 一多通知中心依然会被刷爆到时候你又会怀念那个安静守着会话列表的自己。