从 ChatBot 到:简易 Agent
从会聊天到能干点小活
从会聊天到能干点小活 - 给 bot 上一个 while 循环
前面 ChatBot 已经实现了一个能在终端里面聊天的 AI。
你说一句,他回一句,也能记住你之前说的话。
现在需要让他干点事情,比如:
You: 今天北京天气怎么样?
Assistant: 北京今天天气晴朗,气温大约在 15-25 度之间……
# 全是编的,它没有查任何天气接口,只是根据“北京”,“天气”等关键词编一个看着合理的回复。You: 帮我读一下 package.json 的内容
Assistant: 你可以使用 cat package.json 命令来查看文件内容……
# 教你怎么做,不是帮你做本质区别
ChatBot 和 Agent 的本质区别——ChatBot 只说,Agent 能做。
一个循环产生的差距
ChatBot 的工作方式:
用户输入 → 发给模型 → 拿到文本回复 → 显示 → 等下一轮输入模型只有一次机会——收到消息,生成回复,结束。它没有机会"中途去做点什么再回来继续"。
Agent 的工作方式
用户输入 → 发给模型 → 模型说"我要调 get_weather"
→ 执行 get_weather → 把结果告诉模型
→ 模型继续生成最终回复 → 显示模型在生成回复的过程中,可以决定自己需要先调一个工具,工具执行完再把结果喂给模型,模型根据实际数据生成最终回复。
这就是 Agent Loop 的核心思想:模型不止跑一次,而是跑一个循环——想、做、看结果,然后决定是继续做还是给出最终回答。

经典的想做看: think → act → observe。
- Think:模型分析当前情况,决定下一步做什么 →
- Act:如果需要,调用工具执行操作 →
- Observe:拿到工具返回的结果 →
- 回到 Think,直到模型认为可以给出最终回答
定义工具
先给 Agent 一个能力 → 一个假的天气查询工具。
工具的组成:
-
description:告诉模型这个工具是干什么的(模型可以判断什么时候该调用它)
- 不是给人看的,是给模型看的,描述写得越准确,模型调用的时机就越精准。
-
inputSchema:工具接受什么参数(用 JSON Schema 定义)
- AI SDK 会把它跟 description 一起塞进请求发给模型。
- 如果参数格式不对,SDK 会在执行之前拦截。
- 模型看到的东西大概长这样:
{ "type": "function", "function": { "name": "get_weather", "description": "查询指定城市的天气信息", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名称" } }, "required": ["city"] } } } -
execute:实际执行函数
工具的 description 和 inputSchema 里的属性 description,本质上就是在写 prompt。 写得越清楚、越具体,模型调用的准确率就越高。"查天气"不如"查询指定城市的实时天气信息,包括温度、风向等"。
import { jsonSchema } from 'ai';
export const weatherTool = {
description: '查询指定城市的天气信息,包括紫外线强度,风向,湿度,体感温度等等',
inputSchema: jsonSchema({
type: 'object',
properties: {
city: {
type: 'string',
description: '要查询的城市名称,如"北京"、"上海"',
},
},
required: ['city'],
additionalProperties: false,
example: {
city: '北京',
},
}),
execute: async ({ city }: { city: string }) => {
// 先 mock 假数据
const mockWeather: Record<string, string> = {
'北京': '晴,20℃,紫外线强度:中等,风向:东南风,湿度:40%,体感温度:22℃',
'上海': '多云,22℃,紫外线强度:低,风向:东北风,湿度:50%,体感温度:23℃',
};
return mockWeather[city] || `没有找到 ${city} 的天气信息`;
},
}steamText 接入工具
拼装 tools 对象
ChatBot 部分 steamText 只传了 model 和 messages,现在加上 tools 。
tools 是一个对象,key 是工具名(模型在调用时会引用这个名字),value 是工具定义对象。
import { agentLoop } from './agent-loop';
const tools = {get_weather: weatherTool}从 textStream 到 fullStream
streamText 返回的 textStream 只包含文本 chunk。
_模型除了文本,还可能返回工具调用,_如果模型决定调用工具而不是直接回复文本,textStream 会把这些全部丢掉,只留下文本。
fullStream 可以包含完整的事件流,每个事件都有 type 字段告诉你发生了什么:
text-delta:文本片段(跟textStream一样)tool-call:模型决定调用某个工具,包含工具名和参数tool-result:工具执行完毕,包含返回值step-start/step-finish:每一步的开始和结束finish:所有步骤都完成了
所以需要用 fullStream 来替代 textStream__。
可以在 for await 里通过 switch(part.type) 来分别处理每种事件。
两种循环方式:SDK / 手动循环
SDK方式
Vercel AI SDK 提供了一个很方便的能力——自动多步执行。
当模型返回工具调用时,SDK 会自动执行工具、把结果喂回模型、让模型继续生成,直到模型不再调用工具为止。
控制这个行为的参数叫 stopWhen:很好用,但:它把循环藏起来了。
function ask() {
readline.question('\nYou: ', async (input) => {
const trimmedInput = input.trim();
if (!trimmedInput || trimmedInput.toLowerCase() === 'exit') {
console.log('Bye!');
readline.close();
return;
}
messages.push({ role: 'user', content: trimmedInput });
const result = streamText({
model,
system,
messages,
tools,
stopWhen: stepCountIs(5) // +++++++++++ stopWhen
});
process.stdout.write('Assistant: ');
let fullResponse = '';
for await (const part of result.fullStream) { // +++++++++++
textStream →
fullStream
switch (part.type) {
case 'text-delta':
process.stdout.write(part.text);
fullResponse += part.text;
break;
case 'tool-call':
console.log(` [调用工具:${part.toolName}、${JSON.stringify(part.input)}]`)
break;
case 'tool-result':
console.log(` [工具返回: ${JSON.stringify(part.output)}]`);
break;
}
}
console.log(); // 换行
messages.push({ role: 'assistant', content: fullResponse });
ask();
});
}
console.log('Welcome to the chat with Sylvan Agent v0.2 (type "exit" to quit).\n');
ask();
手动 Agent Loop
why
我们需要看到循环里面都干了什么,生产需要控制每一个循环,添加
- 打日志 调试
- 检查 token 用量 控制成本
- 检测死循环
- 用户确认 安全
- 上下文压缩
import { streamText, type ModelMessage } from 'ai';
const MAX_STEPS = 10;
export async function agentLoop(
model: any,
tools: any,
messages: ModelMessage[],
system: string,
) {
let step = 0;
while (step < MAX_STEPS) {
step++;
console.log(`\n--- Step ${step} ---`);
const result = streamText({
model,
system,
tools,
messages,
// 不设 stopWhen,每次只跑一步
});
let hasToolCall = false;
let fullText = '';
for await (const part of result.fullStream) {
// 遍历 fullStream,收集文本和工具调用
switch (part.type) {
case 'text-delta':
process.stdout.write(part.text);
fullText += part.text;
break;
case 'tool-call':
hasToolCall = true;
console.log(` [调用: ${part.toolName}(${JSON.stringify(part.input)})]`);
break;
case 'tool-result':
console.log(` [结果: ${JSON.stringify(part.output)}]`);
break;
}
}
// 拿到这一步的完整结果,追加到消息历史
const stepMessages = await result.response;
// result.response 是一个 Promise,resolve 之后包含这一步的完整信息。
// 其中 messages 是一个数组,包含模型在这一步里产生的所有消息——文本回复和/或工具调用+工具结果。
// 把它们追加到 messages 数组后,下一次循环调用 streamText 时,模型就能看到"我刚才调了什么工具、结果是什么",然后决定下一步做什么。
// 这就是 Agent 的"记忆"在循环内的运作方式,把每一步的对话记录完整地传回给模型
messages.push(...stepMessages.messages);
// 退出条件:模型没有调用任何工具,说明它认为可以直接回复了
// 退出条件是整个循环最关键的设计决策。
// 当前用的是最简单的策略:模型不再调用工具 → 停止。
// 这在大多数场景下够用了——模型调完该调的工具、拿到了需要的信息,自然会切换到生成文本回复。
if (!hasToolCall) {
if (fullText) console.log();
break;
}
// 还有工具调用 → 继续循环,让模型看到工具结果后继续思考
console.log(' → 模型还在工作,继续下一步...');
}
if (step >= MAX_STEPS) {
console.log('\n[达到最大步数限制,强制停止]');
}
}生产环境里,退出条件会复杂得多:
- 步数上限:防止模型陷入无限循环(这里的
MAX_STEPS) - Token 预算:累计输出超过阈值就强制停止
- 重复检测:连续调用同一个工具、传同样的参数——明显是在兜圈子
- 用户中断:AbortSignal 随时可以打断
像 Claude Code 这样的生产级 Agent,退出路径有 7 种之多:用户中断、token 预算耗尽、步数上限、模型主动结束、API 错误、超时、权限被拒。

把手动循环接入对话
改造 ask() 函数,用 agentLoop 替代原来的 streamText:
import 'dotenv/config';
import { type ModelMessage } from 'ai';
import { weatherTool } from './tool';
import { createOpenAI } from '@ai-sdk/openai';
import { createMockModel } from './mock-model';
import { createInterface } from 'node:readline';
import { agentLoop } from './agent-loop';
const tools = {get_weather: weatherTool}
const qwen = createOpenAI({
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
apiKey: process.env.DASHSCOPE_API_KEY,
});
const model = process.env.DASHSCOPE_API_KEY
? qwen.chat('qwen-plus-latest')
: createMockModel();
const readline = createInterface({
input: process.stdin,
output: process.stdout,
});
const system = `你是 Sylvan Agent, 有工具调用能力的 AI 助手。
需要查询信息时,主动使用工具,不要编造数据。
如果用户的问题不够清晰,你会反问而不是瞎猜。
回答要简洁直接,避免华丽词藻覆盖`;
const messages: ModelMessage[] = [];
// ---------------------------------------------------------------------- v0.3
function ask() {
readline.question('\nYou: ', async (input) => {
const trimmedInput = input.trim();
if (!trimmedInput || trimmedInput.toLowerCase() === 'exit') {
console.log('Bye!');
readline.close();
return;
}
messages.push({ role: 'user', content: trimmedInput });
await agentLoop(model, tools, messages, system);
ask();
});
}
console.log('Welcome to the chat with Sylvan Agent v0.3 (type "exit" to quit).\n');
ask();
总结
从 ChatBot 到 Agent,代码层面的变化不大,行为上的变化是质的——AI 从"只会说"变成了"能做"。
- 定义工具(description + inputSchema + execute)
streamText加了tools参数- 用
fullStream替代textStream,处理工具调用事件 - 加了一个
while循环,让模型能够多步执行
这个 while 循环就是 Agent 的心脏。
现在的 Agent 能调工具了,但它很脆弱——如果模型反复调同一个工具(死循环),token 会被快速烧完;如果 API 超时或报错,整个程序直接崩溃。