实现一个:ChatBot
让 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| 包 | 作用 |
|---|---|
ai | Vercel AI SDK 顶层 API,提供 generateText、streamText、useChat 等核心方法 |
@ai-sdk/openai | OpenAI 模型的具体实现,支持 GPT-4、GPT-3.5 等 |
@ai-sdk/provider | 接口抽象层,定义统一规范(通常间接依赖,无需显式安装) |
dotenv | 从 .env 文件加载环境变量到 process.env |
typescript | TypeScript 编译器,类型检查和代码生成 |
tsx | TypeScript 执行器,直接运行 .ts 文件(类似 ts-node 但更快) |
@types/node | Node.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 之前,可以先实现一个模拟模型。
两个原因:
- 暂时没有 API Key,但不影响先跑通整个流程。
- 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 的区别在于不会自动加换行,所以字符连续输出,呈现出"打字"的效果。
变成对话
现在代码的实现只能发一句话就退出。需要两样东西让它变成持续对话:
- readline:读取用户输入
- 消息历史:把之前的对话传给模型
关键认知:大模型本身没有记忆。每次 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 定义的消息类型,每条包含 role(user 或 assistant)和 content。
messages 数组就是对话历史——用户说一句 push 一条,AI 回复一句 push 一条。
streamText 的参数变化:之前用 prompt(单条消息),现在换成了 messages(消息数组)。
这就是"有记忆"和"没记忆"的区别——传 prompt 模型只看到当前这一句,传 messages 模型看到完整对话上下文。
ask() 在末尾递归调用自己,形成循环:等待输入 → 发给模型 → 流式输出 → 等待输入……这就是最原始的"Agent Loop"雏形,只是还没有工具调用能力。
Mock 效果

真实 API 效果

原因很直接:messages 数组里已经包含前两轮的对话,模型看到了完整上下文。
引发的问题
对话越来越长,每次 API 调用传输的 token 越来越多。
token 数量直接影响成本(按量计费)和延迟(处理长上下文耗时更长)。
每个模型还有 context window 上限,聊久了就会超出限制。
三个核心要素
模型调用:streamText + model。将消息发送给模型,获取流式响应。无论 model 背后是 mock 还是真实 API,调用方式完全一致——Provider 模式的价值。
消息管理:messages: ModelMessage[]。每轮对话向数组 push 一条 user 和一条 assistant 消息,下一轮将整个数组传给模型。这是最基础的上下文管理——全量传递,不做压缩。
交互循环:ask() 递归调用自身,形成 readline → streamText → push → readline 的循环。
后续演化
- 模型调用 → StreamConsumer——解析工具调用、推理过程、token 用量等多种事件。
- 消息管理 → 四层上下文管理——截断、时间衰减修剪、LLM 摘要压缩、Cache 优化。
- 交互循环 → Agent Loop——
while(true) { think → act → observe },模型自己决定调哪个工具、什么时候停。