1. 为什么一个"牛马"非要写这个推送插件
1.1 从一次"跑了一夜没收到结果"说起
我习惯把一些耗时的离线任务丢给DeepSeek API去做,比如批量给商品写标题、批量做文本分类、用长上下文梳理会议纪要。这一类任务的特点是:单个调用不慢,但一批下来要跑很久。头一天晚上八点把任务挂上,算了下预计两小时跑完,结果另一边排队比想象中激烈,实际跑到了凌晨三点。我十二点就睡了,早上七点醒来看手机,什么提示都没有,还得打开电脑连上终端,敲命令看日志,才知道任务早就结束了。更难受的是,有些任务在凌晨就失败了,剩余的重试还没跑,我却浑然不觉,等于整个上午的排期都被耽误了。
这种状态持续了很长一段时间。你说我懒?也不是。我试过睡前设闹钟,半夜爬起来看进度,但是跑了几次发现,任务完成时间完全不可控,有时候排队一小时,有时候排队五小时,闹钟根本没法卡点。也想过用harness自带的日志监控,但日志是给人看的吗?除非我时刻盯着屏幕,否则它不会主动告诉我任何事。
1.2 "牛马"式需求清单
"牛马"这个词最近常被用来形容被工作反复折腾、没有自主时间的打工人,我深有感触。我的需求其实很简单:最重要的事情跑完或者挂掉的时候,能有一个东西主动推到我手机上,让我该睡觉睡觉,该干嘛干嘛。这个"东西"最好还能告诉我任务到底跑完了没有、花多久、失败了多少条,而不是逼我在半夜还去翻日志。需求落下来就是一份清单:
- 任务开始时最好能轻推一下,让我知道"开跑了";
- 任务完成时要推送关键结果,包括总耗时、成功率、耗时分布;
- 任务失败时要推送失败原因和可用的错误摘要;
- 一批并发任务收尾时,把结果合并成一条通知,而不是刷屏;
- 推送动作不能影响主任务的性能,更不能因为网络问题把harness主线程卡死。
基于这些,我写了dsh-notify-push。它是DeepSeek Harness的一个插件,Harness是负责调度和执行DeepSeek任务的一个外壳程序,插件机制允许你在任务的生命周期里挂自己的逻辑。dsh-notify-push就是那根"电话线",把harness里发生的重要事件翻译成一行人话,发到企业微信、飞书、钉钉或者任何一个Webhook上。至少在用上它之后,我半夜被手机吵醒的次数少了很多,早上到工位不再是两眼一抹黑。
2. dsh-notify-push的设计思路
2.1 在Harness插件机制里找对钩子
很多开源框架都有插件概念,DeepSeek Harness也不例外。说白了,插件就是一堆回调函数,框架在特定时机调用你,你可以在回调里做自己想做的事。最粗暴的做法是在每个任务里手动加一行HTTP POST调用,把"执行完"发出去,但这样代码和业务逻辑全搅在一起,改一处就要动一个文件,后期维护起来很痛苦。所以我倾向于顺着harness的事件流走:任务状态一变化,就会有一个对应的事件被发出来,插件只需要订阅这些事件。
在dsh-notify-push里,事件被划分为四种:started(任务开始)、finished(任务完成)、failed(任务失败)、progress(进度变化)。前三种是核心,progress默认不启用,因为高频进度推送除了刷屏没什么用,除非你的任务是那种"步进式"的,需要人工盯到第几万步,我才会建议你打开。事件订阅不是单例死绑,而是按照任务ID订阅,这样多个任务同时跑的时候,每条通知都能对得上号。
2.2 渠道抽象:一切通知都是Webhook
一开始我图省事,只做了企业微信机器人这一个渠道,写着写着发现不对。我这边用企业微信,别人用飞书,还有人用钉钉,甚至有用自定义Webhook的。与其为每个IM写一套发送代码,不如抽象成"把一个标题和一段Markdown内容发送到一个地址"。
抽象之后,每个渠道只剩两个差异:怎么签名、用什么消息格式。企业微信和钉钉支持Webhook直接POST JSON,飞书支持富文本消息,自定义Webhook更灵活,我可以用一个通用的JSON结构把它发出去。因此dsh-notify-push内部只保留了一个send(title, content)接口,根据配置的channel_type动态选择实现类。新增渠道的成本从改一段业务代码,变成写一个十几行的适配类,这个性价比就很高了。
2.3 通知模板:让人一眼就知道发生了啥
通知最怕写成长篇大论。我见过有人把一条任务日志完整贴到群里,几百行根本看不清重点。所以我给模板定了几条原则:主标题说清楚"哪个任务、什么状态";正文只保留四个关键维度:耗时、结果、失败情况、下一步建议。其他详细信息都附一个"查看日志"的提示,而不是把日志粘贴进来。
一条完成通知大概是这样的:
【完成】批量推理任务 #20250515-003 任务:商品标题二次润色 状态:成功 总耗时:3小时12分(排队1小时58分,推理1小时14分) 总数:1300 成功:1243 失败:57 失败原因:超时(最多的是token超限)这个格式花了我不少功夫。一开始我把"耗时分布"写得很细,后来发现对于凌晨醒来的人来说,只需要知道"跑完了、跑多久、失败的要不要管",多余的细节反而增加焦虑。所以模板必须可配置,甚至能自己写Jinja2模板,毕竟每个人关心的数据不一样。
3. 实现细节:钩子挂载、异步发送和防轰炸
3.1 生命周期钩子的正确挂法
dsh-notify-push在harness初始化时注册了一个NotifyHook,内部维护一个事件队列。例如Python代码大致是这样的:
import threading import queue class NotifyHook: def __init__(self, config): self.config = config self.event_queue = queue.Queue() self.worker = threading.Thread(target=self._worker, daemon=True) self.worker.start() def on_event(self, event): self.event_queue.put(event) def _worker(self): while True: event = self.event_queue.get() self._process(event)这里最关键的是,harness调用on_event时,我只做了入队操作,真正的网络发送在另一个工作线程里执行。为什么这么设计?因为harness里的任务执行线程往往承担着状态更新、结果持久化的职责,如果你直接在回调里请求外网,轻则多等几百毫秒,重则因为Webhook响应慢导致状态更新阻塞,甚至触发超时。把这个坑写成血泪教训留在后面讲,反正第一版我就是这么翻车的。
3.2 失败重试与去重
推送本身也可能失败,最常见的是Webhook地址因为网络波动返回5xx。我的策略是:每条通知最多重试3次,退避间隔是1秒、5秒、10秒。这里要尽量克制,不要无限重试。有一次我配置了重试10次,结果接收端机器临时抖动,我的工作线程被拖住近一分钟,虽然没阻塞主任务,但队列里的其他通知全被堵了。重试的次数不是越多越好,多了反而是二次伤害。
去重同样重要。harness在更新任务状态时,偶尔会重复触发同一种事件(比如同一个finished事件被框架内部重放了一次),如果不去重,凌晨三点你能收到三条一模一样的通知。去重逻辑很简单:用task_id + event_type + message_hash作为键,存进一个带TTL的内存字典,默认TTL是10分钟,保证同一条消息不会在短时间内重复推送。如果你的harness实例是多进程或者多机部署,建议把去重字典换成一个共享存储,比如用文件锁或现成的KV存储,不然每个进程各记各的,等于没去重。
3.3 批量任务的合并推送
还有一个更实际的痛点:并发跑50个任务,每个任务完成都推送一次,哪怕每次只发一条,十分钟也能把手机震成按摩仪。dsh-notify-push的默认做法是任务按batch_id分组,每组维护一个延迟发送窗口。具体来说:
- 第一个任务完成时,启动一个5秒的定时器;
- 窗口期内后续完成的任务只更新缓存中的统计数据;
- 5秒窗结束,把该批次所有任务的汇总结果一次性推送出去;
- 如果某个任务单独超过了一个较大阈值(比如45分钟),或者发生了失败,则立刻单独推送,不等待下一批。
这样既不会刷屏,也不会错过单任务的异常。窗口大小可配置,我通常设为5~10秒,太长会让通知失去时效性,太短就起不到合并作用。需要特别注意的是,要避免"旧批次"和"新批次"共用同一个窗口导致串数据,所以分组键必须是batch_id,不能只按时间窗粗粒度聚合。
4. 配置与使用:十分钟跑起来
4.1 最小配置示例
dsh-notify-push的配置采用YAML格式,挂在harness的config文件里。最小可运行配置:
notify: enabled: true channel_type: "wecom" # wecom / feishu / dingtalk / custom webhook_env: "DSH_NOTIFY_WEBHOOK" timezone: "Asia/Shanghai" events: ["started", "finished", "failed"] merge_window: 5 retries: 3webhook_env指从环境变量读取Webhook地址,而不是直接写在配置里。这是一个安全习惯:Webhook地址本质上是一种调用凭证,很多人习惯把它直接放到配置文件里再推到仓库,这是极其危险的做法。比如我见过某个配置文件直接被上传到没加权限的共享目录,被扫描器盯上后,机器人被拿去乱发消息。环境变量在容器化和本地进程里都能方便地注入,没有理由明文保存。
4.2 环境变量与密钥管理
更完整的做法是用密钥管理服务或者在CI/CD平台里配置环境变量。本地测试时可以临时设置:
export DSH_NOTIFY_WEBHOOK="https://example.com/cgi-bin/webhook?token=xxxx"对于自定义Webhook,dsh-notify-push会发送一个统一结构的JSON:
{ "title": "任务完成", "content": "...", "task_id": "...", "event": "finished", "timestamp": "2025-05-15T01:00:00Z" }你只要在自己的服务端接收这个结构体,再决定怎么渲染。这也意味着dsh-notify-push可以被接到自己写的内部系统上,不一定非要走企业微信。从设计角度讲,统一JSON是一个樞纽式的选择,它让插件的下游从"某个固定的聊天工具"变成了"任何能够接收JSON的东西",扩展空间一下子大了很多。
4.3 常见问题排查
我把实践中遇到的问题整理成了表格。如果某天你没收到推送,按这个顺序查:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 完全没有推送 | 未在events里声明事件 | 加入finished、failed |
| 没有推送 | Webhook环境变量没加载 | 检查进程环境,不要写死在代码里 |
| 偶尔丢消息 | 首次发送失败且重试次数已尽 | 看日志中的重试记录,提高重试次数或换稳定通道 |
| 消息重复 | 事件重放且去重缓存TTL太短 | 调大去重TTL,观察harness事件日志 |
| 时区不对 | 只存了UTC没做时区转换 | 配置timezone: "Asia/Shanghai" |
| 任务被拖慢 | 同步发送HTTP请求 | 确认是否升级到异步工作线程版本 |
另外特别提醒:Webhook收到200不代表消息一定送达用户,因为企业微信、飞书这类机器人的Webhook,只保证网关收到了请求,如果被网关限流或者签名错误,它们会返回非200,你要把状态码和响应体都记下来供排查。我一开始只记录了HTTP状态码,没把响应体记录下来。结果某次明明返回了错误信息,我却不知道具体是"签名错误"还是"消息超限",只能去猜。
5. 实测数据与踩坑心得
5.1 运行一周的光荣数据
替换掉之前浑身难受的轮询方案后,我连续跑了一周批量任务,记录了一些指标。单次任务从"harness内部触发finished事件"到"通知成功推送到手机"的中位延迟在1.3秒左右,最慢的一次4.7秒,原因是目标Webhook服务网络比较远。对我来说完全够用。一共跑了80多个任务,合并后实际推送给我的通知是23条,避免了半夜被50次提醒轰炸的惨状。
最让我欣慰的不是延迟低,而是几个失败任务都在第一时间通知了我。比如有个任务因为输入数据里出现了一个极长的无意义字符串,导致多次超时失败,harness自动重试也无济于事。凌晨三点多,手机震了一下,我迷迷糊糊拿起来看到"失败原因:token超限",当场决定把任务参数里的max_tokens调大,手动重跑。第二天早上到公司,同事还在问这任务怎么挂了,其实已经跑完了。这就是推送插件的价值。
5.2 踩过的大坑
第一个坑就是同步HTTP请求。第一版dsh-notify-push就是在事件回调里直接调用requests.post,看起来很直接,直到我的一个批量任务因为Webhook网络抖动,每次发送都等超时,把harness的执行进程拖得奇慢,原本一分钟能处理完的事件循环被拉长到几十秒。后来改成队列+独立线程,问题立刻消失。给所有准备写类似插件的朋友一个建议:凡是事件回调里的外部I/O,全部异步化,这是基本修养。
第二个坑是合并窗口的边界值。早期我设置merge_window: 0,意味着来一个推一个。短任务还好,长任务失败时的瞬间刷屏让我痛苦不堪。后来改成2秒,发现两个靠得很近的完成事件依然会被拆成两条,最终把默认值定为5秒,这个值在多数场景下够用,既不会让人等太久,也能把短时间内完成的一批任务合并成一条。
第三个坑是模板里用了没有被转义的换行和符号。一些聊天工具的Webhook对部分Unicode符号渲染不稳定,偶尔会变成问号。钉钉稍好,但也会随着客户端版本变化。所以dsh-notify-push默认模板尽量避免使用非常见的Unicode符号,用[完成]、[失败]这样的文本标签替代,虽然看起来朴素,但至少不会出现一排问号。
5.3 给同路人的建议
如果你也想给自己的DeepSeek Harness加一个"响铃",我的建议是:先想清楚你要接什么渠道,再动手写适配层。不要一开始就追求支持所有聊天工具,那会分散你的精力。先接一个你最常用的,比如企业微信或者飞书,跑通了再扩展。
对于安全性,再重复一遍:Webhook地址一律走环境变量;推送内容不要带任何机密信息,尤其是不要把完整的结果数据直接发到群里。插件可以做脱敏配置项,比如只推送失败条数和错误类型,不推送具体的数据内容。
最后,我再说一个大家容易忽略的点:通知不是越多越好。人是有通知疲劳的。如果一个工具天天震你,你会习惯性无视,真正出大事时反而错过。所以dsh-notify-push默认只关注started(可选)、finished、failed三个事件,progress事件我根本不推荐常开。让通知保持稀缺,才有被认真对待的价值。
写这个插件前,我的状态像极了"牛马"——被无尽的任务、未知的进度反复牵拉,根本不敢离开工位。用上之后,至少我可以放心地离开键盘去喝杯水、睡个整觉。我不指望一个推送插件能改变世界,但它确实把我从"机械式看进度"的泥潭里捞了出来。如果你也在凌晨守着屏幕等结果,不妨也给自己做一个类似的"电话铃"。