1. 从「一次调用」到「多步 Agent」
上一篇写了一个典型 tool calling 的例子:LLM 说「我要调 searchMemory」,我们执行,把结果回给它,它再生成最终回复。
但如果这次「生成最终回复」时 LLM 又觉得信息不够、还想再调一个工具呢?这就是 Agent 循环:
01user: 帮我记录今天的情绪并推荐一首适合听的歌0203LLM step1: 先调 classifyEmotion04↓05tool-result: { emotion: 'sad', intensity: 0.7 }06↓07LLM step2: 好,现在调 searchMusic,按情绪找08↓09tool-result: [{ title: 'x', url: 'y' }, ...]10↓11LLM step3: 最后调 logEmotion 记录下来12↓13tool-result: { ok: true }14↓15LLM step4: 生成最终回复 「我记录了你今天的情绪,推荐这首...」
AI SDK 把这套循环内置了,你不用自己写 while 循环。只要给 streamText 传一个 stopWhen,它就会自动做这几件事:
- 调 LLM
- LLM 返回 tool-call → AI SDK 执行工具 → 把 tool-result 塞进消息
- 再调 LLM
- 重复,直到 LLM 不再调工具,或者触发
stopWhen
这一篇把这套机制讲透。
2. 最简 Agent 循环
看最简单的用法:
01import { streamText, stepCountIs } from 'ai'0203const result = streamText({04model: models.chat,05messages: [06{ role: 'user', content: '帮我记录今天的情绪并推荐一首适合听的歌' },07],08tools: {09classifyEmotion,10searchMusic,11logEmotion,12},13stopWhen: stepCountIs(5), // 最多 5 step14})1516for await (const part of result.fullStream) {17if (part.type === 'text-delta') process.stdout.write(part.delta)18if (part.type === 'tool-call') console.log('\n[调用]', part.toolName)19if (part.type === 'tool-result') console.log('\n[结果]', part.output)20if (part.type === 'finish-step') console.log('\n[step 完成]')21if (part.type === 'finish') console.log('\n[全部完成]', part.finishReason)22}
对应的 fullStream 会输出多轮 start-step / tool-call / tool-result / finish-step,最后才是 text-delta 和 finish。
3. stopWhen:停止条件
stopWhen 控制循环什么时候结束。它可以是单个条件,也可以是条件数组(任意一个满足就停)。
3.1 stepCountIs:按步数
最常用的一个:
1import { stepCountIs } from 'ai'23stopWhen: stepCountIs(5) // 最多 5 步4stopWhen: stepCountIs(3) // 最多 3 步(适合简单任务)5stopWhen: stepCountIs(10) // 最多 10 步(复杂任务)
给多少步合适,看任务复杂度:
- 简单任务(1-2 个工具顺序调用):3 步够
- 中等任务(需要探索、回退):5-7 步
- 复杂任务(多工具交叉、规划):10 步起
上限的核心意义是防死循环。LLM 有时候会陷入「调工具 → 结果不满意 → 再调 → 还不满意」的死循环,stepCountIs 是硬保险。
3.2 其他停止条件
某个工具被调用过就停,用 hasToolCall:
1import { hasToolCall } from 'ai'23// 只要调过 finalizeResponse 就停4stopWhen: hasToolCall('finalizeResponse')
适合的场景:工具箱里有一个「终结者」工具(比如 finalizeResponse 或 commitAction),调到它就意味着任务完成。
多个条件任一满足就停,用数组组合:
1stopWhen: [2stepCountIs(10),3hasToolCall('finalizeResponse'),4]
stopWhen 也可以传一个函数,接收 { steps },返回 boolean:
1stopWhen: ({ steps }) => {2// 如果有任何 step 用到了超过 3000 个 input token,停3const totalTokens = steps.reduce((sum, s) => sum + s.usage.inputTokens, 0)4return totalTokens > 30005}
这就给成本控制、业务规则留了很多空间。
4. 完整流:一次 Agent 循环发生了什么
用一个详细例子看清 Agent 循环内部到底发生了什么。
输入:
1用户: 帮我查一下上周五聊了什么,用那个话题写一段安慰的回复
工具箱:searchMemory、analyzeEmotion、finalizeResponse。
Step 1:LLM 分析,决定先检索记忆
1start-step2text-delta: "让我先回忆一下..."3tool-call: searchMemory({"query": "上周五 话题"})
AI SDK 执行 searchMemory:
1tool-result: [{content: "上周五你说工作压力大"}, ...]2finish-step
Step 2:LLM 拿到记忆,决定分析情绪
1start-step2tool-call: analyzeEmotion({"text": "上周五你说工作压力大"})
AI SDK 执行 analyzeEmotion:
1tool-result: {emotion: "stressed", intensity: 0.8}2finish-step
Step 3:LLM 综合信息,生成最终回复
1start-step2text-delta: "我记得你上周五说..."3text-delta: "到工作压力特别大..."4text-delta: "希望这周好一点..."5finish-step6finish (reason: stop)
整个过程通过一个 streamText 调用、一次 fullStream 消费就完成了。
这里一个重要的细节:消息历史是自动累积的。AI SDK 内部会把每个 step 的 tool-call / tool-result 拼进下一次 LLM 调用的 messages 里,不需要你手动组装。
5. 访问 steps:拿到每一步的详情
流结束之后,可以拿到所有 step 的结构化数据:
01const result = streamText({ model, messages, tools, stopWhen: stepCountIs(5) })0203// 等流跑完04for await (const _ of result.textStream) { /* 消费或丢弃 */ }0506// 拿到所有 step07const steps = await result.steps08for (const [i, step] of steps.entries()) {09console.log(`Step ${i + 1}:`)10console.log(' - text:', step.text)11console.log(' - toolCalls:', step.toolCalls)12console.log(' - toolResults:', step.toolResults)13console.log(' - usage:', step.usage)14console.log(' - finishReason:', step.finishReason)15}1617// 汇总信息18const totalUsage = await result.usage19const finalText = await result.text
每个 step 就是一次完整的「LLM 调用 + 工具执行」轮次。这个结构非常适合做三件事:
- 埋点——知道每一步多少 token、用了什么工具
- 回放——存下来后可以重现一次 Agent 运行
- Debug——看清楚 LLM 每一步的决策
6. prepareStep:step 之间的钩子
prepareStep 是一个非常强大的钩子。它在每次 step 开始前被调用,可以动态修改下一个 step 的行为:
01streamText({02model: models.chat,03messages,04tools: companionTools,05stopWhen: stepCountIs(5),0607prepareStep: async ({ stepNumber, steps, messages }) => {08// 第一步:强制用结构化工具分类情绪09if (stepNumber === 0) {10return {11toolChoice: { type: 'tool', toolName: 'classifyEmotion' },12}13}1415// 最后一步:禁用所有工具,强制生成文本16if (stepNumber === 4) {17return { toolChoice: 'none' }18}1920// 其他 step 保持默认21return undefined22},23})
还可以在 prepareStep 里动态修改 messages、system、tools:
01prepareStep: async ({ stepNumber, messages }) => {02// 在第 3 步的 prompt 里注入新信息03if (stepNumber === 2) {04return {05messages: [06...messages,07{ role: 'system', content: '请基于前两步的结果生成最终回复。' },08],09}10}11return undefined12}
这让你能在 AI SDK 的 Agent 循环里插入自定义逻辑,不用自己重写循环。
7. 和 LangGraph ReAct Agent 的对比
我们在 LangChain 讲过 createReactAgent。两者对比一下:
| 维度 | LangChain createReactAgent | AI SDK streamText + stopWhen |
|---|---|---|
| 循环控制 | Agent 内部 | stopWhen 参数 |
| step 粒度观察 | .stream({streamMode: 'values'}) | fullStream 的事件 |
| 中间逻辑钩子 | checkpointer 机制 + 自定义节点 | prepareStep / onStepFinish |
| 多 Agent 协作 | LangGraph supervisor / swarm | 不原生支持,需自己组合 |
| HITL | LangGraph interrupt | tool 不传 execute + 自行续传 |
| 时间旅行 / 回滚 | LangGraph checkpointer | 无 |
| 状态持久化 | Checkpointer(PostgresSaver 等) | 业务层自己做 |
| 学习成本 | 较高(要懂 Graph 概念) | 较低(就是一个参数) |
什么时候 AI SDK 的 Agent 循环就够用:
- 线性任务(几个工具顺序或并行调用)
- 不需要分支、回滚、HITL
- 不需要跨会话记忆 step 状态
什么时候需要上 LangGraph:
- 多节点图(情绪节点 → 记忆节点 → Prompt 节点 → LLM 节点)
- 条件路由
- HITL / 时间旅行
- 多 Agent 协作
对 AI 伴侣项目来说,主管线用 LangGraph(情绪状态机、混合记忆、HITL 审批),末端 LLM 节点内部则用 streamText + stopWhen 做 tool 调用。与 LangChain 协同 会展开。
8. 常见问题与小结
为什么 LLM 一直调工具不停
stopWhen 给的步数太少时容易出现这个现象。排查方向:工具 description 写得不清楚,导致 LLM 不知道什么时候算完成;schema 太松,LLM 反复尝试不同参数;缺少一个「终结者」工具(如 finalizeResponse);或者模型推理能力不够。
为什么 LLM 一次也不调工具
常见原因有几个:description 太模糊;prompt 里没引导 LLM 思考工具;模型本身 tool calling 能力弱(部分小型模型会这样)。
工具并行调用失控
如果 LLM 一次 step 里同时调 5 个工具,但你期望顺序执行,可以在 system prompt 里明确要求「一次只调一个工具」,或者用 prepareStep 控制每一步开放的工具集。
如何强制 Agent 以特定工具收尾
用 hasToolCall 配合 prepareStep:
1// stopWhen 用 hasToolCall2stopWhen: hasToolCall('finalizeResponse'),34// prepareStep 在接近上限时强制调用5prepareStep: ({ stepNumber, steps }) => {6if (stepNumber >= 4) {7return { toolChoice: { type: 'tool', toolName: 'finalizeResponse' } }8}9}
小结
stopWhen把 Agent 循环变成一个参数:stepCountIs(N)/hasToolCall(name)/ 自定义函数 / 数组组合prepareStep是 step 之间的强力钩子,能动态改toolChoice/messages/systemresult.steps让你拿到每一步的结构化数据,便于埋点、回放、调试- 适用场景是线性多步任务,不需要图结构、HITL、时间旅行
- 和 LangGraph 的分工:复杂编排交给 LangGraph,LLM 节点内用 AI SDK Agent 循环
本章核心能力部分到这里就结束了。下一篇开始进入前端集成——useChat Hook 替换 Streaming 手写的 useStreamChat。