首页
/ aider 实战复盘:一次横跨多文件的复杂代码改造——用 prompt_toolkit 替换 /dev/null 的协作式调试全流程

aider 实战复盘:一次横跨多文件的复杂代码改造——用 prompt_toolkit 替换 /dev/null 的协作式调试全流程

2026-09-08 23:24:39作者:裴麒琰

导读

本文基于 aider 官方提供的一则真实会话实录(complex-change.md)展开讲解:用户要求把测试代码中"用 /dev/null 重定向 stdin 以模拟无输入"的旧写法,替换为 prompt_toolkit 提供的输入 Mock 函数。这是一个典型的横跨多个源码文件的复杂改动,涉及测试文件、CLI 入口与交互层同步修改,并在过程中经历了多轮"报错 → 粘贴 API 签名 → 修正"的协作式调试。读完本文,你将掌握 aider 多文件改造的完整节奏、edit block 代码格式的读法、^C 中断与外部编辑文件等协作技巧,以及"输入/输出句柄贯穿注入"这一可测试性设计模式在 aider 自身代码中的最终落地形态。

这则实录属于 示例会话合集(Example chat transcripts) 中的一员,同目录下还收录了生成新代码、修改开源仓库、黑盒测试、数据分析和文档更新等不同类型任务的完整对话。


一、场景设定:要解决什么问题

会话开头的操作和第一句话概括了任务全貌:

> $ aider tests/test_main.py aider/getinput.py
> Added tests/test_main.py to the chat
> Added aider/getinput.py to the chat

#### instead of using /dev/null to send no input in the main tests, can we use the prompt_toolkit input mocking functions?

要点有三个:

  1. 启动命令aider <file1> <file2>tests/test_main.pyaider/getinput.py 直接加入会话。启动时指定的文件会立即进入 LLM 的"可见且可编辑"集合。
  2. 任务本质:测试里原先用 open(os.devnull, "r") 把 stdin 指向空设备,模拟"用户不输入任何内容";现在想改用 prompt_toolkit 官方提供的 Mock 输入函数,让测试更贴近真实交互对象。
  3. 为什么是"复杂改动"main() 的调用方在测试侧,而输入行为的实际承载者在 getinput.py 的交互类中,两者之间还隔着 aider/main.py 的调用链。改测试必然牵动生产代码的签名设计——这正是多文件协调改动的最佳案例。

说明:实录发生在项目早期,当时的交互层模块名为 aider/getinput.py。在 当前仓库 中,同类职责的 InputOutput 类已经演进并迁移到 aider/io.py,下文"与当前仓库实现的对应"一节会展开对照。


二、阅读实录前必须先懂的会话格式

打开 examples/README.md 可以看到这段实录所用的"会话排版约定":

  • > 开头的是 aider 工具自身的输出,例如 Added xxx to the chatApplied edit to xxxCommit <hash> <message>
  • #### 开头的是 用户在对话中敲入的请求或反馈
  • 其余正文(以及嵌入其中的 edit block)是 LLM 的回复
  • LLM 提出修改时会输出可机器解析的 edit block,示例格式如下:
hello.py
<<<<<<< ORIGINAL
print("hello")
=======
print("goodbye")
>>>>>>> UPDATED

即:第一行是目标文件路径;<<<<<<< ORIGINAL======= 之间是被替换的旧代码;=======>>>>>>> UPDATED 之间是写入的新代码。aider 收到这种格式后会自动应用到磁盘文件并打印 Applied edit to <path>

正如 README 所总结的会话运作机制:

  • 每次 LLM 给出代码修改,aider 都会自动把它应用到源文件,对应实现可以在 base_coder.py 中看到 self.io.tool_output(f"Applied edit to {path}")(见 base_coder.py)。
  • 应用完修改后,aider 会自动提交到 git,并附上一段描述性的提交信息。
  • LLM 只能看到与会话中已添加的文件;用户既可以在命令行启动时加文件,也可以随时用对话内命令 /add 追加。实录中就多次出现 Added xxx to the chat 通知。

三、第一轮改动:测试全面切换到 prompt_toolkit 输入 Mock

LLM 给出的第一套方案包含四处 edit block,分别对应 import 调整和三个测试方法的调用改写。

① import 部分:引入 create_pipe_inputDummyOutput

tests/test_main.py
<<<<<<< ORIGINAL
import os
import sys
import tempfile
from unittest import TestCase
from aider.main import main
import subprocess
=======
import os
import tempfile
from unittest import TestCase
from aider.main import main
import subprocess
from prompt_toolkit.input import create_pipe_input
from prompt_toolkit.output import DummyOutput
>>>>>>> UPDATED

注意这里顺手删掉了 import sys——因为新方案不再需要手工保存、恢复 sys.stdin 吗?并不尽然,后面会看到 sys.stdin 的保存恢复依然保留。这处 import 删除其实是 LLM 的一个疏漏,随后在 git 层面被捕捉(见第四节)。

② 第一个测试方法:main([])

tests/test_main.py
<<<<<<< ORIGINAL
            with open(os.devnull, "r") as dev_null:
                save_stdin = sys.stdin
                sys.stdin = dev_null
                main([])
                sys.stdin = save_stdin
=======
            pipe_input = create_pipe_input()
            save_stdin = sys.stdin
            sys.stdin = pipe_input
            main([], input=pipe_input, output=DummyOutput())
            sys.stdin = save_stdin
            pipe_input.close()
>>>>>>> UPDATED

③ 第二个测试方法:main(["foo.txt"])

tests/test_main.py
<<<<<<< ORIGINAL
            with open(os.devnull, "r") as dev_null:
                save_stdin = sys.stdin
                sys.stdin = dev_null
                main(["foo.txt"])
                sys.stdin = save_stdin
                self.assertTrue(os.path.exists("foo.txt"))
=======
            pipe_input = create_pipe_input()
            save_stdin = sys.stdin
            sys.stdin = pipe_input
            main(["foo.txt"], input=pipe_input, output=DummyOutput())
            sys.stdin = save_stdin
            pipe_input.close()
            self.assertTrue(os.path.exists("foo.txt"))
>>>>>>> UPDATED

④ 第三个测试方法:main(["--yes", "foo.txt"])

tests/test_main.py
<<<<<<< ORIGINAL
            with open(os.devnull, "r") as dev_null:
                save_stdin = sys.stdin
                sys.stdin = dev_null
                main(["--yes", "foo.txt"])
                sys.stdin = save_stdin
                self.assertTrue(os.path.exists("foo.txt"))
=======
            pipe_input = create_pipe_input()
            save_stdin = sys.stdin
            sys.stdin = pipe_input
            main(["--yes", "foo.txt"], input=pipe_input, output=DummyOutput())
            sys.stdin = save_stdin
            pipe_input.close()
            self.assertTrue(os.path.exists("foo.txt"))
>>>>>>> UPDATED

这段改造透露了两个设计意图:

  • create_pipe_input() 创建可编程写入的管道输入,模拟"无输入"场景;
  • DummyOutput() 吞掉渲染输出,避免测试时终端被 prompt 界面刷屏;
  • 同时大胆地给 main() 传了 input=output= 关键字参数——而彼时 main() 根本还不接受这两个参数。

aider 依次应用了四处编辑并自动提交:

> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Commit c177e29 aider: Replaced usage of /dev/null with prompt_toolkit's input mocking functions in tests.

多行 Applied edit 证明:一次对话回合内,aider 可以连续应用同一文件的多处编辑,并将它们打包成一次带描述的 git 提交。这是 edit block 机制与 git 自动化结合的直观体现。


四、对话外的改动被探测到:git 的"带外变更"提示

第一轮修改落地后,会话中出现了一段非常特殊的交互:

> Git repo has uncommitted changes.
> Suggested commit message:
> Added import statement for sys module in test_main.py.
> Commit before the chat proceeds [y/n/commit message]? y
> Commit 6724fcd Added import statement for sys module in test_main.py.

这说明用户在自己的编辑器里、在对话之外(out-of-band)手动修改了 tests/test_main.py——补回了第一节中被误删的 import sys。aider 虽然不直接感知编辑器内容,但它检测到了 git 工作区存在未提交的改动,于是在继续对话之前暂停并询问用户

  • 输入 y:采用 aider 建议的提交信息并提交;
  • 输入 n:不提交,直接继续;
  • 输入其他文字:以该文字作为自定义提交信息提交。

这是 aider 与 git 深度集成的关键设计:一方面保证每个代码改动都有清晰的提交边界,另一方面避免"对话外改动"污染下一次自动提交的可追溯性。你在本地开发时如果也想体验同样的保护,只要让仓库处于 git 管理之下并正常启动 aider 即可。


五、调试循环 #1:TypeError 暴露 main() 签名缺口,/add 把源码引入会话

第一轮"乐观"的调用改写立即在测试运行中暴露问题:

#### I am getting this error: FAILED tests/test_main.py::TestMain::test_main_with_empty_git_dir_new_file - TypeError: main() got an unexpected keyword argument 'input'

这正是多文件改动的典型连锁反应:测试侧已经按新契约调用,但生产侧签名尚未跟上。用户处理方式很规范:

  1. 先通过 /add aider/main.py 把 CLI 入口源码加入会话(出现 Added aider/main.py to the chat);
  2. 再原样粘贴 pytest 的报错文本作为提示。

LLM 据此把 main() 扩展为接受可选参数:

aider/main.py
<<<<<<< ORIGINAL
def main(args=None):
    if args is None:
        args = sys.argv[1:]
=======
def main(args=None, input=None, output=None):
    if args is None:
        args = sys.argv[1:]
    if input is None:
        input = sys.stdin
    if output is None:
        output = sys.stdout
>>>>>>> UPDATED

这段修复的价值在于兼容性设计:默认值回落为 sys.stdin / sys.stdout,使得"无人传参"时行为与原来完全一致;而测试可以显式注入 Mock。修改被应用后自动提交:

> Applied edit to aider/main.py
> Commit 79dfba9 aider: Fixed main() function to accept input and output keyword arguments.

给读者的实践启示:当 aider 报错只涉及签名缺失时,最快的做法就是把完整 traceback 原样贴进对话,并确保相关源文件在会话内(用 /add 或启动参数添加),LLM 通常能一步到位。


六、需求澄清:把句柄贯穿到 InputOutput 与 prompt_toolkit

第一轮只修好了 main() 的"外壳",但用户随即提出了更准确的诉求——参数不能停在 main() 门口,必须真正流到读取输入的地方

#### we need to pass those into InputOutput and to prompt_toolkit right?

这处澄清非常关键,它把问题从"让函数签名不报错"升级为"让 Mock 输入真正生效"。LLM 的回应是两段式修改:

InputOutput.__init__ 接受并保存 input / output

aider/getinput.py
<<<<<<< ORIGINAL
class InputOutput:
    def __init__(self, pretty, yes, input_history_file, chat_history_file):
=======
class InputOutput:
    def __init__(self, pretty, yes, input_history_file, chat_history_file, input=None, output=None):
        self.input = input
        self.output = output
>>>>>>> UPDATED

get_input() 内调用 prompt() 时把句柄透传进去:

aider/getinput.py
<<<<<<< ORIGINAL
            line = prompt(
                show,
                completer=completer_instance,
                history=FileHistory(self.input_history_file),
                style=style,
                reserve_space_for_menu=4,
                complete_style=CompleteStyle.MULTI_COLUMN,
            )
=======
            line = prompt(
                show,
                completer=completer_instance,
                history=FileHistory(self.input_history_file),
                style=style,
                reserve_space_for_menu=4,
                complete_style=CompleteStyle.MULTI_COLUMN,
                input=self.input,
                output=self.output,
            )
>>>>>>> UPDATED

main.py 构造 InputOutput 时继续下传:

aider/main.py
<<<<<<< ORIGINAL
    io = InputOutput(args.pretty, args.yes, args.input_history_file, args.chat_history_file)
=======
    io = InputOutput(args.pretty, args.yes, args.input_history_file, args.chat_history_file, input=input, output=output)
>>>>>>> UPDATED

至此整条链路的雏形形成:tests → main(argv, input, output) → InputOutput(input, output) → prompt(...)。aider 应用修改并提交:

> Applied edit to aider/getinput.py
> Applied edit to aider/getinput.py
> Applied edit to aider/main.py
> Commit cea9d2a aider: Updated InputOutput class to accept input and output arguments and passed them to prompt_toolkit.prompt() function.

实践启示:当用户发现 LLM 的修改"签名对但语义不够深"时,一句精确的需求澄清("we need to pass those into X and to Y right?")就能把方向掰正。与其重新描述整个问题,不如指出缺失的那一环。


七、调试循环 #2 / #3:API 参数名两次猜错,粘贴签名加速收敛

链路打通后,prompt_toolkit 又开始拒绝参数:

#### FAILED tests/test_main.py::TestMain::test_main_with_empty_git_dir_new_file - TypeError: prompt() got an unexpected keyword argument 'input'

LLM 第一次猜测是"参数名应为 stdin / stdout",于是把 input=self.input, output=self.output 改成 stdin=self.input, stdout=self.output 并提交(Commit 55d3540)。但这依然错误——prompt_toolkit.shortcuts.prompt() 实际上不接受这些参数名。

此时用户采取了最高效的调试投喂方式:直接把 prompt_toolkit 文档中 prompt() 的完整签名抄进对话(那一长串带默认值的类型签名在实录中清晰可见)。LLM 意识到 prompt() 不支持直接注入句柄,转而提议引入 Application 类,回复进行到一半时——

> ^C KeyboardInterrupt

用户按下了 ^C 中断了这次回复。 因为该回答已经开始跑偏(Application 方案远比实际需要的复杂)。随后用户补上真正有用的信息:prompt_toolkit.shortcuts.PromptSession 类的完整构造函数签名(可以看到它接受 input: Input | None = None, output: Output | None = None)。LLM 顺势切换到正确的 PromptSession 方案:

① import 变更:

aider/getinput.py
<<<<<<< ORIGINAL
from prompt_toolkit import prompt
=======
from prompt_toolkit.shortcuts import PromptSession
>>>>>>> UPDATED

② 用 PromptSession 承载句柄,再调 session.prompt()

aider/getinput.py
<<<<<<< ORIGINAL
            line = prompt(
                show,
                completer=completer_instance,
                history=FileHistory(self.input_history_file),
                style=style,
                reserve_space_for_menu=4,
                complete_style=CompleteStyle.MULTI_COLUMN,
                input=self.input,
                output=self.output,
            )
=======
            session = PromptSession(
                message=show,
                completer=completer_instance,
                history=FileHistory(self.input_history_file),
                style=style,
                reserve_space_for_menu=4,
                complete_style=CompleteStyle.MULTI_COLUMN,
                input=self.input,
                output=self.output,
            )
            line = session.prompt()
>>>>>>> UPDATED

随后提交:

> Applied edit to aider/getinput.py
> Applied edit to aider/getinput.py
> Commit ac92cca aider: Replaced `prompt()` with `PromptSession()` to correctly pass input and output arguments.

这段实录浓缩了两条重要协作经验:

  1. 把权威 API 签名粘贴进对话,远比让 LLM 凭记忆猜参数名高效;连猜两次参数名的代价,都不如一段签名省时。
  2. ^C 是合法的协作手段:当模型回复跑偏时立即中断,再用一句澄清(或补充文档片段)重新提问,能得到质量更高的下一条回复。中断不会破坏会话上下文,前文的目标与已应用修改都仍然有效。

八、测试端最终收敛:create_input(StringIO(''))

服务端改用 PromptSession 后,用户又提出一次测试端微调:

#### try using create_input(StringIO('')) in the tests
#### the ones that currently use create_pipe_input()

即把三处 create_pipe_input() 统一替换为 create_input(StringIO(''))

tests/test_main.py
<<<<<<< ORIGINAL
from prompt_toolkit.input import create_pipe_input
=======
from prompt_toolkit.input import create_input
from io import StringIO
>>>>>>> UPDATED

以及每一处调用点:

tests/test_main.py
<<<<<<< ORIGINAL
            pipe_input = create_pipe_input()
=======
            pipe_input = create_input(StringIO(''))
>>>>>>> UPDATED

(同一文件中共有四处相同替换,其中一处 import、三处调用点。)

create_input(StringIO('')) 的含义是:用内存字符串流 StringIO('') 直接构造一个 prompt_toolkit 的输入对象——内容为空即"无输入",与当年 /dev/null 的语义一致,但完全在进程内完成,无需触碰真实文件描述符。全部应用并提交后:

> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Applied edit to tests/test_main.py
> Commit 4bb043f aider: Replaced the usage of `create_pipe_input()` with `create_input(StringIO(''))` in the test cases.

用户最终确认:

#### nice, that worked!

整个改造闭环完成:从 /dev/null 重定向 → create_pipe_inputcreate_input(StringIO('')),配套生产代码从 main() 签名到交互层完成了一次完整的依赖注入改造。


九、对照当前仓库:这次改造的"最终形态"长什么样

作为开源项目,aider 本体一直在演进。虽然 complex-change.md 记录的是历史状态,但对照 当前仓库 的源码,恰好可以验证这次改造的思路正是如今架构的基石,也方便读者把"历史对话"翻译成"现在的代码长什么样"。

1. main() 的现代签名

当前 aider/main.py 的定义为:

def main(argv=None, input=None, output=None, force_git_root=None, return_coder=False):

与实录中修复后的版本同源,只是后续又增加了 force_git_root(指定 git 根目录)与 return_coder(脚本化场景返回 Coder 对象而非执行循环)两个可选项,支撑了 scripting.md 描述的编程式调用。

2. InputOutput 已迁移并深度绑定 PromptSession

实录中的 aider/getinput.py 在现仓库中已不存在,交互核心类 InputOutput 定义在 aider/io.py。它的构造函数同样接受 input=None, output=None(见 aider/io.py),并在初始化时把句柄交给 PromptSession

if fancy_input:
    # Initialize PromptSession only if we have a capable terminal
    session_kwargs = {
        "input": self.input,
        "output": self.output,
        "lexer": PygmentsLexer(MarkdownLexer),
        "editing_mode": self.editingmode,
    }
    ...
    self.prompt_session = PromptSession(**session_kwargs)

aider/io.py。这正是实录第八节引入 PromptSession 的延续:如今不再用函数式 prompt(),而是持有会话对象供 get_input 等流程按需调用。

此外还有一个容易被忽略的设计细节:当外部注入了 output(即非真实终端)时,InputOutput 会自动关闭彩色渲染(见 aider/io.py),保证测试输出干净、不污染断言。

3. 测试端演化为 DummyInput / DummyOutput

实录最终采用 create_input(StringIO(''));而现仓库 tests/basic/test_main.py 的测试则更直接,统一使用 prompt_toolkit 的现成 Dummy 对象:

from prompt_toolkit.input import DummyInput
from prompt_toolkit.output import DummyOutput

tests/basic/test_main.py。典型调用形如:

main(["--no-git", "--exit", "--yes"], input=DummyInput(), output=DummyOutput())

tests/basic/test_main.pyDummyInput 不阻塞等待任何终端输入,天然适合"无输入/直接退出"的 CLI 用例。从历史方案到当前形态可以清晰看到同一条演进主线:把 I/O 从全局隐式状态(sys.stdin / 文件重定向)变成显式注入的参数,让测试彻底脱离真实终端。

4. 可测试性红利:面向脚本与自动化调用

input / output 注入带来的不仅是一两个测试用例的便利。return_coder=Trueforce_git_root 等新参数与句柄注入叠加后,aider 的 main() 已经可以作为一个"可编程函数"被其他 Python 代码直接驱动,这也是 scripting.md 介绍的用法。一次针对测试的改造,最终反哺出了项目级的脚本化接口——这是依赖注入式重构最常见的长期回报。


十、从这则实录提炼的通用工作方法

  1. 多文件改动先铺测试侧契约,再让报错牵引生产侧补齐。实录中测试先行调用 main(..., input=..., output=...),随后以 pytest 的 TypeError 为路标修改签名,是一种低成本、可验证的推进顺序。

  2. 粘贴报错与 API 签名,是最有效的两种调试输入。整段会话的收敛速度几乎与用户提供的证据质量成正比:第一次 TypeError 一步修复,而 API 参数名则经历了两次猜测——直到用户贴出 PromptSession 构造签名才终结。

  3. 跑偏时果断 ^C。中断不是失败,而是把预算从"无用的长回复"中省下来,用于"一条澄清 + 一条更准的回复"。

  4. 善用 git 保护与对话外编辑。aider 自动应用编辑、自动提交,并能在检测到工作区带外变更时主动暂停征询提交意见(Commit before the chat proceeds [y/n/commit message]?),让每一次变更都留痕、可回滚。

  5. 把 I/O 句柄设计为显式参数。从 /dev/nullcreate_pipe_inputcreate_input(StringIO(''))DummyInput/DummyOutput 的演进,本质都是让程序"输入从哪来、输出到哪去"可被替换,从而获得脱离真实终端环境做自动化测试(乃至脚本化复用)的能力。


延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
898
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
921
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
519
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
391