HTTP 200、服务器日志完整、单元测试全绿,但 Claude 类流式接口一直卡在「思考中」。通过 curl 直接抓原始响应帧发现是流终止帧缺失导致状态机未走到完成态。
有些流式传输的故障会发出响亮的网络错误提示,但这个故障却静悄悄的。浏览器显示 200,服务器日志显示完成,所有单元测试都通过了,然而聊天界面却一直在宣布它仍在思考,尽管最后一个可见的 token 已经到来。最后一个 token 看起来像是未完成的,辅助技术状态区域从未达到完成状态。这种组合指向的不是 prompt 的问题,而是流被终止的方式。
为了不花费用就复现这个故障,我用一个小型的测试工具指向 MonkeyCode 的免费模型访问点,并在免费服务器选项上运行代理。披露:本文是 MonkeyCode 产品推广的一部分。免费路径的成本控制不如其可控性重要:我可以检查原始响应帧,而不用让 SDK 隐藏传输细节。
语言模型 SDK 通常会平滑处理流式传输的奇怪行为,这在生产环境中很有用,但在调试协议级故障时却很危险。我移除了 SDK,用 curl 发送相同的 prompt,然后将响应体写入文件,以便检查精确的帧边界。
curl -N 'https://your-endpoint.example/v1/stream?prompt=Explain%20focus%20management%20in%20one%20paragraph.' -o stream.log
tail -c 300 stream.log | xxd
我预期文件以 data: [DONE] 结尾,后面跟着两个换行符。但实际上,tail 结束时显示的是 data: "focus mov,然后什么都没有了:没有闭合引号,没有终端事件,也没有换行符。传输在上升流中断时恰好关闭了。
200 状态只能证明服务器发送了请求头并开始了响应。它不能证明分块流会干净地结束。200 就像餐厅在你点餐后确认了你的订单,但厨房随后断电了;订单被接受了,但餐食从未到来。
在这种故障模式中,端点或中间代理开始发送分块数据,然后连接在终端帧出现之前就断开了。浏览器将此暴露为 ReadableStream 的正常关闭,因此客户端将响应视为已完成。然后我的 UI 永远等待一个永远无法到达的 [DONE] 帧。
修复方法是不再将 [DONE] 视为唯一有效的结束信号。客户端还应在流关闭或空闲时发出终端状态,并且应该保留任何部分块而不是丢弃它。
const decoder = new TextDecoder();
const CR = String.fromCharCode(13);
const LF = String.fromCharCode(10);
const CRLF = CR + LF;
function watchSseStream(response, { onEvent, onDone, idleMs = 12000 } = {}) {
if (!response.body) {
throw new Error('ReadableStream responses are required for this harness.');
}
const reader = response.body.getReader();
let buffer = '';
let idleTimer;
let finished = false;
function finish(reason) {
if (finished) return;
finished = true;
clearTimeout(idleTimer);
emitCompleteFrames();
const leftover = buffer.trim();
if (leftover) {
onEvent({ data: leftover, partial: true });
}
onDone({ reason, hadPartialFrame: leftover.length > 0 });
}
function resetIdle() {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => finish('idle-timeout'), idleMs);
}
function emitCompleteFrames() {
const separator = LF + LF;
while (buffer.includes(separator)) {
const boundary = buffer.indexOf(separator);
const frame = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + separator.length);
const data = frame
.split(LF)
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join(LF);
if (data && data !== '[DONE]') {
onEvent({ data, partial: false });
}
}
}
function pump({ done, value }) {
if (done) return finish('stream-closed');
buffer += decoder.decode(value, { stream: true }).split(CRLF).join(LF);
emitCompleteFrames();
resetIdle();
return reader.read().then(pump);
}
resetIdle();
return reader.read().then(pump).catch(() => finish('stream-error'));
}
这个代码片段是一个测试工具,而不是完整的生产解析器。它故意将有序关闭或空闲超时视为终止状态,并将部分最终帧作为真实信息暴露出来,而不是等待一个永远不会到达的分隔符。
浏览器端的修复不仅仅是显示文本。如果每个 token 都直接写入活动区域,VoiceOver 和 NVDA 会将一个响应变成数十次中断。状态区域应该公告一个稳定的状态,而不是流。
<section id='chat-status' aria-live='polite' aria-atomic='true'>
Waiting for response.
</section>
const status = document.getElementById('chat-status');
let lastAnnouncement = '';
function announce(state) {
if (lastAnnouncement === state) return;
lastAnnouncement = state;
status.textContent = '';
requestAnimationFrame(() => {
status.textContent = state;
});
}
watchSseStream(response, {
onEvent({ data }) {
appendToken(data);
},
onDone({ reason, hadPartialFrame }) {
announce(
hadPartialFrame
? 'Response stopped before completion.'
: reason === 'idle-timeout'
? 'Response stopped after a long silence.'
: 'Response complete.'
);
},
});
屏幕阅读器现在听到的是一个结果,而不是每个 token。这种区别在流失败时最为重要,因为用户需要知道响应是不完整的,可以重试。
当 bug 只在连接丢弃后出现时,愉快路径测试证明的东西很少。我在恰好失败的转换周围添加了一个小矩阵。
在信任任何 SDK 或 UI 抽象之前运行 curl -N。
将连接节流到 50ms 延迟,并在大约三分之一预期流的位置切断它。
在 Chrome + NVDA 和 Safari + VoiceOver 中确认活动区域公告了一个单一的稳定结果。
用 tail -c 300 stream.log | xxd 捕获原始尾部,并记录最终帧是否到达。
它无法恢复服务器从未发送的 token。如果上游模型在思考中途停止,客户端只能告诉用户它停止了。
空闲超时需要调整。设置得太低,缓慢的推理会被切断;设置得太高,死的连接仍然感觉没有响应。
免费服务器和共享模型端点可能会重启或丢弃长寿命会话,因此请将对话状态保持在进程内存之外。
这不能替代官方 SDK 重试行为或受监管系统中的持久性服务器端日志。
当你能够指向一个免费端点并在添加 UX 之前检查帧时,这个测试工具最有用。MonkeyCode 的免费模型访问和免费服务器选项使这变得廉价,但基本教训适用于任何流式提供商:将 200 视为流契约的开始,而不是结束。