一句「服务暂时异常」背后:撞锁与「答案已生成却未送达」两条链路
导读:本文诊断一句「服务暂时异常」背后两条完全不同的成因链路:一是长任务占住会话并发锁,新消息毫秒级被 409 拒绝并反复重排(24 小时撞锁约 4279 行、涉及 315 个会话、占同期请求约一成);二是答案已生成约 17KB(reason=ok)却在渲染层提前收场被判空答(answer_len=0),约 84% 的空答集中在渲染超 600 秒的长任务上,指向一条隐性截止线。核心教训是兜底文案语义与真实原因错配会误导用户与排查方向,沉淀了「文案-链路双向定位」「跨渠道对比」「时长分桶找隐性截止线」等可迁移方法。适合排查 LLM 流式应用收场一致性、设计兜底文案与超时策略的工程师阅读。 分类:AI编程 | 标签:Agent、缺陷诊断、流式输出、超时
维护一款 AI 智能体应用时,用户报障最常带回来的截图就是一行「服务暂时异常,请稍后再试。」。第一反应通常是:服务超时了?服务挂了?
这次排查颠覆了我的直觉——这句话根本不是平台下发的故障提示,而是我们自家代码里的兜底常量,由应用自己主动发出的。更麻烦的是,同一句文案背后藏着两条完全不同的成因链路:一条是「撞锁」,另一条是「答案已生成却未送达」。本文记录这次排查的完整思路,以及沉淀下来的几条可迁移方法。
先给结论,再讲过程:不是超时,也不是服务故障。 被拒绝的请求都在 8~84 毫秒内返回,没有任何等待到超时的迹象;文案来源定位到自家仓库常量,对应请求返回的是业务语义的 409,不是 5xx。
现象:同一句兜底文案,两个"看起来一样"的用户
同一批报障里,有两个用户的症状几乎一模一样,都是收到「服务暂时异常,请稍后再试。」,但背后的东西完全不同。
用户 A(某渠道 · 员工侧):提出一个问题后,这一轮持续了约 48 分钟。期间用户连发多条消息,全部被秒拒,然后被反复重排;用户以为系统坏了,持续重发,最终看到的仍然是那句兜底文案。
用户 B(某渠道 · 员工侧):发了一条简短的追问(就两个字),约 11 分钟后收到同样一句「服务暂时异常」。没有撞锁,没有并发等待——24 小时内该会话的锁冲突记录为 0 条。
两个案例共用同一个 UI 文案,但一个是「我这轮根本没产出」,另一个是「我产出了但没送达」。正式排查前先做两条快速判定:被拒请求毫秒级返回 → 排除超时;文案来自自家常量、返回业务 409 而非 5xx → 排除服务故障。剩下的问题变成:都有哪些链路会走到这句文案? 顺着这条线,挖出了两条链路。
链路一:撞锁——48 分钟长请求占着会话并发锁
机制:同会话同一时刻只允许一个任务
产品上,同一用户会话同一时刻只允许一个 Agent 任务运行(避免上下文互相覆盖)。新消息进来时,agent 侧会先抢这把"会话并发锁":抢到(acquired=1)就正常开流,抢不到(acquired=0)就立即返回 409,不创建生产者、不产出任何帧。
证据:一条 48 分钟的长请求把锁占死了
在自建观测平台的 trace 里,用户 A 的会话有一条请求,latency 约 2884 秒(≈48 分钟),全程持有该会话的锁。观测节点约 480 个,其中工具调用 70 次、生成 33 次——这是一轮非常重的长任务。正是这条请求,成为后续所有撞锁的前置条件。
撞锁时的关键日志长这样:
[chat.agent.recv] thread_id=… user_message_length=19
[chat.agent.lock] request_id=… acquired=0 elapsed=13ms ← 没抢到锁
[chat.agent.done] request_id=… frames=0 output_bytes=0 reason=lock_conflict total_ms=14
关键判读三点:acquired=0(没抢到锁)、frames=0(没产出任何内容)、total_ms 十几毫秒(毫秒级返回,根本没有等待到超时)。上游 409 的响应体也是业务语义:「上一条回复仍在进行中」。
排队、重排与"放弃"
编排层拿到 409 后并不直接结束,而是轮询「会话是否仍忙」并重试,直到本轮重排预算耗尽;预算耗尽后把消息重排到队尾,等待下次尝试(attempt 累加)。用户 A 的 48 分钟内,消息被反复重排 24 次以上,attempt 最高到 7,单条累计等待最长约 110 秒。
到这里,产品缺陷就浮出来了——代码里其实写好了准确话术,但没走到它:
| 用户可见文案 | 触发条件 | 24h 实发 |
|---|---|---|
| 「上一条回复仍在生成中,请稍候再试。」(准确话术常量) | 409 且重排预算耗尽 | 该渠道 0 次(对照渠道有) |
| 「服务暂时异常,请稍后再试。」(兜底常量) | 本轮无可渲染内容 / 未匹配到具体错误的流异常 | 约百次 |
对照渠道(另一个用户体验渠道)实现了「放弃时主动告知」——重排预算耗尽后给用户发准确话术;而报障的这个渠道缺这一段实现,于是所有"排队等不到结果"的用户全部落进兜底文案。
规模:这不是个例,是长任务期间的普遍现象
24 小时内撞锁命中约 4,279 行日志,涉及 315 个不同会话;同期请求约 3.7 万次,撞锁占比约一成(上述数据已按数量级脱敏示意)。仅其中某个 15 分钟窗口内,撞锁涉及的会话就有 16 个——而且当时查询有上限,实际只会更多。
撞锁直接导致的体验是:用户连发消息全被秒拒,且收不到任何"还在处理上一条"的提示,只能干等,误判系统故障后反复重发,把症状进一步放大。
链路二:空答丢弃——答案已生成(约 17KB)却被渲染层提前收场
用户 B 的案例根因完全不同,也更隐蔽:答案真的生成出来了,但被丢掉了。
证据:生产侧 reason=ok,渲染侧 answer_len=0
把双侧日志放到一起看,矛盾一目了然:
agent 侧(生产方)产出正常:
[chat.agent.lock] acquired=1 ← 拿到锁,不是撞锁
[chat.agent.llm] sequence=5 output_tokens=1153 tools=- ← 答案正文在这一轮
[chat.agent.done] frames=110 output_bytes=17287 reason=ok
total_ms=712600 elapsed≈11.9min ← 自认为"正常完成"
渲染侧(消费方)提前收场:
render_start ← 开始渲染
render_done answer_len=0 ← 只持续了约 604 秒,比 agent 早收场 1 分 41 秒
answer_len=0 就意味着以空内容收场,落进兜底常量 →「服务暂时异常,请稍后再试。」,与用户所见完全一致。也就是说:agent 判定"我答完了"(reason=ok、110 帧、约 17KB),渲染层却判定"本轮什么都没产出"(answer_len=0),两者对同一收场事件的认知不一致,答案就在这个缝隙里丢了。
为什么 10 分钟里一个可见字符都没有
看那 48 分钟请求的轮次明细(数据已脱敏示意):
| 轮次 | output_tokens | 耗时 | 工具 | content |
|---|---|---|---|---|
| seq1 | 2494 | 3.9min | 外部 HTTP 请求工具 | 空 |
| seq2 | 202 | 21.7s | 执行类工具 | 空 |
| seq3 | 578 | 58.9s | 执行类工具 | 空 |
| seq4 | 1960 | 3.3min | 执行类工具 | 空 |
| seq5 | 1153 | 2.1min | 无 | 答案正文 |
前四轮模型输出全是「推理 + 工具调用」,content 为空;而渲染侧的内容清洗模块会把推理内容剥掉,不给用户展示。于是约 17KB 的帧里,前 10 分钟没有任何可展示文本——用户看到的永远是"空白 + 等待"。第 5 轮才轮到答案正文,但渲染循环在它到达前 1 分 41 秒就提前结束了。
排除了哪些解释
为了确认"渲染提前收场"而非其他原因,逐条排除了候选解释:
- 同会话并发锁冲突:排除,该会话 24h 内锁冲突 0 条,且拿到锁时 acquired=1;
- 渠道侧关闭流式连接:排除,相关事件计数在关键时间点前后无新增;
- 消息被撤回/删除:排除,无相关终止事件;
- 上游流异常:排除,该时间窗口渲染侧无任何 ERROR / 流失败日志(异常路径必然打 ERROR);
- agent 侧断连:排除,无断连记录。
也就是说,渲染循环是"正常结束"的——它收到流结束信号就收场,而 agent 的生产者其实还在继续跑。渲染层的收场逻辑与 agent 的完成判定没有对齐,这就是丢答案的位置。
触发规律:600 秒附近藏着一条"截止线"
第二例里有个非常可疑的数字:渲染只持续了 604 秒。为什么不是 500 秒、不是 700 秒,偏偏卡在 600 秒上下?把统计数据拉出来看,规律立刻清晰了。
把某渠道 24 小时内每一组 render_start / render_done 配对(成功组约千组),按「本轮是否是空答」统计渲染时长:
| 渲染时长 | 空答组数 | 说明 |
|---|---|---|
| < 300 秒 | 11 | 短请求,另有其他成因 |
| 300–599 秒 | 1 | 几乎为零 |
| ≥ 600 秒 | 63 | 空答主战场(约占 84%) |
- 空答时长中位约 603 秒,最大约 1604 秒;
- 作为对照,非空答的渲染时长中位数只有约 100 秒。
空答不是随机分布,而是集中在「渲染超过 10 分钟」的长任务上,且 300–599 秒区间几乎不存在——这个"断层"强烈指向 600 秒处存在一条截止线。源码注释也印证了这一点:「只能等客户端断连或网关 600s 超时才收场」。
但要小心:同一份数据里也有超过 600 秒、内容非空的长渲染(最长约 1800 秒),说明并非"到 600 秒一律掐断"。截止线的确切语义(整体响应时长上限,还是"最后一帧之后 600 秒"的读超时)尚未最终坐实。把"断层"当作线索、把"反例"当作刹车,是这条统计方法的价值所在。
另外一个值得注意的事实:这个问题并非某单个渠道特有。两个渠道的空答占比都在 7%~10% 的量级(对照渠道略高),共性是长任务 + 前段零用户可见文本,与渠道无关;差别只在可观测性——对照渠道的渲染日志会给出「内容为空」的显式分类,报障的渠道只留下 answer_len=0,排查起来难得多。
为什么文案会骗人:根因归纳
把两条链路放到一起,看这句「服务暂时异常」是怎么"骗"住用户的:
兜底文案的本质是最后一道网——任何"没有可渲染内容"的收场都会落进来。因此它天然是多个根因的汇聚点:
- 撞锁:本轮没产出任何帧(
frames=0),毫秒级 409 后无可渲染内容; - 空答:有产出(约 17KB、reason=ok),但渲染层提前收场 + 内容清洗把推理内容剥空,最终
answer_len=0; - 未匹配到具体错误的流异常:同样落到兜底。
而文案的语义是"服务故障"。三个成因里没有一个是服务故障——文案语义与真实语义错配,把用户引向错误的方向(反复重发),把症状进一步放大。
更深一层,这次问题能发生,是因为两套系统对"一轮是否完成 / 是否有内容"的判断互相独立:
- agent 侧按自己的运行状态判定(
reason=ok即"我答完了"); - 渲染侧按自己收到的流结束信号判定("没等到内容"就以空内容收场);
- 两者之上再叠加渠道实现差异(某渠道缺"放弃"通知、可观测字段更少)。
同一句话,被两套各自"合理"的逻辑分别输出,用户就成了最终承受错配的人。
排查方法沉淀
这次排查里最有复用价值的,是下面几条方法:
1. 跨渠道对比定位。 同一产品往往有多个用户体验渠道(这里是 IM 平台 A / 平台 B)。关键短信号:准确话术在该渠道 24h 内 0 次、对照渠道有——立刻说明缺陷是"某渠道缺实现"而非全局逻辑错误。渠道之间的差异同时就是可观测性的差异:对照渠道日志有「内容为空」显式分类,报障渠道只有 answer_len=0。对比渠道,既定位缺陷范围,也揭示排查难度差异。
2. 「文案-链路」双向定位。 同一报错文案可能对应多种根因时:先列全所有可能输出该文案的代码出口(这里就是"空内容收场"落兜底及其上游);再从文案反查每个环节各自贡献了什么(撞锁帧数为 0 / 空答被清洗剥空 / 未匹配异常)。正向找出口,反向查贡献,两条线汇合处就是根因集合。配合一张可证伪假设表逐条排除:
| 假设 | 判定 | 依据 / 排除方式 |
|---|---|---|
| 服务超时导致失败 | 排除 | 被拒请求 8~84ms 返回,无等待到超时的迹象 |
| 服务故障 / 异常响应 | 排除 | 文案来源为自家常量,且请求返回业务 409 而非 5xx |
| 同会话并发锁冲突 | 证实 | agent 侧 reason=lock_conflict 与上游 409 body 双侧一致 |
| 用户可见文案与真实原因错配 | 证实 | 代码链路完整可读;该渠道 24h 内"放弃通知"0 次,对照渠道有 |
| 48 分钟长任务成因(怀疑与帧日志串行化写入开销同源) | 未验证 | 样本方向吻合,待修复上线后对比 |
| 定位到具体哪条消息触发空答 | 未定位 | 会话内无渲染完成记录可对齐 |
3. 双侧重收场一致性核对。 生产(agent)侧与消费(渲染)侧各自记录"本轮是否完成 / 产出多少":同一事件两侧结论对不上(reason=ok, frames=110, 17KB vs answer_len=0),就是答案丢失点。给两侧都留结构化的收场字段(reason、frames、output_bytes、answer_len),能把这类"答案在缝隙里丢"的问题从"玄学"变成"对账"。
4. 超时截止线触发规律统计。 用「时长分桶 × 异常率」交叉表找隐性截止线:300–599 秒区间空答几乎为零、600 秒以上成为主战场——断层即线索;再保留反例(超过截止线但正常的长渲染),避免把相关当因果。这一招对有网关/反代层超时配置的场景几乎通用。
5. 可复用的检索路径。 事后回溯时,先按「原因标识 + 渠道」检索(lock_conflict / thread_lock / answer_len=0 这类语义化埋点,比裸错误码好查得多),再按 trace 下钻单条长请求。语义化埋点 + 结构化收场字段,是这类排查的基础设施。
6. 修复优先级(建议合并成一个专项)。 核心不是逐条改文案,而是「长任务的用户反馈」与「兜底文案的语义」两件事:(a) answer_len=0 不再报「服务暂时异常」,区分"本轮未产出可展示内容 / 处理超时",并提示重试或查看历史;(b) 补齐该渠道的"放弃"通知,对齐对照渠道;(c) 长任务(超过若干分钟)持续给用户"正在处理中"反馈,避免零提示干等;(d) 核查 600 秒截止线与长任务成因,被上游截断时尝试用"先查 run 状态、再续读流"的方式把答案接回来。
结语
用户以为系统坏了;而系统里没有任何一个环节报错——agent 正常答完,渲染层正常收场,只是两者对"收场"的理解差了 1 分 41 秒。排查这类问题,最重要的不是看"用户看到了什么",而是拆出"哪些链路都能产生用户看到的这句话",逐条证伪,再用统计定位隐性边界。兜底文案只是最后一道防线,真正决定系统可信度的,是每一层"收场"是否能对齐。