问题概述
Aether v0.7.12 在 OpenAI Responses 流式请求的 execution-runtime prefetch 路径中,可能从 SSE 流的绝对 16 KiB 边界开始丢失一段连续字节。
根据缺口落点不同,会出现两种结果:
- JSON 被破坏,客户端报
JSON Parse error,常见错误包括:
Expected '}'
Unterminated string
Invalid escape character
... is not a valid unicode escape
- 删除后的 JSON 仍然合法,客户端不会报错,但生命周期事件中的字段会被静默截短。
问题不在模型生成的 function-call arguments。抓取未解析的原始 SSE 后确认,损坏表现为事件中间的一段连续字节被删除,缺口前后直接拼接;看似随机的非法 escape 是拼接后的结果。
环境(已脱敏)
- Aether:
v0.7.12,revision 06f5d3c8c0fec4599767749f6b321ec640ebb3da
- 客户端:OMP
17.3.0
- 上游:Sub2API
0.1.176
- API:OpenAI Responses,streaming
- Aether 配置:
cyber_continue_failover=true
- 请求经过 Aether 的 execution-runtime stream 路径,并创建
OpenAiResponsesCompat local stream rewriter
- 复现请求中的客户端工具:
advise、read、glob、grep
未包含服务器地址、域名、账号、API key、request ID、完整 prompt 或完整请求/响应正文。
如何触发
满足以下结构条件时容易触发:
- 使用 OpenAI Responses 流式请求;
- 请求进入需要 prefetch 的 execution-runtime stream 路径;
- 存在有状态的 local stream rewriter/normalizer;
- prefetch 已消费的完整上游 worker chunk 跨过
MAX_STREAM_PREFETCH_BYTES = 16384;
- 后台转发重新创建 rewriter,并用只保存到 16 KiB 的
provider_prefetched_body 恢复状态。
在本次环境中,OMP advisor 的 instructions 加 4 个工具 schema 会稳定制造这种首部事件布局。
A/B 结果
| 路径/配置 |
结果 |
默认 advisor(advise/read/glob/grep) |
5/5 流存在 malformed SSE,0/5 到 response.completed |
advisor 仅保留框架强制注入的 advise,禁用 read/glob/grep |
0/5 malformed,5/5 到 response.completed |
| 绕过 Aether,使用同类请求直连 Sub2API,10 路并发 |
0/10 损坏 |
减少工具数量只改变前部事件大小和分帧,是绕过方法,不是修复。
主会话也会静默损坏
该问题并非只影响 advisor。另一条主会话响应没有抛错,但同一次响应内部出现:
| 事件 |
instructions 解码后长度 |
response.created |
53,777 |
紧随其后的 response.in_progress |
54,046 |
| 直连上游的两个事件 |
均为 54,046 |
对原始 SSE 做字节对齐后:
response.created 在 JSON 内偏移 16,354 处开始缺段;
- 加上
event: response.created\n 和 data: 共 30 字节;
- 缺口的 SSE 绝对起点正好是 16,384;
- 缺失 283 个 JSON 编码字符,对应解码后的
instructions 少 269 字符。
这次缺口恰好落在普通字符串内容中,删除后 JSON 仍可解析;客户端又不依赖生命周期事件回显的 instructions,所以表面上主会话正常。Advisor 样本的缺口常落在反斜杠/转义序列附近,因此显式触发严格 JSON parser。
排查方式与使用工具
为避免最终 JSON parser 丢掉坏事件,使用了以下互相独立的方法:
- OMP advisor / 主会话并行 A/B;
- Bun preload 包装
fetch response stream,在客户端 JSON 解析前镜像原始 SSE 字节;
- Aether
usage_http_audits 及 gzip body capture,用于关联 provider/client 两侧事件和 sequence gap;
- 复用脱敏后的同类请求,绕过 Aether 直连 Sub2API;
- 使用与 Aether 相同类型的流式 HTTP client 观察
bytes_stream() 分帧;
- 对成功/失败事件做最长公共前缀和后缀对齐,确认是连续区间删除;
- 对 Aether 官方
v0.7.12 源码、父提交和当前 main 做 git diff/git blame。
原因分析
相关模块:
apps/aether-gateway/src/execution_runtime/stream/execution.rs
在 v0.7.12 中,prefetch 阶段将 provider body 保存为最多 16 KiB 的 capture:
append_stream_capture_bytes(
&mut provider_prefetched_body,
&chunk,
MAX_STREAM_PREFETCH_BYTES,
&mut provider_prefetched_body_truncated,
);
但是 prefetch 期间,旧 rewriter 已经消费了完整的 chunk,完整改写结果也进入了 prefetched_chunks。进入后台转发后旧 rewriter 被销毁并重新创建,新 rewriter 又用截断后的 provider_prefetched_body_for_report 做状态 replay:
let replay_chunk = normalized_prefetched_chunk
.as_deref()
.unwrap_or(provider_prefetched_body_for_report.as_slice());
if let Some(rewriter) = local_stream_rewriter.as_mut() {
rewriter.push_chunk(replay_chunk)?;
}
provider_prefetched_body_truncated 虽然会被设置,但没有参与后续的 rewriter 恢复判断。
因此,如果某个被 prefetch 消费的完整 worker chunk 跨过 16 KiB:
- 旧 rewriter 处理了完整 chunk;
- replay buffer 只保存到 16,384 字节;
- 后台新 rewriter 只恢复到截断位置;
- 已消费但未 replay 的越界尾部从 rewriter 状态中消失;
- 下一 frame 到来后,半条 SSE/JSON 与后续内容直接拼接。
引入问题的提交
初步确认由以下提交引入:
其父提交中使用完整保存:
provider_prefetched_body.extend_from_slice(&chunk);
prefetched_inspection_body.extend_from_slice(&chunk);
该提交将其改为带 MAX_STREAM_PREFETCH_BYTES 的有界 append_stream_capture_bytes(...),但后台仍沿用原有的“销毁并重建 rewriter,再 replay provider_prefetched_body”设计,也没有在 provider_prefetched_body_truncated=true 时停止 replay。
官方当前 main(检查时为 f3a12c10080cff724fa0b58fb196f4c6e40c409b)仍保留这条恢复路径。
建议修复
不建议只增大 16 KiB,因为只会移动触发边界。建议选择以下方案之一:
- prefetch 到后台转发时直接移交原 stream normalizer/rewriter 状态,不销毁后重建;
- inspection/usage capture 可以有界,但用于状态 replay 的 provider bytes 必须完整保存;
- 只 replay 已完整结束的 SSE records,同时显式移交未完成行的 pending buffer;
- 至少在
provider_prefetched_body_truncated=true 时禁止使用该 buffer 重建有状态 parser,并返回明确错误,避免静默损坏。
建议补充回归测试:
- 一个 SSE 事件在绝对 16 KiB 前开始、跨过边界后结束;
- 16 KiB 边界落在 JSON 普通字符串、转义序列和结构字符附近;
- prefetch 后重建 OpenAI Responses rewriter;
- 断言 provider 输入和 client 输出字节/事件语义完整,且不存在可解析但字段被静默截短的情况。
问题概述
Aether
v0.7.12在 OpenAI Responses 流式请求的 execution-runtime prefetch 路径中,可能从 SSE 流的绝对 16 KiB 边界开始丢失一段连续字节。根据缺口落点不同,会出现两种结果:
JSON Parse error,常见错误包括:Expected '}'Unterminated stringInvalid escape character... is not a valid unicode escape问题不在模型生成的 function-call arguments。抓取未解析的原始 SSE 后确认,损坏表现为事件中间的一段连续字节被删除,缺口前后直接拼接;看似随机的非法 escape 是拼接后的结果。
环境(已脱敏)
v0.7.12,revision06f5d3c8c0fec4599767749f6b321ec640ebb3da17.3.00.1.176cyber_continue_failover=trueOpenAiResponsesCompatlocal stream rewriteradvise、read、glob、grep未包含服务器地址、域名、账号、API key、request ID、完整 prompt 或完整请求/响应正文。
如何触发
满足以下结构条件时容易触发:
MAX_STREAM_PREFETCH_BYTES = 16384;provider_prefetched_body恢复状态。在本次环境中,OMP advisor 的 instructions 加 4 个工具 schema 会稳定制造这种首部事件布局。
A/B 结果
advise/read/glob/grep)response.completedadvise,禁用read/glob/grepresponse.completed减少工具数量只改变前部事件大小和分帧,是绕过方法,不是修复。
主会话也会静默损坏
该问题并非只影响 advisor。另一条主会话响应没有抛错,但同一次响应内部出现:
instructions解码后长度response.createdresponse.in_progress对原始 SSE 做字节对齐后:
response.created在 JSON 内偏移 16,354 处开始缺段;event: response.created\n和data:共 30 字节;instructions少 269 字符。这次缺口恰好落在普通字符串内容中,删除后 JSON 仍可解析;客户端又不依赖生命周期事件回显的
instructions,所以表面上主会话正常。Advisor 样本的缺口常落在反斜杠/转义序列附近,因此显式触发严格 JSON parser。排查方式与使用工具
为避免最终 JSON parser 丢掉坏事件,使用了以下互相独立的方法:
fetchresponse stream,在客户端 JSON 解析前镜像原始 SSE 字节;usage_http_audits及 gzip body capture,用于关联 provider/client 两侧事件和 sequence gap;bytes_stream()分帧;v0.7.12源码、父提交和当前 main 做git diff/git blame。原因分析
相关模块:
apps/aether-gateway/src/execution_runtime/stream/execution.rs在
v0.7.12中,prefetch 阶段将 provider body 保存为最多 16 KiB 的 capture:execution.rs6217-6228但是 prefetch 期间,旧 rewriter 已经消费了完整的
chunk,完整改写结果也进入了prefetched_chunks。进入后台转发后旧 rewriter 被销毁并重新创建,新 rewriter 又用截断后的provider_prefetched_body_for_report做状态 replay:execution.rs6846-6905provider_prefetched_body_truncated虽然会被设置,但没有参与后续的 rewriter 恢复判断。因此,如果某个被 prefetch 消费的完整 worker chunk 跨过 16 KiB:
引入问题的提交
初步确认由以下提交引入:
531cf110250579af2013baccea5bece4765a3442feat(gateway): harden provider request execution其父提交中使用完整保存:
该提交将其改为带
MAX_STREAM_PREFETCH_BYTES的有界append_stream_capture_bytes(...),但后台仍沿用原有的“销毁并重建 rewriter,再 replay provider_prefetched_body”设计,也没有在provider_prefetched_body_truncated=true时停止 replay。官方当前 main(检查时为
f3a12c10080cff724fa0b58fb196f4c6e40c409b)仍保留这条恢复路径。建议修复
不建议只增大 16 KiB,因为只会移动触发边界。建议选择以下方案之一:
provider_prefetched_body_truncated=true时禁止使用该 buffer 重建有状态 parser,并返回明确错误,避免静默损坏。建议补充回归测试: