流式响应比预期短有两种截然不同的原因——max_tokens截断(应继续或扩容)vs传输中断(应重试),代码需区分处理。
这个测试要捕获的失败不是一次异常。它是用户看到半个回答,格式完全正常,没有任何出错提示——因为代码把"流结束了"当成了"模型完成了"。
两种短回答
回答比预期短有两种完全不同的原因,它们需要截然不同的处理方式。
完整流报告截断。 模型触发了 max_tokens,所以 provider 发送最后一个 chunk,带有 finish_reason: "length"(或 Anthropic 的 stop_reason: "max_tokens"),然后是它的终止帧(terminal frame),随后关闭。传输完全正常。正确的处理是继续生成或提高上限,并且这些 token 会被计费,因为它们确实被生成了。
不完整流。 socket 在终止帧之前就关闭了。没有任何信息告诉你原因:负载均衡器空闲超时、上游重启、代理响应大小上限、移动网络切换。不完整流的正确处理是重试——这与第一种情况不同,因为对长度截断进行重试只会白白消耗相同的 token。参见重试的成本。
只查看累积文本的代码无法区分这两种情况,只检查是否抛出异常的代码也往往无法做到,因为在几种运行时中,截断的分块响应末尾表现为普通的流结束而不是错误。
捕捉这个问题的恒定条件
只有一句话,却涵盖了整篇文章:一个流在没有任何终止帧的情况下结束就是错误,无论前面收到了多少内容。不是警告,不是部分成功——是错误,需要向调用者抛出,并附带部分文本,以便 UI 可以选择将其显示为明确未完成。
这就是终止帧如此重要的原因,也是"干净关闭测试"成为其必要配对测试的原因。两者共同说明:成功时终止帧存在,失败时它的缺失会被检测到。单独任何一个测试都可以被从未检查终止帧的代码满足。
实现这一点意味着你的读取器需要一个状态标志,而不仅仅是一个累加器:
class IncompleteStreamError extends Error {
constructor(readonly partial: string, readonly bytes: number) {
super("stream ended after " + bytes + " bytes without a terminal frame");
this.name = "IncompleteStreamError";
}
}
async function readCompletion(res: Response): Promise<string> {
let text = "", bytes = 0, sawTerminal = false;
// ...framing loop, setting sawTerminal on the [DONE] line...
if (!sawTerminal) throw new IncompleteStreamError(text, bytes);
return text;
}
模拟连接断开
你需要一台服务器,它写入一些帧然后不经过正常关闭就销毁 socket。Node HTTP 服务器一行代码就能做到,而且与在 fetch 层的 mock 不同,它产生的是真正的传输级截断,这才是你要测试的东西。
import { createServer } from "node:http";
import { once } from "node:events";
async function truncatingServer(framesBeforeDrop: number) {
const server = createServer((req, res) => {
res.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
});
for (let i = 0; i < framesBeforeDrop; i++) {
res.write('data: ' + JSON.stringify({
choices: [{ index: 0, delta: { content: "word" + i + " " }, finish_reason: null }],
}) + '\n\n');
}
// No terminal frame, no res.end(): kill the socket underneath it.
res.socket!.destroy();
});
server.listen(0);
await once(server, "listening");
const { port } = server.address() as { port: number };
return { url: "http://127.0.0.1:" + port, close: () => server.close() };
}
用 res.socket.destroy() 而不是 res.end() 是关键。res.end() 产生一个有效的、完整的 HTTP 响应,只是恰好没有终止帧——作为第二个测试用例有用,但效果较弱。destroy() 在分块编码中途中止,这才是真正的网络故障的样子,也是让客户端库抛出异常的原因。
值得了解真实的截断来自哪里,因为它告诉你哪些变体值得编写。负载均衡器和反向代理对一段时间内没有发送任何内容的连接应用空闲超时,而模型在回答前思考或处理冗长的工具调用时,很容易空闲那么长时间——这就是 keepalive 注释存在的原因。Serverless 平台应用最大响应时长。部署时会滚动持有连接的进程。移动客户端切换网络会直接断开连接。只有最后一种看起来像客户端问题;其余都是你所拥有的基础设施,而且它们都以这一个测试用例的形式向下游呈现。
错误实际上是什么样子
两种情况都必须处理,而且它们的形态不同,所以要对两者进行断言而不是检查消息字符串。在 Node 配合内置 fetch 的情况下,socket 在响应体中途被销毁通常表现为一个 TypeError,其消息以 terminated 结尾,真正的细节在 err.cause 上——通常是 code: "ECONNRESET" 的 socket 错误或一个 undici 特定的 code。在浏览器中它是一个消息更模糊的 TypeError,这是设计如此。在干净的 res.end() 且没有终止帧的情况下,根本没有任何异常,只有你自己的检查会触发。
所以断言是关于你的错误类型,而不是它们的:
调用被拒绝,并且抛出的是 IncompleteStreamError——而不是一个泄露出去的原始 TypeError,也不是一个已解析的值。
err.partial 包含按顺序到达的文本。不要丢弃它;能显示"连接断开了,这是我们收到的内容"的 UI 比什么都看不到的 UI 好得多,而且重试策略也许能够从中断处继续。
错误与取消可区分。因为用户按下停止按钮而抛出的 AbortError 不能被报告为网络故障,也不能被重试。这个区别是取消测试的主题。
没有留下任何未关闭的东西:干净关闭测试中使用的相同句柄计数器在这条路径上也回到零。
用(比如)三个帧启动截断服务器。让你的客户端指向它。
断言 promise 以你自己的错误类型拒绝:await expect(readCompletion(res)).rejects.toBeInstanceOf(IncompleteStreamError)。
捕获错误并断言 err.partial 以 word2 结尾——确保在断开前收到的帧没有被错误路径丢失。
用干净的 res.end() 且没有终止帧添加第二个变体。断言同样的拒绝。如果这个通过了而第一个失败了,你的代码依赖的是传输异常而不是这个恒定条件。
添加一个对照用例:一个以 finish_reason: "length" 和正确的终止帧结束的流一定不能拒绝。它必须解析,并暴露 finish reason 以便调用者决定是否继续。没有这个用例,任何短回答都拒绝的读取器会通过这里的其他所有断言。
最后,断言重试策略:不完整流会重试,而长度截断不会。如果请求有副作用——模型已经调用的工具、你已经写入的行——断言重试携带相同的幂等性 key,这样在工作完成后发生的断开不会让工作执行两次。
一件不要做的事:在读取器内部重试。在 framing loop 中透明地重连并继续是很诱人的,但这使得 bug 更难发现,因为一个每次都断开的流现在表现为一个慢流。抛出错误,让上层的策略决定,并统计发生次数——上升的断开率是关于基础设施的信号,而透明重试会删除这个信号。
Testing That a Stream Closes Cleanly on the Happy Path
Testing Cancellation of an In-Flight Streaming Request
Testing That Partial JSON Mid-Stream Doesn't Crash the Parser