运行时行为盲区:API7 AI 网关 CPU 饱和故障的 AI 辅助复盘

代码逻辑正确,不代表运行时行为正确。本文复盘 API7 AI 网关连续出现的 CPU 饱和问题,以及如何借助 AI 扩展排查思路,再用源码、PR 和实验约束 AI 的结论。

一、问题背景

最近在使用 API7 AI 网关时,我反复遇到 worker CPU 打满的问题。API7 是基于 Apache APISIX 的商业发行版,相关修复最终都提交到了上游 APISIX,分散在多个 PR 中:

时间 问题与修复 解决的核心问题
~ PR #13254 下游客户端断开后,通过同步 flush 的错误传播终止上游流
~ PR #13255 已反馈 在流式循环中增加显式调度点,避免单个请求长期占用 worker
~ PR #13356 已反馈 缓存 post_arg.* 的请求体解析结果,避免重复 decode
~ PR #13377 已反馈 缓存 AI 请求体的 JSON 解析结果,消除重复 decode
~ PR #13391 优化 SSE 解析和 flush 策略,并增加流式请求保护机制

最初我试图找到一个可以解释全部现象的“根本原因”,后来发现这种归因方式本身就是误区。这几个 PR 实际上分别处理了四类问题:

  1. 取消传播:下游已经离开,上游工作是否还在继续;
  2. 调度公平性:一个协程是否长时间占用 worker;
  3. 单位工作成本:每个请求、每个 chunk 做了多少解析、复制与分配;
  4. flush 与背压:追求逐 token 实时输出时,需要付出多少系统调用和调度成本。

它们会互相放大,但不是同一个问题,也不能用同一个修复解决。

本文最重要的结论:yield 解决公平性,缓存和批处理降低 CPU 成本,取消传播回收无效工作,背压约束生产者与消费者的速度差。

二、为什么静态阅读代码时没看出来

当时阅读代码时,我主要在检查:

  • 分支是否正确;
  • 错误是否处理;
  • 数据是否能完整转发;
  • 循环是否有退出条件。

从这个角度看,下面的流式循环没有明显错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
while true do
local chunk, err = body_reader()
if not chunk then
break
end

local ok, flush_err = send_to_downstream(chunk)
if not ok then
close_upstream(flush_err)
return
end

ngx.sleep(0)
end

但运行时审查需要继续追问:

  • body_reader() 这一轮会等待,还是立即返回?
  • send_to_downstream() 会等待,还是只把数据放进缓冲区?
  • 每轮循环创建了多少临时字符串和 Lua 对象?
  • 一秒可能执行多少轮?
  • 下游断开后,谁负责终止上游?
  • 如果上游持续高速输出,有没有时间、字节数或速率预算?

因此,真正的盲区不是“没有看懂 while 循环”,而是默认了几个未经验证的运行时假设:

代码表象 关系 实际不保证
调用了网络 API ≠ 本轮一定发生 I/O 等待
调用了 flush ≠ 客户端应用已经收到数据
代码中有 yield ≠ 总 CPU 消耗一定下降
客户端已断开 ≠ 上游工作自动停止
单轮逻辑很轻 ≠ 高频执行后的总成本很低

三、问题一:下游取消后,上游为什么还在运行

3.1 上游连接和下游连接是两条独立链路

AI 网关位于客户端与大模型之间:

1
客户端  <-- downstream -->  API7/APISIX  <-- upstream -->  LLM

网关从上游读取响应,并不意味着它同时知道下游连接的最新状态。

如果客户端关闭浏览器、取消请求或发送 TCP RST,网关仍可能继续:

  1. 从 LLM 读取数据;
  2. 解析 SSE;
  3. 执行响应过滤器;
  4. 尝试向下游输出。

只有当下游关闭事件被 Nginx/OpenResty 观察到,并传播到当前 Lua 请求处理逻辑后,代码才有机会停止上游工作。

3.2 ngx.flush(true) 到底保证什么

OpenResty 对 ngx.flush(wait?) 的定义是:

  • ngx.flush(false):发起异步 flush,不等待输出写入系统发送缓冲区;
  • ngx.flush(true):等待输出写入系统发送缓冲区,或等待到 send_timeout/错误发生。

这里必须避免一个常见误解:

ngx.flush(true) 等待的是数据进入系统发送缓冲区,不等于客户端应用已经收到或消费了数据。

同步 flush 的价值在于,它会以可等待的方式推进下游输出。当写入路径已经观察到连接错误时,调用可以返回失败,APISIX 随后关闭上游响应,避免继续处理已经无人接收的数据。

这也是 PR #13254 处理的核心问题。

更准确的时序是:

1
2
3
4
5
6
7
8
9
T0  客户端取消请求
↓
T1 内核/Nginx 在某个时刻观察到连接关闭
↓
T2 网关推进下游输出或收到连接关闭事件
↓
T3 flush/write 返回错误
↓
T4 APISIX 终止循环并关闭上游连接

这个过程不是“客户端一断开,Lua 代码就立即知道”,也不能理解为“每次 ngx.flush(true) 都能百分之百实时探测客户端存活”。

OpenResty 还提供 lua_check_client_abort on 和 ngx.on_abort 用于监控下游提前关闭。该机制默认关闭,也有自己的适用条件和运行成本。工程上需要根据请求模型选择:

  • 通过输出错误传播终止上游;
  • 显式监听 client abort;
  • 或组合使用两者。

3.3 这一问题的本质

这个问题首先是取消传播和资源生命周期管理问题,而不是单纯的调度问题:

1
2
3
4
5
6
7
下游请求已经没有价值
↓
上游请求没有及时取消
↓
继续读取、解析、过滤和 flush
↓
产生无效 CPU、网络和连接消耗

代码审查时应当把双向代理看成两个生命周期不同的资源,而不是一个天然同步结束的请求。

四、问题二:协作式调度与 ngx.sleep(0)

4.1 cosocket 调用不一定发生 yield

OpenResty worker 通常以单线程事件循环运行。Lua coroutine 只有在执行到可让出的操作时,才会把控制权归还给 Nginx 调度器。

cosocket API 是非阻塞的,但“调用了 cosocket”不等于“本次调用一定 yield”:

1
2
3
4
5
6
7
8
socket 暂无数据
→ 注册读事件
→ 当前 coroutine yield
→ worker 可以处理其他事件

socket 缓冲区已有数据
→ receive() 立即返回
→ 当前 coroutine 继续执行

当上游 LLM 以突发方式返回大量小 chunk 时,连续多次 body_reader() 都可能立即完成。若循环中没有其他显式调度点,一个请求就可能连续占用 worker 较长时间。

4.2 ngx.sleep(0) 是真实的调度让出

PR #13255 在每轮流式处理后加入了:

1
ngx.sleep(0)

它会通过零延时 timer 把控制权交还给 Nginx 调度器。因此它不是“形式上让出,实际上没让”,而是一个真实的调度点。

它能解决的是:

  • 防止一个请求无限连续执行;
  • 给健康检查、定时任务和其他请求运行机会;
  • 改善同一 worker 内的尾延迟和调度公平性。

它不能解决的是:

  • 减少 JSON/SSE 解析次数;
  • 减少每个 chunk 的字符串分配;
  • 限制单条流每秒处理多少 chunk;
  • 限制上游总响应字节数;
  • 对快速上游实施背压;
  • 保证 worker CPU 下降。

因此,Issue #13256 和后续代码把它称为 workaround 是合理的:它防止单请求垄断 worker,但不限制单请求消耗多少 CPU。

4.3 为什么增加 yield 后 CPU 仍可能是 100%

假设一条流每秒产生大量小 chunk,每个 chunk 都需要解析、转换和输出:

1
2
3
4
5
6
7
处理 chunk A
→ ngx.sleep(0),归还调度权
→ 处理其他 ready 任务
→ 再次调度当前请求
处理 chunk B
→ ngx.sleep(0)
→ ……

调度是公平的,但只要 ready task 足够多,或者当前流持续有工作,worker 仍然可以始终处于 runnable 状态,CPU 使用率自然可能接近 100%。

这时需要区分两个指标:

指标 关注的问题
调度公平性 其他请求是否得到运行机会,尾延迟是否失控
CPU 利用率 所有请求加起来需要执行多少工作

ngx.sleep(0) 主要改善前者,不直接降低后者。

如果 yield 时没有其他 ready coroutine,当前请求随后再次运行是正常现象;此时没有其他任务正在被“饿死”。当其他任务变为 ready 时,显式 yield 才为它们提供调度机会。

五、问题三:重复 JSON decode 放大单请求成本

5.1 post_arg.* 的重复解析

表达式系统在多次访问 post_arg.* 时,如果每次都重新读取并解析请求体,就会重复执行相同工作。

PR #13356 通过缓存解析结果,避免同一个请求在表达式匹配过程中多次 decode。

这一问题的特点是:

  • 单次调用看起来成本可接受;
  • 规则数量增加后会线性放大;
  • 请求体越大,重复解析越昂贵;
  • 它发生在请求处理阶段,与流式响应循环不是同一个问题。

5.2 AI 请求路径中的三次 decode

PR #13377 表明,APISIX AI 请求路径曾对同一个 JSON 请求体执行三次 decode。缓存解析后的 Lua 对象,可以消除其中的重复解析。

这里需要准确描述“无效工作”:

  • 在当前设计下,至少一次 JSON decode 通常是必要的;
  • 可以消除的是后续重复 decode;
  • 将修改后的请求发送给上游时,JSON encode 可能是必要步骤,不能在没有证据时和重复 decode 一起定义为无效开销。

因此,更严谨的结论是:

重复 JSON decode 是已经由源码和 PR 验证的 CPU 放大因素;它是否是某次生产 CPU 饱和的唯一根因,还需要结合线上 profile、请求体大小和修改前后对照数据确认。

5.3 为什么代码审查容易漏掉

开发者通常会在每个函数内部判断复杂度,却不容易注意同一份数据跨层重复转换:

1
2
3
4
5
6
原始请求体
→ 表达式系统 decode
→ AI provider decode
→ 插件再次 decode
→ 修改对象
→ encode 后发往上游

每一步局部上都可能是“合理的”,但组合起来就形成了重复工作。

运行时审查需要追踪数据生命周期:

  • 原始字节在哪里读取?
  • 第一次结构化解析在哪里发生?
  • 解析结果能否放入 request context 复用?
  • 哪些层只需要读取,哪些层确实会修改?
  • encode 是否只在最终边界执行一次?

六、问题四:流式响应的每 chunk 成本

6.1 不要把 body_reader() 想象成必然阻塞

lua-resty-http 的 chunked body reader 会维护 HTTP chunk framing 状态,并调用 cosocket receive() 读取 chunk size 和内容。

它并不是一个简单的 self.buffer Lua 字符串缓存器,因此不能在没有绑定准确依赖版本和源码的情况下,假设“第一次读取进入 self.buffer,后续读取完全不触碰 cosocket”。

但核心风险仍然成立:

即使代码调用了 sock:receive(),只要 cosocket/Nginx 接收缓冲区里已经有足够数据,它仍可能立即返回,不发生 I/O 等待。

这也是为什么评估循环时不能只数“网络 API 调用了几次”,而要判断这些调用在目标负载下是否真的等待。

6.2 高频小 chunk 为什么昂贵

当上游返回相同总字节数时,小 chunk 越多,固定成本执行次数越多:

1
2
3
4
5
6
读取 chunk
→ 拼接或扫描 SSE 边界
→ JSON 解析/转换
→ 执行响应过滤器
→ 写入下游
→ flush

例如,同样是 1 MiB 数据:

  • 1,024 个 1 KiB chunk;
  • 16,384 个 64 B chunk;

后者会执行更多循环、函数调用、边界扫描、临时对象分配和 flush。即使每轮都很快,总成本也可能显著上升。

6.3 实时性与吞吐的 flush 取舍

逐 chunk 同步 flush 的优点是首 token 和 token 间延迟低,也更容易及时暴露下游写错误;缺点是 flush 次数与 chunk 数量直接相关。

周期 flush 会把一个短时间窗口内的多个 chunk 合并输出:

1
2
逐 chunk flush:chunk → flush → chunk → flush → chunk → flush
周期 flush: chunk → chunk → chunk → 每 10ms flush

这减少了 flush 和调度次数,但会引入一个可控的额外延迟窗口。

PR #13391 的处理不是简单删除 flush,而是把几种机制拆开:

  • 优化 SSE framing,减少字符串扫描和分配;
  • 增加 streaming_flush_interval_ms,默认以 10ms 周期批量 flush;
  • 正数间隔下由后台 light thread 周期执行异步 flush;
  • 配置为 0 时保留逐 chunk 同步 flush;
  • 流结束时执行最终同步 flush;
  • 传播异步 flush 错误;
  • 增加最大流持续时间和最大响应字节数保护;
  • 继续保留 ngx.sleep(0) 作为公平性保护。

这个设计恰好说明:

1
2
3
4
ngx.sleep(0)        → 调度公平性
SSE parser 优化 → 降低单位 chunk CPU 成本
周期 flush → 降低高频输出成本
duration/byte limit → 限制异常流的资源上界

四者解决的不是同一个问题。

6.4 周期 flush 仍不等于完整背压

背压的目标是:当下游消费速度赶不上上游生产速度时,让生产者减速或限制中间缓冲增长。

周期 flush 主要减少输出频率,并不必然让上游减速。完整的流式资源治理还需要考虑:

  • 最大流持续时间;
  • 最大响应字节数;
  • 最大未发送缓冲量;
  • 上游读取节奏;
  • 下游写入速度;
  • 超限后的取消和连接关闭策略。

因此,不应把“增加 flush interval”直接等同为“已经实现背压”。

七、重新整理故障因果链

经过拆分后,几个问题可以放进一条更准确的资源模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
请求体重复 JSON decode
→ 增加每个请求的固定 CPU 成本

上游高速产生大量小 chunk
→ 放大 SSE 解析、字符串分配和 flush 次数
→ 增加流式阶段 CPU 成本

流式循环缺少显式调度点
→ 单个请求可能连续占用 worker
→ 其他请求和定时任务尾延迟恶化

下游取消未及时传播
→ 已经失去业务价值的上游流仍继续运行
→ 前述所有 CPU 和网络成本继续发生

这四条链路会互相叠加。例如:

1
2
3
4
5
下游已经取消
+ 上游仍高速输出
+ 每个 chunk 处理成本高
+ 缺少资源上限
= 无效工作持续占用 worker

但仍然要避免一句“没有 yield 导致 CPU 打满”覆盖全部事实。

更合理的故障分类是:

维度 故障表现 主要修复
生命周期 下游取消后上游继续运行 abort propagation、关闭上游
公平性 单请求长期占用 worker 显式 yield
效率 重复 decode、SSE 分配、高频 flush 缓存、parser 优化、批量 flush
资源上界 异常长流或大响应无限消耗资源 duration/bytes/buffer limit
背压 上游长期快于下游 限速、暂停读取或有界缓冲策略

八、AI 在这次复盘中应该扮演什么角色

我最初把问题提交给 Claude Opus,希望 AI 帮助分析:

  • 为什么阅读代码时没有发现问题;
  • 需要补充哪些 OpenResty 和协作式调度知识;
  • 如何形成可复用的代码审查 checklist。

AI 的价值首先是快速扩展假设空间:

1
2
3
4
5
6
7
8
CPU 饱和
├─ 重复 JSON 编解码?
├─ 流式循环没有 yield?
├─ cosocket 持续立即返回?
├─ 高频 flush?
├─ SSE parser 分配过多?
├─ 下游取消未传播?
└─ 缺少流持续时间和字节数上限?

但 AI 给出的解释不能直接作为事故结论。它可能:

  • 根据常见库实现虚构当前版本不存在的 self.buffer;
  • 把“调用可能立即返回”夸大为“绝对不会 yield”;
  • 把 ngx.sleep(0) 的局限错误解释成“它没有真正让出”;
  • 把优化 PR 的 benchmark 当成生产事故根因证明;
  • 混淆系统发送缓冲区和客户端实际接收。

因此,我认为更可靠的 AI 辅助复盘流程是:

第一步:让 AI 生成候选假设

要求 AI 尽量列全:调度、I/O、缓冲、序列化、生命周期、超时、限流和可观测性。

第二步:固定准确版本

记录并提供:

  • API7/APISIX commit;
  • OpenResty 版本;
  • lua-resty-http 版本;
  • 配置文件;
  • 实际调用链。

没有版本约束的源码分析,很容易分析到另一版实现。

第三步:建立证据等级

等级 示例 能说明什么
现象 worker CPU 100%、延迟上升 确实发生了问题
源码 同一请求体被 decode 三次 存在重复工作
profile JSON decode 占用主要 CPU 重复工作与线上 CPU 相关
对照实验 缓存后相同流量 CPU 明显下降 建立较强因果关系
生产验证 发布后指标恢复且问题不再复现 修复对事故有效

PR、源码和 benchmark 可以证明问题存在,但要把它称为某次生产事故的“根本原因”,最好仍有 profile 和对照实验。

第四步:主动验证 AI 最自信的结论

优先检查带有以下词语的回答:

  • “一定”;
  • “绝对”;
  • “必然”;
  • “等于没有”;
  • “唯一根因”。

越确定的表述,越值得回到官方文档和准确版本源码验证。

第五步:让实验而不是叙事完成归因

建议使用固定流量回放,分别控制变量:

实验 观察指标 目标
保留/移除 ngx.sleep(0) 其他请求 P99、worker event loop 延迟 验证公平性
缓存/重复 JSON decode CPU profile、单请求 CPU time 验证解析成本
逐 chunk/周期 flush CPU、首 token 延迟、token 间延迟 量化实时性与吞吐取舍
大/小 chunk,相同总字节数 循环次数、分配量、CPU 验证 per-chunk 固定成本
客户端中途取消 上游关闭延迟、取消后字节数 验证取消传播
上游快、下游慢 缓冲量、内存、读取速率 验证背压和资源上界

AI 最适合帮助设计这些实验、解释结果和发现遗漏,而不是替代实验。

九、运行时代码审查 Checklist

9.1 循环与调度

  • 循环单轮在最坏情况下做多少 CPU 工作?
  • 循环中的 I/O 是否可能因为缓冲区已有数据而立即返回?
  • 是否存在明确且可验证的 yield 点?
  • yield 是为了公平性,还是错误地被当成了限流?
  • 一个请求是否可能连续处理大量 ready data?
  • 是否监控 worker event loop latency 和请求尾延迟?

9.2 取消与资源生命周期

  • 下游断开后,如何通知上游停止?
  • 上游连接、light thread、timer 是否都会被清理?
  • flush/write 错误是否完整传播?
  • 是否需要 lua_check_client_abort / ngx.on_abort?
  • 客户端取消后最多还会读取多少上游数据?
  • 是否有取消传播耗时指标?

9.3 缓冲与背压

  • 上游和下游分别有哪些缓冲层?
  • 上游比下游快时,数据会积累在哪里?
  • 缓冲区是否有上限?
  • 是否能暂停或降低上游读取速度?
  • flush 策略是逐 chunk、按字节还是按时间窗口?
  • 实时性目标是否有明确数值,而不是默认“越快越好”?

9.4 编解码与内存分配

  • 同一请求体被 decode 了几次?
  • 解析结果能否在 request context 中复用?
  • 是否存在 .. 循环拼接导致大量临时字符串?
  • SSE 边界扫描是否重复遍历相同数据?
  • encode 是否只在最终边界发生?
  • 是否按请求体大小和 chunk 数量做过 benchmark?

9.5 资源上界

  • 单条流是否有最大持续时间?
  • 是否限制最大响应字节数?
  • 是否限制最大事件数或未发送缓冲量?
  • 超限后是否取消上游并记录原因?
  • 超时、取消、上游错误是否可以在指标中区分?

9.6 证据与可观测性

  • 是否记录精确版本和 commit?
  • 是否有 worker 维度的 CPU 和延迟指标?
  • 是否采集过 flame graph / CPU profile?
  • 是否记录请求体大小、响应字节数、chunk 数和流持续时间?
  • 是否有修复前后的同负载对照实验?
  • 文章中的每个“根因”是否都有对应证据?

十、最终总结

这次问题让我补上的并不只是某个 OpenResty API 的知识,而是一套运行时分析方法。

10.1 静态正确性只是起点

代码能够正确退出、正确转发数据,不代表它在高频、突发、取消或慢客户端场景下仍然高效。

10.2 不要把四类问题混成一个原因

  • 取消传播决定无效工作何时停止;
  • yield 决定调度是否公平;
  • 缓存和批处理决定单位工作成本;
  • 背压和资源上限决定异常流能消耗多少资源。

10.3 ngx.sleep(0) 有效,但能力边界必须说清楚

它确实让出调度权,可以缓解单请求垄断 worker;它不降低总解析量,不限速,也不构成背压。

10.4 网络 API 不一定等待

cosocket receive()、flush 等 API 的运行时行为取决于缓冲区、socket readiness 和调用参数。看见 I/O 调用,不能默认已经发生 yield。

10.5 AI 负责扩大搜索空间,证据负责收敛结论

AI 可以快速生成假设、解释机制和设计实验,但必须用准确版本源码、官方文档、profile 和对照实验校验。一个听起来完整的运行时故事,不一定是真实调用链。

真正需要培养的能力,不是背诵“哪里应该加一个 ngx.sleep(0)”,而是面对任何事件驱动系统时,都主动追问:执行权何时归还?数据在哪里缓冲?重复工作发生了几次?取消如何传播?资源上界在哪里?

参考资料


📚 系列文章 · 网关故障复盘