Vercel AI SDK 深度解析:ToolLoopAgent 是 Facade,真正的循环在 generateText 里
从 ToolLoopAgent 出发,追踪到 generateText 内部的 do-while 循环;逐行解析 StopCondition 类型设计、isStepCount/hasToolCall 工厂函数,以及中间件洋葱模型的 reverse-reduce 实现。读完可独立复现 AI SDK 的 Agent 核心逻辑。
第 5 篇 / 共 5 篇
一、导读
分析模块:packages/ai/src/agent/(Agent 层)、packages/ai/src/generate-text/(核心循环)、packages/ai/src/middleware/(中间件层)
读完你将理解:
- 为什么
ToolLoopAgent是 Facade,而不是包含真正循环逻辑的类 generateText内部 do-while 的完整结构,包括 Tool Approval 预热阶段StopCondition的三个工厂函数各自的语义,以及Promise.all无短路设计的原因- 中间件洋葱模型的
reverse-reduce实现,以及为什么每个中间件同时持有doGenerate和doStream
源码克隆自 vercel/ai 主分支,所有行号经实测验证。
二、模块全景
在项目中的位置
packages/ai/src/
├── agent/ ← 本文分析的起点
│ ├── agent.ts # Agent 接口定义
│ ├── tool-loop-agent.ts # ToolLoopAgent 类(Facade)
│ ├── tool-loop-agent-settings.ts # 设置类型
│ ├── create-agent-ui-stream.ts # UI 流式接口
│ └── index.ts # 出口
├── generate-text/ ← 真正的循环实现(75 个文件)
│ ├── generate-text.ts # 核心函数,do-while 在这里
│ ├── stop-condition.ts # StopCondition 类型 + 三个工厂函数
│ ├── step-result.ts # StepResult 类型定义
│ ├── execute-tool-call.ts # 工具执行
│ ├── collect-tool-approvals.ts # Tool Approval 收集(预热阶段)
│ └── stream-text.ts # 流式版本
└── middleware/
└── wrap-language-model.ts # 洋葱模型组合
NOTE
模块层次的一处不完全干净之处
packages/ai/src/prompt/prepare-tools.ts:L12 反向引用了 generate-text/tool-order 的类型定义,使 prompt/ 层和 generate-text/ 内部存在同层双向耦合(类型层面)。这是一处轻微的抽象泄漏——ToolOrder 是执行层概念,不该出现在 prompt 准备层的命名空间里。修复方向:将 ToolOrder 上移至共享类型层,或将 prepare-tools.ts 归入 generate-text/ 内部。不影响运行时行为,属于架构演进债务。
核心符号总览
| 符号 | 所在文件 | 类型 | 职责 |
|---|---|---|---|
ToolLoopAgent | tool-loop-agent.ts:L39 | class | Facade,包裹 generateText |
generateText | generate-text.ts:L222 | function | 真正的 Agent Loop 实现 |
StopCondition | stop-condition.ts:L14 | type | 停止条件函数签名 |
isStepCount | stop-condition.ts:L27 | function | 按步数停止的工厂函数 |
hasToolCall | stop-condition.ts:L47 | function | 按工具名停止的工厂函数 |
isLoopFinished | stop-condition.ts:L37 | function | Null Object:永远返回 false,让循环依赖自然终止机制 |
StepResult | step-result.ts:L138 | type | 单步执行结果的不可变快照 |
wrapLanguageModel | wrap-language-model.ts:L23 | function | 洋葱模型中间件组合 |
三、执行流还原
从 ToolLoopAgent 到 generateText
追踪一次 agent.generate() 调用的完整路径:
ToolLoopAgent.generate() tool-loop-agent.ts:L197
└─ prepareCall() tool-loop-agent.ts:L80
│ (合并 settings 层和 call 层参数)
└─ mergeCallbacks() tool-loop-agent.ts:L220-248
│ (onStart / onStep / onEnd 回调合并)
└─ generateText({ tool-loop-agent.ts:L251
stopWhen: isStepCount(20), ← ToolLoopAgent 默认值
...mergedOptions
})
└─ generateText() generate-text.ts:L222
├─ resolveLanguageModel() L552
├─ asArray(stopWhen) L553 ← 统一转为数组
├─ standardizePrompt() L588
├─ collectToolApprovals() L655 ← ⚠️ 预热阶段开始
├─ validateApprovedToolApprovals() L660 ← HMAC 签名验证
├─ executeTools(localApproved) L681 ← 预热工具执行
└─ do { ... } while(...) L785 ← 核心循环
Tool Approval 预热阶段
在 do-while 循环开始之前,generateText 有一段预热逻辑(L655-L759):
collectToolApprovals() L655 从已有 messages 提取历史审批记录
validateApprovedToolApprovals() L660 HMAC 签名验证历史审批合法性
[如有已批准的 tool calls]:
executeTools(localApproved) L681 在循环开始前先执行一批工具
initialResponseMessages.push() L755 结果写入作为循环初始 context
这个阶段使 Agent 可以从中断点恢复执行——将上一次会话的审批结果 replay 进去,不需要重新执行所有工具。这是拦截器(Interceptor)模式在 Tool Approval 场景的应用:在核心循环之前插入审批检查层,被拒绝的调用不进入循环,但结果仍写入 context。
generateText 内部的 do-while 循环
generateText 的核心是一个 do-while 结构(generate-text.ts:L785-L1350):
do {
stepNumber = steps.length L793
prepareStep?.() L805 ← 每步可动态切换 model
convertToLanguageModelPrompt() L831 ← 格式转换
← Tool Approval 拦截层(L1038-L1137)
resolveToolApproval() 对每个 tool call:
[user-approval] → 阻止执行,生成审批请求
[approved] → 自动批准,记录 response
[denied] → 阻止执行,记录 denied result
executeTools(approvedToolCalls) L1169 ← 并行执行
steps.push(new DefaultStepResult(...)) L1323 ← append-only
messagesForNextStep = [...spread] L1325 ← 每步重建新数组
} while (
(clientToolCalls.length > 0 && outputs.length + denied.length === calls.length
|| pendingDeferredToolCalls.size > 0) ← 有理由继续(含延迟结果路径)
&& !(await isStopConditionMet({...})) ← 停止条件未满足
);
步骤结果的构造与追加
每次 LLM 调用完成后,generate-text.ts:L1298-L1323 构建 StepResult 并 push 进 steps 数组。
两个关键性质:
steps.push()之后,代码里没有对steps[i]的任何写操作——完全不可变messagesForNextStep = [...stepMessages, ...stepResponseMessages]是 spread 新数组——每步重建,不原地修改
四、核心数据结构
StopCondition 类型
type StopCondition<TOOLS, RUNTIME_CONTEXT> = (options: {
steps: Array<StepResult<TOOLS, RUNTIME_CONTEXT>>;
}) => PromiseLike<boolean> | boolean;
入参是 { steps } 对象而非直接传数组——这是预留了扩展槽,未来可以往这个对象里加字段而不改函数签名。返回值支持 PromiseLike<boolean>,可以写异步停止条件(如查数据库判断是否超过预算)。
参数是全量 steps 历史数组,不是当前步骤。这使得停止条件可以做跨步判断,例如"过去 3 步都是 tool-calls 则停止"——传当前 step 无法实现这种逻辑。
StepResult 字段语义
StepResult 是单步执行结果的不可变快照,所有字段均为 readonly:
| 字段 | 类型 | 业务语义 |
|---|---|---|
callId | string | 同一次 generateText 调用的关联 ID,用于跨步追踪 |
stepNumber | number | 零起始步骤索引,等于 push 前的 steps.length |
content | ContentPart[] | 底层存储,所有派生字段均由此过滤 |
text | string | get 计算属性,过滤 type==='text' 拼接 |
toolCalls | TypedToolCall[] | get 计算属性,过滤 type==='tool-call' |
toolResults | TypedToolResult[] | get 计算属性,过滤 type==='tool-result' |
staticToolCalls | StaticToolCall[] | 有效解析到声明 ToolSet 的调用 |
dynamicToolCalls | DynamicToolCall[] | 解析失败的 fallback 调用(非动态注册,是错误处理路径) |
finishReason | FinishReason | 标准化终止原因(stop/tool-calls/length/content-filter/error) |
rawFinishReason | string | Provider 原始终止原因,标准化有损时的 fallback |
usage | LanguageModelUsage | Token 用量 |
performance | StepResultPerformance | 7 个性能指标,含 toolExecutionMs(按 toolCallId 分桶) |
warnings | CallWarning[] | undefined | Provider 能力边界警告 |
NOTE
DynamicToolCall 的真实语义
dynamicToolCalls 不是"运行时动态注册的工具",而是"无法解析到声明 ToolSet 的调用"——包括工具名在运行时才知道、input 无法校验、或工具不存在的情况。tool-call.ts:L44 有 TODO 注释说明这是 AI SDK 6 的历史债务,未来会拆出独立的 InvalidToolCall 类型。
TIP
performance 字段的可观测性意义
StepResultPerformance 包含 7 个生产级指标:effectiveOutputTokensPerSecond、outputTokensPerSecond(仅 streaming)、inputTokensPerSecond(仅 streaming)、effectiveTotalTokensPerSecond、stepTimeMs、responseTimeMs、toolExecutionMs(按 toolCallId 分桶)。StepResult 本身就是一个完整的可观测性数据包,配合 callId 可以把整次 Agent 执行的所有步骤归拢到同一个 trace 下。
stopWhen 参数的两个默认值
这里有一个容易踩坑的细节:generateText 和 ToolLoopAgent 的默认 stopWhen 不同。
generateText 的默认值(generate-text.ts:L553):
const stopConditions = asArray(stopWhen ?? isStepCount(1));
// 默认:1 步后停止
ToolLoopAgent.prepareCall() 的默认值(tool-loop-agent.ts:L93):
stopWhen: settings.stopWhen ?? isStepCount(20),
// 默认:20 步后停止
直接调用 generateText 时如果不传 stopWhen,默认只跑 1 步——这是保守的安全默认值,防止意外的无限循环。ToolLoopAgent 作为 Agent 专用 Facade,把默认值提升到 20 步,更符合 Agent 的使用场景。
Arrayable 与 OR 语义
stopWhen 参数类型是 Arrayable<StopCondition>——可以传单个条件,也可以传数组。
generate-text.ts:L553 先用 asArray() 统一转为数组,评估时(stop-condition.ts:L74-L76):
return (
await Promise.all(stopConditions.map(condition => condition({ steps })))
).some(result => result);
数组里任意一个返回 true → 整体停止(OR 语义)。
五、核心算法逐行解读
isStepCount:最简单也最容易误解的工厂
export function isStepCount(stepCount: number): StopCondition<any, any> {
return ({ steps }) => steps.length === stepCount;
}
使用 === 精确等号,不是 >=。在 do-while 循环里,steps.length 严格顺序递增,精确等号不会出现跳过的情况,这个实现是安全的。
WARNING
isStepCount(0) 是静默的语义错误
do-while 保证至少执行一次,第一轮结束后 steps.length === 1,isStepCount(0) 永远返回 false。类型接受 number,0 是合法值,但行为和用户预期完全相反。建议加参数校验或将类型收窄为正整数。
hasToolCall:语义感知的停止条件
export function hasToolCall<TOOLS extends ToolSet>(
...toolName: Array<keyof TOOLS | (string & {})>
): StopCondition<TOOLS, any> {
return ({ steps }) =>
steps[steps.length - 1]?.toolCalls?.some(toolCall =>
toolName.includes(toolCall.toolName),
) ?? false;
}
三个细节都有意为之:
keyof TOOLS | (string & {}):TypeScript 的 autocomplete 保留技巧。直接写 string 会让 IDE 不提示工具名,keyof TOOLS 单独写又不接受任意字符串。string & {} 是 string 的非空子类型,让 TypeScript 保留 keyof TOOLS 的提示,同时允许传任意字符串。
双重可选链:steps[steps.length - 1]?.toolCalls?.some(...) 防止 steps 为空(steps[-1] 是 undefined)和 toolCalls 为空。
?? false:当 steps 为空时兜底返回 false,不抛异常,不停止循环。
isLoopFinished:明确意图的 Null Object
export function isLoopFinished(): StopCondition<any, any> {
return () => false;
}
名字具有误导性——它不是说"循环已结束",而是"让循环自然结束,不人为干预"。
这是 Null Object 模式:isLoopFinished() 返回一个实现了 StopCondition 接口的合法对象,行为是"永远返回 false"。调用方无需判断 if (stopWhen !== undefined) 再决定是否检测——直接调用即可,Null Object 保证没有副作用。
与 Sentinel Value 的区别:Sentinel 用特殊值信号化状态(如 -1 表示未找到),关注的是值的语义;Null Object 关注的是行为——它就是在做该做的事(评估是否停止),只是永远得出"不停"的结论。
NOTE
命名的歧义
isLoopFinished 字面意思是"循环是否完成",但它的实际语义是"永不通过 stop condition 停止循环"。更准确的名字应该是 neverStop 或 naturalTermination。在文档里理解时,记住:选择 isLoopFinished 等于对框架说"你自己决定什么时候结束"。
中间件洋葱模型:reverse-reduce 的精妙之处
wrap-language-model.ts:L37-L41:
return [...asArray(middlewareArg)]
.reverse()
.reduce((wrappedModel, middleware) => {
return doWrap({ model: wrappedModel, middleware });
}, model);
reverse() 后 reduce()——这是洋葱模型:数组第一个元素包裹在最外层,最后一个元素紧贴 model。执行顺序是"外层进 → 中层进 → 内层进 → 内层出 → 中层出 → 外层出"。
doWrap:为什么每个中间件同时持有 doGenerate 和 doStream
wrap-language-model.ts:L81-L94:
doGenerate: async (options) => {
if (middleware.wrapGenerate) {
return middleware.wrapGenerate({
doGenerate: () => wrappedModel.doGenerate(options),
doStream: () => wrappedModel.doStream(options), // ← 两个都传进去
params: options,
model: wrappedModel,
});
}
return wrappedModel.doGenerate(options);
},
中间件同时持有 doGenerate 和 doStream 两个引用——这是 Decorator 而不是 Pipeline 的关键证据。Pipeline 的每个节点只看前一个节点的输出;Decorator 包着整个调用决策权,可以选择调用 generate 还是 stream,甚至完全跳过。一个"把 stream 转成 generate"的缓存中间件在这里完全可以实现,Pipeline 做不到。
六、设计决策解读
决策一:ToolLoopAgent 为什么是 Facade 而不是包含循环逻辑
如果 ToolLoopAgent 自己实现循环,会发生什么?
- 不一致的 API:
generateText和ToolLoopAgent会有两套独立的循环实现,功能差异会在维护中逐渐扩大 - 重复的 stop condition 逻辑:每加一个新的工厂函数,两套实现都要同步
Facade 方案的代价:ToolLoopAgent.generate() 的参数类型需要和 generateText 对齐,这造成了 tool-loop-agent.ts:L260 的 as unknown as 强制转换——类型整合不完整,但运行时行为正确。
决策二:为什么 StopCondition 接收全量 steps 历史
最简单的设计是只传当前步骤:(currentStep: StepResult) => boolean。但设计者选择了传全量历史 { steps: StepResult[] }。
代价:每次停止条件检测都把完整 steps 数组传入,50 步的 agent 第 50 次检测传的是 50 个 StepResult 对象。
收益:停止条件可以做跨步判断——hasToolCall 检查最后一步的 tool calls,用户也可以自己写"过去 3 步内都有 tool-calls 则停止"这样的复杂逻辑。传当前 step 无法实现这类历史感知。
这是"表达能力 vs 运行时开销"的经典取舍,设计者选了表达能力。
决策三:中间件用洋葱模型而不是 Pipeline 节点图
Pipeline(如 LlamaIndex 的 workflow 节点图)的优势是可以可视化,适合复杂的分支逻辑。但它的代价是:每个节点只看到前一个节点的输出,无法做到"进入时处理,退出时也处理"。
洋葱模型可以在 wrapGenerate 里写:
// 进入时:记录请求
const startTime = Date.now();
// 调用下一层
const result = await doGenerate(options);
// 退出时:记录响应时长
telemetry.record(Date.now() - startTime);
return result;
这种"进出双向拦截"是日志、缓存、限流等横切关注点的天然实现模式,Pipeline 做不到。
决策四:为什么没有 Plugin 注册中心——类型系统即扩展点
初次接触 vercel/ai 的开发者通常会找"如何注册自定义 Provider / 自定义 stop condition / 自定义中间件",却发现没有 registry.register() 这类 API。
这是刻意的设计:扩展点就是类型签名。
- 自定义 Provider:实现
LanguageModelV4接口的两个方法即可,无需注册 - 自定义 stop condition:写一个
(options: { steps }) => boolean函数即可 - 自定义中间件:实现
LanguageModelMiddleware的任意 hook 即可
框架通过 TypeScript 类型约束而非运行时注册机制来保证扩展性。这个选择有三个连锁效益:
- 零运行时开销:类型检查在编译期完成,运行时不需要查询注册表
- 可摇树优化(Tree-shaking):未使用的 Provider 实现不会打包进产物
- IDE 补全驱动:类型签名本身就是文档,违反约束时编译器报错,不需要阅读"如何扩展"的教程
对比 LangChain 的运行时工具注册(tool() 装饰器 + 全局注册表)和 Mastra 的 createTool() 注册 API,vercel/ai 的"无注册中心"不是功能缺失,而是一个有意识的架构选择:把扩展契约前移到类型系统,而不是推迟到运行时。
七、横向对比
循环终止设计对比
| 框架 | 终止机制 | 可访问上下文 | 内置策略 |
|---|---|---|---|
| vercel/ai v6 | StopCondition[],OR 语义 | 全量 steps 历史 | isStepCount、hasToolCall、isLoopFinished |
| LangChain.js | maxIterations: number | 无(框架内部计数) | 仅步数截断 |
| Mastra | maxSteps: number | 无 | 仅步数截断,直接复用 vercel/ai Provider 层 |
| pi-mono | shouldStopAfterTurn 回调 | { message, toolResults, context, newMessages } | 无内置,纯开放式 |
hasToolCall 是 vercel/ai 的独特能力——LangChain 的 maxIterations 无法表达"当 agent 调用了 finalAnswer 工具时停止"这个语义。这不是语法糖的差距,是表达能力的差距。
双终止机制是 vercel/ai 最容易被忽视的设计:do-while 有两条独立的退出路径——自然终止(LLM 返回 stop,无 tool calls,loop 直接退出,stop condition 甚至不被检测)和显式终止(stop condition 返回 true)。LangChain 的 maxIterations 是单一截断,不区分自然结束和强制停止。实践意义:isStepCount(1) 和 isStepCount(20) 在 LLM 返回 stop 时行为完全相同——都是一步退出,stop condition 根本不会被调用。
步骤可观测性对比
| 框架 | 步骤性能内置 | 可追踪数据 |
|---|---|---|
| vercel/ai v6 | ✅ StepResult.performance 内置 7 个指标 | toolExecutionMs 按 toolCallId 分桶、stepTimeMs、TTFT、token/s |
| LangChain.js | ❌ 无内置 | 需外部 tracing 工具(LangSmith 等) |
| Mastra | ❌ 无内置 | 无 |
vercel/ai 把性能可观测性作为一等公民设计进了数据结构——不需要接入外部 tracing 系统,每一步的耗时分布都随 StepResult 一起返回,可以直接用于生产告警和性能分析。
扩展机制对比:类型系统 vs 运行时注册
| 框架 | 扩展机制 | 扩展点验证时机 |
|---|---|---|
| vercel/ai | 实现类型签名即扩展,无注册 API | 编译期(TypeScript 类型检查) |
| LangChain.js | tool() 装饰器 + 全局工具注册表 | 运行时 |
| Mastra | createTool() 注册 API | 运行时 |
这个差异是架构哲学层面的分歧,不是功能多寡的问题。vercel/ai 的"无注册中心"意味着:未使用的 Provider 可被摇树优化、扩展契约就是 TypeScript 类型签名、IDE 补全本身就是文档。代价是"如何扩展"不够显式——初次接触的开发者需要先理解类型系统是扩展点,才能找到入口。
Provider 接口厚度对比
LanguageModelV4 接口(@ai-sdk/provider 包)仅要求实现 2 个方法:
interface LanguageModelV4 {
doGenerate(options): Promise<LanguageModelV4GenerateResult>;
doStream(options): Promise<LanguageModelV4StreamResult>;
}
LangChain 的 BaseChatModel 要求实现 _generate、_stream、_llmType、_modelType,还有 bindTools、withStructuredOutput 等高层方法——业务逻辑被压入了 Provider 层,实现者必须理解上层业务概念。
vercel/ai 的 Provider 只管"发请求拿响应",所有业务逻辑(tool call 循环、结构化输出、中间件)都在核心层处理。分层更干净。
中间件组合机制对比
| 框架 | 中间件模型 | 双向拦截 | 调用路径切换 |
|---|---|---|---|
| vercel/ai | 洋葱模型(reverse-reduce) | ✅ | ✅(可选 generate 或 stream) |
| LangChain | callback 链(事件驱动) | ❌(只能监听,不能修改) | ❌ |
| LlamaIndex.TS | 显式 Pipeline 节点图 | ❌ | ❌(节点只看上一个节点输出) |
工程质量评审
| 优先级 | 问题 | 位置 | 影响 |
|---|---|---|---|
| P2 | Stream 错误处理 Provider 间不一致:openai 映射完整(429/401/403...),anthropic 只映射 overloaded→529,其余全 500 | openai-stream-error.ts vs anthropic-language-model.ts | Anthropic 下 rate limit 错误得到 500,重试策略失效 |
| P2 | isStepCount(0) 静默失效,永远不触发 | stop-condition.ts:L28 | 传 0 时行为与预期完全相反,无任何报错 |
| P3 | as unknown as 强制类型转换 | tool-loop-agent.ts:L260, L317 | 类型检查在 Facade/核心边界处有盲区 |
✅ 亮点:95 个测试文件,generate-text/ 含 20 个专项测试,多轮 tool call 有集成测试覆盖;provider-utils 共享基础设施被一致使用;StepResult 全字段 readonly,不可变快照设计彻底。
参考
本文代码均基于 vercel/ai 主分支实测确认 1,API 参考见官方文档 2。