运行时行为盲区: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 实际上分别处理了四类问题:
- 取消传播:下游已经离开,上游工作是否还在继续;
- 调度公平性:一个协程是否长时间占用 worker;
- 单位工作成本:每个请求、每个 chunk 做了多少解析、复制与分配;
- flush 与背压:追求逐 token 实时输出时,需要付出多少系统调用和调度成本。
它们会互相放大,但不是同一个问题,也不能用同一个修复解决。
本文最重要的结论:yield 解决公平性,缓存和批处理降低 CPU 成本,取消传播回收无效工作,背压约束生产者与消费者的速度差。
二、为什么静态阅读代码时没看出来
当时阅读代码时,我主要在检查:
- 分支是否正确;
- 错误是否处理;
- 数据是否能完整转发;
- 循环是否有退出条件。
从这个角度看,下面的流式循环没有明显错误:
1 | while true do |
但运行时审查需要继续追问:
body_reader()这一轮会等待,还是立即返回?send_to_downstream()会等待,还是只把数据放进缓冲区?- 每轮循环创建了多少临时字符串和 Lua 对象?
- 一秒可能执行多少轮?
- 下游断开后,谁负责终止上游?
- 如果上游持续高速输出,有没有时间、字节数或速率预算?
因此,真正的盲区不是“没有看懂 while 循环”,而是默认了几个未经验证的运行时假设:
1 | 调用了网络 API ≠ 本轮一定发生 I/O 等待 |
三、问题一:下游取消后,上游为什么还在运行
3.1 上游连接和下游连接是两条独立链路
AI 网关位于客户端与大模型之间:
1 | 客户端 <-- downstream --> API7/APISIX <-- upstream --> LLM |
网关从上游读取响应,并不意味着它同时知道下游连接的最新状态。
如果客户端关闭浏览器、取消请求或发送 TCP RST,网关仍可能继续:
- 从 LLM 读取数据;
- 解析 SSE;
- 执行响应过滤器;
- 尝试向下游输出。
只有当下游关闭事件被 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 | T0 客户端取消请求 |
这个过程不是“客户端一断开,Lua 代码就立即知道”,也不能理解为“每次 ngx.flush(true) 都能百分之百实时探测客户端存活”。
OpenResty 还提供 lua_check_client_abort on 和 ngx.on_abort 用于监控下游提前关闭。该机制默认关闭,也有自己的适用条件和运行成本。工程上需要根据请求模型选择:
- 通过输出错误传播终止上游;
- 显式监听 client abort;
- 或组合使用两者。
3.3 这一问题的本质
这个问题首先是取消传播和资源生命周期管理问题,而不是单纯的调度问题:
1 | 下游请求已经没有价值 |
代码审查时应当把双向代理看成两个生命周期不同的资源,而不是一个天然同步结束的请求。
四、问题二:协作式调度与 ngx.sleep(0)
4.1 cosocket 调用不一定发生 yield
OpenResty worker 通常以单线程事件循环运行。Lua coroutine 只有在执行到可让出的操作时,才会把控制权归还给 Nginx 调度器。
cosocket API 是非阻塞的,但“调用了 cosocket”不等于“本次调用一定 yield”:
1 | socket 暂无数据 |
当上游 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 | 处理 chunk A |
调度是公平的,但只要 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 | 原始请求体 |
每一步局部上都可能是“合理的”,但组合起来就形成了重复工作。
运行时审查需要追踪数据生命周期:
- 原始字节在哪里读取?
- 第一次结构化解析在哪里发生?
- 解析结果能否放入 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 | 读取 chunk |
例如,同样是 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 | 逐 chunk flush:chunk → flush → chunk → flush → chunk → 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 | ngx.sleep(0) → 调度公平性 |
四者解决的不是同一个问题。
6.4 周期 flush 仍不等于完整背压
背压的目标是:当下游消费速度赶不上上游生产速度时,让生产者减速或限制中间缓冲增长。
周期 flush 主要减少输出频率,并不必然让上游减速。完整的流式资源治理还需要考虑:
- 最大流持续时间;
- 最大响应字节数;
- 最大未发送缓冲量;
- 上游读取节奏;
- 下游写入速度;
- 超限后的取消和连接关闭策略。
因此,不应把“增加 flush interval”直接等同为“已经实现背压”。
七、重新整理故障因果链
经过拆分后,几个问题可以放进一条更准确的资源模型:
1 | 请求体重复 JSON decode |
这四条链路会互相叠加。例如:
1 | 下游已经取消 |
但仍然要避免一句“没有 yield 导致 CPU 打满”覆盖全部事实。
更合理的故障分类是:
| 维度 | 故障表现 | 主要修复 |
|---|---|---|
| 生命周期 | 下游取消后上游继续运行 | abort propagation、关闭上游 |
| 公平性 | 单请求长期占用 worker | 显式 yield |
| 效率 | 重复 decode、SSE 分配、高频 flush | 缓存、parser 优化、批量 flush |
| 资源上界 | 异常长流或大响应无限消耗资源 | duration/bytes/buffer limit |
| 背压 | 上游长期快于下游 | 限速、暂停读取或有界缓冲策略 |
八、AI 在这次复盘中应该扮演什么角色
我最初把问题提交给 Claude Opus,希望 AI 帮助分析:
- 为什么阅读代码时没有发现问题;
- 需要补充哪些 OpenResty 和协作式调度知识;
- 如何形成可复用的代码审查 checklist。
AI 的价值首先是快速扩展假设空间:
1 | CPU 饱和 |
但 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)”,而是面对任何事件驱动系统时,都主动追问:执行权何时归还?数据在哪里缓冲?重复工作发生了几次?取消如何传播?资源上界在哪里?