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

Global Game Jam 2025 - 以 Clean Architecture 思考 AI Agent 的導入

今年的 Global Game Jam 多樣性成就中,有一個挑戰是「以 Email 進行遊戲」看到的當下就決定要說服(aka 強迫)還不知道會是誰的隊友來挑戰這個題目,因為很適合這個 AI 時代用來進行一些探索性的嘗試。

基礎架構

遊戲使用的開發方式跟做網路服務經常有不少差異,因為這次的目標是要 Email 來操作,選擇使用 Serverless(無伺服器)環境當作基底,一定程度比較好處理,也比較接近網路服務的架構,因此套用 Clean Architecture 就相對容易。

在眾多 Serverless 選項中,又以 Cloudflare Email Routing 最容易實現收信的功能,我們只需要設定由 Cloudflare 的收信伺服器接收到的信件會觸發一個 Email Worker 就可以利用程式來處理這些信件,然而受限於濫發廣告信的限制無法發給任意使用者,我們另外選用 AWS SES 作為回信的手段。

到目前為止都還算是常見的處理,然而當我們收到 Email 後需要選擇正確的 AI Agent(代理人)處理這封信,那麼就需要自己製作分派信件給某段程式碼的機制。

因為不像網頁會有路由比對的問題(如:/users/users/aotoki)我們只需要將收件人抽離出來,就可以利用一個簡單的映射(Map)來處理。

1
2const [npcName, domain] = message.to.split('@')
3const controller = routes[npcName];
4
5await controller.handle(message)
6// ...

在 Global Game Jam 開始前,驗證使用 Email 進行遊戲的想法可行後,剩下就是等待活動當天知道主題後跟隊友討論要設計怎樣的遊戲。

大致上來說,這個階段我把「收信」設計成類似一般網站開發的架構。

src/
├─ app/
│  ├─ email.ts
│  ├─ routes/
│  │  ├─ email.ts
├─ controller/
│  ├─ NpcController.ts
│  ├─ NpcJackController.ts
├─ index.ts

AI 元件

要加入 AI 到我們的架構中,需要思考 AI 相關的實作屬於 Clean Architecture 的哪一個階層(如:Adapter、Use Case)同時還要讓 AI 具備根源有系統的互動能力,才算是一個合格的 Agent。

稍微花一點時間分析,會發現我們寫的 Prompt(提示)大多是跟商業邏輯有關的,然而我們若是把整合 LLM(Large Language Models,大型語言模型)的實作放在 Use Case 中的話會造成耦合的問題,尤其目前大多會依賴第三方的服務商,而就直接變成 Vendor-lock(供應商鎖定)

不過 Clean Architecture 書中有給出一個很明確的解法,設計一個 Abstraction Layer(抽象層)處理即可,這也反映了我們很常在討論中看到的圖中,為什麼在 Use Case 外層還有 Adapter 和 Driver / Framework 兩層,Adapter 是抽象層的實作,扮演商業邏輯跟外部的硬體、框架的黏合。

因此,我們的架構進一步發展成這個樣子。

src/
├─ app/
│  ├─ email.ts
│  ├─ routes/
│  │  ├─ email.ts
├─ controller/
│  ├─ NpcController.ts
│  ├─ NpcJackController.ts
├─ agent/
│  ├─ Npc.ts
├─ usecase/
│  ├─ TalkWithNpc.ts
│  ├─ interface.ts
├─ index.ts

在 Use Case 中我們根據商業邏輯需求,定義了 TalkWithNpc 的情境,讓我們可以利用 Email 來和 NPC(Non-player character,非玩家角色)進行互動,至於如何使用某個 LLM 來處理玩家的訊息,則透過 interface.ts 的定義來說明某個抽象概念。

1// interface.ts
2
3export interface TalkableAgent {
4  talk(message: string): Promise<string>
5}
6
7// ...

因此在 Use Case 的實作中,我們的 TalkWithNpc 大致上會像這樣。

 1// TalkWithNpc.ts
 2export class TalkWithNpc {
 3  constructor(
 4    private readonly agent: TalkableAgent,
 5    private readonly presenter: EmailPresenter
 6  ) {}
 7
 8
 9  async execute(message: string) {
10    // 等待 NPC 回覆
11    const reply = await this.agent.talk(message)
12
13	// 設定 Email 回信內容
14    this.presenter.setText(reply)
15  }
16}

上述的版本雖然可以運作,卻無法設定不同 NPC 的語氣、扮演的角色,我們還需要可以設定 System Prompt(系統提示)的機制,除此之外還需要讓不同 NPC 能夠使用不同工具(如:戰鬥、切換開關)來推進遊戲的進行。

在 Global Game Jam 期間,我採取了比較簡單的設計,把 System Prompt 和工具都直接放到 src/agent 裡面,然而 System Prompt 是商業邏輯的一部分,因此更加合理的架構預期是這樣的。

src/
├─ app/
│  ├─ email.ts
│  ├─ routes/
│  │  ├─ email.ts
├─ controller/
│  ├─ NpcController.ts
│  ├─ NpcJackController.ts
├─ repository/
│  ├─ EmbededNpcRepository.ts
├─ agent/
│  ├─ Npc.ts
├─ usecase/
│  ├─ TalkWithNpc.ts
│  ├─ interface.ts
├─ entity/
│  ├─ prompt/
│  │  ├─ Jack.md
│  │  ├─ index.ts
│  ├─ Npc.ts
├─ index.ts

根據上述的架構,我們在 TalkWithNpc 中的實作,會變成下面的樣子。

 1// TalkWithNpc.ts
 2export class TalkWithNpc {
 3  constructor(
 4    private readonly agent: TalkableAgent,
 5    private readonly npcs: NpcRepository,
 6    private readonly presenter: EmailPresenter
 7  ) {}
 8
 9
10  async execute(npcName: string, message: string) {
11	// 找到 NPC
12	const npc = this.npcs.findByName(npcName)
13	if (!npc) {
14	  // ...
15	}
16
17    // 等待 NPC 回覆
18    const reply = await this.agent.talk(npc, message)
19
20	// 設定 Email 回信內容
21    this.presenter.setText(reply)
22  }
23}

NPC 的 System Prompt 可以保存在資料庫,也可以像上述的範例直接放在 Entity(實體)之中,其他幾個物件大致上會像這樣。

1// Npc.ts (Entity)
2export class Npc {
3  constructor(
4    public readonly name: string,
5    public readonly characterSet: string // 人物設定
6  ) {}
7
8  // ...
9}
 1// NpcRepository.ts
 2
 3import { prompts } from '@entity/prompt'; // 從 Entity 取出 System Prompt
 4import { Npc } from '@entity/npc'
 5
 6export class EmbededNpcRepository {
 7  // ...
 8  async findByName(name: string): Promise<Npc> {
 9    // 找出 Prompt 以及其他 Npc 相關數值設定
10    const prompt = prompts[name];
11    if(!prompt) {
12      // ...
13    }
14
15    return new Npc(name, prompt)
16  }
17}

有提供 NPC 的設定後,我們在 Agent Adapter 的實作就可以容易很多,以下的範例使用 Vercel 的 AI SDK 來實作,在 TypeScript 環境中算是非常好用的框架。

 1// Npc.ts (Agent)
 2
 3import { inject, injectable } from 'tsyringe'; // 依賴注入框架
 4import { z } from 'zod';
 5import { type LanguageModelV1, generateText, tool } from "ai";
 6
 7import { OpenAI } from '@app/container';
 8import { Npc } from '@entity/npc';
 9
10@injectable()
11export class Npc {
12  constructor(
13    @inject(OpenAI) private readonly llm: LanguageModelV1,
14  ) {}
15
16  async talk(npc: Npc, prompt: string): Promise<string> {
17    return async generateText({
18      model: this.llm,
19      system: npc.characterSet, // NPC 的人物設定
20      prompt,
21      tools: {
22	    // NPC 通用的行為
23        changeFriendship: tool({
24          description: '隨著與玩家互動,可以調整友善程度',
25          parameters: z.object({
26			change: z.number().int().min(-10).max(10),
27		  }),
28		  execute: async ({ change }) => {
29		    const success = npc.changeFriendship(change)
30
31			return { change, success }
32		  })
33        })
34      }
35    })
36  }
37}

到這個階段,大致上就能很好的讓 AI Agent 融入到原本的遊戲中,並且可以對遊戲造成影響。我們只需要擴充不同的 Use Case 以及讓 AI Agent 能根據傳入的遊戲實體(如:Npc、Mosnter)透過 Tool Use(工具使用)的機制來進行一些互動。

後續改進

即使上述展示了一個相當完整的架構與實作,仍然有一些不足的地方需要進一步地改進。這是以往開發情境還沒有遭遇過的狀況,因為 AI Agent 可以很大一部分替代原本的 Use Case 來處理許多事情。

一部分讓我們的 Entity 從純程式碼,變成了程式碼跟 Prompt 混合的狀態,另一個需要進一步改進的則是 Tool Use 的整合,上述的例子我們直接使用 AI SDK 來定義工具使用,仔細觀察 execute 內部的實作,會發現基本上就是原本 Use Case 或者 Domain-Driven Design(領域驅動開發)中 Domain Service 所實作的部分。

這表示我們為了對 LLM 描述某個 Domain Knowledge(領域知識)不得不將原本封裝在 Use Case、Entity 中的商業邏輯洩漏到了 Adapter 層級裡面,因此這些工具的使用、參數的定義仍須要透過某些方式移動回到所屬商業邏輯的層級,而不是反過來配合 Adapter 的實作,否則會造成耦合以及未來修改上的困難。

在 JavaScript(或 TypeScript)中,我們還可以利用一個 Factory Method 產生一個 Curry Function 來解決這類問題,但是在不同語言可能就不會這麼順利,或者實作起來那麼容易理解。

 1// FriendshipToolFactory.ts
 2import { tool } from "ai";
 3
 4import { ChangeFriendshipService } from '@entity/ChangeFriendshipService';
 5import { Npc } from '@entity/npc'
 6
 7export function canChangeFriendship(npc: Npc) {
 8  return tool({
 9		description: '隨著與玩家互動,可以調整友善程度',,
10		parameters: z.object({
11			change: z.number().int().min(-10).max(10),
12		}),
13		execute: async ({ change }) => {
14			const service = new ChangeFriendshipService(npc)
15
16			return service.execute(change);
17		},
18	});
19}

這個範例還算是比較單純的,只對 Entity 操作。假設有需要呼叫 API、操作資料庫的 Use Case 情境,還會發現單一 Agent 要注入的依賴會變得很多,可能還會有物件不斷變「肥胖」的問題。更進一步,對於如何進行測試,以及如何在整合測試和單元測試等不同規模的測試中保持平衡,也會變成新的問題。

現階段我還沒有很好的解決方式,之後有更多的實作經驗後再回來討論更理想的設計會是怎樣的。

如果對這款遊戲有興趣,可以到遊戲介紹頁面了解玩法或者 GitHub 觀看原始碼