TOPIC §01

从零构建一个 Mini Claude Code

从手写 40 行 ReAct 循环开始,逐步构建出有工具调用、上下文压缩和安全防护的 Code Agent

15 STEPS · CODE WALKTHROUGHBEGIN

Step 01 · 00.ts

ts
type ChatMessage = {  role: 'system' | 'user' | 'assistant'  content: string}type ParsedAssistant = {  action?: { tool: string; input: string }  final?: string}

这是一个面向初学者的动手教程,目标不是复刻完整的 Claude Code,而是把一个 Code Agent 的关键部件拆开,逐个写出来。

我们会做两个项目:

  1. agent-loop:一个只有几十行核心代码的 ReAct Agent,用来理解 Agent 为什么能调用工具、持续执行任务。
  2. mini-claude-code:一个基于 Vercel AI SDK 的本地 Code Agent,支持文件读写、局部编辑、Shell 执行、网页抓取、上下文压缩和危险命令防护。

这两个项目的关系很重要:第一个负责解释原理,第二个负责把原理工程化。很多教程会直接从框架开始讲,结果读者只知道“调用某个 API 就能跑”,但不知道背后的循环、消息、工具结果回填到底发生了什么。我们这里反过来,先手写一个最小闭环,再引入 SDK。

最终你应该能回答这几个问题:

  • Agent 和普通 ChatBot 的本质差异是什么?
  • ReAct 循环到底在循环什么?
  • 工具调用为什么不只是“给模型加几个函数”?
  • 为什么 Code Agent 必须考虑上下文、权限和安全?
  • Vercel AI SDK 帮我们省掉了哪些重复劳动?

第一章:Agent 和 ChatBot,到底差在哪?

开始写代码之前,先把概念讲清楚。因为如果只是照着代码抄一遍,很容易把 Agent 理解成“会调用 API 的聊天机器人”。这个理解不算错,但太浅。

普通 ChatBot 的基本交互是:

用户输入问题模型生成回答用户阅读回答,并决定下一步

也就是说,控制流在人这边。模型只负责给出一段文本建议,至于要不要执行、怎么执行、执行完之后怎么处理错误,都由用户完成。

Agent 的交互方式不同:

用户给出目标模型分析当前状态模型选择工具并执行工具结果回到上下文模型基于结果继续决策直到任务完成或需要用户确认

这就是 Agent 的关键:控制流从人转移到了模型和运行时系统组成的闭环里。用户不再需要每一步都手动推进,而是给出目标,让 Agent 自己拆解、执行、观察结果并调整策略。

三个最小要素

一个 LLM-based Agent 至少需要三个要素:

用户感知技术术语本质
记住前后说了什么上下文窗口 / 对话状态管理维护一个消息数组,包含 system/user/assistant/tool 等历史消息
知道自己是谁、能做什么系统提示词 / 角色注入system 消息里定义能力边界、行为规则、输出格式和安全约束
能和外部世界交互工具调用 / 外部能力扩展模型输出结构化工具调用,代码执行函数,再把结果回填给模型继续判断

如果只有上下文和系统提示词,它还是一个更聪明的 ChatBot;如果再加上工具调用,并且能把工具结果重新纳入下一轮决策,它才开始具备 Agent 的形态。

为什么工具调用会改变一切?

大模型本身不会真的读文件、改代码、查网页、运行测试。它只能生成文本。所谓“工具调用”,本质上是运行时系统和模型之间的一种约定:

  1. 模型按约定输出“我要调用某个工具,参数是什么”。
  2. 你的程序解析这段输出。
  3. 你的程序在真实环境里执行对应函数。
  4. 执行结果作为新的消息追加回上下文。
  5. 模型看到结果后决定下一步。

这个机制让模型从“回答问题”变成“推动任务”。比如你让 Code Agent 修一个 Bug,它不应该直接猜答案,而应该:

  • 先读取报错信息;
  • 搜索相关文件;
  • 阅读上下文;
  • 修改最小必要代码;
  • 运行测试;
  • 如果测试失败,再根据错误继续修。

这就是 Agent 和 ChatBot 的核心差别:ChatBot 给建议,Agent 闭环执行。


第二章:ReAct:让模型边想边做

实现 Agent 有很多范式,入门最适合先理解 ReAct。

ReAct 是 Reasoning + Acting 的缩写。它把一次复杂任务拆成多个循环步骤:

Observation(观察当前状态)Thought(分析下一步)Action(调用工具或执行动作)Observation(拿到工具结果)继续循环

为了直观理解,假设用户问:“上海现在天气怎么样?”

一个 ReAct Agent 的执行过程可能是:

[Thought]用户要查上海当前天气,我需要先知道当前时间,再查询天气。[Action]调用 getTime 工具[Observation]2026-03-02T02:41:33.898Z[Thought]已经拿到当前时间,现在查询上海在这个时间点的天气。[Action]调用 getWeather,参数 {"city":"上海","time":"2026-03-02 02:41"}[Observation]上海在 2026-03-02 02:41 的天气为小雨,气温 15°C。[Final]上海现在是小雨,气温约 15°C。

注意这里有两个重点。

第一,模型不是一次性回答,而是在多轮中逐步推进。每一步都依赖上一轮工具返回的 Observation。

第二,工具结果不是给用户看的终点,而是给模型看的输入。Agent 的能力来自这个“执行结果回填上下文”的闭环。

一个最小公式

我们可以把本教程要实现的 Agent 写成一个简单公式:

Agent = ReAct(LLM + Context) + Tools + UI

展开一点:

  • LLM:负责理解任务、规划下一步、生成工具调用或最终回答。
  • Context:保存系统提示词、用户输入、模型输出、工具结果。
  • Tools:真实执行动作,例如读文件、写文件、运行命令。
  • UI:用户和 Agent 交互的入口,可以是 CLI、Web、IDE 插件。
  • ReAct Loop:把以上部件串起来的循环。

接下来我们先不使用任何 Agent 框架,自己写这个循环。


第三章:手写一个 Agent Loop

这个最小项目叫 agent-loop。它只解决一件事:让你看见 Agent 的核心循环到底长什么样。

我们会使用 Bun + TypeScript。除了调用模型 API,不引入复杂框架。这样每一行代码都和 Agent 的核心机制直接相关。

准备项目

创建项目:

bash
mkdir agent-loop && cd agent-loopbun init -y

项目结构如下:

agent-loop/├── main.ts      # Agent 核心循环├── tools.ts     # 工具定义└── prompt.md    # 系统提示词

这个项目会实现一个天气查询 Agent。它有两个工具:

  • getTime:返回当前时间。
  • getWeather:根据城市和时间返回模拟天气。

为什么用天气,而不是直接写 Code Agent?因为天气例子足够小,能专注讲清楚工具调用闭环。读文件、改文件、运行命令本质上也是同一套机制,只是工具更复杂、风险更高。

先定义核心数据结构

Agent 的核心状态是消息历史。每条消息至少包含两个字段:谁说的,以及说了什么。

这里有两个类型:

  • ChatMessage:发送给大模型的消息格式。
  • ParsedAssistant:从模型回复里解析出的结构化意图。

ParsedAssistant 很关键。模型原始输出是字符串,但我们的运行时需要知道它到底是在请求工具,还是已经给出最终答案。所以我们约定:解析结果要么包含 action,要么包含 final

这也是 Agent 运行时最常见的工作之一:把模型生成的文本转成程序可以执行的结构。

调用大模型

接下来实现 callLLMs。它只做一件事:把消息数组发给模型 API,再返回 assistant 的文本回复。

Step 02 · 01.ts

ts
async function callLLMs(messages: ChatMessage[]): Promise<string> {  const res = await fetch('https://api.deepseek.com/v1/chat/completions', {    method: 'POST',    headers: {      'Content-Type': 'application/json',      Authorization: `Bearer ${process.env.DEEPSEEK_API_KEY}`,    },    body: JSON.stringify({      model: 'deepseek-chat',      messages,      temperature: 0.35,    }),  })  if (!res.ok) {    const text = await res.text()    throw new Error(`API 错误: ${res.status} ${text}`)  }  const data = (await res.json()) as {    choices?: Array<{ message?: { content?: string } }>  }  const content = data.choices?.[0]?.message?.content  if (typeof content !== 'string') {    throw new Error('返回内容为空')  }  return content}

这里用的是 DeepSeek 的 OpenAI 兼容接口,所以请求体和 OpenAI Chat Completions 类似。

几个细节值得注意:

  • messages 是完整历史,不只是最新问题。模型是否“记得”之前发生了什么,取决于你有没有把历史再次发给它。
  • temperature 设置为 0.35,比自由写作场景更低。工具调用需要稳定格式,温度太高会增加输出不符合约定的概率。
  • API 错误必须抛出来。Agent 调试时,沉默失败会非常难查。

到这里,我们只完成了“能和模型说话”。它还不是 Agent,因为还没有解析动作,也没有执行工具。

解析模型回复

为了让模型输出可解析,我们约定两种 XML 标签:

xml
<action tool="getWeather">{"city":"上海","time":"2026-03-02 02:41"}</action><final>上海现在是小雨,气温 15°C。</final>

然后用正则解析:

Step 03 · 02.ts

ts
function parseAssistant(content: string): ParsedAssistant {  const actionMatch = content.match(    /<action[^>]*tool="([^"]+)"[^>]*>([\s\S]*?)<\/action>/i,  )  const finalMatch = content.match(/<final>([\s\S]*?)<\/final>/i)  return {    action: actionMatch      ? {          tool: actionMatch[1],          input: actionMatch[2]?.trim() ?? '',        }      : undefined,    final: finalMatch?.[1]?.trim(),  }}

这段代码很朴素,但它揭示了一个事实:所谓 Agent 框架,很大一部分工作就是在处理“模型输出的结构化协议”。

在生产项目里,你不一定会用 XML。也可以用 JSON、OpenAI Function Calling、Vercel AI SDK 的 tool calling、Anthropic tool use。形式不同,本质相同:让模型用机器可读的方式表达“下一步要做什么”。

搭出循环骨架

现在可以写 ReAct 循环的骨架了。

Step 04 · 03.ts

ts
async function AgentLoop(question: string) {  const systemPrompt = await Bun.file('prompt.md').text()  const history: ChatMessage[] = [    { role: 'system', content: systemPrompt },    { role: 'user', content: question },  ]  for (let step = 0; step < 10; step++) {    const assistantText = await callLLMs(history)    console.log(`\n[LLM 第 ${step + 1} 轮输出]\n${assistantText}\n`)    history.push({ role: 'assistant', content: assistantText })    const parsed = parseAssistant(assistantText)    if (parsed.final) {      return parsed.final    }    break  }  return '未能生成最终回答,请重试或调整问题。'}

这个版本还没有真正执行工具,但循环结构已经出现了:

  1. 把当前 history 发给模型。
  2. 记录模型回复。
  3. 解析模型回复。
  4. 如果是 <final>,返回最终答案。
  5. 如果不是最终答案,进入下一轮或退出。

for (let step = 0; step < 10; step++) 是一个非常重要的保护。Agent 必须有最大步数限制,否则模型一旦陷入重复调用工具,就会无限消耗 token 和 API 费用。

定义工具

现在补上工具。工具放在 tools.ts 里:

Step 05 · 04b.ts

ts
export type ToolName = 'getWeather' | 'getTime'export type ToolFn = (input: string) => Promise<string>function mockWeather(city: string, time: string): string {  const conditions = ['晴', '多云', '阴', '小雨', '阵雨']  const seed = Array.from(`${city}|${time}`).reduce(    (acc, ch) => acc + ch.charCodeAt(0),    0,  )  const condition = conditions[seed % conditions.length] ?? '晴'  const temp = 12 + (seed % 20)  return `天气信息:${city} 在 ${time} 的天气为${condition},气温 ${temp}°C。`}export const TOOLKIT: Record<ToolName, ToolFn> = {  async getTime() {    return new Date().toISOString()  },  async getWeather(input: string) {    try {      const { city, time } = JSON.parse(input)      if (typeof city !== 'string' || typeof time !== 'string') {        return 'getWeather 需要 JSON 参数:{"city":"上海","time":"2026-02-27 10:00"}'      }      return mockWeather(city.trim(), time.trim())    } catch {      return 'getWeather 参数必须是 JSON 字符串'    }  },}

这里把工具设计成一个简单的对象:

ts
TOOLKIT[toolName](input) -> Promise<string>

这不是最类型安全的设计,但足够展示原理。模型输出工具名和输入字符串,运行时根据工具名找到函数并执行,再拿到字符串结果。

天气工具使用模拟数据,而不是接入真实天气 API。这样做有两个好处:

  • 教程不依赖第三方天气服务,读者更容易复现。
  • 同样的城市和时间会得到稳定结果,方便调试 Agent 循环。

接入工具执行

最后把工具调用接入主循环:

Step 06 · 04.ts

ts
type ToolName = 'getWeather' | 'getTime'declare const TOOLKIT: Record<ToolName, (input: string) => Promise<string>>async function AgentLoop(question: string) {  const systemPrompt = await Bun.file('prompt.md').text()  const history: ChatMessage[] = [    { role: 'system', content: systemPrompt },    { role: 'user', content: question },  ]  for (let step = 0; step < 10; step++) {    const assistantText = await callLLMs(history)    console.log(`\n[LLM 第 ${step + 1} 轮输出]\n${assistantText}\n`)    history.push({ role: 'assistant', content: assistantText })    const parsed = parseAssistant(assistantText)    if (parsed.final) {      return parsed.final    }    if (parsed.action) {      const toolFn = TOOLKIT[parsed.action.tool as ToolName]      const observation = toolFn        ? await toolFn(parsed.action.input)        : `未知工具: ${parsed.action.tool}`      console.log(`<observation>${observation}</observation>\n`)      history.push({        role: 'user',        content: `<observation>${observation}</observation>`,      })      continue    }    break  }  return '未能生成最终回答,请重试或调整问题。'}

这里沿用前面已经实现的 callLLMsparseAssistant,只展示新增的工具执行分支。完整闭环出现了:

  1. 模型输出 <action tool="...">...</action>
  2. parseAssistant 解析出工具名和参数。
  3. 运行时从 TOOLKIT 查找工具函数。
  4. 执行工具,得到 observation
  5. <observation>...</observation> 作为新消息追加到 history
  6. 下一轮模型看到 observation,继续判断。

这一行尤其关键:

ts
history.push({  role: 'user',  content: `<observation>${observation}</observation>`,})

工具结果必须回到上下文,否则模型不知道刚才的动作发生了什么。你可以把它理解成 Agent 的“感官输入”:工具负责接触外部世界,Observation 负责把外部世界的反馈交还给模型。

写系统提示词

代码只能执行协议,协议本身要通过系统提示词告诉模型。

创建 prompt.md

markdown
你是天气查询的工具型助手,回答要简洁。可用工具(action 的 tool 属性需与下列名称一致):- getTime: 返回当前 time 字符串,参数为空。- getWeather: 返回模拟天气信息字符串,参数为 JSON,如 {"city":"上海","time":"2026-02-27 10:00"}。回复格式(严格使用 XML,小写标签):<thought>对问题的简短思考</thought><action tool="工具名">工具输入</action>等待 <observation> 后再继续思考。如果已可直接回答,则输出:<final>最终回答(中文,必要时引用数据来源)</final>规则:- 每次仅调用一个工具;工具输入要尽量具体。- 查询天气时,必须调用 getWeather,并提供 city 和 time 两个字段。- 如果拿到 observation 后有了答案,应输出 <final> 而不是重复调用。- 避免幻觉,不确定时请说明。

系统提示词不是“让模型说得更像某个人”的装饰文本,而是 Agent 协议的一部分。它至少要说明四件事:

  • 角色:这个 Agent 是干什么的。
  • 工具:有哪些工具,每个工具怎么用。
  • 输出格式:如何表达 action 和 final。
  • 规则:什么时候调用工具,什么时候结束。

运行最小 Agent

设置 API Key 后运行:

bash
DEEPSEEK_API_KEY=你的_key bun main.ts "上海现在天气怎么样?"

你应该会看到类似流程:

text
用户问题: 上海现在天气如何?[LLM 第 1 轮输出]<thought>用户询问上海现在的天气,需要获取当前时间。</thought><action tool="getTime"></action><observation>2026-03-02T02:41:33.898Z</observation>[LLM 第 2 轮输出]<thought>已获得当前时间,需要查询上海天气。</thought><action tool="getWeather">{"city":"上海","time":"2026-03-02 02:41"}</action><observation>天气信息:上海在 2026-03-02 02:41 的天气为小雨,气温 15°C。</observation>[LLM 第 3 轮输出]<final>上海现在是小雨,气温约 15°C。</final>

这就是一个最小 Agent。它没有复杂框架,本质只是:

text
消息历史 + 模型调用 + 输出解析 + 工具执行 + 结果回填 + 循环

手写版的问题

手写版适合理解原理,但不适合直接扩展成生产级 Code Agent。主要有三个问题。

第一,Provider 适配成本高。 如果从 DeepSeek 换到 OpenAI、Anthropic、Gemini,你要改 URL、Header、请求体、响应解析,甚至工具调用协议。

第二,工具调用状态机全靠自己维护。 什么时候继续循环、什么时候停止、工具结果怎么塞回历史、模型输出不合法怎么办,这些都要手写。

第三,工具参数没有类型安全。 所有工具输入都是字符串,解析 JSON、校验字段、处理错误都靠手工写。工具越多,问题越明显。

所以接下来我们引入 Vercel AI SDK。它不会改变 Agent 的本质,但会把这些重复的工程细节标准化。


第四章:为什么使用 Vercel AI SDK?

Vercel AI SDK 在这里主要解决三类问题。

统一模型 Provider

手写 fetch 时,每个模型服务都有自己的细节。SDK 把这些差异封装成统一的 model 对象。

手写版:

text
fetch(url, headers, body) -> parse response JSON -> get message content

SDK 版:

ts
generateText({ model, messages, tools })

换模型时,业务代码尽量不动,只替换 provider 配置。

内置工具调用循环

手写版需要自己处理:

  • 模型是否调用工具;
  • 调用哪个工具;
  • 工具参数怎么解析;
  • 工具结果怎么回填;
  • 何时再次请求模型;
  • 最多循环多少步。

SDK 的 generateText + maxSteps 会处理这个状态机。你仍然要设计工具和提示词,但不需要重复写循环胶水代码。

Zod 参数校验

手写版工具输入是字符串。SDK 版可以为每个工具定义 Zod schema:

ts
parameters: z.object({  path: z.string(),  limit: z.number().optional(),})

模型生成的参数会被 SDK 解析和校验,execute 函数拿到的是有类型的对象。对 Code Agent 来说,这一点很重要,因为工具参数错误会直接影响文件和命令执行。


第五章:构建 Mini Claude Code

现在开始构建第二个项目:mini-claude-code

它不是完整 Claude Code 的克隆,而是一个教学版 Code Agent。我们保留最关键的能力:

  • 读取文件;
  • 写入文件;
  • 局部编辑文件;
  • 执行 Shell 命令;
  • 抓取网页;
  • 维护多轮对话历史;
  • 在上下文过长时压缩历史;
  • 对危险命令做拦截或确认。

项目结构如下:

text
src/├── index.ts              # CLI 入口├── SYSTEM_PROMPT.md      # 基础系统提示词├── agent/│   ├── provider.ts       # 模型 Provider 配置│   ├── loop.ts           # 核心 AgentLoop│   ├── context.ts        # 上下文压缩│   └── prompt.ts         # 系统提示词组装├── tools/│   ├── index.ts          # 工具注册表│   ├── read-file.ts      # 读取文件│   ├── write-file.ts     # 写入文件│   ├── edit-file.ts      # 局部替换│   ├── bash.ts           # Shell 执行│   └── web-fetch.ts      # 网页抓取└── utils/    ├── truncate.ts       # 工具输出截断    ├── safety.ts         # 危险命令和敏感路径检测    └── confirm.ts        # 用户确认交互

这个结构背后的原则是:Agent Loop 不直接关心具体工具怎么实现,工具也不直接关心模型怎么调用。 这样后续增加工具、替换模型、调整上下文策略都不会互相污染。

Provider 配置

先配置模型:

Step 07 · 05.ts

ts
if (!process.env.QINIU_API_KEY) {  throw new Error('缺少环境变量 QINIU_API_KEY,请参考 .env.example 配置')}const qiniu = createOpenAI({  apiKey: process.env.QINIU_API_KEY,  baseURL: 'https://api.qnaigc.com/v1',  compatibility: 'compatible',})const modelName = process.env.QINIU_MODEL ?? 'claude-4.6-sonnet'export const model = qiniu(modelName)

这里使用 createOpenAI 创建一个 OpenAI 兼容 Provider。很多第三方模型服务都会提供 OpenAI-compatible API,但兼容不代表完全等价,所以 compatibility: "compatible" 很重要。

它的作用是让 SDK 避免发送某些 OpenAI 官方接口特有但第三方服务未必支持的字段。只要你接的是非 OpenAI 官方的兼容接口,就应该优先考虑这个配置。

Provider 层应该尽量薄,只做这些事:

  • 读取 API Key;
  • 设置 baseURL;
  • 选择模型名;
  • 导出统一的 model

这样 Agent Loop 不需要知道底层到底是七牛、DeepSeek、OpenAI 还是 Anthropic。

工具注册:把能力交给模型

接下来注册工具:

Step 08 · 06.ts

ts
export const TOOLS = {  read_file: tool({    description: '读取文件内容,支持 offset/limit 分段读取。',    parameters: z.object({      path: z.string(),      offset: z.number().optional(),      limit: z.number().optional(),    }),    execute: readFile,  }),  write_file: tool({    description: '创建或完整覆盖文件;局部修改优先用 edit_file。',    parameters: z.object({      path: z.string(),      content: z.string(),    }),    execute: writeFile,  }),  edit_file: tool({    description: '替换唯一匹配的 old_string,适合局部修改。',    parameters: z.object({      path: z.string(),      old_string: z.string(),      new_string: z.string(),    }),    execute: editFile,  }),  bash: tool({    description: '执行 Shell 命令;危险命令会触发确认或拒绝。',    parameters: z.object({      command: z.string(),      timeout: z.number().optional(),    }),    execute: bash,  }),  web_fetch: tool({    description: '抓取网页并返回 Markdown,适合查阅文档。',    parameters: z.object({      url: z.string(),    }),    execute: webFetch,  }),}

每个工具都包含三部分:

  • description:给模型看的说明,影响模型什么时候选择这个工具。
  • parameters:Zod schema,定义工具参数。
  • execute:真实执行逻辑。

工具描述不是普通注释,它是模型决策的一部分。比如 edit_file 明确说它只替换唯一匹配的 old_string,模型就更容易先读文件、再做局部修改。一个好的 Code Agent,很多稳定性不是来自“模型更聪明”,而是来自工具描述和系统提示词把边界说清楚。

这里的工具组合也很克制:

  • read_file 负责看上下文;
  • write_file 负责创建或全量覆盖;
  • edit_file 负责局部修改;
  • bash 负责搜索、测试、构建等通用命令;
  • web_fetch 负责查文档。

不要一开始就堆很多工具。工具越多,模型选择成本越高,误用概率也越高。教学版保留最小可用集合更合理。

Bash 工具:能力越大,越要加护栏

Code Agent 最危险的工具通常是 Shell。它很强,因为几乎所有本地开发任务都能通过 Shell 完成;它也危险,因为一个错误命令就可能删除文件、泄漏信息或破坏系统。

Step 09 · 07.ts

ts
export async function bash({  command,  timeout = 30_000,}: {  command: string  timeout?: number}): Promise<string> {  const danger = detectDanger(command)  if (danger === 'block') {    return `拒绝执行:该命令已被自动阻止(高风险操作)。\n命令:${command}`  }  if (danger === 'confirm') {    const approved = await confirmFromUser(command)    if (!approved) {      return `用户拒绝执行命令:${command}`    }  }  try {    const proc = Bun.spawn(['sh', '-c', command], {      stdout: 'pipe',      stderr: 'pipe',    })    const timer = setTimeout(() => proc.kill(), timeout)    const [stdout, stderr, exitCode] = await Promise.all([      new Response(proc.stdout).text(),      new Response(proc.stderr).text(),      proc.exited,    ])    clearTimeout(timer)    const output =      [        stdout,        stderr && `[stderr]\n${stderr}`,        exitCode !== 0 && `[exit code: ${exitCode}]`,      ]        .filter(Boolean)        .join('\n')        .trim() || '(无输出)'    return truncateOutput('bash', output)  } catch (e) {    return `执行失败:${(e as Error).message}`  }}

bash 工具做了几件事:

  1. 执行前调用危险命令检测。
  2. 对需要确认的命令暂停并询问用户。
  3. 对禁止执行的命令直接拒绝。
  4. 设置超时时间,避免命令挂住。
  5. 合并 stdout 和 stderr,返回给模型。
  6. 对长输出做截断。

这不是“锦上添花”,而是 Code Agent 的基本安全要求。只要 Agent 可以执行命令,就必须有最后一道保险。

危险检测逻辑在 utils/safety.ts

Step 10 · 08.ts

ts
export type DangerLevel = 'safe' | 'confirm' | 'block'declare function resolvePath(cwd: string, inputPath: string): stringconst BLOCK_PATTERNS: RegExp[] = [  /rm\s+-\S*r\S*f\s+(\/|~|\$HOME)\b/,  /dd\s+if=.*of=\/dev\//,  /mkfs\./,  />\s*\/dev\/(sda|hda|nvme)/,  /shutdown|reboot|halt/,]const CONFIRM_PATTERNS: RegExp[] = [  /rm\s+-\S*[rf]/,  /sudo\s+/,  /(curl|wget)\s+.*\|\s*(sh|bash|zsh)/,  /npm\s+publish/,  /git\s+push\s+.*--force/,  /git\s+reset\s+--hard/,]export function detectDanger(command: string): DangerLevel {  if (BLOCK_PATTERNS.some((p) => p.test(command))) return 'block'  if (CONFIRM_PATTERNS.some((p) => p.test(command))) return 'confirm'  return 'safe'}export function resolveSafePath(inputPath: string): string {  const cwd = process.cwd()  const resolved = resolvePath(cwd, inputPath)  if (!resolved.startsWith(cwd + '/') && resolved !== cwd) {    throw new Error(      `路径越界:${inputPath} 解析为 ${resolved},超出工作目录 ${cwd}`,    )  }  return resolved}const SENSITIVE_PATTERNS: RegExp[] = [  /\.env(\.|$)/,  /\.aws\/credentials/,  /\.ssh\/(id_rsa|id_ed25519)$/,  /secrets?\.(json|yaml|yml)$/i,]export function isSensitivePath(path: string): boolean {  return SENSITIVE_PATTERNS.some((p) => p.test(path))}

这里把风险分成两类:

  • block:无论如何都不应该执行,例如格式化磁盘、删除根目录。
  • confirm:有合法用途但风险高,例如 rm -rfsudogit push --force

这种设计比简单地全拦或全放更实用。Code Agent 的目标是帮用户做事,不能因为安全而完全失去执行能力;但高风险操作必须让用户明确知情。

同一个文件里还有敏感路径检测。路径安全同样重要,因为文件工具和命令工具都可能被诱导去访问工作目录外的敏感文件。

工具输出截断

Agent 的上下文不是无限的。工具输出是最容易把上下文撑爆的来源之一。

例如模型执行:

bash
find . -type f

如果项目里有 node_modules,输出可能非常长。再比如读取一个几万行日志文件,整个上下文窗口可能瞬间被填满。

所以工具层必须做输出截断:

Step 11 · 09.ts

ts
const MAX_TOOL_OUTPUT = 8_000export function truncateOutput(toolName: string, output: string): string {  if (output.length <= MAX_TOOL_OUTPUT) return output  const truncated = output.slice(0, MAX_TOOL_OUTPUT)  const hint = [    '',    `<system_hint type="tool_output_omitted" tool="${toolName}" reason="too_long"`,    `             actual_chars="${output.length}" max_chars="${MAX_TOOL_OUTPUT}">`,    `  工具输出过长,已自动截断。如需完整内容,请用 offset/limit 参数分段调用。`,    `</system_hint>`,  ].join('\n')  return truncated + hint}

关键点不是单纯截断,而是告诉模型“这里被截断了”。

如果你只是把输出裁成前 8000 字符,模型会误以为这就是完整结果。更好的做法是追加一个结构化提示:

xml
<system_hint type="tool_output_truncated">...</system_hint>

这样模型知道它看到的不是全量内容,后续可以改用更精确的命令、分页读取文件、增加过滤条件,而不是基于不完整信息做判断。

核心 Loop:让 SDK 接管状态机

现在看 Agent Loop:

Step 12 · 10.ts

ts
export async function agentLoop(  question: string,  history: CoreMessage[],  runtimeHints: string[] = [],): Promise<{  text: string  responseMessages: CoreMessage[]  usage: LanguageModelUsage  stepCount: number}> {  const system = await assembleSystemPrompt(runtimeHints)  const messages: CoreMessage[] = [    ...history,    { role: 'user', content: question },  ]  const result = await generateText({    model,    system,    messages,    tools: TOOLS,    maxSteps: 50,    onStepFinish(step) {      const { toolCalls, finishReason } = step      const isFinalStep = finishReason === 'stop' && toolCalls.length === 0      if (!isFinalStep) {        printStep(step)      }    },  })  const stepCount = result.steps.length  if (stepCount > 1) {    console.log(`\n\x1b[90m[共执行 ${stepCount} 步]\x1b[0m\n`)  }  return {    text: result.text,    responseMessages: result.response.messages as CoreMessage[],    usage: result.usage,    stepCount,  }}let stepCounter = 0function printStep({  text,  toolCalls,}: {  text: string  toolCalls: Array<{ toolName: string; args: unknown }>}) {  console.log(`\n── Step ${++stepCounter} ──`)  if (text.trim()) {    console.log(text.trim())  }  for (const call of toolCalls) {    console.log(`${call.toolName} ${JSON.stringify(call.args)}`)  }}export function resetStepCounter() {  stepCounter = 0}

和手写版相比,最大的变化是:这里没有手写 for 循环,也没有手动解析 <action>

generateText 会根据工具调用协议自动执行多步:

  1. 调用模型。
  2. 如果模型请求工具,SDK 校验参数。
  3. 执行对应工具。
  4. 把工具结果放回模型上下文。
  5. 再次调用模型。
  6. 直到模型输出最终文本或达到 maxSteps

maxSteps: 50 仍然很重要。SDK 能帮你循环,但不能替你决定什么叫“无限循环”。Agent 系统必须始终有边界。

onStepFinish 用来观察中间过程。对 Code Agent 来说,透明度非常重要。用户需要知道 Agent 正在读哪些文件、执行哪些命令、是否卡在某一步。

系统提示词:静态规则 + 动态状态

系统提示词不应该永远只是一个静态 Markdown 文件。真实 Agent 运行时经常需要注入动态信息,例如:

  • 当前工作目录;
  • 用户偏好;
  • 项目级规则;
  • 工具输出被截断的提示;
  • 压缩后的执行历史;
  • 上一次失败的原因。

这个项目用 assembleSystemPrompt 组装提示词:

Step 13 · 11.ts

ts
const PROMPT_FILE = '../SYSTEM_PROMPT.md'export async function assembleSystemPrompt(  runtimeHints: string[] = [],): Promise<string> {  const staticPrompt = await Bun.file(    new URL(PROMPT_FILE, import.meta.url),  ).text()  const segments = [staticPrompt]  if (runtimeHints.length > 0) {    segments.push('---\n# 运行时状态\n\n' + runtimeHints.join('\n\n'))  }  return segments.join('\n\n')}

它把系统提示词分成两段:

  • 静态段:从 SYSTEM_PROMPT.md 读取,包含 Agent 身份和长期行为规则。
  • 动态段:由运行时传入 runtimeHints,包含压缩摘要等当前状态。

这种分段方式比把所有内容硬写在一个字符串里更可维护。后续如果要接入类似 AGENTS.md、用户偏好、项目规则、Skills 索引,也可以继续作为新段落拼进去。

一个 Code Agent 的基础提示词至少应该包含这些规则:

  • 修改文件前先读文件;
  • 优先做最小改动;
  • 能局部替换就不要全量覆盖;
  • 工具调用后简要说明发现;
  • 不确定需求时询问用户;
  • 不要编造不存在的文件或命令结果。

这些规则看起来朴素,但能显著降低 Agent 乱改文件的概率。

上下文压缩

长任务会不断积累上下文。每一轮用户输入、模型输出、工具调用、工具结果都会进入历史。哪怕单次工具输出都被截断,长时间运行后也会逼近模型上下文上限。

解决思路是压缩历史:

Step 14 · 12.ts

ts
const MODEL_CONTEXT_LIMIT = 128_000const COMPRESS_THRESHOLD = 0.8export function shouldCompress(promptTokens: number): boolean {  return promptTokens > MODEL_CONTEXT_LIMIT * COMPRESS_THRESHOLD}export async function compressHistory(history: CoreMessage[]): Promise<string> {  const COMPRESS_SYSTEM = `你是一个 Agent 执行历史压缩器。将以下执行历史总结为结构化摘要,输出格式如下:<completed>已完成的具体操作(每行一条,保留关键细节)</completed><remaining>还未完成的任务或子任务</remaining><current_state>当前状态:已修改的文件路径、关键变量、环境状态等</current_state><notes>注意事项:踩过的坑、特殊处理、边界条件</notes>要求:信息密度高,去掉废话,保留所有对后续执行有用的细节。`.trim()  const historyText = history.map(formatMessage).join('\n\n---\n\n')  const { text } = await generateText({    model,    system: COMPRESS_SYSTEM,    prompt: historyText,    maxSteps: 1,  })  return text}export function buildCompressionHint(summary: string): string {  return [    '[执行历史摘要 - 之前会话已压缩]',    '',    summary,    '',    '注意:以上是对之前执行历史的摘要,你处于重建会话状态。',    '请基于摘要继续完成原始任务,不要重复已完成的操作。',  ].join('\n')}function formatMessage(message: CoreMessage): string {  const content =    typeof message.content === 'string'      ? message.content      : JSON.stringify(message.content)  return `[${message.role}]\n${content}`}

这个模块做两件事。

第一,用 shouldCompress 判断是否超过阈值。这里基于 SDK 返回的真实 promptTokens,当超过模型上下文窗口的 80% 时触发压缩。

第二,用 compressHistory 让模型把历史总结成结构化摘要。摘要重点不是“语言优美”,而是保留后续执行需要的信息:

  • 已经完成了什么;
  • 还剩什么没做;
  • 当前修改了哪些文件;
  • 有哪些坑和约束;
  • 哪些操作不要重复。

压缩完成后,旧 history 可以清空,摘要作为 runtime hint 注入下一轮系统提示词。这样 Agent 不需要携带完整历史,也能继续任务。

这里有一个工程取舍:压缩会损失细节,但不压缩会直接超上下文。实际项目里通常还会结合滑动窗口、重要消息保留、文件级索引等策略。本教程先实现最容易理解的一版。

CLI 入口:让 Agent 可以连续工作

最后把所有模块串成一个 CLI:

Step 15 · 13.ts

ts
const rl = readline.createInterface({  input: process.stdin,  output: process.stdout,})let history: CoreMessage[] = []let runtimeHints: string[] = []function prompt() {  rl.question('\n\x1b[34m> \x1b[0m', async (input) => {    const question = input.trim()    if (question === '/exit' || question === '/quit') {      console.log('再见!')      rl.close()      return    }    if (question === '/reset') {      history = []      runtimeHints = []      console.log('\x1b[90m[会话已重置]\x1b[0m')      prompt()      return    }    if (question === '/help') {      printHelp()      prompt()      return    }    if (!question) {      prompt()      return    }    resetStepCounter()    try {      await runTurn(question)    } catch (e) {      console.error(`\n\x1b[31m[错误] ${(e as Error).message}\x1b[0m`)    }    prompt()  })}async function runTurn(question: string) {  const { text, responseMessages, usage, stepCount } = await agentLoop(    question,    history,    runtimeHints,  )  history.push({ role: 'user', content: question }, ...responseMessages)  if (stepCount > 1) console.log('\n── 最终回答 ──')  console.log(text)  if (!shouldCompress(usage.promptTokens)) return  console.log('\n[上下文接近上限,正在压缩...]')  const summary = await compressHistory(history)  history = []  runtimeHints = [buildCompressionHint(summary)]  console.log('[上下文已压缩,下次对话继续]')}function printHelp() {  console.log(`\x1b[1mmini-claude-code\x1b[0m — 教学用 Code Agent\x1b[1m可用命令:\x1b[0m  /reset   清空当前会话历史,重新开始  /exit    退出  /help    显示此帮助\x1b[1m可用工具:\x1b[0m  read_file   读取文件  write_file  写入文件  edit_file   局部编辑文件  bash        执行 Shell 命令  web_fetch   抓取网页内容`)}console.log(  `\x1b[1mmini-claude-code\x1b[0m \x1b[90mv0.1.0 — 输入 /help 查看帮助\x1b[0m\n`,)prompt()

入口逻辑做了几件事:

  • 读取用户输入;
  • 调用 agentLoop
  • 打印最终回答;
  • 保存本轮 responseMessages 到 history;
  • 根据 token 使用量决定是否压缩;
  • 支持 /reset/exit/help 等基础命令。

historyruntimeHints 在多轮之间持久存在,这是它能像一个真正助手一样连续工作的基础。

整个运行链路可以概括为:

text
用户输入index.ts CLI 循环agent/loop.ts 调用 generateTextagent/prompt.ts 组装系统提示词tools/index.ts 提供工具注册表具体工具执行文件操作 / Shell / WebFetch工具结果回填给模型模型继续下一步或输出最终回答agent/context.ts 必要时压缩历史

第六章:从玩具到 Code Agent,中间多了什么?

现在回头看两个版本的差异。

agent-loop 手写版mini-claude-code SDK 版
模型调用手写 fetchgenerateText()
工具协议XML + 正则解析SDK tool calling
参数校验字符串 + 手动解析Zod schema
循环状态机手写 for 循环maxSteps 自动处理
工具结果回填手动 history.pushSDK 自动回填
文件操作read/write/edit
Shell 执行bash 工具 + 危险命令防护
上下文保护工具输出截断 + 历史压缩
多轮对话单次问题CLI history + runtime hints
适合目的理解原理学习工程化 Agent 结构

最重要的结论是:mini-claude-code 并没有改变 Agent 的基本原理。它只是把原理放进了更可靠的工程结构里。

手写版里的这些动作:

text
parse action -> execute tool -> push observation -> next loop

在 SDK 版里仍然存在,只是由 SDK 和工具系统接管了大部分细节。

Code Agent 的三个工程重点

如果你继续扩展这个项目,优先关注三个方向。

第一,工具边界。 工具越强,越要定义清楚输入、输出、权限和失败行为。不要让模型靠猜使用工具。

第二,上下文工程。 不要等上下文爆了才处理。文件读取、搜索结果、命令输出都应该有分页、过滤、截断和明确提示。

第三,安全策略。 只要涉及文件和命令执行,就必须考虑路径限制、危险命令、用户确认、超时、资源回收。

这些问题不是模型能力问题,而是软件工程问题。一个 Code Agent 是否可靠,很大程度取决于这些边界是否设计得足够清楚。


收尾

我们从一个天气查询 Agent 开始,手写了最小 ReAct 循环;然后把同样的思想迁移到 Code Agent,使用 Vercel AI SDK 实现了工具注册、自动工具调用、多轮上下文、安全防护和压缩机制。

这条学习路径的重点不是“记住某个框架 API”,而是理解 Agent 的运行骨架:

text
模型负责决策工具负责执行上下文负责记忆循环负责推进安全边界负责兜底

如果你刚开始学习 Agent 开发,建议先跑通 agent-loop,确认自己理解每一轮消息是如何流动的;再看 mini-claude-code,理解一个能处理真实代码任务的 Agent 需要补哪些工程能力。

核心概念不复杂,几十行代码就能跑起来。但从“能跑”到“能可靠地解决真实问题”,中间每一步都是工程设计。