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

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

一、问题背景

最近在使用 API7 AI 网关时,我反复遇到 worker CPU 打满的问题。相关修复分散在多个 APISIX PR 中:

时间 问题与修复 解决的核心问题
2026-04-18 ~ 2026-04-20 PR #13254 下游客户端断开后,通过同步 flush 的错误传播终止上游流
2026-04-19 ~ 2026-04-20 PR #13255 已反馈 在流式循环中增加显式调度点,避免单个请求长期占用 worker
2026-05-12 ~ 2026-05-13 PR #13356 已反馈 缓存 post_arg.* 的请求体解析结果,避免重复 decode
2026-05-15 ~ 2026-05-18 PR #13377 已反馈 缓存 AI 请求体的 JSON 解析结果,消除重复 decode
2026-05-19 ~ 2026-05-22 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 循环”,而是默认了几个未经验证的运行时假设:

1
2
3
4
5
调用了网络 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 onngx.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)”,而是面对任何事件驱动系统时,都主动追问:执行权何时归还?数据在哪里缓冲?重复工作发生了几次?取消如何传播?资源上界在哪里?

参考资料