最近我把手上的 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 Events(SSE)。我先列一下当时我做的对比表。
| 方案 | 实时性 | 断线重连 | 后端改造成本 | 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 一多,通知中心依然会被刷爆,到时候你又会怀念那个安静守着会话列表的自己。