一个 \r引发的重试循环:AI Agent CLI 在 Windows 上遭遇的 CRLF 匹配病
适用读者:写 AI Agent / LLM 工具链、做代码编辑自动化、或者任何在 Node 里做「字符串精确替换文件内容」的开发者。尤其如果你跨 Windows / macOS 协作,这篇能帮你少踩一个很隐蔽的坑。
问题长什么样
我维护的 agent-cli 有几个写文件的工具:edit_file、multi_edit、delete_range。逻辑很朴素——读文件,indexOf 找到 old_text,替换成 new_text,写回去。在 macOS 上跑得好好的,一上 Windows 就开始抽风。
抽风的方式很典型:模型调用 edit_file,工具返回 TEXT_NOT_FOUND;模型以为自己抄错了,重新 read_file,再调 edit_file,还是 TEXT_NOT_FOUND;再来一次,依旧失败。会话日志 e65f0205 里 round 8 / 10 / 13 连着三次同样的错误码,token 烧了一堆,文件一个字没改。
更恶心的是,Reflector(我给 Agent 写的「失败反思」模块)本来应该在这种时候给模型一个提示,结果它把 TEXT_NOT_FOUND 误判成了「写根外」(路径越权),注入了一条完全跑偏的提示,模型更懵了。
根因:rn ≠ n,indexOf 不背锅
先看问题出在哪。edit_file 原来的核心代码长这样:
复制代码const content = await readFile(filePath, "utf-8");
const firstIndex = content.indexOf(args.old_text);
if (firstIndex === -1) {
return { success: false, data: "未找到 old_text", error: "TEXT_NOT_FOUND" };
}
const newContent = content.replace(args.old_text, args.new_text);
await writeFileWithEol(filePath, content, newContent);
看起来没毛病。但在 Windows 上,content 长这样(注意每行末尾的 r):
复制代码"line onernfoo barrnline three"
而 LLM 给的 old_text 是它从 read_file 输出里「看」到的内容。问题是——read_file 原来是这样拆行的:
复制代码const lines = content.split("n");
"line onernfoo barrnline three".split("n") 会得到 ["line oner", "foo barr", "line three"]。每行末尾挂着一个 r,展示给模型的时候虽然肉眼不太看得出来,但模型一旦把这段内容当作 old_text 原样回传,字符串就变成了 LF 版本(n),而文件里是 CRLF(rn)。
"line onernfoo barrnline three".indexOf("foo bar") 还能凑巧命中(因为 foo bar 中间没换行)。但只要 old_text 跨行——比如 "bncnd"——就彻底完蛋:文件里是 "brncrnd",indexOf("bncnd") 返回 -1,铁定 TEXT_NOT_FOUND。
一句话总结根因:
解法:匹配搬进 LF 空间,落盘再还原原 EOL
思路其实很直接。既然矛盾出在「文件是 CRLF、LLM 给的是 LF」,那就让匹配只发生在 LF 空间,落盘的时候再还原成原文件本来 的 EOL。这样既不会误匹配,也不会产生 EOL 翻转的 diff 噪声。
第一步:一个 toLf 函数
新加的 toLf 只做一件事——把行尾统一成 n,仅供匹配/比较用,不落盘:
复制代码// src/tool/eol.ts
/**
* 把行尾统一为 LF(n),仅供「匹配 / 比较」使用,不要用于落盘。
*
* 策略:匹配前对文件内容与待匹配文本都做 LF 归一化,落盘时再由
* normalizeEol 还原为原 EOL,既避免误匹配又不产生 EOL 翻转噪声。
* 仅替换 `rn` → `n`;罕见的独立 `r`(旧 Mac 风格)也一并归一。
*/
export function toLf(text: string): string {
if (!text.includes("r")) return text;
return text.replace(/rn/g, "n").replace(/r/g, "n");
}
两个细节值得说一下:
- 快路径:
if (!text.includes("r")) return text—— 大部分文件本来就是 LF,直接返回原字符串,不创建新对象,零成本。 - 两步 replace:先
rn→n,再单独的r→n。顺序不能反,否则rn会先被拆成nn,行数翻倍。独立r(老 Mac 风格)现在基本见不到了,但顺手归一一下保持行语义完整。
第二步:edit_file 在 LF 空间匹配,落盘还原
改完的 edit_file 核心逻辑:
复制代码// src/tool/builtins/edit-file.ts
const content = await readFile(filePath, "utf-8");// 匹配在 LF 归一化空间进行:原文件可能是 CRLF,而 LLM 习惯用 LF
// 编写 old_text/new_text。这里把两端都归一为 LF 再 indexOf,落盘时
// 再由 normalizeEol 还原为原 EOL。
const contentN = toLf(content);
const oldTextN = toLf(args.old_text);const firstIndex = contentN.indexOf(oldTextN);
if (firstIndex === -1) {
return { success: false, data: "未找到 old_text", error: "TEXT_NOT_FOUND" };
}const secondIndex = contentN.indexOf(oldTextN, firstIndex + 1);
if (secondIndex !== -1) {
return { success: false, data: "old_text 出现多次", error: "TEXT_MULTIPLE_MATCHES" };
}// 在 LF 空间拼出新内容
const newContentN =
contentN.slice(0, firstIndex) +
toLf(args.new_text) +
contentN.slice(firstIndex + oldTextN.length);// 按原文件 EOL 还原后再落盘,diff 也基于实际落盘内容计算
const writtenContent = normalizeEol(content, newContentN);
await writeFile(filePath, writtenContent, "utf-8");
注意三个点:
- 两端都归一:
content和args.old_text都过toLf。只归一一端没用,必须对齐到同一个空间。 - 拼新内容也在 LF 空间做:
new_text也过toLf,然后用slice拼接,避免replace在归一化后的字符串上行为不直观。 - 落盘前
normalizeEol还原:用原文件content探测 EOL 风格(CRLF 还是 LF),把 LF 空间的newContentN转回去。这一步是关键——不做的话,整个文件的行尾就从 CRLF 翻转成 LF 了,git diff全文件飘红,模型看到一堆「假改动」又会陷入修复循环。
normalizeEol 的实现长这样,核心是「按原 EOL 重新拼接行 + 保留原文件是否以行尾符结尾的特征」:
复制代码// src/tool/eol.ts
export function normalizeEol(originalContent: string, newContent: string): string {
const targetEol = detectEol(originalContent); // 探测原文件是 rn 还是 n
const lines = newContent.replace(/rn/g, "n").split("n");
const originalHasTrailing = hasTrailingNewline(originalContent);
const newHasTrailing = lines.length > 0 && lines[lines.length - 1] === ""; let result = lines.join(targetEol);
if (originalHasTrailing && !newHasTrailing) {
result += targetEol; // 原文件有尾换行,新内容没有,补上
}
if (!originalHasTrailing && newHasTrailing) {
result = result.slice(0, -targetEol.length); // 反之去掉
}
return result;
}
第三步:read_file 也归一化展示
光改写工具不够。read_file 展示给 LLM 的内容也得是干净的 LF,否则模型看到的还是带 r 的行,下次还是写不对 old_text。改一行:
复制代码// src/tool/builtins/read-file.ts
const content = await readFile(filePath, "utf-8");
// 按 LF 归一化后拆行:CRLF 文件每行末尾不再残留 `r`,展示干净,
// 也与 edit_file / multi_edit / delete_range 的 LF 归一化匹配保持一致
// —— LLM 看到什么就能直接拿去作 old_text/锚点。
const lines = toLf(content).split("n");
这是个一致性约束:读工具展示的空间,必须和写工具匹配的空间一致。否则读出来的内容模型拿去当 old_text,写工具又匹配不上,问题原样复现。
同步改 multi_edit 和 delete_range
multi_edit 是多步替换,同样的套路:整个替换循环在 LF 空间跑,最后一步 normalizeEol 还原落盘。
delete_range 用锚点(startAnchor / endAnchor)定位行号,锚点比较也要先 toLf:
复制代码// src/tool/builtins/delete-range.ts
function findUniqueLine(lines: string[], anchor: string, label: string) {
const anchorN = toLf(anchor);
const matches: number[] = [];
for (let i = 0; i < lines.length; i++) {
if (toLf(lines[i]!) === anchorN) { // 行内容也归一化再比
matches.push(i);
}
}
// ...
}
行拆分也得是 toLf(content).split("n"),否则每行带 r,锚点(LF)永远比不上。
验证:CRLF 文件,LF 编写,匹配成功且保留 CRLF
测试用例直接对准会话日志里的失败场景:
复制代码// tests/tool.test.ts
it("CRLF 文件用 LF 的 old_text 仍能匹配且保留原行尾", async () => {
const crlfFile = join(testDir, "crlf-test.txt");
await writeFile(crlfFile, "line onernfoo barrnline three", "utf-8");
const result = await editFileTool.execute(
{ path: crlfFile, old_text: "foo bar", new_text: "baz qux" },
createTestContext(),
);
expect(result.success).toBe(true);
const after = await readFile(crlfFile, "utf-8");
expect(after).toBe("line onernbaz quxrnline three"); // CRLF 保留
});it("CRLF 文件多行 old_text(LF 编写)能匹配", async () => {
const crlfFile = join(testDir, "crlf-multi.txt");
await writeFile(crlfFile, "arnbrncrnd", "utf-8");
const result = await editFileTool.execute(
{ path: crlfFile, old_text: "bncnd", new_text: "xny" }, // LF 编写
createTestContext(),
);
expect(result.success).toBe(true);
const after = await readFile(crlfFile, "utf-8");
expect(after).toBe("arnxrny"); // 落盘还原 CRLF
});
第二个测试是关键:old_text 是 "bncnd"(LF),文件是 "arnbrncrnd"(CRLF)。修复前 indexOf("bncnd") 必然 -1,修复后归一化匹配命中,落盘还原成 "arnxrny",CRLF 保住,git diff 只会显示真正改动的两行。
multi_edit(多步替换 + replaceAll)和 delete_range(LF 锚点定位)也都有对应测试,全过。
Trade-offs
这个方案不完美,有几个地方值得拎出来说。以下每一条都已记录在 bugfix/known-issues.md 里,标了状态和修复方向,后续要动可直接照着改。
1. 归一化匹配可能「误命中」
toLf 之后,old_text 里如果本来就有 r(比如用户真就想匹配一个带 r 的内容),会被归一掉,可能匹配到原本不该匹配的位置。实际场景里 LLM 几乎不会主动写 r,这个风险很低,但理论上存在。如果要做更严格,可以在归一化前先判断 old_text 是否含 r,含 r 就跳过归一化直接比——但这会让逻辑复杂一截,收益不大,我没做。
2. 独立 r(老 Mac 风格)被强行转成 n
toLf 的第二步 replace(/r/g, "n" 会把罕见的独立 r 也转成 n。现代项目基本不会有这种行尾,但如果你的代码库里有,落盘后行尾风格会变。normalizeEol 只探测 rn vs n,不处理独立 r,所以独立 r 的文件会被默默转成 LF。可接受,但要知道。
3. 性能:多一次全量 replace
toLf 对含 r 的文件会做两次正则 replace。对大文件(比如几 MB 的 minified JS)有开销,但 includes("r") 的快路径能挡掉所有 LF 文件,只有 CRLF 文件才走 replace。实测对几百 KB 的源码文件无感,没做进一步优化。
4. 只解决了「读-写」一致性,没解决「外部并发改」
如果模型读完文件、准备改的瞬间,外部(比如用户手动编辑)改了文件,old_text 还是会对不上。这是经典的 TOCTOU 问题,toLf 不解决它,需要文件锁或者乐观并发控制,那是另一个话题。
结论
整个修复的核心其实就一句话:让匹配发生在同一个行尾空间里。
toLf 是匹配空间,normalizeEol 是落盘空间,read_file 的展示空间和写工具的匹配空间保持一致。三个空间对齐了,CRLF / LF 的差异就不再是问题。模型看到什么就能拿去用,工具在内部做归一化,落盘还原原貌,git diff 干干净净。
这个坑之所以隐蔽,是因为在 macOS 上开发根本碰不到——LF 文件配 LF 的 old_text,天然一致。一上 Windows,CRLF 文件配 LF 的 old_text,空间错位,indexOf 字节级精确匹配直接摆烂。如果你也在做让 LLM 编辑代码的工具,跨平台测一下 Windows CRLF 文件,大概率你也有这个 bug,只是还没被触发。
一个 toLf,三个工具,一组测试,会话日志里 round 8/10/13 的三连失败变成一次过。性价比很高的修。
Further Reading
- POSIX 行尾 vs Windows 行尾的历史恩怨 —— 为什么这个世界同时存在
n和rn - git 的
core.autocrlf与.gitattributes—— git 自己怎么处理 EOL 归一化,思路其实是同源的 - Node.js Buffer.indexOf —— 字节级匹配,不感知行尾语义
- LLM 工具设计里的「输入空间 / 工具空间一致性」—— Claude 的 Computer Use 在坐标系统上也踩过类似的坑
-
07.01
izy文献润色
-
07.01
izy方法设计与数据分析
-
07.01
izy选题设计解析
-
07.01
izy文献综述:生成
-
07.01
经济学原理学科导师
-
07.01
文献综述撰写
-
-
-
-
-
- 都市赛博朋克时尚人像
- 07.01
-
- 家庭办公极简人像
- 07.01
-
-
下载
- 《神剑伏魔录》(神剑风云)游戏音乐合集
- 其他游戏|7.73 MB
- 一款非常好玩的武侠闯关游戏
-
-
下载
- 《行尸走肉第一章》免安装中文汉化硬盘版下载
- 单机|436 MB
- 一款以动作冒险为主题的游戏
-
-
下载
- 《街头霸王X铁拳》免安装中文汉化硬盘版下载
- 单机|111MB
- 一款非常好玩的格斗游戏
-
-
下载
- 《生化危机:浣熊市行动》免安装中文硬盘版下载
- 单机|6310 MB
- 一款以动作射击为主题的游戏
-
-
下载
- 《暗黑破坏神3》免安装繁体中文正式版下载
- 单机|7630 MB
- 一款以角色扮演为主题的游戏
-
-
下载
- 《马克思佩恩3》免安装硬盘版下载
- 单机|27033 MB
- 一款以第三人称射击为主题的游戏