Skip to content

[Bug] OpenAI Responses 预取在 16 KiB 边界丢失字节,导致 malformed SSE 或静默损坏 #720

Description

@wmsyw

问题概述

Aether v0.7.12 在 OpenAI Responses 流式请求的 execution-runtime prefetch 路径中,可能从 SSE 流的绝对 16 KiB 边界开始丢失一段连续字节。

根据缺口落点不同,会出现两种结果:

  1. JSON 被破坏,客户端报 JSON Parse error,常见错误包括:
    • Expected '}'
    • Unterminated string
    • Invalid escape character
    • ... is not a valid unicode escape
  2. 删除后的 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
  • 复现请求中的客户端工具:advisereadglobgrep

未包含服务器地址、域名、账号、API key、request ID、完整 prompt 或完整请求/响应正文。

如何触发

满足以下结构条件时容易触发:

  1. 使用 OpenAI Responses 流式请求;
  2. 请求进入需要 prefetch 的 execution-runtime stream 路径;
  3. 存在有状态的 local stream rewriter/normalizer;
  4. prefetch 已消费的完整上游 worker chunk 跨过 MAX_STREAM_PREFETCH_BYTES = 16384
  5. 后台转发重新创建 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\ndata: 共 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,因为只会移动触发边界。建议选择以下方案之一:

  1. prefetch 到后台转发时直接移交原 stream normalizer/rewriter 状态,不销毁后重建;
  2. inspection/usage capture 可以有界,但用于状态 replay 的 provider bytes 必须完整保存;
  3. 只 replay 已完整结束的 SSE records,同时显式移交未完成行的 pending buffer;
  4. 至少在 provider_prefetched_body_truncated=true 时禁止使用该 buffer 重建有状态 parser,并返回明确错误,避免静默损坏。

建议补充回归测试:

  • 一个 SSE 事件在绝对 16 KiB 前开始、跨过边界后结束;
  • 16 KiB 边界落在 JSON 普通字符串、转义序列和结构字符附近;
  • prefetch 后重建 OpenAI Responses rewriter;
  • 断言 provider 输入和 client 输出字节/事件语义完整,且不存在可解析但字段被静默截短的情况。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions