XBSTACK XBSTACK
小白 / Xiaobai

小白 / Xiaobai

开发者 · 产品构建者

持续构建 AI 工程系统、开发者工具与长期数字资产。

关于作者与 XBSTACK →
Vercel AI SDK streamObject 错误后结果 Promise 挂起的复现与 failure fence 示意

AI SDK streamObject() 出错后一直不返回:result.object 为什么会挂住?

实测 Vercel AI SDK 7.0.66:streamObject 在 provider 提前失败或流中返回 error part 后,object、usage、finishReason、response、warnings 可能一直不 settle;包含可复现代码、streamText 对照和应用层 failure fence。

发布 · 2026-08-159 分钟阅读XBSTACK 原创
#Vercel AI SDK#AI SDK 7#streamObject#Structured Output#Error Handling#TypeScript#Streaming#Production Engineering

AI SDK streamObject() 出错后一直不返回:result.object 为什么会挂住?

先给结论:截至 2026 年 8 月 15 日,我在 npm 当前版本 ai@7.0.66 上稳定复现了 streamObject() 的错误结束问题。 当 provider 在真正输出前失败,或者已经建立 stream、随后发出 error part 并关闭时,result.objectresult.usageresult.finishReasonresult.responseresult.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

也就是说,本次实验固定的 ai@7.0.66 就是当天 npm 返回的当前版本,而不是拿一个已经过期的 7.0.x 去证明“新版还有 Bug”。

实验目录独立于站点运行代码,不使用 OpenAI、Anthropic、Gemini 或其他真实 provider,也不读取任何 API Key。模型只实现一个最小 LanguageModelV4 mock,让错误时序完全可控。

项目本次设置
AI SDKai@7.0.66
Zod4.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-starterror 并关闭:

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 可以执行,而且我观察到的 finishReasonother,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

这一步很重要。它说明在本文复现的 ai@7.0.66 路径里,“把 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,核心有三步:

  1. 后台消费 fullStream,确保 stream-level error 被观察;
  2. onError 里把 provider error 转成一个独立 rejecting promise;
  3. 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 resultresult.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 再写数据库,额外引入流生命周期未必有收益。

本次结论的边界

这次我验证的是两个确定性错误路径:

  1. doStream() 在输出前失败;
  2. stream 建立后发出 error part 再关闭。

我没有把结论扩大到“所有 provider 都一定这样”,也没有声称所有 AbortSignal 时序都会触发同一结果。真实 OpenAI、Anthropic、Bedrock、AI Gateway 各自还有网络层、Provider Adapter 和 SSE 行为差异,需要另外测试。

同时,1.5 秒不是生产超时建议,只是实验观察窗口。因为 mock stream 已经结束,继续等待不会提供更多模型输出,所以它用于判定 delayed promise 是否被 settle。生产 deadline 应按你的 provider SLA、模型、代理层和业务容忍度设置。

最终处理建议

如果你现在已经遇到 streamObject() “接口不报错,但 await result.object 一直不回来”,我建议按这个顺序处理:

  1. 先确认实际 ai 包版本;本文在 2026-08-15 的 npm 当前版本 7.0.66 复现。
  2. fullStream 加错误观测,确认 provider 是否已经发出 error
  3. 不要只依赖 result.object 作为唯一终止信号。
  4. 在应用边界加入 error promise + deadline + AbortController 的 failure fence。
  5. 把 provider error、stream end、structured result、HTTP lifecycle 分开记录。
  6. 上游发布修复后,重新跑同一套早期失败、error part、显式消费和 streamText 对照,再决定是否移除临时逻辑。

问题本身看起来只是“一个 Promise 没结束”,但在生产里它会变成占住 API Route、Worker、并发槽位甚至用户请求的资源泄漏。对于结构化生成,这类结束语义比“模型输出质量”更值得先验证。

继续阅读:AI SDK 7 迁移实战:流式中断、Cloudflare 524 边界与 Tool Call 恢复 · AI Tools Lab

实验入口 / AI SDK

把迁移结论继续追到可复现实验

AI Tools Lab 会统一承接 Migration Diff、Tool Call、Persistence、Abort、Retry、Timeout 和 Failure 实验,避免只看版本发布说明。

继续阅读

返回专题 →
AI SDK 7 迁移实战:流式中断、Cloudflare 524 边界与 Tool Call 恢复AI SDK 7 迁移:基于 AI SDK 6/7 隔离实验与真实 localhost HTTP/SSE 断线测试,覆盖 Node.js 22、ESM、ToolLoopAgent、WorkflowAgent、Tool Approval、@ai-sdk/otel、消息持久化、Tool Call 幂等恢复与 Cloudflare 524 边界。Kimi K3 编程能力怎么样?真实 Astro 项目实测 + Kimi Code 与 100 万上下文Kimi K3 编程能力实测:用真实 Astro 项目测试 K3 Max 的跨文件分析、代码审查和自我纠错,并核对 Kimi Code、k3-256k、100 万上下文、会员门槛与缓存切换规则。ChatGPT Chat、Work、Codex 有什么区别?怎么选与真实任务对比ChatGPT Work 和 Codex 区别是什么?本文同时对比 Chat、Work、Codex 的任务边界,并补充 Cloud/Local、Mobile Remote 与 Voice 的 2026 最新变化。GPT-5.6 实测:编程、内容创作、数据分析与 Sol、Terra、Luna 怎么选GPT-5.6 实测:GPT-5.6 值得升级吗?本文用真实 Astro 项目、内容创作、Search Console 和 GA4 分析测试 GPT-5.6,并对比 Sol、Terra、Luna、Max 与 Ultra 的适用场景。

AI 工程周报

只发真正改变工程判断的变化、故障、实验和新资产。

评论与补充证据

参与讨论

问题、验证与勘误

登录后可发表评论。所有新评论先进入审核;审核期间仅评论者本人和管理员可见,通过后才公开。

登录评论 审核后公开
正在加载评论区…