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_KEY=sk-你的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: Record<string, 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 执行完工具、把结果整理成消息,但你的代码没有再调用一次模型,断点就在这里。
修复只需要加一行stopWhen:
import { 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_calls,if (!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 的自主性不是模型自带的魔法,是你代码里那台循环造出来的。