欢迎来到 Mini Claude Code,一个共六集的动手系列,我们会用 TypeScript 从零搭一个真的能跑的 coding agent。不用框架,不玩魔法,也不碰 LangChain。就 Anthropic SDK、一个终端,加上"一次只加一个能力"的克制。

到系列结束时,你手上会有一个虽小但完整的 Claude Code 克隆版:能读你的代码库,能用 unified diff 改文件,能自己管理上下文,能拉起 sub-agent,还能被一份 mini 评测集打分。第 01 集要做的东西,是最小到可以被称作 agent 的形态:一个能和模型对话、能把响应 streaming 回来、并且记得上一轮说过什么的 REPL。

如果你已经跑过 10 个 chat demo,但还是搞不清自己写的那个为什么什么都记不住,这一集就是给你的。

今晚要搭的东西

一个单文件程序 agent.ts,做四件事:

  1. 在终端提示你输入
  2. 把你的消息连同之前所有轮次一起发给 Claude
  3. 把响应 streaming 回终端,边到边显示
  4. 循环直到你输入 /exit

就这些。没有 tool use,没有文件系统访问,进程一退所有记忆全没。大约 40 行 TypeScript。重点是把轮次结构钉死——因为后面每一集加的能力,都会骑在这个循环之上。

环境准备——三条命令

假设你已经装了 Node 20+。如果版本更老,先升级;SDK 里 fetch 和 AbortController 的语义都依赖它。

mkdir mini-claude-code && cd mini-claude-code
pnpm init && pnpm add @anthropic-ai/sdk
pnpm add -D typescript tsx @types/node

写一份最小的 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

配好 key:

export ANTHROPIC_API_KEY=sk-ant-...

准备工作到此结束。接下来所有内容都在一个文件里。

那 40 行 REPL

新建 agent.ts

import Anthropic from "@anthropic-ai/sdk";
import readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

const client = new Anthropic();
const MODEL = "claude-sonnet-4-5";
const SYSTEM = "You are Mini Claude Code, a helpful engineering assistant. Keep answers concrete and short unless asked otherwise.";

type Turn = { role: "user" | "assistant"; content: string };
const history: Turn[] = [];

const rl = readline.createInterface({ input, output });

async function turn(userText: string) {
  history.push({ role: "user", content: userText });

  const stream = client.messages.stream({
    model: MODEL,
    max_tokens: 1024,
    system: SYSTEM,
    messages: history,
  });

  let assistantText = "";
  for await (const event of stream) {
    if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
      process.stdout.write(event.delta.text);
      assistantText += event.delta.text;
    }
  }
  process.stdout.write("\n");
  history.push({ role: "assistant", content: assistantText });
}

async function main() {
  console.log("Mini Claude Code · Ep.01 REPL. Type /exit to quit.\n");
  while (true) {
    const line = (await rl.question("you › ")).trim();
    if (line === "/exit") break;
    if (!line) continue;
    process.stdout.write("cc  › ");
    await turn(line);
  }
  rl.close();
}

main().catch((e) => { console.error(e); process.exit(1); });

跑起来:

pnpm tsx agent.ts

你应该会看到:

Mini Claude Code · Ep.01 REPL. Type /exit to quit.

you › my name is Alice
cc  › Hi Alice! How can I help?
you › what did I just tell you my name was?
cc  › Alice.

第二轮里那个 "Alice" 不是什么小胜利,它就是这一集的全部意义。我在 code review 里见过的"我的 chatbot 没有记忆"类 bug,一半都来自同一个原因:下一次请求的时候忘了把 assistant 上一轮的回复一起发回去。模型是无状态的。记忆得由你来维护。

那 40 行里,有三件事比你想象中更重要

1. history 数组就是状态

Claude 在这个 REPL 里将来能"记住"的一切,都活在 history 数组里。清空它,记忆就没了。改动它,你就是在改写过去——是的,这一点在第 04 集会有大用,那时我们会有意识地编辑 history 来省 token。眼下先记住这条不变式:每一轮追加一条 user 消息,成功之后再追加一条 assistant 消息。如果请求半路失败,我们不能留下一条悬空的 user 消息;这个问题在第 02 集处理。

2. streaming 对 agent 不是可选项

你可以把 client.messages.stream 换成 client.messages.create,一次拿回一整坨响应。别这么干。将来要跑 tool 的 agent,必须用 streaming,理由有两个:(a)你希望响应边到边显示,用户才知道程序没卡住;(b)tool call 是作为 content block 出现在 stream 里的,现在把这套增量解析的管线搭好,比之后回头补要容易得多。从第 02 集开始我们就要重度使用这些 content block。

3. system prompt 是安放"人设"的地方,不是堆砌指令的地方

SYSTEM 只是一段很短的字符串。忍住冲动,别往里塞"永远做 X,绝不做 Y,记得 Z"。lost-in-the-middle 那条研究线(context engineering 那篇文章里聊过)告诉我们:模型对上下文的注意力集中在开头和结尾。在这个循环里,system prompt 是开头,最近一条 user 消息是结尾。如果一条规则只对某个特定任务重要,那就把它写进那一轮的 user 消息里,别塞进 system prompt。

我自己写这一集时踩过的坑

"assistant 文本为空"的 bug。 第一版我忘了把 assistantText 累起来,只是往 stdout 写。history 里于是塞进去一堆空的 assistant 轮次,Claude 就会像上一轮从没发生过一样重新作答。修法:把 streaming 到的文本累进一个字符串里,等 stream 结束之后再 push 进 history。

readline 提示把响应吃掉。 如果你在模型还在 streaming 的时候就调用 rl.question("you › "),提示字符串会和模型输出交错在一起。修法:先 await turn(line) 完整跑完再调 rl.question。上面这份代码里,turn 会 await 完整个 for await 循环,所以本身没问题——但你要是重构成 fire-and-forget,这个 bug 就会回来。

API key 没被读到。 SDK 会自动从环境变量里读 ANTHROPIC_API_KEY。Windows PowerShell 上是 $env:ANTHROPIC_API_KEY = "sk-ant-...";macOS/Linux 上是 export ANTHROPIC_API_KEY=...。如果你用 .env 文件,装个 dotenv/config,或者用 SDK 的 apiKey 选项——千万别硬编码进代码。

下一集要修的东西

现在这个 agent 有一个明晃晃的局限:它活在鱼缸里。它读不了文件,列不了目录,跑不了命令。你问它的每个问题,它都只能靠训练数据回答,或者靠你手动粘到终端里的内容。

第 02 集会改变这件事。我们会加入 tool use——三个 tool:read_filelist_dirrun_bash——让 Claude 真的能翻一翻项目目录。也正是从那一集起,我们今天搭的 streaming 管线才开始真正回本,因为 tool call 会作为 content block 回来,需要我们分发并作出响应。

我们还会顺手修掉今晚这份代码里偷懒混过去的一件事:现在如果模型的响应被截断了(撞到了 max_tokens),我们是悄悄地截掉不吭声的。第 02 集会正经地处理 stop reason。

速查表——第 01 集

| 名目 | 位置 | |---|---| | 模型 | claude-sonnet-4-5 | | SDK | @anthropic-ai/sdk | | 循环形状 | while(true) { question → stream → push history } | | 记忆 | 内存里的 history: Turn[] | | 关心的 streaming 事件 | content_block_deltadelta.type === "text_delta" | | 退出 | /exit |

最小可行轮次:

history.push({ role: "user", content: userText });
const stream = client.messages.stream({ model, system, messages: history, max_tokens: 1024 });
let out = "";
for await (const e of stream) {
  if (e.type === "content_block_delta" && e.delta.type === "text_delta") out += e.delta.text;
}
history.push({ role: "assistant", content: out });

能让你活到第 02 集的三条规矩:

  1. 永远不要忘了把 assistant 那一轮 push 回 history。
  2. 永远不要在 stream 还开着的时候调 question
  3. 永远不要把和某次任务相关的动态指令塞进 system prompt。

今晚这份 agent 的完整源码会放在 github.com/claude-community/mini-claude-codeep-01 目录下。周一见,讲 tool。