type Role = 'system' | 'user' | 'assistant'
type ChatMessage = {
role: Role
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 的文本回复。

type DeepSeekMessage = { content?: string }
type DeepSeekChoice = { message?: DeepSeekMessage }
type DeepSeekResponse = { choices?: DeepSeekChoice[] }
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 DeepSeekResponse
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>

然后用正则解析:

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 DeepSeekResponse
const content = data.choices?.[0]?.message?.content
if (typeof content !== 'string') {
throw new Error('返回内容为空')
}
return content
}
function parseAssistant(content: string): ParsedAssistant {
const actionMatch = content.match(
/<action[^>]*tool="([^"]+)"[^>]*>([\s\S]*?)<\/action>/i,
)
const finalMatch = content.match(/<final>([\s\S]*?)<\/final>/i)
const parsed: ParsedAssistant = {}
if (actionMatch) {
parsed.action = {
tool: actionMatch[1],
input: actionMatch[2]?.trim() ?? '',
}
}
if (finalMatch) {
parsed.final = finalMatch[1]?.trim()
}
return parsed
}

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

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

搭出循环骨架

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

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 DeepSeekResponse
const content = data.choices?.[0]?.message?.content
if (typeof content !== 'string') {
throw new Error('返回内容为空')
}
return content
}
function parseAssistant(content: string): ParsedAssistant {
const actionMatch = content.match(
/<action[^>]*tool="([^"]+)"[^>]*>([\s\S]*?)<\/action>/i,
)
const finalMatch = content.match(/<final>([\s\S]*?)<\/final>/i)
const parsed: ParsedAssistant = {}
if (actionMatch) {
parsed.action = {
tool: actionMatch[1],
input: actionMatch[2]?.trim() ?? '',
}
}
if (finalMatch) {
parsed.final = finalMatch[1]?.trim()
}
return parsed
}
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 里:

export type ToolName = 'getWeather' | 'getTime'
export type ToolFn = (input: string) => Promise<string>
type WeatherInput = { error: string } | { city: string; time: string }
function parseWeatherInput(input: string): WeatherInput {
try {
const parsed = JSON.parse(input)
const city = parsed?.city
const time = parsed?.time
if (!city || typeof city !== 'string') {
return { error: 'getWeather 需要 city 字符串' }
}
if (!time || typeof time !== 'string') {
return { error: 'getWeather 需要 time 字符串' }
}
return { city: city.trim(), time: time.trim() }
} catch {
return {
error:
'getWeather 参数需为 JSON,如 {"city":"上海","time":"2026-02-27 10:00"}',
}
}
}
function buildMockWeather(city: string, time: string): string {
const conditions = ['晴', '多云', '阴', '小雨', '阵雨']
const winds = ['东北风 2 级', '东风 3 级', '西南风 2 级', '北风 1 级']
const seed = Array.from(`${city}|${time}`).reduce(
(acc, ch) => acc + ch.charCodeAt(0),
0,
)
const condition = conditions[seed % conditions.length] ?? '晴'
const wind = winds[seed % winds.length] ?? '微风 1 级'
const temp = 12 + (seed % 20)
const humidity = 35 + (seed % 55)
return `天气信息:${city} 在 ${time} 的天气为${condition},气温 ${temp}°C,${wind},湿度 ${humidity}%。`
}
export const TOOLKIT: Record<ToolName, ToolFn> = {
async getTime() {
return new Date().toISOString()
},
async getWeather(rawInput: string) {
const parsed = parseWeatherInput(rawInput.trim())
if ('error' in parsed) return parsed.error
return buildMockWeather(parsed.city, parsed.time)
},
}

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

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

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

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

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

接入工具执行

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

type Role = 'system' | 'user' | 'assistant'
type ChatMessage = {
role: Role
content: string
}
type ParsedAssistant = {
action?: { tool: string; input: string }
final?: string
}
type DeepSeekMessage = { content?: string }
type DeepSeekChoice = { message?: DeepSeekMessage }
type DeepSeekResponse = { choices?: DeepSeekChoice[] }
// 示例里省略 import,默认 TOOLKIT / ToolName 来自 tools.ts
type ToolName = 'getWeather' | 'getTime'
declare const TOOLKIT: Record<ToolName, (input: string) => Promise<string>>
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 DeepSeekResponse
const content = data.choices?.[0]?.message?.content
if (typeof content !== 'string') {
throw new Error('返回内容为空')
}
return content
}
function parseAssistant(content: string): ParsedAssistant {
const actionMatch = content.match(
/<action[^>]*tool="([^"]+)"[^>]*>([\s\S]*?)<\/action>/i,
)
const finalMatch = content.match(/<final>([\s\S]*?)<\/final>/i)
const parsed: ParsedAssistant = {}
if (actionMatch) {
parsed.action = {
tool: actionMatch[1],
input: actionMatch[2]?.trim() ?? '',
}
}
if (finalMatch) {
parsed.final = finalMatch[1]?.trim()
}
return parsed
}
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
}
// 新增:模型触发 action 时,执行工具并回填 observation
if (parsed.action) {
const toolFn = TOOLKIT[parsed.action.tool as ToolName]
let observation: string
if (toolFn) {
observation = await toolFn(parsed.action.input)
} else {
observation = `未知工具: ${parsed.action.tool}`
}
console.log(`<observation>${observation}</observation>\n`)
history.push({
role: 'user',
content: `<observation>${observation}</observation>`,
})
continue
}
break
}
return '未能生成最终回答,请重试或调整问题。'
}
// 新增:CLI 入口
async function main() {
const userQuestion = process.argv.slice(2).join(' ') || '上海现在天气如何?'
console.log(`用户问题: ${userQuestion}`)
try {
const answer = await AgentLoop(userQuestion)
console.log('\n=== 最终回答 ===')
console.log(answer)
} catch (err) {
console.error(`运行失败: ${(err as Error).message}`)
}
}
await main()

完整闭环出现了:

  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 配置

先配置模型:

if (!process.env.QINIU_API_KEY) {
throw new Error('缺少环境变量 QINIU_API_KEY,请参考 .env.example 配置')
}
// 示例省略 import:默认 createOpenAI 已可用
// 七牛大模型推理服务,兼容 OpenAI 协议
const qiniu = createOpenAI({
apiKey: process.env.QINIU_API_KEY,
baseURL: 'https://api.qnaigc.com/v1',
// 兼容模式:不发送 OpenAI 专属字段,避免第三方接口报错
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。

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

接下来注册工具:

// 示例省略 import:默认 tool/z/readFile/writeFile/editFile/bash/webFetch 已可用
export const TOOLS = {
read_file: tool({
description:
'读取本地文件内容。大文件建议用 offset + limit 分段读取,避免一次性读取撑爆上下文。输出带行号,方便定位。',
parameters: z.object({
path: z.string().describe('文件路径(相对于当前工作目录)'),
offset: z
.number()
.optional()
.describe('从第几行开始读(0-indexed,默认从头)'),
limit: z
.number()
.optional()
.describe('最多读取多少行(默认读到文件末尾)'),
}),
execute: readFile,
}),
write_file: tool({
description:
'将内容写入文件。文件不存在则创建,已存在则完整覆盖。局部修改优先用 edit_file。',
parameters: z.object({
path: z.string().describe('文件路径(相对于当前工作目录)'),
content: z.string().describe('要写入的完整文件内容'),
}),
execute: writeFile,
}),
edit_file: tool({
description:
'替换文件中的特定字符串。old_string 必须在文件中唯一存在(仅出现一次),否则会报错。建议先用 read_file 确认目标字符串。',
parameters: z.object({
path: z.string().describe('文件路径(相对于当前工作目录)'),
old_string: z.string().describe('要被替换的原始字符串,必须唯一'),
new_string: z.string().describe('替换后的新字符串'),
}),
execute: editFile,
}),
bash: tool({
description:
'执行 Shell 命令。危险命令(如 rm -rf)会暂停并等待用户确认。命令输出超长时自动截断。',
parameters: z.object({
command: z.string().describe('要执行的 Shell 命令'),
timeout: z.number().optional().describe('超时时间(毫秒),默认 30000'),
}),
execute: bash,
}),
web_fetch: tool({
description:
'抓取网页内容并转换为 Markdown 格式返回。适合查阅文档、README、API 参考。',
parameters: z.object({
url: z.string().describe('要抓取的完整 URL(包含 https://)'),
}),
execute: webFetch,
}),
}

每个工具都包含三部分:

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

工具描述不是普通注释,它是模型决策的一部分。比如 edit_file 的描述里写了:

text
old_string 必须在文件中唯一存在(仅出现一次),否则会报错。建议先用 read_file 确认目标字符串。

这条约束会直接影响模型行为。一个好的 Code Agent,很多稳定性不是来自“模型更聪明”,而是来自工具描述和系统提示词把边界说清楚。

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

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

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

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

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

interface Params {
command: string
timeout?: number
}
// 示例省略 import:默认 detectDanger / confirmFromUser / truncateOutput 已可用
export async function bash({
command,
timeout = 30_000,
}: Params): Promise<string> {
// 危险命令检测:block 直接拒绝,confirm 等用户确认
const danger = detectDanger(command)
if (danger === 'block') {
return `拒绝执行:该命令已被自动阻止(高风险操作)。\n命令:${command}`
}
if (danger === 'confirm') {
const approved = await confirmFromUser(command)
if (!approved) {
return `用户拒绝执行命令:${command}`
}
}
// 执行命令
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeout)
let stdout = ''
let stderr = ''
let exitCode = 0
try {
const proc = Bun.spawn(['sh', '-c', command], {
stdout: 'pipe',
stderr: 'pipe',
})
controller.signal.addEventListener('abort', () => proc.kill())
;[stdout, stderr] = await Promise.all([
new Response(proc.stdout).text(),
new Response(proc.stderr).text(),
])
exitCode = await proc.exited
} catch (e) {
return `执行失败:${(e as Error).message}`
} finally {
clearTimeout(timer)
}
// 整合输出
const parts: string[] = []
if (stdout) parts.push(stdout)
if (stderr) parts.push(`[stderr]\n${stderr}`)
if (exitCode !== 0) parts.push(`[exit code: ${exitCode}]`)
const output = parts.join('\n').trim() || '(无输出)'
return truncateOutput('bash', output)
}

bash 工具做了几件事:

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

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

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

export type DangerLevel = 'safe' | 'confirm' | 'block'
// 示例省略 import:这里用一个占位函数表示 path.resolve(cwd, inputPath)
declare function resolvePath(cwd: string, inputPath: string): string
// block 级:直接拒绝,没有合法的 Agent 使用场景
const BLOCK_PATTERNS: RegExp[] = [
/rm\s+-\S*r\S*f\s+(\/|~|\$HOME)\b/, // rm -rf / 或 rm -rf ~
/dd\s+if=.*of=\/dev\//, // dd 写入磁盘设备
/mkfs\./, // 格式化文件系统
/>\s*\/dev\/(sda|hda|nvme)/, // 重定向写入磁盘
/shutdown|reboot|halt/, // 系统关机重启
]
// confirm 级:暂停并等待用户明确确认
const CONFIRM_PATTERNS: RegExp[] = [
/rm\s+-\S*[rf]/, // rm -r 或 rm -f 类
/sudo\s+/, // sudo 命令
/curl\s+.*\|\s*(sh|bash|zsh)/, // curl pipe to shell
/wget\s+.*\|\s*(sh|bash|zsh)/, // wget pipe to shell
/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(\.|$)/, // .env 文件
/\.aws\/credentials/, // AWS 凭证
/\.ssh\/(id_rsa|id_ed25519)$/, // SSH 私钥
/secrets?\.(json|yaml|yml)$/i, // secrets 文件
]
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,输出可能非常长。再比如读取一个几万行日志文件,整个上下文窗口可能瞬间被填满。

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

// 工具输出截断保护
// 单次工具返回超过此长度时截断,并附加 system_hint 告知 LLM
const MAX_TOOL_OUTPUT = 8_000
export function truncateOutput(toolName: string, output: string): string {
if (output.length <= MAX_TOOL_OUTPUT) return output
const truncated = output.slice(0, MAX_TOOL_OUTPUT)
// 用结构化的 system_hint 告知 LLM 内容被截断,而非直接截断
// 这样 LLM 不会误以为"内容就这么多",而是知道还有更多内容
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:

// 示例省略 import:默认 generateText / CoreMessage / model / TOOLS 已可用
export interface RunResult {
text: string
responseMessages: CoreMessage[]
usage: LanguageModelUsage
stepCount: number
}
export async function agentLoop(
question: string,
history: CoreMessage[],
runtimeHints: string[] = [],
): Promise<RunResult> {
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: ({ text, toolCalls, finishReason }) => {
const isFinalStep = finishReason === 'stop' && toolCalls.length === 0
if (!isFinalStep) {
printStep({ text, toolCalls, finishReason })
}
},
})
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,
}
}
interface StepInfo {
text: string
toolCalls: Array<{ toolName: string; args: unknown }>
}
let stepCounter = 0
function printStep({ text, toolCalls }: StepInfo) {
stepCounter++
console.log(
`\n\x1b[36m── Step ${stepCounter} ──────────────────────────────────\x1b[0m`,
)
if (text.trim()) {
console.log(`\x1b[37m${text.trim()}\x1b[0m`)
}
for (const call of toolCalls) {
const argsOneLine = JSON.stringify(call.args)
const argsPreview =
argsOneLine.length > 120 ? argsOneLine.slice(0, 120) + '…}' : argsOneLine
console.log(
`\n\x1b[32m🔧 ${call.toolName}\x1b[0m \x1b[90m${argsPreview}\x1b[0m`,
)
}
}
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 组装提示词:

const PROMPT_FILE = '../SYSTEM_PROMPT.md'
export async function assembleSystemPrompt(
runtimeHints: string[] = [],
): Promise<string> {
const segments: string[] = []
// Segment 1: 静态指令
segments.push(await Bun.file(new URL(PROMPT_FILE, import.meta.url)).text())
// Segment 2: 运行时状态(有则注入)
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 乱改文件的概率。

上下文压缩

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

解决思路是压缩历史:

// 示例省略 import:默认 CoreMessage / generateText / model 已可用
const MODEL_CONTEXT_LIMIT = 128_000
const COMPRESS_THRESHOLD = 0.8
// 使用 SDK 返回的真实 promptTokens 判断是否需要压缩
export function shouldCompress(promptTokens: number): boolean {
return promptTokens > MODEL_CONTEXT_LIMIT * COMPRESS_THRESHOLD
}
// 将完整 history 压缩为结构化摘要
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((m) => {
const content =
typeof m.content === 'string' ? m.content : JSON.stringify(m.content)
return `[${m.role}]\n${content}`
})
.join('\n\n---\n\n')
const { text } = await generateText({
model,
system: COMPRESS_SYSTEM,
prompt: historyText,
maxSteps: 1,
})
return text
}
// 用压缩摘要重建运行时 hint,注入到下一轮系统提示词
export function buildCompressionHint(summary: string): string {
return [
'[执行历史摘要 - 之前会话已压缩]',
'',
summary,
'',
'注意:以上是对之前执行历史的摘要,你处于重建会话状态。',
'请基于摘要继续完成原始任务,不要重复已完成的操作。',
].join('\n')
}

这个模块做两件事。

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

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

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

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

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

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

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

// 示例省略 import:默认 readline / agentLoop / context helpers 已可用
// 并假设 CoreMessage 类型已在项目中定义
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
// 维护跨轮对话的消息历史(不含系统提示词,generateText 单独传 system)
let history: CoreMessage[] = []
// 运行时 hint 列表(如压缩摘要,下一轮会注入系统提示词)
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 {
const { text, responseMessages, usage, stepCount } = await agentLoop(
question,
history,
runtimeHints,
)
// 将本轮消息(含所有中间工具调用步骤)追加到 history
history.push({ role: 'user', content: question })
history.push(...responseMessages)
if (stepCount > 1) {
console.log(
`\n\x1b[36m── 最终回答 ─────────────────────────────────────\x1b[0m`,
)
}
console.log(text)
// 上下文压缩检查(基于 SDK 返回的真实 token 用量)
if (shouldCompress(usage.promptTokens)) {
console.log('\n\x1b[33m[上下文接近上限,正在压缩...]\x1b[0m')
try {
const summary = await compressHistory(history)
const hint = buildCompressionHint(summary)
history = []
runtimeHints = [hint]
console.log('\x1b[90m[上下文已压缩,下次对话继续]\x1b[0m')
} catch (e) {
console.warn(`\x1b[33m[压缩失败: ${(e as Error).message}]\x1b[0m`)
}
}
} catch (e) {
console.error(`\n\x1b[31m[错误] ${(e as Error).message}\x1b[0m`)
}
prompt()
})
}
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 需要补哪些工程能力。

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