我的钉钉数字员工每个月偶尔会抽下风:「⚠️ 暂时无法处理你的消息,请稍后再试。」,往往重启一下就好了。今天我让 opencode + GLM-5.3 排查了下这个问题。
十五分钟后,它把案子破了。凶手很狡猾:藏在三份完美证词背后,还顺走了我的 /etc/hosts 当人质。
一、案发
早上 8 点整,「Agent 任务消息」准时投递今日早读任务:读公众号后台、读 GA、digest 小红书、更新 token 消耗、读 aibase——五步,预算一小时。
两分钟后,我的数字员工「涌现」回了那句熟悉的:
⚠️ 暂时无法处理你的消息,请稍后再试。
以前一个月碰上一两次,重启就好,从没深究。翻了日志才发现近一周它已经吃了三次兜底:8 月 31 日、9 月 2 日、今天。病在加重。
opencode 上手第一件事不是改代码,是验尸。它列了张嫌疑人名单挨个问话,每份证词都无懈可击:
- 进程全活着:monitor、event_watcher、三条 dws event consume、dws event _bus、opencode serve,一个不少;
- 健康检查每五分钟报一次「✅ 健康」,从案发到现在没红过;
- 模型网关 ping 通,延迟 0.9 毫秒;
- curl 网关返回 HTTP 401——只是没带密钥,服务明明活着。
所有证人都说「我没事」。可尸体就躺在日志里:
error: Cannot connect to API: Unable to connect.
Is the computer able to access the url?
url: http://dev2:4000/v1/chat/completions
翻译成人话:大脑活着,但摸不到模型。从 7:31 起,每次调用都是这个死法——先走 HTTP 通道,60 秒必死;回退 CLI,再 60 秒,还是死;最后发兜底。连我 9 点亲自发的 「hi」(十个字符,排除上下文过长的嫌疑)也被它扔回一句兜底。而所有不需要模型的能力——比如盯行情——都活得好好的。死亡的边界,精确地沿着「要不要过模型网关」切开。
二、AI 也翻了一次车
opencode 顺藤摸到配置:模型的 baseURL 是 http://dev2:4000/v1。它带上密钥真调了一次补全接口,模型回了句 「ok」。
于是它给出了一个错误判断:「网络已恢复,故障是历史。」
但它接着做了一件多数人类专家都不会做的事——用自己的假设去撞一次真实链路。它构造了一条最小消息,直接喂给大脑的服务端口。
63.7 秒,空回复。
还在死。假设被自己的实验当场推翻。它立刻改口,这次的推理非常锋利:
「我的 shell 全通,它的进程必死。问题不在网络,在这两个进程眼里的 网络不一样。」
serve.log 坐实了:大脑在疯狂重试,每次失败都是「秒失败」——不是超时,是 connect 被立即拒绝。在 TCP 的世界里,秒失败有个特指:路由不存在。超时是「对方不在家」,秒失败是「这条路根本没修」。
三、四张牌
dev2 是内网机器名。多年前我就在 /etc/hosts 里写死了它:
30.45.41.44 dev2
自认防了一手。opencode 却指着这行字说:这就是人质,而且没用。然后它抽出四张牌:
牌一,裸名在 DNS 里查无此人。 dig dev2——A 记录、AAAA 记录,一条都没有。这机器名压根不在 DNS 里,全靠 hosts 和解析器自觉。
牌二,搜索域暗度陈仓。 本机 DNS 配了搜索域 hz.xyz.com,解析器会把裸名补全成 dev2.hz.xyz.com 再查一次。这个全名下恰好只有一条 AAAA 记录:fd00:1:30:...——一个 IPv6 私有地址,没有对应的 IPv4。于是系统解析 dev2 的最终结果是一份拼盘:IPv4 来自我的 hosts,IPv6 来自 DNS。它用一行 getaddrinfo 打出来给我看,果真两条,齐齐整整。
牌三,/etc/hosts 只能加,不能减。 我那行映射只能「贡献」一条 IPv4,挡不住搜索域塞进来的 AAAA。防了多年,防了个寂寞。
牌四,运行时各有各的脾气。 curl 有 Happy Eyeballs(RFC 8305):IPv6 打不通会自动换 IPv4 重试;这台机器的 Node 也默认开了同款回退。所以我手工测全通——不是网络好,是我的工具都会兜底。而 opencode 是 Bun 打出来的单文件二进制:fetch 没有这层回退,对 /etc/hosts 的处理也自成一路。它眼里的 dev2,就是一条无路由的 IPv6。
为排除巧合,它还补了一刀对照实验:强制走 IPv6 的 curl,退出码 7,0 秒失败;强制走 IPv4 的 curl,4 毫秒连上。同机、同端口、同目标,唯一变量是协议族。
最后是它自己说出的闭环逻辑:「如果它当时走的是 hosts 里的 IPv4,就不可能失败——所以它必然走了 IPv6。」凶手到案:一条无路由的 IPv6 私有地址,撞上一个不回退的运行时。
四、「重启就好」的真相
最难堪的部分来了:为什么重启总能「治好」它?
因为根本不是重启治好的。fd00::/8 是 IPv6 的私有地址段,相当于 IPv4 里的 192.168.x.x——有没有去它的路由,取决于机器当时在哪个网段:工位、热点、VPN,各不相同。网络一漂移,IPv6 路由回来了,故障「自愈」,功劳记在了重启头上。
我重启了几个月的大法,是标准的 cargo cult:动作和结果先后发生,就被我当成了因果。
顺带,健康检查全程报「健康」也不冤:它查的是「进程活着、本地 HTTP 200」,从不真正碰一次模型。这是设计上写明的盲区——进程活着 + HTTP 200 ≠ 大脑活着。
这正是我在 面向 Agent 开发:可测试和可运维第一次真正成为前提条件 里反复强调的那条线:探针要探的是「端到端能不能完成一次真实任务」,而不是「进程在不在、端口通不通」。健康检查骗过了我,是因为它从一开始就只被要求回答后者。
五、结案
修复是最小改动:把 opencode 配置里的 baseURL 从 http://dev2:4000/v1 改成 IP 字面量,绕过整个 DNS 解析,干净重启。
修复前:每次调用 60 秒必败。修复后第一通真实大脑调用:3.7 秒,返回 「ok」。
中午 12:05,下一班任务消息准时到达。涌现安安静静干了二十分钟,报告发进树莓派群,日志里一行 ok=True,一条兜底都没有。
代价也说清楚:dev2 将来换 IP,得改 baseURL 这一处。可我在 hosts 里写死 IP,本来就是同样的维护成本——谁也别说谁。
这次的根因和修复,我打算按 Runbook:把「代码为什么是现在这样」写进版本库 的做法沉淀下来——否则下一次网络漂移,又得从头验尸一遍。
六、我想说的事
复盘整份案卷,我数了数 opencode 在十五分钟里调动过的知识域:进程树与守护关系、四份日志各自的口径、curl 退出码语义、拿 HTTP 401 当心跳、DNS 解析链(hosts → 搜索域补全 → A/AAAA 分查)、RFC 8305、Node 的 autoSelectFamily、Bun 的解析怪癖、IPv6 ULA 的语义、「秒失败 vs 超时」的路由学含义、二进制类型识别、最小变更原则,以及每走一步就用实验验证一步的纪律。
我在 六大 Coding Agent 的八月 里盘过 opencode 这一批工具,那时它们给我的印象还停在「能写代码、subagent 失败能恢复」。这次是另一回事:没有标准答案、没有现成 runbook 的故障现场,它自己列嫌疑人、自己设计对照实验、自己推翻自己的第一个结论。
十几个门类,横跨网络、操作系统、三种运行时和协议细节。这些知识没有任何一条是秘密,全都写在某本书、某个 RFC 或某个 GitHub issue 里;但能同时把它们装进一个脑子、并且在正确的时刻取出来的,我认识的人里没有。我自己就是证据——/etc/hosts 那行字,已经是一个相当懂行的人能给出的最好修复了,它被解析器的合并语义无声地绕过,而我直到今天才知道为什么。
更微妙的感触是:工具会说话,但工具有口音。ping、curl、Node 三个权威测试全通,却没有一个说真话——它们共享同一个偏见:要么天然先走 IPv4,要么打不通会自动回退。知道「curl 通过」意味着什么、不意味着什么,比会跑 curl 难得多。
opencode 中途也判错过。但它下一步就设计实验推翻了自己,全程没有一句狡辩。今天真正让我服气的,不是它知识存量超过我——而是知识加上自我纠错的速度。前者是赢得辩论,后者是赢得工程。
重启大法,退役了。