Appearance
10 分钟,让你的 AI 开口说话
约 28 分钟
你打开 Claude Code,输入一句"帮我重构一下这个函数",几秒钟后它开始读文件、改代码、跑测试。整个过程你什么都没做,就看着它一步步把事情办了。
这背后到底是什么在驱动?
拆到最底层,Claude Code 就是一个 Node.js 进程。调一次大模型 API,拿到回复,解析出"要调哪个工具",执行完之后把结果再喂回给模型,模型再想下一步做什么——如此循环。
听起来也没那么复杂?实际上确实不复杂。
这篇我们来做第一步——用不到 50 行 TypeScript,让一个 AI 在你的终端里开口说话。
它还不是 Agent——还不会调工具、不会自主决策、不会记住你说过什么。但它是一切的起点。后面 20 篇的每一个能力,工具系统、上下文压缩、记忆、插件、飞书接入……全都是在这 50 行代码上一层一层叠加的。
先不管那么远。这篇的目标只有一个:跑起来,聊几句,建立直觉。
你会得到什么
这篇结束后,你的项目长这样:
super-agent/
├── package.json
├── tsconfig.json
└── src/
├── index.ts ← 终端对话程序,~50 行
└── mock-model.ts ← 模拟模型,不需要 API Key 就能跑跑起来之后,你可以在终端里跟 AI 聊天,它会实时流式输出回复——就像 ChatGPT 打字的效果。你说一句它回一句,而且它能记住前面聊了什么。
零配置就能跑——默认用模拟模型,不需要任何 API Key。填了 DASHSCOPE_API_KEY 后自动切换到真实的 Qwen 模型。
通关条件:运行 pnpm dev,跟 AI 聊三轮以上,它能记住你之前说的话。
搭脚手架
创建项目目录,初始化:
以下脚手架命令用于本地终端开发。如果你在右侧的 WebContainer 编辑器中操作,项目已经初始化好了,直接跳到「实现模拟模型」即可。
bash
复制
bash
mkdir super-agent && cd super-agent
pnpm init安装依赖:
bash
复制
bash
pnpm add ai @ai-sdk/openai dotenv
pnpm add -D typescript tsx @types/node说一下这几个包的作用。
ai 是 Vercel 的 AI SDK。你可能会想,直接用 fetch 调模型的 HTTP API 不行吗?当然可以,但你需要自己处理 SSE 流式解析、错误重试、请求格式构造这些底层工作。而且以后换模型,整套请求代码都得重写。AI SDK 把不同模型的差异屏蔽了——不管背后是 Qwen、Claude 还是 GPT,调用代码完全一致。换模型改一行配置。
@ai-sdk/openai 是 OpenAI 兼容协议的适配器。Qwen 的 DashScope API 支持 OpenAI 兼容格式,所以用这个包就能直接调。AI SDK 采用的是 Provider 模式——核心包 ai 定义统一接口,每个模型厂商出一个 Provider 来适配自己的 API。
dotenv 负责从 .env 文件加载环境变量,API Key 等敏感信息不会写死在代码里。
tsx 是 TypeScript 的即时运行器,不需要先编译再运行,直接执行 .ts 文件。
修改 package.json,加上 type: "module" 和启动脚本:
json
复制
json
{
"name": "super-agent",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts"
}
}type: "module" 告诉 Node.js 使用 ESM 模块系统。AI SDK 和大部分现代库都是 ESM 的,混用 CJS 和 ESM 会遇到各种 import 兼容问题,统一用 ESM 可以避免。
tsx watch 会监听文件变动,修改代码后自动重启。
再加一个 tsconfig.json:
json
复制
json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}ES2022 是当前主流的编译目标,AI SDK 和大部分现代库都基于这个版本。
脚手架完成,三个文件。
实现模拟模型
先安装依赖:
bash
运行复制
bash
pnpm install在接入真实 API 之前,我们先实现一个模拟模型。
两个原因:第一,你可能暂时没有 API Key,但不影响先跑通整个流程。第二,AI SDK 提供了 MockLanguageModelV3 测试工具,它实现了和真实模型完全一样的接口。用它写的代码,切换到真实模型时不需要改任何业务逻辑。
创建 src/mock-model.ts:
src/mock-model.ts
应用复制
typescript
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() {
return {
specificationVersion: 'v2' as const,
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 事件流。pickResponse 就更简单了,纯 if-else 关键词匹配,跟大模型没有任何关系——我们只是需要一个能返回文字的东西来验证流式对话的机制。
mock 模型在后续课程中会反复出现。开发工具系统、测试 Agent Loop 时,用它可以跳过真实 API 调用,加速开发调试。当然,你也可以在
.env中填入真实的 API Key 来开发——右侧编辑器使用的是本地的 WebContainer 环境,你的 API Key 不会上传到平台,可以放心使用。
第一次调用模型
创建 src/index.ts:
src/index.ts
应用复制
typescript
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();运行:
bash
运行复制
bash
pnpm start终端输出类似:
我是通义千问(模拟版),在本地模拟回复,机制和真实 API 完全一致。没有 API Key 也能正常运行——模拟模型自动接管。需要切换到真实模型时,创建 .env 文件:
DASHSCOPE_API_KEY=你的keyAPI Key 从阿里云百炼平台获取,免费额度足够完成本课程的所有练习。填入后重启自动切换。
这里有个核心设计——model 变量的类型是 AI SDK 的统一接口。不管背后是 mock 还是 Qwen,generateText 都不需要知道具体实现。这就是 Provider 模式的价值:调用方和实现方解耦,切换模型不改业务代码。
注意我们用的是 createOpenAI 而不是某个 Qwen 专用的包。通过 qwen.chat() 来创建模型实例,确保走标准的 Chat Completions 协议。Qwen 的 DashScope API 兼容 OpenAI 协议格式,只需要将 baseURL 指向 DashScope 的地址即可。这也是 OpenAI 兼容协议的优势:同一套代码,换个地址就能对接不同的模型。
generateText 是同步返回的——等模型把完整回复生成完毕后一次性返回。这对后台任务没问题,但在聊天场景下体验很差:如果回复有 500 字,用户需要等待数秒,然后突然出现一大段文字。
所以我们换成流式输出。
从"等半天"到"边想边说"
把 generateText 换成 streamText:
src/index.ts
应用复制
typescript
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 的区别在于不会自动加换行,所以字符连续输出,呈现出"打字"的效果。
右侧的 WebContainer 教学环境受限于浏览器沙箱,无法实现流式响应的效果,只能一次性完整输出。所以默认使用 mock 模型来演示流式输出的机制。想体验真实的逐字流式效果,在本地终端运行即可。
让它变成对话
现在代码只能发一句话就退出。我们需要两样东西让它变成持续对话:
- readline:读取用户输入
- 消息历史:把之前的对话传给模型
这两件事看起来简单,但背后有一个关键认知:大模型本身没有记忆。每次 API 调用,对模型来说都是一次全新的对话。它之所以能"记住"之前说了什么,是因为你每次都把完整的对话历史传给了它。
不是模型在"记忆",而是你在"提醒"。
src/index.ts
应用复制
typescript
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 rl = createInterface({
input: process.stdin,
output: process.stdout,
});
const messages: ModelMessage[] = [];
function ask() {
rl.question('\nYou: ', async (input) => {
const trimmed = input.trim();
if (!trimmed || trimmed === 'exit') {
console.log('Bye!');
rl.close();
return;
}
messages.push({ role: 'user', content: trimmed });
const result = streamText({
model,
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('Super Agent v0.1 (type "exit" to quit)\n');
ask();ModelMessage 是 AI SDK 定义的消息类型,每条包含 role(user 或 assistant)和 content。messages 数组就是对话历史——用户说一句 push 一条,AI 回复一句 push 一条。
注意 streamText 的参数变化:之前用 prompt(单条消息),现在换成了 messages(消息数组)。这就是"有记忆"和"没记忆"的区别——传 prompt 模型只看到当前这一句,传 messages 模型看到完整对话上下文。
ask() 在末尾递归调用自己,形成循环:等待输入 → 发给模型 → 流式输出 → 等待输入……这实际上就是最原始的"Agent Loop"雏形,只是还没有工具调用能力。
填入了真实的
DASHSCOPE_API_KEY后,这里的对话体验会好很多——模型会根据上下文给出真正有意义的回复。
应用代码后运行:
bash
运行复制
bash
pnpm start运行效果:
Super Agent v0.1 (type "exit" to quit)
You: 你好,我叫小明
Assistant: 你好小明!很高兴认识你,有什么我能帮你的吗?
You: 我刚才说我叫什么来着?
Assistant: 你说你叫小明呀 :)第三轮它记住了你叫小明。原因很直接:messages 数组里已经包含前两轮的对话,模型看到了完整上下文。
但这也引出了一个问题:对话越来越长,每次 API 调用传输的 token 越来越多。 token 数量直接影响成本(按量计费)和延迟(处理长上下文耗时更长)。每个模型还有 context window 上限,聊久了就会超出限制。
这个问题后续课程会系统解决——四层渐进式上下文管理。当前阶段先不处理。
定义角色
目前 AI 的回复比较通用。通过 system prompt 可以定义它的行为风格:
typescript
复制
typescript
const result = streamText({
model,
system: `你是 Super Agent,一个专注于软件开发的 AI 助手。
你说话简洁直接,喜欢用代码示例来解释问题。
如果用户的问题不够清晰,你会反问而不是瞎猜。`,
messages,
});增加了 system 参数后,回复风格会有明显变化——从泛泛而谈变成直接给代码示例加简要解释。
System prompt 在 Agent 开发中的地位很特殊。它不只是"一段提示词",更像是 Agent 的行为控制系统——决定了在什么场景下做什么、如何组织回复、如何使用工具。Claude Code 的 system prompt 有上千行,按模块动态组装。
当前阶段写死在代码里就够用了。后续课程会将其改造为 Prompt Pipe 系统。
三个核心要素
回顾一下。代码不长,但包含了对话系统的三个核心要素:
模型调用: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 },模型自己决定调哪个工具、什么时候停

现在它们还只是最简单的形态。
下一篇预告
现在你有了一个能聊天的 AI,但它只会聊天——让它帮你读个文件,它只能告诉你怎么读,自己做不了任何事。
下一篇,我们给它加上 while 循环和工具调用。改造之后,同样的问题:
- 当前的 ChatBot:「你可以用 cat 命令来查看文件内容……」
- 改造后的 Agent:直接调用 read_file 读取文件,把内容返回给你
从「能聊天」到「能执行」,就差一个循环。