經過一週左右,根據 2024 年底 ihower 的淺談 LLM-based AI Agents 應用開發演講提到的核心概念,用了數百行程式碼完成 Autoflux 這個設計給 Ruby 的 AI Agent 框架,設計過程中反覆修改時思考了一些問題,很值得分享給大家參考。
定義 Agent
我認為關鍵的第一步,是知道我們要做的 AI Agent 到底是怎樣的東西。因此需要給 Agent 一個定義,而且是能轉換成一個程式上的 API 定義。
「根據提示(Prompt)完成一連串操作的程式」
舉例來說,我們想在一個線上賣場辦理退貨,但是需要經過一些驗證,所以聯絡了客服人員。接下來客服人員向你搜集足夠的資訊後,幫你完成的退貨流程,這個客服人員就是 Agent(代理)替你處理退貨。
根據上述的定義,釐清後我們的物件預期會滿足單一職責(SOLID原則)以 Golang 表示會看起來像這樣。
1type Agent interface {
2 Execute(ctx context.Context, prompt string) (string, error)
3}
1res, _ := agent.Execute(ctx, "我要把訂單 #1234 退貨")
2// res => "已完成訂單 #1234 的退貨"
使用 Golang 來表示是因為可以呈現型別(這很關鍵)因此我對 Agent 的期待是以字串輸並且得到字串輸出的方法,以大型語言模型(LLM)為基礎的應用,互動的介面就是字串。
即使是使用 JSON 字串也沒有問題,像是 OpenAI 有提供根據
json_schema
回應的選項,因為可以後續做編碼處理,因此不影響介面的定義。
在這裡,我們先假設使用者會一次性的給予完整資訊,Agent 能夠一次性完成所有必要的任務。
賦能 Agent
如果只是簡單的呼叫 LLM 的話,只是實現基於 LLM 的對話功能,所以要在支援工具使用(Tools 或者 Functions)的 LLM 透過提供可用工具的指示,讓這些 LLM 回答需要使用的工具,我們再呼叫對應的 API 來處理。
大致上來說,我們會需要一段類似這樣的實作。
1class Agent
2 # ...
3
4 def call(prompt)
5 # 紀錄狀態
6 messages = [
7 { role: "system", content: "..." },
8 { role: "user", content: prompt }
9 ]
10
11 loop do
12 # 呼叫 LLM
13 res = @client.call(
14 model: "gpt-4o-mini",
15 messages: messages,
16 tools: @tools
17 )
18 # 更新狀態
19 messages.push(res)
20
21 # 當沒有其他工具需求時回傳 LLM 的結論
22 break res[:content] unless res[:tool_calls]&.any?
23
24 res[:tool_calls].each do |call|
25 # 呼叫工具
26 # res = use_tool(call)
27
28 # 工具回傳
29 messages.push(role: "tool", content: res.to_json, tool_call_id: call[:id])
30 end
31 end
32 end
33end
在這個階段,我們要做的事情就是判斷「還有沒有其他操作」這件事情,當沒有後續操作時 LLM 最後回傳的訊息應該就是要給使用者的資訊,在使用工具時我們需要紀錄「操作的任務」跟「操作的結果」讓 LLM 可以根據上下文判斷發生什麼事情。
延續退貨的舉例,我們要教新來的客服人員協助顧客退貨,大致上來說會這樣描述。
- 在系統的後台有一個「退貨」的功能
- 退貨時需要輸入「訂單編號」
- 成功時會看到「退貨成功」
- 當成功退貨時,回訊息告訴顧客「已完成訂單 <訂單編號> 的退貨」
這就是我們要教給 Agent 的事情,只是上面的 UI 操作變成一個 API 呼叫,像是 POST /orders/:id/cancel
這樣的端點,我們將他封裝起來讓 Agent 可以去呼叫。
在真實的情境單一 Agent 可能會使用好幾個功能,因此我們需要一個迴圈來持續檢查 LLM 目前的進度,直到 LLM 停止操作工具。
這裡會有一些不容易處理的地方,像是你的提示怎麼讓 Agent 知道要依照特定順序操作,如果失敗太多次要怎麼終止並且告訴使用者等等。除此之外,有些 LLM 會有「平行使用工具」的選項,沒有設定好可能會無法保證某些操作的順序。
到這個階段,我們已經得到了一種新的介面。原本需要透過 HTML 呈現在瀏覽器上的輸入欄位跟按鈕,或者透過 JSON 定義的 API 轉換成了以文字為基礎的操作介面,也許現在可以提供一個通用的 POST /chat
API 來讓使用者透過任意文字進行操作。
工作流程
Agent 已經可以很好的處理不少情境,然而還是跟以往透過瀏覽器、APP、API 的操作方式一樣有不連續的狀況。這個時候我們可以再設計一個工作流程(Workflow)來完成連續的任務,進一步的改善 Agent 一次只能完成一個任務的缺點。
我們已經有了以下的 Agent 定義,但是每次呼叫只會做一次動作,並且只能有一種處理
1type Agent interface {
2 Execute(ctx context.Context, prompt string) (string, error)
3}
要解決這樣的問題,我們只需要再加入一個新的步驟——確認使用者的提示。
1def run(agent)
2 loop do
3 prompt = gets&.chomp
4 break if prompt.nil? # gets 會在 EOF 時回傳 nil
5
6 puts agent.call(prompt)
7 end
8end
這並不複雜,我們只需要用一個迴圈持續呼叫 Agent 直到沒有新的輸入為止,那麼就可以得到這樣的效果。
1puts agent.call("我要退貨")
2# => "請問訂單編號是?"
3puts agent.call("#1234")
4# => "已完成訂單 #1234 的退貨"
在我們前面的設計,每次呼叫 Agent 時才會建立一個暫時的記憶用來紀錄工具使用,那麼兩次呼叫之間就無法讓 LLM 知道關聯性,因此我們需要以 Agent 為單位建立訊息的紀錄,而非單一次的呼叫。
1class Agent
2 # ...
3 def initialize(memory: [])
4 @messages = memory
5 end
6
7 def call(prompt)
8 loop do
9 # 呼叫 LLM
10 res = @client.call(
11 model: "gpt-4o-mini",
12 messages: @messages,
13 tools: @tools
14 )
15 # 更新狀態
16 @messages.push(res)
17
18 # ...
19 end
20 end
21end
當然,也可以選擇用 call(prompt, messages)
這樣的形式傳遞,在大多數語言中 Array 都是一個指標,因此可以輕鬆的在不同 Agent 之間共用,來達到延續記憶的能力。
有了 Workflow 後,我們能解決大部分的情境,接下來還有 Multi Agent 的狀況,但也不太複雜,我們將 Workflow 設計成一個物件,紀錄目前使用的 Agent 是誰,就能夠進行切換。
1class Workflow
2 attr_accessor :agent
3
4 def initialize(agent:)
5 @agent = agent
6 end
7
8 def run
9 loop do
10 prompt = gets&.chomp
11 break if prompt.nil? # gets 會在 EOF 時回傳 nil
12
13 puts agent.call(prompt)
14
15 # 根據 Agent 回傳切換
16 # self.agent = new_agent
17 end
18 end
19end
大致上來說 AI Agent 由兩組迴圈構成,一個是 Agent 完成一個任務可能需要多個步驟,需要一個迴圈來處理,另一個則是把一件事處理好,還需要另一個迴圈持續跟 Agent 進行互動。
抽象定義
要實現 AI Agent 的機制遠比想像簡單,大多數會花費時間在設計好的提示以及處理 LLM 產生的一些異常行為,因此我們可以抽象的定義出這幾個概念。
1type Agent interface {
2 Name() string // 切換 Agent 時可能會很有用
3 Execute(ctx context.Context, prompt string) (string, error)
4}
5
6type Workflow struct {
7 agent Agent
8 agents []Agent
9}
10
11func (w *Workflow) SwitchAgent(name string) bool {
12 for agent := range w.agents {
13 if agent.Name() == name {
14 w.agent = agent
15 return true
16 }
17 }
18
19 return false
20}
對於主要的系統,只需要 Agent 是一種以 String 輸入跟輸出的物件即可。至於 Agent 的實作,是完全不用跟 Workflow 綁定的,這樣才能夠彈性的對應不同 LLM 有不一樣 API 的狀況。
1type Tool interface {
2 Name() string
3 Execute(ctx context.Context, params json.Unmarshaler) (json.Marshaler, error)
4 // 根據 LLM 不同,可能還會有像是 Parameters() 之類的方法
5}
6
7type Memory interface {
8 Add(Message)
9 List() []Message
10}
11
12type Agent struct {
13 name string
14 client Client // 可能是 OpenAI or Claude
15 tools []Tool
16 memory Memory
17}
18
19func (a *Agent) Execute(ctx context.Context, prompt string) (string, error) {
20 // ...
21}
仔細觀察會發現 Agent 跟 Workflow 的介面有點相似,這是因為兩種物件都是透過呼叫時運行迴圈處理,而物件本身的資訊都是跟處理相關的。
剩下的就是語言上的差異,像是在 Golang 的工具中 params
使用 json.Unmarshaler
對 Agent 來說比較好處理,因為只需要提供 json.RawMessage
即可,而不需要去管內容是什麼。
在 Ruby 的話,這個 params
通常會是 Hash 而且可以先做 JSON.parse()
的解析,這是在語言差異上會體現出來的部分,在 Ruby 我會習慣用 #call
去命名,這樣在滿足條件時可以用 -> (params, **) { { success: true } }
的方式來代替實際的物件。
實務應用
雖然框架已經成形,然而怎樣的安排在我們的軟體架構中是最符合預期的呢?在最後我們來用幾個案例探討一下。
假設我們決定用 POST /chat
作為端點,不是採用即時對話的方式使用 Agent 的話,應該會是一個怎樣的情境。
1class ChatController < ApplicationController
2 def create
3 # 用一個 Agent 判斷對話相關的 Agent
4 agent = ChatAgent.new(
5 # ...
6 response_format: {
7 type: "json_schema",
8 json_schema: {
9 # ...
10 }
11 }
12 )
13 res = JSON.parse(agent.call(params[:prompt]))
14
15 # 載入對話
16 memory = Memory.find(current_user.id)
17
18 # 選擇推薦的 Agent
19 agent = case res['agent_name']
20 when "shopping" then ShoppingAgent.new(memory: memory)
21 when "checkout" then CheckoutAgent.new(memory: memory)
22 else SupportAgent.new(memory: memory)
23 end
24 res = agent.call(params[:prompt])
25 # 更新對話紀錄
26 memory.save
27 # 回傳結果
28 render json: res
29 rescue MyLLM::BadRequest
30 render json: { error: "unable to chat with LLM" }, status: :bad_request
31 end
32
33 # ...
34end
依照上述的範例,我們可以透過一個 Agent 來判斷收到的訊息適合用哪一種 Agent 來處理,並且根據建議生成 Agent 以及附加這個使用者過去的對話紀錄,讓 Agent 適當的協助使用者。
然而 LLM 並不是非常穩定的工具,像是可能會生成了一個錯誤的 Function Name 造成錯誤,因此對話紀錄需要確保在呼叫成功後才能保存,而不能在呼叫時就保存,基於這樣的問題我們還需要考慮各種疑似隨機發生的問題。
當然,上述的流程通常需要再做封裝,此時一個客製化的 Workflow 就可以派上用場,根據 Agent 使用的複雜程度可以評估是否有必要使用 Workflow 來多做一層抽象化。
假設是以 TCP、WebSocket 的情境,我們也可以像這樣使用。
1server = TCPServer.new 8080
2
3loop do
4 Thread.start(server.accept) do |client|
5 agent = MudAgent.new
6
7 loop do
8 client.print '> '
9 prompt = client.gets&.chomp
10 break if prompt.nil? # 使用者關閉連線時為 EOF
11
12 client.puts agent.call(prompt)
13 end
14 end
15end
我們可以設計一個 MUD Agent(MUD 是一種文字連線遊戲)來處理使用者的輸入,可以看到進一步封裝迴圈後,就會變成 Workflow 的樣子,從特性上來分析 Workflow 是很類似 Controller 的物件。
然而,在思考 LLM 應用會有很多不確定性的資訊,需要多次輸入才能完成必要資訊的標準化,因此相比以往設計以單一操作為主的思考,更可能轉向串流(Streaming)的形式,保持連線直到問題被處理完畢。
關於 TCP 的範例可以參考 Autoflux Gem 的範例就可以看出為什麼會選擇 IO 介面作為 Workflow 的輸出跟輸入。
經過這一連串的思考,會發現 LLM-based 的應用開發,將以往分析需求轉換成架構、意圖清楚的程式碼,而 LLM-based 只要掌握了核心的邏輯後,原本「分析需求」的任務就變成了「清楚定義」的提示詞,減少了開發但是並沒有省去需求釐清的部分,甚至可能需要做的更好。