蒼時弦也
蒼時弦也
資深軟體工程師
發表於

如何設計自己的 AI Agent 框架

經過一週左右,根據 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 可以根據上下文判斷發生什麼事情。

延續退貨的舉例,我們要教新來的客服人員協助顧客退貨,大致上來說會這樣描述。

  1. 在系統的後台有一個「退貨」的功能
  2. 退貨時需要輸入「訂單編號」
  3. 成功時會看到「退貨成功」
  4. 當成功退貨時,回訊息告訴顧客「已完成訂單 <訂單編號> 的退貨」

這就是我們要教給 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 只要掌握了核心的邏輯後,原本「分析需求」的任務就變成了「清楚定義」的提示詞,減少了開發但是並沒有省去需求釐清的部分,甚至可能需要做的更好。