行业动态
dev.to ·
@Masih Maafi
Aider 内部工作原理剖析
文章通过跟踪一条消息在 Aider Python 源码中的流转,说明其与 Codex 的设计差异:Codex 中模型拥有工具并自行探索仓库,而 Aider 中模型没有工具,由 harness 决定模型看到的内容,模型以纯文本作答,harness 再从文本中提取编辑并应用。文中代码引自 Aider 0.86.3.dev(commit 5dc9490),作者声明未实际运行,仅基于源码阅读。
AI推荐理由 基于 Aider 0.86.3.dev 源码逐段解读其架构:模型无工具、由 harness 决定上下文并解析纯文本编辑,与 Codex 的工具调用设计形成对比,对理解编码 Agent 实现有参考价值;但作者明确说明未实际运行,仅为源码阅读。
72
正文
Codex and Aider look alike from the outside: you type, the model edits your code. Inside
they are opposite designs. In Codex the model has tools and explores the repository
itself. In Aider the model has no tools at all. The harness decides what the model
sees, the model answers in plain text, and the harness finds the edits in that text and
applies them.
This post follows one message through Aider's Python source, the same way my
Codex and Elpis post does for Codex. Code is quoted
verbatim from Aider 0.86.3.dev, commit 5dc9490, with paths relative to the
repository root. Nothing here was run; it is read from the source.
1. The shape: one object per mode
main() builds a single Coder and loops on coder.run().
Each edit format is its own Coder subclass with its own prompts and parser, so a mode
change such as /ask or /architect is not a flag. It raises SwitchCoder, and main
builds a new Coder from the old one, copying the files, history and cost across
(in Coder.create).
# aider/main.py
while True:
try:
coder.ok_to_warm_cache = bool(args.cache_keepalive_pings)
coder.run()
analytics.event("exit", reason="Completed main CLI coder.run")
return
except SwitchCoder as switch:
# …
kwargs = dict(io=io, from_coder=coder)
kwargs.update(switch.kwargs)
# …
coder = Coder.create(**kwargs)
Enter fullscreen mode
Exit fullscreen mode
If the edit format
changed, the old history is summarized first, because the code's own comment says the old
format "will confused the new LLM. It may try and imitate it".
# aider/coders/base_coder.py
# If the edit format changes, we can't leave old ASSISTANT
# messages in the chat history. The old edit format will
# confused the new LLM. It may try and imitate it, disobeying
# the system prompt.
done_messages = from_coder.done_messages
if edit_format != from_coder.edit_format and done_messages and summarize_from_coder:
try:
done_messages = from_coder.summarizer.summarize_all(done_messages)
Enter fullscreen mode
Exit fullscreen mode
2. What the model sees
The request is assembled by ChatChunks in a fixed order: system prompt, examples,
read-only files, the repo map, past history, the editable files, the current turn, and
last a reminder of the edit-format rules.
# aider/coders/chat_chunks.py
def all_messages(self):
return (
self.system
+ self.examples
+ self.readonly_files
+ self.repo
+ self.done
+ self.chat_files
+ self.cur
+ self.reminder
)
Enter fullscreen mode
Exit fullscreen mode
done is the past history and cur is the current turn. Stable
parts come first and volatile ones last, so the prompt cache keeps a long prefix (my
inference from the caching code below). Notice that the editable files sit after the
history, so the model reads the newest version of a file right next to your request.
Context blocks are faked as conversation: a message such as "here are the files" followed
by an invented "Ok." from the assistant. A history summary comes back the same way, as a
user message beginning "I spoke to you previously about..." (see summarize_all in
aider/history.py and summary_prefix in aider/prompts.py).
The model sees these as things that were said, not as notes from the system.
The whole conversation goes to litellm, one API over many providers, at temperature 0
by default (in Model.send_completion). Retryable errors back off from 0.125 seconds,
doubling until the delay passes 60.
# aider/models.py
RETRY_TIMEOUT = 60
# …
retry_delay = 0.125
# …
if should_retry:
retry_delay *= 2
if retry_delay > RETRY_TIMEOUT:
should_retry = False
Enter fullscreen mode
Exit fullscreen mode
3. No tools, on purpose
The base class sets functions = None, and the reflection limit sits right next to it.
# aider/coders/base_coder.py
functions = None
# …
num_reflections = 0
max_reflections = 3
Enter fullscreen mode
Exit fullscreen mode
Three coders
once used JSON function calls to edit, but they are out of the registry, and two raise
"Deprecated" when built (in editblock_func_coder.py and wholefile_func_coder.py). The only reason the
repository gives is a changelog note from v0.7.0 in HISTORY.md: "Initial experiments show that using
functions makes 3.5 less competent at coding".
So the model writes edits as fenced text, and Aider finds them with regular expressions.
The nearest thing to a shell tool is a fenced bash block in the reply, which runs only if you
say yes (in run_shell_commands). Its output goes into the next turn, not
back to the model at once.
That changes what "agentic" means. Codex loops until the model stops asking for tools.
Aider's only loop is a reflection: after a turn, if Aider itself found a problem, it
sends the problem back as the next user message, at most three times
(the max_reflections = 3 above). Four things count as a problem: an edit that
would not parse or match, a repo file the model named that is not in the chat, lint
errors, and test errors. They all share the same budget of three.
That second one is how the model "opens a file". It cannot call a read tool, so it names
the file in its reply. Aider notices, asks you whether to add it, and replays the turn.
The check runs before any edit is applied, so an otherwise valid edit in that same reply
is thrown away.
# aider/coders/base_coder.py
if not interrupted:
add_rel_files_message = self.check_for_file_mentions(content)
if add_rel_files_message:
if self.reflected_message:
self.reflected_message += "\n\n" + add_rel_files_message
else:
self.reflected_message = add_rel_files_message
return
Enter fullscreen mode
Exit fullscreen mode
The early return comes before apply_updates() is ever called. A person is the gate where
Codex would have a tool call.
4. From a reply to a change on disk
The default format is SEARCH/REPLACE: the model quotes the lines to change, then the
lines to put there. The parser is loose on purpose, accepting five to nine marker
characters and hunting up to three lines above a block for its filename, because one
model kept misplacing them (in find_original_update_blocks and find_filename).
# aider/coders/editblock_coder.py
HEAD = r"^?\s*$"
DIVIDER = r"^={5,9}\s*$"
UPDATED = r"^>{5,9} REPLACE\s*$"
# …
# if next line after HEAD exists and is DIVIDER, it's a new file
if i + 1 2 and not part_lines[0].strip():
skip_blank_line_part_lines = part_lines[1:]
res = perfect_or_whitespace(whole_lines, skip_blank_line_part_lines, replace_lines)
# …
res = try_dotdotdots(whole, part, replace)
# …
return
# Try fuzzy matching
res = replace_closest_edit_distance(whole_lines, part, part_lines, replace_lines)
Enter fullscreen mode
Exit fullscreen mode
perfect_or_whitespace covers the first two steps. Keep an eye on the bare return
near the end; section 10 comes back to it.
When a block fails, the error the model receives is written to be fixable: each failed
block echoed back, the closest real lines in the file, a reminder that SEARCH must match
exactly including whitespace, and "The other N blocks were applied successfully. Don't
re-send them." (in EditBlockCoder.apply_edits).
# aider/coders/editblock_coder.py
res += (
"The SEARCH section must exactly match an existing block of lines including all white"
" space, comments, indentation, docstrings, etc\n"
)
if passed:
pblocks = "block" if len(passed) == 1 else "blocks"
res += f"""
# The other {len(passed)} SEARCH/REPLACE {pblocks} were applied successfully.
Don't re-send them.
Just reply with fixed versions of the {blocks} above that failed to match.
Enter fullscreen mode
Exit fullscreen mode
Aider also has unified-diff, whole-file and OpenAI-style patch formats. The whole-file
format has no matching step at all: the file is simply overwritten.
5. The repo map: context without a model choosing it
Aider never lets the model search the repository, so it hands over a map. For every file
it uses tree-sitter to pull out definitions and references, and caches them on disk
keyed by modification time (in RepoMap.get_tags and get_tags_raw, in aider/repomap.py).
Then it builds a graph. Files are nodes, and an identifier that one file defines and
another references becomes an edge from the referencing file to the defining one. The
weight is a multiplier times the square root of the number of references, so a name
repeated a hundred times does not drown out the rest. The multiplier is 10 if you
mentioned the name, 10 if it looks distinctive (long snake, kebab or camel case), 0.1 if
it starts with an underscore, 0.1 if more than five files define it, and a further 50 if
the edge comes from a file already in the chat (in RepoMap.get_ranked_tags).
# aider/repomap.py
if ident in mentioned_idents:
mul *= 10
if (is_snake or is_kebab or is_camel) and len(ident) >= 8:
mul *= 10
if ident.startswith("_"):
mul *= 0.1
if len(defines[ident]) > 5:
mul *= 0.1
# …
if referencer in chat_rel_fnames:
use_mul *= 50
# …
num_refs = math.sqrt(num_refs)
G.add_edge(referencer, definer, weight=use_mul * num_refs, ident=ident)
Enter fullscreen mode
Exit fullscreen mode
"Long" means at least eight characters.
Personalized PageRank then ranks the files, seeded by the chat files and anything you
mentioned (the nx.pagerank call in the same function). No language model is involved. The score is split
over each file's definitions, definitions in chat files are dropped because their full
text is already sent, and Aider binary-searches for the largest map that fits the token
budget, stopping once it is within 15% of it (in RepoMap.get_ranked_tags_map_uncached).
# aider/repomap.py
pct_err = abs(num_tokens - max_map_tokens) / max_map_tokens
ok_err = 0.15
if (num_tokens best_tree_tokens) or pct_err 1:
# raise SearchTextNotUnique()
if num == 0:
return
new_text = original_text.replace(search_text, replace_text)
return new_text
Enter fullscreen mode
Exit fullscreen mode
/drop a.py also drops data.py. Dropping matches by substring of the path, where
/add matches exactly or by glob (in cmd_drop).
# aider/commands.py
# For editable files, use glob if word contains glob chars, otherwise use substring
if any(c in expanded_word for c in "*?[]"):
matched_files = self.glob_filtered_to_repo(expanded_word)
else:
# Use substring matching like we do for read-only files
matched_files = [
self.coder.get_rel_fname(f) for f in self.coder.abs_fnames if expanded_word in f
]
Enter fullscreen mode
Exit fullscreen mode
What this does not cover
I did not trace ContextCoder, watch mode's internals beyond the outline, voice input,
the web scraper, or analytics. Aider can be run with a different edit format per model,
and the defaults live in a settings file I read only through git.
Originally published at https://masihmoafi.com/blog/aider-under-the-hood.