Skip to content

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 适配器是同一个接口。你传给 generateTextstreamText,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=你的key

API 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.writeconsole.log 的区别在于不会自动加换行,所以字符连续输出,呈现出"打字"的效果。

右侧的 WebContainer 教学环境受限于浏览器沙箱,无法实现流式响应的效果,只能一次性完整输出。所以默认使用 mock 模型来演示流式输出的机制。想体验真实的逐字流式效果,在本地终端运行即可。


让它变成对话

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

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

这两件事看起来简单,但背后有一个关键认知:大模型本身没有记忆。每次 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 定义的消息类型,每条包含 roleuserassistant)和 contentmessages 数组就是对话历史——用户说一句 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 读取文件,把内容返回给你

从「能聊天」到「能执行」,就差一个循环。