---
title: "Global Game Jam 2025 - 以 Clean Architecture 思考 AI Agent 的導入"
date: 2025-02-05T00:00:00+08:00
publishDate: 2025-02-05T00:00:00+08:00
lastmod: 2025-02-01T16:24:38+08:00
tags: ["經驗","AI","TypeScript","Clean Architecture","Global Game Jam","黑客松"]
toc: true
permalink: "https://blog.aotoki.me/posts/2025/02/05/global-game-jam-think-about-ai-agent-with-clean-architecture/"
language: "zh-tw"
---


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

<!--more-->

## 基礎架構{#base-architecture}

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

在眾多 Serverless 選項中，又以 [Cloudflare Email Routing](https://developers.cloudflare.com/email-routing/) 最容易實現收信的功能，我們只需要設定由 Cloudflare 的收信伺服器接收到的信件會觸發一個 [Email Worker](https://developers.cloudflare.com/email-routing/email-workers/) 就可以利用程式來處理這些信件，然而受限於濫發廣告信的限制無法發給任意使用者，我們另外選用 [AWS SES](https://aws.amazon.com/tw/ses/) 作為回信的手段。

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

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

```ts

const [npcName, domain] = message.to.split('@')
const controller = routes[npcName];

await controller.handle(message)
// ...
```

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

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

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

## AI 元件{#ai-component}

要加入 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` 的定義來說明某個抽象概念。

```ts
// interface.ts

export interface TalkableAgent {
  talk(message: string): Promise<string>
}

// ...
```

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

```ts
// TalkWithNpc.ts
export class TalkWithNpc {
  constructor(
    private readonly agent: TalkableAgent,
    private readonly presenter: EmailPresenter
  ) {}


  async execute(message: string) {
    // 等待 NPC 回覆
    const reply = await this.agent.talk(message)

	// 設定 Email 回信內容
    this.presenter.setText(reply)
  }
}
```

上述的版本雖然可以運作，卻無法設定不同 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` 中的實作，會變成下面的樣子。

```ts
// TalkWithNpc.ts
export class TalkWithNpc {
  constructor(
    private readonly agent: TalkableAgent,
    private readonly npcs: NpcRepository,
    private readonly presenter: EmailPresenter
  ) {}


  async execute(npcName: string, message: string) {
	// 找到 NPC
	const npc = this.npcs.findByName(npcName)
	if (!npc) {
	  // ...
	}

    // 等待 NPC 回覆
    const reply = await this.agent.talk(npc, message)

	// 設定 Email 回信內容
    this.presenter.setText(reply)
  }
}
```

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

```ts
// Npc.ts (Entity)
export class Npc {
  constructor(
    public readonly name: string,
    public readonly characterSet: string // 人物設定
  ) {}

  // ...
}
```

```ts
// NpcRepository.ts

import { prompts } from '@entity/prompt'; // 從 Entity 取出 System Prompt
import { Npc } from '@entity/npc'

export class EmbededNpcRepository {
  // ...
  async findByName(name: string): Promise<Npc> {
    // 找出 Prompt 以及其他 Npc 相關數值設定
    const prompt = prompts[name];
    if(!prompt) {
      // ...
    }

    return new Npc(name, prompt)
  }
}
```

有提供 NPC 的設定後，我們在 Agent Adapter 的實作就可以容易很多，以下的範例使用 Vercel 的 [AI SDK](https://sdk.vercel.ai/) 來實作，在 TypeScript 環境中算是非常好用的框架。

```ts
// Npc.ts (Agent)

import { inject, injectable } from 'tsyringe'; // 依賴注入框架
import { z } from 'zod';
import { type LanguageModelV1, generateText, tool } from "ai";

import { OpenAI } from '@app/container';
import { Npc } from '@entity/npc';

@injectable()
export class Npc {
  constructor(
    @inject(OpenAI) private readonly llm: LanguageModelV1,
  ) {}

  async talk(npc: Npc, prompt: string): Promise<string> {
    return async generateText({
      model: this.llm,
      system: npc.characterSet, // NPC 的人物設定
      prompt,
      tools: {
	    // NPC 通用的行為
        changeFriendship: tool({
          description: '隨著與玩家互動，可以調整友善程度',
          parameters: z.object({
			change: z.number().int().min(-10).max(10),
		  }),
		  execute: async ({ change }) => {
		    const success = npc.changeFriendship(change)

			return { change, success }
		  })
        })
      }
    })
  }
}
```

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

## 後續改進{#future-improve}

即使上述展示了一個相當完整的架構與實作，仍然有一些不足的地方需要進一步地改進。這是以往開發情境還沒有遭遇過的狀況，因為 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 來解決這類問題，但是在不同語言可能就不會這麼順利，或者實作起來那麼容易理解。

```ts
// FriendshipToolFactory.ts
import { tool } from "ai";

import { ChangeFriendshipService } from '@entity/ChangeFriendshipService';
import { Npc } from '@entity/npc'

export function canChangeFriendship(npc: Npc) {
  return tool({
		description: '隨著與玩家互動，可以調整友善程度',,
		parameters: z.object({
			change: z.number().int().min(-10).max(10),
		}),
		execute: async ({ change }) => {
			const service = new ChangeFriendshipService(npc)

			return service.execute(change);
		},
	});
}
```

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

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

> 如果對這款遊戲有興趣，可以到[遊戲介紹頁面](https://globalgamejam.org/games/2025/call-depth-1)了解玩法或者 [GitHub](https://github.com/elct9620/GGJ2025) 觀看原始碼

