未设置发布日期约 3456 字11 分钟阅读

Vercel AI SDK 深度解析:ToolLoopAgent 是 Facade,真正的循环在 generateText 里

从 ToolLoopAgent 出发,追踪到 generateText 内部的 do-while 循环;逐行解析 StopCondition 类型设计、isStepCount/hasToolCall 工厂函数,以及中间件洋葱模型的 reverse-reduce 实现。读完可独立复现 AI SDK 的 Agent 核心逻辑。

GitHub 源码解析

第 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 实现,以及为什么每个中间件同时持有 doGeneratedoStream

源码克隆自 vercel/ai 主分支,所有行号经实测验证。

二、模块全景

在项目中的位置

text
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/ 内部。不影响运行时行为,属于架构演进债务。

核心符号总览

符号所在文件类型职责
ToolLoopAgenttool-loop-agent.ts:L39classFacade,包裹 generateText
generateTextgenerate-text.ts:L222function真正的 Agent Loop 实现
StopConditionstop-condition.ts:L14type停止条件函数签名
isStepCountstop-condition.ts:L27function按步数停止的工厂函数
hasToolCallstop-condition.ts:L47function按工具名停止的工厂函数
isLoopFinishedstop-condition.ts:L37functionNull Object:永远返回 false,让循环依赖自然终止机制
StepResultstep-result.ts:L138type单步执行结果的不可变快照
wrapLanguageModelwrap-language-model.ts:L23function洋葱模型中间件组合

三、执行流还原

从 ToolLoopAgent 到 generateText

追踪一次 agent.generate() 调用的完整路径:

text
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):

text
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):

text
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 类型

stop-condition.ts:L14-L19

typescript
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

字段类型业务语义
callIdstring同一次 generateText 调用的关联 ID,用于跨步追踪
stepNumbernumber零起始步骤索引,等于 push 前的 steps.length
contentContentPart[]底层存储,所有派生字段均由此过滤
textstringget 计算属性,过滤 type==='text' 拼接
toolCallsTypedToolCall[]get 计算属性,过滤 type==='tool-call'
toolResultsTypedToolResult[]get 计算属性,过滤 type==='tool-result'
staticToolCallsStaticToolCall[]有效解析到声明 ToolSet 的调用
dynamicToolCallsDynamicToolCall[]解析失败的 fallback 调用(非动态注册,是错误处理路径)
finishReasonFinishReason标准化终止原因(stop/tool-calls/length/content-filter/error)
rawFinishReasonstringProvider 原始终止原因,标准化有损时的 fallback
usageLanguageModelUsageToken 用量
performanceStepResultPerformance7 个性能指标,含 toolExecutionMs(按 toolCallId 分桶)
warningsCallWarning[] | undefinedProvider 能力边界警告

NOTE

DynamicToolCall 的真实语义 dynamicToolCalls 不是"运行时动态注册的工具",而是"无法解析到声明 ToolSet 的调用"——包括工具名在运行时才知道、input 无法校验、或工具不存在的情况。tool-call.ts:L44 有 TODO 注释说明这是 AI SDK 6 的历史债务,未来会拆出独立的 InvalidToolCall 类型。

TIP

performance 字段的可观测性意义 StepResultPerformance 包含 7 个生产级指标:effectiveOutputTokensPerSecondoutputTokensPerSecond(仅 streaming)、inputTokensPerSecond(仅 streaming)、effectiveTotalTokensPerSecondstepTimeMsresponseTimeMstoolExecutionMs(按 toolCallId 分桶)。StepResult 本身就是一个完整的可观测性数据包,配合 callId 可以把整次 Agent 执行的所有步骤归拢到同一个 trace 下。

stopWhen 参数的两个默认值

这里有一个容易踩坑的细节:generateTextToolLoopAgent 的默认 stopWhen 不同

generateText 的默认值(generate-text.ts:L553):

typescript
const stopConditions = asArray(stopWhen ?? isStepCount(1));
// 默认:1 步后停止

ToolLoopAgent.prepareCall() 的默认值(tool-loop-agent.ts:L93):

typescript
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):

typescript
return (
  await Promise.all(stopConditions.map(condition => condition({ steps })))
).some(result => result);

数组里任意一个返回 true → 整体停止(OR 语义)。

StopCondition 步进演示 — Promise.all 并行求值与双终止机制

五、核心算法逐行解读

isStepCount:最简单也最容易误解的工厂

stop-condition.ts:L27-L29

typescript
export function isStepCount(stepCount: number): StopCondition<any, any> {
  return ({ steps }) => steps.length === stepCount;
}

使用 === 精确等号,不是 >=。在 do-while 循环里,steps.length 严格顺序递增,精确等号不会出现跳过的情况,这个实现是安全的。

WARNING

isStepCount(0) 是静默的语义错误 do-while 保证至少执行一次,第一轮结束后 steps.length === 1isStepCount(0) 永远返回 false。类型接受 number,0 是合法值,但行为和用户预期完全相反。建议加参数校验或将类型收窄为正整数。

hasToolCall:语义感知的停止条件

stop-condition.ts:L47-L54

typescript
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

stop-condition.ts:L37-L39

typescript
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 停止循环"。更准确的名字应该是 neverStopnaturalTermination。在文档里理解时,记住:选择 isLoopFinished 等于对框架说"你自己决定什么时候结束"。

中间件洋葱模型:reverse-reduce 的精妙之处

wrap-language-model.ts:L37-L41

typescript
return [...asArray(middlewareArg)]
  .reverse()
  .reduce((wrappedModel, middleware) => {
    return doWrap({ model: wrappedModel, middleware });
  }, model);

reverse()reduce()——这是洋葱模型:数组第一个元素包裹在最外层,最后一个元素紧贴 model。执行顺序是"外层进 → 中层进 → 内层进 → 内层出 → 中层出 → 外层出"。

中间件洋葱模型 — reverse().reduce() 执行过程

doWrap:为什么每个中间件同时持有 doGenerate 和 doStream

wrap-language-model.ts:L81-L94

typescript
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);
},

中间件同时持有 doGeneratedoStream 两个引用——这是 Decorator 而不是 Pipeline 的关键证据。Pipeline 的每个节点只看前一个节点的输出;Decorator 包着整个调用决策权,可以选择调用 generate 还是 stream,甚至完全跳过。一个"把 stream 转成 generate"的缓存中间件在这里完全可以实现,Pipeline 做不到。

六、设计决策解读

决策一:ToolLoopAgent 为什么是 Facade 而不是包含循环逻辑

如果 ToolLoopAgent 自己实现循环,会发生什么?

  • 不一致的 APIgenerateTextToolLoopAgent 会有两套独立的循环实现,功能差异会在维护中逐渐扩大
  • 重复的 stop condition 逻辑:每加一个新的工厂函数,两套实现都要同步

Facade 方案的代价:ToolLoopAgent.generate() 的参数类型需要和 generateText 对齐,这造成了 tool-loop-agent.ts:L260as 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 里写:

typescript
// 进入时:记录请求
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 类型约束而非运行时注册机制来保证扩展性。这个选择有三个连锁效益:

  1. 零运行时开销:类型检查在编译期完成,运行时不需要查询注册表
  2. 可摇树优化(Tree-shaking):未使用的 Provider 实现不会打包进产物
  3. IDE 补全驱动:类型签名本身就是文档,违反约束时编译器报错,不需要阅读"如何扩展"的教程

对比 LangChain 的运行时工具注册(tool() 装饰器 + 全局注册表)和 Mastra 的 createTool() 注册 API,vercel/ai 的"无注册中心"不是功能缺失,而是一个有意识的架构选择:把扩展契约前移到类型系统,而不是推迟到运行时

七、横向对比

循环终止设计对比

框架终止机制可访问上下文内置策略
vercel/ai v6StopCondition[],OR 语义全量 steps 历史isStepCounthasToolCallisLoopFinished
LangChain.jsmaxIterations: number无(框架内部计数)仅步数截断
MastramaxSteps: number仅步数截断,直接复用 vercel/ai Provider 层
pi-monoshouldStopAfterTurn 回调{ 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 v6StepResult.performance 内置 7 个指标toolExecutionMs 按 toolCallId 分桶、stepTimeMsTTFT、token/s
LangChain.js❌ 无内置需外部 tracing 工具(LangSmith 等)
Mastra❌ 无内置

vercel/ai 把性能可观测性作为一等公民设计进了数据结构——不需要接入外部 tracing 系统,每一步的耗时分布都随 StepResult 一起返回,可以直接用于生产告警和性能分析。

扩展机制对比:类型系统 vs 运行时注册

框架扩展机制扩展点验证时机
vercel/ai实现类型签名即扩展,无注册 API编译期(TypeScript 类型检查)
LangChain.jstool() 装饰器 + 全局工具注册表运行时
MastracreateTool() 注册 API运行时

这个差异是架构哲学层面的分歧,不是功能多寡的问题。vercel/ai 的"无注册中心"意味着:未使用的 Provider 可被摇树优化、扩展契约就是 TypeScript 类型签名、IDE 补全本身就是文档。代价是"如何扩展"不够显式——初次接触的开发者需要先理解类型系统是扩展点,才能找到入口。

Provider 接口厚度对比

LanguageModelV4 接口(@ai-sdk/provider 包)仅要求实现 2 个方法:

typescript
interface LanguageModelV4 {
  doGenerate(options): Promise<LanguageModelV4GenerateResult>;
  doStream(options): Promise<LanguageModelV4StreamResult>;
}

LangChain 的 BaseChatModel 要求实现 _generate_stream_llmType_modelType,还有 bindToolswithStructuredOutput 等高层方法——业务逻辑被压入了 Provider 层,实现者必须理解上层业务概念。

vercel/ai 的 Provider 只管"发请求拿响应",所有业务逻辑(tool call 循环、结构化输出、中间件)都在核心层处理。分层更干净。

中间件组合机制对比

框架中间件模型双向拦截调用路径切换
vercel/ai洋葱模型(reverse-reduce)✅(可选 generate 或 stream)
LangChaincallback 链(事件驱动)❌(只能监听,不能修改)
LlamaIndex.TS显式 Pipeline 节点图❌(节点只看上一个节点输出)

工程质量评审

优先级问题位置影响
P2Stream 错误处理 Provider 间不一致:openai 映射完整(429/401/403...),anthropic 只映射 overloaded→529,其余全 500openai-stream-error.ts vs anthropic-language-model.tsAnthropic 下 rate limit 错误得到 500,重试策略失效
P2isStepCount(0) 静默失效,永远不触发stop-condition.ts:L28传 0 时行为与预期完全相反,无任何报错
P3as unknown as 强制类型转换tool-loop-agent.ts:L260, L317类型检查在 Facade/核心边界处有盲区

亮点:95 个测试文件,generate-text/ 含 20 个专项测试,多轮 tool call 有集成测试覆盖;provider-utils 共享基础设施被一致使用;StepResult 全字段 readonly,不可变快照设计彻底。

参考

本文代码均基于 vercel/ai 主分支实测确认 1,API 参考见官方文档 2

参考文献

  1. vercel/ai — GitHub 仓库Vercel Team · 仓库
  2. Vercel AI SDK 官方文档Vercel Team · 文档