從網頁對話到 coding agent第 7 篇,共 9 篇

整理專案

讓專案換個人、換個地方、換個時間仍能執行。

發佈於

查看他人的 code,經常見到一支腳本長達上千行,不同腳本之間又有大半相同。這基本上是因為網頁上生成的 code 都傾向於單一檔案,網頁對話看不到電腦上已有的檔案,每次給出的都是一支能獨立執行的完整腳本,讀取、清理、模型、作圖全寫在一起;換一份資料再問一次,得到的又是一支完整的腳本,其中多數與上一支相同。

但這會造成諸多問題。腳本一長,想改一處便得先在其中找到那一段,並擔心是否牽動了別處;重複的部分則須逐份修改,漏掉一處,結果便前後不一致,且不會有任何錯誤訊息。改由 agent 動手之後問題仍在,它每次都須讀進整支腳本,context window 耗在與任務無關的內容上。

除了長與重複,常見的還有幾件事:不同工作的檔案混在同一處,路徑寫的是自己電腦上的完整位置,用了哪些套件、各是哪一版沒有紀錄。這幾個習慣都影響這個專案換個人、換個地方、換個時間,還能否正常執行。

一份工作一個資料夾

以下內容都以專案為單位來談,而專案須先有邊界。「初次使用:讀取既有資料夾」一節已要求選取單一分析所在的資料夾,此處說明其原因,因為實際看到的情況大多違背該準則。

常見的情況之一是平鋪,「文件」或桌面之下,三門課的作業、論文的資料、去年的報告並列於同一層,哪幾個檔案屬於同一份工作,只有本人記得。另一種是雖已分類,開啟時卻選了上一層,比如在 Code 分頁或 VS Code 直接開啟裝著所有專案的 code 或 projects 資料夾,一次面對十幾份彼此無關的工作。

以上兩種看得到資料夾的,已經屬於其中較好的。更多是所有檔案都在下載資料夾中,用編輯器也是開啟單獨一個檔案,而不是開啟資料夾或專案。此時沒有專案可言,要交給 agent 時能選的只有下載資料夾本身,情況與平鋪相同,且其中還混著安裝檔、講義與各種無關的下載。

這使得 Agent 看得到的範圍過大,會讀到別份工作的檔案,同名的腳本或 data.csv 分不清該用哪一個,context window 也耗在無關的內容上;規範檔放在這一層,寫下的規矩便對所有工作一體適用,而各份工作的資料位置與慣例本不相同;提交如果建在這一層,一份作業與一份論文的改動記在同一份歷史裡,要退回其中一份,另一份也隨之退回。本篇的相對路徑也一樣,路徑從專案資料夾算起,資料夾如果沒有邊界,便無法算起。

整理的原則是一份工作一個資料夾,其所需的資料、code 與文件都在其中,無關的檔案不放進來,這個資料夾即是專案。原始資料如果還留在桌面或下載資料夾,也須先搬進來。各個專案資料夾可以並列於同一個上層資料夾,但開啟的永遠是其中一個。既有的檔案如果是平鋪的,可交代 agent 協助歸類,這也是唯一須開啟上層資料夾的時候:

這個資料夾裡混著好幾份不同的工作。先不要搬動任何檔案,
依檔名與內容判斷哪些檔案屬於同一份工作,列出建議的分組與資料夾名稱,
拿不準的檔案單獨列出來問我。

分組確認之後,再請它建立資料夾並搬動;此後每份工作各自開啟,各有自己的規範檔與提交。

相對路徑

許多分析腳本的第一行是這樣的:

setwd("C:/Users/wang/Desktop/thesis")

Python 的腳本沒有 setwd(),同一件事寫在讀檔的那一行:

df = pd.read_csv("C:/Users/wang/Desktop/thesis/data/raw.csv")

這一行只在寫下它的那台電腦、那個帳號、那個資料夾位置上成立,換了任何一項便出錯。

路徑有兩種寫法,以下面的資料夾為例:

C:/
└── Users/
└── wang/
└── Desktop/
└── thesis/ 專案資料夾
├── data/
│ └── raw.csv
└── main.R

絕對路徑由磁碟的最上層一路寫到檔案,raw.csv 的絕對路徑是 C:/Users/wang/Desktop/thesis/data/raw.csv。相對路徑由某個資料夾算起,只寫其後的部分,由 thesis 算起,同一個檔案是 data/raw.csv。thesis 搬到另一個磁碟或另一台電腦之後,它之上的那一段全都變了,之內的那一段不變,因此絕對路徑失效,相對路徑仍然成立。

相對路徑的原則是,凡是腳本裡出現的路徑,都從專案資料夾算起。

相對路徑由哪個資料夾算起,看的是執行腳本時的工作目錄,因此工作目錄須是專案資料夾,編輯器開啟的也須是專案資料夾,不是其中的單一檔案。單獨開啟一支腳本時,工作目錄由編輯器自行決定,未必是專案資料夾,data/raw.csv 便可能找不到。Agent 這一側不必操心,它本來就在所選的資料夾內執行指令;RStudio 開啟資料夾的方式是 Project,於選單 File 之下選 New Project,再選 Existing Directory 並指向專案資料夾,資料夾內會多出一個 .Rproj 檔,此後以它開啟 RStudio,工作目錄便自動落在專案資料夾,setwd() 即可刪去。Python 沒有對應的專案檔,交由 agent 執行,或日後以 VS Code 開啟專案資料夾再執行,工作目錄都是專案資料夾。

Windows 上尚有一個常見的錯誤來源,由檔案總管複製出來的路徑以反斜線分隔,而反斜線在 R 與 Python 的字串裡另有用途,直接貼上會出錯,須改為斜線 / 或寫成兩個反斜線 \\。路徑改為相對之後,這個問題多半也隨之消失,因為不再需要由檔案總管複製路徑。

既有的腳本不必自己逐行找,交代 agent 即可。指示可以這樣寫:

檢查這個專案裡所有的腳本,找出 setwd() 與絕對路徑,改為相對於專案資料夾的路徑。
如果腳本讀取的檔案不在專案資料夾內,先列出來問我,不要自行搬動或複製。
改完之後,從專案資料夾實際執行每一支腳本,確認檔案都讀得到,再回報改了哪些地方。

「先列出來問我」一句是為了原始資料。Agent 看得到的範圍只有專案資料夾,路徑指向外面的檔案時,它可能要求存取該位置,也可能直接略過;哪些檔案該搬進來、哪些因為太大或涉及個資而應留在外面,須由使用者決定。

在 Windows 上,agent 未必叫得到 R。PATH 是 Windows 用來尋找指令的資料夾清單,指令所在的資料夾不在清單上,系統便回答找不到。R 的安裝程式預設不把 R 加進 PATH,RStudio 是另有辦法找到它,agent 卻只能依賴 PATH,因此可能回報找不到 Rscript。遇到時可以請 agent 自行在 C:\Program Files\R 之下尋找 Rscript.exe 並以完整路徑執行,找到之後可請它把完整路徑記進規範檔,之後不必再找。

函數與分檔

同一段資料清理步驟,三個年度的資料各貼一次,只改檔名;同一張圖換了五個變數,便複製五段。處理的辦法是函數與分檔。

函數並不陌生,R 的 mean()、read.csv(),Python 的 len()、pd.read_csv() 都是函數,每天都在用,只是多數人沒有寫過自己的。所謂把重複的片段寫成函數,即是替一段會反覆使用的步驟取一個名字,講明它需要什麼輸入、交出什麼結果,此後每次使用只寫名字與輸入,步驟本身只存在一處。

以三個年度的資料為例,清理的步驟如果寫成一個函數,三個年度便是呼叫三次、各給一個檔名,日後發現某個欄位的缺失值處理有誤,只須修改函數內的那一處,三個年度一併更正。改進也一樣,函數日後改得更快,或多處理了一種例外情況,所有呼叫它的地方都隨之受益。同理,對數個變數做同一種計算、對數個子群體畫同一種圖,都是同一段邏輯換了輸入,適合寫成函數。判斷的方式是,同一段 code 如果是第二次貼上,且只改了其中幾個名字,便該寫成函數。

分檔處理的是另一個問題,即腳本太長,讀取、清理、模型、作圖交錯在同一個檔案裡。常見的拆法是依分析的階段各立一檔,再由一支主腳本依序執行,以 R 為例:

專案資料夾/
├── data/
│ ├── raw/ 原始資料,只讀不改
│ └── clean/ 清理後的資料
├── output/
│ ├── figures/ 圖檔
│ └── tables/ 表格
├── R/
│ ├── clean.R 讀取與清理
│ ├── model.R 模型
│ └── plot.R 作圖
└── main.R 依序 source() 上面三支

Python 的拆法相同,副檔名改為 .py,主腳本改以 import 取用其餘各檔。如此每一檔只做一件事,檔名即說明了用途,要重現整份分析則執行主腳本一次。分出來的檔案也可以由別的腳本取用,換一份資料再做一次分析時,清理與作圖不必再複製一份。對 agent 而言也有好處,它每次只須讀取與任務相關的那一檔,佔用的 context window 較少;權限提示中顯示的改動也限於一個小檔案,交代改圖卻動了清理的部分,一眼可以看出。

哪些片段該寫成函數、該拆成哪幾檔,也可以交給 agent 找。mattpocock-skills 這組 skill(安裝方式見「安裝他人寫好的 skill」一節)裡有一個 improve-codebase-architecture,它會掃過整個專案,找出值得重構的地方,整理成一份 HTML 報告並開啟,使用者選定其中一項之後,再逐題問清楚要怎麼改。它只能由使用者指名喚用:

/mattpocock-skills:improve-codebase-architecture

這個 skill 原為軟體開發而寫,報告裡會出現 module、interface 一類的用語,看不懂的可以請 agent 換成白話再說一次。選定之後真正動手時,仍照後文「由 agent 重構並驗證結果一致」一項的流程。

由 agent 重構並驗證結果一致

把既有的 code 改寫成函數、拆成數檔而不改變其行為,稱為重構。重構的風險在於「不改變其行為」這一句只是意圖,agent 搬動兩千行 code 的過程中,漏掉一行篩選條件、把某個物件的名稱接錯,都有可能,而腳本照樣可以執行,只是數字不同。LLM 與 agent 的運作原理一篇說過結果須自行驗證,此處正是用得上的地方,且驗證的方法十分具體,重構前後的輸出應當完全相同。

流程如下:

  1. 提交一次,存下現狀。

  2. 執行現有的腳本,把目前的輸出存下來作為基準,包含主要的數字(清理後的資料筆數、主要的結果表格)與所有的圖,並再提交一次,基準檔因此在提交之內:

    在重構之前,先執行目前的腳本,把清理後資料的筆數與各欄摘要、主要的結果表格存成 output/baseline 之下的文字檔,
    圖也各存一份到同一個資料夾。不要修改任何既有的 code。完成後提交一次。
  3. 以 plan mode 請 agent 提出重構的計畫,看過之後再讓它動手:

    這支腳本太長,且有重複的片段。提出一份重構計畫:哪些片段寫成函數、拆成哪幾個檔案、主腳本如何依序執行。
    先不要動手。
  4. 重構完成後,重新執行一次,與基準逐項比對,比對在讓它動手的同一則指示裡交代:

    照計畫重構。完成後執行主腳本,把新的輸出與 output/baseline 逐項比對,
    數字須完全相同,列出每一項是否一致;有任何不一致就停下來回報,不要自行調整到一致為止。
  5. 一致才接受,並再提交一次;不一致則退回存下基準的那一次提交,或請 agent 找出差異的來源。

「不要自行調整到一致為止」一句的用意在於,agent 被要求「結果要一致」時,有可能為了達成一致而去改動基準或放寬比對的標準,因此須講明不一致時該做的是回報。

數字由 agent 比對,圖則應自己並排看過一次,圖檔即使內容相同,逐位元比較也未必相等,而肉眼看兩張圖是否相同並不費事。

如果分析中有隨機的成分,例如隨機抽樣或模擬,未固定亂數種子時,同一支腳本每次執行的數字本來就不同,無法比對。此時須先固定種子再存基準,亂數種子於「亂數與結果的重現」一節另有說明。

依賴管理

腳本開頭只有幾行 library() 或 import,至於當時用的是哪一版,查不到;半年後套件更新,某個函數的預設值變了,或是整個函數被移除,同一支腳本便跑不出原來的數字,甚至無法執行。

原因在於套件的安裝位置。R 與 Python 的套件預設都安裝在整台電腦共用的一處,所有專案都由此載入。因此替新專案更新了某個套件,舊專案也跟著換了版本,而舊專案當初用的是哪一版,沒有紀錄。

涉及深度學習的套件時,這個問題尤其明顯。這類套件對彼此的版本有嚴格的要求,不同專案需要的版本又往往不一致,重現他人的 code 時,對方指定的版本與自己電腦上已裝的不合,裝上新的,原有的專案便跟著出錯,這種情形稱為依賴衝突。反過來也一樣,自己的 code 交給他人時,對方如果不知道用的是哪些套件、各是哪一版,便重現不出相同的結果。

專案用到的套件稱為它的依賴(dependency),記下依賴的版本並在別處還原,稱為依賴管理,主要的用途是讓他人能重現自己的 code,自己換電腦或日後重跑時用得上的也是同一份紀錄。這在 CS 領域本身就是長年令人頭痛的問題,現今通行的解法是每個專案各自記下依賴及其版本。

R 與 Python 現在各有一個照此思路的工具,即 renv 與 uv,做法是給專案一個自己的套件庫,放在專案資料夾內,此專案安裝與更新套件都只影響自己;並把專案用到的每個套件及其版本記在一個檔案裡,作用近似報告的參考文獻註明教科書是第幾版,日後才找得到同一段內容。

這些都可交代 agent 執行,指示分別見以下 R 與 Python 的小節。安裝套件需要連網,Codex 預設關閉網路,執行時會出現核准提示。

R:renv

renv 是 R 的套件。專案的套件庫位於 renv/library,紀錄是一個名為 renv.lock 的檔案,其中也記下當時的 R 版本。同一個套件的同一版如果已被別的專案下載過,renv 會由共用的快取連結過來,不會重複下載。

啟用時交代 agent:

替這個專案啟用 renv。先安裝 renv 套件,再執行 renv::init(),完成後說明專案裡多了哪些檔案,
然後執行主腳本確認結果與先前相同,並提交一次。

renv::init() 之後,專案裡會多出 renv.lock、.Rprofile 與一個 renv 資料夾。這些檔案中,renv.lock、.Rprofile、renv/activate.R 與 renv/settings.json 須納入提交,renv/library 則不納入,套件本身體積大,且可依 renv.lock 重新安裝。renv 會自行建立 .gitignore 排除套件庫,不須另外設定,請 agent 提交時確認一次即可。

.Rprofile 是 R 在專案資料夾啟動時自動執行的檔案,renv 藉此讓 R 改用專案的套件庫,因此 RStudio 須以「相對路徑」一節的 .Rproj 開啟,renv 才會生效。

還原時交代 agent:

這個專案使用 renv。執行 renv::restore() 依 renv.lock 安裝套件,
完成後執行主腳本,確認可以跑完,並回報有無安裝失敗的套件。

還原的那台電腦上不必事先安裝 renv,在專案資料夾啟動 R 時,它會自行下載安裝對應的版本,並詢問是否還原套件。

renv 管的只有套件。renv.lock 雖記下了 R 的版本,卻不會代為安裝,那台電腦的 R 須另行安裝,版本應與紀錄相同或相近;相差太遠時,紀錄中較舊的套件版本未必裝得起來。此外,較舊的套件版本在 Windows 上可能沒有現成的安裝檔,須由原始碼編譯,此時要另外安裝 Rtools。

Python:uv

uv 不是 Python 的套件,須另行安裝,步驟見官方文件。專案的套件庫位於 .venv,專案直接用到的套件記在 pyproject.toml,實際裝上的每個套件及其版本則記在 uv.lock。與 renv 不同的是,uv 連 Python 本身也管,專案所需的 Python 版本如果電腦上沒有,它會自行下載。

啟用時交代 agent:

替這個專案啟用 uv。執行 uv init --bare,找出所有腳本 import 的套件並以 uv add 加入,
完成後說明專案裡多了哪些檔案,然後以 uv run 執行主腳本確認結果與先前相同,並提交一次。

renv::init() 會自行掃描腳本用到的套件,uv 不會,因此指示裡多了找出套件的一句。啟用之後,腳本須以 uv run 執行,用到的才是專案的套件庫。

須納入提交的是 pyproject.toml 與 uv.lock,.venv 不納入,uv 同樣會自行建立 .gitignore 排除它。

還原時交代 agent:

這個專案使用 uv。執行 uv sync 依 uv.lock 安裝套件,
完成後以 uv run 執行主腳本,確認可以跑完,並回報有無安裝失敗的套件。

還原的那台電腦上須先裝好 uv,Python 本身由它依紀錄下載,不必另外安裝。

交給 agent 的整理規矩

本篇的做法可以寫成規矩,放進規範檔,即專案資料夾內、agent 每個對話開始時都會讀取的一份檔案,此後 agent 寫新的 code 時照此辦理,讀到既有的 code 違反時也會提出。Claude 的規範檔是 CLAUDE.md,Codex 的是 AGENTS.md,內容相同,詳見「規範檔的作用與位置」一節:

## 專案整理
- 這個資料夾只放這一份工作的資料、code 與文件。發現無關的檔案,或需要讀寫資料夾以外的檔案時,先指出來問我。
- 路徑一律相對於專案資料夾,不使用 `setwd()` 與絕對路徑。發現既有的 code 違反時,指出並提議修正。
- 同一段邏輯出現第二次就寫成函數;單一腳本過長時,提議分檔,經同意後再動手。重構前先存下輸出作為基準並提交一次,重構後比對一致才算完成,再提交一次。
- 套件版本由 `renv`(R)或 `uv`(Python)管理。安裝、更新或移除套件之後,確認 `renv.lock` 或 `uv.lock` 已更新,並將它納入提交。

這幾條也可以直接交代 agent 寫入,專案裡還沒有規範檔時它會一併建立,寫完再打開檔案確認內容。