实现一个:ChatBot

agent实战

让 AI 在终端开口说话

让 AI 在终端开口说话 ChatBot

你打开 Claude Code,输入一句"帮我重构一下这个函数",几秒钟后它开始读文件、改代码、跑测试。整个过程你什么都没做,就看着它一步步把事情办了。

这背后到底是什么在驱动?

拆到最底层,Claude Code 就是一个 Node.js 进程。调一次大模型 API,拿到回复,解析出"要调哪个工具",执行完之后把结果再喂回给模型,模型再想下一步做什么——如此循环。

搭脚手架

创建项目目录,初始化:

mkdir sylvan-agent && cd sylvan-agent
pnpm init

安装依赖:

pnpm add ai @ai-sdk/openai @ai-sdk/provider dotenv
pnpm add -D typescript tsx @types/node
作用
aiVercel AI SDK 顶层 API,提供 generateTextstreamTextuseChat 等核心方法
@ai-sdk/openaiOpenAI 模型的具体实现,支持 GPT-4、GPT-3.5 等
@ai-sdk/provider接口抽象层,定义统一规范(通常间接依赖,无需显式安装)
dotenv从 .env 文件加载环境变量到 process.env
typescriptTypeScript 编译器,类型检查和代码生成
tsxTypeScript 执行器,直接运行 .ts 文件(类似 ts-node 但更快)
@types/nodeNode.js API 的 TypeScript 类型定义

修改 package.json,加上 type: "module" 和启动脚本:

{
  "name": "sylvan-agent",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "start": "tsx src/index.ts"
  }
}
type: "module"现代项目统一用 ESM,避免模块系统兼容问题。
tsx watch监听文件变动,修改代码后自动重启

再加一个 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ES2022",
    "moduleResolution": "bundler",
    "strict": true,
    "esModuleInterop": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
 
// ES2022 是当前主流的编译目标,AI SDK 和大部分现代库都基于这个版本。

实现模拟模型

在接入真实 API 之前,可以先实现一个模拟模型

两个原因:

  1. 暂时没有 API Key,但不影响先跑通整个流程。
  2. AI SDK 提供了 MockLanguageModelV3 测试工具,它实现了和真实模型完全一样的接口。用它写的代码,切换到真实模型时不需要改任何业务逻辑

创建 src/mock-model.ts

import type { LanguageModelV3 } from '@ai-sdk/provider';
 
const RESPONSES: Record<string, string> = {
  default: '你好!我是模拟模型。填了 DASHSCOPE_API_KEY 后会自动切换到真实的 Qwen。',
  greeting: '你好!虽然是模拟的,但流式输出的效果和真实 API 一致 :)',
  name: '你刚才告诉我了呀!我能"记住"是因为代码把对话历史传给了我。',
  intro: '我是通义千问(模拟版),在本地模拟回复,机制和真实 API 完全一致。',
};
 
function pickResponse(prompt: any[]): string {
  const userMsgs = (prompt || []).filter((m: any) => m.role === 'user');
  const last = userMsgs[userMsgs.length - 1];
  const text = (last?.content || []).map((c: any) => c.text || '').join('').toLowerCase();
  if (text.includes('介绍你自己') || text.includes('你是谁')) return RESPONSES.intro;
  if (text.includes('你好') || text.includes('hello')) return RESPONSES.greeting;
  if (text.includes('叫什么') || text.includes('记住')) return RESPONSES.name;
  return RESPONSES.default;
}
 
const USAGE = {
  inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined },
  outputTokens: { total: 20, text: 20, reasoning: undefined },
};
 
function createDelayedStream(chunks: any[], delayMs = 30): ReadableStream {
  return new ReadableStream({
    start(controller) {
      let i = 0;
      function next() {
        if (i < chunks.length) {
          controller.enqueue(chunks[i++]);
          setTimeout(next, delayMs);
        } else {
          controller.close();
        }
      }
      next();
    },
  });
}
 
export function createMockModel(): LanguageModelV3 {
  return {
    specificationVersion: 'v3',
    provider: 'mock',
    modelId: 'mock-model',
    get supportedUrls() { return Promise.resolve({}); },
 
    async doGenerate({ prompt }: any) {
      return {
        content: [{ type: 'text', text: pickResponse(prompt) }],
        finishReason: { unified: 'stop', raw: undefined },
        usage: USAGE,
        warnings: [],
      };
    },
 
    async doStream({ prompt }: any) {
      const text = pickResponse(prompt);
      const id = 'text-1';
      const chunks = [
        { type: 'text-start', id },
        ...text.split('').map((char: string) => ({ type: 'text-delta', id, delta: char })),
        { type: 'text-end', id },
        { type: 'finish', finishReason: { unified: 'stop', raw: undefined }, usage: USAGE },
      ];
      return { stream: createDelayedStream(chunks, 30) };
    },
  };
}
 
// 核心: 让 createMockModel 返回的对象"长得像"真实模型。
// 实现 LanguageModelV3 接口 ———— 和 Qwen、Claude、GPT 的 Provider 适配器是同一个接口。你传给 generateText 或 streamText,SDK 根本分不清它是假的。
// createDelayedStream 用的是 Web 标准的 ReadableStream,每 30ms 往外推一个字符,模拟真实 API 的 SSE 事件流。

第一次调用模型

创建 src/index.ts

import 'dotenv/config';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createMockModel } from './mock-model';
 
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();
 
async function main() {
  const { text } = await generateText({
    model,
    prompt: '用一句话介绍你自己',
  });
 
  console.log(text);
}
 
main();

运行:

pnpm start

终端输出类似:

我是通义千问(模拟版),在本地模拟回复,机制和真实 API 完全一致。

核心

model 变量的类型是 AI SDK 的统一接口

不管背后是 mock 还是 Qwen,generateText 都不需要知道具体实现。

这就是 Provider 模式的价值:调用方和实现方解耦,切换模型不改业务代码。

不足

generateText 是同步返回的——等模型把完整回复生成完毕后一次性返回。

这对后台任务没问题,但聊天场景下体验很差:如果回复有 500 字,用户需要等待数秒,然后突然出现一大段文字。

流式:从"等半天"到"边想边说"

把 generateText 换成 streamText

import 'dotenv/config';
import { streamText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createMockModel } from './mock-model';
 
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();
 
async function main() {
  const result = streamText({
    model,
    prompt: '用一句话介绍你自己',
  });
 
  for await (const chunk of result.textStream) {
    process.stdout.write(chunk);
  }
 
  console.log(); // 换行
}
 
main();

再运行一下会看到文字逐个出现,就像 ChatGPT 的打字效果。

机制

调用 streamText 时,SDK 发出一个带 stream: true 的请求。

模型不再等全部生成完,而是每生成几个 token 就通过 SSE(Server-Sent Events)推送一个事件。

SDK 将这些 SSE 事件解析成异步迭代器 textStream——每次 for await 就拿到一小段新文字。

process.stdout.write 和 console.log 的区别在于不会自动加换行,所以字符连续输出,呈现出"打字"的效果。

变成对话

现在代码的实现只能发一句话就退出。需要两样东西让它变成持续对话:

  1. readline:读取用户输入
  2. 消息历史:把之前的对话传给模型

关键认知:大模型本身没有记忆。每次 API 调用,对模型来说都是一次全新的对话。

它之所以能"记住"之前说了什么,是因为你每次都把完整的对话历史传给了它。

不是模型在"记忆",而是你在"提醒"。

import 'dotenv/config';
import { streamText, type ModelMessage } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { createMockModel } from './mock-model';
import { createInterface } from 'node:readline';
 
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 messages: ModelMessage[] = [];
 
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: '你是 Sylvan Agent, 一个专注于软件开发的 AI 助手。你说话简洁直接,喜欢用代码示例来解释问题。如果用户的问题不够清晰,你会反问而不是瞎猜。',
            messages,
        });
 
        process.stdout.write('Assistant: ');
        let fullResponse = '';
 
        for await (const chunk of result.textStream) {
            process.stdout.write(chunk);
            fullResponse += chunk;
        }
 
        console.log(); // 换行
 
        messages.push({ role: 'assistant', content: fullResponse });
 
        ask();
    });
}
 
console.log('Welcome to the chat with Sylvan Agent v0.1 (type "exit" to quit).\n');
ask();
 
// async function main() {
//     const result = streamText({
//         model,
//         prompt: '用一句话介绍你自己',
//     });
 
//     for await (const chunk of result.textStream) {
//         process.stdout.write(chunk);
//     }
 
//     console.log();
// }
 
// main();

关键点

ModelMessage 是 AI SDK 定义的消息类型,每条包含 roleuser 或 assistant)和 content

messages 数组就是对话历史——用户说一句 push 一条,AI 回复一句 push 一条。

streamText 的参数变化:之前用 prompt(单条消息),现在换成了 messages(消息数组)。

这就是"有记忆"和"没记忆"的区别——传 prompt 模型只看到当前这一句,传 messages 模型看到完整对话上下文。

ask() 在末尾递归调用自己,形成循环:等待输入 → 发给模型 → 流式输出 → 等待输入……这就是最原始的"Agent Loop"雏形,只是还没有工具调用能力。

Mock 效果

image.png

真实 API 效果

image.png

原因很直接:messages 数组里已经包含前两轮的对话,模型看到了完整上下文。

引发的问题

对话越来越长,每次 API 调用传输的 token 越来越多。 

token 数量直接影响成本(按量计费)和延迟(处理长上下文耗时更长)。

每个模型还有 context window 上限,聊久了就会超出限制。

三个核心要素

模型调用streamText + model。将消息发送给模型,获取流式响应。无论 model 背后是 mock 还是真实 API,调用方式完全一致——Provider 模式的价值。

消息管理messages: ModelMessage[]。每轮对话向数组 push 一条 user 和一条 assistant 消息,下一轮将整个数组传给模型。这是最基础的上下文管理——全量传递,不做压缩。

交互循环ask() 递归调用自身,形成 readline → streamText → push → readline 的循环。

后续演化

  1. 模型调用 → StreamConsumer——解析工具调用、推理过程、token 用量等多种事件。
  2. 消息管理 → 四层上下文管理——截断、时间衰减修剪、LLM 摘要压缩、Cache 优化。
  3. 交互循环 → Agent Loop——while(true) { think → act → observe },模型自己决定调哪个工具、什么时候停。