流式恢复不是重试按钮:AI SDK 7.0.91 如何把失败变成可验收终态

48 阅读17分钟

流式恢复不是重试按钮:AI SDK 7.0.91 如何把失败变成可验收终态

核心判断:响应流一旦开始发送,已经发出去的片段就不是可以撤回的事务。ai@7.0.91 提供的是“受限恢复”能力,不是无条件回滚;@ai-sdk/workflow@2.0.212.0.22 则要求 Durable Agent 把工具、文件、来源和结构化输出一起持久化。可靠完成的定义应当是:尝试次数有上限、每次尝试有证据、外部副作用可隔离、最终状态可被列表和指标验收。

这篇文章解决一个具体问题:当 AI Agent 的流式响应已经把一部分 token 发给用户,随后网络或 Provider 失败时,怎样恢复而不重复扣费、不重复执行工具,也不把半截文本当成成功?读者可以用文中的状态模型、TypeScript 适配器、Node.js 22 模拟和验收表,给自己的流式 API 或 Durable Workflow 加上一条可回放的失败边界。

流式恢复不是重试按钮

1. 先把“重试”这个词拆开

传统 HTTP 请求常把重试理解成“再发一次相同请求”。这个定义在普通幂等 GET 上还勉强成立,在流式生成和 Agent 工具调用里却不够。至少有三种不同动作经常被同一个 retry() 包住:

  1. 传输恢复:响应已经开始,连接断开,客户端希望继续拿到最终答案。
  2. 模型重试:Provider 在没有可用结果时再次采样,可能产生不同文本和不同 token 成本。
  3. 副作用重放:工具已经写数据库、发邮件或创建任务,又被重试逻辑再次调用。

这三种动作的回滚能力完全不同。传输恢复可以在“只保留成功尝试结果”的前提下继续;模型重试只能通过次数、预算和提示版本限制损失;副作用重放则必须依赖幂等键、事务或人工补偿。把它们都叫重试,会让监控看起来很简单,却让账单、数据和用户看到的文本互相对不上。

1.1 版本证据和验证边界

2026-09-02 的 Vercel AI SDK 发布资料给出 ai@7.0.91 的明确变化:streamText 在响应流已经开始后支持显式配置的受限恢复;恢复成功时只保留成功尝试的结果与 metadata。Workflow 2.0.212.0.22 延续跨步骤工具行为、Provider 文件/来源顺序和已执行工具结果,并修复构造器级结构化输出推断。Harness 1.0.100 的凭据转发修复,则把“已替换成短期假凭据”和“未配置 Credential Brokering”区分开。

本文按四种证据写作:

信息层本文如何使用明确不推出什么
官方事实版本、方法名、受限恢复语义、Workflow 持久化对象来自官方 Release / Changelog不推出所有 Provider 都支持相同的恢复能力,也不推出模型服务本身具备 exactly-once
本地验证Node.js v22.19.0 内存模拟,复现一次流中断、一次成功恢复和工具结果落盘不冒充真实 Provider、网络链路或 SDK 集成测试
工程判断用尝试账本、状态机和副作用隔离定义完成不替代目标存储的 SLA、计费合同和合规留存策略
验证边界明确没有覆盖区域可用性、真实 token 价格、第三方工具 API 和多租户压力不把预发布版本或 Beta 字段写成生产承诺

2. 为什么“已经发过片段”会改变恢复语义

非流式调用可以在收到完整响应前保持内部状态,失败后丢弃整个响应。流式调用不同:首个片段通过 SSE、WebSocket 或 HTTP chunk 发出后,用户已经看到一部分事实。服务端无法让浏览器撤回那几个字符,也无法保证第二次采样会从同一个 token 边界继续。

因此可以把一次请求抽象成三个时间点:

T0  Provider 请求发出
T1  第一个片段已发送(响应开始)
T2  最终状态已写入账本

T0—T1 之间失败,通常可以安全地按普通请求重试;在 T1—T2 之间失败,只能走“受限恢复”:给重试设上限,记录旧尝试已经发送过的内容,并且只把成功尝试的完整结果标记为最终结果。任何已经产生的外部副作用,都不能因为文本没有收完而自动当作从未发生。

一个只返回布尔值的接口会掩盖这个差异:

streamText() -> { ok: true }

更可用的接口需要暴露尝试、阶段和副作用边界:

type StreamAttempt = {
  id: string;
  index: number;
  started: boolean;
  emittedChars: number;
  status: 'running' | 'failed_after_start' | 'succeeded' | 'aborted';
  error?: { name: string; message: string };
};

emittedChars 不是为了做文本拼接,而是为了回答客服和计费两个问题:失败尝试是否已经向用户暴露内容?恢复成功后,公开页面应该展示哪一次尝试?答案必须来自账本,而不是从浏览器截图猜测。

3. 用状态机限制恢复,而不是把异常吞掉

流式恢复与持久证据

本文采用下面的最小状态机。每个箭头都对应一个可以写入事件表的动作:

CREATED -> REQUESTING -> STREAMING -> SUCCEEDED
             |             |
             v             v
          FAILED       FAILED_AFTER_START
             \             /
              \           /
               -> RECOVERY_BUDGETED -> SUCCEEDED
                                      -> ABORTED
                                      -> TERMINAL_FAILED

FAILED_AFTER_START 不能直接跳到 SUCCEEDED。它必须先通过三个门:剩余次数大于零、错误类型允许恢复、当前尝试没有提交不可重复的副作用。任一门失败就进入 TERMINAL_FAILED,前端应显示“答案未完成,可重新发起新任务”,而不是把半截文本拼成绿色成功提示。

3.1 一个可复用的受限恢复适配器

下面的 TypeScript 代码刻意不依赖具体 Provider。runAttempt 负责读取流,append 只写账本,commit 只提交成功结果。把副作用放到 commit 之后,可以避免“文本失败但工具已经执行”的隐式耦合。

type AttemptResult = {
  text: string;
  metadata: Record<string, unknown>;
};

type RunAttempt = (input: {
  attempt: number;
  onChunk: (chunk: string) => void;
  signal: AbortSignal;
}) => Promise<AttemptResult>;

type RecoveryPolicy = {
  maxRetries: number;
  allowAfterStart: boolean;
};

export async function runWithRestrictedRecovery(
  runAttempt: RunAttempt,
  policy: RecoveryPolicy,
  append: (event: Record<string, unknown>) => Promise<void>,
  commit: (result: AttemptResult, attempt: number) => Promise<void>,
): Promise<AttemptResult> {
  for (let attempt = 1; attempt <= policy.maxRetries + 1; attempt += 1) {
    const controller = new AbortController();
    let started = false;
    let emittedChars = 0;

    await append({ type: 'attempt_started', attempt });
    try {
      const result = await runAttempt({
        attempt,
        signal: controller.signal,
        onChunk(chunk) {
          started = true;
          emittedChars += chunk.length;
          // 这里只能发送给客户端,不能在这里提交不可逆副作用。
        },
      });
      await append({ type: 'attempt_succeeded', attempt, emittedChars,
        metadata: result.metadata });
      await commit(result, attempt);
      return result;
    } catch (error) {
      const failedAfterStart = started;
      await append({
        type: failedAfterStart ? 'attempt_failed_after_start' : 'attempt_failed',
        attempt,
        emittedChars,
        error: error instanceof Error ? { name: error.name, message: error.message } : String(error),
      });
      const canRecover = failedAfterStart && policy.allowAfterStart && attempt <= policy.maxRetries;
      if (!canRecover) throw error;
    }
  }
  throw new Error('unreachable');
}

有三个实现细节不能删:

  • attempt 为维度记录 metadata,不能只覆盖一条“最后错误”;否则恢复后的成本和失败率无法按尝试拆分。
  • onChunk 只做输出和计数,不执行写库、扣库存、发通知等动作;工具调用要进入单独的幂等层。
  • commit 在账本写入成功后才运行。若账本写入失败,宁可把任务留在 TERMINAL_FAILED,也不要让业务以为结果已经可追踪。

4. 工具调用必须和流式文本分层

Agent 常见的错误是:模型输出了“我正在查询订单”,代码看到一个 tool call 就立即执行写操作,然后网络在最终文本阶段断开。恢复时模型再次输出同一个 tool call,订单就被写两次。

建议把工具分成两类:

工具类型默认策略恢复条件
纯读取(查天气、读配置)可重试,但每次调用仍记录 attempt结果可以接受轻微时间漂移,且请求带超时
外部副作用(扣库存、发消息)只允许幂等执行必须有业务幂等键、去重记录和补偿路径
长任务提交(视频、训练、导出)提交与查询分离提交返回稳定 jobId,恢复只查询 jobId,不重复创建

工具协议可以这样表达:

type ToolInvocation = {
  invocationId: string;
  toolName: string;
  inputHash: string;
  attempt: number;
  sideEffect: 'none' | 'idempotent' | 'compensatable';
  status: 'planned' | 'started' | 'succeeded' | 'failed' | 'unknown';
};

function buildIdempotencyKey(taskId: string, invocationId: string) {
  return `${taskId}:${invocationId}`;
}

status='unknown' 时,不能凭异常类型猜“肯定没执行”。网络在服务端提交后、客户端收到响应前断开,是最典型的未知状态。正确做法是用幂等键查询工具方的执行记录,查询不到再进入人工或补偿队列;不允许恢复循环直接再发一次写请求。

5. Workflow 2.0.21—2.0.22:持久化的不只是最终文本

Durable Workflow 的恢复点通常跨越多个步骤。只保存最终文本,会丢掉决定可重复性的上下文:某个工具是否执行、文件来源顺序是什么、结构化输出用的 schema 版本是什么。Workflow 更新把跨步骤工具行为、Provider 文件/来源顺序和已执行工具结果放到持久语义里,应用层仍需为这些字段设计稳定的存储协议。

推荐至少保存以下步骤记录:

type DurableStep = {
  taskId: string;
  stepId: string;
  inputDigest: string;
  toolCalls: Array<{
    invocationId: string;
    name: string;
    resultRef?: string;
    status: 'succeeded' | 'failed' | 'unknown';
  }>;
  files: Array<{ id: string; order: number; checksum?: string }>;
  sources: Array<{ uri: string; order: number; retrievedAt: string }>;
  structuredOutput?: { schemaVersion: string; value: unknown };
  state: 'running' | 'waiting' | 'succeeded' | 'failed';
};

这里的 order 很重要。多文件输入和多来源检索如果在恢复时排序变化,模型可能得到不同上下文;即使最终文本看起来相同,评测和审计也无法证明输入一致。schemaVersion 则防止构造器级结构化输出推断更新后,旧任务被新 schema 误读。

5.1 文件和来源不是日志附件

文件对象要记录稳定 ID、摘要或校验和、产生时间和过期策略;来源要记录 URI、检索时间和顺序。把它们只放在日志字符串里,会出现两个问题:日志保留期比任务短,或者恢复程序无法可靠解析日志。

一个最小的持久化接口如下:

interface StepStore {
  put(step: DurableStep): Promise<void>;
  get(taskId: string, stepId: string): Promise<DurableStep | null>;
  compareAndSet(
    taskId: string,
    stepId: string,
    from: DurableStep['state'],
    to: DurableStep['state'],
  ): Promise<boolean>;
}

compareAndSet 让两个恢复 worker 不能同时把同一个步骤从 running 提交到 succeeded。数据库实现可以用版本号或条件更新;内存实现只能用于本地模拟,不能替代生产锁和持久存储。

6. 本地 Node.js 22 模拟:失败一次,恢复一次,工具只落一条

为了验证状态模型,本文在 Node.js v22.19.0 下写了一个不访问网络的内存模拟。第一次 Provider 调用在首个片段之后抛出 socket reset after first chunk,第二次调用返回答案;工具结果随后按步骤键持久化。运行输出如下:

{
  "providerCalls": 2,
  "result": {
    "attempt": 2,
    "text": "final answer",
    "metadata": { "inputTokens": 42 }
  },
  "ledger": [
    {
      "attempt": 1,
      "status": "failed_after_start",
      "error": "socket reset after first chunk"
    },
    {
      "attempt": 2,
      "status": "succeeded",
      "metadata": { "inputTokens": 42 }
    }
  ],
  "persistedTool": {
    "tool": "weather",
    "result": { "city": "嘉兴", "tempC": 26 },
    "attempt": 2
  },
  "idempotentDelete": true
}

这个输出只证明状态机和账本结构在本地可运行,不证明 ai@7.0.91 已经和某个真实 Provider 完成兼容性测试。它有三个可验收点:调用次数是 2 而不是无限循环;失败尝试被标记为 failed_after_start;工具结果由稳定键读取,而不是依赖最后一段文本。

6.1 把模拟换成真实 Provider 前先列缺口

接入真实模型前,至少补做这些探针:

  1. 让 Provider 在首片段后主动断开,确认第二次尝试是否只在 allowAfterStart=true 时启动。
  2. 用一个可查询的测试工具制造“服务端已提交、客户端超时”的未知状态,验证幂等键查询和补偿分支。
  3. 在取消信号触发后检查流、工具 HTTP 请求和持久化 worker 是否都结束;只停止前端读取不算取消传播。
  4. 记录每次尝试的输入 token、输出 token、缓存命中和成本字段,确认账单按 attempt 归因,而不是把失败尝试抹掉。
  5. 重新加载同一个 taskId,核对文件和来源顺序、schemaVersion 与第一次执行一致。

7. 失败分类、观测和回滚边界

Langfuse v4.27.0 增加多模态 Evaluator 输入、MCP Evaluator 测试工具以及缓存输入 Token / 成本字段,同时回补 API Key entitlement、JWT 默认期限和 OTel 属性安全修复。对本文问题最有用的不是“多了几个观测字段”,而是把恢复账本接入评测和成本分析:失败后的第二次尝试不能在报表里消失,工具输入也不能因为日志脱敏而失去关联键。

建议把以下字段作为统一事件:

事件必填字段告警条件
stream_startedtaskId、attempt、model、inputDigest同一 taskId 的 attempt 超过预算
stream_failed_after_startemittedChars、error、providerRequestId失败后仍有副作用提交
recovery_startedpreviousAttempt、remainingBudget恢复比例持续升高
tool_unknowninvocationId、idempotencyKey、toolName未知状态超过 TTL
workflow_succeededfinalAttempt、stepIds、cost没有对应成功步骤或成本字段

回滚也要分层:

  • 代码回滚:把 SDK 固定回上一稳定版本,只能阻止新行为,不能撤回已经发送的片段。
  • 任务回滚:把未提交的步骤标为 TERMINAL_FAILED,允许用户以新 taskId 重新开始。
  • 副作用补偿:调用业务方提供的撤销或冲正接口;如果没有补偿接口,必须进入人工队列。
  • 凭据回滚:Harness 的短期假凭据替换失败时,回退到安全拒绝,不把真实凭据塞进重试上下文。

不要把“删掉一条 trace”当成回滚。Trace 是证据,不是事务;删除它只会让事故更难定位。

8. 上线前的最小验收表

把下面的表复制到 CI 或发布记录中,逐项填入真实结果:

检查项通过标准未通过时的动作
受限恢复开关allowAfterStart 显式配置,默认关闭或有上限禁止把所有网络异常自动重试
尝试账本每次 attempt 有开始、失败/成功、metadata 和错误补事件表,不覆盖旧尝试
文本副作用onChunk 不执行不可逆写操作把工具移到幂等层或 commit 后
工具未知状态有 invocationId、幂等键和查询/补偿路径暂停自动恢复,转补偿队列
Durable 步骤工具、文件、来源、结构化输出和顺序可读回不能只保存最终文本
取消传播Provider、工具请求、流和 worker 都能结束修复 AbortSignal 连接链
成本归因失败与成功 attempt 都有 token / 成本记录不能用最终结果覆盖账单
版本边界生产锁定 ai@7.0.91、Workflow 2.0.22 的实际依赖并记录 Provider预发布版本单独隔离
回滚演练代码、任务、副作用三种回滚均有负责人没有补偿接口则不开放写工具

9. 结论:完成不是“拿到一段文本”,而是能证明发生了什么

ai@7.0.91 的受限恢复解决的是流已经开始后的一个窄问题:在可控次数内重新取得成功尝试,并且不把失败尝试误当最终结果。Workflow 2.0.212.0.22 解决的是跨步骤恢复时的上下文完整性:工具、文件、来源和结构化输出必须可持久、可排序、可回读。两者都没有替应用决定副作用是否幂等,也没有替应用提供业务补偿。

如果你的系统只能回答“最后返回了什么”,它还不能验收流式 Agent。至少把下面四个问题写进发布门禁:

  1. 哪一次尝试向用户发过片段?
  2. 哪个工具已经执行,哪个处于未知状态?
  3. 恢复时文件、来源和 schema 是否与原步骤一致?
  4. 发生错误后,代码、任务和业务副作用分别怎样回滚?

能逐项回答,才算把重试从一个按钮改造成一条有边界的恢复协议。

10. 实施时最容易漏掉的四个细节

第一,恢复预算必须绑定任务,而不是绑定进程。服务重启后如果把计数器清零,原本只允许两次恢复的任务可能在多个 worker 上无限重试。把 remainingBudget 写进任务记录,并用条件更新扣减。

第二,失败尝试的片段不要直接拼进最终答案。前端可以保留它作为“连接中断”提示,但服务端的 finalText 只能来自 attempt_succeeded 事件。否则用户会看到重复的开头,评测也无法判断哪次结果生效。

第三,工具结果要存引用而不是无限复制。大 JSON、文件和多模态输出放对象存储,步骤记录只保存 resultRef、摘要和校验和;恢复时先校验引用仍在有效期内,再决定复用或重新执行。

第四,成本与质量指标要能按 attempt 和 step 下钻。只看任务级平均延迟,会把一次失败重试隐藏在成功任务里;只看最终 token,也会漏掉失败尝试产生的费用。把 taskIdattemptstepId、模型版本和缓存命中字段写入同一条 trace,才能在回滚或调价后重算。

这四个细节都不依赖特定 Provider,却决定了受限恢复能否在生产环境保持可解释。上线前可以故意缩短 TTL、降低恢复预算、制造工具超时,再逐项确认任务会停在预期终态,而不是靠人工删除队列消息“恢复正常”。

还要把恢复结果暴露给调用方。接口响应至少返回 taskIdfinalAttemptstateevidenceRef,前端据此显示“已完成”“等待补偿”或“需要重新提交”。不要让前端通过是否收到最后一个换行符来猜状态,也不要让网关在超时后把 504 重写成 200。状态码、事件账本和用户提示必须指向同一个终态。

在多租户系统中,再增加租户维度的隔离检查。恢复 worker 读取步骤时必须同时匹配 tenantIdtaskId,对象存储的 resultRef 也要带租户前缀;只校验任务 ID 会把一个租户的工具结果暴露给另一个租户。把这个检查放在持久化层,而不是只放在路由层,才能覆盖定时任务、补偿队列和人工重放入口。

官方来源

素材清单

  • 2026-09-03-Vercel-AI-SDK流式恢复-发布素材/01-封面.png:1536×1024,文章封面,包含“流式恢复不是重试按钮”和版本副标题。
  • 2026-09-03-Vercel-AI-SDK流式恢复-发布素材/02-流式恢复与持久证据.png:1672×941,第 3 节和第 5 节配图,解释已发送片段、受限恢复、工具结果持久化与可验收终态的关系。

G0—G3 审核记录(2026-09-03)

  • G0:单一读者问题是“流已开始后如何恢复而不重复副作用”;主类型为工程教程与上线门禁,受众为接入 AI SDK / Durable Agent 的前后端工程师。
  • G1:版本和语义来自官方 Changelog / Release;Node.js 输出是本地内存模拟;预发布 Ollama、Langflow、vLLM 和信息不足的 ComfyUI 仅保留边界说明。
  • G2:包含受限恢复适配器、工具幂等协议、DurableStep 存储接口、Node.js 输出、失败分类、取消传播与三层回滚;没有真实凭据、内部地址或用户数据。
  • G3:完成主动语态和具体判断编辑,删除重复总结与宏大铺垫;封面与知识图均为独立 PNG,尺寸已回读;平台稿需在 CSDN 与掘金编辑器分别确认实际正文不少于 5000 字并独立上传图片。