1. 心跳丢失为什么总在凌晨三点炸锅
运维监控里最让人后背发凉的不是进程直接挂掉,而是进程还在、CPU 还在跳、端口还通着,但业务逻辑已经卡死。这种「假活」状态在量化策略、定时任务、长连接网关里特别常见。你写个ps aux | grep strategy看进程还在,心里松一口气,实际上策略已经三分钟没收到行情、没写心跳、没下单了。
传统监控的盲区就在这里。基于进程存活、端口探测、CPU 占用的脚本,对「静默挂起」完全失效。等你在早上发现账户异常,损失已经发生。真正要抓的是心跳时间戳的差值——只要超过阈值没刷新,就判定失联,并且要在 3 秒内把带 Traceback 的告警推到飞书群,让值班的人一眼看到崩在哪一行。
这篇就围绕这个场景,用 Cursor 写一套可落地的报警脚本:Redis 存心跳、独立守护进程扫描、飞书机器人推卡片。同时把模型调用和 Key 管理统一走 TaoToken,避免在多个脚本里散落不同的 API Key 和通道配置。适合做运维监控、量化实盘、后台任务守护的同学直接抄。
2. TaoToken 前置:统一 Key 与通道,别让报警脚本自己管密钥
报警脚本本身不复杂,麻烦的是它往往要调用模型做日志摘要、异常归类,或者把 Traceback 丢给模型生成一句人话结论。如果每个脚本各自维护一套 Key、各自写一套请求逻辑,密钥泄露风险和排障成本都会飙升。
TaoToken 在这里的角色是统一入口:一个 Key 走所有模型调用,接口地址固定,配置集中。你可以在 Cursor 里把这段配置写进config.toml,脚本读配置而不是硬编码密钥。官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api ,注意 API 地址不带 UTM 参数。
需要先拿到 Key 的话,直接去控制台创建:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。创建完在 API Keys 页面复制:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。如果你只是想先验证模型通不通,用模型对话页最快:https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。
注意:Key 只放在环境变量或本地 config 文件里,别提交到 Git。报警脚本读
os.environ或tomllib加载,这是底线。
3. 可复制配置:config.toml 骨架与飞书 Webhook 验证
先把配置骨架搭好。Cursor 里新建config.toml,把 Redis、飞书、TaoToken 三段分开写,脚本只认字段名,换环境只改这个文件。
# config.toml —— 心跳监控报警配置骨架 [redis] host = "127.0.0.1" port = 6379 db = 0 heartbeat_key = "strategy:heartbeat:001" timeout_seconds = 3 # 心跳丢失判定阈值 [feishu] webhook_url = "https://open.feishu.cn/open-apis/bot/v2/hook/你的hookid" secret = "你的签名密钥" [taotoken] base_url = "https://taotoken.net/api" api_key = "sk-你的Key" # 建议改为从环境变量读取 model = "claude-sonnet-4-5"飞书机器人要先在群里加好:群设置 → 群机器人 → 添加自定义机器人 → 安全设置勾选「签名校验」,拿到 Webhook 和 Secret。加完后先做一次最小验证,确认 Webhook 通,再写业务逻辑。下面这段单独跑,能收到消息就说明通道没问题。
# verify_feishu.py —— 飞书 Webhook 最小验证 import time, hmac, hashlib, base64, requests WEBHOOK = "你的webhook" SECRET = "你的secret" ts = str(int(time.time())) sign_str = f"{ts}\n{SECRET}" sign = base64.b64encode( hmac.new(sign_str.encode(), digestmod=hashlib.sha256).digest() ).decode() payload = { "timestamp": ts, "sign": sign, "msg_type": "text", "content": {"text": "心跳监控通道验证:Webhook 正常"} } r = requests.post(WEBHOOK, json=payload, timeout=10) print(r.status_code, r.json())返回code: 0就通了。如果报sign match fail,九成是时间戳格式或换行符问题,后面排障章节细说。
4. 心跳上报 + 守护扫描 + Traceback 推送完整实现
核心逻辑分三块:策略线程每秒写一次 Redis 时间戳;守护进程每秒读一次算差值;差值 ≥ 3 秒就组装飞书卡片推送。崩溃时用traceback.format_exc()抓完整堆栈,心跳丢失时则标注「可能挂起/死锁」。
# heartbeat_alert.py —— 心跳丢失秒级报警 + Traceback 推送 import os, time, hmac, hashlib, base64, threading, traceback import tomllib, requests, redis with open("config.toml", "rb") as f: cfg = tomllib.load(f) R = cfg["redis"] F = cfg["feishu"] T = cfg["taotoken"] def make_sign(ts, secret): s = f"{ts}\n{secret}" return base64.b64encode( hmac.new(s.encode(), digestmod=hashlib.sha256).digest() ).decode() def push_card(strategy_id, err_msg, tb_stack): ts = str(int(time.time())) payload = { "timestamp": ts, "sign": make_sign(ts, F["secret"]), "msg_type": "interactive", "card": { "config": {"wide_screen_mode": True}, "header": { "template": "red", "title": {"content": "策略心跳丢失报警", "tag": "plain_text"} }, "elements": [ {"tag": "div", "text": { "content": f"**策略ID:** `{strategy_id}`\n**时间:** {time.strftime('%Y-%m-%d %H:%M:%S')}", "tag": "lark_md"}}, {"tag": "div", "text": { "content": f"**异常:** <font color='red'>{err_msg}</font>", "tag": "lark_md"}}, {"tag": "hr"}, {"tag": "div", "text": { "content": f"**Traceback:**\n```python\n{tb_stack}\n```", "tag": "lark_md"}} ] } } try: resp = requests.post(F["webhook_url"], json=payload, timeout=10) print("推送结果:", resp.json().get("code")) except Exception as e: print("推送失败:", e) def strategy_worker(conn, sid): print(f"[Strategy] {sid} 启动") for sec in range(1, 6): if sec == 4: try: raise ConnectionError("MySQL 连接超时 10.1.1.185:3306") except Exception as e: tb = traceback.format_exc() push_card(sid, str(e), tb) break conn.set(R["heartbeat_key"], int(time.time())) print(f"[Heartbeat] {sid} 上报 {sec}") time.sleep(1) def monitor_daemon(conn, sid): print("[Daemon] 1Hz 扫描启动") for _ in range(8): time.sleep(1) raw = conn.get(R["heartbeat_key"]) if raw: diff = int(time.time()) - int(raw) if diff >= R["timeout_seconds"]: print(f"[Alert] 失联 {diff} 秒,触发告警") push_card(sid, f"心跳丢失 {diff}s", "No Traceback. 进程可能挂起或死锁。") conn.delete(R["heartbeat_key"]) break print(f"[Monitor] 延迟 {diff}s 正常") else: print("[Monitor] 无心跳数据") def main(): conn = redis.Redis(host=R["host"], port=R["port"], db=R["db"], decode_responses=True) conn.ping() sid = "STRATEGY_MA_001" t = threading.Thread(target=strategy_worker, args=(conn, sid), daemon=True) t.start() monitor_daemon(conn, sid) conn.delete(R["heartbeat_key"]) if __name__ == "__main__": main()跑之前确认 Redis 在 6379 端口活着,依赖装好:
uv add redis requests uv run python heartbeat_alert.py5. 验证请求与成功结果:日志长什么样
正常跑起来,控制台会交替出现心跳上报和守护扫描。第 4 秒策略抛异常,立刻推一次带 Traceback 的卡片;随后心跳断流,守护进程在差值达到 3 秒时再推一次失联卡片。预期日志:
[Strategy] STRATEGY_MA_001 启动 [Heartbeat] STRATEGY_MA_001 上报 1 [Daemon] 1Hz 扫描启动 [Monitor] 延迟 0s 正常 [Heartbeat] STRATEGY_MA_001 上报 2 [Monitor] 延迟 0s 正常 [Heartbeat] STRATEGY_MA_001 上报 3 [Monitor] 延迟 0s 正常 推送结果: 0 [Monitor] 延迟 1s 正常 [Monitor] 延迟 2s 正常 [Alert] 失联 3 秒,触发告警 推送结果: 0两个触发点都命中:崩溃瞬间的 Traceback 卡片,和心跳丢失 3 秒的失联卡片。飞书群里能看到红色状态栏、策略 ID、异常描述和代码块格式的堆栈。如果你还想让模型把 Traceback 总结成一句人话,可以在push_card前调一次 TaoToken:
def summarize_tb(tb): r = requests.post( f"{T['base_url']}/v1/messages", headers={"Authorization": f"Bearer {T['api_key']}"}, json={"model": T["model"], "max_tokens": 200, "messages": [{"role": "user", "content": f"用一句话总结这个报错:\n{tb}"}]}, timeout=15 ) return r.json()["content"][0]["text"]长期跑编码和 Agent 任务的话,Coding Plan 更省心:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。接入细节看文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。
6. 本篇常见错排查
签名报sign match fail:飞书要求时间戳是整型秒,且拼接格式必须是timestamp\nsecret。多毫秒、少换行、Secret 前后有空格都会失败。服务器开 NTP 对时,timedatectl确认时钟没漂。
Redis 连不上:先redis-cli ping看返回是不是 PONG。Docker 起的 Redis 注意端口映射,远程实例注意bind和防火墙。
告警刷屏:策略频繁重连会短时间推几十条。加个熔断:同一策略 5 分钟内超过 3 次就暂停推送,只发一条「已熔断」提示。
误报:网络抖动导致心跳写不进 Redis,但进程其实活着。把阈值从 3 秒放宽到 5 秒,或者失联时补一次进程存活探测再决定是否报警。
Traceback 为空:心跳丢失场景本来就没有异常堆栈,这是正常的,卡片里写清楚「可能挂起/死锁」即可,别硬塞假堆栈。
Key 泄露:config.toml加进.gitignore,生产环境用环境变量覆盖api_key字段,别把 Key 写死在代码里。
把这几处踩平,整条链路基本就稳了。真正上线前,建议拿一个测试策略故意kill -STOP模拟挂起,看 3 秒内飞书是否收到卡片,确认端到端延迟符合预期。