基于 main.ts — 约 70 行,一个最小可运行的 AI Agent CLI。


1. 整体架构

┌──────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  user input  │ ──→ │   Agent Loop     │ ──→ │   DeepSeek API  │
│  (readline)  │ ←── │   (while true)   │ ←── │   (streamText)  │
└──────────────┘     └────────┬─────────┘     └─────────────────┘
                              │
                              │ tool-call
                              ▼
                     ┌──────────────────┐
                     │    readFile tool │
                     │  (fs.readFile)   │
                     └──────────────────┘

程序 = 模型 + 工具 + 多轮对话循环,三个要素缺一不可。


2. 依赖图谱

作用

用在哪里

ai (Vercel AI SDK)

提供 streamTexttoolstepCountIs 三个核心 API

整个 agent 循环

@ai-sdk/deepseek

DeepSeek 模型适配器,封装鉴权和请求格式

deepseek("deepseek-chat")

zod

定义工具参数的 schema,模型依此生成合法的 JSON 参数

readFileinputSchema

dotenv

.env 文件加载环境变量到 process.env

第 8 行,程序入口

node:fs/promises

文件系统操作

readFileexecute

node:readline

终端交互,逐行读取用户输入

ask() 函数


3. 逐段拆解

3.1 环境变量加载(第 8 行)

import "dotenv/config";

副作用导入——执行时把 .env 中的 DEEPSEEK_API_KEY 注入 process.env。后续 @ai-sdk/deepseek 自动从环境变量读取,无需显式传参。

3.2 工具定义(第 16–24 行)

const readFile = tool({
  description: "读取一个文本文件,返回完整内容",
  inputSchema: z.object({
    path: z.string().describe("要读取的文件路径"),
  }),
  execute: async ({ path }) => {
    return await fs.readFile(path, "utf-8");
  },
});

Vercel AI SDK 的 tool() 接收三个核心字段:

字段

含义

description

告诉模型"这工具是干什么的"——模型根据描述决定是否调用

inputSchema

Zod schema → 自动转成 JSON Schema 发给模型,约束参数格式

execute

模型决定调用后,SDK 执行这个函数,返回值回传给模型

这就是 function calling 的完整实现:描述 → 声明参数 → 执行函数 → 结果回传。

3.3 对话基础设施(第 30–38 行)

const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const ask = (q: string) => new Promise<string>((r) => rl.question(q, r));
const messages: Array<{ role: "user" | "assistant"; content: any }> = [];
  • rl / ask:把 Node.js 回调式的 readline 包装成 Promise,方便在 async 函数里 await

  • messages对话历史数组,agent 的"记忆"。每轮把用户输入 + 模型的所有回复(文本、工具调用、工具结果)追加进去

3.4 Agent 主循环(第 40–67 行)

for (;;) {
  const input = (await ask("\n > : ")).trim();
  if (!input || input === "exit") break;
  messages.push({ role: "user", content: input });

  const result = streamText({
    model: deepseek("deepseek-chat"),
    messages,
    tools: { readFile },
    stopWhen: stepCountIs(10),
  });

  // 流式消费...
  const { messages: newMessages } = await result.response;
  messages.push(...(newMessages as any));
}

每一步做了什么:

 ┌─ ① 读用户输入
 │    空输入 / "exit" → 退出循环
 │
 ├─ ② 追加到 messages(role: "user")
 │
 ├─ ③ 调用 streamText
 │    把 messages + tools 发给 DeepSeek
 │    SDK 内部自动跑 multi-step:
 │      model 输出 text ──→ model 输出 tool-call ──→ execute 工具 ──→ tool-result 回传 model ──→ model 输出 text ──→ ...
 │    最多跑 10 步(stepCountIs(10) 兜底)
 │
 ├─ ④ 流式消费 fullStream
 │    把 text-delta / tool-call / tool-result 实时打印到终端
 │
 └─ ⑤ 拿 SDK 累积的新消息,追加进 messages
      下一轮对话时模型就能"记住"之前发生了什么

3.5 流式输出处理(第 52–63 行)

process.stdout.write("助手: ");
for await (const chunk of result.fullStream) {
  if (chunk.type === "text-delta") process.stdout.write(chunk.text);
  else if (chunk.type === "tool-call")
    process.stdout.write(`\n  [调用 ${chunk.toolName}(${JSON.stringify(chunk.input)})]`);
  else if (chunk.type === "tool-result")
    process.stdout.write(`\n  [返回 ${String(chunk.output).length} 字节]\n助手: `);
}

fullStream 是一个异步可迭代流,产出三种 chunk:

chunk.type

含义

打印方式

text-delta

模型输出的增量文本片段

直接追加到终端,实现"打字机"效果

tool-call

模型决定调用某个工具(含参数)

打印工具名 + JSON 参数

tool-result

工具执行完毕的返回值

打印结果字节数(不打印完整内容,避免刷屏)


4. 数据流

用户: "帮我看看 package.json 里有哪些依赖"

  messages = [
    { role: "user", content: "帮我看看 package.json 里有哪些依赖" }
  ]
           │
           ▼
  ┌────────────────────────────────────────────────┐
  │           streamText()                         │
  │                                                │
  │  Step 1: model → tool-call                     │
  │    chunk: { type: "tool-call",                 │
  │             toolName: "readFile",              │
  │             input: { path: "package.json" } }  │
  │                                                │
  │  SDK: execute readFile({ path })               │
  │    → 返回文件内容 (487 字节)                      │
  │                                                │
  │  chunk: { type: "tool-result",                 │
  │           output: "...文件内容..." }            │
  │                                                │
  │  Step 2: model → text-delta                    │
  │    chunk: { type: "text-delta",                │
  │             text: "你的 package.json..." }     │
  │    ...更多 text-delta chunk...                 │
  └───────────────────────────────────────────────┘
           │
           ▼
  result.response.messages = [
    { role: "assistant", content: [tool-call, text] },
    { role: "tool", content: "文件内容" }
  ]
           │
           ▼
  追加到 messages → 下一轮对话带上所有历史

5. 关键设计决策

5.1 stopWhen: stepCountIs(10) — 防死循环

模型可能陷入"调工具 → 不满意 → 再调 → 再不满意 → …"的循环。stepCountIs(10) 是最简单的刹车:最多 10 步(一次 tool-call + tool-result = 1 步),超过就强制终止。

5.2 result.response.messages — SDK 帮你记账

不需要手动追踪模型发了哪些 tool-call、收到了哪些 tool-result——SDK 的 result.response.messages 包含了本轮生成的所有新消息(assistant 文本 + tool-call + tool-result),直接 concat 就行。

5.3 流式消费 — 用户体验

fullStream 让文本边生成边显示,用户不用盯着空白终端等 30 秒。tool-call/tool-result 也实时可见,让用户知道"模型在读文件,不是在发呆"。


6. 这段代码没做什么

这是刻意做减法的结果,以下是明确 不做 的事情(以及为什么不做):

没做的

后果

终端 UI 框架

readline,不支持多行、补全、历史搜索

权限确认

模型说读文件就直接读,没有"允许/拒绝"环节

错误恢复

网络抖一下、文件不存在 → 整个进程崩溃

多供应商

换 OpenAI / Anthropic 要改代码

更多工具

只能读文件,不能写、不能跑命令、不能搜索

上下文压缩

聊到几十轮后 token 溢出

结构化输出

没有 jsonSchema 约束,模型回复格式不可控

这些是小册后续章节要逐一补齐的能力。


7. 快速参考

# 启动
npm run dev        # 等同于 npx tsx main.ts

# 前提条件
# 1. Node.js ≥ 20.19
# 2. .env 中填入 DEEPSEEK_API_KEY=sk-...

代码骨架(最小复刻)

// 1. 定义工具
const myTool = tool({ description: "...", inputSchema: z.object({...}), execute: async (args) => {...} });

// 2. 维护历史
const messages = [];

// 3. 主循环
for (;;) {
  messages.push({ role: "user", content: await getUserInput() });
  const result = streamText({ model, messages, tools: { myTool }, stopWhen: stepCountIs(10) });
  for await (const chunk of result.fullStream) { /* 打印 */ }
  messages.push(...(await result.response).messages);
}

这就是 AI Agent 最朴素的形态