第 1 章 · 编辑格式:LLM 怎么「说出」一次改动
本章讲:Aider 不让模型"调用工具写文件",而是让它在自然语言回复里夹一段约定格式的补丁。我们看这段补丁长什么样、有哪几种、以及解析器怎么从一坨自由文本里把编辑切出来。
1.1 核心思想:编辑即语言
大多数 agent 让模型走 function-calling("调用 write_file(path, content)")。Aider 选了另一条路:
模型照常用文字回复,只是在回复里嵌入一段机器能解析的补丁块。
为什么?因为 LLM 在海量代码/文档上见过的"diff""冲突标记""代码块"远多于某个具体工具的 JSON schema。用模型最熟悉的文本格式,它出错率最低。 这就是 Aider 把默认格式设计成酷似 git 冲突标记的原因。
1.2 三种主要编辑格式
Aider 有十几个 *Coder,但核心是三族编辑格式:
格式(edit_format) | 长什么样 | 适合 | 实现 |
|---|---|---|---|
diff(SEARCH/REPLACE) | 「旧代码块 ⟶ 新代码块」,像冲突标记 | 默认、绝大多数强模型 | EditBlockCoder |
udiff | 标准 unified diff(@@ ... @@ + -/+ 行) | GPT-4 Turbo 等爱"偷懒省略"的模型 | UnifiedDiffCoder |
whole | 直接回完整文件内容 | 弱模型 / 小文件 | WholeFileCoder |
选哪种由模型决定:Model 在 aider/models.py:configure_model_settings 里按模型名设 self.edit_format(例如多处 self.edit_format = "diff",aider/models.py:439 起),Coder.create 默认就用 main_model.edit_format(base_coder.py:148)。
1.3 默认格式:SEARCH/REPLACE 块
这是要重点理解的格式。一个块由"文件名 + 围栏 + 旧代码 + 分隔线 + 新代码"组成。来自 Aider 自己喂给模型的示例提示(editblock_prompts.py:46-54,示意保留原貌):
mathweb/flask/app.py
```python
<<<<<<< SEARCH
from flask import Flask
=======
import math
from flask import Flask
>>>>>>> REPLACE
```
规则被写死在系统提醒里(editblock_prompts.py:120-159 system_reminder),其中最关键的两条:
- SEARCH 段必须"逐字符"匹配现有文件("EXACTLY MATCH ... character for character",
editblock_prompts.py:134)。 - 新建文件:用一个 SEARCH 段为空、REPLACE 段是文件内容的块(
editblock_prompts.py:152-155)。
这两条决定了第 2 章的全部难度:模型被要求逐字匹配,但它做不到——所以应用端必须容错。
1.4 解析:从自由文本里切出编辑
模型回复是"解释 + 若干块"混在一起的自由文本。EditBlockCoder.get_edits(editblock_coder.py:21)调用 find_original_update_blocks 逐行扫描切块。
标记不是写死的字符串,而是正则,容忍 5–9 个 </=/>(editblock_coder.py:386-388):
HEAD = r"^<{5,9} SEARCH>?\s*$" # 容忍 <<<<< 到 <<<<<<<<<
DIVIDER = r"^={5,9}\s*$"
UPDATED = r"^>{5,9} REPLACE\s*$"
原理演示(极简版,抓住"状态机扫行"的直觉;示意,非源码):
# 演示:扫描每一行,在 HEAD/DIVIDER/UPDATED 之间切换状态收集文本
def parse_blocks(lines):
i = 0
while i < len(lines):
if HEAD.match(lines[i]): # 进入一个块
i += 1
search = collect_until(lines, i, DIVIDER) # 收 SEARCH 段
i = search.end
replace = collect_until(lines, i, UPDATED) # 收 REPLACE 段
yield (current_filename, search.text, replace.text)
i += 1
# 重点看:文件名不在块里,要从块「上方几行」回溯
真实实现的几个巧妙细节:
- 文件名回溯。 文件名单独一行在块上方,
find_filename(editblock_coder.py:538)回看前 3 行,并做"精确名 → basename → 模糊匹配 → 带扩展名"四级挑选(editblock_coder.py:576-599),专门对付 DeepSeek 这类会多套一层围栏的模型。 - shell 命令也顺手解析。 扫到
```bash这类围栏且后面不是编辑块时,把内容当 shell 命令产出(editblock_coder.py:452-485,以yield None, ...标记),后续可让用户确认执行。 - 解析失败立即变成可回灌的报错。 缺 DIVIDER 等结构错误会
raise ValueError,并把"已处理到的文本 +^^^ 错误"拼进异常(editblock_coder.py:530-533)——这条报错最终成为反思循环的输入(见第 4 章)。
1.5 udiff 与整文件(各一句)
- udiff(
udiff_coder.py:find_diffs)解析标准@@hunk,应用走search_replace.py的策略阶梯(第 2 章末)。它的提示反复强调"别跳过空行/注释",因为 unified diff 的上下文行必须对齐。 - whole(
wholefile_coder.py)最简单:模型回"文件名 + 完整内容",直接整体覆盖;胜在弱模型也能做对,输代价是 token 多、改大文件易超输出上限。
1.6 小结与下一章
你现在知道了模型怎么把一次改动"说出来",以及 Aider 怎么把它从自由文本里切出来。但切出来的 (文件, 旧文本, 新文本) 里那段"旧文本"几乎从不和磁盘逐字一致——怎么把它"容错地"落到真实文件上,是 第 2 章 的主题,也是 Aider 工程含量最高的部分。
代码地图
| 主题 | 文件 | 符号 |
|---|---|---|
| diff 解析入口 | aider/coders/editblock_coder.py | EditBlockCoder.get_edits、find_original_update_blocks |
| 标记正则 | aider/coders/editblock_coder.py | HEAD、DIVIDER、UPDATED |
| 文件名回溯 | aider/coders/editblock_coder.py | find_filename、strip_filename |
| 提示与规则 | aider/coders/editblock_prompts.py | EditBlockPrompts.system_reminder、example_messages |
| udiff 解析 | aider/coders/udiff_coder.py | find_diffs |
| 整文件 | aider/coders/wholefile_coder.py | WholeFileCoder |
| 格式选择 | aider/models.py | Model.configure_model_settings、edit_format |