Agent CLI深水区 - 编辑器Diff与补丁引擎 · AI Agent
Agent CLI 深水区 07:编辑器、Diff 与补丁引擎
为什么编辑不是简单 writeFile
让 Agent 改代码,最危险的不是“不会写”,而是:
- 覆盖用户未保存内容。
- 替换错位置。
- 破坏编码和换行。
- 大文件整体重写导致 diff 混乱。
- 写入后没有验证。
所以成熟 Agent CLI 通常不会只有 write_file,还会有更精细的编辑工具。
三种编辑方式
| 方式 | 说明 | 适合 |
|---|---|---|
| full write | 整个文件重写 | 新文件、小文件 |
| string replace | 用 old/new 替换 | 精确小改 |
| patch apply | 统一 diff / patch | 多处修改 |
入门最容易实现的是 string replace。
EditInput
代码块收起展开
// EditInput = 模型调用 edit_file 时需要传入的参数。
// 这个工具只适合“小范围精确替换”,不适合整文件重写。
type EditInput = {
path: string; // 要编辑的文件路径,必须解析到 workspace 内。
oldText: string; // 当前文件里必须精确存在的旧文本,相当于编辑锚点。
newText: string; // 要替换成的新文本。
replaceAll?: boolean;// 是否替换所有命中。默认 false,避免误改多个位置。
};为什么要 oldText?
因为它是防误改的锚点。Agent 必须证明自己知道要替换哪段。
editFile 实现
代码块收起展开
// editFile = 精确字符串替换版编辑工具。
// 职责:校验路径 -> 读取文件 -> 校验 oldText -> 生成 diff -> 请求批准 -> 写入。
export async function editFile(input: EditInput, ctx: ToolContext): Promise<ToolResult> {
// 把用户/模型给的相对路径解析成绝对路径,并限制在工作区内。
// 这一步防止 ../../ 逃逸到 workspace 外。
const abs = resolveWorkspacePath(ctx.cwd, input.path);
// 读取当前文件内容。注意真实系统还要检测编码、BOM、二进制文件。
const original = await readFile(abs, "utf8");
// 统计 oldText 出现次数。编辑工具的第一条安全线就是“锚点必须明确”。
const count = countOccurrences(original, input.oldText);
// 找不到 oldText,说明模型上下文过期或路径错了,必须拒绝。
if (count === 0) {
throw new AgentError("TOOL_INPUT_INVALID", "oldText not found.", {
path: input.path
});
}
// oldText 出现多次时默认拒绝,因为模型可能只想改其中一处。
// 除非模型显式传 replaceAll=true,才允许全部替换。
if (count > 1 && !input.replaceAll) {
throw new AgentError("TOOL_INPUT_INVALID", "oldText appears multiple times.", {
path: input.path,
count
});
}
// 在内存里生成新内容,还没有落盘。
const updated = input.replaceAll
? original.split(input.oldText).join(input.newText)
: original.replace(input.oldText, input.newText);
// diff 必须由程序根据 old/new 生成,不能让模型自己描述“我改了什么”。
const diff = createUnifiedDiff(original, updated, input.path);
// 写入前让用户/权限系统看到真实 diff。
await requireWriteApproval({
path: input.path,
diff
});
// 最后才写文件。真实系统建议用 atomic write,而不是普通 writeFile。
await writeFile(abs, updated, "utf8");
// 工具返回 diff 和替换次数,模型下一步可以据此决定是否跑测试。
return {
summary: `Edited ${input.path}`,
content: diff,
metadata: {
replacements: input.replaceAll ? count : 1
}
};
}这段代码的保护点:
- 找不到 oldText 就拒绝。
- oldText 多次出现就拒绝,除非明确 replaceAll。
- 写入前生成 diff。
- 写入前要求批准。
- 返回 diff 给模型和用户。
为什么不能盲目 replace
坏写法:
代码块收起展开
content.replace("config", "settings")问题:
- 可能替换注释。
- 可能替换字符串。
- 可能替换多个无关位置。
- 模型以为改了一处,实际改了多处。
所以 Agent 编辑工具必须要求精确上下文。
Unified Diff
diff 的意义是让用户和 Agent 都看到“改了什么”。
简化实现:
代码块收起展开
// createUnifiedDiff = 把修改前/后的文本转换成统一 diff。
// 这里是教学版:真实项目建议用 diff 库处理复杂行变更。
function createUnifiedDiff(oldText: string, newText: string, path: string) {
// 按行比较。这里先假设输入已经处理好 CRLF/LF。
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
return [
// diff 头:a/ 表示旧文件,b/ 表示新文件。
`--- a/${path}`,
`+++ b/${path}`,
// simpleLineDiff 负责产出以 +、-、空格开头的变更行。
...simpleLineDiff(oldLines, newLines)
].join("\n");
}真实项目建议用成熟 diff 库。自己手写 diff 容易在复杂场景下错。
写入前后都要检查
写入前:
- 路径安全。
- oldText 唯一。
- diff 可展示。
- 用户批准。
写入后:
- 文件能重新读取。
- 内容包含 newText。
- 可选:运行 formatter。
- 可选:运行测试。
代码块收起展开
// verifyEdit = 写入后的最小回读校验。
// 它不能证明语义正确,但能证明文件确实写进去了。
async function verifyEdit(abs: string, expected: string) {
// 重新从磁盘读取,而不是相信内存里的 updated。
const after = await readFile(abs, "utf8");
// 如果新内容没出现,说明写入失败、写错文件或被其他进程覆盖。
if (!after.includes(expected)) {
throw new AgentError("TOOL_EXECUTION_FAILED", "Edit verification failed.");
}
}编码和换行
Windows 项目常见 CRLF。不要随便把整文件改成 LF。
代码块收起展开
// detectLineEnding = 判断原文件主要使用哪种换行。
// Windows 项目常见 CRLF,Unix/macOS 项目常见 LF。
function detectLineEnding(text: string) {
return text.includes("\r\n") ? "\r\n" : "\n";
}
// preserveLineEnding = 把更新后的文本换行统一回原文件风格。
// 目的:避免因为一次小修改导致全文件换行都变了,diff 爆炸。
function preserveLineEnding(original: string, updated: string) {
const ending = detectLineEnding(original);
return updated.replace(/\r?\n/g, ending);
}写入前:
代码块收起展开
const updatedWithOriginalEnding = preserveLineEnding(original, updated);Patch Apply
更高级的编辑工具支持 patch:
代码块收起展开
// PatchInput = 更高级的补丁输入。
// 它不是简单 oldText/newText,而是多个 hunk 组成的结构化 diff。
type PatchInput = {
path: string; // 要应用 patch 的文件。
hunks: Array<{
oldStart: number; // patch 认为旧文件从第几行开始匹配。
oldLines: string[]; // 需要在旧文件中找到的上下文/删除行。
newLines: string[]; // 应用后要变成的新行。
}>;
};patch apply 的难点:
- 行号可能过期。
- 上下文可能变了。
- 多 hunk 之间会互相影响行号。
- 失败时要精确告诉哪一段失败。
Patch 失败如何恢复
当 patch 失败,不要让模型继续猜。
返回:
代码块收起展开
Patch failed at hunk 2.
Expected context:
...
Actual nearby content:
...
Please re-read the file before generating a new patch.这样模型下一轮会先读文件,而不是继续用过期上下文。
Edit Tool 的审计
代码块收起展开
// EditAudit = 编辑审计记录。
// 真实 Agent CLI 应该能回答:谁在什么时候改了哪个文件、改前改后是什么版本。
type EditAudit = {
sessionId: string; // 哪个会话发起的编辑。
path: string; // 被修改的文件。
editType: "replace" | "write" | "patch"; // 编辑方式:精确替换、整文件写入、patch。
oldHash: string; // 修改前文件 hash。
newHash: string; // 修改后文件 hash。
diff: string; // 真实 diff,用于审查和回放。
approvedByUser: boolean; // 是否经过用户/策略批准。
time: string; // 发生时间。
};为什么要 hash?
因为 diff 可能很长,hash 能快速确认文件版本。
模型如何学会正确编辑
工具描述要写清楚:
代码块收起展开
Use edit_file for small, precise replacements.
oldText must match the current file exactly.
If oldText is not unique, read the file again and include more surrounding context.
Do not use write_file for modifying existing large files unless replacing the whole file is intended.工具描述不是随便写,它会直接影响模型调用方式。
编辑系统常见坑
| 坑 | 后果 | 修复 |
|---|---|---|
| 整文件重写 | diff 巨大,易覆盖用户改动 | 小范围 edit |
| oldText 不唯一 | 改错位置 | 要求唯一或 replaceAll |
| 不看 diff | 用户无法审查 | 写前 diff preview |
| 不保留换行 | 全文件变更 | 检测 CRLF/LF |
| patch 失败继续猜 | 越改越乱 | 要求 re-read |
| 写完不验证 | 以为成功实际失败 | read back check |
读源码时看编辑引擎
重点找:
- 有哪些编辑工具。
- 是否区分 write/edit/patch。
- oldText 是否必须唯一。
- diff 在哪里生成。
- 用户批准发生在写前还是写后。
- 是否保留换行和编码。
- patch 失败如何反馈。
- 写入后是否验证。
编辑引擎决定 Agent 是“可靠协作”,还是“随机覆盖文件”。
从一次真实编辑看全链路
假设用户说:
把
src/config.ts里的默认模型从sonnet改成opus,顺便更新测试。
成熟 Agent CLI 不应该立刻写文件,而是走一条可审计链路:
这条链路的核心不是“模型会写代码”,而是每一步都有边界:
- 搜索负责定位,不让模型凭记忆猜路径。
- 读取负责建立当前事实,不让模型使用过期上下文。
- 编辑引擎负责把意图转换成可验证变更。
- 权限层负责在落盘前让人看见 diff。
- 测试负责证明改动没有破坏行为。
- Trace 负责让失败能回放。
文件状态模型
编辑系统至少要区分四种状态:
关键点:Loaded 到 Written 之间,文件可能被用户或另一个进程改掉。
所以编辑工具不能只依赖路径和 oldText,还应该记录读取时的 hash。
代码块收起展开
// LoadedFile = Agent 读到的文件快照。
// 重点是 hash:后续写入前要确认磁盘文件还是这个版本。
type LoadedFile = {
path: string; // 文件路径。
text: string; // 读取到的完整文本。
hash: string; // 读取时的内容 hash,用作乐观锁。
lineEnding: "\n" | "\r\n"; // 原始换行风格。
encoding: "utf8" | "utf8-bom" | "unknown"; // 原始编码信息,写回时要尽量保持。
};
// EditPlan = 尚未落盘的编辑计划。
// baseHash 表示“这个计划基于哪个文件版本生成”。
type EditPlan = {
path: string; // 要修改的文件。
baseHash: string; // 生成计划时文件的 hash。
operations: EditOperation[]; // 一个或多个编辑操作,例如 replace、insert、delete。
};写入前重新读一次:
代码块收起展开
// assertNotStale = 写入前的乐观锁检查。
// 如果用户或另一个工具在中途改了文件,就拒绝继续写。
async function assertNotStale(plan: EditPlan, abs: string) {
// 写入前重新读当前磁盘内容。
const current = await readFile(abs, "utf8");
// 计算当前版本 hash。
const currentHash = sha256(current);
// 如果当前 hash 和计划里的 baseHash 不同,说明上下文过期。
if (currentHash !== plan.baseHash) {
throw new AgentError("FILE_STALE", "File changed after it was read.", {
path: plan.path,
expectedHash: plan.baseHash,
actualHash: currentHash
});
}
}这就是乐观锁。它和数据库里的 version 字段是同一个思想:我可以修改,但必须证明我修改的是刚才读到的那个版本。
oldText 精确匹配为什么还不够
oldText 唯一匹配只能防一类错,防不了全部错。
| 风险 | oldText 能否发现 | 需要额外机制 |
|---|---|---|
| 找不到文本 | 能 | 返回失败并要求重读 |
| 同一文本多处出现 | 能 | 要求更多上下文 |
| 文件被用户改过 | 不一定 | baseHash / mtime 检查 |
| 换行符被全文件转换 | 不能 | 行尾检测与保持 |
| 编码从 UTF-8 BOM 变无 BOM | 不能 | 编码探测 |
| 目标是生成文件 | 不能 | generated file guard |
| 语义改错位置 | 不能 | AST / 测试 / 人审 |
| 二进制文件被当文本 | 不能 | binary detection |
所以“可用编辑工具”通常是组合防线:
代码块收起展开
path allowlist
+ binary check
+ file size limit
+ baseHash
+ oldText uniqueness
+ diff preview
+ approval
+ atomic write
+ read-back verification
+ optional formatter/test原子写入
直接 writeFile(abs, updated) 有两个问题:
- 写到一半进程崩了,文件可能损坏。
- Windows 上杀进程、杀终端时更容易留下半文件。
更稳妥的写法:
代码块收起展开
// atomicWriteText = 原子写入教学版。
// 思路:先写临时文件,再 rename 覆盖目标文件,降低半写入风险。
async function atomicWriteText(abs: string, text: string) {
// 临时文件放在同目录,避免跨磁盘 rename 失败。
const dir = dirname(abs);
// 带 pid 是为了减少多个进程同时写临时文件时重名。
const tmp = join(dir, `.${basename(abs)}.${process.pid}.tmp`);
// 先把完整内容写到临时文件。
await writeFile(tmp, text, "utf8");
// rename 在同一文件系统内通常是原子替换。
await rename(tmp, abs);
}真实系统还要处理:
- 目标文件权限。
- tmp 文件清理。
- 跨磁盘 rename 失败。
- Windows 文件被占用。
- antivirus / editor lock。
如果写失败,不要假装成功,要把错误归类:
代码块收起展开
// WriteFailureKind = 写入失败分类。
// 分类越具体,模型越容易知道下一步是重试、重读,还是请求用户处理权限。
type WriteFailureKind =
| "permission_denied" // 没有写权限。
| "file_locked" // 文件被编辑器、杀毒软件或其他进程占用。
| "path_not_found" // 目录或文件不存在。
| "disk_full" // 磁盘空间不足。
| "stale_file" // 文件版本变了,不能基于旧上下文写。
| "unknown"; // 未分类错误,应该保留原始错误信息。错误越具体,模型越容易下一步恢复。
Patch Engine 的核心对象
不要把 patch 当字符串处理。它应该被解析成结构。
代码块收起展开
// Patch = 一个完整补丁,可能涉及多个文件。
type Patch = {
files: FilePatch[]; // 每个 FilePatch 对应一个文件的增删改。
};
// FilePatch = 单个文件的补丁。
type FilePatch = {
oldPath: string; // 修改前路径。新增文件时可能是 /dev/null。
newPath: string; // 修改后路径。删除文件时可能是 /dev/null。
hunks: Hunk[]; // 一个文件里可能有多处不连续修改。
kind: "add" | "delete" | "modify" | "rename"; // 文件级操作类型。
};
// Hunk = patch 中一段连续的修改区域。
type Hunk = {
oldStart: number; // 旧文件中这段从第几行开始。
oldCount: number; // 旧文件中这段覆盖多少行。
newStart: number; // 新文件中这段从第几行开始。
newCount: number; // 新文件中这段覆盖多少行。
lines: PatchLine[]; // 具体每一行是上下文、删除还是新增。
};
// PatchLine = hunk 内的行。
type PatchLine =
| { kind: "context"; text: string } // 未改变的上下文行,用来定位。
| { kind: "remove"; text: string } // 旧文件中要删除的行。
| { kind: "add"; text: string }; // 新文件中要新增的行。Apply 的结果也要结构化:
代码块收起展开
// ApplyResult = patch 应用结果。
// 成功时返回更新后的文本;失败时返回失败 hunk 和附近上下文。
type ApplyResult =
| {
ok: true; // patch 应用成功。
updatedText: string; // 应用 patch 后的新文件内容。
appliedHunks: number; // 成功应用了几个 hunk。
warnings: string[]; // 非致命提示,例如行号发生漂移。
}
| {
ok: false; // patch 应用失败。
failedHunkIndex: number; // 第几个 hunk 失败。
reason: "context_not_found" | "overlap" | "malformed_patch"; // 失败原因。
expected: string; // patch 期望找到的上下文。
nearby: string; // 当前文件中相近位置的内容,帮助模型重读和修正。
};失败时最有价值的信息不是“失败了”,而是:
- 第几个 hunk 失败。
- 期望上下文是什么。
- 当前文件附近内容是什么。
- 是否建议重读文件。
这能把模型从“继续猜 patch”拉回“重新建立事实”。
Hunk 定位:行号只是提示,不是真理
Unified diff 里有行号,但行号可能过期。稳一点的做法:
- 先按 oldStart 附近尝试匹配。
- 匹配失败后,在有限窗口内搜索上下文。
- 仍失败则全文件搜索完整 remove/context 块。
- 多处匹配则拒绝,要求更精确上下文。
伪代码:
代码块收起展开
// locateHunk = 在当前文件里找到某个 hunk 应该应用的位置。
// 返回值是 0-based 行号;找不到或多义时应该返回 null/失败。
function locateHunk(lines: string[], hunk: Hunk): number | null {
// add 行是新内容,不应该参与“在旧文件里定位”。
// context/remove 才是旧文件中应该存在的内容。
const expected = hunk.lines
.filter(line => line.kind !== "add")
.map(line => line.text);
// patch 里的 oldStart 是 1-based,这里转成数组下标 0-based。
const hinted = hunk.oldStart - 1;
// 先在 oldStart 附近搜索,允许上下文有少量行号漂移。
const window = range(Math.max(0, hinted - 20), Math.min(lines.length, hinted + 20));
// 如果附近能唯一匹配,直接使用。
const local = findUniqueMatch(lines, expected, window);
if (local !== null) return local;
// 附近找不到,再全文件搜索;如果多处匹配,findUniqueMatch 应该拒绝。
const global = findUniqueMatch(lines, expected, range(0, lines.length));
return global;
}这里的原则是:可以容忍轻微行号漂移,但不能容忍多义匹配。
三路合并的心智模型
Agent 编辑最好理解成三路合并:
| 版本 | 含义 |
|---|---|
| Base | Agent 读取时的文件 |
| Current | 写入前磁盘上的文件 |
| Desired | Agent 想写成的文件 |
如果 Base == Current,可以安全写。
如果 Base != Current,说明用户或其他工具改过文件。
此时有三种策略:
| 策略 | 行为 | 适合 |
|---|---|---|
| fail fast | 拒绝写入,要求重读 | 默认安全策略 |
| rebase patch | 把 patch 尝试应用到 Current | 小范围非冲突修改 |
| merge | 做三路合并并标冲突 | 高级 IDE/编辑器场景 |
Agent CLI 默认应该 fail fast。
因为模型不是 Git,它很容易把用户刚改的东西“合并没了”。
编辑权限不是一个 yes/no
粗糙权限:
代码块收起展开
Allow write? yes/no成熟权限应该看风险:
| 风险项 | 低风险 | 高风险 |
|---|---|---|
| 文件数量 | 1 个文件 | 多文件批量 |
| 文件类型 | 源码/测试 | 配置/密钥/锁文件 |
| 改动大小 | 几行 | 大段重写 |
| 路径 | workspace 内 | workspace 外 |
| 操作 | replace | delete/rename |
| 可恢复性 | git tracked | untracked 且无备份 |
可以设计风险评分:
代码块收起展开
// scoreEditRisk = 根据变更规模和敏感度给编辑打风险分。
// 分数越高,越应该要求用户明确确认,甚至禁止自动执行。
function scoreEditRisk(change: PlannedChange): number {
let risk = 0;
// 一次改太多文件,说明影响面变大。
if (change.files.length > 3) risk += 2;
// 改动行数太多,diff 审查成本高,也更容易误伤。
if (change.totalChangedLines > 80) risk += 2;
// 命中密钥、配置、锁文件等敏感路径时提高风险。
if (change.paths.some(isSensitivePath)) risk += 3;
// workspace 外路径基本应该直接拒绝,这里给极高分。
if (change.paths.some(isOutsideWorkspace)) risk += 10;
// 删除操作比普通替换危险。
if (change.kind === "delete") risk += 3;
return risk;
}风险高不一定禁止,但要升级提示。
编辑工具返回值要喂给模型
编辑完成后,工具返回值不是给用户看的摘要而已,它还是下一轮模型决策的事实来源。
代码块收起展开
// EditToolResult = 编辑工具返回给 QueryEngine/模型的事实。
// 它应该足够详细,让模型知道下一步该不该跑测试、该不该继续改。
type EditToolResult = {
summary: string; // 一句话摘要,例如 Edited src/config.ts。
diff: string; // 真实 diff。
changedFiles: string[]; // 本次改动涉及哪些文件。
oldHash: string; // 修改前版本。
newHash: string; // 修改后版本。
verification: {
readBack: boolean; // 是否写后回读。
formatter?: "passed" | "failed" | "skipped"; // 是否跑了 formatter。
tests?: "passed" | "failed" | "skipped"; // 是否跑了测试。
};
};如果只返回 ok,模型下一步不知道:
- 改了哪些文件。
- diff 是什么。
- 有没有验证。
- 是否还需要跑测试。
- 失败时该读哪个文件。
编辑系统的测试用例
编辑引擎要写单元测试,不要只靠人工体验。
| 测试 | 目标 |
|---|---|
| oldText 找不到 | 必须失败 |
| oldText 多处出现 | 默认失败 |
| replaceAll=true | 多处替换才允许 |
| CRLF 文件 | 写后仍是 CRLF |
| UTF-8 BOM | 写后保留 BOM |
| 文件 hash 变化 | 写前拒绝 |
| patch 行号漂移 | 可在窗口内匹配 |
| patch 多处匹配 | 拒绝 |
| binary file | 拒绝文本编辑 |
| workspace 外路径 | 拒绝 |
| atomic write 失败 | 不报告成功 |
一个最小测试例子:
代码块收起展开
// 这个测试验证:文件被别人改过后,旧编辑计划不能继续写入。
it("rejects stale file before writing", async () => {
// 先读取文件,生成一个基于当前 hash 的编辑计划。
const loaded = await loadFile("config.ts");
const plan = buildReplacePlan(loaded, "sonnet", "opus");
// 模拟用户或其他进程在中途修改了同一个文件。
await writeFile("config.ts", "user changed it", "utf8");
// 应用旧计划必须失败,并且错误码应该明确是 FILE_STALE。
await expect(applyEditPlan(plan)).rejects.toMatchObject({
code: "FILE_STALE"
});
});Java 后端视角类比
如果你从 Java 后端转 Agent CLI,可以这样类比:
| Agent 编辑概念 | Java 后端类比 |
|---|---|
| baseHash | 数据库乐观锁 version |
| diff preview | 事务提交前审计日志 |
| write approval | 高危操作二次确认 |
| atomic write | 事务 commit |
| patch failure | SQL update affected rows = 0 |
| read-back verification | 写后查询确认 |
| per-file lock | 按资源 key 加锁 |
| audit record | 操作日志 / binlog |
真正的难点不是“怎么写文件”,而是把文件修改做成一套小型事务系统。
源码验收门槛
编辑引擎的验收标准必须比普通 writeFile 高。因为它直接修改用户资产,错误一次就可能污染整个工作区。
| 验收项 | 必须看到的源码证据 | 不合格表现 |
|---|---|---|
| 写前快照 | 读取内容、mtime/hash、编码、换行风格 | 只拿路径和 newText |
| 预览可审 | 用户批准的是具体 diff 或 patch plan | 只显示“将修改文件” |
| stale 检测 | apply 前重新读取并比较 base hash | 用户中途改文件仍覆盖 |
| 原子写入 | temp file + rename 或平台等价机制 | 写一半崩溃留下半截文件 |
| 写后验证 | read-back hash/diff 确认落盘内容 | 调用 writeFile 成功就算完成 |
| 失败可恢复 | patch 失败返回邻近上下文和失败 hunk | 只说 replace failed |
| 审计可回放 | 记录 old/new hash、diff、approval、tool call id | 只能从 git diff 猜发生了什么 |
最小回归样例:同一个文件做三次测试:精确替换成功、oldText 不存在失败、中途被外部修改失败。合格实现必须只让第一种落盘,后两种保留原文件,并给模型足够上下文重新制定编辑方案。
读源码时的高阶问题
读到编辑模块时,不要只问“有没有 edit_file”。要追问:
- 文件读取后是否记录 hash?
- 写入前是否检测 stale?
- diff 是模型生成的,还是程序根据 old/new 生成的?
- 用户批准的是自然语言描述,还是实际 diff?
- patch 失败时是否返回附近上下文?
- 大文件、二进制、生成文件、锁文件如何处理?
- 是否支持 dry-run?
- 是否能撤销或回滚?
- 编辑 trace 是否可回放?
- 测试失败后是否保留已写文件,还是自动回滚?
这些问题决定这个 Agent 是“能帮你写代码”,还是“你要防着它写代码”。