dev-signal 在翻譯層出現之前就已經在跑了。當初建它是為了追蹤 AI/ML 領域的發展,不想靠演算法推播——十七個精挑的英文來源,全部用自己的 SQLite 資料庫管理閱讀狀態,不依賴任何第三方同步。來源清單反映的是真正有料的地方:個人部落格這端,Andrej Karpathy、Chip Huyen、Sebastian Ruder、Simon Willison、Eugene Yan;公司這端,OpenAI、Google、Meta、Hugging Face、GitHub;社群訊號則靠 Hacker News 和 r/MachineLearning。不是收件匣,是刻意設計的知識基礎設施。
問題不在資訊的取得,在摩擦力。中文是我的主要語言。早上七點、第一杯咖啡還沒喝完、用第二語言讀論述型文章——那是一種特定的認知負擔。Karpathy 寫文章是在推論;Huyen 的文章是在用證據建構主張。那種文章需要的注意力,跟讀條列式資訊不一樣。結果是:值得細讀的文章我在略讀,技術密度高的我直接跳過。聚合器在正常運作,我在浪費它。
DeepL 是顯而易見的下一層。手動用了好幾年——對論述型散文、尤其是意圖和措辭同樣重要的那種句子,它比 Google 翻譯好一截。論述型文章翻錯了不是換個說法,是換了個主張。另一個選項是 LLM 翻譯:彈性更高,每篇文章成本高出一個數量級,對這類結構化散文的品質提升卻不決定性。DeepL 免費方案每月五十萬字元——以我的來源數量和文章頻率來說,緊但可行。緊到在呼叫 API 之前的每一個決定,都有 quota 代價。
先偵測語言,再花錢
第一個問題是偵測,不是翻譯。十七個來源裡有幾個偶爾會同時發中英文版;把中文文章送進 DeepL,浪費 quota,輸出也會壞掉,快速掃視時不一定看得出來。
評估過語言偵測套件之後,決定不用。來源清一色英文、偶爾例外的情況下,Unicode 字元比例的啟發式方法就夠了,而且完全沒有執行期成本:
export function detectLanguage(text: string): string {
if (!text || text.length < 10) return 'en';
const chineseChars = text.match(/[一-鿿㐀-䶿]/g);
if (chineseChars && chineseChars.length / text.length > 0.3) return 'cmn';
const japaneseChars = text.match(/[-ゟ゠-ヿ]/g);
if (japaneseChars && japaneseChars.length / text.length > 0.2) return 'jpn';
const koreanChars = text.match(/[가-ᄀ-ᇿ]/g);
if (koreanChars && koreanChars.length / text.length > 0.2) return 'kor';
return 'en';
}
超過三成的字元落在 CJK 範圍就判定為中文。低於十個字元的直接預設英文——信號不足以做有意義的判斷,而且誤判一行短文的 quota 代價,在五十萬字元的月度上限裡比例很顯眼。不需要外部相依,不用下載模型,沒有精確度和召回率的 trade-off 要調。對這份來源清單,這個方法從來沒有誤判到需要追查的程度。
在入庫時翻,不在讀取時翻
翻譯在文章寫入資料庫時執行,不是在讀取介面載入文章時才動。把 API 延遲疊進每一次頁面載入,閱讀體驗會變黏——這個問題本來不存在,不需要自己製造。等到打開文章,翻譯好的標題和摘要已經在同一筆 SQLite 記錄裡了。
翻譯呼叫透過 deepl-node SDK 處理。RSS 抓取器在入庫時呼叫 translateArticle(articleId, ['title', 'summary'])——只翻標題和摘要,不翻全文。全文翻譯會加速 quota 消耗,但邊際價值低:摘要通常已經足夠判斷要不要去讀英文原文。Schema 把 title_zh、summary_zh、content_zh 和原文欄位存在同一筆記錄裡,每次讀取是單一查詢,不需要 join。另開一張翻譯表唯一換來的是每次讀取多一個 join——在一對一、目標語言永遠不變的關係裡,這個代價換不到任何東西。
FTS5 全文搜尋索引同時覆蓋四個文字欄位——title、content、title_zh、content_zh——搜尋功能從第一天就能跨語言運作,不需要額外的索引建置步驟。
剛好夠用的去重機制
RSS 抓取器在 insert 之前先呼叫 articleExists(url)——對 url 欄位做一次 SELECT。文章已存在就跳過。articles 資料表有 url TEXT NOT NULL UNIQUE 作為最後防線,萬一預查詢沒攔到重複,INSERT 在 constraint 這關就被擋下,不會產生重複記錄。
不需要 hash cache,不需要另外建去重表,也沒有競態條件的問題——排程器是單一 process,透過 node-cron 每小時執行一次,SQLite 的序列化寫入讓並發插入衝突根本不存在。原本預期 pipeline 跑穩了會需要更複雜的機制。六個月後,預查詢加上 UNIQUE constraint 的組合從來沒出過問題。
詞彙表問題還沒解
領域術語是這套系統還沒解決、而且我還沒有工程解法的地方。「可觀察性」是 observability 的正確翻譯,但中文 ML 工程師講這個詞就是直接說 observability。「金絲雀部署」結構上翻對了,讀起來是翻出來的,不是說出來的。「背壓」、「分散式追蹤」、「最終一致性」——每一個都有從業者之間約定俗成的說法,和字面直譯不一樣,用錯了,讀者接收到的概念就偏了。
DeepL 有 Glossary API——上傳術語對,翻譯時自動套用——方向是對的。API 本身的整合不複雜。難的是維護 pipeline。每篇新文章都帶進我沒見過的術語。手動建的詞彙表起步完整,然後隨著新術語出現慢慢過時。從文章自動擷取術語,又需要另一套驗證機制來判斷哪些詞真的需要特殊處理、哪些字面直譯就夠好。目前的做法是維護一份短名單,在已知問題術語的中文後面用括號保留英文原文。無法擴展。
對這份清單上的大多數文章——關於 AI 系統、模型行為、工具決策、研究回顧——DeepL 的品質乾淨到我可以直接讀中文版,不需要對照原文。技術密度高的文章——Karpathy 的長文、Ruder 的 NLP 深度分析——翻譯偶爾有重心的問題:句子結構沒問題,但讀起來有翻譯腔,是被翻出來的感覺,不是說出來的。語意正確,質地不對。
技術正確和讀起來像中文之間那個距離,目前還沒有工程解法。Glossary API 能部分填補,但「要在詞彙表裡放什麼、怎麼讓清單自動保持最新」這個問題,還開著。