小白 / Xiaobai
开发者 · 产品构建者
持续构建 AI 工程系统、开发者工具与长期数字资产。
关于作者与 XBSTACK →
AI SDK streamObject() 出错后一直不返回:result.object 为什么会挂住?
实测 Vercel AI SDK 7.0.66:streamObject 在 provider 提前失败或流中返回 error part 后,object、usage、finishReason、response、warnings 可能一直不 settle;包含可复现代码、streamText 对照和应用层 failure fence。
AI SDK streamObject() 出错后一直不返回:result.object 为什么会挂住?
先给结论:截至 2026 年 8 月 15 日,我在 npm 当前版本 [email protected] 上稳定复现了 streamObject() 的错误结束问题。 当 provider 在真正输出前失败,或者已经建立 stream、随后发出 error part 并关闭时,result.object、result.usage、result.finishReason、result.response、result.warnings 都没有在错误流结束后 settle。更关键的是,我进一步显式消费了 fullStream:错误确实被读到,onError 也确实执行,但这五个 Promise 仍然保持 pending。
这意味着生产代码如果直接 await result.object,又没有独立的错误信号和 deadline,某些 provider failure 可能把请求长期挂在这里。临时处理可以放在应用层:主动消费 fullStream,把 onError 转成一个会 reject 的 Promise,再和 result.object、deadline 做 Promise.race。 我在同一组离线实验中验证了早期失败和流中 error 两条路径,failure fence 都能及时失败,不再无限等待。
上游问题来自 Vercel AI SDK 仓库的 Issue #18930。这不是第一次有人对 streamObject 的错误传播感到困惑:Vercel 早期推出最终 object Promise 时,就把它描述为可在流结束后取得最终强类型结果;历史 Issue #5027 也讨论过 streamObject 的错误如何从流式路径暴露。本文不重复 Issue,而是把当前版本放进一个不依赖模型、不依赖网络的可重复实验里,确认到底是哪一层没有结束。
先确认:不是旧版本遗留问题
我先做了一个很简单但必要的检查:
npm view ai version
2026 年 8 月 15 日返回:
7.0.66
也就是说,本次实验固定的 [email protected] 就是当天 npm 返回的当前版本,而不是拿一个已经过期的 7.0.x 去证明“新版还有 Bug”。
实验目录独立于站点运行代码,不使用 OpenAI、Anthropic、Gemini 或其他真实 provider,也不读取任何 API Key。模型只实现一个最小 LanguageModelV4 mock,让错误时序完全可控。
| 项目 | 本次设置 |
|---|---|
| AI SDK | [email protected] |
| Zod | 4.4.3 |
| Provider | 本地确定性 mock |
| 外部 API | 无 |
| Promise 观察窗口 | 1.5 秒 |
| 对照 | streamText() |
这里的 1.5 秒不是“正常模型应该 1.5 秒返回”。错误流在更早之前已经结束,所以它只用来回答一个问题:流已经确定失败并结束以后,结果 Promise 有没有 settle。
复现一:provider 在输出前失败,五个 Promise 全部 pending
第一组模型最简单:doStream() 直接抛错。
function failingModel(errorMessage) {
return {
specificationVersion: 'v4',
provider: 'xbstack.mock',
modelId: 'intentional-failure',
async doStream() {
throw new Error(errorMessage);
},
};
}
然后创建 streamObject(),分别观察最终对象、usage、finish reason、response 和 warnings:
const result = streamObject({
model: failingModel('streamObject provider failure'),
schema: z.object({ content: z.string() }),
prompt: 'hello',
});
await result.object;
为了避免测试脚本自己永远卡死,我没有直接裸 await,而是给每个 Promise 都放进一个 1.5 秒的观察窗口。结果五项全部超时:
streamObject.object -> timeout
streamObject.usage -> timeout
streamObject.finishReason -> timeout
streamObject.response -> timeout
streamObject.warnings -> timeout
这个结果只证明“provider 在 stream 建立前失败”的路径,所以还不够。如果问题只发生在初始化失败,生产影响和修复方向都会窄很多。
复现二:stream 已经建立,再发 error part,结果仍然不结束
第二组我让 provider 正常返回 ReadableStream,然后依次发 stream-start、error 并关闭:
async doStream() {
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue({ type: 'stream-start', warnings: [] });
controller.enqueue({
type: 'error',
error: new Error('streamObject mid-stream error part'),
});
controller.close();
},
}),
};
}
结果还是同样的五个 timeout。
更值得警惕的是,这条路径里 onFinish 可以执行,而且我观察到的 finishReason 是 other,usage 只有空的 token detail 对象。如果业务只记录“onFinish 已触发”,很容易把“流程结束”误解成“结果对象已经可以安全读取”。在本次实验里,两件事并不等价。
这也是为什么我不建议用下面这种逻辑作为唯一成功判断:
onFinish(() => {
markJobFinished();
});
至少应该区分“流生命周期回调执行了”和“最终结构化结果 Promise 已经成功 settle”。
反证:不是因为没有消费 fullStream
到这里仍然有一个合理质疑:streamObject 毕竟是流式 API,是不是因为测试代码没有消费流,内部背压导致 Promise 一直不结束?
所以第三组我明确消费 result.fullStream:
for await (const part of result.fullStream) {
console.log(part.type);
}
实际拿到:
error -> streamObject consumed error part
fullStream 本身正常完成,onError 也收到了同一个 provider error。也就是说错误已经穿过流,应用完全能观察到它。
然后我再次检查五个结果 Promise:
object -> timeout
usage -> timeout
finishReason -> timeout
response -> timeout
warnings -> timeout
这一步很重要。它说明在本文复现的 [email protected] 路径里,“把 stream 消费完”并不能自动让这些 delayed result Promise 进入 fulfilled 或 rejected 状态。 因此生产排障时,不要只加一个 for await 就认为问题已经解决。
为什么我还加了 streamText 对照
只测 streamObject,最多只能得出“它在这两个 mock error path 里会挂”。为了判断这是整个 AI SDK 流式模型的共同语义,还是 streamObject 的具体差异,我用相同的早期失败模型跑了一次 streamText()。
观察三个对应结果:
streamText.finishReason -> rejected
streamText.totalUsage -> rejected
streamText.response -> rejected
三项都以 No output generated. Check the stream for errors. 结束,而不是留在 pending。
这和 AI SDK 当前 streamText 结果接口的设计方向是一致的:它的结果 Promise 会自动消费流,错误路径需要能够终止这些结果。这里不能据此推导“streamText 所有错误处理都没有问题”,只能说同一个早期失败对照中,streamText 没有复现本文的 pending 行为。
如果你正在做 AI SDK 7 的整体迁移、Tool Call、Abort、Retry 和持久化,仍然应该看 AI SDK 7 迁移实战。本文只把 streamObject 的一个错误结束边界单独拿出来解决。
临时方案:不要只给 result.object 套一个 timeout
最简单的防卡死方式当然是:
await Promise.race([
result.object,
timeout(10_000),
]);
它能限制等待时间,但有一个明显缺点:如果 provider 已经给出了准确错误,你最后只得到“10 秒超时”,把真正错误吃掉了。
我在实验里采用了更完整的 failure fence,核心有三步:
- 后台消费
fullStream,确保 stream-level error 被观察; - 在
onError里把 provider error 转成一个独立 rejecting promise; - 用
Promise.race同时等待result.object、provider error 和 deadline,并在结束时 Abort 清理。
核心代码如下:
async function streamObjectWithFailureFence({ model, timeoutMs = 10_000 }) {
const controller = new AbortController();
let rejectObservedError;
const observedError = new Promise((_, reject) => {
rejectObservedError = reject;
});
const result = streamObject({
model,
schema,
prompt,
abortSignal: controller.signal,
onError({ error }) {
rejectObservedError(
error instanceof Error ? error : new Error(String(error)),
);
},
});
const consume = (async () => {
for await (const _part of result.fullStream) {
// 主动消费,让 stream-level error 可观测
}
})();
const deadline = new Promise((_, reject) => {
setTimeout(
() => reject(new Error(`streamObject deadline exceeded`)),
timeoutMs,
);
});
try {
return await Promise.race([
result.object,
observedError,
deadline,
]);
} finally {
controller.abort();
await consume.catch(() => undefined);
}
}
本地回归结果:
early provider failure -> rejected with original provider error
provider error part -> rejected with original provider error
这比单纯 timeout 更适合作为临时生产防护,因为能尽量保留原始错误,同时仍然有 deadline 兜底。
但需要强调:它不是 SDK 修复。 result.object 等 Promise 在内部依然可能 pending,只是你的业务请求不再把它们当作唯一终止信号。
生产代码还需要补哪几层保护
如果你的 API Route、队列 Worker 或 Agent 任务依赖 streamObject(),我建议至少把下面四个状态分开记录:
| 状态 | 应记录什么 |
|---|---|
| provider error | 原始错误类型、provider、model、request id |
| stream observed | 是否真正收到 error / finish / abort |
| structured result | result.object 是否 fulfilled / rejected / deadline |
| request lifecycle | 客户端是否取消、服务端是否主动 abort、总耗时 |
不要把 onFinish、HTTP 连接关闭、fullStream 结束、最终 Object 可用这四件事合并成一个 finished=true。
如果生成结果后还会触发写数据库、发邮件、创建订单等副作用,更不要因为 onFinish 执行就直接进入下一步。先确认结构化结果真的通过 Schema 验证并成功返回,再提交后续动作。
哪些场景可以直接避开 streamObject
如果你并不需要“对象生成到一半时就把局部字段展示给前端”,而只是想最终得到一个符合 Schema 的对象,那么非流式结构化输出通常更简单:一次调用成功就拿最终对象,失败就进入普通异常路径。
streamObject 更适合这些场景:
- UI 确实要展示逐步生成的结构化字段;
- 长对象生成中希望尽早呈现部分结果;
- 已经有完整 stream lifecycle、abort、timeout、error telemetry;
- 能接受针对 provider 差异做回归测试。
如果只是后台生成一段 JSON 再写数据库,额外引入流生命周期未必有收益。
本次结论的边界
这次我验证的是两个确定性错误路径:
doStream()在输出前失败;- stream 建立后发出
errorpart 再关闭。
我没有把结论扩大到“所有 provider 都一定这样”,也没有声称所有 AbortSignal 时序都会触发同一结果。真实 OpenAI、Anthropic、Bedrock、AI Gateway 各自还有网络层、Provider Adapter 和 SSE 行为差异,需要另外测试。
同时,1.5 秒不是生产超时建议,只是实验观察窗口。因为 mock stream 已经结束,继续等待不会提供更多模型输出,所以它用于判定 delayed promise 是否被 settle。生产 deadline 应按你的 provider SLA、模型、代理层和业务容忍度设置。
最终处理建议
如果你现在已经遇到 streamObject() “接口不报错,但 await result.object 一直不回来”,我建议按这个顺序处理:
- 先确认实际
ai包版本;本文在 2026-08-15 的 npm 当前版本7.0.66复现。 - 给
fullStream加错误观测,确认 provider 是否已经发出error。 - 不要只依赖
result.object作为唯一终止信号。 - 在应用边界加入 error promise + deadline + AbortController 的 failure fence。
- 把 provider error、stream end、structured result、HTTP lifecycle 分开记录。
- 上游发布修复后,重新跑同一套早期失败、error part、显式消费和 streamText 对照,再决定是否移除临时逻辑。
问题本身看起来只是“一个 Promise 没结束”,但在生产里它会变成占住 API Route、Worker、并发槽位甚至用户请求的资源泄漏。对于结构化生成,这类结束语义比“模型输出质量”更值得先验证。
继续阅读:AI SDK 7 迁移实战:流式中断、Cloudflare 524 边界与 Tool Call 恢复 · AI Tools Lab
把迁移结论继续追到可复现实验
AI Tools Lab 会统一承接 Migration Diff、Tool Call、Persistence、Abort、Retry、Timeout 和 Failure 实验,避免只看版本发布说明。
继续阅读
返回专题 →AI 工程周报
只发真正改变工程判断的变化、故障、实验和新资产。
参与讨论
问题、验证与勘误
登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。