手写 Agent ③,让它会改代码,edit_file

将整文件重写改为精确字符串替换,设计 edit_file、新建文件和修改前检查,减少无关 diff 并验证改动。

outlier 14 分钟 5363 字 0 0
系列教程Agent从入门到放弃 第 26 / 32 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02大模型、Chatbot、Agent,一次把名字理清楚
  3. 03Agent 的最小定义,一个 while 循环
  4. 04Agent 简史,从 ReAct 到 Claude Code
  5. 05Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
  20. 20Function Calling 协议拆解
  21. 21工具设计,写给模型用的 API
  22. 22MCP 入门,工具界的 USB-C
  23. 23用 Go 手写一个 MCP Server
  24. 24MCP 进阶与边界
  25. 25手写 Agent ②,第一个工具,read_file 与 list_files
  26. 26手写 Agent ③,让它会改代码,edit_file
  27. 27手写 Agent ④,给它一个终端,run_bash 与权限
  28. 28手写 Agent ⑤,上下文管理
  29. 29手写 Agent ⑥,流式输出、错误处理与重试
  30. 30手写 Agent ⑧,Skills 与 MCP 接入
  31. 31Claude Code 解剖,对照自己的实现
  32. 32自动化,Hooks、自定义命令与 Headless 模式
查看教程目录

开场:它修好了一个 bug,顺手删了三个函数

我给 mini-agent 写的第一版"改文件"工具,非常简单:

{"name": "write_file", "parameters": {"path": "...", "content": "文件的完整新内容"}}

模型想改哪个文件,就把整个文件的新内容写出来,我原样覆盖进去。实现只有五行,我很满意,觉得自己找到了最简洁的设计。

然后我让它修一个折扣计算的 bug,在一个三百多行的文件里,问题只有一行:(1 + d) 应该是 (1 - d)。

它修好了,测试也过了。我很高兴,顺手看了一眼 git diff,然后高兴不起来了:diff 有一百多行。 它把那一行改对了,同时还"顺手"调整了几处注释的措辞,把两个函数的顺序换了一下,而文件末尾三个它可能觉得"没用"的辅助函数,消失了。

不是故意的。它要重新输出三百多行,凭记忆复述一个它刚读过的文件,难免有地方复述得不一样。就像让你默写一篇刚读过的三页文章,意思大概都对,但措辞、顺序、细节,总会和原文有出入。大部分地方一样,少部分地方"差不多",个别地方忘了。而这些改动混在一百多行的 diff 里,我差点就没发现。

那天我花了半个小时,对着那一百多行 diff,一行一行地把它"顺手"改掉的地方恢复回去。之后,我把 write_file 换成了现在的 edit_file:模型不再重写整个文件,而是给出"把这段原文替换成那段新文"。 这一篇讲这个工具的设计。读完本篇,你应该能回答这几个问题:

  • 为什么不让模型整个文件重写?
  • 字符串替换式的编辑是怎么工作的?为什么要求原文在文件里唯一?
  • 怎么防止模型改一个它没读过、或者已经过时的文件?
  • 新建文件怎么处理?
  • 一个工具是怎么被真实的翻车现场一点点打磨出来的?
  • 除了字符串替换,还有哪些改法?它们为什么没被选中?

一、为什么不整文件重写

开场的故事已经说明了问题。整理一下,整文件重写有两个根本的缺陷:

一是费 token。 改一行,要把整个文件重新输出一遍。第 07 篇说过,输出 token 最贵、最慢。一个三百行的文件,改一行要输出几千个 token,等十几秒;改十处,就是十次。

二是危险,而且危险是隐蔽的。 模型重写文件,本质上是在"凭记忆复述",复述就会走样:注释被改写、空行被删掉、顺序被调整、某个函数被遗漏。这些改动不是它想做的,也不在你的预期里,而且它们会混在真正的修改里,很难从结果里看出来。

flowchart TB subgraph W["整文件重写"] W1["模型输出 300 行"] --> W2["真正想改的:1 行"] W1 --> W3["顺手改的、忘了的、复述走样的:
不知道多少行"] end subgraph E["字符串替换"] E1["模型输出:原文 1 行 + 新文 1 行"] --> E2["只改这 1 行
其余一个字节都不动"] end style W3 fill:#ffcdd2,stroke:#b71c1c style E2 fill:#e8f5e9,stroke:#2e7d32

字符串替换反过来:模型只输出要改的那一小段,文件里其余的部分,一个字节都不会被碰到。 改动的范围是精确的、可预期的,你看 diff 的时候也一目了然。

二、字符串替换

2.1 定义

agent/tools/edit_file.go 里的工具定义:

func (EditFile) Definition() llm.Tool {
    return llm.Tool{
        Name: "edit_file",
        Description: "修改文件:把 old_str 替换成 new_str。old_str 必须与文件中的原文逐字一致(包括缩进和空白)," +
            "并且在文件中只出现一次;如果出现多次,请在 old_str 里多带几行上下文,或者设置 replace_all=true。" +
            "old_str 为空表示新建文件,内容为 new_str。修改已有文件之前必须先用 read_file 读过。" +
            "注意:read_file 输出里每行开头的行号和制表符不是文件内容,不要放进 old_str。",
        InputSchema: schema(`{
            "type": "object",
            "properties": {
                "path":        {"type": "string", "description": "文件路径,相对于工作目录"},
                "old_str":     {"type": "string", "description": "要被替换的原文;新建文件时留空"},
                "new_str":     {"type": "string", "description": "替换后的新内容"},
                "replace_all": {"type": "boolean", "description": "替换所有出现的地方,默认 false"}
            },
            "required": ["path", "old_str", "new_str"]
        }`),
    }
}

修那个折扣 bug,模型只需要这样调用:

{"path": "order.go", "old_str": "return p * (1 + d)", "new_str": "return p * (1 - d)"}

这个描述比前面几个工具都长,因为它的用法最讲究,出错的方式也最多。描述里的每一句,后面都会讲到它是怎么来的。

2.2 其他几种改法

在定下"字符串替换"之前,我也认真考虑过另外几种格式。它们各有道理,值得摆在一起看:

格式模型输出什么优点毛病
整文件重写整个文件的新内容最简单,模型最熟悉费 token,悄悄改坏别处
按行号替换"第 37 到 39 行换成……"输出很少行号一改全乱,模型数不清行
unified diff一段 @@ -37,3 +37,3 @@ 格式的补丁通用,git 认识补丁头里的行号和上下文行数要算对,模型经常算错
字符串替换一段原文 + 一段新文不依赖行号,范围精确原文要逐字一致,空白字符容易出错

unified diff 看起来最"专业",但它要求模型同时做对两件事:补丁的内容,和补丁头里那几个数字。后者正好是第 06 篇说的模型最不擅长的那类"数数"工作。字符串替换只要求一件事:把原文抄对。而"抄对一段刚读过的文字",恰恰是模型很擅长的。

还有一个不那么技术、但很实际的原因:这种格式被很多编码 Agent 采用,模型在训练里见得多。 第 11 篇 3.3 节说过,工具的形状越接近模型熟悉的样子,它用得越顺手。

2.3 为什么不用行号

一个很自然的想法是:"把第 37 行改成 xxx"。为什么不这样设计?

第 06 篇和第 13 篇都讲过:模型数不清行。 它看到的行号来自 read_file 的输出,但改了一处之后,后面所有的行号都变了;它还得记住"我刚才在第 20 行插了 3 行,所以原来的第 37 行现在是第 40 行"。这种簿记工作,模型很容易出错,而且错了不会报错,只会悄悄改错地方。

字符串替换不依赖行号,原文本身就是定位。只要原文在文件里能找到,改的就一定是那里;前面插了几行、删了几行,都不影响后面的定位。

2.4 唯一匹配

这是整个工具最关键的约束:old_str 必须在文件里只出现一次。

content := string(data)
count := strings.Count(content, in.OldStr)
switch {
case count == 0:
    // ……没找到,后面讲
case count > 1 && !in.ReplaceAll:
    return "", fmt.Errorf("old_str 在 %s 中出现了 %d 次,无法确定改哪一处。请在 old_str 里多带几行上下文让它唯一,或者设置 replace_all=true", in.Path, count)
}

为什么?想象文件里有两处 return nil,模型说"把 return nil 改成 return err"。它想改的是哪一处?程序不知道,模型可能自己也没想清楚。匹配到多处,说明模型给的上下文不够,这时候猜一个去改,很可能改错。

所以出现多次就拒绝,并且告诉它怎么办:多带几行上下文,让它变得唯一。比如把函数签名也带上:

{"old_str": "func Load(path string) error {\n\tif path == \"\" {\n\t\treturn nil", "new_str": "..."}

如果它确实想改所有地方(比如重命名一个变量),就显式地设置 replace_all=true。默认安全,显式才能批量。不过即使是重命名,replace_all 也要小心:把 id 全部替换成 userID,valid、provider 这些包含 id 的词也会被一起改掉。真正的重命名,更好的办法是让 Agent 用语言自带的重构工具,比如 Go 的 gopls rename。

2.5 没找到时的提示

old_str 一处都没找到,是最常见的失败。原因通常是模型复述原文时走样了:空格和 tab 搞混了,漏了一个空行,或者引号不一样。

case count == 0:
    hint := "请先用 read_file 确认原文,注意缩进(空格还是 tab)和空白行。"
    if lineNumberPrefix.MatchString(in.OldStr) {
        hint = "old_str 里似乎带上了 read_file 输出的行号前缀,请去掉行号和后面的 tab。"
    }
    return "", fmt.Errorf("在 %s 中没有找到 old_str。%s", in.Path, hint)

两种提示:

  • 通用的:用 read_file 确认原文,注意缩进和空白行;
  • 针对性的:如果 old_str 里看起来带着行号前缀,就直接告诉它"去掉行号"。

后一种,就是开场说的"被翻车现场打磨出来的"。

为什么"没找到"这么常见?因为"逐字一致"比看上去难。人看代码时,四个空格和一个 tab 长得一模一样,行尾多一个空格根本看不出来;模型也一样,它看到的是 read_file 输出里的文字,而它复述的时候,是在一个 token 一个 token 地重新生成(第 07 篇)。Go 代码用 tab 缩进(gofmt 的规定),模型在别的语言里见惯了空格缩进,复述时偶尔就会把 tab 写成空格。

这也是为什么 old_str 最好短一点:只带上能让它唯一的最少几行。带的行越多,抄错一个空白字符的机会就越多。唯一性和准确性之间,要找一个平衡:先带一两行,提示"出现了多次"再多带几行。

三、一个工具是怎么被打磨出来的

3.1 行号的副作用

上一篇讲过,read_file 的输出每行前面都带行号:

     4        return p * (1 + d)

这对模型理解代码、引用位置很有帮助。但我很快发现了一个副作用:模型在构造 old_str 时,经常把行号和后面的 tab 一起抄了进去:

{"old_str": "     4\t\treturn p * (1 + d)", "new_str": "     4\t\treturn p * (1 - d)"}

文件里当然没有 4\t 这个前缀,于是匹配失败。模型看到"没有找到 old_str,请先用 read_file 确认原文",就去重新读文件,读到的还是带行号的输出,于是又抄了一遍行号……陷入循环。

3.2 三层修补

我做了三件事:

  1. 在描述里加一句话:"read_file 输出里每行开头的行号和制表符不是文件内容,不要放进 old_str。"这能预防大部分情况;
  2. 在错误信息里做针对性提示:用一个正则检测 old_str 里有没有行号前缀,有的话,错误信息直接说"请去掉行号和后面的 tab":

    // read_file 输出的行号前缀,形如 "    12\t"。模型经常不小心把它抄进 old_str。
    var lineNumberPrefix = regexp.MustCompile(`(?m)^\s*\d+\t`)
  3. 写一个测试,确保这个提示一直在:

    // 没找到,且带了行号前缀:给出针对性提示
    _, err = run(t, ef, `{"path":"order.go","old_str":"     4\treturn p","new_str":"x"}`)
    if err == nil || !strings.Contains(err.Error(), "行号") {
        t.Fatalf("应提示去掉行号,err=%v", err)
    }
flowchart LR F["翻车:模型把行号
抄进了 old_str"] --> D["描述里加一句
预防"] F --> E["错误信息里针对性提示
纠正"] F --> T["写个测试
别让它退化"] style F fill:#ffcdd2,stroke:#b71c1c style D fill:#e8f5e9,stroke:#2e7d32 style E fill:#e8f5e9,stroke:#2e7d32 style T fill:#e8f5e9,stroke:#2e7d32

这就是第 20 篇说的"工具描述里最有价值的句子,往往来自真实的翻车现场"。好的工具不是一次设计出来的,是看着模型一次次犯错、一次次修补出来的。预防(描述)、纠正(错误信息)、防退化(测试),三层一起上。

四、改之前必须读过

4.1 问题

还有一类问题更隐蔽:模型改了一个它没读过的文件,或者它读的是旧版本。

  • 没读过:它凭"印象"构造 old_str。运气好的话匹配失败;运气不好的话,它记得的那段代码在文件里恰好存在,但上下文和它想的不一样,改出来的结果就是错的;
  • 读的是旧版本:它半小时前读过这个文件,之后你手动改过,或者它自己改过别处、运行过格式化工具。它现在看到的上下文里的内容已经过时了,基于过时的内容做修改,很容易出错。

4.2 记录读取时间,修改前检查

上一篇提到,read_file 读完一个文件后会调用 markRead,记下它当时的修改时间。edit_file 修改之前,会调用 checkFresh 检查:

// checkFresh 确认文件读过,而且读过之后没有被别人改过。
func (w Workspace) checkFresh(path string) error {
    if w.seen == nil {
        return nil
    }
    v, ok := w.seen.Load(path)
    if !ok {
        return fmt.Errorf("修改 %s 之前必须先用 read_file 读一遍,确认它现在的内容", w.Rel(path))
    }
    if info, err := os.Stat(path); err == nil && !info.ModTime().Equal(v.(time.Time)) {
        return fmt.Errorf("%s 在你上次读取之后被修改过了,请重新 read_file 再编辑", w.Rel(path))
    }
    return nil
}

两个检查,两种错误信息,都告诉模型下一步该做什么:先去读,或者重新读。

还有一个细节:edit_file 修改成功之后,会再调用一次 markRead:

t.WS.markRead(path) // 自己改的不算"被别人改过"

否则它改完第一处、紧接着改同一个文件的第二处时,会因为"修改时间变了"而被拒绝。

sequenceDiagram participant M as 模型 participant R as read_file participant E as edit_file participant W as Workspace M->>E: 改 order.go(没读过) E->>W: checkFresh W-->>E: 没读过 E-->>M: 错误:修改之前必须先 read_file M->>R: 读 order.go R->>W: markRead(记下修改时间) M->>E: 改 order.go E->>W: checkFresh:读过了,时间没变 E-->>M: 已修改 E->>W: markRead(自己改的不算)

4.3 修改时间这个信号,粗糙但够用

用文件的修改时间来判断"有没有被别人改过",是一个很粗糙的办法。它的好处是简单:不用存文件内容,不用算哈希,一次 os.Stat 就够了。但它也有几个你应该知道的特点:

  • 它会把"别人"算得很宽:你在编辑器里保存了一下、第 33 篇的 hook 在编辑之后自动跑了 gofmt、Agent 自己用 run_bash 跑了一句 sed -i,都会改变修改时间。对 edit_file 来说,这些都是"被别人改过",它会要求模型重新读一遍。多读一次的代价,比基于旧内容改错要小得多,所以这种"宁可错杀"是可以接受的;
  • 它不是万能的:有的工具会在改文件之后把修改时间设回原值,文件系统的时间精度也各不相同。如果你遇到"文件明明改了,检查却通过了"的情况,更可靠的做法是在 markRead 时记下内容的哈希,checkFresh 时比较哈希;
  • 它只管读过的那一刻:如果模型读了文件的前 2000 行,然后去改第 3000 行,checkFresh 照样通过,因为"读过"只看路径,不看读了哪一段。这种情况下,old_str 多半会匹配失败,错误信息会让它回去读对应的那一段。

一个检查不需要完美,只需要在它最常见的出错方式上拦得住。 这和第 25 篇的工作目录限制是同一个思路。

五、新建文件

5.1 用空的 old_str 表示新建

新建文件没有"原文",所以约定:old_str 为空,表示新建文件,内容是 new_str。

data, err := os.ReadFile(path)
if os.IsNotExist(err) {
    if in.OldStr != "" {
        return "", fmt.Errorf("文件 %s 不存在。如果要新建文件,请把 old_str 留空", in.Path)
    }
    return t.create(path, in.NewStr)
}
// ……
if in.OldStr == "" {
    return "", fmt.Errorf("文件 %s 已经存在,old_str 不能为空。请先 read_file,再指定要替换的原文", in.Path)
}

两个方向都有检查:

  • 文件不存在,但 old_str 不为空:模型以为它在改一个已有的文件,其实路径写错了。提示它:要新建的话,把 old_str 留空;
  • 文件已存在,但 old_str 为空:模型想"新建"一个已经存在的文件,等于要用 new_str 覆盖整个文件。这正是开场那种整文件重写,拒绝。

第二条特别重要。没有它,edit_file 就留了一个"后门":模型可以通过"新建"一个已存在的文件,绕开所有的唯一匹配检查,重新回到整文件重写的老路。

5.2 自动创建目录

func (t EditFile) create(path, content string) (string, error) {
    if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
        return "", err
    }
    // ……写文件,markRead,返回"已新建 xxx(N 行)"
}

新建 pkg/util/strings.go 时,如果 pkg/util 目录不存在,就自动创建。否则模型还得先去跑一次 mkdir,多绕一圈。

六、改完之后:给它看一眼结果

修改成功后,返回的不是简单的"已修改",而是修改后那一段的上下文:

start := strings.Index(updated, in.NewStr)
return fmt.Sprintf("已修改 %s(%d 处)。修改后的片段:\n%s", in.Path, replaced, snippet(updated, start, len(in.NewStr))), nil

snippet 会返回改动位置前后各几行,带行号:

已修改 order.go(1 处)。修改后的片段:
     1    package shop
     2    
     3    func Discount(p, d float64) float64 {
     4        return p * (1 - d)
     5    }
     6    
     7    var a = 1

这样模型能立刻确认自己改对了:缩进对不对、有没有改错位置、上下文是不是它预期的样子,而不用再调用一次 read_file。省一圈,也省得它在上下文里留下一份同一个文件的重复副本。

还有一些小的防御:old_str 和 new_str 完全相同时直接报错(模型有时会提出一个"什么都没改"的修改);写文件时保留原文件的权限(一个可执行的脚本改完之后还应该是可执行的)。

6.1 一个还没修的小毛病

读这段代码时,如果你足够仔细,会发现一个问题:

start := strings.Index(updated, in.NewStr)

它在修改后的整个文件里,找 new_str 第一次出现的位置,作为片段的中心。如果 new_str 恰好在文件更前面的地方也出现过呢?比如模型把第 80 行的 return nil 改成了 return err,而文件第 12 行本来就有一个 return err。这时返回的片段,会是第 12 行附近的代码,而不是它真正改的第 80 行。

模型看到一个"看起来没改"的片段,可能会以为修改没有生效,再改一次,或者去重新读文件。不致命,但会让它多绕几圈。

正确的做法是用替换之前 old_str 的位置:strings.Index(content, in.OldStr) 算出的偏移量,在替换后的文件里,就是 new_str 开始的位置(第一处替换之前的内容没有变)。这是一个改三行的小练习,改完记得在 edit_file_test.go 里补一个测试:构造一个"new_str 在前面已经出现过"的文件,检查返回的片段里有没有真正改动的那一行。

哪怕是被测试覆盖得很好的代码,也藏着测试没想到的情况。 第 35 篇讲验证时会再说这件事。

6.2 它没有处理的几种情况

除了这个小毛病,edit_file 还有几种情况是刻意没有处理的,用之前要心里有数:

  • Windows 换行符:如果文件用的是 \r\n 换行,模型构造 old_str 时多半只写 \n,匹配会失败。错误信息会让它"注意缩进和空白行",但不会直接告诉它是换行符的问题;
  • 写到一半的崩溃:它用 os.WriteFile 直接覆盖原文件。第 22 篇的书签 store 是先写临时文件再改名,写到一半崩了也不会坏;edit_file 没有这么做,极端情况下文件可能只写了一半。对一个跑在 git 仓库里的编码 Agent,这个风险可以接受,因为 git checkout 能救回来;
  • 撤销:它没有"撤销上一次修改"的功能。成品的编码 Agent 大多有某种形式的检查点或者撤销(截至写作时,各家做法不同),mini-agent 把这件事完全交给了 git。所以让它改代码之前,先 commit 一下手头的工作,这个习惯值得养成。

七、动手:看它改代码

实验 1:修一个 bug

要看模型自己改代码,需要接真实模型:先 export DEEPSEEK_API_KEY=sk-...。在线 Playground 跑不了 mini-agent。

准备一个有 bug 的小项目,比如:

mkdir -p /tmp/shop && cd /tmp/shop
go mod init shop
cat > order.go <<'EOF'
package shop

// Discount 返回打折后的价格,d 是折扣率,比如 0.1 表示打九折。
func Discount(price, d float64) float64 {
    return price * (1 + d)
}
EOF

然后启动 mini-agent(第 25 篇说过怎么在别的目录运行它),说:"order.go 里的折扣算反了,帮我改一下。"

mini-agent 默认的权限模式下,改文件之前会问你一句(第 27 篇)。看看它给出的 old_str 和 new_str 是什么样的,确认之后,用 git diff 或者 cat order.go 看看是不是只改了那一行。

实验 2:制造几次失败

接着在同一个会话里试试:

  1. 先手动改一下 order.go(比如加一行注释),再让它改别的地方。它会收到"被修改过了,请重新 read_file";
  2. 在文件里加两个一模一样的函数,让它改其中一个。看它在收到"出现了 2 次"之后,是怎么多带上下文让匹配变唯一的;
  3. 让它"新建一个 order.go,内容是……"。看它收到"文件已经存在,old_str 不能为空"之后怎么做。

实验 3:跑测试

agent/tools/edit_file_test.go 把上面每一种情况都测了一遍:没读过不许改、正常修改、多处匹配被拒绝、replace_all 允许批量、带行号前缀时的针对性提示、被外部修改后要求重读、新建文件、不能用空 old_str 覆盖已有文件。

go test ./agent/tools -run TestEditFile -v

实验 4:修掉 6.1 节的小毛病

按 6.1 节的思路改 edit_file.go,并补上测试。改完跑一遍:

go test ./agent/tools -run TestEditFile -v

这是整个第五部分第一个"让你改 mini-agent 本身"的练习。改的时候留意一下:你是先写测试、看它失败,还是先改代码?第 35 篇会讲为什么前一种顺序更好。

八、踩坑记录:改文件的七个坑

坑 1:让模型整文件重写。
开场的故事。费 token,而且会悄悄改坏它没打算改的地方。用字符串替换,只改需要改的那一段。

坑 2:匹配到多处就改第一处。
模型想改的可能是第二处。匹配不唯一就拒绝,让它多带上下文;确实要改所有地方,显式地 replace_all。

坑 3:用行号定位。
改一处,后面的行号全变了,模型会改错地方,而且不报错。用原文定位。

坑 4:允许改没读过的文件。
模型凭印象构造原文,碰巧匹配上了,改出来的东西和它想的不一样。改之前必须读过,而且读过之后没变过。

坑 5:留了"用新建覆盖"的后门。
文件已存在、old_str 为空,如果当成新建来处理,就等于允许整文件重写。拒绝它。

坑 6:改完只说"已修改"。
模型不知道改得对不对,只能再读一遍文件,多绕一圈,上下文里还多了一份重复的文件内容。返回修改位置前后的片段,让它当场确认。

坑 7:让 Agent 在没有 git 的目录里改代码。
edit_file 没有撤销,改坏了只能靠版本控制。在一个没有 git init 的目录里让它改一堆文件,改坏了,你连"它改了什么"都很难说清楚。开工之前先确认有版本控制,并且工作区是干净的。

一句话总结

edit_file 用字符串替换代替整文件重写:模型只给出要改的原文和新文,原文必须逐字一致、在文件里唯一,改之前必须读过、读过之后没变过;空的原文表示新建,但不能用来覆盖已有文件;改完返回修改后的片段让它确认。每一条约束和每一句错误提示,都来自一次真实的翻车。

flowchart LR R["read_file
记下修改时间"] --> E["edit_file"] E --> C1{"读过、没变过?"} C1 -->|"否"| X1["请先读 / 重新读"] C1 -->|"是"| C2{"old_str 唯一?"} C2 -->|"0 处"| X2["没找到:检查缩进、去掉行号"] C2 -->|"多处"| X3["多带上下文,或 replace_all"] C2 -->|"1 处"| OK["替换,返回修改后的片段"] style OK fill:#e8f5e9,stroke:#2e7d32

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭