Companion:桌面上的 3D AI 夥伴
Back to Projects
aidesktop-app3d-graphicstaurivoice-aivtuber

Companion:桌面上的 3D AI 夥伴

大腦只會說話,身體怎麼學會演:拆解桌面 3D AI 夥伴的兩進程架構,以及把語言模型輸出翻譯成表情和動作的一行文字協定。

Companion:桌面上的 3D AI 夥伴

Companion 是我寫的一個桌面應用,讓一個 3D 角色常駐在螢幕角落,背後接著語言模型。會做這個東西,是因為我每天跟語言模型講的話,比跟大多數人講的都多,但相處方式很奇怪。打開視窗,打字,拿到答案,關掉。它聰明得能陪我解決任何問題,唯獨做不到「在場」。我想要的是另一種東西,一個知道我現在開著什麼軟體、聊過的事隔天還記得、偶爾自己開口的角色。不是一個等我發問的輸入框,是一個同伴。


核心難題:把文字翻譯成表情和動作

讓角色一直站在桌面上,桌寵早就做到了。難的是中間那層翻譯。語言模型的輸出是一串文字,一個有身體的角色需要的卻是表情和動作,還有出手的時機。你跟它說了一件難過的事,它應該垂下眼睛,而不是繼續播待機動畫。

我最初的直覺是讓模型輸出結構化資料,每次回一包 JSON,裡面裝著台詞,外加該配的情緒和動作,前端照著演。這條路我真的寫完了,它的下場留到最後講。先講活下來的版本。


產品形態:透明視窗加 VRM 模型

活下來的版本叫 Companion,一個住在桌面上的 3D AI 夥伴。它像桌寵,但腦子是語言模型;像聊天機器人,但有身體,會出聲,聊過的事還記得。一個透明無邊框的視窗永遠浮在最上層,滑鼠點到角色本體才會被攔下,點旁邊直接穿透到底下的視窗,所以它一直都在,卻不擋事。角色用的是 VTuber 圈通用的 VRM 模型格式,我不定義角色長什麼樣,你可以把任何喜歡的模型放進來,Companion 負責讓它活起來。


系統分層:Rust 與前端各一個進程

要讓它活起來,得先看骨架長什麼樣。整個系統是兩個進程:Rust 側負責所有 OS 特權操作,React/Three.js 側負責渲染和業務邏輯。下面這張圖一次講兩件事,每一塊在幹嘛,還有一句話從輸入到表演要過哪些關卡:

┌── Rust 側 (OS 特權層) ──────────────────────────────────────┐
│  常駐 LLM 子進程管理         環境感測                記憶檔 I/O │
│  (claude CLI, stdin/stdout)  (前景視窗 / CPU / 時間) (memory.md)│
└──────────────────┬─────────────────────────────────────────┘
                   │ 跨進程呼叫 (invoke / event)
┌──────────────────▼─────────────────────────────────────────┐
│  React / Three.js 側                                         │
│                                                              │
│  文字輸入 / 語音辨識                                          │
│        │                                                     │
│        ▼                                                     │
│  對話橋接層: 組 prompt ──► Rust 子進程 ──► 收回回覆            │
│  "[action:wave emotion:happy] 嗨嗨!"                         │
│        │ 解析成 { text, emotion, action }                     │
│        ├──► 角色狀態 store ──► 3D 場景 (每幀更新)              │
│        │      ExpressionController  表情 blendshape 插值      │
│        │      AnimationManager      動作片段 crossfade        │
│        │      IdleAnimator          眨眼呼吸, 永遠在跑         │
│        │      LipSync               語音音訊 → 嘴型            │
│        ├──► 語音合成 (把台詞唸出來)                            │
│        └──► OBS 字幕頁 (直播用)                                │
└──────────────────────────────────────────────────────────────┘

Rust 側的邊界劃在哪?一個數字就能看出來:它對外只開 12 個跨進程命令,清一色是視窗控制、子進程收發、檔案讀寫這類特權操作,沒有一個碰 3D 或動畫。渲染和業務邏輯整個留在前端,這條線劃得乾淨,後面的解耦才有地基。


對話資料流:一句話從輸入到回覆

地基看完了,順著圖從上往下,跟著一句話走一遍。你按快捷鍵打字,或按住另一顆鍵講話,語音就地轉成文字。對話橋接層接手,組出這次要送的 prompt。固定不動的那塊是角色人設加上長期記憶檔全文,擺在開頭是為了吃快取,開頭每次長一樣才吃得到;每次都變的只有一小段環境前綴,像「使用者正在寫 code,前景是編輯器」,來自 Rust 側的環境感測。

剛剛塞進 prompt 的那個記憶檔,值得多講兩句。長期記憶不是資料庫,就是使用者目錄下的一個 memory.md,只追加不覆寫。寫入時機只有一個:session 累計對話 1000 次之後輪替,輪替前先請模型自己條列「這段時間學到的使用者資訊」,有料才寫進去。所以檔案裡躺的是模型自己篩過的摘要,不是流水帳,你隨時可以打開來看,看不順眼就直接改。

prompt 組好,跨進程送進 Rust。這裡有一個我自己都遲疑過的選擇:為什麼不直接打語言模型的 HTTP API?我讓 Rust 在背景養一條常駐的 claude CLI 子進程(Anthropic 的命令列 AI 工具),用 stdin/stdout 的 JSON 行協定跟它對話。夥伴最需要的是連續性,CLI 天生能把 session 一路接續下去,也不用另外管 API key。帳單的另一面是進程生命週期要自己扛。子進程死了要偵測,下次呼叫時重新拉起來,每輪請求還得加鎖排隊,不然兩個問題的回覆會串在一起。


情緒協定:用一行文字標籤代替 JSON

Rust 送回來的東西,就是圖中間那行字。我要求模型在每句回覆開頭手寫一個純文字標籤,這份約定我叫它情緒協定

模型回覆原文:  [action:wave emotion:happy] 嗨嗨, 今天過得怎麼樣?
前端解析後:    { text: "嗨嗨, ...", emotion: "happy", action: "wave", intensity: 0.7 }
沒有標籤時:    先用關鍵字規則猜情緒, 猜不到才退回中性表情

這不是 tool call,也不是 structured output,就是文字前綴加一條 regex。原因很土。CLI 回傳的就是對話文字流,逼它吐穩定的結構化輸出,遲早收到殘缺的 JSON,與其在解析端賭運氣,不如把契約降到模型最不容易寫壞的形式。文字標籤跟回覆本來就是同一種東西,混在文字流裡怎麼樣都不會壞;代價是解析脆弱,模型偶爾會漏寫標籤(跑的是速度優先的小模型,更常漏),所以後面墊了約 30 條關鍵字規則兜底,寧可猜錯情緒,也不讓角色卡在面無表情。

這次降級還有一個看不見的傷口:上面那個 intensity(情緒強度)是死的。文字標籤的語法裡沒有它的位置,所以生產路徑上它永遠是寫死的 0.7,表情系統明明支援按強度縮放,卻永遠拿到常數。協定換來穩定的同時,悄悄砍掉了一個維度,這筆帳我是很後來才看清楚的。


動畫消費者:每幀更新的四個元件

解析出來的情緒和動作落進角色狀態 store,這是驅動 3D 角色唯一的門,所有業務邏輯只准改 store,不准直接碰 Three.js 物件。門後面是一群各管一攤的每幀更新元件,圖下半部列過分工:ExpressionController 只管把表情往目標值插值,AnimationManager 只管動作片段的交叉淡入,IdleAnimator 只管眨眼和呼吸,LipSync 只管聽語音音訊算嘴型。表情和動作這兩個元件互不知道彼此存在,只共享 store 裡兩個獨立欄位;IdleAnimator 跟協定完全無關,永遠在跑;LipSync 聽的甚至不是協定,是語音合成的音訊。

這樣拆的好處,出事的時候最明顯。大腦沉默或講錯話,待機動畫照樣眨眼呼吸,角色不會因為上游失靈變成蠟像。

情緒和動作,我還刻意給了不一樣的粒度。情緒這邊是封閉枚舉:

export type Emotion =
  | 'neutral' | 'happy' | 'sad' | 'surprised' | 'angry'
  | 'shy' | 'thinking' | 'sleepy' | 'excited'

只有 9 種,因為表情是連續的臉部混合形狀,種類一多,相鄰情緒在臉上根本分不出來。動作那邊是離散的動畫片段庫,有 30 個具名動作,肢體動作一眼就分得出來,多一點沒關係。一套狀態機同時管臉和身體,會被迫遷就其中一邊;拆成兩個欄位讓它們各自演化,是這個系統裡我最滿意的一刀。


直播擴充:把輸入換成聊天室彈幕

這一刀的紅利來得比預期早。因為大腦和身體之間只隔著情緒協定,把輸入從「麥克風前的我」換成「直播聊天室的觀眾」幾乎不用動架構。YouTube、Twitch、Twitter 三個平台的彈幕各自有 adapter 收進來,統一轉成同一種訊息型別,丟進單一的訊息池,之後走的就是跟我打字一模一樣的那條協定路。

彈幕不走一問一答。訊息池每 60 秒跑一輪:有斗內先回斗內,沒有就按預設一半的機率抽一則彈幕回,連彈幕都沒有就讓角色自己找話題。用固定節奏而不是逐則回覆,一半是技術限制(常駐子進程一次只能處理一輪對話),一半是刻意的直播節奏感,角色不該像回音壁一樣對每則彈幕抽搐。副作用是大部分彈幕不會被翻牌,想被逐則回覆的觀眾會失望,這個取捨我接受。

還順便撿到一個安全上的便宜,過濾只要做在一個地方。所有不可信的外部文字都得先過訊息池的正則黑名單和長度上限,各平台的 adapter 不用自己再做一遍。


未解問題:蓋完卻沒通車的那條路

回到開頭挖的那個坑。repo 裡至今躺著一整套通用 LLM provider 抽象層:接 OpenAI 相容 API,要求模型輸出完整 JSON 的情緒格式,有自己的解析器和對話 store。寫完了,能跑。

然後全 repo 搜不到任何呼叫點。

它就是前面那條「讓模型輸出 JSON」的直覺路線,生產路徑從旁邊繞了過去,連它的對話 store 都只被當成純陣列容器在用。留著它有真實的成本,兩套情緒協定並存,新人(包括三個月後的我)很容易誤以為那才是入口。也有真實的理由,哪天要支援不裝 CLI、純 API key 的用法,這條路已經修了一半。刪不刪,我到現在還沒下定決心。

順著誠實講到底,帳上還有一筆。環境感測整段只有 Windows 實作,其他平台拿到空值,這個 app 事實上是 Windows-first,跨平台目前只是殼。

第一版架構最大的價值,常常是死在原地,告訴你為什麼第二版長這樣。

那條沒通車的路,每天都在對我說這句話。

Joey Chen

Joey Chen

Build things that are interesting. All made by AI.

AIWeb3