0%

Claude Code 源码解析 (2):差异编辑的设计艺术

Claude Code 源码解析 (2):差异编辑的设计艺术

导读: 这是 Claude Code 20 个功能特性源码解析系列的第 2 篇,深入分析文件编辑工具的设计艺术。


📋 目录

  1. [问题引入:AI 修改文件的信任危机](#问题引入 ai-修改文件的信任危机)
  2. 技术原理:差异编辑的完整架构
  3. 设计思想:为什么这样设计
  4. 解决方案:完整实现详解
  5. OpenClaw 最佳实践
  6. 总结

问题引入:AI 修改文件的信任危机

痛点场景

场景 1:不知道改了哪里

1
2
3
4
5
6
7
用户:"帮我修复这个 bug"
AI:(直接修改文件,保存)
AI:"改好了"
用户:"改了什么?"
AI:"改了几行代码"
用户:"哪几行?"
AI:"呃..."

场景 2:改错了无法恢复

1
2
3
4
AI 修改了配置文件,服务挂了
用户:"能恢复吗?"
AI:"抱歉,没有备份..."
用户:"..."

场景 3:覆盖了其他人的修改

1
2
3
4
T1: AI 读取文件 (版本 A)
T2: 同事手动修改 (版本 B)
T3: AI 基于版本 A 写入
→ 同事的修改被覆盖了!

核心问题

设计 AI 助手的文件编辑工具时,面临以下挑战:

  1. 透明性问题

    • 用户不知道 AI 改了什么
    • 缺乏有效的沟通方式
  2. 可逆性问题

    • 改错了无法恢复
    • 没有版本管理
  3. 并发问题

    • 多人协作时的冲突
    • 版本覆盖风险
  4. 信任问题

    • 用户不敢让 AI 修改重要文件
    • 影响 AI 的使用效率

Claude Code 用差异编辑解决了这些问题。


技术原理:差异编辑的完整架构

整体架构

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
┌─────────────────────────────────────────────────────────────┐
│ 用户请求 │
│ "帮我修改 test.py 的第 10 行" │
└─────────────────────┬───────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ 第 1 层:读取原文件 │
│ - 检查文件存在性 │
│ - 验证访问权限 │
│ - 记录版本号 │
└─────────────────────┬───────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ 第 2 层:应用变更 │
│ - 精确匹配 → 直接替换 │
│ - 模糊匹配 → 相似度计算 │
│ - 匹配失败 → 报告错误 │
└─────────────────────┬───────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ 第 3 层:生成差异 │
│ - 计算 diff │
│ - 格式化输出 │
│ - 变更说明 │
└─────────────────────┬───────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ 第 4 层:用户确认 │
│ - 显示差异 │
│ - 等待确认 │
│ - 拒绝则取消 │
└─────────────────────┬───────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ 第 5 层:备份 + 写入 │
│ - 创建多版本备份 │
│ - 原子写入 │
│ - 版本检查 │
└─────────────────────┬───────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────┐
│ 返回结果 │
│ - 成功/失败 │
│ - 备份位置 │
│ - 差异内容 │
└─────────────────────────────────────────────────────────────┘

核心组件

1. 变更定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface Change {
// 要查找的文本
oldText: string;

// 替换成的文本
newText: string;

// 可选:变更说明
description?: string;

// 可选:上下文 (提高匹配精度)
contextBefore?: string;
contextAfter?: string;
}

为什么这样设计?

  • oldText + newText = 最小变更单元
  • description = 人类可读的说明
  • context = 提高模糊匹配精度

2. 模糊匹配算法

问题: AI 说”修改第 10 行”,但文件已被修改,行号不对了。

解决:模糊匹配

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
async function fuzzyMatch(
content: string,
searchText: string
): Promise<string | null> {
// 1. 尝试忽略空白匹配
const normalizedContent = content.replace(/\s+/g, ' ');
const normalizedSearch = searchText.replace(/\s+/g, ' ');

if (normalizedContent.includes(normalizedSearch)) {
return findOriginalMatch(content, searchText);
}

// 2. 相似度匹配 (Levenshtein 距离)
for (let i = 0; i < content.length; i++) {
const window = content.substring(i, i + searchText.length);
const similarity = calculateSimilarity(window, searchText);

if (similarity > 0.8) {
return window;
}
}

return null;
}

function calculateSimilarity(s1: string, s2: string): number {
const distance = levenshteinDistance(s1, s2);
const longer = Math.max(s1.length, s2.length);
return (longer - distance) / longer;
}

Levenshtein 距离:

1
2
3
4
5
6
7
8
9
10
11
12
13
"Hello World" → "Hello OpenClaw"

操作:
1. 删除 "World" (5 次删除)
2. 插入 "OpenClaw" (8 次插入)

距离 = 13
相似度 = (11 - 13) / 11 = 不匹配

但如果是:
"Hello World" → "Hello Worlds"
距离 = 1
相似度 = (11 - 1) / 11 = 90% ✅

3. 差异生成

使用 diff-match-patch 库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { createTwoFilesPatch } from 'diff';

function generateDiff(
filePath: string,
oldContent: string,
newContent: string
): string {
return createTwoFilesPatch(
filePath,
filePath,
oldContent,
newContent
);
}

输出示例:

1
2
3
4
5
6
7
8
9
10
--- test.py
+++ test.py
@@ -8,7 +8,7 @@
return result

-def process_data(data):
- print("Processing...")
+def process_data(data, options=None):
+ print(f"Processing with options: {options}")
return transform(data)

4. 多版本备份

备份策略:

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
class BackupManager {
private maxVersions = 5; // 保留最近 5 个版本

async backup(filePath: string): Promise<string> {
// 1. 生成备份文件名
const fileName = `${path.basename(filePath)}.${Date.now()}.backup`;
const backupPath = path.join(this.backupDir, fileName);

// 2. 复制文件
await fs.promises.copyFile(filePath, backupPath);

// 3. 轮转旧备份
await this.rotateBackups(filePath);

return backupPath;
}

private async rotateBackups(filePath: string): Promise<void> {
const backups = await this.getBackupsForFile(filePath);

// 如果超过最大版本数,删除最旧的
while (backups.length > this.maxVersions) {
const oldest = backups.shift();
if (oldest) {
await fs.promises.unlink(oldest);
}
}
}
}

备份目录结构:

1
2
3
4
5
6
~/.openclaw/backups/
├── test.py.1712137200000.backup
├── test.py.1712140800000.backup
├── test.py.1712144400000.backup
├── test.py.1712148000000.backup
└── test.py.1712151600000.backup

5. 原子写入

问题: 写入过程中断电,文件损坏。

解决:临时文件 + 原子重命名

1
2
3
4
5
6
7
8
9
10
11
12
async function atomicWrite(
filePath: string,
content: string
): Promise<void> {
// 1. 写入临时文件
const tempPath = filePath + '.tmp.' + Date.now() + '.' + process.pid;
await fs.promises.writeFile(tempPath, content, 'utf-8');

// 2. 原子重命名
await fs.promises.rename(tempPath, filePath);
// rename 是原子的:要么成功,要么失败
}

为什么原子?

1
2
3
4
5
6
7
8
9
传统写入:
打开文件 → 写入内容 → 关闭文件

断电 → 文件损坏 (只写入了一半)

原子写入:
写入临时文件 → 重命名

断电 → 要么旧文件完整,要么新文件完整

6. 版本检查

问题: AI 读取后,用户手动修改了文件,AI 基于旧版本写入。

解决:版本检查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function editFile(path: string, changes: Change[]) {
// 1. 读取文件,记录版本
const { content, version } = await readFileWithVersion(path);
const initialVersion = version;

// 2. 计算修改
const newContent = applyChanges(content, changes);

// 3. 用户确认...
await userConfirm(diff);

// 4. 写入前检查版本
const currentVersion = getFileVersion(path);
if (currentVersion !== initialVersion) {
throw new ConcurrentModificationError({
message: '文件被其他人修改了',
suggestion: '请重新读取文件后重试',
});
}

// 5. 安全写入
await writeFile(path, newContent);
}

设计思想:为什么这样设计

思想 1:差异是最好的沟通语言

问题: AI 说”改了几行”,用户不知道具体改了哪里。

解决: 用 diff 说话。

1
2
3
4
5
6
7
8
9
10
11
12
糟糕的沟通:
AI:"我修改了 test.py"
用户:"改了哪里?"
AI:"第 10 行和第 25 行"
用户:"改成什么?"

更好的沟通:
AI:"我修改了 test.py:"
```diff
def hello():
- print("Hello")
+ print("Hello, World!")

要应用这个修改吗?

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

**设计智慧:**

> **差异 (diff) 是人类理解代码变更的最佳方式。**

### 思想 2:修改比创建更危险

**风险分级:**

| 操作类型 | 风险 | 原因 |
|----------|------|------|
| 创建新文件 | 低 | 最坏删掉重来 |
| 修改现有文件 | 中 | 可能破坏已有功能 |
| 覆盖关键文件 | 高 | 可能丢失重要数据 |
| 修改系统文件 | 极高 | 可能影响系统稳定 |

**不同风险,不同处理:**

```typescript
function getConfirmLevel(operation: string, path: string): ConfirmLevel {
if (operation === 'create') {
return 'notify'; // 事后通知
}

if (path.includes('/config/')) {
return 'confirm'; // 强制确认
}

if (path.startsWith('/etc/')) {
return 'reject'; // 直接拒绝
}

return 'confirm'; // 默认确认
}

思想 3:永远给自己留后路

核心原则:

任何修改都可能是错的,必须能够恢复。

实现方式:

  1. 多版本备份

    • 保留最近 5 个版本
    • 可以恢复到任意历史点
  2. 原子写入

    • 不会损坏文件
    • 要么成功,要么失败
  3. 版本检查

    • 检测并发修改
    • 避免覆盖他人工作

备份的价值:

1
2
3
4
5
6
7
8
9
Day 1: AI 修改 → backup.1
Day 2: AI 修改 → backup.2
Day 3: 发现 Day 2 的修改有问题

单版本备份:
→ 只能恢复到 Day 1,但 Day 1 的修改也丢了

多版本备份:
→ 可以恢复到 Day 1 或 Day 2 之前的任意状态

思想 4:模糊匹配,提高鲁棒性

问题: AI 的修改指令可能不精确。

解决: 模糊匹配。

1
2
3
4
5
6
7
AI 指令:"把 'Hello World' 改成 'Hello OpenClaw'"

文件内容:
"Hello World" (多个空格)

精确匹配:失败
模糊匹配:成功 (忽略空白差异)

匹配策略:

1
2
3
4
5
1. 精确匹配 → 直接替换
2. 忽略空白匹配 → 归一化后匹配
3. 相似度匹配 → Levenshtein 距离
4. 上下文匹配 → 前后各 50 字符
5. 都失败 → 报告错误,给出建议

思想 5:透明操作,建立信任

透明性设计:

  1. 事前预览

    • 显示要修改的内容
    • 用户确认后再执行
  2. 事中反馈

    • 报告进度
    • 提示错误
  3. 事后确认

    • 显示实际修改
    • 提供备份位置

信任建立过程:

1
2
3
4
第 1 次:用户确认 → AI 修改 → 结果正确 → 信任 +1
第 2 次:用户确认 → AI 修改 → 结果正确 → 信任 +1
第 3 次:用户确认 → AI 修改 → 结果错误 → 恢复备份 → 信任不变
...

解决方案:完整实现详解

FileEditTool 核心实现

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
export class FileEditTool extends Tool {
name = 'file_edit';
description = '编辑文件,显示差异并创建备份';

async execute(input: FileEditInput, context: ToolContext): Promise<ToolResult> {
try {
// ========== 阶段 1: 路径验证 ==========
const validatedPath = this.validatePath(input.path, context);

// ========== 阶段 2: 读取原文件 ==========
const originalContent = await fs.promises.readFile(
validatedPath,
'utf-8'
);

// ========== 阶段 3: 应用变更 ==========
const { newContent, applied, failed } = await this.applyChanges(
originalContent,
input.changes
);

// ========== 阶段 4: 生成差异 ==========
const diff = this.diffGenerator.generate(
validatedPath,
originalContent,
newContent
);

// ========== 阶段 5: 干运行 (仅预览) ==========
if (input.dryRun) {
return {
success: true,
diff,
applied: applied.length,
failed: failed.length,
dryRun: true,
};
}

// ========== 阶段 6: 备份原文件 ==========
const backupPath = await this.backupManager.backup(validatedPath);

// ========== 阶段 7: 原子写入 ==========
await this.atomicWrite(validatedPath, newContent);

// ========== 阶段 8: 返回结果 ==========
return {
success: true,
diff,
applied: applied.length,
failed: failed.length,
backupPath,
};

} catch (error) {
return {
success: false,
error: error.message,
errorCode: 'edit_error',
};
}
}
}

变更应用实现

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
async function applyChanges(
content: string,
changes: Change[]
): Promise<{
newContent: string;
applied: Array<{ index: number, description?: string }>;
failed: Array<{ index: number, oldText: string, reason: string }>;
}> {
let newContent = content;
const applied = [];
const failed = [];

for (let i = 0; i < changes.length; i++) {
const change = changes[i];

// 1. 尝试精确匹配
if (newContent.includes(change.oldText)) {
newContent = newContent.replace(change.oldText, change.newText);
applied.push({ index: i, description: change.description });
continue;
}

// 2. 尝试模糊匹配
const fuzzyMatch = await this.fuzzyMatch(newContent, change.oldText);
if (fuzzyMatch) {
newContent = newContent.replace(fuzzyMatch, change.newText);
applied.push({ index: i, description: change.description });
continue;
}

// 3. 匹配失败
failed.push({
index: i,
oldText: change.oldText.substring(0, 50) + '...',
reason: 'Text not found in file',
});
}

return { newContent, applied, failed };
}

差异生成器

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
import { createTwoFilesPatch } from 'diff';

export class DiffGenerator {
generate(
filePath: string,
oldContent: string,
newContent: string
): string {
// 统一格式 diff
const diff = createTwoFilesPatch(
filePath,
filePath,
oldContent,
newContent
);

return diff;
}

// HTML 格式 (用于 UI 显示)
generateHtml(
filePath: string,
oldContent: string,
newContent: string
): string {
const diff = this.generate(filePath, oldContent, newContent);

const lines = diff.split('\n');
const htmlLines = lines.map(line => {
if (line.startsWith('+')) {
return `<div class="diff-added">${escapeHtml(line)}</div>`;
} else if (line.startsWith('-')) {
return `<div class="diff-removed">${escapeHtml(line)}</div>`;
} else {
return `<div class="diff-context">${escapeHtml(line)}</div>`;
}
});

return `<div class="diff">${htmlLines.join('\n')}</div>`;
}
}

备份管理器

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
export class BackupManager {
constructor(private config: BackupConfig) {}

async backup(filePath: string): Promise<string> {
const backupDir = this.config.backupDir;
const fileName = `${path.basename(filePath)}.${Date.now()}.backup`;
const backupPath = path.join(backupDir, fileName);

// 确保备份目录存在
await fs.promises.mkdir(backupDir, { recursive: true });

// 复制文件
await fs.promises.copyFile(filePath, backupPath);

// 轮转旧备份
await this.rotateBackups(filePath);

return backupPath;
}

private async rotateBackups(filePath: string): Promise<void> {
const backups = await this.getBackupsForFile(filePath);

while (backups.length > this.config.maxVersions) {
const oldest = backups.shift();
if (oldest) {
await fs.promises.unlink(oldest);
}
}
}

async getBackupsForFile(filePath: string): Promise<string[]> {
const safeName = path.basename(filePath);
const files = await fs.promises.readdir(this.config.backupDir);

const backups = files
.filter(f => f.startsWith(safeName) && f.endsWith('.backup'))
.map(f => path.join(this.config.backupDir, f))
.sort();

return backups;
}

async restore(backupPath: string, targetPath?: string): Promise<string> {
if (!targetPath) {
targetPath = this.inferOriginalPath(backupPath);
}

await fs.promises.copyFile(backupPath, targetPath);
return targetPath;
}
}

配置文件

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
# ~/.openclaw/config/file-ops.yaml

# 备份配置
backup:
enabled: true
dir: ~/.openclaw/backups
max_versions: 5

# 权限配置
permissions:
allowed_dirs:
- ~/.openclaw/workspace
- /tmp

denied_dirs:
- /etc
- /root
- /boot

# 文件类型权限
file_types:
- pattern: "*.py"
operations: [read, write, edit]
- pattern: "*.yaml"
operations: [read]
edit_requires_confirm: true

# 大小限制
limits:
max_file_size: 104857600 # 100MB
max_read_lines: 10000
max_edit_changes: 50

# 性能配置
performance:
large_file_threshold: 10485760 # 10MB
cache_enabled: true
cache_ttl: 300 # 5 分钟

OpenClaw 最佳实践

实践 1:创建文件操作插件

目录结构:

1
2
3
4
5
6
7
8
~/.openclaw/extensions/file-ops/
├── index.ts
├── tools/
│ ├── FileReadTool.ts
│ ├── FileWriteTool.ts
│ └── FileEditTool.ts
├── config.yaml
└── package.json

插件入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// index.ts
import { FileReadTool } from './tools/FileReadTool';
import { FileWriteTool } from './tools/FileWriteTool';
import { FileEditTool } from './tools/FileEditTool';

export const plugin = {
name: 'file-ops',
version: '1.0.0',

async init(gateway: any) {
const config = loadConfig();

gateway.registerTool('file_read', new FileReadTool(config));
gateway.registerTool('file_write', new FileWriteTool(config));
gateway.registerTool('file_edit', new FileEditTool(config));

console.log('[file-ops] 3 tools registered');
},
};

实践 2:使用差异编辑

命令行方式:

1
2
3
4
5
6
7
8
9
10
# 预览修改 (不实际执行)
openclaw run file_edit \
--path "./test.py" \
--changes '[{"oldText":"Hello","newText":"Hi"}]' \
--dry-run

# 实际修改
openclaw run file_edit \
--path "./test.py" \
--changes '[{"oldText":"Hello","newText":"Hi"}]'

Agent 对话方式:

1
2
3
4
5
6
7
8
9
用户:"帮我修改 test.py,把 print('Hello') 改成 print('Hi')"

AI:```diff
--- test.py
+++ test.py
@@ -1,2 +1,2 @@
def main():
- print('Hello')
+ print('Hi')

要应用这个修改吗?

用户:”✅ 同意”

AI:修改完成,备份位置:~/.openclaw/backups/test.py.xxx.backup

1
2
3
4
5
6
7

### 实践 3:恢复备份

**查看备份历史:**

```bash
openclaw run backup_list --file "./test.py"

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
test.py 的备份历史:

1. test.py.1712151600000.backup
时间:2026-04-03 19:00
大小:1.2KB

2. test.py.1712148000000.backup
时间:2026-04-03 18:00
大小:1.1KB

3. test.py.1712144400000.backup
时间:2026-04-03 17:00
大小:1.0KB

恢复备份:

1
2
3
4
5
6
# 恢复到最新备份
openclaw run backup_restore --file "./test.py"

# 恢复到指定备份
openclaw run backup_restore \
--backup "~/.openclaw/backups/test.py.1712144400000.backup"

实践 4:集成审批系统

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 检查是否需要审批
const requiresApproval = this.requiresApproval(filePath, changes);

if (requiresApproval) {
// 请求审批
const approval = await context.requestApproval({
type: 'file_edit',
path: filePath,
diff,
changes,
});

if (!approval.approved) {
return { success: false, error: '用户拒绝' };
}
}

审批规则:

1
2
3
4
5
6
# 需要审批的情况
requires_approval:
- file_type: "*.yaml" # 配置文件
- file_type: "*.json" # JSON 配置
- changes_count: ">10" # 大范围修改
- path: "*/config/*" # 配置目录

总结

核心要点

  1. 差异显示 - 用 diff 沟通,透明可见
  2. 多版本备份 - 保留历史,随时恢复
  3. 原子写入 - 防止损坏,保证完整
  4. 模糊匹配 - 提高鲁棒性,减少错误
  5. 版本检查 - 避免并发冲突

设计智慧

好的文件编辑工具,让用户敢于把文件交给 AI。

Claude Code 的文件编辑设计告诉我们:

  • 透明性建立信任
  • 可逆性降低风险
  • 鲁棒性提升体验

下一步

  • 创建 file-ops 插件
  • 配置备份目录
  • 集成审批系统
  • 添加恢复命令

系列文章:

  • [1] Bash 命令执行的安全艺术 (已发布)
  • [2] 差异编辑的设计艺术 (本文)
  • [3] 文件模式匹配的底层原理 (待发布)

上一篇: Claude Code 源码解析 (1):Bash 命令执行的安全艺术


关于作者: John,OpenClaw 平台开发者,专注 AI 助手架构设计与实现。