从 ChatBot 到:简易 Agent

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 的核心思想:模型不止跑一次,而是跑一个循环——想、做、看结果,然后决定是继续做还是给出最终回答。

image.png

经典的想做看: think → act → observe

  1. Think:模型分析当前情况,决定下一步做什么 →
  2. Act:如果需要,调用工具执行操作 →
  3. Observe:拿到工具返回的结果 →
  4. 回到 Think,直到模型认为可以给出最终回答

定义工具

先给 Agent 一个能力 → 一个假的天气查询工具。

工具的组成:

  1. description:告诉模型这个工具是干什么的(模型可以判断什么时候该调用它)

    1. 不是给人看的,是给模型看的,描述写得越准确,模型调用的时机就越精准
  2. inputSchema:工具接受什么参数(用 JSON Schema 定义)

    1. AI SDK 会把它跟 description 一起塞进请求发给模型。
    2. 如果参数格式不对,SDK 会在执行之前拦截。
    3. 模型看到的东西大概长这样:
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "查询指定城市的天气信息",
        "parameters": {
          "type": "object",
          "properties": {
            "city": { "type": "string", "description": "城市名称" }
          },
          "required": ["city"]
        }
      }
    }
  3. 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 只传了 modelmessages,现在加上 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();

image.png

手动 Agent Loop

why

我们需要看到循环里面都干了什么,生产需要控制每一个循环,添加

  1. 打日志 调试
  2. 检查 token 用量 控制成本
  3. 检测死循环
  4. 用户确认 安全
  5. 上下文压缩
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 错误、超时、权限被拒。

image.png

把手动循环接入对话

改造 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();

image.png

总结

从 ChatBot 到 Agent,代码层面的变化不大,行为上的变化是质的——AI 从"只会说"变成了"能做"。

  1. 定义工具(description + inputSchema + execute)
  2. streamText 加了 tools 参数
  3. 用 fullStream 替代 textStream,处理工具调用事件
  4. 加了一个 while 循环,让模型能够多步执行

这个 while 循环就是 Agent 的心脏。

现在的 Agent 能调工具了,但它很脆弱——如果模型反复调同一个工具(死循环),token 会被快速烧完;如果 API 超时或报错,整个程序直接崩溃。