告警发到群里,你在群里追问一句,它终于知道你在说什么了

凌晨两点,部署流水线挂了,webhook 把这条告警推到了你的 Telegram。你第二天早上看到,顺手回了一句「这个和昨天那个是同一个问题吗?」——对方回你:「你指的是什么?」你确实刚跟它说过,但它不知道。这不是它笨,是 webhook 投递从设计上就是发完就走:每一次事件都在自己的会话里跑完,投递出去的那条消息,在目标聊天的会话记录里根本不存在。这篇讲 v0.21.5 里新增的那个开关,以及它到底解决和没解决什么。
问题出在哪:每次投递都是一次独立会话
Webhook 路由的工作方式是:外部服务 POST 一个事件 → payload 按模板渲染成 prompt → 起一次 agent 运行 → 把结果投递到指定聊天。整个过程中,那个 webhook:<route>:<delivery_id> 会话是临时的,跑完就散。
而你在 Telegram 那个聊天里跟 agent 说话时,它加载的是那个聊天的会话。两边的会话记录没有交集,所以:
- 它不知道自己刚给你发过一条告警;
- 你说「昨天那个错」,它没有任何线索指向哪条消息;
- 你唯一的选择是把告警复述一遍,或者放弃在聊天里追问。
打开这个开关
路由现在可以显式开启镜像。用命令行创建或更新订阅时加一个参数:
hermes webhook subscribe ci-alerts \
--prompt "构建 {route} 失败了:{payload}" \
--deliver telegram \
--mirror-to-session
订阅成功后的输出里会多一行提示:
Created webhook subscription: ci-alerts
URL: https://…/webhooks/ci-alerts
Profile: default
Events: (all)
Deliver: telegram
Replies: each delivery is mirrored into the target chat's session
已经存在的路由也可以改配置。在你的 config.yaml 里为对应路由加上键:
platforms:
webhook:
extra:
routes:
ci-alerts:
prompt: "构建 {route} 失败了:{payload}"
deliver: telegram
mirror_to_session: true
注意这里的取值判断很严格:只有真正的布尔 true 才开启。写字符串 "true" 不会生效——这个设计是有意的,因为镜像会把内容写进你的对话历史,作者必须明确要求它。
镜像进去的到底是什么
不是把整条 HTTP 请求塞进历史,而是投递出去的那条消息本身,加上一个标签:
[Webhook delivery: ci-alerts]
构建 4821 在 main 上失败
它以 user 身份(user authority)追加到目标聊天的会话里,和“可续跑的 cron 简报”走同一条代码路径、同一个角色约定。为什么是 user 而不是 assistant?因为这段文字不是目标会话里那个 agent 说的,它只是“你被告知过这件事”。如果按 assistant 记,历史里就会出现 assistant → assistant 的连续两条,严格交替的 provider 直接报错;而按下 user 记,连续两条 user 会被安全合并,不会破坏对话结构。
结果就是:你在那个聊天里回一句「这是昨天那个错吗」,agent 能在自己的历史里看到那条标签和被投递的正文,于是答得上来。
三条边界,开之前必须知道
1. 它是 opt-in,默认关闭
不写 mirror_to_session 的路由行为完全不变。这一点和 cron 的 mirror_delivery 是同一套思路:默认不往会话里写东西。
2. 它是按 profile 作用域的
镜像在 _profile_scope(profile) 里执行,也就是写在那条路由所属 profile 的 state.db 里。这条不是细枝末节:Telegram 的私聊 chat_id 在每一个 bot 上都等于这个人的用户 id,所以如果镜像不限定 profile,就会落到另一个 profile 和同一个人的私聊里。开 /p/<profile>/ 路由时尤其要清楚这一点。
3. 它是尽力而为的
镜像失败不会让投递失败(官方实现里明确“永不抛出”),而且当目标聊天还没有 gateway 会话(没人跟它说过话)时会直接跳过。也就是说:你开了这个开关,不代表一定能追问——如果那个群从来没跟 agent 有过对话,就没有会话可写。
什么时候别开
deliver_only 路由要特别小心。这种路由不跑 agent,模板渲染出来就是最终消息体,零 token 成本——而镜像进去的就是渲染后的原始载荷。
这意味着任何能让外部服务控制这段文本的东西,都会以一个 user 轮次进入你的对话历史:
- 公开 issue tracker 的 webhook:陌生人能在你的 issue 标题里写什么,就能在你的对话历史里出现什么;
- 监控系统的原始告警正文:如果它包含用户可控字段(错误消息、请求路径、UA 字符串),同样成立。
所以规则很简单:只给内容可信的来源开镜像,比如你自己的服务、你自己的构建流水线。别人能写内容的渠道,开之前先想三秒。
怎么确认它生效了
hermes webhook list
列表里能看到每条路由的信息;创建或更新订阅时的输出里会打印 Replies: each delivery is mirrored into the target chat's session 这一行。
想验证真实行为,最省事的办法是:hermes webhook test 打一条测试事件到路由,然后在目标聊天里问一句「你刚才给我发的是什么?」——如果它能答出来(而不是反问),镜像就通了。
和别的东西怎么配合
这个功能和 重复回复的投递台账 解决的是同一类问题的两面:那个解决“同一条回复被投递两次”,这个解决“投递出去了但对话里没有记录”。两者都在 gateway 的消息投递层。
如果你在用 cron 做定期巡检,cron 的简报早就有等价的 mirror_delivery 开关——webhook 现在补上了短板,两条路径的语义是一致的(标签 + user 轮次),所以你在聊天里追问的主观体验也一样。
小结
- 默认行为不变:webhook 投递是发完就走,目标聊天不知道发生过什么。
- 开
mirror_to_session: true(或--mirror-to-session)之后,投递内容会以[Webhook delivery: <route>]标签 + user 轮次写进目标聊天的会话。 - 三条边界记住:默认关闭、按 profile 作用域、尽力而为(没有会话就跳过)。
deliver_only路由镜像进去的是原始载荷,只给可信来源开。
这个能力是 v0.21.5(tag v2026.9.24)带来的,属于那个“没有发布说明”的补丁窗口——如果你想看这个窗口里还有哪些已经能用的变化,v0.21.5 窗口自查指南 和站内的 v0.21.5 发版页 里都有整理。Webhook 的完整参数表在命令参考里。