我點樣用大型語言模型 (LLM) 嚟寫程式?

Ai

學習機器

我如何與大型語言模型編程

生成模型在編程中可以非常有用——前提是你願意調整你的方法。

這篇文章是我在過去一年使用生成模型進行編程的個人經歷總結。這並不是一個被動的過程。我有意尋找在編程時使用大型語言模型(LLMs)的方法,以了解它們的運作。結果是,我現在在工作中經常使用LLMs,我認為它們對我的生產力產生了正面影響。(我嘗試不使用它們進行編程的經歷相當不愉快。)

在這個過程中,我發現了一些可以自動化的常見步驟,我們中的幾個人正在努力將這些步驟構建成一個專門為Go編程設計的工具:sketch.dev。雖然目前還處於早期階段,但到目前為止,經驗是積極的。

背景

我通常對新技術充滿好奇。對LLMs的實驗幾乎立刻讓我想看看能否從中提取實際價值。這項技術的魅力在於,它能(至少在某些情況下)針對挑戰性問題生成複雜的回應。看到計算機根據請求嘗試編寫程序的一部分並取得實質進展,讓我感到更加興奮。

我所經歷的唯一一個類似的技術變革發生在1995年,當時我們首次配置了可用的默認路由。我用一台可以路由撥號連接的計算機替換了另一間房間裡運行Trumpet Winsock的共享計算機,瞬間我就可以隨時上網。隨時上網是驚人的,感覺像是未來。對我來說,這種感受可能更強烈,因為我立刻進入了高端互聯網技術的世界:網頁瀏覽器、JPEG圖像和數百萬人。獲得強大的LLM的訪問權限感覺也類似。

因此,我跟隨這份好奇心,看看這種能生成大多數不錯內容的工具能否在我的日常工作中帶來淨收益。答案似乎是“是的”——生成模型在我編程時確實是有用的。達到這一點並不容易。對新技術的根本迷戀是我能夠理清思路的唯一原因,所以當其他工程師聲稱LLMs是“無用”的時候,我會表示理解。但由於我被問過多次如何有效使用它們,這篇文章是我描述目前所發現的內容的嘗試。

概述

我在日常編程中使用LLMs的三種方式:

1. **自動補全**。這使我更具生產力,因為它能為我完成許多明顯的打字工作。事實證明,當前的技術狀態在這方面是可以改進的,但這是另一個話題。即使是市面上標準的產品對我來說也比沒有要好。我通過試著放棄它們來說服自己這一點。我無法在沒有感到沮喪的情況下度過一周,因為我在使用FIM模型之前不得不進行大量繁瑣的打字。這是第一個實驗的地方。

2. **搜尋**。如果我對複雜的環境有疑問,比如“如何使CSS中的按鈕透明”,我發現向任何消費者型的LLM詢問會得到比使用傳統的網頁搜尋引擎更好的答案。(有時LLM也會錯誤。人類也會錯誤。前幾天,我把鞋子放在頭上,問我兩歲的女兒對我的“帽子”有什麼看法。她應對了這一切,並給了我一頓好罵。我也能接受LLM偶爾出錯。)

3. **聊天驅動的編程**。這是三者中最困難的。這是我從LLMs中獲得最多價值的地方,但也是我最困擾的地方。這涉及到學習很多並調整你的編程方式,這在原則上是我不喜歡的。從LLM聊天中獲得價值需要的麻煩程度與學習使用計算尺一樣,還有一個令人煩惱的事實是,它是一個非確定性的服務,經常改變其行為和用戶界面。事實上,我在工作的長期目標是取代聊天驅動編程的需求,以便以不那麼令人反感的方式將這些模型的力量帶給開發者。但目前,我致力於逐步解決這個問題,這意味著要弄清楚如何利用現有資源並加以改進。

由於這是關於編程實踐的內容,因此這是一個根本上質性而非量化的過程。最接近數據的說法是,根據我的記錄,現在每兩小時的編程中,我會接受超過10個自動補全建議,使用LLM進行一次類似搜尋的任務,並在聊天會話中編程一次。

接下來的內容將集中在如何從聊天驅動編程中提取價值。

為什麼使用聊天?

讓我來激勵一下懷疑者。我從聊天驅動編程中獲得的價值之一是,當我知道需要編寫的內容時,我能夠描述它,但卻沒有精力去創建新文件、開始打字,然後開始查找所需的庫。(我是早起型的人,所以這通常是我上午11點之後的任何時候,雖然也可能是我切換到不同語言/框架等的任何時候。)LLMs在編程中為我執行這一服務。它們給我提供了一個初稿,提供了一些好點子和幾個我需要的依賴——而且經常還會有一些錯誤。通常,我發現修正這些錯誤比從零開始要容易得多。

這意味著聊天驅動的編程可能不適合你。我正在進行一種特定類型的編程,即產品開發,可大致描述為試圖通過穩健的接口將程序交付給用戶。這意味著我在建設大量東西,丟棄大量東西,並在不同環境之間反復切換。有些日子,我主要寫TypeScript,有些日子主要寫Go。上個月我花了一周時間在C++代碼庫中探索一個想法,剛好有機會學習HTTP服務器端事件格式。我在各處跳躍,不斷忘記和重新學習。如果你花更多的時間證明你的加密算法優化不會受到時間攻擊的威脅,而不是編寫代碼,那我認為我在這裡的觀察對你沒有什麼幫助。

聊天型LLMs最擅長考試風格的問題

給LLM一個具體的目標以及它所需的所有背景資料,以便它能夠生成一份完整且獨立的代碼審查包,並期望它在你質疑它時進行調整。有兩個主要要素:

1. 避免創造一種過於複雜和模糊的情況,讓LLM感到困惑並產生不良結果。這就是為什麼我在IDE中進行聊天時成功率不高的原因。我的工作空間通常很雜亂,我正在處理的代碼庫默認過於龐大,並且充滿了干擾。人類似乎在這方面比LLMs更優秀(截至2025年1月),因為人類不容易分心。這就是為什麼我仍然通過網頁瀏覽器使用LLM——因為我想要一個乾淨的環境來編寫一個完整的請求。

2. 要求易於驗證的工作,作為使用LLM的程序員,你的工作是閱讀它生成的代碼,思考它,並決定工作是否良好。你可以要求LLM執行一些你永遠不會要求人類做的事情。“重寫所有你的新測試,引入一個”是一件可怕的事情;你會面臨幾天的緊張反覆討論,關於工作的成本是否值得。LLM會在60秒內完成,而不需要你為了完成這項工作而與它鬥爭。利用重新做工作的極低成本這一點。

LLM的理想任務是需要使用大量常用庫的任務(超過人類能記住的數量,因此它為你進行大量小規模的研究),按照你設計的接口工作,或者使其生成一個小接口,你可以快速驗證它的合理性,並且它可以編寫可讀的測試。有時這意味著如果你想要一些不常見的庫,你需要為它選擇庫(不過在開源代碼中,LLMs在這方面的表現相當不錯)。

你總是需要將LLM的代碼通過編譯器進行編譯並運行測試,然後再花時間閱讀它。它們有時會生成不會編譯的代碼(每次看到一個錯誤,我都感到驚訝,因為這些錯誤反而顯得相當人性化——每次看到它,我都在想:“若不是上天庇佑,我也會這樣。”)。更好的LLMs在從錯誤中恢復方面非常出色;通常,它們只需要你將編譯器錯誤或測試失敗的結果粘貼到聊天中,它們就能修正代碼。

額外的代碼結構成本更低

我們每天都在進行一些模糊的取捨,圍繞著寫作成本、閱讀成本和重構代碼成本。讓我們以Go包邊界為例。標準庫有一個名為“net/http”的包,其中包含一些處理線路格式編碼、MIME類型等的基本類型。它包含一個HTTP客戶端和一個HTTP服務器。它應該是一個包還是幾個包?合理的人可能會有不同意見!如此多,以至於我今天都不知道是否有正確的答案。現有的包運行良好;經過15年的使用,我仍然不清楚是否有其他包的排列會更好。

更大包的優勢包括為調用者提供集中式文檔、簡化初始編寫、簡化重構以及在不設計穩健接口的情況下輕鬆共享輔助代碼(這通常涉及將包的基本類型提取到另一個葉子包中,該包充滿了類型)。劣勢包括包的可讀性下降,因為許多不同的事情同時發生(試著閱讀net/http客戶端實現而不在服務器代碼中迷路幾分鐘),或者因為包中發生的事情太多而使用起來困難。例如,我有一個代碼庫使用C庫的一些基本類型,但代碼庫的某些部分需要在不技術上需要C庫的二進制文件中,這樣就有比你想像中更多的包,以避免在多平台二進制文件中使用cgo。

這裡沒有正確的答案。我們只是在進行不同類型的工作之間的取捨(前期和持續)。LLMs影響這些取捨:

1. 由於LLMs在考試風格問題上表現更好,更多更小的包使得為一個工作提供完整且獨立的上下文變得更容易。這對人類也是如此,這就是為什麼我們使用包的原因,但我們在包的大小與編寫/安裝/歸檔的額外輸入之間進行折衷。對於LLM來說,執行並從大量額外工作中受益,這一取捨發生了變化。(作為額外好處,我們人類得到了更可讀的代碼!)

2. 更小且數量更多的包可以獨立於不相關的代碼進行編譯和測試。這對LLM的開發周期有幫助,因為這意味著在重構其他依賴包之前,可以編譯和測試變更,並且該包具有更簡單的測試環境。

一個示例

讓我舉一個例子來結合幾個討論過的想法:

編寫一個浮點數四分位數的水庫取樣器。

首先是包結構。如果在使用LLMs之前我做這件事,我會選擇某種streamstat包,裡面包含幾個算法,也許每個算法一個文件。這似乎並不是一個獨特的意見;這裡有一個遵循該模型的開源分位數包。現在,我希望將這個算法放在自己的包中。其他變體或相關算法可以有自己的包。

接下來,我們從LLM那裡得到了什麼?第一版還不錯。這個提示,加上一些關於希望它用Go編寫的細節,得到了quartile_sampler.go:

“`go
// QuartileSampler maintains a reservoir sample to estimate quartiles of a stream
// of float64 values. It uses reservoir sampling to maintain a representative
// sample when processing large streams of data.
type QuartileSampler struct {
reservoir []float64
seen int
maxSize int
rng *rand.Rand
}

// NewQuartileSampler creates a new sampler that maintains up to maxSize samples.
// A larger maxSize provides more accurate estimates but uses more memory.
func NewQuartileSampler(maxSize int) *QuartileSampler { … }
“`

核心接口也很好:

“`go
// Add adds a new value to the sampler.
func (qs *QuartileSampler) Add(value float64) { … }

// Quartiles returns the estimated 25th, 50th (median), and 75th percentiles
// of the data seen so far. Returns (0,0,0) if no data has been added.
func (qs *QuartileSampler) Quartiles() (q1, median, q3 float64) {
if len(qs.reservoir) == 0 {
return 0, 0, 0
}

// Make a copy to avoid modifying the reservoir
sorted := make([]float64, len(qs.reservoir))
copy(sorted, qs.reservoir)
slices.Sort(sorted)

n := len(sorted)
q1 = percentile(sorted, 0.25)
median = percentile(sorted, 0.50)
q3 = percentile(sorted, 0.75)

return q1, median, q3
}
“`

太好了!還有測試。

附帶一提:這可能是停止的地方。有時我將LLM代碼生成作為一種特殊搜尋的形式。例如,我對水庫取樣很感興趣,但想看看在某種驚人的約束下如何應用該算法——例如,時間窗口取樣。與其進行文獻搜尋,我不如修改我的提示,要求實現一個跟蹤新鮮度的版本。(我也可以要求它在註釋中提及文獻,這樣我可以手動檢查它是否在編造東西,或者是否有一些可靠的研究可以作為參考。)

我經常花60秒閱讀一些生成的代碼,看到一個明顯的技巧,然後將其丟棄並重新開始。現在我知道這個技巧是可行的。這就是為什麼從LLMs中產生的價值如此難以歸屬。是的,有時它會生成糟糕的代碼,陷入困境,編造一些不可能的東西(前幾天它幻想了一部分我希望存在的monaco API),並浪費我的時間。它也可以通過指出我不知道的相關內容來節省我幾小時。

回到代碼。令人驚訝的是,生成的初始代碼並不編譯。在Quartiles實現的中間,有這一行:

“`go
n := len(sorted)
“`

這是一行不錯的代碼;sorted是幾行前定義的切片。但該值從未被使用,因此gopls(如果你運行go build的話,Go編譯器也會)立即顯示:

“`
declared and not used: n
“`

這是一個非常簡單的修復。如果我將錯誤粘貼回LLM,它會進行修正。不過在這種情況下,當我閱讀代碼時,對我來說很明顯,我可以自己刪除這一行,所以我這樣做了。

現在是測試。我得到了我期待的結果。在quartile_sampler_test.go中:

“`go
func TestQuartileSampler(t *testing.T) {
tests := []struct {
name string
data []float64
wantQ1 float64
wantMed float64
wantQ3 float64
epsilon float64 // allowed error for floating point comparison
}{
{
name: “basic sequence”,
data: []float64{1, 2, 3, 4, 5, 6, 7},
wantQ1: 2,
wantMed: 4,
wantQ3: 6,
epsilon: 0.1,
},

}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
qs := NewQuartileSampler(100) // Use large reservoir for accurate testing
for _, v := range tt.data {
qs.Add(v)
}

q1, med, q3 := qs.Quartiles()

if math.Abs(q1-tt.wantQ1) > tt.epsilon {
t.Errorf(“Q1 = %v, want %v (±%v)”, q1, tt.wantQ1, tt.epsilon)
}
if math.Abs(med-tt.wantMed) > tt.epsilon {
t.Errorf(“Median = %v, want %v (±%v)”, med, tt.wantMed, tt.epsilon)
}
if math.Abs(q3-tt.wantQ3) > tt.epsilon {
t.Errorf(“Q3 = %v, want %v (±%v)”, q3, tt.wantQ3, tt.epsilon)
}
})
}
}
“`

這正是我會寫的那種測試!我會通過另一個實現運行一些案例,生成預期的輸出,然後將它們複製到這樣的測試中。但這裡有兩個問題。

第一,LLM沒有將這些數字通過另一個實現運行。(據我所知。在使用複雜的LLM服務時,很難確定背後發生了什麼。)它編造了這些數字,而LLMs在算術方面的表現不佳。因此,這種測試雖然對人類來說是合理的,因為我們基於另一個工具的輸出來寫它——或者,若我們特別老派,自己做一些算術——但這對LLM來說並不好。

第二個問題是,我們可以做得更好。我很高興我們現在生活在一個程序員編寫自己測試的時代,但我們對測試的標準並不如對生產代碼的標準那麼高。這是一個合理的折衷;一天的時間是有限的。但LLMs在算術能力上的不足,讓它們在熱情方面彌補了這一點。

讓我們要求一個更好的測試。

在測試中,實現一個最簡單、最可讀的標準代碼版本,用於在固定已知值的切片上計算四分位數。然後將測試案例通過標準代碼和水庫取樣器,確認它們在一定範圍內的相似性。將比較代碼結構化,以便也可以用於模糊測試。

這為我們帶來了一些新的測試代碼:

“`go
// referenceQuartiles calculates the exact quartiles for a slice of float64 values
// using linear interpolation, matching the behavior expected from the sampler.
func referenceQuartiles(data []float64) (q1, median, q3 float64) { … }

// compareQuartiles checks if two sets of quartiles are within epsilon of each other.
// Returns true if they match within the tolerance, false otherwise.
func compareQuartiles(q1a, meda, q3a, q1b, medb, q3b, epsilon float64) bool { … }

// checkQuartiles is a test helper that compares sampler output against the reference
// implementation and reports any differences.
func checkQuartiles(t *testing.T, data []float64, epsilon float64) {
t.Helper()

// Get reference values
wantQ1, wantMed, wantQ3 := referenceQuartiles(data)

// Get sampler values using a large reservoir for accuracy
qs := NewQuartileSampler(1000)
for _, v := range data {
qs.Add(v)
}
gotQ1, gotMed, gotQ3 := qs.Quartiles()

if !compareQuartiles(gotQ1, gotMed, gotQ3, wantQ1, wantMed, wantQ3, epsilon) {
t.Errorf(“Quartiles mismatch:ngot (q1=%v, med=%v, q3=%v)nwant (q1=%v, med=%v, q3=%v)nepsilon=%v”,
gotQ1, gotMed, gotQ3, wantQ1, wantMed, wantQ3, epsilon)
}
}
“`

原始測試已經重新編寫,以使用checkQuartiles,我們得到了新的內容:

“`go
func FuzzQuartileSampler(f *testing.F) {
// Add some seed corpus
f.Add([]float64{1, 2, 3, 4, 5})

f.Fuzz(func(t *testing.T, data []float64) {
// Use a larger epsilon for fuzzing since we might get more extreme values
checkQuartiles(t, data, 0.2)
})
}
“`

這非常有趣,因為它是錯誤的。我的gopls工具立即顯示:

“`
fuzzing arguments can only have the following types:
string, bool, float32, float64,
int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64,
[]byte
“`

將這一錯誤粘貼回LLM使其重新生成模糊測試,這樣它就圍繞一個`func(t *testing.T, data []byte)`函數構建,該函數使用`math.Float64frombits`從數據切片中提取浮點數。這些互動指向我們自動化工具的反饋;它所需的僅僅是顯而易見的錯誤消息,就能朝著有用的方向取得重大進展。我並不需要參與。

對於我最近幾周的LLM聊天記錄進行快速調查顯示(如我之前提到的,這並不是一個合適的量化分析),在80%以上的情況下,工具錯誤,LLM能在沒有我提供任何見解的情況下進行有用的進展。大約一半的時候,它完全可以在我沒有任何顯著貢獻的情況下解決問題。我只是充當了信使。

我們的未來?更好的測試,也許更少的重複代碼

大約25年前,有一個編程運動專注於“不要重複自己”的原則。正如許多對本科生教授的短小精悍的原則那樣,它被過度解讀。抽象出一段代碼以便重用的代價非常高;這需要創建中介抽象,必須學習這些抽象,並且需要為事實上要提取出來的代碼添加功能,以使其對最多的人最大限度地有用,這意味著我們依賴於充滿無用分散功能的庫。

在過去的10到15年中,編程人員對編寫代碼的理解變得更加溫和,許多人明白如果共享實現的成本高於實現和維護單獨代碼的成本,那麼重新實現一個概念會更好。我不再經常在代碼審查中寫下:“這不值得,將實現分開。”(這是幸運的,因為人們在完成所有工作後真的不想聽到這種話。)程序員在權衡取捨方面變得更好。

我們現在擁有的是一個取捨發生了變化的世界。編寫更全面的測試變得更容易。你可以讓LLM編寫你想要但沒有時間正確構建的模糊測試實現。你可以花更多的時間編寫可讀性高的測試,因為LLM不會在那裡不停地思考:“如果我去解決另一個問題,對公司會更好,而不是這個。”因此,取捨向擁有更多專門實現的方向轉移。

我預計這在語言特定的REST API封裝中會最為明顯。每個主要公司的API都有數十個這樣的封裝(通常質量不高),由那些實際上並未針對特定目標使用其實現的人編寫,而是試圖在一個大型複雜接口中捕捉API的每個細節。即使做得很好,我發現去REST文檔(通常是一組curl命令)並為我實際關心的1%的API實現一個語言封裝要容易得多。這減少了我需要提前學習的API數量,並減少了未來的程序員(包括我自己)閱讀代碼時需要理解的內容。

例如,作為我最近在sketch.dev上工作的部分,我用Go實現了一個Gemini API封裝。儘管官方的Go封裝是由熟悉該語言並明確關心的人精心手工製作的,但要理解它卻有很多內容需要閱讀:

“`
$ go doc -all genai | wc -l
1155
“`

我簡單的初始封裝總共只有200行代碼——一個方法,三種類型。閱讀整個實現只佔閱讀官方包文檔的20%的工作,如果你試圖深入了解其實現,你會發現它是一個圍繞另一個主要是代碼生成的實現的封裝,使用protos和grpc等。所有我想要的只是發送cURL請求並解析JSON對象。

當然,項目中會有一個點,Gemini成為整個應用的基礎,幾乎每個功能都被使用,這時使用大型官方封裝是合適的。但大多數時間,這樣做的時間成本通常更高,因為我們幾乎總是只想要今天需要的API的一小部分。因此,由GPU大致編寫的自定義客戶端對於完成工作來說更有效。

因此,我預見到一個世界,將會有更多專門的代碼、更少的通用包和更可讀的測試。可重用的代碼將繼續圍繞小而穩健的接口蓬勃發展,否則將被拆分為專門的代碼。根據這樣的做法,這可能會導致更好的軟件或更糟的軟件。我預計兩者都有,長期趨勢可能會朝著更好的軟件發展,這些軟件在重要指標上表現更佳。

自動化這些觀察:sketch.dev

作為一名程序員,我的直覺是讓計算機為我工作。從LLMs中獲取價值是一項艱巨的工作——那麼計算機如何做到這一點?

我相信解決問題的關鍵是不過度概括。解決一個特定的問題,然後慢慢擴展。因此,我們不打算構建一個在COBOL和Haskell中同樣出色的通用聊天編程UI,而是想專注於一個特定的環境。我的大部分編程工作是在Go中進行的,所以我想要的對於Go程序員來說是容易想像的:

– 類似Go playground的東西,圍繞編輯一個包和測試構建
– 一個可編輯代碼的聊天界面
– 一個可以運行go get和go test的小型UNIX環境
– goimports集成
– gopls集成
– 自動模型反饋:在模型編輯時運行go get、go build、go test,反饋缺失的包、編譯器錯誤、測試失敗,並嘗試自動修正它們
– 我們中幾個人已經構建了一個早期原型:sketch.dev。

目標不是成為一個“Web IDE”,而是挑戰聊天驅動編程甚至屬於傳統所謂IDE的觀念。IDE是為人們安排的工具集合。這是一個微妙的環境,我知道發生了什麼。我不希望LLM在我的當前分支上亂發初稿。雖然LLM最終是一個開發者工具,但它是一個需要自己的IDE來有效運作的工具。

換句話說,我們沒有將goimports嵌入sketch是為了讓人類使用,而是為了通過自動信號使Go代碼更接近編譯,以便編譯器能夠向驅動它的LLM提供更好的錯誤反饋。也許將sketch.dev視為“LLMs的Go IDE”會更好。

這一切都是非常新的工作,還有很多工作要做,例如git集成,以便我們可以加載現有包進行編輯,並將結果丟到一個分支上。我們也需要更好的測試反饋和更多控制台控制。(如果答案是運行sed,那就運行sed。不論是人還是LLM。)我們仍在探索,但我們相信,專注於特定類型編程的環境將比通用工具提供更好的結果。

這篇文章的作者David Crawshaw是Tailscale的聯合創始人(和前CTO),目前居住在灣區,正在構建sketch.dev。他編程已有30年,計劃再編程30年。

以上文章由特價GPT API KEY所翻譯及撰寫。而圖片則由FLUX根據內容自動生成。

Chat Icon