Skip to main content

Wei Ji
info

這篇屬於 TiddlyRAG 的開發筆記,而不是單純的技術選型建議。

前情提要

AsyncFuncAI/deepwiki-open 是一個開源專案,能夠把 Git Repo 透過 LLM 轉譯成方便人類閱讀的網頁(手冊)。但是隨著我 Review 程式碼發現其實作品質低下,宛如結集各種反模式於一身的負面教科書,於是我決定抽出提示詞然後試著重新實做再現類似的資料處理流程。更多資訊可以見我的前一篇文章:

從 Zettelize 到糞坑:評點 deepwiki-open 程式碼

我先用 Jinja2 把抽出的提示詞重新整理過,再透過 LLM 可觀測留下的紀錄逆向工程回去理解這些提示詞是怎麼組裝的,以及經過什麼樣的步驟。

提示詞樣板

根據調查可以知道 AsyncFuncAI/deepwiki-open 的實作是經過兩個步驟:

步驟一:建立手冊框架

/no_think {% include 'segments/simple_chat_system_prompt.md' %}

<START_OF_CONTEXT>
{% for item in files %}
## File Path: {{ item.path }}

{{ item.content }}
{% endfor %}
<END_OF_CONTEXT>

<query>
{% include 'segments/wiki_structure_generator.md' %}
</query>

Assistant:

其輸出會類似於:

<wiki_structure>
<title>Ariadne GIS Wiki</title>
<description>這是一個將 GIS(地理資訊系統)與 Windows 使用者體驗(UX)結合的概念驗證(POC)專案,旨在模擬 Windows XP 風格的 GIS 應用環境。</description>
<sections>
<section id="overview">
<title>專案概覽</title>
<pages>
<page_ref>project-intro</page_ref>
</pages>
</section>
<!-- 以下省略 -->
</sections>
<pages>
<page id="project-intro">
<title>專案介紹</title>
<description>說明 Ariadne GIS 的核心理念:建立一個具有 Windows UX 的 GIS 系統,以及其 POC 的定位。</description>
<importance>high</importance>
<relevant_files>
<file_path>README.md</file_path>
<file_path>packages/app/src/App.tsx</file_path>
</relevant_files>
<related_pages>
<related>arch-overview</related>
</related_pages>
<parent_section>overview</parent_section>
</page>
<!-- 以下省略 -->
</pages>
</wiki_structure>

步驟二:為每一個頁面生成內容

/no_think {% include 'segments/simple_chat_system_prompt.md' %}

<START_OF_CONTEXT>
{% for item in files %}
## File Path: {{ item.path }}

{{ item.content }}
{% endfor %}
<END_OF_CONTEXT>

<query>
{% include 'segments/technical_wiki_generator_prompt.md' %}
</query>

Assistant:

接著我要把這些提示詞轉移到 NestJS 去建立 POC。

AdalFlow

可以留意到 files 的部份不是單靠樣板引擎就能處理的,

根據可觀測,除了 LLM 的處理以外,在步驟一、二以前還有一個量體不小的嵌入請求。

往下深究可以得知是仰賴來自 AdalFlow 的實作,它將大部分的檔案進行嵌入,然後根據任務內容從 AdalFlow 檢索出大約 20 個檔案填入 files。然而 AdalFlow 的實作方式有幾個問題:

  1. AdalFlow 使用 .pkl 儲存嵌入向量。

簡單來說 .pkl 是將 Python 直接序列化儲存到檔案系統的格式,讀取後反序量化可以在記憶體重建一樣的物件,然而這在機器學習領域已經被普遍認為是不安全的方式,因為這種儲存方式可以夾帶「可執行的惡意程式」。

  1. 自行實作以及運行時記憶體。

使用 .pkl 的另外一個問題是,它必須將整個資料載入記憶體才能運算,而不像成熟的資料庫方案透過特殊的處置每次只需要將少量的資料放入記憶體運算,資料本身是儲存在檔案系統上的。

這帶來另外一個問題,資料結構與檢索演算法是由 AdalFlow 自行實做的,但是 AdalFlow 的軟體定位更接近「自動化優化提示詞」,其實做品質必然低於專職的嵌入資料庫。

因此我會使用在前一個 POC 使用的 pgvector 處理這一個資料處理流程。

Node.js 樣板引擎

好了,回到 Node.js 樣板引擎的話題。在 NestJS 官方文件中使用的樣板引擎是 hbs,但是它的使用方式長得像這樣:

export class AppController {
constructor(private appService: AppService) {}

@Get()
root(@Res() res: Response) {
return res.render(
this.appService.getViewName(),
{ message: 'Hello world!' },
);
}
}

跟 Response 深度榜定,它總是假設視圖 (View) 是用於 HTTP Response 的,這在 LLM 提示詞「用於 OpenAI API Request」 的使用情境下會顯得有問題。

於是我在前一個 POC 中是使用未被封裝的 Handlebars 本身:

export class RetrievalTools {
async resolveWiki({ query }: ResolveWikiParamsDto): Promise<string> {
const templateStr = await readFile(
resolve(__dirname, './prompts/resolve-wikis-response.hbs'),
'utf8',
);

const wikis = await this.retrievalService.resolveWiki(query);

return Handlebars.compile(templateStr, { noEscape: true })({
wikis,
});
}
}

然而隨著提示詞的複雜化,我現在需要嵌套樣板,這在 Handlebars 中必須這樣處理1

Handlebars.registerPartial('myPartial', '{{prefix}}');

我不知道你怎麼想,反正我覺得不夠優雅。一來是它必須手動註冊每一個樣板;二來是這是單例全域狀態污染,因此我必須尋找替代方案。

很快的我列出了一個清單:

pug 雖然在 GitHub 有較高的聲望,但是它的語法一看就知道是為了簡化 HTML 而生的,在處理多數情況為 Markdown 的提示詞不見得方便。而且我沒有很喜歡它的語法。

mustache.js 和 handlebars.js 師出同門那就不用提了。

swig 雖然有名但是 Javascript 套件已經停止維護。

其他方案我沒有花太多時間調查,因為當我看到 Nunjucks 時,覺得它能滿足我的需求。

  1. jinja2 友善

Rich Powerful language with block inheritance, autoescaping, macros, asynchronous control, and more. Heavily inspired by jinja2

既然我原本的實作是 jinja2,而 Nunjucks 又高度參考 jinja2,那麼遷移成本理應較低。

  1. Mozilla

Nunjucks 是 Mozilla 之下的專案,並且 Mozilla 其他專案也有使用 Nunjucks。雖然 GitHub 已經沒有活躍的編輯紀錄,但是上一次編輯是關於 Node 25 的,因此穩定性可以期待,畢竟樣板引擎就這樣,完成了的話其實就沒有東西好加了。

另外快速掃一下 issue,不少都是跟安全性有關的,但是這應該屬於「不信任源輸入過濾」的職責,跟樣板引擎本身無關。

Footnotes

  1. Partials | Handlebars. https://handlebarsjs.com/guide/partials.html

Wei Ji

前情提要

最近完成了 TiddlyRAG 的 POC,

https://github.com/FlySkyPie/tiddlyrag-poc/tree/poc/type-a

簡單來講這是一個伺服器,能把 TiddlyWiki 轉換成可以被 MCP 檢索的資料,專案的目的不僅指於此,不過 POC 的目的僅限驗證「TiddlyWiki→MCP 檢索」這件事。

TiddlyRAG 是我面對 LLM 浪潮的回答,它建立在有點複雜的哲學觀之上,一言難盡,有興趣的人可以瀏覽我之前發過得一些廢文:

POC 完成之後我有幾個繼續前進的方向:

  • 非同步資料處理
    • LLM 摘要與嵌入運算是相對花時間的吃重運算,目前實作僅用於 POC 目的,因此是把一次 HTTP request 掛著直到所有任務完成,生產環境並不適用這種作法。
  • Graph 演算法
    • 目前僅只用最基本的嵌入向量搜尋,尚未對資料建立圖譜 (Graph)。
  • 卡片化 (Zettelize)
    • 研究並建立將其他類型資料轉換成 TiddyWiki 的資料流水線,例如:Git 庫或 PDF。

我決定先研究 Zettelize,列了幾個可以研究的方向:

最後我選擇先看看 AsyncFuncAI/deepwiki-open,沒想到卻陷入糞(Code)坑之中,雖然現在已經有下一步的計畫了,但是不寫一篇噴它一下難消我心頭氣,於是就決定寫一篇紀錄一下,畢竟負面教材也是教材。

info

【聲明】 以下內容可能用字比較強烈,但是這是對事不對人,純屬閱讀品質低下程式碼造成後的情緒釋放,對原作者絕對沒有惡意。

差勁的 Dockerfile

文件上寫的安裝步驟是從 Git repo 用 Docker Compose 運行:

但是從 YAML 中並沒有看到 image,需要自己本地建置:

services:
deepwiki:
build:
context: .
dockerfile: Dockerfile
ports:
- "${PORT:-8001}:${PORT:-8001}" # API port
- "3000:3000" # Next.js port
env_file:
- .env
environment:
- PORT=${PORT:-8001}
- NODE_ENV=production
- SERVER_BASE_URL=http://localhost:${PORT:-8001}
- LOG_LEVEL=${LOG_LEVEL:-INFO}
- LOG_FILE_PATH=${LOG_FILE_PATH:-api/logs/application.log}
volumes:
- ~/.adalflow:/root/.adalflow # Persist repository and embedding data
- ./api/logs:/app/api/logs # Persist log files across container restarts
# Resource limits for docker-compose up (not Swarm mode)
mem_limit: 6g
mem_reservation: 2g
# Health check configuration
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT:-8001}/health"]
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
info

後來才看到 GitHub 上其實有預建置的 Image

所以就檢查一下它的 Dockerfile,然而映入眼簾的是這的東西:

被噴的一臉屎猝不及防。

它試圖在同一個容器同時運行 Node.js 和 Python,然後用一段嵌入在 Dockerfile 的 Shell Script 直接運行兩個 process,撇開混合 Node.js 和 Python 兩種環境不談,這裡直接犯了兩個錯誤:

  1. 義大利麵程式碼:容器的 entrypoint 應該放在獨立的檔案而不是嵌在 Dockerfile 內。
  2. 行程管理:這種作法的 process 管理十分糟糕,經典的作法有 supervisord 或是比較潮的 s6 overlay 都可以有效的解決這個問題,而不是直接用 Shell Script 的背景執行硬幹。

差勁的嵌入模型抽象

為了讓它運行在我的環境中,我還必須對著 JSON 打 patch,這時我才想起來為什麼之前我在測試 LLM RAG 應用程式的跳過它,因為它的文件完全沒有提到「OpenAI-Compatible API」如何配置的資訊。

info

後來深入研究發現其實可以 Volume 掛載 JSON 處理。

差勁的後端專案結構

從 Dockerfile 得知專案內有兩個主體:FastAPI (Python) 和 Nest.js (Typescript),考量關鍵邏輯的處理應該是後端,於是我優先調查後端的程式。

在處理 Dockerfile 時就看到 Poetry,根據我上次使用的經驗,它比較不遵守 Python 的標準,加上它在處理 PyPI 鏡像來源的時候問題似乎比較多1,於是我就打了 patch 改成用 PDM:

接著看看專案結構:

.
├── api.py
├── azureai_client.py
├── bedrock_client.py
├── config
│   ├── embedder.json
│   ├── embedder.json.bak
│   ├── embedder.ollama.json.bak
│   ├── embedder.openai_compatible.json.bak
│   ├── generator.json
│   ├── lang.json
│   └── repo.json
├── config.py
├── dashscope_client.py
├── data_pipeline.py
├── google_embedder_client.py
├── __init__.py
├── logging_config.py
├── main.py
├── ollama_patch.py
├── openai_client.py
├── openrouter_client.py
├── pdm.lock
├── prompts.py
├── pyproject.toml
├── rag.py
├── README.md
├── simple_chat.py
├── tools
│   └── embedder.py
└── websocket_wiki.py

為什麼不開個資料夾?資料夾結構的建議或指引網路上隨便找一下都一大堆好不好?2

單一職責反模式

程式碼實作視單一職責原則於無物,很多冗長的程式,把 class 和 function 塞在一塊,甚至出現這種鬼東西:

甚至複雜到我的 VSCode 插件放棄解析:

缺乏抽象:

DRY 反模式

程式碼實作視 DRY 原則於無物,有不少重複的東西,我後來看前端程式碼知道有一個 HTTP API 和 WebSocket 是備援關係:

所以有 751 行的 simple_chat.py 和 915 行的 websocket_wiki.py,並未將關注點實作抽出,而是分別在兩個檔案實做東西,而這間接造成另外一個問題的發生。

websocket_wiki.py 中嵌入了提示詞(是的,義大利麵程式碼):

然而 prompts.py 也有提示詞:

而且還有 Issue 號?我來看看:

然後我就看到只有 LLM 在 Review,還稱讚了一番程式碼就合併進去了...Holy Shit...

義大利麵程式碼

樣板引擎在後端處理 HTML 之類的之串已經行之有年,就算我開始接觸 LLM 應用程式不到一年也知道應該使用樣板引擎處理提示詞,但是就像你在前面看到的,該專案直接嵌入提示詞,然而專案明明有安裝 Jinja2:

或著是用這種方式手搓字串(XML 結構):

Nest.js 那邊更是義大利麵程式碼的重災區,2280 行的 page.tsx 同時內嵌提示詞和 CSS:

其他頁面甚至全部都有:

文藝復興啊!我知道 Nest.js 前後端喇在一起的特性會提高產出義大利麵的機率,但是親自看到不免還是感到驚豔。

錯誤的技術棧

從 FastAPI 部份的 commit 紀錄跟 Nest.js 的比重判斷,以及隨便抽幾個開發人員的 GitHub 是 Typescript 居多,可以知道這是一個前端本位的團隊,既然是前端本位的團隊,就不應該貿然引入 Python 的後端,不熟悉開發模式只會製造糞 Code,那怕是使用 NestJS 可能都比較好。

另外一個是語言特性,該專案似乎想用 WebSocket 來實現一些比較實時的功能,但是 WebSocket 是事件驅動的,而 Javascript 是為了事件驅動而生的語言,從早期的 callback、Promise 再到 async,是一個很順暢的演化路徑,不論是把非同步變成同步語法的 async 還是事件驅動發展而來的 Rx.js,都能從不同角度處理非同步問題。

反觀,Python 並不是一開始就為此設計的,即便現在它也支援 async 語法,以及 ASGI 體系,但是它依然有同步的歷史包袱,在處理 WebSocket 會略遜一籌。

不過 WebSocket 基於事件的開發模型,會讓多數後端針對 request/response 建構的 MVC 架構崩潰,開發者容易寫出不受控制的程式碼,NestJS 在這方面就做得很好,它把包含 WebSocket、Message Queue 在內的事件驅動技術棧全部使用類似 Controller 的進入點處理,提高程式碼風格的統一性。

放棄

原本我有抽出後端的部份試著進行重構,但是正如你在前面看到的那樣,有一部分的提示詞是放在 Nest.js 那邊,FastAPI 那邊的業務邏輯是不完整的,幾乎只是作為 LLM 的中繼,以及暴露幾個用來存資料的 API。並且因為前面多行程管理不當的問題,Docker 容器關閉之後 SQLite 資料並未進行存檔,所以我也沒辦法觀察它究竟往 SQLite 寫了什麼。

所以最後我覺得直接從我架的 LLM 可觀測服務跟 OpenRouter 那邊的嵌入模型呼叫紀錄來推斷關鍵的「Git Repo 變成 Wiki」的流程大概是怎麼回事,然後把 FastAPI 和 Nest.js 的提示詞都單獨抽出來做成樣板。

不再折騰研究這坨糞 Code 了。

Footnotes

  1. Replacing the URL of a source (e.g. PyPI) at the global level · Issue #1632 · python-poetry/poetry. https://github.com/python-poetry/poetry/issues/1632

  2. zhanymkanov/fastapi-best-practices: FastAPI Best Practices and Conventions we used at our startup. https://github.com/zhanymkanov/fastapi-best-practices

Wei Ji
info

這篇文章是我在 TiddlyWiki 官方論壇的發文的副本

I made a POC server, it can import/export TiddlyWiki and exposed MCP and HTTP API allowed retrieval Tiddlers from database.

Link: https://github.com/FlySkyPie/tiddlyrag-poc/tree/poc/type-a

The POC only implemented a simple pipeline:

P.S. When I using the terms llm.txt not just "robot.txt but for AI", but all "preprocessed plain text used for LLM providing context", the preprocessing tools including repomix, markitdown, Context7...

Long Story

Perspective 1: The development of LLM ecosystem feels wrongs to me

As open source stan, I don't believe performance of close source "Model", until I saw the open weight, who knows? Maybe it's a huge RAG system behind the API.

"Agentic" is most popular things in the current LLM ecosystem, but if you know how it works, you know the process is O(N²) a token wasted process. Meanwhile some people even advocating "RAG is dead", you should just put all context let "Model" (close source API) handle it.

Yes, create RAG system is annoying, you need clean your data, doing chunking, embedding, implement strategy...But to me, RAG is the correct way to using LLM, prevent it bullshit things, at least at the moment.

Conclusion: I should build a RAG and meaninace knowledge.

Perspective 2: Data Silos

I had investigated Open WebUI, LobeHub, kotaemon, Bionic, AstrBot, AnythingLLM. Some problems are common in most case:

  • User can't review uploaded document.
  • User can't review chunked document.
  • The application didn't chunking document at all.
  • Embedding related UI is glitchy.
  • Can't edit uploaded document.
  • Won't trigger re-embedding after edit text.

Nobody (application developers) care ETL process, I guess.

The situation of most application: You can upload file. and then? there is not then. I either don't doing chunking, or chucking badly, and you can't review or fix it, even it chunking good, you still can't reused those chunk.

Conclusion: Chunks should able transfer between systems, and I should build better review mechanism create data feedback loop.

Perspective 3: Human Readability

Context7 is a neat MCP server, allowed LLM get latest state of library, but it's close source, and there is a company behind it. As open source stan, I can make this thing in my work flow. I did check some alternatvies such as GitMCP, but performance not good, GitMCP didn't chunking text right.

If you put MCP beside, llm.txt is most important part, you need prepare clean plain text to feed LLM. Some tools like repomix, markitdown can do the job, but here is the thing: the output is a bundle text, the chunking process may split it in wrong way, plus, it's hard to read for human.

Yes, it's plain text, human "can" read, but when it's a text over 10k lines, that kind of information is hard to maintain and review.

Conclusion: The readability issue of llm.txt must be solve.

Conclusion

Ok, I have a clear goals:

Build RAG system, but we already have bunch of chat-based applications, I should not reinventing wheels but focus on ETL part, which no body care.

"A payload for chunking knowledge and improve human readability"...wait, doesn't it talking TiddlyWiki? I don't need reinventing wheels, and when it's compatible with TiddlyWiki, the system would allowed import existing TiddlyWiki into RAG system.

Perspective 4: Scenario of Lazy Domain Expert

This is further vision of TiddlyRAG, it's based on very clear scenario which related my career experience.

Stakeholders in Agile and Domain Expert in DDD, both roles design shared a same philosophy: prevent developer straying too far from reality or actual needs, I would using perspective of DDD to explain this.

Domain Expert is the person who understand Domain knowledge, developer must frequently communicate with it, so make sure software is built on top of Domain Model that match real industry, but DDD make a assumption implicitly: it's asumming Developer and Domain Expert are allocable human resource inside orginization, frequently communication only works when this assumption is ture.

How ever many development condition is shift from this assumption, Domain Expert who have Domain knowledge and Developer are work for different company or organization. In this kind of scenario more often shows: Stakeholders seems not care about it, they don't give answer even Developer is asking with, thir thought is that the things should automaticly done because they paied money, not to mention getting them to write documents.

TiddlyRAG is planning forcus on review and audit knowledge.

The system alloed Developer or LLM draft up Tiddlers, only approved knoeledge would included by Wiki. So that Stakeholders or Domain Expert don't need write document but answer yes or not, even add comment if they welling.

Wei Ji

以下從幾個角度分享我對開源軟體的看法。

談作業系統

「自由不是免費的」,這樣的概念大家或多或少都有聽過,不過「自由」本身很抽象而且難以釐清邊界,所以我今天只談開源的自由。

在電腦早期的發展歷史中,作業系統是跟硬體分開來銷售的東西,因為作業系統需要有人來撰寫,有人撰寫就要時間跟勞動,有時間跟勞動就有成本,有成本就要靠銷售來支付,這是經濟運作的 ABC。

但是消費者買電腦就是要用啊?買了卻不能用啟不是很奇怪?於是後來來使興起了捆綁銷售的商業模式,如此一來消費者買到電腦回家組裝完就有作業系統可以使用了。除了作業系統以外,瀏覽器也有類似的情況,微軟的作業系統便靠著這個商業模式佔據了大量的市場份額。

消費者可以無腦的開始使用作業系統、網頁瀏覽器,不過代價什麼?微軟的作業系統一代比一代佔用資源,逼迫消費者購買新硬體,自動更新、「AI」工具在 2026 的今天不斷的騷擾著多數消費者。

「當一個產品是免費的,那不是產品,你才是產品」用來警惕所有貪小便宜的人,不過最惡劣是,消費者實際上在購買電腦時,是有對作業系統支付授權費的。

這是我在 2015 年投入 Linux 懷抱的原因, 是的,在桌面環境下 bug 比較多,因為這是多個各自為政的開源軟體整合在一起,而不是統一由企業開發的; 是的,要操作的順手多少需要會一點指令操作,不比桌面環境來得直觀; 是的,遇到問題要自己爬文,沒有門市或是客服可以求助, 是的,很多主流軟體無法在 Linux 運行,特別是遊戲軟體...

不過我也因此可以自行選擇更新的時機; 我也因此可以在性能比較差的電腦運行系統; 我也因此可以選擇什麼軟體要留在我的電腦上、什麼不要...

我支付了非資本形式的代價,獲得了開源軟體賦予的自由。

談 LLM

都 AI 時代了,誰還談開源精神啊

前幾天我在網路上看到的一段留言是驅使我寫這篇文章的原因,「我啊」我想這麼回答,不過在論述扁平化的社群平台上吵這個沒什麼意義。

在談我的立場以前,我需要解釋這裡至少有兩種看待 LLM 的視角。

info

大眾用語會使用 AI 來稱呼這些基於深度學習的工具,不過它們實際上是指稱基於 LLM (large language model) 的工具,以下我使用 LLM,不過讀者可以自行理解我是在談「AI」。

「AI 好棒棒」視角

這個視角看到的是 Claude Code、Codex...等商業軟體,稍微講究一點人可能是用自己喜歡的工具對接 Claude、OpenAI GPT、Gemini...等商用 LLM API。

這個視角看到的是這些「模型」有很強的解題能力、還是能理解照片或影音檔案的「多模態模型」、可以訂閱吃到飽;經濟實惠。

「LLM 只是工程組件」視角

LLM 最大的問題就是幻覺 (Hallucination),簡單來說就是它會很自信的提供錯誤的資訊,而解決這個問題目前最有效的方法就是建立 RAG (Retrieval-Augmented Generation) 並將相關知識或是潛在答案本身直接和使用的問題一同輸入給 LLM 來獲得穩定且正確的回應。

在 RAG 系統中,LLM 只是組件之一,而不是一切的核心,它的功能是用來揉合和合成文義通暢、好理解的自然語言。又或是在缺乏專用模型或演算法的時候,處理一些文字相關的問題。

我的觀點與分析

要讓一個 RAG 系統堪用,至少需要三個要素:

  • 一定水準之上的 LLM 組件
  • 維護優質的知識庫
  • 有效的演算法(RAG 策略)實現

生態系內已經有不少開放權重的 LLM 模型,llama.cpp 與 GGUF 努力,模型量化以及允許使用多種運算後端,大幅的降低 LLM 的記憶體門檻以及擺脫 CUDA 的供應商鎖定,這是開源陣營的一大進展。

「不過為什麼這些開放權重模型表現的一般般,不如商業方案來得可靠?」 「是不是因為這些模型是削弱版的?」 「那我還是付錢買商業『模型』好了」 以上可能是不少人的想法,那是因為它們是透過第一種視角看事情。

眼尖的讀者可能發現我在談論商業 LLM 時,「模型」是括弧起來的,因為我採用的是第二種視角,這些運算都藏在 API 之後,它很有可能已經實作了一個完整的 RAG 系統,API 表現並不等於模型表現,我不管這些企業怎樣宣傳它們的「模型」,直到我看到開放權重的模型以前,我都預設它是一個 RAG 系統而不是模型。


「RAG 已死」或是基於 Agent 的軟體策略,在我看來也都是第一種視角下的世界。

info

建議讀過我之前解釋 Agent 的文章:

Agent! Agent! Agent! 所以 Agent 到底是什麼?

會對整個上下文有更清楚的了解。

「RAG 已死」的主張者認為,新型的「模型」具有更大的上下文窗口,我們只要把大量的資料倒入,「模型」就能自己解決問題。

Agent 的軟體策略則是透過建立一個迴圈,每一步都給予「模型」更多的資訊,「模型」最終就能解決問題。

你發現問題了嗎?如我稍早提過得,一個良好的 RAG 系統至少需要具備:LLM、策略實作與知識庫。

這些人一邊抱怨開放權重的 LLM 不夠好用,一邊轉向使用商業「模型」,然後絲毫不考慮改進策略與維護知識庫。

在我眼中,這種作法只會讓自己成為企業的待宰羔羊。

你每一次的消費,都是在為你想要的世界投票

Every time you spend money, you're casting a vote for the kind of world you want

試想一下一個高度仰賴閉源 API 的世界是怎麼運作的。

硬體閉源循環

目前深度學習生態系建立在 CUDA 之上,供應商業 API 的企業自然使用 Nvidia 的硬體來避免遷移成本,Nvidia 的 HPC GPU 需求變高,Nvidia GPU 的市場行情變高,消費者買不到便宜的消費級 Nvidia GPU,於是選擇使用商業 API,循環發生了。

這就是為了開源陣營積極發展通用型運算後端,如 llama.cpp 的 Vulkan 後端;ONNX 的 WebGPU 後端。這也是為什麼我會對於通用 GPU 進行深度學習運算特別執著。

消費性循環

「RAG 已死」與 「Agent 流派軟體」,傾向單純的提高資料吞吐量而不是優化算法,意味著更多的 Token,意味著更多的運算,更多的運算代表更多的 GPU 與記憶體需求,這些企業不斷的擴建資料中心,消耗 GPU 和記憶體市場的產能,造成 GPU 與記憶體價格飆漲,消費者買不到設備,於是轉而使用商業 API,循環發生了。

這就是為什麼我主張應該強化知識庫以及 RAG 策略,而不是把所有事情都外包給商用「模型」。

小結

「使用開放權重模型」「尋找某種標準化資料使其能在知識庫系統之間轉移」「繼續精進 RAG 而不是使用 Agent Tool」我的立場看似違背大趨勢、逆風、固執且無理,為什麼?因為:

自由不是免費的。

Wei Ji

工欲善其事,必先利其器

當要和(主觀視角)未知的檔案格式打交道的時候,必定要先準備好:開啟檔案的軟體與檔案樣本。

info

「開啟檔案的軟體」可透過 Editor, Reader, Browser, Viewer ...等關鍵字尋找。

從領域驅動開發 (DDD, domain-driven design) 的視角來看,針對特定用例設計的檔案格式其實封裝了大量的領域模型,而能打開檔案的軟體則是提供最直觀的方式讓人理解這些領域模型,這些軟體可以說是領域大門的鑰匙。

檔案樣本是用來驗證多個軟體可靠性的,因為在開源的世界裡沒有「唯一解」只有「相對有用解」,在某些情況甚至需要同時使用多個軟體,因為每一個軟體可能是針對特定的用例設計。

以下舉幾個我過去實際接觸過得例子。

大圖輸出、廣告帆布 - PDF

「列印要匯出成 PDF 檔案」大概是這個時代的常識,不過如果是「10 公尺×10 公尺」這種 PDF 呢?不幸的是一般的開源 PDF 瀏覽器無法正常瀏覽這種類型的 PDF,而且這種 PDF 的樣本也不好取得。

Inkscape 屬於向量繪圖軟體; LibreOffice Draw 仍是偏向辦公文書處理的圖像軟體; Okular, Gnome Evince 是 PDF 瀏覽器,但是無法正常在「10 公尺×10 公尺」這種檔案中縮放瀏覽。

上述軟體或多或少都能處理 PDF 的部份用例,但是就是無法覆蓋「10 公尺×10 公尺」這種 PDF,原因是這是一個名為桌面排版軟體 (DTP, Desktop publishing) 的領域。

Scribus 的定位則剛好屬於這個領域,因此用它可以輕易的產生與遊覽「10 公尺×10 公尺」這樣的樣本。

info

以上資訊源自於和廣告帆布業者相關的軟體開發經驗。

GIS - GeoJSON

GeoJSON 是 GIS (Geographic Information System) 使用的經典檔案交換格式。

info

Mapbox 的 Vector Tile 本質上是透過 Protocol buffers 封裝的 GeoJSON。

從規範的層面你可以去翻閱 RFC 7946,從函式庫的層面可以使用諸如 @types/geojson 來獲得封裝好的 Typescript 界面。

OSM (OpenStreetMap) 本身是一個豐富的 GIS 資料來源,QGIS 本身是一個用於瀏覽與編輯 GIS 資料的軟體,同時能夠透過它從 OSM 提取特定地區的資料並儲存成 GeoJSON1

線上的工具也有像是 geojson.io 這樣的網站可以產生與預覽小型的 GeoJSON 資料。

info

以上資訊源自於林木業 GIS 解決方案相關的軟體開發經驗。

Web3D - glTF

如果你想要在網頁上渲染一個 3D 模型,將模型輸出成 glTF 是標準方案之一,通俗的描述這個檔案格式為「3D 的 JPEG」。

glTF 的檔案樣本:

https://github.com/khronosgroup/gltf-sample-models

glTF 的線上瀏覽器:

https://gltf-viewer.donmccurdy.com/ (較舊) https://github.khronos.org/glTF-Sample-Viewer-Release/ (較新)

當然,glTF 不只用於 Web 應用,你也可以使用其他軟體匯入或是開啟,但是當要開發 Web3D 應用時,3D 的渲染能力受限於遊覽器,因此使用上述兩個線上(基於瀏覽器)的瀏覽方案更能一併測試「在瀏覽器 runtime」下的渲染效果。

info

以上資訊源自於基於 Three.js 應用程式的開發經驗。

機械工程 - CAD

我拿一般平面的圖檔來比喻,常見的圖檔這這幾種,具有不同的特性:

  • JPG:有損壓縮點陣圖,用於呈現給人類看,實際上有部份資訊會丟失。
  • PNG:無損壓縮點陣圖,能夠保留原始像素資訊。
  • SVG:向量圖,不像點陣圖放大後會變成馬賽克,向量圖以幾何參數的方式儲存影像。

前面介紹的 glTF 可以說是 3D 的 JPG,而 STEP 則是 3D 的 SVG。一種典型的 3D 描述方式是用三角面近似各種幾何體,就像用多邊形近似圓形這樣,但是在工業製造這種對精細度有要求的產業中,用這種近似方式滿足產業需求的話需要非常大量的三角面,檔案儲存效率非常差勁,所以會用 STEP 這樣的檔案,以儲存幾何參數來交換 3D 模型。

以下是可獲得一些 STEP 檔案樣本的地方:

可以打開 STEP 的線上工具也不少:Online3DViewer、Autodesk Viewer、Onshape。本機軟體則有像 FreeCAD 這樣的軟體可以用來打開 STEP 檔案。

不過 STEP 是一個難搞檔案格式,它有很多細部的規範:

AP 編號名稱說明
AP203Configuration controlled 3D design
AP210Electronic assembly design
AP214Automotive mechanical design
AP218Ship arrangement (造船)
AP219Electrical harness design
AP220Process plans and resources
AP232Mechanical product definition (tolerance)
AP238STEP-NC (CNC加工)
AP242Managed model-based 3D engineering

即便是像 Autodesk 或 Onshape 這種專門在這個領域深耕的公司,它們的軟體也會有一些細部特性不支援。

程序化操作 STEP 檔案的函式庫則有 OCCT (Open CASCADE Technology),不過它的兩個 Python binding (pyocctpythonocc) 都僅支援 conda 安裝,為了在符合 Python 標準的環境下運行可能需要混合使用 micromamba 和 venv 等工具將 conda 的套件和 PyPI 的套件放在同一個生產專案中使用。

額外補充,FreeCAD 本身有一個 Python 的終端機,可以用來腳本化操作 CAD 軟體,同時也可以透過一些方式把這些函式庫從 Python 直接 import 進來使用。要注意的是 1.0.0 前後組織 Python 函式庫的方式不太一樣。

info

以上資訊源自於對接製造業相關需求的原型軟體開發經驗。

三維重建 - 點雲

前面介紹過兩種 3D 模型,但是它們皆產自數位世界,如果我們想要從現實世界捕捉三維物體的資訊呢?

我們會透過 ToF 攝影機或光達 (LiDAR) 之類的工具來掃描現實世界的物體,這些資訊會以點雲 (point cloud) 的形式儲存。

CloudCompare ViewerMeshLab 是可以用來瀏覽點雲檔案的工具。

不過對於缺乏專用設備的人而言,有另外一條路徑:SFM (Structure-from-Motion),可以從二維影像回推三維資訊。

SFM 的經典工具為 colmap,你只要手上有一段環視物體或場景的影片,透過 FFmpeg 切成若干個圖片,再用 colmap 運行 SFM 流程,就能獲得點雲檔案了。

info

以上資訊源自於對接製造業相關需求以及感測器設備週邊軟體開發的經驗。

3D 列印 - G-Code

info

熔融沉積成型 (FDM) 跟光固化的生態不太一樣,以下以 FDM 的角度描述。

在 3D 列印中,會需要將一個僅有形體的幾何資訊轉換成可以被列印機的指令碼,這個過程稱為「切片」。PrusaSlicerCuraSlic3r...這些用來切片的軟體則被統稱為「切片軟體」。

G-Code 原本是被用於 CNC 這種切削工藝的指令碼,不過該概念與技術被 3D 列印機重複使用,作為執行列印的指令碼。G-Code 檔案預覽起來會變成由線條構成,並且一層一層的畫面。

info

以上資訊源自於對接製造業相關需求以及學生時期課餘接觸 3D 列印的經驗。

其他 3D 檔案與工具

如果你需要和 3D 美術合作,手邊有一個 Blender 永遠不會是壞主意,你可能需要處理諸如 FBX、OBJ 之類的檔案。

FBX 是 Autodesk 的專有檔案,標準的操作方式是使用 Autodesk 官方的 FBX SDK,不過你或許可以在 GitHub 上找到像 fbx-tree-view 這樣的野雞軟體來檢查內部的樹狀結構。

assimp 是 3D 檔案的瑞士刀工具,不過正如我先前介紹的「3D 檔案」的複雜性,使用前還是需要先讀過它的文件描述的對各種檔案格式的支援性

CSG (Constructive solid geometry) 是一種透過布林運算進行 3D 建模的技術,如果你有實時根據參數生成 3D 模型的需求,在 Three.js 的生態系有 three-bvh-csg 這樣的函式庫可以處理。

info

three-bvh-csg 中有一些演算法優化相關的問題被懸賞喔~想賺外快的朋友不妨參考看看。

小結

在大型組織中,領域專家通常是內部人員;在正常的開發情境中,領域通常也是一個相對穩定的空間,因為需求相對清晰。不過在一些特殊的條件下,我需要面對的是來自各種千奇百怪領域的需求。早在接觸 DDD 以前我就已經使用各種開源軟體作為領域指南針,直到接觸 DDD 後,我終於可以把這個概念明確的用「領域對齊」這個詞表達了。

從前面的例子中,我們可以知道哪怕只是「3D 檔案」都有非常多種似是而非、截然不同的可能,在開發之前如果不先進行領域對齊,可想其後果有多嚴重。

Footnotes

  1. How to download OSM data using QuickOSM Plugin in QGIS. Retrieved 2026-04-15, from https://www.giscourse.com/how-to-download-osm-data-using-quickosm-plugin-in-qgis/

Wei Ji
info

實際上花了一兩天摸索之後,這個坑大概是填不完了,但是姑且還是紀錄一下。

起因

最近在 104 看到這樣一個職位描述:

Digital Twins Engineer

Scope of Work

  • Integrate outsourced BIM/3D models into the ██████████ platform.
  • Develop capabilities to bind dynamic data (e.g., IoT sensor data, system states) to 3D models and visualize them.
  • Embed LLM use-cases into DT scenarios—e.g., natural-language queries for asset status, simulation results, or triggering operations.
  • Partner with frontend/UX to optimize DT UI/UX and 3D rendering.
  • Work with backend/data teams to ensure efficient DT data flows.
  • Participate in DT PoCs to explore and realize innovative applications.
  • Ensure DT solution performance, stability, and scalability.

Qualifications

  • Proficient in at least one backend or frontend language (e.g., Python, C#, JavaScript/TypeScript).
  • Knowledge of 3D graphics and at least one 3D engine/library (e.g., Unity, Unreal Engine, Omniverse, Three.js, Babylon.js).
  • Experience with data integration and APIs (RESTful, WebSocket).
  • Understanding of BIM/CAD formats (e.g., IFC, Revit API) and processing workflows.

你知道嗎?我本來就想在我的房間搞數位孿生了,看來這就是我的下一個題目了!

工欲善其事,必先利其器

正如我在前一篇文章(從 DDD 看檔案格式)中提到的,打開領域的第一步是先找檔案跟軟體。

從職缺內容來看 IFC (Industry Foundation Classes) 應該就是這次的重點關注檔案格式了,這些地方可以找到 ifc 的檔案樣本:

並且目前看起來最可靠的開源編輯器方案是 Bonsai (原名 BlenderBIM),它是一個 Blender 插件,IFC 的相關實作則是由 IfcOpenShell 提供。

接下來就是用 Bonsai 繪製我的房間了嗎?我可不這麼認為。

Scan to BIM

現在是 2026 年;「AI」依然大行其道;泡沫尚未被戳破;X 上充滿了高斯潑濺、實景掃描、三維重建的貼文,IFC 總該能透過演算法從點雲自己生出來了...吧?

事實是,就算撇開 AI 不談,Scan to BIM 也已經是在 AEC (Architecture Engineering Construction) 業界內存在已久的詞彙,用來描述「從現實掃描並建立 BIM (Building Information Modeling)」的過程,不過傳統上這個過程可能是指:把點雲匯入軟體後,由工程師拉出批配的模型,比較接近機械逆向工程的作法。

OK,所以接下來就是要研究 Scan to BIM 的方案了對吧?

我:( ՞ټ՞)

事情是這樣的,在 AEC 領域,掃描通常由 ToF 攝影機或光達 (LiDAR) 這樣的專門設備完成,而我只能透過 SFM (Structure-from-Motion) 的手段獲得點雲。SFM 使用二維圖像回推三維資訊的技術,講白化來說就是我可以用手機拍影片、切成一堆圖片、經過 SFM 處理來獲得點雲。

高斯潑濺 (3DGS, 3D Gaussian Splatting) 可以向一個點雲填充額外的機率資訊,最後呈現出一種現代 3D 渲染技術所不能企及的寫實度,就像一張 3D 的照片,是近幾年日漸熱門的一種技術。因為 SFM 是主流高斯潑濺資料處理流程的一部分,加上這是一個我之前就有興趣但是沒有花心思跑過的技術,所以我打算在執行 Scan to BIM 之前先試著跑一次 SFM 到高斯潑濺的路徑。

SFM

SFM 的經典工具是 colmap,比較遺憾是它在 GitHub 的 release 中預編譯的 release 只有 Windows 的,並且計算過程仰賴 CUDA,2024 年使用它的時候可是稍微折騰了一番。

不過「去 CUDA」是我目前的主要原則,姑且還是在沒有 CUDA 的環境硬著頭皮編譯跟跑下去了,讓我有點意外是,無 CUDA 版本依然會使用 GPU 加速:

不知道這是本來就有;但是我在 2024 遺漏掉的特性,還是這是近一兩年內新加入的能力。

整個流程大概是這樣:

mkdir -p output
ffmpeg -i video.mp4 -qscale:v 1 -qmin 1 -vf fps=2 ./images/%04d.jpg

mkdir -p ./data
colmap database_creator --database_path ./data/database.db

colmap feature_extractor \
--database_path ./data/database.db \
--image_path ./images

colmap exhaustive_matcher \
--database_path ./data/database.db

mkdir -p ./data/sparse
colmap mapper \
--database_path ./data/database.db \
--image_path ./images \
--output_path ./data/sparse

colmap bundle_adjuster \
--input_path ./data/sparse/0 \
--output_path ./data/sparse/0 \
--BundleAdjustment.refine_principal_point 1

colmap gui \
--database_path ./data/database.db \
--image_path ./images \
--import_path ./data/sparse/0
info

話說回來,我之前使用過得經驗其中幾次是針對 360 影像修改過得 fork (json87/spheresfm)就是了。

高斯潑濺

試著用了比較有名的實作 nerfstudio

ns-process-data video --data source.mp4 --output-dir processed_data

ns-train splatfacto --data processed_data

第一個指令實際上是 colmap 的封裝,所以還是要先安裝 colmap。比較遺憾的是第二個指令高度仰賴 CUDA。來回翻了一下其他方案,全部都仰賴 CUDA。

Taichi Lang 是一個 GPU API 的高級封裝,允許開發者可以用較簡單的程式使用 GPU 進行平行化運算,並且支援 Vulkan 作為運算後端1

後來我找到了 wanmeihuali/taichi_3d_gaussian_splatting,一個使用 Taichi 的高斯潑濺實作,然而其程式碼依然使用了不少 PyTorch + CUDA,感覺去 CUDA 的成本依然很高,因為我對 GPU 平行化運算跟機器學習的程式相對陌生,所以試著玩高斯潑濺的念頭至此打消了。

Scan to BIM

建了一個口袋名單之後抽從星星數比較高的抽出來看,打開 LTTM/Scan-to-BIM 就看到滿滿的:

'cuda' if torch.cuda.is_available() else 'cpu'

因為沒有 CUDA 就不讓我用 GPU 加速,那我乾脆不要用好了,下一個。

VaclavNezerka/Cloud2BIM 雖然沒看到 CUDA 的影子,不過它有其他問題,它在 README 提供的點雲檔案是 Kladno station:

然而預設組態與論文內使用的是 Hotel Opatov:

看起來是一個結構與格局相對方正的建築,讓我不禁懷疑這個算法處理像我房間這種堆放各種物品空間的可靠性,更重要的是它的 README 還寫著:

This repository contains the foundational open-source research version of the Cloud2BIM algorithm. An advanced, production-ready version of this software, Cloud2BIM-AI, is now available through Constriq (a Czech Technical University spin-off).

看來這是典型的「開源的心不甘情不願」的那種專案。

Footnotes

  1. Taichi 和 PyTorch 有哪些相似和不同? - 知乎. Retrieved 2026-04-15, from https://www.zhihu.com/question/535601383

Wei Ji

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

本文僅覆蓋「連續性測試」相關的主題。

連續性測試

下述內容與其他文章有高度關聯,缺乏上下文的情況可能很難理解。

關於通用 GPU 加速與標準化請見:

Qwen3 TTS 之旅:語音嵌入

關於資料視覺化請見:

Qwen3 TTS 之旅:資料視覺化


採集兩個人的聲音(例如:一男一女),並對其進行嵌入運算得到嵌入向量,再兩個向量進行線性內插。

將內插的數個向量分別給予 Voice Clone 模型進行運算,會得到「女 100% + 男 0 %」、「女 90% + 男 10 %」...的聲音,如果空間是連續的,理應得到一個漸近改變聲音的過程。

這是一個進行視覺化以前就應該先進行的基本測試,不論是 AI 還是教授(?)都建議我先做這個測試。之所以一拖再拖的原因是我尚未完成 TTS 相關程式基礎設施的建立,包含通用 GPU 加速與標準化。所以不是很想在實驗內加入需要運行太多次 TTS 的步驟。

不過也因為這個測試意外的讓我發現嵌入伺服器實作有 bug。

測試結果

它並沒有很明顯的漸進,因此我們可以知道特徵空間不是線性的,不過也沒有出現「非人聲音」或是 TTS 故障的現象,可以肯定空間至少是連續的。

至於女聲跟男聲在 60~70%時劇烈變化,問題原因可能在於兩個樣本處於空間的位置有關,

如果女聲的樣本距離男聲區很遠,自然大部份內插值都落於女聲區。

Wei Ji

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

並且預計是系列文章的收尾。

標準化

關於模型組件的標準化細節請見:

Qwen3 TTS 之旅:語音嵌入

簡單來說,在在我的工作流程中,一個模型的標準化代表著:

  • OCI (Open Container Initiative) 映像檔封裝,可以透過 Docker 或其他相同技術佈署。
  • 可以透過 OpenAI-Compatible API 呼叫。
  • 通用 GPU 加速,使用 Vulkan、WebGPU...等通用 GPU 界面實現硬體加速,避免被硬體供應商鎖定。
  • 使用 Hugging Face SDK,除了分離程式碼與模型檔案管理的邏輯以外,可透過設定 HF_ENDPOINT 等參數指向鏡像站獲得本地下載加速。

從而將使用者的認知負荷降到最低,不論是實驗還是產品研發,調用者不用花費太多經歷在配置模型,這才符合我對「可用模型」的標準。換句話說,使用 CUDA 加速、凌亂的 Python 程式碼皆未達該門檻。

Qwen3 TTS 的 ONNX 移植

ONNX 是目前我覺得最可靠的格式,雖然可以想像轉檔相關的工具必定存在,只是我還是稍微搜尋一下看有沒有現成實作,畢竟能不自己寫程式就不自己寫(?)

DLL、C#、量化的方案不考慮,刪去法剃除後就只剩下 xkos/Qwen3-TTS-12Hz-1.7B-ONNX 看起來比較可行,但是它是 Hugging Face 上的野雞專案,作者也沒有提供足夠多的資訊構成公信力:

模型跟程式碼沒有分離,通通放在 Hugging Face 上,專案下也沒有足夠多的討論,這對我而言不夠成足夠多的可信度。

另一方面,xkos 的實作不知道為什麼比 Qwen3 官方的操作方式來得複雜,需要「預先生成」一些檔案。

安全性檢查

綜合上述上傳者缺乏公信力,程式碼又稱不上乾淨,模型相關的封裝全部擠在一個 1.3k 行的程式內,很難一眼看出有什麼問題,於是我用 opengrep 做了一個簡單的掃描:

$ opengrep scan --config auto . -v

synthesize.py
❯❯❱ trailofbits.python.pickles-in-numpy.pickles-in-numpy
Functions reliant on pickle can result in arbitrary code execution. Consider using fickling or
switching to a safer serialization method
Details: https://sg.run/ryKe

64┆ data = np.load(args.speaker, allow_pickle=True)

tts_engine.py
❯❯❱ trailofbits.python.pickles-in-numpy.pickles-in-numpy
Functions reliant on pickle can result in arbitrary code execution. Consider using fickling or
switching to a safer serialization method
Details: https://sg.run/ryKe

559┆ data = np.load(cache_path, allow_pickle=True)

掃到的兩個 pickle 都是前面說的「預先生成的檔案」,可能問題不大。ONNX 本身似乎也有風險1,但是已經是相對安全的模型格式。

info

沒用 semgrep 的原因是因為:

$ podman run --rm -v "$PWD:/src" docker.io/semgrep/semgrep:1.157.0-nonroot semgrep ci
run `semgrep login` before using `semgrep ci` or use `semgrep scan` and set `--config`
There were errors during analysis but Semgrep will succeed because there were no blocking findings, use --no-suppress-errors if you want Semgrep to fail when there are errors.

開源軟體還要我登入?相關的故事可以在 Rddit 的討論上看到2

重構與壞味道

info

以下不是針對 xkos,而是就程式碼本身進行探討。因為他至少也是把程式跟模型上傳給別人使用了,也有標記 Qwen3 TTS 原本的開源許可證,所以以下就稱呼他為「熱心鄉民」。

為了封裝成 API 伺服器,我需要對熱心鄉民的程式碼進行重構,抽出我用得上的邏輯,因為並不是所有 Qwen3 TTS 的模型我都需要,我只需要負責 Voice Clone 的模型。

程式碼與模型未分離

即便是 Qwen3-TTS 官方也是採取程式碼模型分離的策略。

熱心鄉民則是將程式碼與模型一同上傳到 Hugging Face,並且手刻 os.path.join 來讀取模型,同時將未量化模型與量化模型放在同一個 repo 內。

Qwen3-TTS 官方至少也是採取 1.7B 和 0.6B 兩種大小的模型分開來放的措施。

透過 Hugging Face SDK 實現程式與模型解偶除了我前面提到的可以用環境變數指定鏡像來源以外,還能在程式內實作「有用到才下載」的邏輯,這種整包上傳的方式,不是只能用 Git LFS 一次下載就是要一個手動挑選檔案,不管怎樣都不是應該發生在 Hugging Face SDK 已經成為實質產業標準的現代,屬於非常不成熟的作法。

違反使用直覺的封裝

在 Qwen3-TTS 的官方實作使用模型是像這樣的:

Sample Code
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
device_map="cuda:0",
dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
)

# single inference
wavs, sr = model.generate_custom_voice(
text="其实我真的有发现,我是一个特别善于观察别人情绪的人。",
language="Chinese", # Pass `Auto` (or omit) for auto language adaptive; if the target language is known, set it explicitly.
speaker="Vivian",
instruct="用特别愤怒的语气说", # Omit if not needed.
)
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
device_map="cuda:0",
dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
)

# single inference
wavs, sr = model.generate_voice_design(
text="哥哥,你回来啦,人家等了你好久好久了,要抱抱!",
language="Chinese",
instruct="体现撒娇稚嫩的萝莉女声,音调偏高且起伏明显,营造出黏人、做作又刻意卖萌的听觉效果。",
)
model = Qwen3TTSModel.from_pretrained(
"Qwen/Qwen3-TTS-12Hz-1.7B-Base",
device_map="cuda:0",
dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
)

ref_audio = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone.wav"
ref_text = "Okay. Yeah. I resent you. I love you. I respect you. But you know what? You blew it! And thanks to you."

wavs, sr = model.generate_voice_clone(
text="I am solving the equation: x = [-b ± √(b²-4ac)] / 2a? Nobody can — it's a disaster (◍•͈⌔•͈◍), very sad!",
language="English",
ref_audio=ref_audio,
ref_text=ref_text,
)

每一種模型的使用都非常直觀。

反觀熱心鄉民的程式長這樣:

# Step 1: Generate Global Cache (one-time) 
python generate_cache.py --model_dir ./model

# Step 2: Create Speaker Profile (once per voice)
Step 2: Create Speaker Profile (once per voice)
python create_speaker.py \
--model_dir ./model \
--ref_audio reference.wav \
--ref_text "Transcript of the reference audio" \
--language english \
--output ./speakers/my_voice.npz

# Step 3: Synthesize Speech
python synthesize.py \
--model_dir ./model \
--speaker ./speakers/my_voice.npz \
--text "The weather is wonderful today." \
--output output.wav

讓我無法理解的是 generate_cache.py 的設計,如果這是一個 pure function 每次都產生一樣的東西,那為什麼不乾脆做成模型直接載入,更別提為什麼 Qwen3 TTS 的官方實作就沒有這個問題?

當然,這可能是我對 ONNX 的理解還不夠深入而產生的疑問。

上帝物件

在熱心鄉民的程式碼中,tts_engine.py 總計 1.3k 行的程式碼中包含了一個 1k 行的 Qwen3TTSONNXInference class,同時處理了以下幾種模型:

  • 16f量化/原始
    • speaker_encoder.onnx
    • speech_tokenizer_decoder.onnx
    • speech_tokenizer_encoder.onnx
    • Vocie Design/Voice Clone
      • code_predictor.onnx
      • code_predictor_kv.onnx
      • text_embedding

至少 18 種排列組合,表面上在遵守 DRY 原則:避免重複撰寫載入模型的程式,但是實際上卻因為缺乏抽象,內部充滿大量的 if 判斷式,讓整個實例的狀態與可能性變得非常臃腫且不好輕易理解狀態變化。

舉例來說 def get_codec_embedding(self, input_ids: np.ndarray) -> np.ndarray: 的 call stack 需要追朔如下這麼多層才知道到底是用哪一個模型計算的:

# self.get_codec_embedding
# self.codec_embedding
# models["codec_embedding"]
# models = self._vc_models
# self._vc_models = self._load_talker_set(self.onnx_vc_dir, "voice_clone")
# self._load_talker_set
# result[name] = ONNXInferenceSession(path, self.use_gpu)
# ("codec_embedding", "codec_embedding.onnx"),

某某Service某某Repository 這在後端的軟體開發中是十分常見的模式,就算不使用反轉注入或工廠模式那種高深的 OOP 技巧,單純的根據職責切割、輾平也不會寫出這種毫無工程美感的程式碼。

info

我知道實務上超過一萬行的 class 並不罕見,但是以現代開發的建議來說,500 行以上就算多了3

可疑且意義不明的模型拆分

最後我終於碰到了放棄前的最後一個障礙:

(…)main/onnx/voice_clone/talker_decode.onnx: 100%|████████████████████████████████████████████████████████████████████████████████████████| 1.72M/1.72M [00:05<00:00, 308kB/s]
2026-04-05 22:51:44.604204820 [W:onnxruntime:, session_state.cc:1327 VerifyEachNodeIsAssignedToAnEp] Some nodes were not assigned to the preferred execution providers which may or may not have an negative impact on performance. e.g. ORT explicitly assigns shape related ops to CPU to improve perf.
2026-04-05 22:51:44.604225200 [W:onnxruntime:, session_state.cc:1329 VerifyEachNodeIsAssignedToAnEp] Rerunning with verbose output on a non-minimal build will show node assignments.
2026-04-05 22:51:45.667070708 [E:onnxruntime:, inference_session.cc:2600 operator()] Exception during initialization: filesystem error: cannot get file size: No such file or directory [/home/flyskypie/.cache/huggingface/hub/models--xkos--Qwen3-TTS-12Hz-1.7B-ONNX/snapshots/6023a58eba391c4e2dbe7ff2dd73fbc9f039c76e/onnx/voice_clone/layers.10.input_layernorm.weight]

熱心鄉民的 repo 內充滿了這種意義不明的小檔案:

原本我想說可能是上傳了一些中間文件,可能最後根本用不到,事實證明這些檔案是有仰賴關係的。

我試著用 netron 觀察 ONNX 檔案的模型結構,但是並沒有太大幫助:

注意右邊和下方的捲軸塊,顯示這是一張十分巨大的結構圖,可見這個 ONNX 模型似乎以非常不自然的方式被呈現。

接下來合理的方案應該是放棄熱心鄉民的實作,直接自己轉 ONNX,不過想必有額外不少知識需要理解,也不知道需要花多少時間,於是我決定這個旅途先到此為止。

小結

過程中的幾個 ETL 步驟的程式碼我整理過之後上傳 GitHub 歸檔了:

https://github.com/FlySkyPie/qwen3-tts-etl

這一系列文章其實也是寫給我自己的紀錄,方便過一陣子之後回來處理這個主題的時候可以透過文字回憶一下細節。


在「序」中,我提到了整個旅途是因 Qwen3 Voice Embedding 而起,不過實際上還有其他原因。

我對於包含建造仿生人形機器人在內以及 TTS 等技術都有興趣,這個部份的情感與思緒比較複雜,改天有機會再談。今天先談「資料專案」的部份。

工作的時候因為公司內部的 AI 專案做準備,當時讀了不少資料,其中一個鐵人 30 天系列我很推薦:

吵什麼 AI 煉金術?!你家有礦嗎?(資料領域必知的 30 個詞彙) :: 2023 iThome 鐵人賽

雖然內容缺乏組織,但是我認為建立基本概念以及提供足夠多的領域關鍵字上是十分適合的入門材料,ETL 跟資料專案的概念我就是從這系列文章為起點建立起來的。

求職未果後,我便想著我似乎還沒跑過一次資料專案,手上剛好有著一個資料集以及可以用來玩它的嵌入模型,就想著當著模擬跑一遍流程試試看,反正 S3 實例、PyPi 鏡像、Hugging Face 鏡像...等等資料專案大概會用到的基礎設施我都準備好了。

很遺憾花了兩個多禮拜並沒有達到我一開始預期的進度,不過我還有其他主題需要處理,只好先寫文章把結果做個整理之後先告一段落了。

Footnotes

  1. LobotoMl/ONNX_runtime_hacks at main · alkaet/LobotoMl. Retrieved 2026-04-07, from https://github.com/alkaet/LobotoMl/tree/main/ONNX_runtime_hacks

  2. Opengrep - a truly Open Source fork of the Code Security tool Semgrep - Announced : r/devops. Retrieved 2026-04-07, from https://www.reddit.com/r/devops/comments/1i83yde/opengrep_a_truly_open_source_fork_of_the_code/

  3. max-lines - ESLint - Pluggable JavaScript Linter. Retrieved 2026-04-07, from https://eslint.org/docs/latest/rules/max-lines

Wei Ji

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

資料處理的幾個前置作業請見:

本文僅覆蓋「使用主成份分析進行資料視覺化」相關的主題。

PCA

主成份分析 (PCA, Principal component analysis) 簡單來說是一種對資料進行線性降維的技巧。

在開始寫程式之前我有稍微學習一下相關理論,學習筆記放在其他文章,細節我就不再此贅述:

Qwen3 TTS 之旅:流形學習

如果知道 PCA 原理的話大概可以直觀的發覺它不適合直接套用在 140k 筆 2048 維資料上,實際上可能需要使用增量 PCA (Incremental PCA),並搭配 joblib 實現持久化與斷點續傳之類的機制。

不過為了驗證整個流程的可行性,在正式跑以前,我先抽比較小的樣本直接跑 PCA,或是搭配其他非線性算法。

抽樣失敗

以下是我第一次嘗試,抽樣 1000 個資料跑 PCA 的結果:

我原本以為是資料原本特性的關係,所以試著跑 PCA 降到 50 維,再跑幾個非線性的降維:

不過結果都不太理想。

累積解釋變異圖

累積解釋變異圖 (Cumulative Explained Variance) 是根據特徵值大小推論不同維度主成份的「資訊量」(或理解為「影響力」):

綠色是每一個主成份的資訊量佔比,紅色曲線是把前 N 個主成份的佔比累計,上述的「取 50 維」是根據這個圖表中佔比 75% 對應的主成份數得來的。

抽樣成功

後來在做連續性測試的時候才發現嵌入伺服器實作有問題,簡單來說就是 Garbage in, garbage out 的具體例子,細節不在此贅述,請見其他文章:

Qwen3 TTS 之旅:語音嵌入

修復後重新跑一遍終於正常了,男聲和女聲的差異在三個主成份以內就能分離:

Wei Ji

前情提要

這個文章是「Qwen3 TTS 之旅」系列的一部分,關於旅程的起因與整體概覽請見:

本文僅覆蓋「資料預處理」相關的主題。

Mozilla Common Voice

Common Voice 是 由 Mozilla 基金會所发起的群眾參與專案,旨在為語音辨識軟體建立自由資料庫,並且主要宗旨為建立多樣化的語音樣本,因此裡面也包含了一個台灣語音資料集,2023 年的時候我就以防萬一下載了一份放在手邊。

關於 Mozilla Common Voice 我認為有一點值得一提,2024 年的時候我收到這樣一封電子郵件:

大意就是語音資料的貢獻者想要撤銷貢獻,因此 Mozilla 通知下載過資料集的人(也就是我)刪除對應的資料。

先不談這個機制是否對下載的人有實質約束力,至少在這個「AI 公司」在網際網路上掠奪數據與資料,致個人的資料權利於無物的時代,Mozilla Common Voice 可以說是一股清流,至少我持有的資料是這些志工自願貢獻的,在法律與道德上我皆有權利在合理的範圍內自由使用。

然而我並沒有對 2023 年的資料做適度的處理,也很難從中挑出要被撤銷的檔案,於是我直接重新下載了一份 2026 版的。

資料集與預處理

Mozilla Common Voice 2026 (cv-corpus-25.0-2026-03-09/zh_TW) 內容物大致上長這樣:

├── clips/*.mp3
├── validated_sentences.tsv
├── unvalidated_sentences.tsv
├── other.tsv
├── validated.tsv
└── invalidated.tsv

首先映入眼簾的是 .tsv 檔案,它們提供了標籤資訊,也就是某個 .mp3 是誰※貢獻的、什麼性別、什麼年齡區間、字稿...。

info

資料集本身是使用去識別化的 id 去辨識貢獻者,同時 Mozilla Common Voice 的使用者授權也明確的要求下載的人禁止對貢獻者進行再識別,所以若後續文章我需要描述「某人」時,我會使用專案內部重新生成的 id,而不是資料集本身提供的 id。

這是一個尚未經過正規化 (Normalization) 的資料,如果我要讓應用程式能夠更方便的提取特定貢獻者的資料或是其他標籤(性別...等),勢必需要使用關聯資料庫,並對資料進行正規化。

另外一個問題是該資料集包含 140k 筆語音資料,也就是 140k 個 .mp3 檔案,而這種在檔案系統上大量且碎片化的檔案在進行遷移(複製)時非常耗時,

因此我先把這些原始資料預處理成 SQLite 檔案,並用 BLOB 資料欄位儲存 .mp3