流式恢复不是重试按钮:AI SDK 7.0.91 如何把失败变成可验收终态
核心判断:响应流一旦开始发送,已经发出去的片段就不是可以撤回的事务。
ai@7.0.91提供的是“受限恢复”能力,不是无条件回滚;@ai-sdk/workflow@2.0.21—2.0.22则要求 Durable Agent 把工具、文件、来源和结构化输出一起持久化。可靠完成的定义应当是:尝试次数有上限、每次尝试有证据、外部副作用可隔离、最终状态可被列表和指标验收。
这篇文章解决一个具体问题:当 AI Agent 的流式响应已经把一部分 token 发给用户,随后网络或 Provider 失败时,怎样恢复而不重复扣费、不重复执行工具,也不把半截文本当成成功?读者可以用文中的状态模型、TypeScript 适配器、Node.js 22 模拟和验收表,给自己的流式 API 或 Durable Workflow 加上一条可回放的失败边界。
1. 先把“重试”这个词拆开
传统 HTTP 请求常把重试理解成“再发一次相同请求”。这个定义在普通幂等 GET 上还勉强成立,在流式生成和 Agent 工具调用里却不够。至少有三种不同动作经常被同一个 retry() 包住:
- 传输恢复:响应已经开始,连接断开,客户端希望继续拿到最终答案。
- 模型重试:Provider 在没有可用结果时再次采样,可能产生不同文本和不同 token 成本。
- 副作用重放:工具已经写数据库、发邮件或创建任务,又被重试逻辑再次调用。
这三种动作的回滚能力完全不同。传输恢复可以在“只保留成功尝试结果”的前提下继续;模型重试只能通过次数、预算和提示版本限制损失;副作用重放则必须依赖幂等键、事务或人工补偿。把它们都叫重试,会让监控看起来很简单,却让账单、数据和用户看到的文本互相对不上。
1.1 版本证据和验证边界
2026-09-02 的 Vercel AI SDK 发布资料给出 ai@7.0.91 的明确变化:streamText 在响应流已经开始后支持显式配置的受限恢复;恢复成功时只保留成功尝试的结果与 metadata。Workflow 2.0.21—2.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 前先列缺口
接入真实模型前,至少补做这些探针:
- 让 Provider 在首片段后主动断开,确认第二次尝试是否只在
allowAfterStart=true时启动。 - 用一个可查询的测试工具制造“服务端已提交、客户端超时”的未知状态,验证幂等键查询和补偿分支。
- 在取消信号触发后检查流、工具 HTTP 请求和持久化 worker 是否都结束;只停止前端读取不算取消传播。
- 记录每次尝试的输入 token、输出 token、缓存命中和成本字段,确认账单按 attempt 归因,而不是把失败尝试抹掉。
- 重新加载同一个 taskId,核对文件和来源顺序、schemaVersion 与第一次执行一致。
7. 失败分类、观测和回滚边界
Langfuse v4.27.0 增加多模态 Evaluator 输入、MCP Evaluator 测试工具以及缓存输入 Token / 成本字段,同时回补 API Key entitlement、JWT 默认期限和 OTel 属性安全修复。对本文问题最有用的不是“多了几个观测字段”,而是把恢复账本接入评测和成本分析:失败后的第二次尝试不能在报表里消失,工具输入也不能因为日志脱敏而失去关联键。
建议把以下字段作为统一事件:
| 事件 | 必填字段 | 告警条件 |
|---|---|---|
stream_started | taskId、attempt、model、inputDigest | 同一 taskId 的 attempt 超过预算 |
stream_failed_after_start | emittedChars、error、providerRequestId | 失败后仍有副作用提交 |
recovery_started | previousAttempt、remainingBudget | 恢复比例持续升高 |
tool_unknown | invocationId、idempotencyKey、toolName | 未知状态超过 TTL |
workflow_succeeded | finalAttempt、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.21—2.0.22 解决的是跨步骤恢复时的上下文完整性:工具、文件、来源和结构化输出必须可持久、可排序、可回读。两者都没有替应用决定副作用是否幂等,也没有替应用提供业务补偿。
如果你的系统只能回答“最后返回了什么”,它还不能验收流式 Agent。至少把下面四个问题写进发布门禁:
- 哪一次尝试向用户发过片段?
- 哪个工具已经执行,哪个处于未知状态?
- 恢复时文件、来源和 schema 是否与原步骤一致?
- 发生错误后,代码、任务和业务副作用分别怎样回滚?
能逐项回答,才算把重试从一个按钮改造成一条有边界的恢复协议。
10. 实施时最容易漏掉的四个细节
第一,恢复预算必须绑定任务,而不是绑定进程。服务重启后如果把计数器清零,原本只允许两次恢复的任务可能在多个 worker 上无限重试。把 remainingBudget 写进任务记录,并用条件更新扣减。
第二,失败尝试的片段不要直接拼进最终答案。前端可以保留它作为“连接中断”提示,但服务端的 finalText 只能来自 attempt_succeeded 事件。否则用户会看到重复的开头,评测也无法判断哪次结果生效。
第三,工具结果要存引用而不是无限复制。大 JSON、文件和多模态输出放对象存储,步骤记录只保存 resultRef、摘要和校验和;恢复时先校验引用仍在有效期内,再决定复用或重新执行。
第四,成本与质量指标要能按 attempt 和 step 下钻。只看任务级平均延迟,会把一次失败重试隐藏在成功任务里;只看最终 token,也会漏掉失败尝试产生的费用。把 taskId、attempt、stepId、模型版本和缓存命中字段写入同一条 trace,才能在回滚或调价后重算。
这四个细节都不依赖特定 Provider,却决定了受限恢复能否在生产环境保持可解释。上线前可以故意缩短 TTL、降低恢复预算、制造工具超时,再逐项确认任务会停在预期终态,而不是靠人工删除队列消息“恢复正常”。
还要把恢复结果暴露给调用方。接口响应至少返回 taskId、finalAttempt、state 和 evidenceRef,前端据此显示“已完成”“等待补偿”或“需要重新提交”。不要让前端通过是否收到最后一个换行符来猜状态,也不要让网关在超时后把 504 重写成 200。状态码、事件账本和用户提示必须指向同一个终态。
在多租户系统中,再增加租户维度的隔离检查。恢复 worker 读取步骤时必须同时匹配 tenantId 和 taskId,对象存储的 resultRef 也要带租户前缀;只校验任务 ID 会把一个租户的工具结果暴露给另一个租户。把这个检查放在持久化层,而不是只放在路由层,才能覆盖定时任务、补偿队列和人工重放入口。
官方来源
- Vercel AI SDK
ai@7.0.91Changelog - Vercel Workflow Changelog
- Vercel Harness Changelog
- Langfuse v4.27.0 Release
素材清单
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 字并独立上传图片。