ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

给 Agent 任务接上实时提醒:SSE + 桌面通知实战

给 Agent 任务接上实时提醒:SSE + 桌面通知实战 这活儿我早该干了。上周我把一个数据清洗的 Agent 任务丢进 DSH 跑预估要 20 分钟。干等着嫌浪费时间切去写别的需求又总忍不住隔几分钟切回 DSH Web 扫一眼会话列表看它是不是卡在某个工具调用上。最后任务跑完了我是 15 分钟之后才注意到的白白浪费了半条命。事后我就在想Agent 都进化到能自己规划、自己调工具了怎么任务跑完通知我一声这种最基础的能力反而没有于是就有了这篇——给 DSH Web 接一个任务完成提醒。核心思路不复杂任务状态实时推送 浏览器桌面通知配合浏览器标签页标题和声音兜底。这篇文章会完整走一遍方案选型、后端推送通道、前端 Notification API 接入以及怎么做成 DSH 插件顺手扔到 dshmarket 里适合所有用 DSH 命令行和 Web 控制台跑 Agent 任务的开发者。1. 先把需求说清楚这个提醒功能到底在解决什么1.1 真正的痛点不是慢而是不可感知先说结论Agent 任务跑得快不快是另一回事真正让人难受的是任务状态完全不可感知。DSH Web 的会话列表是拉取式的你要主动刷新页面、主动点进某个会话才能看到 Agent 执行到哪一步。这种模式放在交互会话里没问题毕竟人在现场但一旦你把 Agent 当作后台任务跑问题就出来了——你根本不知道它什么时候结束。更麻烦的是很多 Agent 任务不是单纯快慢的问题而是会终结于两种完全不同的状态正常完成或者某个中间环节挂掉。如果是正常完成你希望立刻去拿产物如果是失败你希望立刻止损。这两种状态如果没有主动通知你都得靠人工轮询去发现而轮询本身就是一种隐性成本。我在实际使用中把这种成本分成三档任务分钟级完成你盯着会话列表也许还能忍任务十分钟以上你一定会切出去做别的事任务超过半小时回来看到结果时大概率已经浪费了一段时间。也就是说任务耗时越长不可感知带来的浪费越大。这个提醒功能真正要解决的问题是把不知道什么时候结束变成结束的时候一定会知道我。1.2 提醒链路的目标定义动手之前先定目标不然容易做成一个花架子。我给自己列了几条硬性要求任务结束时必须主动告诉用户而不是依赖用户来问。通知要能区分成功完成和异常失败因为后续动作完全不同。用户切走标签页、甚至切到别的应用时也要有效得有桌面通知兜底。不能影响任务的执行链路通知通道必须和任务主流程解耦。要能一键开启或关闭不能让通知变成扰民弹窗。基于这几条整个功能可以拆成三块来看后端负责把任务状态推出来前端负责把状态收进来浏览器权限能力负责把状态通知到人。三个环节环环相扣但每一块又相对独立这也为后面做插件化埋了伏笔。2. 方案选型轮询、SSE、WebSocket 的三方对比2.1 浏览器能用的通知机制先厘清边界在聊实时通道之前先看浏览器本身提供了什么通知到人的能力。现代浏览器基本标配 Notification API也就是桌面通知。它的工作方式是网页申请通知权限用户点允许之后网页就能在任意时刻弹出一条系统级的通知气泡哪怕浏览器不在前台也能看到。但这里有个关键限制Notification 本身只是展示层它不负责传输数据。要让桌面通知在任务完成的瞬间被触发前提是网页能在任务完成的那一刻拿到任务完成这个信息。所以核心问题不是怎么弹通知而是怎么高效地把状态从后端送到前端页面。这就是典型的实时通信问题。我整理了一下可选的路无非三条前端轮询、SSEServer-Sent Events、WebSocket。三者的区别从名字就能看出大概——轮询是前端反复问完了吗SSE 是后端持续往前端推我这边情况变了WebSocket 则是前后端建立一条全双工通道随便聊。2.2 三条技术路线的直观对比直接上对比表省得绕方案实现成本数据方向断线重连典型适用场景我的评价前端轮询最低setInterval 就行单向请求天然就有状态变化不频繁、可容忍分钟级延迟简单但不解决痛点因为通知不够即时SSE低后端一个长连接后端到前端单向浏览器自动重连服务端状态持续推送、通知类场景最贴合任务状态推送WebSocket中需要维护协议双向需要自己实现聊天、协同编辑等双向交互功能过剩杀鸡用牛刀为什么轮询是最容易想到但我不建议的方案因为在任务完成提醒这个场景下轮询的反直觉之处在于任务没完成的时候你每次请求都在浪费任务完成的瞬间你反而可能因为轮询间隔而错过即时通知。假设你设置 5 秒轮询一次最坏情况下你会在任务完成后整整 5 秒才感知到而如果 Agent 在任务完成后做了某些清理工作5 秒足以让现场错过很多信息。WebSocket 则是反过来双向能力在这里几乎用不上——任务执行途中的状态明细走的是什么通道还是 DSH 原本会话列表那套逻辑。任务完成提醒需要的信息量极小无非一个任务 ID、一个状态、一个结果摘要。为这点消息维护一条 WebSocket 长连接还要操心心跳、重连、鉴权属于明显过度设计。2.3 为什么最终选了 SSE以及它带来的额外收益SSE 的定位正好契合这个场景它是服务端到客户端的单向推送协议基于普通 HTTP 长连接浏览器原生支持 EventSource 对象断线了还自动重连。这几条特性几乎就是为任务状态推送量身定做的。这里有个值得展开的点SSE 的自动重连机制在任务执行中网页刷新这种常见场景下价值很大。我用我实际的实现为例EventSource 一旦和服务端断连浏览器会默认按几秒间隔自动重新建立连接。重连过程中后端只要在任务状态通道里保留了最近一次状态快照前端重连后第一时间就能拿到任务是否已完成不需要用户手动刷新页面。这个特性让提醒的可靠性高了一个级别。另一个容易被忽略的收益是消息格式。SSE 的消息格式是文本协议每一条消息可以带 event 字段和 data 字段天然适合做心跳消息、状态更新、任务完成这几类事件的区分。聚合到一起选 SSE 的决策就清晰了它在复杂度、浏览器生态、断线重连这三个维度上全部命中了这个场景的需求。3. 后端实现给任务状态加一个实时推送通道3.1 先约定一个极简任务状态模型后端改造前先约定任务状态的数据结构。DSH 本身肯定有一套任务状态记录这里我建议不要在业务层做大改动而是单独建立一个状态推送层的约定。因为提醒功能本质是一个旁路系统插在任务生命周期外面尽量不要和任务执行器耦合。我用的状态模型很简单就四个字段task_id: 全局唯一的任务标识status: 取值统一为pending、running、done、failedfinished_at: 状态变为终态时的时间戳summary: 一句话摘要成功时是产物路径或结果概要失败时是错误信息为什么不在推送消息里塞过多字段因为通知面板的空间有限桌面通知简介里放太长内容只能是灾难。用户看到通知后想了解详情自然会点进 DSH Web 去查完整会话。推送通道只承担提醒职责不承担详情展示职责。为了让任务运行中的中间状态也能被感知running状态可以附带当前 Agent 正在执行的工具名比如tool: read_file这样用户即使不打开会话列表也能通过标签页上灰掉的小字了解任务大概走到哪了。这一步不是必须但实测很提升体验。3.2 后端 SSE 端点的核心实现后端我用的是 FastAPI 风格的异步实现逻辑上任何支持流式响应的后端都可以平移。核心是维护一个task_id - 订阅者队列的映射当任务状态变更时把消息写进对应队列SSE 流再把它吐给前端。from collections import defaultdict import asyncio, json from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse app FastAPI() # task_id - set[asyncio.Queue] subscribers: dict[str, set[asyncio.Queue]] defaultdict(set) app.get(/api/v1/tasks/{task_id}/events) async def task_events(task_id: str): # 每个前端页面建立一个属于自己的队列 queue: asyncio.Queue asyncio.Queue(maxsize100) subscribers[task_id].add(queue) async def event_stream(): try: # 连接建立后先补发一条当前快照避免重连后漏状态 snapshot get_task_snapshot(task_id) if snapshot: yield fevent: snapshot\ndata: {json.dumps(snapshot)}\n\n while True: try: message await asyncio.wait_for(queue.get(), timeout15) yield fevent: {message[event]}\ndata: {json.dumps(message[data])}\n\n except asyncio.TimeoutError: # 15秒没有新消息则发一行注释格式心跳保持连接不被切断 yield : keep-alive\n\n finally: subscribers[task_id].discard(queue) if not subscribers[task_id]: del subscribers[task_id] return StreamingResponse(event_stream(), media_typetext/event-stream)发布状态变更的入口函数是另一个独立的函数在任务完成或失败时被调用async def publish_task_event(task_id: str, event: str, data: dict): if task_id not in subscribers: return # 队里最多放100条超出时直接丢弃最旧防止慢消费者拖垮任务进程 for queue in list(subscribers[task_id]): if queue.full(): try: queue.get_nowait() except asyncio.QueueEmpty: pass await queue.put({event: event, data: data})这里有一个很容易踩的坑asyncio.Queue的默认行为是如果队列满了put会一直阻塞。如果前端迟迟不消费消息后端任务发布点会被卡住严重时甚至把任务执行器拖停。所以我在publish_task_event里做了一个满了就丢最旧的兜底提醒功能可以丢消息但绝不能把任务执行链路拖垮。3.3 心跳、超时与异常兜底的细节SSE 连接维持有几个细节非得实测过才明白。第一代理层空闲超时是最常见的断连因素。很多网关默认会切断长时间没有数据流量的 HTTP 连接所以我在循环里加了 15 秒的: keep-alive心跳注释行。SSE 协议里以单个冒号开头的行是注释浏览器会自动忽略它但这一行足以让代理层认为连接是活跃的从而避免被空闲超时掐断。心跳间隔建议设置在代理超时时间的一半以下。第二连接断开后队列里的残留消息要趁着finally里清掉。我在finally里做了discard和空集合清理防止订阅集合泄漏。如果不清理断线用户的队列会一直挂在subscribers里任务状态发布时就会往一个没人消费的队列里写消息越积越多最后把内存吃掉。第三任务完成事件的发布必须放在任务状态事务提交之后。如果你在 Agent 任务回调里先发布了已完成紧接着任务本身的收尾步骤失败那用户收到的是假消息。我这边是在任务记录真正落库之后才调publish_task_event宁可在通知准确性和即时性之间让位给前者。4. 前端落地从 EventSource 到桌面提醒4.1 监听逻辑与状态解析前端这块DSH Web 的界面是 Vue 技术栈我就按 Vue 组件的方式来写但核心逻辑用原生 EventSource方便各位平移到 React 或者原生 JS。先说我建立监听模块的几个设计原则每个任务页面只建立一个 EventSource 连接一个页面同时看多个任务时用统一的TaskNotifier管理器管理多条连接。EventSource 的每条消息按event字段分发避免在onmessage里用一大串 if 判断。页面卸载时务必调用close()不然标签页切走之后隐藏页面的连接还在白白占资源。核心监听逻辑示意class TaskNotifier { constructor() { this.esMap new Map(); } watch(taskId, handlers {}) { if (this.esMap.has(taskId)) return this.esMap.get(taskId); const es new EventSource(/api/v1/tasks/${taskId}/events); es.addEventListener(snapshot, (e) { const state JSON.parse(e.data); this.handleState(taskId, state, handlers); }); es.addEventListener(done, (e) { const state JSON.parse(e.data); handlers.onDone?.(state); }); es.addEventListener(failed, (e) { const state JSON.parse(e.data); handlers.onFailed?.(state); }); es.addEventListener(running, (e) { const state JSON.parse(e.data); handlers.onRunning?.(state); }); es.onerror () { // EventSource 断线会自动重连这里主要做用户提示 handlers.onReconnecting?.(); }; this.esMap.set(taskId, es); return es; } unwatch(taskId) { const es this.esMap.get(taskId); if (es) { es.close(); this.esMap.delete(taskId); } } }这里要注意一个快照处理逻辑。前面后端代码里说过连接建立后会立刻补发一条snapshot消息。为什么需要它因为如果用户在任务跑完后才打开页面EventSource 连上时可能已经错过了done事件。有了快照机制前端一打开就能立即知道任务当前状态该弹通知弹通知该恢复界面状态恢复界面状态。对页面刷新导致遗漏提醒这个场景是刚需。4.2 Notification API 的权限处理与桌面提醒触发拿到任务完成事件之后真正弹桌面通知的是 Notification API。这块有两个必须处理的现实问题权限申请时机和权限被拒的降级方案。先看权限申请时机。很多开发者的直觉是页面一加载就调用Notification.requestPermission()这其实是个很差的交互——用户还没明白这个网站会用通知干什么就收到了权限弹窗大概率会点拒绝。我的做法是首次有任务被监听时弹一次权限申请并在页面里加一句说明任务完成时进行桌面提醒可在设置中选择关闭。async function ensureNotifyPermission() { if (!(Notification in window)) { // 当前浏览器不支持桌面通知 return unsupported; } if (Notification.permission granted) return granted; if (Notification.permission denied) return denied; // 首次使用时主动请求权限 const result await Notification.requestPermission(); return result; } function showTaskDoneNotification(task) { if (Notification.permission ! granted) return; const title task.failed ? DSH 任务失败${task.task_id.slice(0, 8)} : DSH 任务完成${task.task_id.slice(0, 8)}; const body task.failed ? 错误原因${task.summary} : 耗时 ${task.durationSeconds}s产物${task.summary}; const notification new Notification(title, { body }); // 点击通知时聚焦到 DSH Web 窗口 notification.onclick () { window.focus(); notification.close(); }; }这里有一个必须说明的边界情况Notification构造函数在设置onclick时如果用onclick属性赋值需要注意某些移动端浏览器不支持点击事件冒泡。不过 DSH Web 主要跑桌面端这个影响不大。另一个要命的问题是用户把浏览器通知权限设成了拒绝Notification构造函数直接就会抛异常。所以每次调用前判一下Notification.permission是必须的不能只靠申请时的返回值判断。4.3 标题栏与声音的兜底提醒桌面通知是主力方案但不能是唯一方案。我在实际使用中遇到过一个很尴尬的场景公司电脑锁屏状态下Chrome 的桌面通知能不能正常弹取决于系统的专注助手设置有时候通知被折叠进通知中心根本看不到。所以我加了两层兜底。第一层是浏览器标签页标题联动。任务完成后把document.title临时改成带标记的文案比如[完成] DSH Web5 秒后再恢复原标题。这样即使桌面通知被系统拦截只要切回浏览器扫一眼标签页也能立刻感知状态。实现非常简单function flashTabTitle(statusText) { const originalTitle document.title; document.title [${statusText}] ${originalTitle}; setTimeout(() { document.title originalTitle; }, 5000); }第二层是声音提示。DSH Web 在任务启动时给用户一个开关选项打开后任务完成时会播放一小段短促的提示音。声音的实现用了 Web Audio API 直接合成不需要引入任何音频文件function playDoneChime() { const ctx new (window.AudioContext || window.webkitAudioContext)(); const oscillator ctx.createOscillator(); const gain ctx.createGain(); oscillator.frequency.setValueAtTime(880, ctx.currentTime); oscillator.frequency.setValueAtTime(1320, ctx.currentTime 0.15); gain.gain.setValueAtTime(0.3, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime 0.5); oscillator.connect(gain); gain.connect(ctx.destination); oscillator.start(); oscillator.stop(ctx.currentTime 0.5); }两层兜底加上桌面通知就构成了一个从强到弱的提醒梯度桌面通知最醒目标签页标题次之声音作为最后的听觉提示。我在实际使用中标签页标题这一层的重要性被严重低估——它几乎不会打扰任何人但信息传达特别有效。5. 以插件方式接入 DSH 生态5.1 把提醒能力从一次性代码变成可分发插件单独给 DSH Web 加个功能不难难的是让这个能力可以复用、可以分发。DSH 有一个插件系统插件可以通过dsh plugin子命令安装也可以从 dshmarket 的远程仓库拉取。比如下面这个命令就是把 dshmarket 插件仓库挂到当前配置的 Web profile 下dsh plugin --profile web add dshmarket这个命令做的是把远程插件索引加入本地的 Web profile 配置。加上之后后续再装具体插件时DSH 就能从 dshmarket 仓库解析到插件的名称和版本。整个流程非常像包管理器。我要分享的经验是任何看起来简单的能力一旦你想在团队内推广就值得花半小时做成插件。不然每个同事遇到同样需求后都会各自复制一份代码最后维护成本全算在你头上。做成插件之后发布的流程大概是写好插件元信息文件和实现文件验证通过后打到 dshmarket 仓库。团队内其他成员只需要一行dsh plugin --profile web install plugin-name就能装好DSH Web 启动时会自动加载插件在首选项里出现提醒开关。5.2 插件目录结构与提醒模块的组织方式插件内部的组织方式我这边遵循 DSH 的插件约定目录大致如下task-notifier/ ├── plugin.toml # 插件元信息名称、版本、入口 ├── notifier/ │ ├── __init__.py │ ├── events.py # 任务状态事件解析 │ ├── notify.py # Notification 权限与弹窗 │ ├── sound.py # 声音提示 │ └── ui.py # 设置项注入与菜单集成plugin.toml是插件的入口描述里面声明了插件名称、版本、以及 DSH Web 加载时要注入的模块列表。重要的是里面有一个配置项声明可以把任务完成提醒这个开关自动注册到 DSH Web 的用户设置区[plugin] name task-notifier version 0.1.0 entry notifier [config] enable_desktop_notify { type bool, default true } enable_tab_title_flash { type bool, default true } enable_sound { type bool, default false }插件加载时notifier/__init__.py里注册一个全局的after_task_finish钩子任务完成时由 DSH 调度器调用。插件本身不需要侵入 DSH Web 原有的任务执行代码只要注册钩子主程序在合适的时机把任务对象传给钩子函数即可。这也是我认为这个提醒功能最优雅的落地方式不改动核心代码不引入强依赖纯旁路能力随时可卸。6. 实战中踩过的坑与排查实录6.1 Notification API 在 HTTP 环境下的权限限制这是我在本地联调时踩的第一个坑。桌面通知的权限机制里有一条硬规则非安全上下文也就是 HTTP 环境下未必能弹出浏览器通知。Chrome 从某个版本开始把 Notification API 限定在 secure context 中才能使用http://localhost作为本地开发地址是例外但如果你通过局域网 IP 访问 DSH Web 或者部署在纯 HTTP 的内网服务上会发现Notification.permission永远是denied申请权限的弹窗根本不出现。排查方法很简单在浏览器控制台执行window.isSecureContext如果是false基本可以断定是这个原因。解决办法自然是用 HTTPS 部署 DSH Web或者用反向代理把 HTTP 升级成 HTTPS。如果你的 DSH Web 跑在内网环境且无法上证书那就只能依赖标签页标题和声音这两层兜底桌面通知直接降级为不可用。我在落地的时候顺手在设置页加了个状态提示告诉用户当前环境是否支持桌面通知免得他们一直开开关发现没反应。6.2 EventSource 自动重连引发的重复提醒前端实现完成后的第二个坑来自 EventSource 的过度勤劳。浏览器在连接断开时会自动重连这本是好设计但搭配后端快照机制后出现了一个奇怪的现象用户明明已经看过了任务完成的桌面通知但几秒后浏览器又弹了一次因为 EventSource 重连成功后收到了后端补发的snapshot消息而快照里的 status 是done。这个问题必须在客户端做去重而不是依赖后端状态。我的做法是在TaskNotifier里维护一个completedTasks集合已经处理过done事件的任务 ID 直接忽略后续同类消息handleState(taskId, state, handlers) { if (state.status done this.completedTasks.has(taskId)) { return; } this.completedTasks.add(taskId); handlers.onDone?.(state); }这个集合在标签页的生命周期内有效刷新页面后清空此时如果任务还在已完成状态快照机制反而能正确地补上提醒。一进一出去重和防漏刚好平衡。6.3 后台标签页的节流对提醒逻辑的影响浏览器对后台标签页有一个节流机制尤其影响定时器、音频播放等操作。我在实现声音提示时发现一个有意思的现象标签页完全没有被切走时AudioContext是直接就绪状态一旦标签页切到后台一段时间AudioContext的状态会变成suspended此时调用playDoneChime不会报错但也没有声音。解决办法是提前创建AudioContext并把它挂到页面任意一次用户交互手势里。我写了个初始化函数在用户点击运行任务按钮时调用一次让 AudioContext 在用户手势的上下文里恢复为running状态function ensureAudioContext() { const ctx new AudioContext(); if (ctx.state suspended) { ctx.resume(); } return ctx; }另外一个容易被忽略的细节是后台标签页的定时器会被节流到至少 1 秒一次这会影响标签页标题的 5 秒恢复逻辑。实测下来切到后台后setTimeout的 5 秒可能变成 10 秒甚至更久标题闪烁时间被拉长。这个一般不影响体验但如果你的产品对标题回复时机有要求建议用visibilitychange事件在页面回到前台时立刻恢复原标题。6.4 常见问题排查速查表症状可能原因快速处理桌面通知完全不弹非安全上下文导致 Notification 权限被禁用控制台查window.isSecureContext改用 HTTPS 部署权限弹窗没有出现浏览器在过去已经拒绝过权限手动去浏览器站点设置里重置通知权限任务完成提醒重复弹EventSource 重连后快照消息再次触发客户端加completedTasks去重集合任务完成但没有任何提醒订阅连接被代理空闲超时切断检查后端心跳间隔是否小于代理超时的一半标签页标题不恢复后台标签页定时器被节流用visibilitychange事件恢复标题声音提示无声AudioContext 被浏览器挂起在用户手势里创建并恢复 AudioContext7. 收尾把这个能力沉淀下来之后的一点体会做完这个提醒功能之后我最大的体会是Agent 工具链里任务执行器和人之间的最后一公里往往被严重忽略。DSH 已经把 Agent 的执行、调度、会话管理做得足够好但如果你还是靠人肉刷新去感知任务状态那整套系统在体验上就是断的。这次我做的不过是一条 SSE 推送加一个 Notification 弹窗但使用体验上的提升是质的——我再也不用提心吊胆地反复切标签页了。最后分享一个更高阶的用法因为提醒事件已经变成了一个钩子后续可以在这个 Hook 后面继续扩展。比如任务失败时自动把错误摘要同步到团队的即时通讯群或者把多个任务完成事件聚合到一个面板里做批量处理。提醒这件事本身只是个开始真正有价值的是它把任务状态事件化之后带来的自动化空间。哪怕你暂时不做插件分发只为自己的 DSH Web 加上这一个小能力我建议你也按事件化的思路来设计因为接下来你大概率会想在这个事件上挂更多东西。
返回列表