作为一个天天和各类 Coding Agent 打交道的工程师,我最近被一个看似基础的问题缠住了:既然 Bash 工具已经能执行 cat、echo、sed、awk,几乎所有文件操作都能完成,为什么成熟一些的 Coding Agent 方案仍要单独暴露 read_file / write_file 这两个专用工具?
这个问题不搞清楚,你搭出来的 Agent 很容易走进两种极端:一种是给了一堆工具但模型根本不会用,另一种是图省事只给一个 Bash,结果真实项目一跑就翻车——上下文爆炸、文件被改坏、出了问题还没法回滚。我准备把这两类工具的边界彻底拆开讲清楚,再用一份最小实现告诉大家,read_file / write_file 到底在 Coding Agent 的系统设计里扮演什么角色。这篇文章适合正在自己搭建 Agent 的开发者,也适合被 Agent 各种迷惑行为搞得一头雾水的使用者。
1. 先说结论:Bash 是给“人”的终端工具,read_file/write_file 是给“模型”的上下文工具
1.1 同一个动作,两种完全不同的服务对象
先给一个我积累下来的总判断:Bash 这个接口,天生是给“坐在终端前的人类”设计的;而 Coding Agent 里的 read_file / write_file,是给“靠 token 思考的语言模型”设计的。两者服务对象不同,所以表面功能重叠,实质天差地别。
为什么这么说?我们看人是怎么用 Bash 的:一个程序员敲 cat 命令,屏幕哗啦啦滚过去几百行,人眼能快速扫关键部分,大脑会自己过滤噪声;我们根本不在乎输出里的每一个字节,也不需要系统帮我们记录这次命令读了哪几行。但 Coding Agent 不一样,工具调用返回的每一个字都会进入模型的上下文窗口,都得被“加工”。模型没有“人眼扫一下”的能力,它只能对已经进入上下文的文本做推理。
我打个比方:人用 Bash 就像开手动挡汽车,手脚并用换来完全掌控;模型用 Bash 则像让一个盲人通过拉杆在高速公路上开车。它必须精确知道每一步的状态,否则一步错步步错。read_file / write_file 这种专用工具,本质上把“要不要看、看多少、怎么看、改哪里、怎么改”这些决策,从“让模型自己猜 Bash 黑魔法”变成了“通过清晰的参数和返回值来协作”。
1.2 模型的“手”是 JSON 参数,不是命令字符串
另一个关键点是:今天主流 Coding Agent 调用工具,走的是函数调用机制,模型的输出会被解析成一个结构化的工具调用。它要生成的不是一行字符串命令,而是一个 JSON 对象,比如:
{ "name": "read_file", "arguments": { "path": "src/main.py", "offset": 0, "limit": 80 } }这个机制的隐含意思很重要:模型最强的能力是对语义做规划,最弱的能力是精确拼写带各种转义规则的命令文本。让模型去拼一条 bash 命令,等于逼它做字节级的语法编译;让模型只提供路径和意图,剩下由框架完成,等于把语法负担移交给确定性的代码。read_file / write_file 正是后一种思路的产物。
我见过不少 Agent 卡死,不是模型不会改代码,而是它在拼 bash 命令时把引号搞错了,一条 heredoc 写出去,文件直接坏掉。模型的脑力应该花在“改什么”上,而不是“怎么写这条 shell 命令不出错”。专用工具恰好把后者从模型身上卸掉了。
1.3 核心矛盾:上下文窗口是稀缺资源,Bash 输出是无限洪水
还有一层,也是最硬核的一层:上下文窗口。LLM 的上下文就像一个临时桌面,空间有限,桌面上堆着需求说明、已有代码、报错信息、工具返回值。Bash 工具的输出规模几乎不可控,cat 一个 5000 行的文件,输出可能轻松超过 8 万 token;而 read_file 可以只取 100 行,并且明确告诉你“文件共有 5000 行,这是第 1-100 行,后面还有”。
对 Coding Agent 来说,上下文就是命根子。一旦工具输出灌爆了上下文,最先被挤出去的就是用户需求、项目规范、之前的修改决定。Agent 表现得像“失忆”一样,开始胡写,这就是大量 Agent 翻车的根因。Bash 在设计时从未考虑过“输出预算”,它默认终端屏幕就那么大,人看完就丢了;而模型的推理却必须依赖进入上下文的全部文本。这一条差异,决定了文件读取这种高频操作绝不能粗暴地交给 cat。
2. 为什么文件读取需要专门的“翻书协议”
2.1 cat 一个真实翻车现场
我自己踩过一次非常典型的坑。当时让 Agent 重构一个 Python 项目里的数据管道脚本,脚本差不多 8000 行。Agent 先是想了解文件结构,直接用 Bash 的 cat 把整个文件读了出来。那一刻我其实没太在意,等它继续执行时才发现坏了:这条 cat 的输出直接把上下文灌满了,Agent 开始“忘事”——先是忘了最初要求保留的某个函数名,接着连项目目录结构都开始瞎猜,最后把两个模块的引入路径都写错了。
这不是模型变笨了,是上下文被污染了。8000 行代码,平均每行 60 个字符就是 480KB 文本。就算按英文 4 个字符一个 token 来算,也至少是十几万 token。很多模型的上下文窗口也就 128K,一次 cat 干掉大半,剩余空间只够塞一小段对话历史,需求说明早被挤到窗口外面去了。
从那以后我彻底学乖了:凡是文件读取,一律走 read_file,只读需要的区间。你要让 Agent 改一个函数,就先读这个函数所在的 50-100 行;你要修一个 bug,先读报错相关的 30 行。上下文始终干干净净,模型才能保持住完整的任务心智。
2.2 行号、分页、截断:read_file 提供的模型语义
read_file 和 cat 最大的区别,不只是一个能截断、一个不能截断,而是它提供了一套“翻书协议”。一个设计良好的 read_file 返回值一般长这样:
{ "path": "src/main.py", "total_lines": 8124, "offset": 0, "limit": 80, "truncated": true, "content": " 1| import os\n 2| import sys\n 3| # ..." }这个返回里有几个字段是模型非常需要的:
- total_lines 让模型知道文件有多大,该不该继续读。
- offset 和 limit 告诉模型当前看到的是哪一段,后续继续读时从哪个位置开始。
- truncated 字段是一个协议信号:为 true 时模型就知道后面还有内容,可以选择继续翻页,也可以先基于当前片段做初步判断。
- content 附带行号,模型后续描述修改时可以直接说“第 15 行的变量定义有问题”,语义非常清晰。
Bash 里虽然也能用 sed -n '1,80p' 来实现分页,但每次模型都得自己构造 sed 表达式,还得自己记住已经读到第几行。模型一旦在多轮工具调用之间丢了这个记忆,就会重复读、跳读、漏读,整个流程乱成一团。read_file 把“翻书状态”交给工具返回值维护,模型只负责决定“读哪一段”,这才是可靠的分工。
2.3 从实际日志看:多读一次 vs 多猜一次
如果你手头有 Agent 的工具调用日志,不妨翻出来对比一下。纯 Bash 方案的 Agent 读文件,日志往往是这样的怪组合:
cat src/main.py grep -n "def handle_request" src/main.py sed -n '120,180p' src/main.py tail -n 50 src/main.py这一套下来,模型既看了全貌又定位了目标,看起来效率很高,但实际上它把所有文件内容都往上下文里灌了一遍,还同时执行了三次无关命令。而用 read_file 的 Agent,日志通常非常清爽:
read_file("src/main.py", offset=0, limit=80) read_file("src/main.py", offset=80, limit=80) read_file("src/main.py", offset=160, limit=80)offset 稳步递增,像翻书一样有节奏。模型每次只拿到一小块内容,不会造成上下文污染,也不会因为信息过量而分析失误。从结果上看,用专用工具读文件的 Agent,在多轮修改任务里的成功率明显更高。省 token 不是空话,它直接降低了模型“记错重点”的概率。
3. 写文件才是真正的雷区:为什么 write_file 比 echo/heredoc 安全十倍
3.1 转义地狱:$、反引号、单引号、CRLF
如果说读文件时 Bash 只是“效率差”,那写文件时 Bash 就是“高危操作”。让模型在 Bash 里写多行代码,你等于把它扔进了一个转义地狱。我用实际例子说:
# 代码里包含单引号,外层单引号直接冲突 echo 'const msg = 'it\'s ok'' > demo.js # 代码里包含 $ 符号,bash 会当变量展开 echo "const dir = ${__dirname}" > config.js # 代码里包含反引号,bash 会执行命令替换 echo "const cmd = `pwd`" > script.sh这还只是最简单的情况。如果模型想用 heredoc 写多行文件:
cat <<EOF > app.py import os path = f"{os.path.join('usr', 'bin')}" EOF注意,heredoc 里的内容如果包含$符号或者反引号,默认也会被 shell 展开。模型以为写入的是 Python 的 f-string 语法,实际写进文件的可能是空字符串。我做过一个小实验,让模型通过 bash heredoc 写入一个包含${VAR}和$(command)字面量的配置文件,十次里有三四次文件内容是错的,完全不可接受。
write_file 就不存在这个问题。文件内容作为 JSON 字符串传递给工具,框架层负责把 JSON 转义解开,然后按字节写入文件。模型只需要输出“想写什么”,不用考虑 shell 语法,写进去的是什么就是什么。
3.2 原子写入与中断恢复
Bash 的重定向操作>是直接截断目标文件的。如果 Agent 写文件写到一半,因为超时、网络中断、模型输出被截断、磁盘空间不足等原因停下来了,目标文件已经变成了残缺的半截内容,原文件彻底回不去了。这种事故在开发环境里尤其致命,因为坏掉的文件可能正是项目的主入口。
write_file 的成熟实现几乎都会采用“临时文件 + 原子替换”策略:先把内容写到一个同目录的临时文件,写完后用 os.replace 一次性覆盖目标文件,中间任意一步失败都不会碰原文件。这个思路参考的是数据库中“先写日志再提交”的做法,牺牲一点磁盘开销,换来稳定性的大幅提升。我在自己的 Agent 框架里还额外加了一步:写入前先把原文件备份到备份目录,出了问题能随时恢复。
3.3 权限边界:一个 rm -rf 就能毁掉全部工作
Bash 是万能工具,万能的反面就是没有边界。模型只要有一条完整的 bash 调用权限,理论上就能执行任意命令。尤其在 Agent 自主决策的场景下,一个错误的 rm -rf、一条覆盖配置目录的重定向,就能把整个项目毁掉。市面上不少 Coding Agent 事故,都是模型在尝试清理临时文件时误删了不该删的目录。
read_file / write_file 则天然可以在工具层加装“路径围栏”。工具实现里可以做一个强制校验:传入的路径必须是当前工作区内的路径,否则直接返回错误。这样模型的能力边界被限制在项目内,即使它产生了一个十分离谱的写入意图,也最多是改错一个项目内的文件,而不会把整个用户目录清空。权限最小化原则在这里体现得非常彻底:模型不需要“任意读写整个文件系统”的能力,它需要的是“读写项目内文件”的能力;Bash 应该留给那些不可替代的 shell 操作。
4. 可观测性、审计与跨平台:文件工具是 Agent 框架的地基
4.1 结构化返回让“计划-行动-观察”循环可追踪
Coding Agent 的核心运行机制是“计划—行动—观察”循环。模型先想清楚下一步该做什么,然后调用一个工具,根据工具返回值决定接下来的动作。这个循环里每一步都应该可以被记录、回放、审计。read_file / write_file 的参数和返回值都是结构化 JSON,框架可以非常方便地把每次调用记成日志:
{ "time": "2025-01-01T10:00:00Z", "tool": "write_file", "args": {"path": "src/main.py", "mode": "overwrite"}, "content_preview": "def handle_request(...)", "result": {"ok": true, "bytes": 1520} }有了这种日志,你就能在 Agent 出问题时完整还原它改了什么、按什么顺序改的、每一步的依据是什么。Bash 虽然也能记录命令字符串,但一行 bash 往往包含多个管道、重定向和子命令,事后很难从命令本身准确推断它做了什么,尤其是遇到sed -i这种原地修改,日志里就只留下一句“sed -i 's/foo/bar/g' 文件”,改了什么内容还需要靠猜。专用工具把“操作意图”记录得明明白白,这才是可以排查问题的审计链路。
4.2 跨平台与 git bash:路径、编码、缺命令的坑
很多 Windows 用户习惯装 git bash 来获得类 Unix 环境,这带来了一个非常典型的问题:路径格式。git bash 里看到的路径是/c/Users/xxx/project,而同一个文件在 Windows 原生接口里是C:\Users\xxx\project。模型在 bash 里执行cat /c/Users/xxx/project/config.json没问题,但要它把该路径交给另一个原生工具时,就很容易拼错成C:/c/Users/...这种不伦不类的值。
read_file / write_file 可以让模型只提供一个相对路径,比如src/config.json,工具层拿到后用 pathlib 转成当前平台的绝对路径。模型不用再关心/c/还是C:,路径转换和拼接完全由确定性代码负责。我在 macOS 和 Windows 的 git bash 环境里跑同一个 Agent,文件工具的表现完全一致,而 bash 命令的行为却千差万别,单是路径分隔符就够模型喝一壶的。
同样的坑还出现在编码上。git bash 的终端默认用 UTF-8,但 Windows 原生进程有时会按 GBK 写文件,结果模型读出来全是乱码。专用读取工具显式指定encoding="utf-8",并且可以在返回里带上实际检测到的编码信息,乱码问题从源头就被堵住了。
还有一类问题专属于“缺命令”。你在 git bash 里敲screen,报bash: screen: command not found;想用jq,也没有;想用tree,还是没有。依赖这些命令的 Agent 会直接卡死在工具调用阶段。而 read_file / write_file 是用 Python 或框架原生能力实现的,根本不依赖 shell 里有没有装什么命令。环境依赖越少,Agent 就越稳,这点在跨机器跑 Agent 时尤其重要。
4.3 备份、Diff 与回滚:让 Agent 不会“一失足成千古恨”
我在自己的 write_file 实现里加了三个环节:备份、diff、回滚。每次写入之前,先把原文件复制到.agent_backup/目录;写入之后立刻生成一份修改前后的 diff 摘要;如果后续测试发现代码坏了,Agent 可以主动从备份恢复。这套机制在纯 Bash 方案里实现起来极其别扭,因为 bash 本身没有天然的文件版本概念。
有了 diff 摘要,还有一个额外的好处:模型下一次决策时不需要重读整个文件,直接看 diff 就能知道刚才改了什么、改得对不对。这又帮模型省下了一大笔 token。你算算账就明白了:一次 write_file 之后跟着一次 diff 生成,比“cat 整个文件重新读一遍”便宜得多,信息密度却高得多。这正是专用工具在“Agent 循环”里的杠杆效应——每一个设计细节都在替模型节省脑力和上下文预算。
5. Bash 仍然不可替代:什么场景必须留给它
5.1 Bash 的正确角色:跑命令,不是读文件
说了这么多,千万别误会,我并不是主张 Coding Agent 完全抛弃 Bash。恰恰相反,Bash 在 Agent 里承担着一类 read_file / write_file 永远无法替代的角色:执行环境操作。安装依赖、构建项目、跑测试、git 操作、启动服务、查看进程和端口、复杂的文本批处理,这些都需要 Bash。
我见过最离谱的 Agent 设计,是把所有操作都抽象成专用工具,连执行测试都要单独做个 run_test 工具,结果工具列表膨胀到十几二十个,模型反而不知道该选哪个。正确的设计不是消除 Bash,而是把 Bash 限定在它最适合的领域:凡是涉及“运行程序、管理进程、调用系统命令”的操作,统统走 Bash;凡是涉及“文件内容读改写”的操作,优先用专用工具。
5.2 我给 Agent 定下的三条分工规则
我在系统提示词里给 Agent 写死了三条规则,简单直接,实测下来效果很好:
- 需要看文件内容、了解代码结构时,优先用 read_file,不要用 cat。
- 需要修改文件内容时,用 write_file,不要用 echo、sed、heredoc。
- 只有需要运行命令、安装依赖、启动进程时,才用 Bash。
这组规则可以用一张小表概括:
| 操作场景 | 推荐工具 | 原因 |
|---|---|---|
| 查看文件内容 | read_file | 上下文可控、带行号、支持分页 |
| 修改文件内容 | write_file | 原子写入、转义安全、可审计 |
| 安装依赖、构建、测试 | Bash | 需要执行真正的 shell 环境 |
| git 操作 | Bash | 涉及子命令和仓库状态 |
| 启动、停止、查看服务 | Bash | 进程管理和系统调用必须走 shell |
| 关键词搜索代码 | grep 系列命令或专用 grep 工具 | 检索类操作两者皆可 |
规则定完之后,Agent 的行为变得特别可预期。调试工具日志的时候,一眼就能看出它是在“读文件→定位问题→改文件→跑测试”的正轨上,还是在“cat 大文件→上下文爆炸→胡写命令”的翻车路上。
5.3 为什么“全能 Bash”方案在小 demo 里香,上项目就崩
网上有些极简 Agent 只给一个 Bash 工具,号称什么都能干。在小 demo 里它确实跑得通:读文件用 cat,写文件用 echo,改配置用 sed。但一上真实项目就崩,原因我前面已经拆得很透了:上下文不可控、错误处理全靠模型猜、文件损坏无法恢复、操作记录难以审计。
我记得有人举过一个很形象的类比:只给一个 Bash 工具的 Agent,就像给一个厨师一把瑞士军刀,让他去做一桌宴席。瑞士军刀确实能切菜、开罐头、削水果,但真正要出菜的时候,你需要的是一把趁手的中式菜刀和一套明确的流程。read_file / write_file 就是那把菜刀,Bash 则是厨房里的炉灶和烤箱。你把炉灶当菜刀用,不是不行,但代价是效率和安全性的双重妥协。
6. 实操:一个最小可用的 read_file / write_file 实现
6.1 read_file 实现与关键参数
把理论讲完,直接上代码。这是一个接近生产可用、但保持最小规模的 Python 实现,你完全可以拿去改。核心思路:路径校验 + 分页读取 + 结构化返回值。
import os from pathlib import Path from typing import Optional WORKSPACE_ROOT = Path(os.environ.get("WORKSPACE_ROOT", ".")).resolve() def _resolve(path: str) -> Path: p = Path(path) if not p.is_absolute(): p = WORKSPACE_ROOT / p return p.resolve() def _is_within(path: Path, root: Path) -> bool: try: path.relative_to(root) return True except ValueError: return False def read_file(path: str, offset: int = 0, limit: int = 80, include_line_numbers: bool = True) -> dict: target = _resolve(path) if not _is_within(target, WORKSPACE_ROOT): return {"error": "path is outside workspace", "path": path} if not target.exists() or not target.is_file(): return {"error": "file not found", "path": str(target)} try: with open(target, "r", encoding="utf-8", errors="replace") as f: lines = f.readlines() except OSError as e: return {"error": str(e), "path": str(target)} total = len(lines) selected = lines[offset: offset + limit] if include_line_numbers: content = "".join( f"{i + 1:>6}| {line}" for i, line in enumerate( selected, start=offset ) ) else: content = "".join(selected) return { "path": str(target), "total_lines": total, "offset": offset, "limit": limit, "truncated": offset + limit < total, "content": content, }几个值得注意的设计:
_resolve之后再做relative_to校验,可以防止../这种路径逃逸,比简单字符串前缀匹配安全得多。errors="replace"保证文件里即便有非法 UTF-8 字节,读取也不会直接抛异常,最多显示替换符。include_line_numbers是一个常用开关。模型在改代码时需要行号做锚点,但有时它只需要取一段配置内容,行号反而是噪音。
6.2 write_file 实现:临时文件 + 原子替换 + 备份
写文件比读文件更需要谨慎,我的实现里做了四件事:路径校验、目录创建、原子替换、原文件备份。代码如下:
import os import shutil import time from pathlib import Path BACKUP_DIR = WORKSPACE_ROOT / ".agent_backup" def write_file(path: str, content: str, create_dirs: bool = True) -> dict: target = _resolve(path) if not _is_within(target, WORKSPACE_ROOT): return {"error": "path is outside workspace", "path": path} if create_dirs: target.parent.mkdir(parents=True, exist_ok=True) # 备份原文件 if target.exists(): BACKUP_DIR.mkdir(exist_ok=True) backup_path = BACKUP_DIR / f"{target.name}.{int(time.time())}.bak" shutil.copy2(target, backup_path) # 写临时文件,再原子替换 tmp_path = target.with_name(target.name + ".tmp") try: with open(tmp_path, "w", encoding="utf-8") as f: f.write(content) os.replace(tmp_path, target) except OSError as e: if tmp_path.exists(): tmp_path.unlink() return {"error": str(e), "path": str(target)} return { "ok": True, "path": str(target), "bytes": len(content.encode("utf-8")), "backup": str(backup_path) if target.exists() else None, }这个实现里最关键的一行是os.replace(tmp_path, target)。它保证写入是原子的:要么新文件完整生效,要么原文件保持原样,不会存在中间状态。备份机制则给了 Agent 一次“后悔药”的机会,配合 diff 工具甚至可以自动回滚。
6.3 接入 Agent 循环时的 5 个细节
把上面的工具接进 Coding Agent 循环,看起来很简单,但有几个细节决定了好不好用,我一个个说:
第一,工具描述要写得让模型一看就懂。光写“Read a file”是不够的,模型不知道什么时候该用它。我实际用的描述是:“Read a file with line numbers and pagination. Use this instead of cat when you need to inspect code or config file content. The return value includes total_lines and truncated; if truncated is true, continue reading with a larger offset.” 描述里直接点明“instead of cat”,模型才知道场景。
第二,设置输出上限。按行数截断还不够,因为一行可能特别长。压缩过的 JS、JSON、日志文件可能一行就是几万字符。我一般在 read_file 内部再套一个字符上限:
MAX_CHARS = 8000 if len(content) > MAX_CHARS: content = content[:MAX_CHARS] truncated = True这样无论行数规则怎么变化,单次工具返回的 token 消耗都被锁死在一个可接受的范围内。
第三,路径校验必须用绝对路径比较。不要用path.startswith(workspace_root)这种方式,因为/workspace_evil也能通过/workspace的前缀校验。用Path.resolve()解析完再relative_to判断,才能挡住..和符号链接的绕过。
第四,编码策略统一为 UTF-8。读取和写入都显式指定encoding="utf-8",不要依赖系统默认编码。Windows 上的系统默认可能是 GBK,一旦不指定,Agent 写出来的中文代码到了别人机器上就是乱码。
第五,日志要落盘。每次工具调用都 append 到 JSONL 文件,记录入参、返回值、时间戳。调试 Agent 的时候,这份日志比任何 IDE 调试器都好用,因为你能看到模型每一轮的完整推理轨迹。
7. 常见问题与排查技巧实录
7.1 我的 Agent 老是用 bash cat,怎么引导?
先说结论:多半不是模型不想用 read_file,而是它不知道 read_file 更适合读文件。模型选择工具主要看描述,如果你只写了一句“Read a file”,它可能觉得 cat 更直接。解决方法是双管齐下:把 read_file 的描述改成“Read a file with line numbers, pagination and token control. Prefer this overcatwhen inspecting source code.";同时在系统提示词里加一条硬规则:“文件内容读取必须优先使用 read_file,bash cat 仅用于查看极短文件或非文本文件。” 模型对规则文字很敏感,加一句之后行为立刻收敛。
7.2 读大文件还是爆上下文?
先检查是不是按行截断但单行超长。比如一个压缩过的 JS 文件,一行可能有 500KB,按行读前 80 行照样爆。解决方法就是我在 6.3 说的字符上限,MAX_CHARS设成 8000 到 12000 比较合适。再配合返回里的truncated字段,模型看到截断标记就继续用 offset 翻页。还有一个更保守的方案:把初始 limit 调小到 40 行,让模型用一个更小的窗口先探路,需要再继续读。
7.3 乱码、路径不识别、写失败怎么办?
乱码问题几乎都是编码不一致。git bash 环境下终端默认 UTF-8,但 Windows 原生进程可能用 GBK。统一方案:所有读取都显式encoding="utf-8",遇到无法解码的字节用errors="replace";所有写入也都显式 UTF-8,写完如果怀疑乱码,再用 read_file 回读一遍验证。这个“写后读”的校验习惯比任何编码检测工具都靠谱。
路径不识别,多见于混用 POSIX 路径和 Windows 路径。发现/c/Users/xxx或C:/c/Users这种字符串时,先检查工具层是否做了resolve(),如果没有,参考 6.1 的_resolve实现。写文件失败则按照三板斧排查:先看父目录是否存在,再看路径是否在工作区内,最后看磁盘空间。工具返回的 error 字段会告诉你具体是哪一种。
7.4 只留 Bash 能不能跑?
能跑,但你要接受三个代价:上下文管理靠自己,错误处理靠自己,审计恢复靠自己。在小项目和一次性脚本里,这些代价几乎感受不到;但在大型项目多轮迭代里,你每天都会为“上下文又爆了”“文件被改坏了”“这步操作没记录下来”而头疼。我的建议是:即便你只想维护一个极简 Agent,也至少要加 read_file 和 write_file 两个工具,它们的实现成本很低,收益却立竿见影。Bash 负责跑命令,文件工具负责内容读改写,这个分工一旦定下来,Agent 的稳定性和可维护性都会有质的提升。
我自己的实际体会是,把 read_file / write_file 和 Bash 的分工彻底想明白之后,Agent 的“靠谱程度”明显上了一个台阶。以前看工具调用日志,经常是一长串 bash 命令来回瞎试,像是在碰运气;现在则是一条清晰的“读文件—定位问题—改文件—跑测试”链路,每一步都有据可查,出了问题也能从 JSONL 日志里还原现场。Bash 不是不需要,而是应该待在它最擅长的地方:跑命令、跑构建、跑环境。文件内容这种模型要长期反复消费的数据,用结构化专用工具去喂,才是真正替模型省脑力的做法。这个原则我后来用到所有 Agent 项目里,都再没翻过车。