Agent04_从零构建 Coding Agent · 核心骨架

目标:把前 3 单元学的机制,用 ~2000 行代码拼成一个真实可跑的 Coding Agent
主线教程:claude-code-from-scratch(本地已克隆,每章可跑、无需 API key)

agent的大致目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
04-构建CodingAgent核心/
├── package.json
├── cli.mjs
├── agent-loop.mjs # 学习用:逐步实验 Agent Loop
├── src/
│ ├── config.mjs # 配置
│ ├── client.mjs # OpenAI 兼容客户端
│ ├── agent.mjs # 正式 Agent Loop
│ ├── tools/
│ │ ├── index.mjs # 工具定义和分发器
│ │ ├── read-file.mjs
│ │ ├── list-files.mjs
│ │ ├── grep-search.mjs
│ │ ├── write-file.mjs
│ │ ├── edit-file.mjs
│ │ └── run-shell.mjs
│ ├── prompts/
│ │ ├── system-prompt.mjs
│ │ └── project-rules.mjs
│ ├── session/
│ │ ├── session-manager.mjs
│ │ └── session-store.mjs
│ └── utils/
│ ├── paths.mjs
│ └── truncate.mjs
└── sessions/
  • cli.mjs:只负责命令行输入和输出。
  • config.mjs:只负责读取环境变量。
  • client.mjs:只负责创建 OpenAI 客户端。
  • agent.mjs:只负责模型调用和工具循环。
  • tools/index.mjs:只负责工具注册和分发。
  • 每个工具文件:只负责一个工具。
  • system-prompt.mjs:只负责生成系统提示词。
  • session-store.mjs:只负责保存和读取文件。

Agent Loop — 核心循环(ch1)

本章目标

造出 coding agent 的心脏:一个循环,不停地「调模型 → 看它要不要用工具 → 用完把结果喂回去 → 再调模型」,直到模型说任务做完了。

起点只有一个十几行的循环,第一次跑它只会聊天、读不了文件;补上一小段工具回路,读文件这类活才走得通。走完这一步,再回头看真实 Claude Code 的循环,多出来的复杂都在解决什么就清楚了。

第一版:一个只会聊天的循环

把用户的话加进消息数组,调一次模型,把回复打印出来——就这些

1
2
3
4
5
6
7
8
9
10
11
12
13
async function chatOnce(messages, userMessage) {
messages.push({ role: "user", content: userMessage });

const response = await client.messages.create({
model: "claude-...",
max_tokens: 4096,
messages,
});

const text = response.content.find(b => b.type === "text")?.text ?? "";
console.log(text);
messages.push({ role: "assistant", content: response.content });
}

这就是我们在agent01分析过的chat的部分(一个简单的POST)+打印回复内容。我们再填一些有关API KEY和Base URL调用的东西,让他能成功跑起来(把agent_01的代码改改)

我这里用的是deepseek的api key,所以全部改成了OpenAI 兼容 SDK

SDK是什么

SDK 是 Software Development Kit(软件开发工具包)的缩写。

简单说,就是别人帮你写好的工具包,让你不需要从零开始写代码就能调用某个服务。

这里主要改动了两个地方

  1. client.messages.create改为client.chat.completions.create

这个改动是因为 API 提供商不同,SDK 和接口规范也不同。

client.messages.create是Anthropic 的 SDK(专门为 Claude 设计),需要改成OpenAI 兼容的 SDK

  1. <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">response.content.find(...)</font> 改为 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">response.choices[0]?.message?.content</font>

两种 API 的响应格式对比
Anthropic Claude API 的响应格式

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"id": "msg_123",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "这是Claude的回复内容"
}
],
"stop_reason": "end_turn",
"usage": { ... }
}
  • 回复在 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">content</font> 数组里
  • 每个元素有 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">type</font> <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">text</font> 字段
  • 所以要:<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">response.content.find(b => b.type === "text")?.text</font>

OpenAI 兼容 API 的响应格式(DeepSeek/GPT等)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"id": "chatcmpl-123",
"object": "chat.completion",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "这是DeepSeek的回复内容"
},
"finish_reason": "stop"
}
],
"usage": { ... }
}
  • 回复在 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">choices</font> 数组里
  • 每个元素有 <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">message</font> 对象
  • <font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">message.content</font> 直接是字符串
  • 所以要:<font style="color:rgb(15, 17, 21);background-color:rgb(235, 238, 242);">response.choices[0]?.message?.content</font>

数据结构差异的可视化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Anthropic 响应树:
response
└── content (数组)
└── [0]
├── type: "text"
└── text: "回复内容" ← 在这里

OpenAI 响应树:
response
└── choices (数组)
└── [0]
└── message
├── role: "assistant"
└── content: "回复内容" ← 在这里
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import readline from "node:readline/promises";
import OpenAI from "openai";

const BASE_URL = process.env.AGENT_BASE_URL ?? "https://api.deepseek.com/v1";
const API_KEY = process.env.AGENT_API_KEY;
const MODEL = process.env.AGENT_MODEL ?? "deepseek-chat";

if (!API_KEY) {
console.error("缺少 AGENT_API_KEY。任何 OpenAI 兼容的 key 都行(DeepSeek / Kimi / GLM / OpenRouter / 本地 Ollama)。");
process.exit(1);
}

// 初始化 OpenAI 客户端
const client = new OpenAI({
baseURL: BASE_URL,
apiKey: API_KEY,
});

async function chatOnce(messages, userMessage) {
// 1. 添加用户消息
messages.push({ role: "user", content: userMessage });

// 2. 调用模型(使用 OpenAI 兼容格式)
const response = await client.chat.completions.create({
model: MODEL,
max_tokens: 4096,
messages: messages,
});

// 3. 提取回复内容
const text = response.choices[0]?.message?.content ?? "";
console.log("\n🤖 Assistant:", text);

// 4. 将助手回复加入消息历史
messages.push({
role: "assistant",
content: text
});

return text;
}

// 测试运行:简单的命令行对话
async function main() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

const messages = [];
console.log("💬 开始对话(输入 'exit' 退出)\n");

while (true) {
const userInput = await rl.question("👤 You: ");
if (userInput.toLowerCase() === "exit") break;

await chatOnce(messages, userInput);
console.log(""); // 空行分隔
}

rl.close();
}

main().catch(console.error);

运行,发现现在确实只停留在chat模式,不能调用工具

给循环装上手:工具回路

要让模型能动手,只差两件事。一是在请求里带上工具清单,告诉它有哪些工具可调——第 1 章先只给一个 read_file,下一章再补齐写文件、跑命令等;二是当它回复里带着「我要调 read_file」时,我们真的去执行,把结果作为下一条消息喂回去,然后再调一次模型,让它接着往下走。

这两件事就是那个 while 循环的由来。把上面的 chatOnce 改成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
async function chat(messages, userMessage) {
messages.push({ role: "user", content: userMessage });

while (true) {
const response = await client.messages.create({
model: "claude-...",
max_tokens: 4096,
messages,
tools: toolDefinitions, // ← 就多这一行:把工具清单发过去,模型才知道有哪些工具能调
});
messages.push({ role: "assistant", content: response.content });

// 挑出模型这一轮想调用的工具
const toolUses = response.content.filter(b => b.type === "tool_use");
if (toolUses.length === 0) break; // 一个都没调 → 它认为任务完成了,退出

// 逐个执行,把结果收集起来
const toolResults = [];
for (const toolUse of toolUses) {
printToolCall(toolUse.name, toolUse.input);
const result = await executeTool(toolUse.name, toolUse.input);
printToolResult(toolUse.name, result);
toolResults.push({ type: "tool_result", tool_use_id: toolUse.id, content: result });
}

// 结果作为一条 user 消息喂回去,循环回到开头,模型接着往下想
messages.push({ role: "user", content: toolResults });
}
}

比第一版多的就两处:请求里多了 tools: toolDefinitions(让模型知道有哪些工具),外面套了个 while(工具跑完把结果喂回去、再问一轮)。刚才读不了文件的同一个问题,现在走得通了。

决定循环转不转的,从头到尾是模型,不是我们的代码。 我们没写任何「如果是读文件请求就……」的分支——是模型自己决定这一步要不要动手、动手之后够不够、要不要再来一轮。这一点就是 agent 和聊天机器人的分界线。

消息数组是怎么长大的

理解这个循环,关键在看懂消息数组每一轮怎么变长:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
第 1 轮:
messages = [
{ role: "user", content: "帮我修复 bug" }
{ role: "assistant", content: [text + tool_use(read_file)] }
{ role: "user", content: [tool_result("文件内容...")] }
]

第 2 轮(模型看到文件内容后决定编辑):
messages = [
...前 3 条,
{ role: "assistant", content: [text + tool_use(edit_file)] }
{ role: "user", content: [tool_result("编辑成功")] }
]

第 3 轮(模型认为任务完成):
messages = [
...前 5 条,
{ role: "assistant", content: [text("已修复!")] } ← 无 tool_use → break
]

带工具的那几轮,数组通常多两条:一条 assistant(模型要调的工具),一条 user(工具结果);最后收尾那轮模型不再调工具,只多一条 assistant 文本。模型每次都能看到从头到尾的完整历史,这就是它能「记得」自己之前做过什么的原因——所谓记忆,此刻不过是一个不断变长的数组。工具结果之所以用 role: "user" 装,是 Anthropic API 的协议要求,而且每条结果必须靠 tool_use_id 认回它对应的那次调用。

与OpenAI 兼容 API 的差异

这里与我们学习的OpenAI 兼容 API 有差异,在OpenAI 兼容 API中,tool调用的结果是作为role: “rool”写入message。而Anthropic API 的协议要求工具结果用 <font style="color:rgb(15, 17, 21);">role: "user"</font>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Anthropic 风格
results.push({
type: "tool_result", // ← 指明这是工具结果
tool_use_id: tu.id,
content: output
});
messages.push({
role: "user", // ← Anthropic 把工具结果放在 user 消息中
content: results
});

// OpenAI 格式
toolResults.push({
role: "tool", // ← 不是 "user"!
tool_call_id: "call_123",
content: "文件内容..."
});
messages.push(...toolResults);

为什么 Anthropic 这样设计?

  • 在 Anthropic 的视角中,工具执行后的结果本质上是一种”新的用户输入”(模型需要基于这个新信息继续思考)

收尾:让它能停下来

本章的最小版还没处理中断——按 Ctrl+C 让它中途优雅停下来,是第 4 章接上 CLI 时才补的事。真实 Claude Code 用一个 AbortController 贯穿整个循环:abort() 一调,signal 变 aborted,循环在下一个检查点退出,连正在飞的那次网络请求也一起取消;Python 侧没有 AbortController,用一个标志加取消当前 asyncio task 达到同样效果。

真实 Claude Code 比这多做了什么

上面那个循环,判断逻辑只有一条:有 tool_use 就继续,没有就停。真实的 Claude Code 处理的情况要多得多——把它的循环拆开看,正好照出一个玩具循环和一个生产级引擎之间隔着哪些东西。

下面这些结构(层次、模块名、大致行数)来自对公开版本的分析,Anthropic 官方文档能坐实的只是 tool_use / tool_result 这条工具回路本身;内部实现细节会随版本变化,具体的名字和行数看看趋势就好,别当精确事实。

它把一层循环拆成了两层。外层 QueryEngine(约 1155 行)管整个对话的生命周期:用户输入、USD 预算、Token 统计、会话恢复;内层 queryLoop(约 1728 行)只管一次查询怎么执行:消息压缩、API 调用、工具执行、错误恢复。这样拆是为了关注点分离——外层不必操心「PTL 错误怎么恢复」,内层不必操心「用户输入怎么解析」。

它的内层循环是个异步生成器(async function*)。选生成器而不是回调,图的是两点:一是背压,消费端没处理完,生产端就不往下生成,天然不会堆积事件;二是控制流是线性的,所有分支都用普通的 continue / break 表达,不用写状态机。

「继续循环」这件事,它分了七种情况。最小版只有一种(有 tool_use 就继续),它有七种:

# 名称 什么时候 怎么办
1 next_turn 模型调了工具 执行工具,结果推回,继续
2 collapse_drain_retry PTL 错误,有暂存的折叠操作 提交折叠腾空间,重试
3 reactive_compact_retry PTL 错误,折叠空间还不够 强制全量摘要压缩,重试
4 max_output_tokens_escalate 输出被截断,第一次 升到更高 Token 上限(16K→64K),重试
5 max_output_tokens_recovery 输出被截断,升级已用尽 注入续写提示,最多重试 3 次
6 stop_hook_blocking 任务做完但 Stop Hook 拦下了 接着执行循环
7 token_budget_continuation API 侧 Token 预算耗尽 继续生成

我们只实现第 1 种,其余六种都是各类错误和边界的恢复策略。

可恢复的错误,它先扣着不往上抛。输出被截断时,如果直接把错误 yield 给外层,界面就会跳报错——可内层循环后面的恢复逻辑其实能自己处理。所以它先「扣留」这个错误,跑一遍恢复;成功了用户完全无感,失败了才最终暴露。大多数 max_output_tokensprompt_too_long 就是这样被静默消化掉的。

它在流式响应还没结束时就开始执行工具。一个典型响应有 5 到 30 秒的流式窗口,Claude Code 用 StreamingToolExecutor 抓住这段时间:某个工具的参数 JSON 一旦拼完整,立刻开跑,不等整个响应收完。

1
2
3
4
5
6
7
串行(本章最小版,暂时串行):
[========= API 流式响应 =========][tool1][tool2][tool3]

并行(Claude Code):
[========= API 流式响应 =========]
↑ tool1 的 JSON 完成 → 立即执行
↑ tool2 的 JSON 完成 → 立即执行

这些都是「怎么把同一个循环做得又稳又快」的工程。地基却是同一个——上面那个最小循环,就是它们全部长在上面的地基。

思考题

  1. 为什么必须带上 tool_use_id

核心原因:把”调用请求”和”执行结果”配对

  • 模型可能会同时调用多个工具
  • 错误的工具调用需要被识别
  • 异步执行的顺序保证

第一阶段:先让聊天程序变成 Agent

拆分我们的CLI-demo.mjs

1. 创建 <font style="color:rgb(15, 17, 21);">src/config.mjs</font>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import path from "node:path";

export const config = {
baseURL: process.env.AGENT_BASE_URL ?? "https://api.deepseek.com/v1",
apiKey: process.env.AGENT_API_KEY,
model: process.env.AGENT_MODEL ?? "deepseek-chat",
projectRoot: path.resolve(
process.env.AGENT_PROJECT_ROOT ?? process.cwd()
),
};

if (!config.apiKey) {
throw new Error("缺少 AGENT_API_KEY");
}

2. 创建 src/client.mjs

创建一个连接大模型服务的客户端对象,后面的代码通过这个对象调用模型。

export const client = new OpenAI({

这一行创建了一个 client 对象。

它可以理解成:

client = 一个已经知道:

  • API 地址
  • API Key
  • 如何发送 OpenAI 格式请求
    的对象

后面调用模型时就可以写:

const response = await client.chat.completions.create({

1
2
3
4
5
6
7
import OpenAI from "openai";
import { config } from "./config.mjs";

export const client = new OpenAI({
baseURL: config.baseURL,
apiKey: config.apiKey,
});

3. 创建第一个工具 src/tools/read-file.mjs

一个工具三样东西:名字、给模型看的说明、干活的函数。

先来写名字和说明

OpenAI 兼容协议的工具定义格式是:

简单来记就是名字+描述+参数,每个都要写明type,description

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export const readFileDefinition = {
type: "function",
function: {
name: "read_file",
description: "读取项目中的文本文件。用户需要查看文件时使用。",
parameters: {
type: "object",
properties: {
file_path: {
type: "string",
description: "项目内文件路径,例如 README.md",
},
},
required: ["file_path"],
additionalProperties: false,
},
},
};

additionalProperties: false

表示:不允许模型传入工具定义之外的参数真正执行工具的函数:

1
2
3
4
5
6
7
import fs from "node:fs/promises";
import path from "node:path";

export async function readFileTool(input, context) {
const absolutePath = path.resolve(context.projectRoot, input.file_path);
return await fs.readFile(absolutePath, "utf8");
}

这里有两个概念:

1
2
readFileDefinition:告诉模型工具是什么
readFileTool:程序真正读取文件

模型不能直接访问电脑,模型只能返回工具调用请求。

4. 创建 src/tools/index.mjs

工具系统的核心实现

一个工具三样东西:名字、给模型看的说明、干活的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import {
readFileDefinition,
readFileTool,
} from "./read-file.mjs";

const toolEntries = [
{
definition: readFileDefinition,
execute: readFileTool,
},
];

//导出给模型用的工具列表(工具注册表)
//用 .map() 遍历 toolEntries,把每项的 definition 抽出来组成新数组。
//导出为 tools,这就是发给大模型看的工具清单(模型据此决定要不要调用、调哪个、传什么参数)。
export const tools = toolEntries.map((entry) => entry.definition);

//建立"名字 → 执行函数"的映射表
//创建一个 Map(键值对集合)。
//遍历每个工具条目,生成 [工具名, 执行函数] 这样的键值对。
//最终得到:"read_file" -> readFileTool
//用途:当模型说"我要调用 read_file"时,能快速通过名字找到对应的函数去执行。
const toolMap = new Map(
toolEntries.map((entry) => [
entry.definition.function.name,
entry.execute,
])
);

//执行工具的统一入口函数
export async function executeTool(toolCall, context) {
const name = toolCall.function.name;
const execute = toolMap.get(name);

if (!execute) {
return `Unknown tool: ${name}`;
}

let input;
try {
input = JSON.parse(toolCall.function.arguments);
} catch {
return "工具参数不是有效 JSON";
}

return await execute(input, context);
}

5. 创建正式的 src/agent.mjs

这是本项目的核心文件,对照我们之前学过的runTurn主循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import { client } from "./client.mjs";
import { config } from "./config.mjs";
import { tools, executeTool } from "./tools/index.mjs";

export async function runAgent(messages, userMessage) {
messages.push({
role: "user",
content: userMessage,
});

while (true) {
const response = await client.chat.completions.create({
model: config.model,
max_tokens: 4096,
messages,
tools,
});

const assistantMessage = response.choices[0]?.message;

if (!assistantMessage) {
throw new Error("模型没有返回有效消息");
}

// 必须保存完整消息,不能只保存 content
messages.push(assistantMessage);

// 没有工具调用,说明模型完成回答
if (!assistantMessage.tool_calls?.length) {
return assistantMessage.content ?? "";
}

// 一个 assistant 消息可能请求多个工具
for (const toolCall of assistantMessage.tool_calls) {
const toolName = toolCall.function.name;
const args = JSON.parse(toolCall.function.arguments); // ← 新增
console.log(`🔧 正在调用工具:${toolName}${args.file_path})`); // ← 替换原来的 log


let result;

try {
result = await executeTool(toolCall, {
projectRoot: config.projectRoot,
});
} catch (error) {
result = `工具执行失败:${error.message}`;
}

console.log(`✅ ${toolName} 执行完成(${result.length} 字符)`);
messages.push({
role: "tool",
tool_call_id: toolCall.id,
content: result,
});
}
}
}

Agent Loop 的完整过程:

1
2
3
4
5
6
7
8
9
添加user信息
调用模型
获取模型回复,保存完整assistant 消息
确认tool_calls
没有 tool_calls:返回最终文本
有 tool_calls:解析参数
调用tool
返回tool的message
回到第二步判断是否循环

6. 修改 cli.mjs

写一个入口

最简单的REPL

读取输入 → 执行处理 → 打印结果 → 再次读取输入

REPL 循环

1
读取用户 -> 调用 -> Agent -> 显示结果等待下一次用户输入
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import readline from "node:readline/promises";
import { runAgent } from "./src/agent.mjs";

async function main() {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

const messages = [];
console.log("Coding Agent 已启动(输入 exit 退出)\n");

try {
while (true) {
const input = (await rl.question("👤 You: ")).trim();

if (input.toLowerCase() === "exit") {
break;
}

if (!input) {
continue;
}

try {
const answer = await runAgent(messages, input);
console.log(`\n🤖 Assistant: ${answer}\n`);
} catch (error) {
console.error(`\n请求失败:${error.message}\n`);
}
}
} finally {
rl.close();
}
}

main().catch(console.error);

7. 第一阶段测试

运行:

1
node cli.mjs

第二阶段:按 02-tools.md 增加工具

这里我们使用每个工具单独一个文件的形式管理

先写文件相关的

这个报错证明我们在实际场景中path参数不是必需的

路径安全防护

1
2
3
4
5
6
7
8
9
10
11
12
import path from "node:path";

export function resolveProjectPath(projectRoot, userPath) {
const absolutePath = path.resolve(projectRoot, userPath);
const relativePath = path.relative(projectRoot, absolutePath);

if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
throw new Error("路径超出项目目录范围");
}

return absolutePath;
}

这段代码在干什么

relative 反推是否越界(读取相对路径)

1
const absolutePath = path.resolve(projectRoot, userPath);

第一步把用户路径归一化成绝对路径(上面两种姿势都会被“压平”成规范形式)。

1
const relativePath = path.relative(projectRoot, absolutePath);

第二步是核心技巧:path.relative 计算”从 projectRoot 走到 absolutePath 需要怎么走”。关键性质是——

如果目标就在项目目录内部,走法永远不需要“向上跳”(..);一旦需要 ..,说明目标在项目外。

真实场景下(设 projectRoot = E:\AI_Agent\04-构建CodingAgent核心\CodeAgent):

模型传的 file_path resolve 后 relative 结果 判定
src/agent.mjs E:\...\CodeAgent\src\agent.mjs src\agent.mjs ✅ 正常路径
../../secret.txt E:\AI_Agent\secret.txt ..\..\secret.txt ❌ 以 ..
开头
C:\Windows\system.ini C:\Windows\system.ini C:\Windows\system.ini ❌ 是绝对路径
1
2
3
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
throw new Error("路径超出项目目录范围");
}

第三步就是两个判定条件,正好对应表格里两种越界:

  • startsWith(".."):抓相对路径越狱。还要注意 path.relative 会把结果归一化,所以 .. 只会出现在开头,不会藏在中间。
  • path.isAbsolute(relativePath):抓绝对路径姿势。最有意思的是跨盘符的情况——从 E:\ 项目走到 C:\ 目标,relative 根本无法用相对走法表达(不在同一棵目录树下),它会放弃并直接返回目标的绝对路径,这个返回值一检查 isAbsolute 就现形了。Windows 上 \\server\share 这种网络路径同理。
1
return absolutePath;

只有活过检查的路径才会被返回,工具拿到的必然是项目内的合法绝对路径。

这个设计的巧妙之处:它不靠黑名单(“禁止 ..“、“禁止 C:\”这种枚举永远列不全,..\..\src\..\..\.. 各种变形防不胜防),而是把问题转化成一个几何判断——计算“走法”然后看走法里有没有向上跳。

安全问题

这种防护是否能够实现越狱

在单纯的目录穿越角度,仅用../../实现穿越肯定是不可以的了,但是这里没有做软链接防护

符号链接(symlink)能穿透它。如果项目里有个软链指向 C:\WindowsresolveProjectPath 算出来的路径在“字面上”还在项目内,检查通过,但实际读写会落到外面。生产级做法是检查前先 fs.realpath() 把真实路径解析出来再验证。

符号链接是什么

在我的理解里可以说成是文件指针

符号链接(symlink,也叫软链接)是文件系统里的一种特殊条目:它自己不存内容,只存一个“路径指向”。你可以把它理解成文件系统层面的“传送门”或者“快捷方式”。

具体是什么样
在目录列表里,符号链接看起来就是一个普通文件/文件夹,但它内部存的不是数据,而是另一个路径的字符串:

1
2
3
E:\proj
├── src\ ← 真实目录,里面真的有文件
├── evil → C:\Windows ← 符号链接:只是一个"指针",本体在别处

你对 evil 做的一切操作——列目录、读文件、写文件——操作系统都会自动转手给它指向的 C:\Windows。程序感觉不到任何区别,就像它真的是个本地文件夹。

最直观的体验:

1
2
3
4
5
6
7
8
9
10
在一个真实文件夹里放个文件
mkdir C:\real
echo hello > C:\real\a.txt
创建指向它的符号链接(需要管理员或开发者模式)
cmd /c mklink /D C:\link C:\real
现在通过链接也能访问同一个文件
type C:\link\a.txt # → hello(读的是 C:\real\a.txt)
echo world > C:\link\a.txt
type C:\real\a.txt # → world(写操作也穿透过去了,是同一个文件)
它和几个“长得像的东西”的区别

那么这就又体现出来ai安全问题发生的本质,他无法区分文本和指令

在ai安全防护中要同时注意两个点

  1. 不要完全信任用户输入
  2. 不要完全信任模型输入(确认的布尔值不能做成模型传的参数,模型为了完成任务会自己传入true)

第三阶段:System Prompt

System prompt 是什么

它是 messages 数组开头那条 role: "system" 的消息,是作为开发者唯一一次“编程”模型整个会话行为的机会。我自己的理解是给予开发的agent固定身份和环境的机会,让他只基于当前身份工作

编写System prompt 的六条核心原则

7 层递进结构

提示词从抽象到具体分为 7 层——先建立身份和约束框架,再填充具体行为指导。这个顺序很重要:模型先建立的概念会成为理解后续内容的框架。

1
2
3
4
5
6
7
1. Identity   → 我是谁?interactive agent
2. System → 运行环境的基本事实
3. Doing Tasks → 怎么写代码?(反模式接种)
4. Actions → 哪些操作需要确认?(爆炸半径框架)
5. Using Tools → 怎么用工具?(偏好映射表)
6. Tone & Style → 输出什么格式?
7. Output Efficiency → 怎么更简洁?

反模式接种

明确告诉模型”不要做什么”,比只描述”要做什么”有效得多。

正面指令(”be concise”)给模型留下了自我合理化的空间——它会认为”加注释是让代码更简洁易读的”,然后给每个函数加 docstring。而负面指令(”don’t add docstrings to code you didn’t change”)消除了解释余地。

Claude Code 的 Doing Tasks 部分有三条精确的”不要”:

  • 不要扩大范围:修 bug 不需要顺手重构周围代码
  • 不要防御性编程:不为不可能发生的场景加 try-catch 和校验
  • 不要过早抽象:”Three similar lines of code is better than a premature abstraction”

这些规则的价值不在概念(谁都知道”不要过度工程”),而在措辞的精确度——给了模型具体的判断标准,而非模糊的原则。

爆炸半径框架

Actions 部分没有罗列”不能做 X、Y、Z”,而是教给模型一个风险评估框架

1
Carefully consider the reversibility and blast radius of actions.

二维模型:可逆性 × 影响范围。高风险 = 不可逆 + 影响共享环境(force push、删除云资源);低风险 = 可逆 + 只影响本地(编辑本地文件)。

这比穷举规则扩展性强得多——模型遇到规则列表之外的新场景(比如调用 API 删除云资源)能自行推理,而不是不知道怎么做。

还有一条关键规则:用户批准一次操作,不等于批准所有类似操作。每次授权只对当前范围有效。

工具偏好映射表

Claude Code 在提示词中明确要求模型用专用工具而非 bash 命令:

1
2
3
4
Use Read instead of cat/head/tail
Use Edit instead of sed/awk
Use Glob instead of find/ls
Use Grep instead of grep/rg

专用工具和 bash 命令底层功能差不多,差异在用户体验:权限可以细粒度控制(读取 vs 写入分开授权)、输出结构化、原生支持并行调用。没有这张映射表,模型会默认用训练数据中出现最多的方式——即各种 bash 命令。

核心分块

静态核心和动态环境分开拼。 静态部分(身份、规则、工具偏好)跨会话逐字不变——这是前缀缓存能命中的前提(DeepSeek 的上下文缓存也是按前缀匹配的,静态放前面同样受益)。动态部分(项目路径、git 状态、日期、项目的 CLAUDE.md)因机器和项目而异,跟在后面。

调用工具失败解决

告诉模型“工具失败是正常流程”。edit_file 会拒绝匹配 0 次和多次的情况,这时模型正确的反应是重新 read_file 再改,而不是换个猜测的字符串硬试。这条不写,模型容易在错误里打转。

第四阶段:Session会话与CLI

seesion-store.mjs与session-manager.mjs

文件 负责什么 不负责什么
session-store.mjs 和磁盘文件打交道:保存、读取、查找会话 不负责 CLI 交互,不负责决定何时保存
session-manager.mjs 管理当前会话的 ID 和 messages 状态,并调用 store 完成保存/读取 不直接操作 fs,不负责模型调用

可以把它们理解成:

1
2
3
4
5
SessionManager
↓ 调用
Session Store
↓ 操作
sessions/<session-id>.json

添加了会话保存落盘和读取的功能

到保存session的地方可以看到落盘的记录message的文件


Agent04_从零构建 Coding Agent · 核心骨架
http://huang-d1.github.io/2026/09/11/04-从零构建 Coding Agent · 核心骨架/
作者
huangdi
发布于
2026年9月11日
许可协议