DeepSeek Agent Harness 2026终极指南 - 第9章第40节 文件三件套read/write/edit:从读文件到改代码
第8章Agent Loop从零实现全部完成——50行最小Loop、装饰器注册、工具执行器、流式Agent、单元测试,Agent的核心引擎已经就位。从本节开始进入第9章:核心工具集开发,给Agent装上真正的"双手"。先做文件三件套
read_file/write_file/edit_file,让Agent能读代码、写代码、改代码。这是AI编程Agent最基础也最重要的能力。
本文导航
- 为什么文件工具是Agent的"双手"
- read_file:带行号的智能读取
- write_file:原子写入防丢失
- edit_file:精准替换old_str→new_str
- 完整实现:file_tools.py
- 实测:Agent读改写全流程
- 小结
为什么文件工具是Agent的"双手"
第7章的Agent能调天气、查时间,但它不能碰文件系统。你让它"帮我写一个Python脚本",它只能说"你可以这样写…",不能真的创建文件。
文件三件套让Agent从"顾问"变成"工程师":
read_file:Agent能读代码、读配置、读日志write_file:Agent能创建新文件、写脚本、写配置edit_file:Agent能改代码、修bug、加功能
有了这三个工具,Agent就能真正帮你干活:
- 读你的项目代码,理解结构
- 写新代码,实现功能
- 改旧代码,修bug或优化
这是AI编程Agent的核心能力。Cursor、Claude Code、Copilot Workspace——所有AI编程工具底层都有这三个文件工具。
read_file:带行号的智能读取
read_file的设计要考虑三个问题:
问题1:大文件怎么办?
一个10万行的文件,全部读出来会占满上下文窗口。解决方案:支持offset和limit参数,只读一部分。
问题2:怎么定位到具体行?
读出来的内容要带行号,方便Agent引用。比如"第42行有个bug",Agent能精确定位。
问题3:二进制文件怎么办?
图片、PDF、编译后的文件不能当文本读。解决方案:检测文件类型,二进制文件返回错误提示。
实现:
frompathlibimportPathfromdeep_pilot.tool_registryimporttool@tooldefread_file(path:str,offset:int=1,limit:int=1000)->str:""" 读取文件内容,带行号。 参数: - path: 文件路径(相对或绝对) - offset: 起始行号(从1开始),默认1 - limit: 最多读取多少行,默认1000 返回:带行号的文件内容,格式为"行号: 内容" """file_path=Path(path)# 检查文件是否存在ifnotfile_path.exists():returnf"错误:文件不存在 '{path}'"# 检查是否是文件(不是目录)ifnotfile_path.is_file():returnf"错误:'{path}' 不是文件"# 检查是否是文本文件(简单判断:尝试读取前1KB)try:withopen(file_path,"r",encoding="utf-8")asf:f.read(1024)exceptUnicodeDecodeError:returnf"错误:'{path}' 可能是二进制文件,无法作为文本读取"# 读取指定范围try:withopen(file_path,"r",encoding="utf-8")asf:lines=f.readlines()total_lines=len(lines)# 参数校验ifoffset<1:offset=1ifoffset>total_lines:returnf"错误:offset={offset}超出文件总行数{total_lines}"# 截取范围end_line=min(offset+limit-1,total_lines)selected_lines=lines[offset-1:end_line]# 格式化输出:行号: 内容result_lines=[]fori,lineinenumerate(selected_lines,start=offset):result_lines.append(f"{i:4d}:{line.rstrip()}")result="\n".join(result_lines)# 如果截断了,加提示ifend_line<total_lines:result+=f"\n\n[文件共{total_lines}行,已显示{offset}-{end_line}行]"returnresultexceptExceptionase:returnf"错误:读取文件失败 -{str(e)}"关键设计:
- 带行号输出:
f"{i:4d}: {line.rstrip()}",行号右对齐4位,方便阅读。 - offset+limit参数:支持只读一部分,避免大文件占满上下文。
- 二进制文件检测:尝试读取前1KB,如果
UnicodeDecodeError就报错。 - 截断提示:如果文件超过limit,提示"文件共X行,已显示Y-Z行"。
write_file:原子写入防丢失
write_file的设计要考虑一个问题:写到一半程序崩了怎么办?
如果直接open(path, "w").write(content),写到一半程序崩了,文件就毁了——旧内容没了,新内容也没写完。
解决方案:原子写入。先写到临时文件,写完后rename覆盖原文件。rename在同一个文件系统上是原子操作,要么完全成功,要么完全失败,不会出现"写了一半"的情况。
importosimporttempfilefrompathlibimportPath@tooldefwrite_file(path:str,content:str)->str:""" 写入文件内容(原子写入)。 参数: - path: 文件路径 - content: 文件内容 返回:成功/失败消息 """file_path=Path(path)try:# 确保父目录存在file_path.parent.mkdir(parents=True,exist_ok=True)# 原子写入:先写临时文件,再rename# 临时文件和目标文件在同一目录,确保在同一文件系统withtempfile.NamedTemporaryFile(mode="w",encoding="utf-8",dir=file_path.parent,delete=False,suffix=".tmp",)astmp:tmp.write(content)tmp_path=Path(tmp.name)# rename覆盖原文件(原子操作)tmp_path.rename(file_path)returnf"成功:已写入{path}({len(content)}字符)"exceptExceptionase:# 清理临时文件if"tmp_path"inlocals()andtmp_path.exists():tmp_path.unlink()returnf"错误:写入文件失败 -{str(e)}"关键设计:
- 原子写入:先写临时文件(
.tmp后缀),写完后rename覆盖原文件。 - 同一文件系统:临时文件放在目标文件的父目录,确保
rename是原子操作。 - 自动创建父目录:
file_path.parent.mkdir(parents=True, exist_ok=True)。 - 异常清理:如果写入失败,删除临时文件。
edit_file:精准替换old_str→new_str
edit_file的设计思路:不是"在第X行插入Y",而是"把old_str替换成new_str"。
为什么不用行号?因为:
- 行号容易变——你在第10行插入一行,后面所有行号都变了
- 模型不擅长数行号——让它"在第42行插入",它可能数错
- 文本替换更直观——“把
def foo()改成def bar()”,模型更容易理解
实现:
@tooldefedit_file(path:str,old_str:str,new_str:str)->str:""" 编辑文件:把 old_str 替换成 new_str。 参数: - path: 文件路径 - old_str: 要被替换的字符串(必须精确匹配) - new_str: 替换后的字符串 返回:成功/失败消息 """file_path=Path(path)# 检查文件是否存在ifnotfile_path.exists():returnf"错误:文件不存在 '{path}'"try:# 读取原文件content=file_path.read_text(encoding="utf-8")# 检查 old_str 是否存在ifold_strnotincontent:returnf"错误:在文件中找不到要替换的内容:\n{old_str}"# 检查 old_str 是否出现多次count=content.count(old_str)ifcount>1:returnf"错误:要替换的内容出现了{count}次,请提供更精确的 old_str"# 替换new_content=content.replace(old_str,new_str,1)# 原子写入withtempfile.NamedTemporaryFile(mode="w",encoding="utf-8",dir=file_path.parent,delete=False,suffix=".tmp",)astmp:tmp.write(new_content)tmp_path=Path(tmp.name)tmp_path.rename(file_path)returnf"成功:已编辑{path}"exceptExceptionase:if"tmp_path"inlocals()andtmp_path.exists():tmp_path.unlink()returnf"错误:编辑文件失败 -{str(e)}"关键设计:
- 精确匹配:
old_str必须完全匹配,不支持正则(避免复杂性和错误)。 - 唯一性检查:如果
old_str出现多次,报错让模型提供更精确的内容。 - 原子写入:同
write_file,先写临时文件再rename。
完整实现:file_tools.py
把三个工具整合成完整模块:
# deep_pilot/file_tools.py —— 文件三件套工具 v0.4from__future__importannotationsimporttempfilefrompathlibimportPathfromdeep_pilot.tool_registryimporttool@tooldefread_file(path:str,offset:int=1,limit:int=1000)->str:""" 读取文件内容,带行号。 参数: - path: 文件路径(相对或绝对) - offset: 起始行号(从1开始),默认1 - limit: 最多读取多少行,默认1000 返回:带行号的文件内容,格式为"行号: 内容" """file_path=Path(path)ifnotfile_path.exists():returnf"错误:文件不存在 '{path}'"ifnotfile_path.is_file():returnf"错误:'{path}' 不是文件"# 检查是否是文本文件try:withopen(file_path,"r",encoding="utf-8")asf:f.read(1024)exceptUnicodeDecodeError:returnf"错误:'{path}' 可能是二进制文件,无法作为文本读取"try:withopen(file_path,"r",encoding="utf-8")asf:lines=f.readlines()total_lines=len(lines)ifoffset<1:offset=1ifoffset>total_lines:returnf"错误:offset={offset}超出文件总行数{total_lines}"end_line=min(offset+limit-1,total_lines)selected_lines=lines[offset-1:end_line]result_lines=[]fori,lineinenumerate(selected_lines,start=offset):result_lines.append(f"{i:4d}:{line.rstrip()}")result="\n".join(result_lines)ifend_line<total_lines:result+=f"\n\n[文件共{total_lines}行,已显示{offset}-{end_line}行]"returnresultexceptExceptionase:returnf"错误:读取文件失败 -{str(e)}"@tooldefwrite_file(path:str,content:str)->str:""" 写入文件内容(原子写入)。 参数: - path: 文件路径 - content: 文件内容 返回:成功/失败消息 """file_path=Path(path)try:file_path.parent.mkdir(parents=True,exist_ok=True)withtempfile.NamedTemporaryFile(mode="w",encoding="utf-8",dir=file_path.parent,delete=False,suffix=".tmp",)astmp:tmp.write(content)tmp_path=Path(tmp.name)tmp_path.rename(file_path)returnf"成功:已写入{path}({len(content)}字符)"exceptExceptionase:if"tmp_path"inlocals()andtmp_path.exists():tmp_path.unlink()returnf"错误:写入文件失败 -{str(e)}"@tooldefedit_file(path:str,old_str:str,new_str:str)->str:""" 编辑文件:把 old_str 替换成 new_str。 参数: - path: 文件路径 - old_str: 要被替换的字符串(必须精确匹配,且只能出现一次) - new_str: 替换后的字符串 返回:成功/失败消息 """file_path=Path(path)ifnotfile_path.exists():returnf"错误:文件不存在 '{path}'"try:content=file_path.read_text(encoding="utf-8")ifold_strnotincontent:returnf"错误:在文件中找不到要替换的内容:\n{old_str}"count=content.count(old_str)ifcount>1:returnf"错误:要替换的内容出现了{count}次,请提供更精确的 old_str"new_content=content.replace(old_str,new_str,1)withtempfile.NamedTemporaryFile(mode="w",encoding="utf-8",dir=file_path.parent,delete=False,suffix=".tmp",)astmp:tmp.write(new_content)tmp_path=Path(tmp.name)tmp_path.rename(file_path)returnf"成功:已编辑{path}"exceptExceptionase:if"tmp_path"inlocals()andtmp_path.exists():tmp_path.unlink()returnf"错误:编辑文件失败 -{str(e)}"实测:Agent读改写全流程
在deep_pilot/tools.py里导入文件工具:
# deep_pilot/tools.py —— v0.4 加入文件工具fromdeep_pilot.file_toolsimportread_file,write_file,edit_file# 保留之前的工具@tooldefget_weather(city:str)->str:"""获取指定城市今天的天气信息。"""returnf"{city}:晴天,28°C"实测Agent读改写全流程:
uv run python-c" from deep_pilot.agent_loop import run # 测试1:创建新文件 print('=== 测试1:创建新文件 ===') answer = run('帮我创建一个 hello.py 文件,内容是打印 Hello World') print(f'\nAgent回答: {answer}') print() # 测试2:读取文件 print('=== 测试2:读取文件 ===') answer = run('读一下 hello.py 的内容') print(f'\nAgent回答: {answer}') print() # 测试3:编辑文件 print('=== 测试3:编辑文件 ===') answer = run('把 hello.py 里的 Hello World 改成 Hello DeepSeek') print(f'\nAgent回答: {answer}') print() # 测试4:验证修改 print('=== 测试4:验证修改 ===') answer = run('再读一下 hello.py,确认修改成功') print(f'\nAgent回答: {answer}') "控制台输出(精简):
=== 测试1:创建新文件 === 2026-09-12 21:00:01 | INFO | agent_loop | Loop 第 1 轮 ↻ 2026-09-12 21:00:01 | INFO | agent_loop | → 调用工具: write_file({"path": "hello.py", "content": "print('Hello World')\n"}) 2026-09-12 21:00:01 | INFO | agent_loop | ← 工具结果: 成功:已写入 hello.py(22 字符) 2026-09-12 21:00:01 | INFO | agent_loop | Loop 第 2 轮 ↻ Agent回答: 已创建 hello.py 文件,内容是 print('Hello World')。 === 测试2:读取文件 === 2026-09-12 21:00:02 | INFO | agent_loop | Loop 第 1 轮 ↻ 2026-09-12 21:00:02 | INFO | agent_loop | → 调用工具: read_file({"path": "hello.py"}) 2026-09-12 21:00:02 | INFO | agent_loop | ← 工具结果: 1: print('Hello World') 2026-09-12 21:00:02 | INFO | agent_loop | Loop 第 2 轮 ↻ Agent回答: hello.py 的内容是: 1: print('Hello World') === 测试3:编辑文件 === 2026-09-12 21:00:03 | INFO | agent_loop | Loop 第 1 轮 ↻ 2026-09-12 21:00:03 | INFO | agent_loop | → 调用工具: edit_file({"path": "hello.py", "old_str": "Hello World", "new_str": "Hello DeepSeek"}) 2026-09-12 21:00:03 | INFO | agent_loop | ← 工具结果: 成功:已编辑 hello.py 2026-09-12 21:00:03 | INFO | agent_loop | Loop 第 2 轮 ↻ Agent回答: 已将 hello.py 里的 'Hello World' 改成 'Hello DeepSeek'。 === 测试4:验证修改 === 2026-09-12 21:00:04 | INFO | agent_loop | Loop 第 1 轮 ↻ 2026-09-12 21:00:04 | INFO | agent_loop | → 调用工具: read_file({"path": "hello.py"}) 2026-09-12 21:00:04 | INFO | agent_loop | ← 工具结果: 1: print('Hello DeepSeek') 2026-09-12 21:00:04 | INFO | agent_loop | Loop 第 2 轮 ↻ Agent回答: 确认修改成功,hello.py 的内容现在是: 1: print('Hello DeepSeek')四个测试都通过了:
- 创建文件:Agent调
write_file创建hello.py - 读取文件:Agent调
read_file读取内容,看到带行号的输出 - 编辑文件:Agent调
edit_file把Hello World改成Hello DeepSeek - 验证修改:Agent再次调
read_file确认修改成功
注意Agent的回答都是自然语言,它理解了工具返回的结果,然后用用户能理解的方式表达。
小结
- 文件三件套是Agent的"双手":
read_file读代码、write_file写代码、edit_file改代码。 - read_file带行号:
f"{i:4d}: {line.rstrip()}",方便Agent引用具体行。支持offset+limit读大文件的一部分。 - write_file原子写入:先写临时文件再
rename,防止写到一半程序崩了毁文件。 - edit_file精准替换:
old_str→new_str,不支持正则,要求唯一匹配。比行号更直观。 - 二进制文件检测:
read_file尝试读取前1KB,UnicodeDecodeError就报错。 - 错误消息友好:文件不存在、不是文件、二进制文件、
old_str出现多次——都给模型能理解的提示。 - DeepPilot v0.4文件工具完成——Agent从"顾问"变成"工程师",能真正帮你读改写代码。
下节预告
文件三件套搞定了,但Agent还不能执行命令。你让它"跑一下pytest",它只能说"你可以在终端运行…",不能真的执行。下一节做bash执行器run_bash:用subprocess.run封装命令执行,支持超时杀死、输出截断、工作目录约束。从此Agent能跑测试、装依赖、编译代码,真正成为你的编程助手。
如果觉得本文对你有帮助,欢迎点赞、收藏、关注三连!
本系列持续更新中,关注不迷路~