---
title: "如何設計自己的 AI Agent 框架"
date: 2025-01-15T00:00:00+08:00
publishDate: 2025-01-15T00:00:00+08:00
lastmod: 2025-01-12T19:52:58+08:00
tags: ["經驗","AI","Ruby","Golang"]
toc: true
permalink: "https://blog.aotoki.me/posts/2025/01/15/design-your-ai-agent-framework/"
language: "zh-tw"
---


經過一週左右，根據 2024 年底 ihower 的[淺談 LLM-based AI Agents 應用開發](https://ihower.tw/blog/archives/12586)演講提到的核心概念，用了數百行程式碼完成 [Autoflux](https://github.com/elct9620/autoflux) 這個設計給 Ruby 的 AI Agent 框架，設計過程中反覆修改時思考了一些問題，很值得分享給大家參考。

<!--more-->

## 定義 Agent {#define-agent}

我認為關鍵的第一步，是知道我們要做的 AI Agent 到底是怎樣的東西。因此需要給 Agent 一個定義，而且是能轉換成一個程式上的 API 定義。

「根據提示（Prompt）完成一連串操作的程式」

舉例來說，我們想在一個線上賣場辦理退貨，但是需要經過一些驗證，所以聯絡了客服人員。接下來客服人員向你搜集足夠的資訊後，幫你完成的退貨流程，這個客服人員就是 Agent（代理）替你處理退貨。

根據上述的定義，釐清後我們的物件預期會滿足單一職責（[SOLID](https://zh.wikipedia.org/zh-tw/SOLID_(%E9%9D%A2%E5%90%91%E5%AF%B9%E8%B1%A1%E8%AE%BE%E8%AE%A1))原則）以 Golang 表示會看起來像這樣。

```go
type Agent interface {
    Execute(ctx context.Context, prompt string) (string, error)
}
```

```go
res, _ := agent.Execute(ctx, "我要把訂單 #1234 退貨")
// res => "已完成訂單 #1234 的退貨"
```

使用 Golang 來表示是因為可以呈現型別（這很關鍵）因此我對 Agent 的期待是以字串輸並且得到字串輸出的方法，以大型語言模型（LLM）為基礎的應用，互動的介面就是字串。

> 即使是使用 JSON 字串也沒有問題，像是 [OpenAI](https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format) 有提供根據 `json_schema` 回應的選項，因為可以後續做編碼處理，因此不影響介面的定義。

在這裡，我們先假設使用者會一次性的給予完整資訊，Agent 能夠一次性完成所有必要的任務。

## 賦能 Agent{#empower-agent}

如果只是簡單的呼叫 LLM 的話，只是實現基於 LLM 的對話功能，所以要在支援工具使用（Tools 或者 Functions）的 LLM 透過提供可用工具的指示，讓這些 LLM 回答需要使用的工具，我們再呼叫對應的 API 來處理。

大致上來說，我們會需要一段類似這樣的實作。

```ruby
class Agent
    # ...

	def call(prompt)
	  # 紀錄狀態
	  messages = [
		  { role: "system", content: "..." },
		  { role: "user", content: prompt }
	  ]

	  loop do
	    # 呼叫 LLM
	    res = @client.call(
	      model: "gpt-4o-mini",
	      messages: messages,
	      tools: @tools
	    )
	    # 更新狀態
	    messages.push(res)

	    # 當沒有其他工具需求時回傳 LLM 的結論
	    break res[:content] unless res[:tool_calls]&.any?

		res[:tool_calls].each do |call|
		  # 呼叫工具
		  # res = use_tool(call)

		  # 工具回傳
		  messages.push(role: "tool", content: res.to_json, tool_call_id: call[:id])
		end
	  end
	end
end
```

在這個階段，我們要做的事情就是判斷「還有沒有其他操作」這件事情，當沒有後續操作時 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 來讓使用者透過任意文字進行操作。

## 工作流程{#workflow}

Agent 已經可以很好的處理不少情境，然而還是跟以往透過瀏覽器、APP、API 的操作方式一樣有不連續的狀況。這個時候我們可以再設計一個工作流程（Workflow）來完成連續的任務，進一步的改善 Agent 一次只能完成一個任務的缺點。

我們已經有了以下的 Agent 定義，但是每次呼叫只會做一次動作，並且只能有一種處理

```go
type Agent interface {
    Execute(ctx context.Context, prompt string) (string, error)
}
```

要解決這樣的問題，我們只需要再加入一個新的步驟——確認使用者的提示。

```ruby
def run(agent)
  loop do
    prompt = gets&.chomp
    break if prompt.nil? # gets 會在 EOF 時回傳 nil

	puts agent.call(prompt)
  end
end
```

這並不複雜，我們只需要用一個迴圈持續呼叫 Agent 直到沒有新的輸入為止，那麼就可以得到這樣的效果。

```ruby
puts agent.call("我要退貨")
# => "請問訂單編號是？"
puts agent.call("#1234")
# => "已完成訂單 #1234 的退貨"
```

在我們前面的設計，每次呼叫 Agent 時才會建立一個暫時的記憶用來紀錄工具使用，那麼兩次呼叫之間就無法讓 LLM 知道關聯性，因此我們需要以 Agent 為單位建立訊息的紀錄，而非單一次的呼叫。

```ruby
class Agent
    # ...
    def initialize(memory: [])
	  @messages = memory
    end

	def call(prompt)
	  loop do
	    # 呼叫 LLM
	    res = @client.call(
	      model: "gpt-4o-mini",
	      messages: @messages,
	      tools: @tools
	    )
        # 更新狀態
	    @messages.push(res)

	    # ...
	  end
	end
end
```

當然，也可以選擇用 `call(prompt, messages)` 這樣的形式傳遞，在大多數語言中 Array 都是一個指標，因此可以輕鬆的在不同 Agent 之間共用，來達到延續記憶的能力。

有了 Workflow 後，我們能解決大部分的情境，接下來還有 Multi Agent 的狀況，但也不太複雜，我們將 Workflow 設計成一個物件，紀錄目前使用的 Agent 是誰，就能夠進行切換。

```ruby
class Workflow
  attr_accessor :agent

  def initialize(agent:)
    @agent = agent
  end

  def run
    loop do
      prompt = gets&.chomp
      break if prompt.nil? # gets 會在 EOF 時回傳 nil

      puts agent.call(prompt)

      # 根據 Agent 回傳切換
      # self.agent = new_agent
    end
  end
end
```

大致上來說 AI Agent 由兩組迴圈構成，一個是 Agent 完成一個任務可能需要多個步驟，需要一個迴圈來處理，另一個則是把一件事處理好，還需要另一個迴圈持續跟 Agent 進行互動。

## 抽象定義{#abstract-definition}

要實現 AI Agent 的機制遠比想像簡單，大多數會花費時間在設計好的提示以及處理 LLM 產生的一些異常行為，因此我們可以抽象的定義出這幾個概念。

```go
type Agent interface {
	Name() string // 切換 Agent 時可能會很有用
    Execute(ctx context.Context, prompt string) (string, error)
}

type Workflow struct {
	agent Agent
	agents []Agent
}

func (w *Workflow) SwitchAgent(name string) bool {
	for agent := range w.agents {
		if agent.Name() == name {
			w.agent = agent
			return true
		}
	}

	return false
}
```

對於主要的系統，只需要 Agent 是一種以 String 輸入跟輸出的物件即可。至於 Agent 的實作，是完全不用跟 Workflow 綁定的，這樣才能夠彈性的對應不同 LLM 有不一樣 API 的狀況。

```go
type Tool interface {
	Name() string
	Execute(ctx context.Context, params json.Unmarshaler) (json.Marshaler, error)
	// 根據 LLM 不同，可能還會有像是 Parameters() 之類的方法
}

type Memory interface {
	Add(Message)
	List() []Message
}

type Agent struct {
	name string
	client Client // 可能是 OpenAI or Claude
	tools []Tool
	memory Memory
}

func (a *Agent) Execute(ctx context.Context, prompt string) (string, error) {
	// ...
}
```

仔細觀察會發現 Agent 跟 Workflow 的介面有點相似，這是因為兩種物件都是透過呼叫時運行迴圈處理，而物件本身的資訊都是跟處理相關的。

剩下的就是語言上的差異，像是在 Golang 的工具中 `params` 使用 `json.Unmarshaler` 對 Agent 來說比較好處理，因為只需要提供 `json.RawMessage` 即可，而不需要去管內容是什麼。

在 Ruby 的話，這個 `params` 通常會是 Hash 而且可以先做 `JSON.parse()` 的解析，這是在語言差異上會體現出來的部分，在 Ruby 我會習慣用 `#call` 去命名，這樣在滿足條件時可以用 `-> (params, **) { { success: true } }` 的方式來代替實際的物件。

## 實務應用{#real-application}

雖然框架已經成形，然而怎樣的安排在我們的軟體架構中是最符合預期的呢？在最後我們來用幾個案例探討一下。

假設我們決定用 `POST /chat` 作為端點，不是採用即時對話的方式使用 Agent 的話，應該會是一個怎樣的情境。

```ruby
class ChatController < ApplicationController
  def create
	# 用一個 Agent 判斷對話相關的 Agent
    agent = ChatAgent.new(
	    # ...
	    response_format: {
		    type: "json_schema",
		    json_schema: {
		      # ...
		    }
	    }
    )
    res = JSON.parse(agent.call(params[:prompt]))

	# 載入對話
	memory = Memory.find(current_user.id)

	# 選擇推薦的 Agent
	agent = case res['agent_name']
	        when "shopping" then ShoppingAgent.new(memory: memory)
	        when "checkout" then CheckoutAgent.new(memory: memory)
	        else SupportAgent.new(memory: memory)
	        end
	res = agent.call(params[:prompt])
	# 更新對話紀錄
	memory.save
	# 回傳結果
	render json: res
  rescue MyLLM::BadRequest
	render json: { error: "unable to chat with LLM" }, status: :bad_request
  end

  # ...
end
```

依照上述的範例，我們可以透過一個 Agent 來判斷收到的訊息適合用哪一種 Agent 來處理，並且根據建議生成 Agent 以及附加這個使用者過去的對話紀錄，讓 Agent 適當的協助使用者。

然而 LLM 並不是非常穩定的工具，像是可能會生成了一個錯誤的 Function Name 造成錯誤，因此對話紀錄需要確保在呼叫成功後才能保存，而不能在呼叫時就保存，基於這樣的問題我們還需要考慮各種疑似隨機發生的問題。

當然，上述的流程通常需要再做封裝，此時一個客製化的 Workflow 就可以派上用場，根據 Agent 使用的複雜程度可以評估是否有必要使用 Workflow 來多做一層抽象化。

假設是以 TCP、WebSocket 的情境，我們也可以像這樣使用。

```ruby
server = TCPServer.new 8080

loop do
  Thread.start(server.accept) do |client|
    agent = MudAgent.new

    loop do
      client.print '> '
      prompt = client.gets&.chomp
      break if prompt.nil? # 使用者關閉連線時為 EOF

      client.puts agent.call(prompt)
    end
  end
end
```

我們可以設計一個 MUD Agent（MUD 是一種文字連線遊戲）來處理使用者的輸入，可以看到進一步封裝迴圈後，就會變成 Workflow 的樣子，從特性上來分析 Workflow 是很類似 Controller 的物件。

然而，在思考 LLM 應用會有很多不確定性的資訊，需要多次輸入才能完成必要資訊的標準化，因此相比以往設計以單一操作為主的思考，更可能轉向串流（Streaming）的形式，保持連線直到問題被處理完畢。

> 關於 TCP 的範例可以參考 Autoflux Gem 的[範例](https://github.com/elct9620/autoflux/blob/main/examples/tcp.rb)就可以看出為什麼會選擇 IO 介面作為 Workflow 的輸出跟輸入。

經過這一連串的思考，會發現 LLM-based 的應用開發，將以往分析需求轉換成架構、意圖清楚的程式碼，而 LLM-based 只要掌握了核心的邏輯後，原本「分析需求」的任務就變成了「清楚定義」的提示詞，減少了開發但是並沒有省去需求釐清的部分，甚至可能需要做的更好。

