你让 Codex 给 UserService.ts 加一个 validate() 方法。它返回一份漂亮的 diff——但 diff 里建了一个 UserServiceV2.ts 把方法放进去了,原文件一个字都没改。结果:你的 import 还指向旧类,新文件是死代码,最后还是你自己手动把变更挪回去。
这是 Codex 的「对冲」行为:当原文件看起来「复杂」或它不确定要保留哪些时,干脆建个兄弟文件绕过合并问题。修复办法是明确的就地编辑规则 + AGENTS.md 守则 + git 兜底。
常见原因
按命中率从高到低:
1. Prompt 里用了「new version」「v2」这种词
Codex 对文件命名词非常字面。「写一个 utils.ts 的 new version」会产出 utils-new.ts;「做个 v2」就给你 utils.v2.ts。线索是你自己给的,不是 Codex 主动选的。
如何判断:在 prompt 里搜 “new”、“v2”、“improved”、“refactored”。改成「edit in place」重跑一遍,重复文件就消失。
2. 原文件里有 Codex 不愿动的写法
utils.ts 用了自定义 decorator 或不常见的泛型——Codex 训练数据里覆盖不深。它就对冲:原文件不动,新文件用它熟悉的写法重写。
如何判断:对比新旧文件的写法——新的明显更朴素(裸函数 vs 带 decorator 的类),这种不对称就是信号。
3. AGENTS.md / CLAUDE.md 里没写规则
没明确规则时,Codex 的默认偏向「新建」——因为新建可逆(删掉就行),而覆盖不一定可逆(取决于 harness)。
如何判断:cat AGENTS.md | grep -i "in place\|do not create"——空就是没规则。
4. 对原文件的 diff 太大没贴上
Codex 试了就地 edit,patch 因为行号对不上失败了,于是退化成「整文件新建」。这个 fallback 不一定会在日志里说。
如何判断:在 chat 里搜 “patch failed” / “could not apply”——出现在新建文件之前就是这个原因。
5. 权限范围禁了覆盖
某些 Codex 沙箱默认 “可新建、禁覆盖”。Codex 绕开限制就建了个并行文件。
如何判断:对 utils.ts 做个 no-op edit(加个空格)。报权限错就是 harness 拦了写。
6. Task 本来就是要并行实现
「加一个 utils.js 的 TypeScript 版本」「做一个严格模式变种」——这两种本来就要两份文件。先确认你不是要”就地替换”。
如何判断:回看原 task。没出现「就地」「替换」这种词,重复可能是合规的。
最短修复路径
按收益从高到低,Step 1 一步覆盖 70% 的情况。
Step 1:删掉重复文件,用明确「就地」措辞重 prompt
删掉新建那个,保留原文件,发:
直接就地(IN PLACE)编辑 `src/utils.ts`。
- 不要新建文件。
- 不要重命名文件。
- 直接改原文件。
- 如果原文件太复杂无法干净编辑,停下来解释为什么,不要建新文件。
「停下来解释」这条特别关键——给 Codex 一个”卡住时该做什么”的出口,避免它逃到”新建文件”。
Step 2:把规则永久写进 AGENTS.md
在 repo 根目录加进 AGENTS.md:
## 文件创建策略
- 永远就地编辑现有文件。
- 不允许的变种命名:`*-new.ts`、`*.v2.ts`、`*_copy.ts`、`*-improved.ts`、`*-refactored.ts`。
- 仅以下情况可以建新文件:
- 用户明确说 "create a new file"
- 新功能确实需要新模块
- 给现有模块加测试文件(如 `utils.test.ts`)
- 就地 edit 风险大时,先停下来问,不要自作主张建新文件。
Codex 每个 task 都会读这个,规则跨 session 生效。
Step 3:用 git 兜底,不要靠新建文件兜底
Codex 之所以”对冲”,是怕弄坏原文件。把这个担忧用 git 接走:
# 改动前打一个 checkpoint
git add -A && git commit -m "checkpoint before Codex edit"
# 让 Codex 就地改
# ... 如果改坏了:
git restore src/utils.ts # 一行回滚
在 prompt 里告诉它:
当前状态已经 commit,改坏了我可以 `git restore` 立即回滚。
请就地 edit——git 是安全网,不要靠建新文件兜底。
Step 4:清理历史遗留的重复文件
repo 里之前的 session 留下的 *-v2、*-new、*-copy 全部列出来:
find src -type f \( \
-name "*-new.*" -o \
-name "*.v2.*" -o \
-name "*-v2.*" -o \
-name "*_copy.*" -o \
-name "*-copy.*" -o \
-name "*-refactored.*" \
\) | sort
每一对决定保留哪个:
| 旧文件 | 新文件 | 处理 |
|---|---|---|
| utils.ts | utils-new.ts | utils-new 内容并回 utils.ts,删 utils-new |
| UserService.ts | UserServiceV2.ts | 如果 import 用 V2,rename V2 → 原名;删未被 import 的那个 |
然后用 IDE 的 “rename symbol” 批量改 import。
Step 5:在 PR review 阶段拦截变种文件名
CI 加一条:新增文件名匹配变种模式就 fail:
# .github/workflows/no-variant-files.yml
- name: Block variant filenames
run: |
if git diff --name-only --diff-filter=A origin/main...HEAD | grep -E '(\-new|\.v2\.|\-v2\.|_copy\.|\-copy\.|\-refactored)' ; then
echo "不允许变种文件名,请就地编辑。"
exit 1
fi
Step 6:真要并行实现,按用途命名而不是按版本
两种实现真要共存(老 API + 新 API),按功能命名,不要按版本:
auth/oauth.ts + auth/magic-link.ts ← 好
api/users-v1.ts + api/users-v2.ts ← 公开 API 版本可接受
api/utils.ts + api/utils-new.ts ← 永远不要
这个命名约定能让 Codex 不把”改”理解成”再来一个变种”。
预防建议
- 「就地编辑」写进 AGENTS.md 一次就够——Codex 每个 task 都读
- 除非真要分文件,否则 prompt 里不要出现 “new version”、“v2”、“improved”、“refactored”
- 改高风险代码前先 commit 一次,让 Codex 不必”防灾性新建”
- CI 加一条变种文件名 lint——人工 review 漏掉的它能拦
- 季度审一次现存变种文件——它们悄悄堆积、最后让 import target 一团乱
- 真要并行实现,按功能命名(
oauth+magic-link),不要按版本
相关阅读
标签: #Codex #Coding Agent #排查 #排查 #复制文件