健康检查全绿,数字员工失联了 16 分钟:生产交付的几个关键实践

When All Health Checks Are Green but the Brain Is Gone

2026 年 8 月 8 日下午,我们的数字员工失联了 16 分钟。监控这边,它一切正常。

说「失联」其实不准确——从所有监控指标的视角看,它一直活得好好的:进程存活检查正常,HTTP /session 探测返回 200,健康检查每 5 分钟跑一次,次次报「健康」。唯一的异常是:群里任何人 @ 它,它都不回。最早的「告警」是一个人在钉钉里发现不对劲——这比我们任何一条监控都快。

16 分钟不长,但足够让人意识到一件事:我们精心搭建的监控体系,监控的根本不是「数字员工在工作」,而是「装着数字员工的那个容器还在」。

这篇文章讲的,就是从这类事故里长出来的几个生产级实践。它们已经沉淀进数字员工的 harness 模板——三步上线一个数字员工 里讲过这个脚手架是什么、怎么三步上线;模板本身也在持续迭代,从内部生产版本一路提炼成开源的 dingtalk-opencode-tag。这篇是它的续篇:上线只是开始,生产环境会用它自己的方式告诉你,「能跑」和「生产级」之间还隔着多少坑。

When All Health Checks Are Green but the Brain Is Gone

数字员工:循环之内很简单,循环之外全是脏活

先快速对齐一下背景。「数字员工」本质上就是一个企业里的钉钉账号:在群里或私聊里收消息,用 LLM 理解并生成回复,再以这个账号的身份发回去。听起来就是一个「群消息监听 → LLM 生成 → 发回群」的循环。

但真要从零做生产级交付,脏活全在循环之外:进程挂了谁来拉起?拉起了但「大脑」和模型网关失联了怎么办?断线重连、富媒体受控下载、多轮会话怎么复用才不串上下文、上游框架天天变你的定制代码怎么还能干净 merge。

这些在 三步上线一个数字员工:开源脚手架背后的交付范式 里已经展开过——core/custom 分层、能力插件、三步上线。这篇不重复那些,只讲当时没讲的一层:上线之后,怎么知道它真的在工作;以及那些只有真实流量才逼得出来的隐蔽坑。

如果你只记一句话,我希望是这句:

「能跑」和「生产级」的分界线,是故障发生时你能不能自己发现,而不是等用户在钉钉里先发现。

分工不变:opencode 负责「想和做」,模板负责人机协同

模板的分工哲学没有变:数字员工「推理 + 任务执行」这件重活,交给更完备的 opencode——它有模型生态、工具调用、会话管理、权限控制一整套。模板只做「人机协同」那一层:

  • 钉钉侧的消息收发(dws CLI)
  • 富媒体受控处理:图片识别 / 文件解读 / 合并转发
  • Question 人在回路作答、群消息聚合
  • 进程守护与自愈

这一层的边界我在 用完备的 Harness 工程实现 AI 原生协同 里定义过:Harness 是模型之外的整个环境。分工清晰带来的直接好处是——换 IM 平台,只需要替换 custom 层的发送实现,core 一行不动。

为什么这个分工值得再强调一次?因为它决定了下面所有实践的位置:它们全部在人机协同层,没有一条是去改 opencode 的。推理层的问题有 opencode 的生态兜底,而协同层的脏活,只能靠你自己的 Harness 一件件做成标准件。

进程活着 + HTTP 200 ≠ 大脑活着

回到 8 月 8 日那次事故。把时间线摊开看:

T+0min     大脑与模型网关失联(通道异常)
T+0~16min  进程存活检查 → 正常
           HTTP /session 探测 → 200
           健康检查(每 5 分钟) → 「健康」×3 次
           实际表现:任何请求都答不出来
T+16min    人在钉钉里发现「它不回消息」
# generated by hugo AI

问题不在于监控不够多,而在于 所有检查都停留在「容器层」。进程活着,只能证明操作系统没回收它;/session 返回 200,只能证明 HTTP 服务还在监听。这两件事加在一起,证明不了「大脑能思考」——大脑到模型网关的链路断了,容器依然完好无损。

这是两个独立的故障域:

┌──────────────────────────────────────────────────┐
│  容器层:进程活着、端口在听、/session 返回 200      │
│  检查:进程存活 / 端口探测 / HTTP 探测             │
│  盲区:进程活着 ≠ 能干活                           │
├──────────────────────────────────────────────────┤
│  大脑层:请求 → 模型网关 → 生成 → 返回             │
│  检查:真实模型调用(check_brain)                 │
│  盲区:不检查就永远不知道断了                       │
└──────────────────────────────────────────────────┘
# generated by hugo AI

只盯着第一层,就会得到 8 月 8 日那样的结果:所有绿灯,全员失联。

于是模板里加了第 7 项健康检查 check_brain。它的思路很简单,甚至有点「笨」:

  1. 平时只数日志——统计「距上次检查以来」新增的失败条数,不发任何请求
  2. 失败条数超过阈值,才触发 一次真实模型调用 自检
  3. 调用失败 → 立刻告警;成功 → 确认大脑活着
check_brain() {
    local out n
    # ① 平时只数日志:统计「距上次检查以来」opencode 日志里新增的失败条数
    out=$(count_new_matches "$AGENT_OPENCODE_LOG" "$BRAIN_FAIL_OFFSET_FILE" \
                            "$_BRAIN_FAIL_PATTERN" "$consume")
    n=$(echo "$out" | awk '{print $3}')

    # ② 新增失败 < 阈值:不发任何请求,零成本返回
    if [[ "$n" -lt "$HEALTHCHECK_BRAIN_FAIL_THRESHOLD" ]]; then
        echo "OK: 新增失败 ${n}(<${HEALTHCHECK_BRAIN_FAIL_THRESHOLD})"
        return
    fi

    # ③ 超阈值:真发一次模型调用,走完「请求 → 模型网关 → 生成 → 返回」
    local rc probe_out
    probe_out=$(run_with_timeout "$((HEALTHCHECK_BRAIN_PROBE_TIMEOUT + 15))" brain_probe 2>&1)
    rc=$?
    case "$rc" in
        0)   echo "OK: 探针通过 (新增失败 ${n})" ;;
        124) echo "FAIL: 大脑自检超时 (新增失败 ${n})" ;;
        *)   echo "FAIL: 大脑自检失败 (新增失败 ${n})" ;;
    esac
}
# generated by hugo AI

代码里有几个细节值得停下来看:

  • 失败计数的正则 必须锚定行首。开调试模式时,日志里还混着整段模型输出的 body,不锚定的话 body 里碰巧出现 ok=False 就能伪造计数——计数器被污染,探针就会在该触发的时候不触发
  • 探针本身也要包一层超时。「探针挂了」和「大脑挂了」是两种故障,不能混为一谈,宁可多包一层
  • 探针复用脚本已经解析出的 serve 凭据,而不是自己再发现一遍——两边发现凭据可能指向 不同的 serve 实例,出现「HTTP 检查说不通、探针说通」这种自相矛盾的裁决
健康时:零请求,零成本
失联时:最迟一个检查周期内报警
# generated by hugo AI

为什么用「真实模型调用」而不是继续探测某个接口?因为 只有走完「请求 → 模型网关 → 生成 → 返回」的完整链路,才能证明大脑真的活着。接口探测证明的是「有人在门口应声」,模型调用证明的是「里面真有人在干活」。而「按失败条数增量触发」的设计,让这项检查在健康时零成本——你不需要为每一轮健康检查付一次模型调用的钱,只在出现可疑信号时才付。

有人会问:那为什么不每 5 分钟直接做一次真实模型调用?答案就是成本。一个群聊数字员工一天可能要跑上百轮健康检查,每轮一次真实调用,既花钱又给模型网关增加无谓负载——而且 99% 的轮次里它什么都查不出来。先数日志里的失败条数,等于给昂贵的真探加了一道便宜的过滤器。

这个设计的思路,和我在 修 Bug 的真正目的:让 AI 下次能自己修 里讲的是同一件事:Harness 的反馈回路必须测到真正重要的量,否则它给你的只是虚假的安全感。

会话复用:按 conversation 粒度,不按目录

第二个容易出错的地方是多轮会话的复用。一个工作目录下往往会有上百个 session——如果复用逻辑是「找最近活跃的那个」,在群聊这种多话题并行的场景里,几乎必然会串上下文:A 话题的上下文被 B 话题的回复拿走,数字员工开始答非所问。

模板里的复用逻辑统一按 conv_id(钉钉的 openConversationId)走:

  • 每个会话(群 / 私聊)有唯一的 conv_id,同一会话内的消息共享上下文,不同会话之间完全隔离
  • TTL 过期 + LRU 逐出,防止 session 无限堆积
  • 单会话串行、不同会话并行——同一个群里不会出现两条消息互相踩上下文
  • 复用的 session 如果 404 或返回空,自动重建并重试一次

文本、图片、文件、合并转发,所有能力共用这一套复用逻辑。这带来一个很具体的体验:转发完再追问,能接上上下文。用户把一段聊天记录合并转发给数字员工,它总结完之后,用户接着问「那第三条里的数据是哪天的」——它知道你在问刚才那段转发,因为这两条消息落在同一个 conv_id 下。

串上下文这类 bug 在演示环境里几乎不会暴露(单轮对话永远正确),只有真实群聊的多话题并发流量才逼得出来。

守护循环:拉起要自动,轰炸要熔断

进程守护本身没什么新鲜,新鲜的是两个边界条件:

崩溃要自动拉起,但主动退出不能拉。 monitor.sh 由 launchd(macOS)或 systemd –user(Linux)托管,服务配置里写的是 KeepAlive={SuccessfulExit:false}——进程异常崩溃,自动拉起;进程主动以 0 退出,说明它认为自己该停(通常是配置问题或环境问题),这时候应该等人工介入,而不是无脑拉起一个注定还会退出的进程。

连续失败要熔断,不能无限重试。 连续 N 次健康检查失败后,模板的动作是:发告警 + 正常退出(exit 0)。如果没有熔断,一个因配置错误起不来的进程会被守护循环反复拉起、反复失败,形成「重启轰炸」——告警被刷成噪音,真正的故障反而淹没在里面。

崩溃 → 自动拉起(自愈)
主动退出 → 等待人工(不拉起)
连续 N 次健康检查失败 → 告警 + exit 0(熔断)
# generated by hugo AI

自愈和止损是同一枚硬币的两面:只有自动拉起没有熔断,守护进程会变成故障放大器;只有熔断没有拉起,一次偶发崩溃就要人来收拾。两个都要。

四个隐蔽到极致的坑(外加监控自己的一个)

剩下这几个坑,单测抓不住、文档不会写,全是真实流量逼出来的。

1. 上游事件格式随版本变,bridge 会静默跳过所有事件。

事件流格式有嵌套和扁平两种。上游某次版本变更后,格式不匹配时,bridge 的表现是:每条事件都解析失败、每条都被跳过——进程活着、连接正常、健康检查全绿,但数字员工完全不响应。这个症状和 8 月 8 日那次几乎一样,排查方向却完全不同。解法是在 bridge 里内置格式健康检查:收到 ≥3 条原始事件却解析出 0 条,立刻告警。「静默失败」必须被改造成「显式报警」——解析失败不是可以容忍的边界情况,是事故。

2. for line in sys.stdin 有 readahead 缓冲。

for line in sys.stdin 读事件流时,Python 会做预读缓冲。高频场景没感觉,但低频事件流会卡在缓冲里迟迟不出来——表现是「消息过了好几秒甚至几十秒才被处理」。改成 iter(sys.stdin.readline, "") 逐行读,事件实时到达。这类坑的隐蔽之处在于:它在你的测试环境里永远正常,因为测试时你总是连续发消息。

3. 杀进程要连子树一起杀。

事件订阅会派生子进程,真正持有长连接的是子进程。只杀父进程,子进程会被甩成孤儿,继续占着那条订阅连接。这时候你重启服务,新旧两个进程抢同一条消息投递——表象就是「收不到消息」或者「消息时有时无」。杀进程要按进程组杀整个子树,重启前先确认旧连接真的断了。

4. 远程重启必须用干净环境。

/reboot 指令是由老进程派生执行的,子进程继承了老进程的完整 env。如果直接把这份 env 透传给新进程,你刚改的配置就不生效——因为环境变量的优先级盖过了配置文件。解法是用 env -i 跑重启,让 config 文件成为环境变量的唯一来源。配置要生效,就不能有任何「上一代的残留」替它说话。

5. 健康检查的输出格式,会决定熔断能不能触发。

这是 8 月 8 日事故的直接机制,也是复盘时才找到的:健康检查脚本曾把 serve HTTP 异常报告为 HTTP_FAIL:,而主判定逻辑匹配的是 FAIL* 前缀——前缀对不上,这条失败 永远进不了「不健康」的判定。于是 serve 挂了也照样报「健康」,熔断一次都没触发过。监控自己也会静默失败。教训很朴素:判定的匹配规则和输出的格式约定,是同一份契约——改任何一边,另一边必须跟着改,并且要有测试钉住这个契约。

这五个坑有一个共同点:它们都不会让程序报错,只会让程序「看起来正常但不干活」——和 8 月 8 日的失联是同一类故障。

把全文的实践收拢,其实就是一套应对「静默故障」的三件套,可以迁移到任何 Agent 系统的生产交付上:

  1. 分域:把「容器活着」和「大脑在工作」当成两个独立的故障域,各自的检查不能互相证明
  2. 真探:对真正重要的链路,用走完全链路的真实调用去验证,而不是探测某个接口
  3. 显警:一切静默失败都要改造成显式告警——解析 0 条、缓冲卡住、连接被抢,都要变成看得见的信号

对数字员工来说,最危险的故障不是崩溃,而是静默。

模板 100% AI Coding

和脚手架时代一样,这个模板的所有功能都由 AI 编码完成——人给方向和验收,AI 探查、实现、真实链路验证、提 PR。这不是刻意安排的实验,而是架构设计的自然结果:物理分层让 AI 知道哪里能改、哪里不能改,插件契约清晰、每个能力自带单测、边界写进 AGENTS.md,Coding Agent 读完就知道怎么改不越界。

我在 让钉钉机器人自己开发自己 里第一次验证了这个模式,这个项目把它变成了日常。要加一个新能力?把需求一句话丢给 Coding Agent,它照着现有插件范式就能写,还会自己补单测、跑验证。本文讲的 check_brain 这类守护逻辑,同样是 AI 按照「先描述症状和期望行为,再实现 + 验证」的方式交付的。

写在最后

回到模板想回答的那个问题:当「推理和执行」已经有 opencode 这样完备的生态时,把人机协同层做成可复制交付的标准件,能把一个新数字员工的上线成本压到多低?

脚手架给出的答案是:几分钟,零成本,一个会写 prompt 的人就能交付。

这篇续篇想补充的是另一半:「上线成本趋零」之后,真正的成本会转移到可靠性上。 而可靠性没有银弹,只有一个一个具体的故障模式——失联 16 分钟、串上下文、重启轰炸、静默跳过——把它们逐个封装成检查、复用策略、熔断和显式告警。每踩一个坑,模板就厚一层;下一个使用者,就少踩一个。

项目开源(MIT),欢迎来提 issue 和 PR:把你在生产里踩过的坑,变成下一个人的起跑线。

你在生产环境里还遇到过哪些「看起来正常但不干活」的坑?欢迎留言讨论。


See also