1. 为什么你的 Agent 跑一半就停了很多人第一次用 AI SDK 的generateText接工具都会遇到一个很迷惑的现象工具明明被调用了日志里也打印出了结果但模型最后返回的文本却是「我来查询这两个城市的气温信息」这种半截话而不是真正的答案。这不是模型笨也不是工具写错了而是你只给了它一轮机会。模型的一次调用只返回一轮响应。它可以在这一轮里发起工具调用请求但它不会在服务端停下来等你把工具跑完。工具执行发生在你的代码里执行完的结果需要你手动写回messages然后再调用一次模型模型才能看到这些结果并继续推理。这个「调用模型 → 执行工具 → 回灌结果 → 再调用模型」的重复过程就是 Agentic Loop也就是 Agent 的核心循环。这篇聚焦 Agentic Loop 与 ReAct 思路在 AI SDK 的generateText上搭一个能跑通的最小 Agent 循环。你会看到循环骨架长什么样、工具怎么定义、消息怎么拼接、停止条件怎么判断以及怎么跑通一次多步推理调用。适合已经会用generateText做单轮对话、但还没让它自己转起来的开发者。读完你能自己写出一个可运行的多步 Agent并且知道每一步为什么这么写。2. 前置准备TaoToken 接入与依赖安装在写循环之前先把模型接入这步搞定。我用的是 TaoToken 提供的兼容接口它支持 OpenAI 风格的调用方式AI SDK 可以直接对接。你需要先去控制台创建一个 API Key然后把它放进环境变量。创建 Key 的入口在控制台的 API Keys 页面登录后新建一个就行。拿到 Key 之后在项目根目录建一个.env文件写入下面这行TAOTOKEN_API_KEYsk-你的key注意不要把 Key 硬编码进源码也不要把.env提交到 git。接下来安装依赖AI SDK 的核心包加上 Zod 用来做参数校验npm install ai ai-sdk/openai zod npm install -D tsx typescript types/node这里用ai-sdk/openai这个 provider通过配置baseURL指向 TaoToken 的接口地址就能复用 OpenAI 兼容协议。模型我选deepseek-chat便宜、响应快跑 demo 足够。如果你更习惯用别的模型换成对应的 model id 即可循环逻辑完全不变。配置 provider 的代码长这样import { createOpenAI } from ai-sdk/openai const taotoken createOpenAI({ apiKey: process.env.TAOTOKEN_API_KEY, baseURL: https://taotoken.net/api, }) const model taotoken(deepseek-chat)baseURL后面不要加多余的路径SDK 会自己拼/chat/completions。这一步配好之后后面所有generateText调用都用这个model变量。3. 可复制配置定义工具与循环骨架先定义一个天气工具用来演示多步推理。工具不接真实接口直接从一张假数据表取值这样你能专注看循环怎么转而不是被网络请求干扰。import { tool } from ai import { z } from zod const weatherData: Recordstring, string { 上海: 上海: 20°C, 深圳: 深圳: 28°C, } const weather tool({ description: 查询指定城市当前气温, inputSchema: z.object({ city: z.string().describe(城市名), }), execute: async ({ city }) { const value weatherData[city] ?? ${city}: 未知 console.log([天气工具结果] ${value}) return value }, })description告诉模型这个工具能干什么inputSchema约束参数格式execute是真正执行的业务逻辑。模型只负责决定「要不要调、调哪个、传什么参数」执行永远在你的代码里。现在写第一版调用故意不加停止条件看看会发生什么import { generateText } from ai const result await generateText({ model, prompt: 上海和深圳谁更热, tools: { weather }, }) console.log([模型输出] ${result.text})跑起来你会看到终端打印两行工具结果然后模型输出一句「我来查询这两个城市的气温信息」。两个城市的气温都查到了但模型没回答谁更热。原因就是前面说的第一次模型调用返回工具请求后就结束了SDK 执行完工具、把结果整理成消息但你的代码没有再调用一次模型断点就在这里。修复只需要加一行stopWhenimport { generateText, stepCountIs } from ai const result await generateText({ model, prompt: 上海和深圳谁更热, tools: { weather }, stopWhen: stepCountIs(3), }) console.log([模型输出] ${result.text})stepCountIs(3)允许这次任务最多走三步。两次天气查询发生在第一步里第一步结束后还没到上限generateText内部的循环就拿着工具结果发起第二次模型调用模型这时才看到两份气温回答「深圳更热」。一行配置跑通了任务但循环本身还藏在 SDK 里面。想看清它得自己手写一台。4. 手写主循环看清 Agent 为什么自己往下走手写循环之前先理解一件事模型不会记住上一次请求。每一轮你都要把完整的消息记录重新发过去。messages就是一份不断加长的对话记录每条消息的role标注这句话是谁说的user是用户assistant是模型tool是工具结果。下面这段是循环骨架十几行每一行都对应一个明确动作const messages: any[] [ { role: user, content: 上海和深圳谁更热 }, ] let round 0 while (true) { if (round 10) break const msg (await callLLM(messages)).message messages.push(msg) if (!msg.tool_calls) break for (const tc of msg.tool_calls) { const { city } JSON.parse(tc.function.arguments) const content weatherData[city] ?? ${city}: 未知 messages.push({ role: tool, tool_call_id: tc.id, content, }) } }callLLM(messages)是你自己封装的函数把完整消息和工具定义发给模型拿回一轮原始响应。第一轮响应里有两条天气工具调用代码保存这条模型回复执行两次查询把结果写进messages。程序走到while末尾又回到开头第二次模型调用就这样启动了。模型在第二次调用里读到气温回答「深圳更热」响应里没有tool_callsif (!msg.tool_calls) break退出循环。tool_call_id要原样使用模型给出的tc.id这样接口才能把每个结果配回对应的那次查询。两个break分工不同if (!msg.tool_calls) break是自然停止模型自己说完了if (round 10) break是强制停止防止模型一直要求调工具转个不停。强制停出来的时候messages最后一条还是没消化完的工具结果用户拿到的是半成品。把三个阶段标到循环里就是 ReAct 的节奏while (true) { const msg (await callLLM(messages)).message // Reason模型判断下一步 messages.push(msg) if (!msg.tool_calls) break for (const tc of msg.tool_calls) { // Act模型发起的工具调用 const { city } JSON.parse(tc.function.arguments) const result weatherData[city] ?? ${city}: 未知 // Observe工具带回新信息 messages.push({ role: tool, tool_call_id: tc.id, content: result }) } }Reason 判断下一步、Act 返回tool_calls、代码执行工具得到 Observe回灌进messages后再转一圈三阶段闭环。这套「想一步、做一步、看一步」的节奏就叫 ReAct它是一种思路不是某个库。你手写的这台while就是它。5. 验证请求跑通一次多步推理调用把上面的骨架补全成可运行文件验证一次完整的多步推理。先写callLLM用 fetch 直接调接口方便你看清原始返回async function callLLM(messages: any[]) { const res await fetch(https://taotoken.net/api/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.TAOTOKEN_API_KEY}, }, body: JSON.stringify({ model: deepseek-chat, messages, tools: [ { type: function, function: { name: weather, description: 查询指定城市当前气温, parameters: { type: object, properties: { city: { type: string, description: 城市名 } }, required: [city], }, }, }, ], }), }) return res.json() }然后跑主循环在每轮打印关键信息let round 0 while (true) { if (round 10) break const msg (await callLLM(messages)).message console.log([第 ${round} 轮] finish_reason${msg.finish_reason ?? stop}) messages.push(msg) if (!msg.tool_calls) break for (const tc of msg.tool_calls) { const { city } JSON.parse(tc.function.arguments) const content weatherData[city] ?? ${city}: 未知 console.log([工具执行] ${content}) messages.push({ role: tool, tool_call_id: tc.id, content }) } } console.log([最终答案] ${messages[messages.length - 1].content})用tsx loop.ts跑起来终端应该打出这样的过程第 1 轮finish_reason为tool_calls返回两个工具调用回灌两条tool结果第 2 轮finish_reason为stop返回「深圳更热」自然停止。最终答案就是「深圳更热」。如果你看到的是这个结果说明循环跑通了多步推理成立。6. 本篇常见错排查工具结果回灌了但模型还是没回答。最常见的原因是role写错了。工具结果必须是role: tool并且带上tool_call_id不能写成role: user或role: assistant。接口靠tool_call_id把结果配回对应的调用缺了它模型就看不到结果。循环转不停一直要求调工具。检查你的强制停止条件有没有生效。if (round 10) break里的round要在每次循环开头自增别写在for里面。另外确认break的位置在callLLM之后、执行工具之前否则会多跑一轮。generateText加了stopWhen还是只跑一步。确认stopWhen的值是stepCountIs(n)而不是数字。stepCountIs需要从ai包导入写成stopWhen: 3是无效的。另外tools里的工具必须带execute没有execute的工具 SDK 不会自动执行循环也就不会继续。报错tool_call_id不匹配。检查你是不是自己生成了 id。tool_call_id必须原样使用模型返回的tc.id不要自己拼一个。模型返回的 id 是接口用来配对结果的唯一凭据。模型返回的arguments解析失败。有些模型返回的arguments是空字符串或非法 JSON。加一层保护JSON.parse(tc.function.arguments || {})避免整个循环因为一次解析异常崩掉。7. 从手写到 SDK循环的开关和上限在你手里手写是为了看懂看懂之后就可以交给 SDK。generateText接的是一次任务不是一次调用。没有工具的时候一次任务刚好等于一次生成带上execute之后干完一个任务可能需要好几轮往返那台while就装进了它内部。你手写的while骨架对应一次generateText调用你手写的callLLM()对应 SDK 内部每个 step 对模型发的那一次请求。对照关系很清楚parse校验、遍历调度、造消息回灌、循环判停这一整圈 SDK 全接管了你只交出execute这个业务函数。stopWhen默认只跑 1 步是因为每转一轮都在花 token花多少得由开发者自己拿主意。写下stopWhen就等于同意它自主转、上限自己定。如果你要长期跑编码类或 Agent 类任务建议把模型接入和额度管理放到 Coding Plan 里统一处理避免每次调试都手动换 Key。接入文档里有完整的参数说明和示例遇到接口层面的问题可以先查文档。想先验证模型对话效果可以直接在模型对话页面试几轮确认模型和参数没问题再写进代码。循环可以不亲手写但它的开关和上限一直在你的代码里。stopWhen你不写它默认只跑一步就停你写了多少步它就最多转多少步。Agent 的自主性不是模型自带的魔法是你代码里那台循环造出来的。