目标:把前 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 ├── src/ │ ├── config.mjs │ ├── client.mjs │ ├── agent.mjs │ ├── 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(软件开发工具包)的缩写。
简单说,就是别人帮你写好的工具包,让你不需要从零开始写代码就能调用某个服务。
这里主要改动了两个地方
- client.messages.create改为client.chat.completions.create
这个改动是因为 API 提供商不同,SDK 和接口规范也不同。
client.messages.create是Anthropic 的 SDK(专门为 Claude 设计),需要改成OpenAI 兼容的 SDK
- 从
<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); }
const client = new OpenAI({ baseURL: BASE_URL, apiKey: API_KEY, });
async function chatOnce(messages, userMessage) { messages.push({ role: "user", content: userMessage });
const response = await client.chat.completions.create({ model: MODEL, max_tokens: 4096, messages: messages, });
const text = response.choices[0]?.message?.content ?? ""; console.log("\n🤖 Assistant:", text); 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 }); }
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
| results.push({ type: "tool_result", tool_use_id: tu.id, content: output }); messages.push({ role: "user", content: results });
toolResults.push({ role: "tool", 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_tokens 和 prompt_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 完成 → 立即执行
|
这些都是「怎么把同一个循环做得又稳又快」的工程。地基却是同一个——上面那个最小循环,就是它们全部长在上面的地基。
思考题
- 为什么必须带上
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, });
|
一个工具三样东西:名字、给模型看的说明、干活的函数。
先来写名字和说明
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:程序真正读取文件
|
模型不能直接访问电脑,模型只能返回工具调用请求。
工具系统的核心实现
一个工具三样东西:名字、给模型看的说明、干活的函数。
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, }, ];
export const tools = toolEntries.map((entry) => entry.definition);
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("模型没有返回有效消息"); }
messages.push(assistantMessage);
if (!assistantMessage.tool_calls?.length) { return assistantMessage.content ?? ""; }
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})`);
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. 第一阶段测试
运行:

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

这个报错证明我们在实际场景中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 这种网络路径同理。
只有活过检查的路径才会被返回,工具拿到的必然是项目内的合法绝对路径。
这个设计的巧妙之处:它不靠黑名单(“禁止 ..“、“禁止 C:\”这种枚举永远列不全,..\..\、src\..\..\.. 各种变形防不胜防),而是把问题转化成一个几何判断——计算“走法”然后看走法里有没有向上跳。
安全问题
这种防护是否能够实现越狱
在单纯的目录穿越角度,仅用../../实现穿越肯定是不可以的了,但是这里没有做软链接防护
符号链接(symlink)能穿透它。如果项目里有个软链指向 C:\Windows,resolveProjectPath 算出来的路径在“字面上”还在项目内,检查通过,但实际读写会落到外面。生产级做法是检查前先 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 echo world > C:\link\a.txt type C:\real\a.txt 它和几个“长得像的东西”的区别
|
那么这就又体现出来ai安全问题发生的本质,他无法区分文本和指令
在ai安全防护中要同时注意两个点
- 不要完全信任用户输入
- 不要完全信任模型输入(确认的布尔值不能做成模型传的参数,模型为了完成任务会自己传入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的文件
